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/.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/_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/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: 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: 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: 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) 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) diff --git a/Scripts/ios-test-report.js b/Scripts/ios-test-report.js index 83da5f5f..d4f05025 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}개의 결과만 반영됐습니다. ` + + "실행되지 않은 샤드의 모듈은 아래 목록에서 빠져 있어 전체 수치가 아닙니다.", + "", + ] + : []), "
모듈별 커버리지", "", "| 모듈 | 커버리지 | | 라인 |", @@ -496,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(" · "); @@ -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/"); @@ -560,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", @@ -589,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개/); +}); 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, 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: + - "이번에도 조금씩 나아졌어요!" + - "자잘한 개선이 들어갔어요."