From 075076d0b673378f36469fecdf7ead5f8335dd38 Mon Sep 17 00:00:00 2001 From: Roy Date: Mon, 7 Sep 2026 22:09:16 +0900 Subject: [PATCH 1/8] =?UTF-8?q?fix:=20E2E=20=ED=95=98=EC=9C=84=20=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=EC=9A=B0=EA=B0=80=20=EB=8B=A8=EB=8F=85=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=EB=8F=BC=EB=8F=84=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EB=A5=BC=20=EC=8A=A4=EC=8A=A4=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=B4=EC=9E=A5=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maestro Studio 는 워크스페이스 config 의 flows 패턴과 무관하게 모든 플로우 파일을 목록에 띄운다. 그래서 로그인 상태를 전제하던 하위 플로우들(clearState: false)이 단독 실행되면서 member.home.root / management.attendance.list 를 기다리다 30초 타임아웃으로 죽었다. _shared/ensure_member_home.yaml 과 ensure_management_home.yaml 을 추가해 홈이 아닐 때만 로그인하도록 하고, 피처 플로우 13개가 이를 먼저 부르게 했다. 이미 로그인돼 있으면 건너뛰므로 full_test 에서는 추가 비용이 거의 없다. 워크스페이스 실행 대상도 엔트리포인트 두 개로 좁혔다. --- .maestro/config.yaml | 13 ++++++-- .../flows/_shared/ensure_management_home.yaml | 31 +++++++++++++++++++ .../flows/_shared/ensure_member_home.yaml | 31 +++++++++++++++++++ .../flows/management/attendance_refresh.yaml | 3 ++ .maestro/flows/management/home_smoke.yaml | 4 +++ .maestro/flows/management/qr_scanner.yaml | 3 ++ .../flows/management/schedule_change.yaml | 3 ++ .maestro/flows/management/schedule_tab.yaml | 3 ++ .maestro/flows/management/team_switch.yaml | 3 ++ .maestro/flows/management/vote_tab.yaml | 3 ++ .maestro/flows/member/home_smoke.yaml | 6 ++-- .maestro/flows/member/qr_code.yaml | 3 ++ .maestro/flows/member/schedule_list.yaml | 3 ++ .maestro/flows/member/vote.yaml | 3 ++ .maestro/flows/member/vote_feedback.yaml | 3 ++ .maestro/flows/member/vote_team_select.yaml | 3 ++ 16 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 .maestro/flows/_shared/ensure_management_home.yaml create mode 100644 .maestro/flows/_shared/ensure_member_home.yaml diff --git a/.maestro/config.yaml b/.maestro/config.yaml index 6fe5ac51..ff7ca246 100644 --- a/.maestro/config.yaml +++ b/.maestro/config.yaml @@ -1,5 +1,14 @@ # Maestro 워크스페이스 설정. 이 디렉토리(.maestro)를 워크스페이스로 연다. -# ** 는 중간 디렉토리가 있어야 매칭되므로 flows 바로 아래 파일용 패턴을 함께 둔다. +# +# 워크스페이스 실행 대상은 엔트리포인트 두 개뿐이다. +# flows/full_test.yaml — 스플래시·로그인부터 전체 순회 +# flows/smoke_test.yaml — 짧은 확인용 +# +# 하위 디렉토리 플로우(member/home_smoke.yaml, member/vote_team_select.yaml, +# login/google_management.yaml, _shared/switch_section.yaml 등)는 단독으로 돌 수 없다. +# 로그인된 상태를 전제하거나(clearState: false) env 로 ID 를 받는 헬퍼이기 때문이다. +# flows/**/*.yaml 로 싸잡으면 이것들이 워크스페이스 플로우 목록에 떠서 +# 로그인 없이 실행되고 member.home.root 를 기다리다 타임아웃으로 죽는다. +# 그래서 글롭을 flows 바로 아래로 제한하고, 나머지는 runFlow: file: 로만 부른다. flows: - "flows/*.yaml" - - "flows/**/*.yaml" diff --git a/.maestro/flows/_shared/ensure_management_home.yaml b/.maestro/flows/_shared/ensure_management_home.yaml new file mode 100644 index 00000000..d41301cc --- /dev/null +++ b/.maestro/flows/_shared/ensure_management_home.yaml @@ -0,0 +1,31 @@ +# 하위 플로우의 로그인 선행조건. +# Maestro Studio 는 워크스페이스 config 의 flows 패턴과 무관하게 모든 플로우 파일을 +# 목록에 띄우므로, 각 플로우가 단독 실행돼도 스스로 로그인 상태를 만들 수 있어야 한다. +# +# 이미 홈이면 로그인을 건너뛰므로 full_test 처럼 앞서 로그인한 경우엔 비용이 거의 없다. +appId: io.DDD.Attendance +name: 운영진 홈 보장 +tags: + - e2e + - shared +--- +- launchApp: + clearState: false + +# 스플래시가 끝나 홈이든 로그인 화면이든 확정될 때까지 기다린다. +# id 는 정규식으로 매칭되므로 둘 중 먼저 뜨는 쪽에서 풀린다. +- extendedWaitUntil: + visible: + id: "management.staff.root|auth.login.root" + timeout: 60000 + +- runFlow: + when: + notVisible: + id: "management.staff.root" + file: ../management/login_to_home.yaml + +- extendedWaitUntil: + visible: + id: "management.staff.root" + timeout: 60000 diff --git a/.maestro/flows/_shared/ensure_member_home.yaml b/.maestro/flows/_shared/ensure_member_home.yaml new file mode 100644 index 00000000..8da8ef5e --- /dev/null +++ b/.maestro/flows/_shared/ensure_member_home.yaml @@ -0,0 +1,31 @@ +# 하위 플로우의 로그인 선행조건. +# Maestro Studio 는 워크스페이스 config 의 flows 패턴과 무관하게 모든 플로우 파일을 +# 목록에 띄우므로, 각 플로우가 단독 실행돼도 스스로 로그인 상태를 만들 수 있어야 한다. +# +# 이미 홈이면 로그인을 건너뛰므로 full_test 처럼 앞서 로그인한 경우엔 비용이 거의 없다. +appId: io.DDD.Attendance +name: 멤버 홈 보장 +tags: + - e2e + - shared +--- +- launchApp: + clearState: false + +# 스플래시가 끝나 홈이든 로그인 화면이든 확정될 때까지 기다린다. +# id 는 정규식으로 매칭되므로 둘 중 먼저 뜨는 쪽에서 풀린다. +- extendedWaitUntil: + visible: + id: "member.home.root|auth.login.root" + timeout: 60000 + +- runFlow: + when: + notVisible: + id: "member.home.root" + file: ../member/login_to_home.yaml + +- extendedWaitUntil: + visible: + id: "member.home.root" + timeout: 60000 diff --git a/.maestro/flows/management/attendance_refresh.yaml b/.maestro/flows/management/attendance_refresh.yaml index 4977f175..e94ee2e3 100644 --- a/.maestro/flows/management/attendance_refresh.yaml +++ b/.maestro/flows/management/attendance_refresh.yaml @@ -8,6 +8,9 @@ tags: - management - attendance --- +# 단독 실행돼도 돌 수 있게 로그인 상태를 먼저 보장한다. +- runFlow: + file: ../_shared/ensure_management_home.yaml - extendedWaitUntil: visible: diff --git a/.maestro/flows/management/home_smoke.yaml b/.maestro/flows/management/home_smoke.yaml index 32a3a073..f9909d53 100644 --- a/.maestro/flows/management/home_smoke.yaml +++ b/.maestro/flows/management/home_smoke.yaml @@ -4,6 +4,10 @@ tags: - e2e - management --- +# 단독 실행돼도 돌 수 있게 로그인 상태를 먼저 보장한다. +- runFlow: + file: ../_shared/ensure_management_home.yaml + - extendedWaitUntil: visible: id: "management.staff.root" diff --git a/.maestro/flows/management/qr_scanner.yaml b/.maestro/flows/management/qr_scanner.yaml index 4979423c..d6c5c4e6 100644 --- a/.maestro/flows/management/qr_scanner.yaml +++ b/.maestro/flows/management/qr_scanner.yaml @@ -6,6 +6,9 @@ tags: - management - qr --- +# 단독 실행돼도 돌 수 있게 로그인 상태를 먼저 보장한다. +- runFlow: + file: ../_shared/ensure_management_home.yaml - extendedWaitUntil: visible: diff --git a/.maestro/flows/management/schedule_change.yaml b/.maestro/flows/management/schedule_change.yaml index acec9496..f8bd7c60 100644 --- a/.maestro/flows/management/schedule_change.yaml +++ b/.maestro/flows/management/schedule_change.yaml @@ -6,6 +6,9 @@ tags: - management - schedule --- +# 단독 실행돼도 돌 수 있게 로그인 상태를 먼저 보장한다. +- runFlow: + file: ../_shared/ensure_management_home.yaml - extendedWaitUntil: visible: diff --git a/.maestro/flows/management/schedule_tab.yaml b/.maestro/flows/management/schedule_tab.yaml index 8897f944..d5a65577 100644 --- a/.maestro/flows/management/schedule_tab.yaml +++ b/.maestro/flows/management/schedule_tab.yaml @@ -6,6 +6,9 @@ tags: - management - schedule --- +# 단독 실행돼도 돌 수 있게 로그인 상태를 먼저 보장한다. +- runFlow: + file: ../_shared/ensure_management_home.yaml - extendedWaitUntil: visible: diff --git a/.maestro/flows/management/team_switch.yaml b/.maestro/flows/management/team_switch.yaml index 103ecd59..9fbef2c3 100644 --- a/.maestro/flows/management/team_switch.yaml +++ b/.maestro/flows/management/team_switch.yaml @@ -6,6 +6,9 @@ tags: - management - attendance --- +# 단독 실행돼도 돌 수 있게 로그인 상태를 먼저 보장한다. +- runFlow: + file: ../_shared/ensure_management_home.yaml - extendedWaitUntil: visible: diff --git a/.maestro/flows/management/vote_tab.yaml b/.maestro/flows/management/vote_tab.yaml index ac10fde7..0319a1ea 100644 --- a/.maestro/flows/management/vote_tab.yaml +++ b/.maestro/flows/management/vote_tab.yaml @@ -6,6 +6,9 @@ tags: - management - vote --- +# 단독 실행돼도 돌 수 있게 로그인 상태를 먼저 보장한다. +- runFlow: + file: ../_shared/ensure_management_home.yaml - extendedWaitUntil: visible: diff --git a/.maestro/flows/member/home_smoke.yaml b/.maestro/flows/member/home_smoke.yaml index 821799c5..c5c3e542 100644 --- a/.maestro/flows/member/home_smoke.yaml +++ b/.maestro/flows/member/home_smoke.yaml @@ -4,8 +4,10 @@ tags: - e2e - member --- -- launchApp: - clearState: false +# 단독 실행돼도 돌 수 있게 로그인 상태를 먼저 보장한다. +- runFlow: + file: ../_shared/ensure_member_home.yaml + - extendedWaitUntil: visible: id: "member.home.root" diff --git a/.maestro/flows/member/qr_code.yaml b/.maestro/flows/member/qr_code.yaml index a89db9f2..4a43d17f 100644 --- a/.maestro/flows/member/qr_code.yaml +++ b/.maestro/flows/member/qr_code.yaml @@ -6,6 +6,9 @@ tags: - member - qr --- +# 단독 실행돼도 돌 수 있게 로그인 상태를 먼저 보장한다. +- runFlow: + file: ../_shared/ensure_member_home.yaml - extendedWaitUntil: visible: diff --git a/.maestro/flows/member/schedule_list.yaml b/.maestro/flows/member/schedule_list.yaml index bd584099..855c0db9 100644 --- a/.maestro/flows/member/schedule_list.yaml +++ b/.maestro/flows/member/schedule_list.yaml @@ -6,6 +6,9 @@ tags: - member - schedule --- +# 단독 실행돼도 돌 수 있게 로그인 상태를 먼저 보장한다. +- runFlow: + file: ../_shared/ensure_member_home.yaml - extendedWaitUntil: visible: diff --git a/.maestro/flows/member/vote.yaml b/.maestro/flows/member/vote.yaml index 49cf726a..7f3836ec 100644 --- a/.maestro/flows/member/vote.yaml +++ b/.maestro/flows/member/vote.yaml @@ -9,6 +9,9 @@ tags: - member - vote --- +# 단독 실행돼도 돌 수 있게 로그인 상태를 먼저 보장한다. +- runFlow: + file: ../_shared/ensure_member_home.yaml - extendedWaitUntil: visible: diff --git a/.maestro/flows/member/vote_feedback.yaml b/.maestro/flows/member/vote_feedback.yaml index 4eeeffb7..dd763c12 100644 --- a/.maestro/flows/member/vote_feedback.yaml +++ b/.maestro/flows/member/vote_feedback.yaml @@ -8,6 +8,9 @@ tags: - member - vote --- +# 단독 실행돼도 돌 수 있게 로그인 상태를 먼저 보장한다. +- runFlow: + file: ../_shared/ensure_member_home.yaml - extendedWaitUntil: visible: diff --git a/.maestro/flows/member/vote_team_select.yaml b/.maestro/flows/member/vote_team_select.yaml index 7604343a..4de0a61d 100644 --- a/.maestro/flows/member/vote_team_select.yaml +++ b/.maestro/flows/member/vote_team_select.yaml @@ -8,6 +8,9 @@ tags: - member - vote --- +# 단독 실행돼도 돌 수 있게 로그인 상태를 먼저 보장한다. +- runFlow: + file: ../_shared/ensure_member_home.yaml - extendedWaitUntil: visible: From e2f0703bc12f86a33d82d0eb0c6f3c3533634517 Mon Sep 17 00:00:00 2001 From: Roy Date: Mon, 7 Sep 2026 22:09:35 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20Google=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20=ED=94=8C=EB=A1=9C=EC=9A=B0=EA=B0=80=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=ED=99=94=EB=A9=B4=20=EB=A1=9C=EB=94=A9?= =?UTF-8?q?=EC=9D=84=20=EA=B8=B0=EB=8B=A4=EB=A6=AC=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실패 녹화 30.7초 지점에 accounts.google.com 이 아직 "잠시만 기다려 주세요..." 스피너를 띄우고 있었고, 32초에 30초 타임아웃으로 웹뷰가 닫혔다. 계정 세션 문제가 아니라 콜드 로딩이 제한 시간을 넘긴 것이었다. 로딩 문구가 사라질 때까지 먼저 기다리고 대기 시간을 60초로 늘렸다. 계정 선택 화면 제목은 Google 이 여러 요소로 쪼개 렌더할 수 있어 매칭이 불안정하므로, 제목 대신 실제로 탭할 계정 행이 뜨는 것을 기다린다. 어느 화면에서 막혔는지 남기도록 스크린샷도 추가했다. --- .maestro/flows/login/google.yaml | 12 ++++++++++++ .maestro/flows/login/google_management.yaml | 5 +++-- .maestro/flows/login/google_member.yaml | 5 +++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.maestro/flows/login/google.yaml b/.maestro/flows/login/google.yaml index c71bd1b1..5c6a8ad5 100644 --- a/.maestro/flows/login/google.yaml +++ b/.maestro/flows/login/google.yaml @@ -10,3 +10,15 @@ name: Google 로그인 시작 - tapOn: text: "Continue|계속" optional: true + +# 여기부터는 앱이 아니라 SafariViewService 위다. +# accounts.google.com 콜드 로딩이 30초를 넘기는 일이 있다. +# (실패 녹화 30.7초 지점에 "잠시만 기다려 주세요..." 스피너가 아직 떠 있었고 +# 32초에 타임아웃으로 웹뷰가 닫혔다.) +# 그래서 로딩이 끝나는 것을 먼저 기다린 뒤 계정 화면을 확인한다. +- extendedWaitUntil: + notVisible: ".*잠시만 기다려 주세요.*|.*Just a moment.*" + timeout: 90000 +# 계정 선택 화면의 제목("계정을 선택하세요.")은 Google 이 여러 요소로 쪼개 렌더할 수 있어 +# 통째로 매칭되지 않는다. 그래서 제목이 아니라 실제로 탭할 계정 행을 호출 측에서 기다린다. +- takeScreenshot: .maestro/artifacts/login/google-auth-page diff --git a/.maestro/flows/login/google_management.yaml b/.maestro/flows/login/google_management.yaml index 67cf1e85..69b87f3c 100644 --- a/.maestro/flows/login/google_management.yaml +++ b/.maestro/flows/login/google_management.yaml @@ -5,9 +5,10 @@ name: Google 운영진 계정 로그인 file: google.yaml # suhwj81이 포함된 계정은 운영진 계정이다. +# 제목 대신 탭 대상인 계정 행이 뜨는 것을 기다린다. - extendedWaitUntil: - visible: ".*계정(을)? 선택(하세요)?[.]*|.*Choose an account.*" - timeout: 30000 + visible: ".*suhwj81.*" + timeout: 60000 - tapOn: text: ".*suhwj81.*" - tapOn: diff --git a/.maestro/flows/login/google_member.yaml b/.maestro/flows/login/google_member.yaml index 76fcb0d1..80aa5814 100644 --- a/.maestro/flows/login/google_member.yaml +++ b/.maestro/flows/login/google_member.yaml @@ -5,9 +5,10 @@ name: Google 멤버 계정 로그인 file: google.yaml # 멤버 계정은 suhwj81 운영진 계정 바로 아래에 표시된다. +# 제목 대신 탭 대상인 계정 행이 뜨는 것을 기다린다. - extendedWaitUntil: - visible: ".*계정(을)? 선택(하세요)?[.]*|.*Choose an account.*" - timeout: 30000 + visible: ".*suhwj81.*" + timeout: 60000 - tapOn: text: ".*@.*" below: From 1c87b886c391fcd253902d1165ebab7e62206b02 Mon Sep 17 00:00:00 2001 From: Roy Date: Mon, 7 Sep 2026 22:09:36 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20TestFlight=20=EB=A6=B4=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=20=EB=85=B8=ED=8A=B8=EB=A5=BC=20=EC=BB=A4=EB=B0=8B=20?= =?UTF-8?q?=EC=84=B1=EA=B2=A9=EC=97=90=20=EB=A7=9E=EC=B6=B0=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=EC=9C=BC=EB=A1=9C=20=EB=8F=8C=EB=A0=A4=20=EC=93=B0?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지금까지는 커밋 제목을 그대로 changelog 로 넘겨서 "fix: 배포 오류 수정" 같은 내부 작업 로그가 테스터에게 나갔고, 9월 7일 하루에만 같은 문구가 8번 반복됐다. release_notes.yml 에 카테고리별 문구를 두고 커밋 제목의 키워드로 성격을 판별한다. 같은 성격이라도 빌드번호(YYMMDDHHMM)를 문구 개수로 나눈 나머지로 골라 별도 상태 없이 매번 다른 문장이 나간다. 커밋 제목은 conventional commit 접두사와 끝의 PR 번호를 벗겨 부제로만 붙인다. --- fastlane/Fastfile | 70 ++++++++++++++++++++++++++++++++++++-- fastlane/release_notes.yml | 48 ++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 fastlane/release_notes.yml diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 8086a616..82b020b0 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -1,4 +1,6 @@ # Fastlane 기본 설정 +require "yaml" + default_platform(:ios) ENV["FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT"] = "600" @@ -192,6 +194,69 @@ def build_ipa(scheme) ipa_path end +# TestFlight "테스트할 사항" 문구. +# 커밋 제목을 그대로 넘기면 "fix: 배포 오류 수정" 같은 내부 작업 로그가 +# 테스터에게 나가고 같은 문구가 계속 반복된다(9/7 하루에만 8회). +# 그래서 안내 문구는 풀에서 돌려 쓰고, 커밋 제목은 접두사와 PR 번호를 벗겨 부제로만 붙인다. +RELEASE_NOTES_FILE = File.expand_path("release_notes.yml", __dir__) + +# 커밋 제목에서 작업 성격을 판별한다. 위에서부터 먼저 걸리는 카테고리를 쓰므로 +# release_notes.yml 의 순서가 곧 우선순위다. +def matching_category(categories, subject) + haystack = subject.to_s.downcase + + matched = categories.find do |category| + Array(category["keywords"]).any? { |keyword| haystack.include?(keyword.to_s.downcase) } + end + + matched || categories.find { |category| category["id"] == "default" } || categories.last +end + +# 빌드번호는 YYMMDDHHMM 이라 항상 증가한다. 개수로 나눈 나머지를 쓰면 +# 별도 상태 없이 배포마다 같은 카테고리 안에서도 다른 문장이 배정된다. +def rotating_test_note(build_number, subject) + categories = YAML.load_file(RELEASE_NOTES_FILE)["categories"] + return nil if categories.nil? || categories.empty? + + notes = Array(matching_category(categories, subject)&.fetch("notes", nil)) + return nil if notes.empty? + + notes[build_number.to_i % notes.size] +rescue StandardError => e + UI.important("release_notes.yml 을 읽지 못해 안내 문구를 생략한다: #{e.message}") + nil +end + +# "fix: 배포 오류 수정 (#96)" -> "배포 오류 수정" +def humanized_commit_subject + subject = ENV.fetch("RELEASE_NOTES", "").strip + return nil if subject.empty? + + cleaned = subject + .sub(/\A(feat|fix|chore|refactor|docs|test|ci|build|perf|style)(\([^)]*\))?!?:\s*/i, "") + .sub(/\s*\(#\d+\)\z/, "") + .strip + + cleaned.empty? ? nil : cleaned +end + +def testflight_changelog(build_number) + lines = [] + subject = humanized_commit_subject + + # 카테고리 판별은 접두사를 벗기기 전 원문으로 해야 "fix:" 같은 힌트를 살릴 수 있다. + note = rotating_test_note(build_number, ENV.fetch("RELEASE_NOTES", "")) + lines << note if note + + if subject + lines << "" unless lines.empty? + lines << "이번 변경: #{subject}" + end + + lines << "빌드 #{build_number}" + lines.join("\n") +end + platform :ios do before_all do unlock_match_keychain @@ -208,7 +273,8 @@ platform :ios do in_house: false ) - apply_build_number(next_build_number) + build_number = next_build_number + apply_build_number(build_number) match( type: "appstore", @@ -225,7 +291,7 @@ platform :ios do begin upload_to_testflight( ipa: ipa_path, - changelog: ENV.fetch("RELEASE_NOTES", "변경사항"), + changelog: testflight_changelog(build_number), groups: ["DDD", "ddd"], beta_app_description: "DDD 출석 앱입니다.", notify_external_testers: true, diff --git a/fastlane/release_notes.yml b/fastlane/release_notes.yml new file mode 100644 index 00000000..b0315790 --- /dev/null +++ b/fastlane/release_notes.yml @@ -0,0 +1,48 @@ +# TestFlight "테스트할 사항" 문구. +# 배포마다 직접 쓰지 않아도 되도록 커밋 제목에서 작업 성격을 판별해 문구를 고른다. +# 같은 성격이라도 매번 같은 문장이 나오지 않게 카테고리마다 여러 개를 두고 +# 빌드번호(YYMMDDHHMM)로 순환한다. +# +# keywords 는 커밋 제목을 소문자로 바꿔 부분 일치로 본다. 위에서부터 먼저 걸리는 것을 쓴다. +# 순서가 곧 우선순위다. design 을 fix 위에 둔 이유는 +# "디자인 수정" 같은 제목이 fix 로 빠지지 않게 하기 위해서다. +categories: + - id: design + keywords: [design, 디자인, ui, 스타일, 컴포넌트] + notes: + - "디자인 시스템 변경했어요!" + - "화면 디자인을 다듬었어요." + - "UI를 조금 손봤어요!" + + - id: feature + keywords: [feat, 기능, 추가, 신규] + notes: + - "새 기능이 들어갔어요!" + - "기능을 하나 추가했어요." + - "새로 만든 화면이 있어요!" + + - id: performance + keywords: [perf, 성능, 최적화, 속도] + notes: + - "속도를 개선했어요!" + - "더 빠르게 돌도록 손봤어요." + + - id: fix + keywords: [fix, 수정, 오류, 버그, 해결, 안정화] + notes: + - "안정화 했어요!" + - "동작이 이상하던 부분을 고쳤어요." + - "버그를 잡았어요!" + + - id: refactor + keywords: [refactor, chore, 정리, 리팩터, 리팩토링, 의존성] + notes: + - "내부 구조를 정리했어요." + - "코드 정리 작업을 했어요!" + + # 위 어디에도 안 걸릴 때 쓴다. + - id: default + keywords: [] + notes: + - "이번에도 조금씩 나아졌어요!" + - "자잘한 개선이 들어갔어요." From ac4f93f99a697d26424c7622cb1be5a5b70a088a Mon Sep 17 00:00:00 2001 From: Roy Date: Mon, 7 Sep 2026 22:09:36 +0900 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20=EC=B6=9C=EC=84=9D=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=ED=95=98=EB=8B=A8=20=EC=97=AC=EB=B0=B1=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/AttendanceCheck/View/AttendanceCheckView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Projects/Feature/Management/Sources/AttendanceCheck/View/AttendanceCheckView.swift b/Projects/Feature/Management/Sources/AttendanceCheck/View/AttendanceCheckView.swift index 9f17ce6d..729f3371 100644 --- a/Projects/Feature/Management/Sources/AttendanceCheck/View/AttendanceCheckView.swift +++ b/Projects/Feature/Management/Sources/AttendanceCheck/View/AttendanceCheckView.swift @@ -30,7 +30,6 @@ struct AttendanceCheckView: View { selectPartType() selectPartAttendanceStatus() - .padding(.bottom, 20) } .accessibilityElement(children: .contain) .dddAccessibilityID(ManagementAccessibilityID.Attendance.root) From 734528c2a368b5e0c0387494a77e5da8f6c5abe5 Mon Sep 17 00:00:00 2001 From: Roy Date: Mon, 7 Sep 2026 23:07:41 +0900 Subject: [PATCH 5/8] =?UTF-8?q?fix=20:=20=EB=B9=8C=EB=93=9C=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Management/Sources/StaffMain/View/StaffView.swift | 5 ++++- Projects/Feature/Profile/Sources/Main/View/ProfileView.swift | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Projects/Feature/Management/Sources/StaffMain/View/StaffView.swift b/Projects/Feature/Management/Sources/StaffMain/View/StaffView.swift index a5260366..1920c589 100644 --- a/Projects/Feature/Management/Sources/StaffMain/View/StaffView.swift +++ b/Projects/Feature/Management/Sources/StaffMain/View/StaffView.swift @@ -58,7 +58,10 @@ public struct StaffView: View { closeDropDown() } } - .sheet(item: $store.scope(\.$destination, action: \.destination).qrcode) { qrCodeStore in + .sheet(item: $store.scope( + state: \.destination?.qrcode, + action: \.destination.qrcode + )) { qrCodeStore in QRScannerView(store: qrCodeStore) .presentationDetents([.height(UIScreen.screenHeight * 0.85)]) .presentationCornerRadius(20) diff --git a/Projects/Feature/Profile/Sources/Main/View/ProfileView.swift b/Projects/Feature/Profile/Sources/Main/View/ProfileView.swift index b898e1d5..97fca521 100644 --- a/Projects/Feature/Profile/Sources/Main/View/ProfileView.swift +++ b/Projects/Feature/Profile/Sources/Main/View/ProfileView.swift @@ -44,7 +44,10 @@ public struct ProfileView: View { } } - .sheet(item: $store.scope(\.$destination, action: \.destination).createApp) { crateAppStore in + .sheet(item: $store.scope( + state: \.destination?.createApp, + action: \.destination.createApp + )) { crateAppStore in CreateAppView(store: crateAppStore) .presentationDetents([.height(UIScreen.screenHeight * 0.65)]) .presentationCornerRadius(20) From 32329ab00f59200c558f1964d09b8b8970a6ac38 Mon Sep 17 00:00:00 2001 From: Roy Date: Mon, 7 Sep 2026 23:28:39 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=9C=ED=95=84=C2=B7?= =?UTF-8?q?=EC=98=A8=EB=B3=B4=EB=94=A9=20E2E=20=ED=94=8C=EB=A1=9C=EC=9A=B0?= =?UTF-8?q?=EC=97=90=EB=8F=84=20=EB=8B=A8=EB=8F=85=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=EC=84=A0=ED=96=89=EC=A1=B0=EA=B1=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앞선 커밋에서 member/management 플로우 13개에 로그인 선행조건을 붙였지만, profile/* 와 onboarding/invite_code 는 홈보다 깊은 상태(프로필 화면 진입)를 전제해서 빠져 있었다. Studio 에서 단독 실행하면 profile.main.root 를 기다리다 죽는다. _shared/ensure_profile_main.yaml 을 추가했다. 이미 프로필 화면이면 아무것도 하지 않고, 아니면 운영진 홈을 확보한 뒤 management.staff.profilebutton 으로 진입한다. profile/home_smoke 는 "홈에서 프로필로 진입" 자체가 검증 대상이라 프로필 진입까지 대신하지 않고 운영진 홈 보장만 붙였다. 이로써 하위 플로우 18개 전부 단독 실행이 가능해졌다. --- .../flows/_shared/ensure_profile_main.yaml | 27 +++++++++++++++++++ .maestro/flows/onboarding/invite_code.yaml | 3 +++ .maestro/flows/profile/create_app.yaml | 3 +++ .maestro/flows/profile/home_smoke.yaml | 5 ++-- .../flows/profile/privacy_policy_web.yaml | 3 +++ .maestro/flows/profile/swipe_back.yaml | 3 +++ 6 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 .maestro/flows/_shared/ensure_profile_main.yaml diff --git a/.maestro/flows/_shared/ensure_profile_main.yaml b/.maestro/flows/_shared/ensure_profile_main.yaml new file mode 100644 index 00000000..51f38af9 --- /dev/null +++ b/.maestro/flows/_shared/ensure_profile_main.yaml @@ -0,0 +1,27 @@ +# 프로필 화면 선행조건. +# profile/* 와 onboarding/invite_code 는 프로필 화면이 열려 있어야 도는데, +# Maestro Studio 는 모든 플로우 파일을 단독 실행할 수 있게 띄우므로 +# 각 플로우가 스스로 이 상태를 만들 수 있어야 한다. +# +# 이미 프로필 화면이면 아무것도 하지 않는다. +appId: io.DDD.Attendance +name: 프로필 화면 보장 +tags: + - e2e + - shared +--- +- runFlow: + when: + notVisible: + id: "profile.main.root" + commands: + # 운영진 홈까지 확보한 뒤 우측 상단 프로필 버튼으로 들어간다. + - runFlow: + file: ensure_management_home.yaml + - tapOn: + id: "management.staff.profilebutton" + +- extendedWaitUntil: + visible: + id: "profile.main.root" + timeout: 30000 diff --git a/.maestro/flows/onboarding/invite_code.yaml b/.maestro/flows/onboarding/invite_code.yaml index 35d4825e..a2cc97fc 100644 --- a/.maestro/flows/onboarding/invite_code.yaml +++ b/.maestro/flows/onboarding/invite_code.yaml @@ -6,6 +6,9 @@ tags: - e2e - onboarding --- +# 단독 실행돼도 돌 수 있게 선행 상태를 먼저 보장한다. +- runFlow: + file: ../_shared/ensure_profile_main.yaml - extendedWaitUntil: visible: diff --git a/.maestro/flows/profile/create_app.yaml b/.maestro/flows/profile/create_app.yaml index 59946302..3eff4323 100644 --- a/.maestro/flows/profile/create_app.yaml +++ b/.maestro/flows/profile/create_app.yaml @@ -5,6 +5,9 @@ tags: - e2e - profile --- +# 단독 실행돼도 돌 수 있게 선행 상태를 먼저 보장한다. +- runFlow: + file: ../_shared/ensure_profile_main.yaml - extendedWaitUntil: visible: diff --git a/.maestro/flows/profile/home_smoke.yaml b/.maestro/flows/profile/home_smoke.yaml index 2c5ea55a..eb3ba4da 100644 --- a/.maestro/flows/profile/home_smoke.yaml +++ b/.maestro/flows/profile/home_smoke.yaml @@ -4,8 +4,9 @@ tags: - e2e - profile --- -- launchApp: - clearState: false +# 단독 실행돼도 돌 수 있게 선행 상태를 먼저 보장한다. +- runFlow: + file: ../_shared/ensure_management_home.yaml # 운영진 홈에서 우측 상단 프로필 버튼으로 들어간다. - extendedWaitUntil: diff --git a/.maestro/flows/profile/privacy_policy_web.yaml b/.maestro/flows/profile/privacy_policy_web.yaml index d147b2b3..3e4206b2 100644 --- a/.maestro/flows/profile/privacy_policy_web.yaml +++ b/.maestro/flows/profile/privacy_policy_web.yaml @@ -6,6 +6,9 @@ tags: - profile - web --- +# 단독 실행돼도 돌 수 있게 선행 상태를 먼저 보장한다. +- runFlow: + file: ../_shared/ensure_profile_main.yaml - extendedWaitUntil: visible: diff --git a/.maestro/flows/profile/swipe_back.yaml b/.maestro/flows/profile/swipe_back.yaml index 3f6f26f7..94035d1c 100644 --- a/.maestro/flows/profile/swipe_back.yaml +++ b/.maestro/flows/profile/swipe_back.yaml @@ -6,6 +6,9 @@ tags: - profile - navigation --- +# 단독 실행돼도 돌 수 있게 선행 상태를 먼저 보장한다. +- runFlow: + file: ../_shared/ensure_profile_main.yaml - extendedWaitUntil: visible: From dee162c139c4f24f1865ca69a76dfb3a907f119c Mon Sep 17 00:00:00 2001 From: Roy Date: Mon, 7 Sep 2026 23:56:45 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20=EB=B0=B0=ED=8F=AC=EB=8A=94=20?= =?UTF-8?q?=EC=A0=95=EC=A0=81=20=EB=A7=81=ED=81=AC=EB=A5=BC=20=EC=9C=A0?= =?UTF-8?q?=EC=A7=80=ED=95=98=EB=A9=B4=EC=84=9C=20Stage=20=EC=BB=A4?= =?UTF-8?q?=EB=B2=84=EB=A6=AC=EC=A7=80=20=EA=B3=84=EC=B8=A1=EC=9D=84=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #96 시점에는 커버리지가 정상이었다 (xccov framework 85개 / 타깃 124개, 14,553 / 29,539 라인). 이후 d7160556 "fix: 배포 오류 수정" 이 App Store 업로드 거부(framework 안 중첩 Frameworks/libswift_*.dylib)를 막으려고 외부 의존성 7개를 .framework 에서 .staticFramework 로 바꿨다. 정적으로 링크되면 모듈이 소비자 바이너리에 흡수돼 독립 바이너리가 사라지고 xccov 가 개별 타깃으로 잡지 못한다. 그 결과 framework 타깃이 3개(FeatureAssembly, Management, Profile)로 줄고 리포트가 3,987 / 8,581 로 붕괴했다. 테스트는 860개 모두 정상 실행됐다. 계측 대상 바이너리가 없었을 뿐이다. develop 배포도 Stage 스킴을 쓰므로 스킴으로는 가를 수 없다. TUIST_RELEASE_BUILD 환경변수로 갈라 ios-deploy 워크플로에서만 정적으로 링크한다. PR·develop 테스트 빌드는 동적으로 돌아가 계측이 붙는다. 되돌린 대상은 그 커밋이 바꾼 7개뿐이고 원래부터 정적이던 항목은 건드리지 않았다: AppCheckCore, IdentifiedCollections, TCAFlow, Clocks, CombineSchedulers, ConcurrencyExtras, SwiftUINavigation. --- .github/workflows/ios-deploy.yml | 4 ++++ .github/workflows/ios-pr-coverage.yml | 1 + Scripts/ios-test-report.js | 34 +++++++++++++++++++++++---- Tuist/Package.swift | 27 +++++++++++++++------ 4 files changed, 54 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ios-deploy.yml b/.github/workflows/ios-deploy.yml index d8de98a2..36a2e653 100644 --- a/.github/workflows/ios-deploy.yml +++ b/.github/workflows/ios-deploy.yml @@ -28,6 +28,10 @@ jobs: APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }} APP_STORE_CONNECT_API_KEY_IS_KEY_CONTENT_BASE64: ${{ secrets.APP_STORE_CONNECT_API_KEY_IS_KEY_CONTENT_BASE64 }} TUIST_CONFIG_TOKEN: ${{ secrets.TUIST_TOKEN }} + # 배포 아카이브만 외부 의존성을 정적으로 링크한다. + # 중첩 Frameworks/libswift_*.dylib 로 인한 업로드 거부를 막기 위한 것이고, + # 이 플래그가 없는 빌드는 동적으로 링크돼 커버리지 계측이 붙는다. + TUIST_RELEASE_BUILD: '1' LANG: en_US.UTF-8 LC_ALL: en_US.UTF-8 FASTLANE_SKIP_UPDATE_CHECK: '1' diff --git a/.github/workflows/ios-pr-coverage.yml b/.github/workflows/ios-pr-coverage.yml index 50ea6865..856f29de 100644 --- a/.github/workflows/ios-pr-coverage.yml +++ b/.github/workflows/ios-pr-coverage.yml @@ -486,6 +486,7 @@ jobs: uses: actions/github-script@v7 env: RESULT_BUNDLE_DIR: Artifacts/tests + COVERAGE_SHARD_IDS: ${{ join(fromJSON(needs.build-test-shards.outputs.matrix).shard, ',') }} TEST_OUTCOME: ${{ needs.build-test-shards.result == 'success' && needs.test-shards.result == 'success' && 'success' || 'failure' }} TEST_RUN_REPORT_PATH: Artifacts/tests BUILD_RUN_REPORT_PATH: Artifacts/bundle/BuildRunReport.json diff --git a/Scripts/ios-test-report.js b/Scripts/ios-test-report.js index 83da5f5f..74ce74e3 100644 --- a/Scripts/ios-test-report.js +++ b/Scripts/ios-test-report.js @@ -129,8 +129,14 @@ function findFilesByExtension(root, extension) { }); } -function readMergedCoverage(bundles) { - if (bundles.length < 2) return bundles.flatMap(readCoverage); +// 샤드마다 xcresult 가 따로 나오고, 각 번들에는 그 샤드에서 실행된 타깃의 커버리지만 담긴다. +// 샤드 하나가 실패해 아티팩트를 못 올리면 모듈 목록이 조용히 일부만 나오므로 +// 몇 개가 실제로 병합에 기여했는지 stats 로 돌려준다. +function readMergedCoverage(bundles, stats = {}) { + if (bundles.length < 2) { + stats.contributed = bundles.length; + return bundles.flatMap(readCoverage); + } const directory = fs.mkdtempSync(path.join(os.tmpdir(), "pr-shard-coverage-")); try { @@ -155,6 +161,8 @@ function readMergedCoverage(bundles) { } } + stats.contributed = coveragePairs.length; + if (coveragePairs.length === 0) return []; if (coveragePairs.length === 1) { return JSON.parse( @@ -428,17 +436,29 @@ function bar(covered, total) { return `\`${"█".repeat(filled)}${"░".repeat(BAR_WIDTH - filled)}\``; } -function renderCoverage(coverage) { +function renderCoverage(coverage, stats = {}) { if (coverage.targets.length === 0) { return ["### 📊 커버리지", "", "> 커버리지 데이터가 없습니다.", ""]; } + // 기여한 샤드가 기대치보다 적으면 모듈 목록이 통째로 빠진다. + // 완전한 리포트와 겉모습이 같아서 구분이 안 되므로 명시한다. + const partial = + stats.expected > 0 && stats.contributed > 0 && stats.contributed < stats.expected; + const lines = [ `### 📊 커버리지 ${percent(coverage.coveredLines, coverage.executableLines)} ` + `(${comma(coverage.coveredLines)} / ${comma(coverage.executableLines)} 라인)`, "", `${grade(coverage.coveredLines, coverage.executableLines)} ${bar(coverage.coveredLines, coverage.executableLines)}`, "", + ...(partial + ? [ + `> ⚠️ 샤드 ${stats.expected}개 중 ${stats.contributed}개의 결과만 반영됐습니다. ` + + "실행되지 않은 샤드의 모듈은 아래 목록에서 빠져 있어 전체 수치가 아닙니다.", + "", + ] + : []), "
모듈별 커버리지", "", "| 모듈 | 커버리지 | | 라인 |", @@ -543,7 +563,7 @@ function renderReport({ summary, coverage, buildErrors, bundleInsights, outcome, if (summary.failures.length > 0) lines.push(...renderFailures(summary.failures)); lines.push(...renderBundleInsights(bundleInsights)); - lines.push(...renderCoverage(coverage)); + lines.push(...renderCoverage(coverage, coverageStats)); return lines.join("\n"); } @@ -551,7 +571,11 @@ function renderReport({ summary, coverage, buildErrors, bundleInsights, outcome, module.exports = async ({ github, context, core }) => { const bundles = findResultBundles(process.env.RESULT_BUNDLE_DIR); const summaries = bundles.map(readSummary).filter(Boolean); - const coverage = mergeCoverage([readMergedCoverage(bundles)]); + // 매트릭스가 만든 샤드 수. 여기에 못 미치면 모듈 목록이 일부만 나온 것이다. + const coverageStats = { + expected: (process.env.COVERAGE_SHARD_IDS || "").split(",").filter(Boolean).length, + }; + const coverage = mergeCoverage([readMergedCoverage(bundles, coverageStats)]); const buildErrors = bundles.flatMap(readBuildErrors); const testRunUrl = readDashboardURL(process.env.TEST_RUN_REPORT_PATH, "/tests/test-runs/"); const buildRunUrl = readDashboardURL(process.env.BUILD_RUN_REPORT_PATH, "/builds/build-runs/"); diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 376e178d..da14947d 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -11,6 +11,19 @@ #if TUIST @preconcurrency import ProjectDescription +import Foundation + +// TestFlight/App Store 아카이브는 framework 안에 중첩 Frameworks/libswift_*.dylib 가 생기면 +// 업로드가 거부된다. 그래서 배포 빌드에서는 이 의존성들을 정적으로 링크한다. +// +// 반대로 정적으로 링크하면 모듈이 소비자 바이너리에 흡수돼 독립 바이너리가 사라지고, +// xccov 가 개별 타깃으로 잡지 못해 커버리지에서 통째로 빠진다. +// (이 전환 뒤 PR 리포트의 framework 타깃이 85개에서 3개로 줄었다.) +// 그래서 배포가 아닌 빌드 - Stage 커버리지 측정 - 에서는 동적으로 둔다. +// PackageDescription 에도 Product 가 있어 모듈을 명시해야 모호성이 없다. +private let deploymentLinked: ProjectDescription.Product = + ProcessInfo.processInfo.environment["TUIST_RELEASE_BUILD"] == "1" ? .staticFramework : .framework + private extension Settings { /// 외부 패키지 타깃이 앱과 동일한 빌드 configuration을 사용하도록 맞춘다. static var baseSettings: Settings { @@ -49,7 +62,7 @@ let packageSettings = PackageSettings( "FirebaseAppCheckInterop": .framework, "GoogleDataTransport": .framework, "nanopb": .framework, - "AppCheckCore": .staticFramework, + "AppCheckCore": deploymentLinked, "FBLPromises": .framework, "Promises": .framework, "GoogleUtilities-AppDelegateSwizzler": .framework, @@ -68,14 +81,14 @@ let packageSettings = PackageSettings( "GTMSessionFetcherCore": .framework, "ComposableArchitecture": .framework, - "IdentifiedCollections": .staticFramework, - "TCAFlow": .staticFramework, + "IdentifiedCollections": deploymentLinked, + "TCAFlow": deploymentLinked, "IssueReporting": .framework, "IssueReportingPackageSupport": .framework, "XCTestDynamicOverlay": .framework, - "Clocks": .staticFramework, - "CombineSchedulers": .staticFramework, - "ConcurrencyExtras": .staticFramework, + "Clocks": deploymentLinked, + "CombineSchedulers": deploymentLinked, + "ConcurrencyExtras": deploymentLinked, "SDWebImageSwiftUI": .framework, "SDWebImage": .framework, @@ -98,7 +111,7 @@ let packageSettings = PackageSettings( "StructuredQueriesSQLite": .framework, "StructuredQueriesSQLiteCore": .framework, "SwiftNavigation": .staticFramework, - "SwiftUINavigation": .staticFramework, + "SwiftUINavigation": deploymentLinked, "CasePaths": .staticFramework, "Alamofire": .framework, From d5030d2277931ad750e05506c923b9cdb97c2505 Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 8 Sep 2026 11:42:27 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20?= =?UTF-8?q?=EB=A0=8C=EB=8D=94=EB=A7=81=EC=97=90=EC=84=9C=20coverageStats?= =?UTF-8?q?=20=EB=AF=B8=EC=A0=84=EB=8B=AC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20?= =?UTF-8?q?ReferenceError=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderReport 안에서 coverageStats 를 참조하는데 그 변수는 module.exports 함수의 지역 변수였다. 파라미터로 넘기도록 고쳤다. 기존 테스트는 renderReport 를 호출하지 않아 이 오류를 CI 에서야 발견했다. 렌더 경로 전체를 한 번 태우는 테스트를 추가했고, 수정 전 코드에서 실제로 실패하는 것을 확인했다. --- Scripts/ios-test-report.js | 4 +++- Scripts/ios-test-report.test.js | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/Scripts/ios-test-report.js b/Scripts/ios-test-report.js index 74ce74e3..d4f05025 100644 --- a/Scripts/ios-test-report.js +++ b/Scripts/ios-test-report.js @@ -516,7 +516,7 @@ function renderBundleInsights(bundle) { ]; } -function renderReport({ summary, coverage, buildErrors, bundleInsights, outcome, runUrl, sha, testRunUrl, buildRunUrl }) { +function renderReport({ summary, coverage, coverageStats, buildErrors, bundleInsights, outcome, runUrl, sha, testRunUrl, buildRunUrl }) { const lines = [MARKER, ""]; const footer = [`\`${sha.slice(0, 7)}\``, `[워크플로 로그](${runUrl})`].join(" · "); @@ -584,6 +584,7 @@ module.exports = async ({ github, context, core }) => { const body = renderReport({ summary: summaries.length > 0 ? mergeSummaries(summaries) : null, coverage, + coverageStats, buildErrors, bundleInsights, outcome: process.env.TEST_OUTCOME || "unknown", @@ -613,6 +614,7 @@ module.exports = async ({ github, context, core }) => { module.exports.__test__ = { findFilesByExtension, + renderReport, internalCoverageTargetNames, mergeCoverage, readMergedCoverage, diff --git a/Scripts/ios-test-report.test.js b/Scripts/ios-test-report.test.js index 5e3bc1d1..c05bdf90 100644 --- a/Scripts/ios-test-report.test.js +++ b/Scripts/ios-test-report.test.js @@ -11,6 +11,7 @@ const { readBundleInsights, readDashboardURL, renderBundleInsights, + renderReport, } = require("./ios-test-report.js").__test__; @@ -165,3 +166,37 @@ test("기준 bundle JSON이 있으면 install/download delta를 계산해 표시 "", ]); }); + +test("리포트 본문을 끝까지 렌더한다", () => { + // renderReport 를 호출하지 않아 coverageStats 미전달로 인한 ReferenceError 를 + // CI 에서야 발견했다. 렌더 경로 전체를 한 번 태워 같은 실수를 막는다. + const body = renderReport({ + summary: { + passed: 860, + failed: 0, + skipped: 0, + expectedFailures: 0, + failures: [], + device: null, + startTime: 0, + finishTime: 1, + }, + coverage: { + targets: [{ name: "Member", coveredLines: 636, executableLines: 3619 }], + coveredLines: 636, + executableLines: 3619, + }, + coverageStats: { expected: 2, contributed: 1 }, + buildErrors: [], + bundleInsights: null, + outcome: "success", + runUrl: "https://example.test/run", + sha: "0123456", + testRunUrl: null, + buildRunUrl: null, + }); + + assert.match(body, /모듈별 커버리지/); + // 기여 샤드가 기대치보다 적으면 부분 결과임을 알린다. + assert.match(body, /샤드 2개 중 1개/); +});