From dda27aa9a7bfa9149da11c83e689a8e2e480c786 Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Fri, 15 May 2026 14:38:54 +0900 Subject: [PATCH 001/100] =?UTF-8?q?chore:=20Firebase=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=EC=9D=84=20SPM=20=EB=AF=B8=EB=9F=AC=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20-=20#302?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/App/Project.swift | 1 - Projects/Domain/Auth/Project.swift | 3 +- Tuist/Package.resolved | 143 +----------------- Tuist/Package.swift | 13 +- .../Scripts/CrashlyticsScript.swift | 4 +- .../TargetDependency+External.swift | 7 +- 6 files changed, 19 insertions(+), 152 deletions(-) diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 52daecaa..ae4fa289 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -43,7 +43,6 @@ private let commonDependencies: [TargetDependency] = [ .external(dependency: .KakaoSDKAuth), .external(dependency: .KakaoSDKCommon), .external(dependency: .GoogleSignIn), - .external(dependency: .FirebaseCore), .external(dependency: .FirebaseMessaging), .external(dependency: .FirebaseRemoteConfig), .core(implements: .crashlytics) diff --git a/Projects/Domain/Auth/Project.swift b/Projects/Domain/Auth/Project.swift index 3cf298c5..33136d0e 100644 --- a/Projects/Domain/Auth/Project.swift +++ b/Projects/Domain/Auth/Project.swift @@ -23,8 +23,7 @@ let project = Project.makeModule( .external(dependency: .KakaoSDKCommon), .external(dependency: .KakaoSDKAuth), .external(dependency: .KakaoSDKUser), - .external(dependency: .GoogleSignIn), - .external(dependency: .GoogleSignInSwift) + .external(dependency: .GoogleSignIn) ] ) ), diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index 2e613ec7..2c2a68b3 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -1,15 +1,6 @@ { - "originHash" : "5c0d0ed23de9ecd5ee8f58f55c05e9268ba72f94994e48c0f6f308e2b3d7430e", + "originHash" : "8c94e2269422c116e5525a199d307e9532a7fdf925bdb423193501f8bfe1d3fe", "pins" : [ - { - "identity" : "abseil-cpp-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/abseil-cpp-binary.git", - "state" : { - "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", - "version" : "1.2024072200.0" - } - }, { "identity" : "alamofire", "kind" : "remoteSourceControl", @@ -19,24 +10,6 @@ "version" : "5.11.0" } }, - { - "identity" : "app-check", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/app-check.git", - "state" : { - "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", - "version" : "11.2.0" - } - }, - { - "identity" : "appauth-ios", - "kind" : "remoteSourceControl", - "location" : "https://github.com/openid/AppAuth-iOS.git", - "state" : { - "revision" : "145104f5ea9d58ae21b60add007c33c1cc0c948e", - "version" : "2.0.0" - } - }, { "identity" : "combine-schedulers", "kind" : "remoteSourceControl", @@ -47,95 +20,14 @@ } }, { - "identity" : "firebase-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/firebase-ios-sdk", - "state" : { - "revision" : "d10045cace0b4c335c4efa8f7df7e9a9fc5a7c60", - "version" : "12.13.0" - } - }, - { - "identity" : "google-ads-on-device-conversion-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", - "state" : { - "revision" : "19dffda9a9caf8d86570ff846535902d8509d7bf", - "version" : "3.5.0" - } - }, - { - "identity" : "googleappmeasurement", + "identity" : "firebase-ios-sdk-xcframeworks", "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleAppMeasurement.git", + "location" : "https://github.com/akaffenberger/firebase-ios-sdk-xcframeworks", "state" : { - "revision" : "c2c76bebcfbb90d90ea10599f934f9af160e1604", + "revision" : "7f17017dc1f529bab158eb950bd6012acd9e3ad1", "version" : "12.13.0" } }, - { - "identity" : "googledatatransport", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleDataTransport.git", - "state" : { - "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", - "version" : "10.1.0" - } - }, - { - "identity" : "googlesignin-ios", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleSignIn-iOS", - "state" : { - "revision" : "913b4005ea26aebe1c97d54e35ad82a515924c71", - "version" : "9.1.0" - } - }, - { - "identity" : "googleutilities", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleUtilities.git", - "state" : { - "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", - "version" : "8.1.0" - } - }, - { - "identity" : "grpc-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/grpc-binary.git", - "state" : { - "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", - "version" : "1.69.1" - } - }, - { - "identity" : "gtm-session-fetcher", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/gtm-session-fetcher.git", - "state" : { - "revision" : "a2ab612cb980066ee56d90d60d8462992c07f24b", - "version" : "3.5.0" - } - }, - { - "identity" : "gtmappauth", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GTMAppAuth.git", - "state" : { - "revision" : "56e0ccf09a6dd29dc7e68bdf729598240ca8aa16", - "version" : "5.0.0" - } - }, - { - "identity" : "interop-ios-for-google-sdks", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/interop-ios-for-google-sdks.git", - "state" : { - "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", - "version" : "101.0.0" - } - }, { "identity" : "kakao-ios-sdk", "kind" : "remoteSourceControl", @@ -154,33 +46,6 @@ "version" : "8.6.2" } }, - { - "identity" : "leveldb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/leveldb.git", - "state" : { - "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", - "version" : "1.22.5" - } - }, - { - "identity" : "nanopb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/nanopb.git", - "state" : { - "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", - "version" : "2.30910.0" - } - }, - { - "identity" : "promises", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/promises.git", - "state" : { - "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", - "version" : "2.4.0" - } - }, { "identity" : "pulse", "kind" : "remoteSourceControl", diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 750193a4..92d2ec34 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -9,13 +9,17 @@ import PackageDescription "ComposableArchitecture": .framework, "Kingfisher": .framework, "Pulse": .framework, - "KakaoSDK": .staticLibrary, - "GoogleSignIn": .staticLibrary, - "GoogleSignInSwift": .staticLibrary + "KakaoSDK": .staticLibrary ] ) #endif +// Firebase / GoogleSignIn은 akaffenberger 미러를 통해 prebuilt xcframework로 통합한다. +// 미러는 Firebase 공식 zip을 SPM `binaryTarget`으로 재포장한 것으로, Firebase가 +// 의존하는 GoogleUtilities/Promises 등을 동일 패키지가 함께 제공하기 때문에 +// 다른 SPM 경로(예: google/GoogleSignIn-iOS의 transitive deps)와의 sub-framework +// 분할 충돌을 구조적으로 피한다. 따라서 GoogleSignIn도 같은 미러의 product를 사용한다. +// 버전은 미러의 release tag(= Firebase 공식 버전)와 일치한다. let package = Package( name: "Twix", dependencies: [ @@ -23,7 +27,6 @@ let package = Package( .package(url: "https://github.com/onevcat/Kingfisher", from: "8.0.0"), .package(url: "https://github.com/kean/Pulse", from: "5.1.4"), .package(url: "https://github.com/kakao/kakao-ios-sdk", from: "2.27.1"), - .package(url: "https://github.com/google/GoogleSignIn-iOS", from: "9.1.0"), - .package(url: "https://github.com/firebase/firebase-ios-sdk", from: "12.13.0") + .package(url: "https://github.com/akaffenberger/firebase-ios-sdk-xcframeworks", from: "12.13.0") ] ) diff --git a/Tuist/ProjectDescriptionHelpers/Scripts/CrashlyticsScript.swift b/Tuist/ProjectDescriptionHelpers/Scripts/CrashlyticsScript.swift index dd91c6cf..1fac3a61 100644 --- a/Tuist/ProjectDescriptionHelpers/Scripts/CrashlyticsScript.swift +++ b/Tuist/ProjectDescriptionHelpers/Scripts/CrashlyticsScript.swift @@ -21,9 +21,9 @@ public extension TargetScript { exit 0 fi - UPLOAD_SYMBOLS="$SRCROOT/../../Tuist/.build/checkouts/firebase-ios-sdk/Crashlytics/upload-symbols" + UPLOAD_SYMBOLS="$SRCROOT/../../Tuist/.build/checkouts/firebase-ios-sdk-xcframeworks/Sources/FirebaseCrashlytics/upload-symbols" if [ ! -x "$UPLOAD_SYMBOLS" ]; then - UPLOAD_SYMBOLS=$(find "$SRCROOT/../.." -maxdepth 6 -name "upload-symbols" -path "*/firebase-ios-sdk/Crashlytics/*" 2>/dev/null | head -1) + UPLOAD_SYMBOLS=$(find "$SRCROOT/../.." -maxdepth 8 -name "upload-symbols" -path "*/FirebaseCrashlytics/*" 2>/dev/null | head -1) fi if [ -z "$UPLOAD_SYMBOLS" ] || [ ! -x "$UPLOAD_SYMBOLS" ]; then echo "warning: Firebase Crashlytics upload-symbols not found. Run 'tuist install'." diff --git a/Tuist/ProjectDescriptionHelpers/Target/Dependency/TargetDependency+External.swift b/Tuist/ProjectDescriptionHelpers/Target/Dependency/TargetDependency+External.swift index c78b39e1..d5c2f69b 100644 --- a/Tuist/ProjectDescriptionHelpers/Target/Dependency/TargetDependency+External.swift +++ b/Tuist/ProjectDescriptionHelpers/Target/Dependency/TargetDependency+External.swift @@ -16,14 +16,15 @@ public extension TargetDependency { /// 각 case는 SPM 패키지를 나타내며, /// `TargetDependency.external(dependency:)`와 유기적으로 사용됩니다. enum External: String { + // Firebase: akaffenberger 미러는 product 단위로 노출하며 FirebaseCore는 + // 별도 product가 아닌 다른 Firebase product의 transitive 의존성으로 포함된다. + // 따라서 FirebaseCore는 enum에 두지 않는다. case FirebaseAnalytics - case FirebaseCore case FirebaseMessaging case FirebaseRemoteConfig case FirebaseCrashlytics - + case GoogleSignIn - case GoogleSignInSwift case KakaoSDKCommon case KakaoSDKAuth From ce9072694bf0288b363dc6fca17e260870fc524a Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Sat, 16 May 2026 11:35:09 +0900 Subject: [PATCH 002/100] =?UTF-8?q?docs:=20=EC=97=90=EC=9D=B4=EC=A0=84?= =?UTF-8?q?=ED=8A=B8=20=EA=B8=B0=EC=A4=80=20=EB=AC=B8=EC=84=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20-=20#305?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 295 +++++++++++++++++++++ Claude.md | 141 ++-------- Prompt.md | 216 --------------- Rules.md | 185 ------------- docs/Architecture/Overview.md | 49 +++- docs/Checklists.md | 34 --- docs/Examples/NavigationStackExample.swift | 108 +++++--- docs/Guides/NetworkGuide.md | 59 ++++- docs/QuickStart.md | 35 ++- docs/Reference/Checklists.md | 103 +++++-- docs/Reference/FileOrganization.md | 30 ++- docs/Reference/NamingConventions.md | 29 +- docs/Reference/ProjectRules.md | 205 ++++++++++++++ 13 files changed, 824 insertions(+), 665 deletions(-) create mode 100644 AGENTS.md delete mode 100644 Prompt.md delete mode 100644 Rules.md delete mode 100644 docs/Checklists.md create mode 100644 docs/Reference/ProjectRules.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..9fff2b10 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,295 @@ +# AGENTS.md + +Cross-agent baseline instructions for this repository. + +This file summarizes the stable project rules that apply to any coding agent. Detailed technical guidance remains in `docs/*.md`; Claude Code-specific guidance remains in `CLAUDE.md`; reusable Pi skills live under `.pi/skills/`. + +--- + +## Project baseline + +- Platform: iOS only +- Minimum target: iOS 17 +- UI: SwiftUI +- State management: TCA 1.23 +- Architecture: Clean Architecture + Micro Feature Architecture (MFA) +- Project docs are authoritative. Do not invent architecture rules. + +Core principles: + +- Keep Interface and Implementation separated. +- Use TCA Dependency Container for dependency injection. +- Use ViewFactory/factory-based external view composition where required. +- Do not access token storage directly; token access must be mediated through TokenManager. +- Prefer minimal, deterministic, reproducible changes. +- Ask for missing files or unclear project information instead of assuming. + +--- + +## Documentation roles + +### `AGENTS.md` + +Cross-agent operational baseline: + +- project stack +- safe editing policy +- architecture guardrails +- documentation lookup order +- known unresolved items + +### `CLAUDE.md` + +Claude Code-specific guide: + +- Claude Code quick reference +- Claude-specific usage tips +- links into detailed documentation + +Do not treat Claude-specific workflow tips as universal agent rules unless also stated here or in technical docs. + +### `.pi/skills/` + +Reusable Pi skills: + +- `docs-refactor`: documentation refactoring and architecture rule cleanup +- `review-twix`: Twix iOS architecture/code review + +### `docs/*.md` + +Project technical documentation: + +- architecture details +- feature implementation rules +- TCA patterns +- network guide +- navigation guide +- naming conventions +- file organization rules +- checklists and examples + +Use these docs for implementation details rather than expanding `AGENTS.md` with long tutorials. + +--- + +## Recommended documentation lookup order + +Before architectural or feature work, read the relevant docs in this order: + +1. `AGENTS.md` +2. `CLAUDE.md` if the workflow is Claude-specific +3. `docs/Reference/ProjectRules.md` when team/project rules are requested +4. `docs/Architecture/Overview.md` +5. Task-specific docs: + - Canonical implementation checklist: `docs/Reference/Checklists.md` + - Network/client patterns: `docs/Guides/NetworkGuide.md` + - Navigation: `docs/Guides/NavigationStack.md` + - File structure: `docs/Reference/FileOrganization.md` + - Naming/style: `docs/Reference/NamingConventions.md` + - Project/team rules: `docs/Reference/ProjectRules.md` + - TCA onboarding/tutorial only: `docs/QuickStart.md` + +If a referenced doc is missing, ask before assuming its contents. + +--- + +## Architecture guardrails + +### Feature structure + +General Feature modules should follow Interface/Sources separation: + +```text +Projects/Feature/{Feature}/ +├── Interface/Sources/ +└── Sources/ +``` + +Interface layer is the public boundary. Consumers should generally depend on Interface modules, not implementation Sources. Interface typically contains public API/contracts: + +- public Reducer type +- public State and Action +- Client definitions if needed +- ViewFactory/factory definitions if needed +- `DependencyValues` extensions +- `TestDependencyKey` conformance where appropriate + +Sources layer hides implementation details. Sources typically contains implementation: + +- default Reducer initializer +- reducer logic +- internal SwiftUI View +- live dependency implementations +- linker/static-library support if required by the project pattern + +### Exception features + +The following are documented as exception features: + +- Auth +- Onboarding +- MainTab + +They may be directly composed by App or coordinator/root features and are not always forced through the same Interface/ViewFactory constraints as ordinary feature modules. + +### TCA rules + +- Use `@Reducer` for reducers. +- Use `@ObservableState` for state. +- State should conform to `Equatable`. +- Keep TCA nested `State` and `Action` with their Reducer. +- Model actions as events, not commands. +- Use `@Dependency` for dependencies. +- Wrap asynchronous side effects in TCA Effects such as `.run`. +- Reducers should not directly perform side effects outside Effects/dependencies. + +### Action naming + +Follow documented naming conventions: + +- User actions: `Tapped`, `Changed`, `Selected` +- System responses: `Response` +- Lifecycle: `onAppear`, `onDisappear`, etc. +- Delegate events: `delegate(Delegate)` when parent communication is needed + +### Navigation + +Use the project-wide `[Route]` array NavigationStack pattern documented in `docs/Guides/NavigationStack.md`. + +Do not introduce TCA's official `StackState` + `@Reducer enum Path` pattern unless the architecture docs are explicitly changed first. The project currently documents that this pattern does not fit the Interface/Implementation split. + +### Network clients + +For feature dependencies, prefer struct-based TCA Clients documented in `docs/Guides/NetworkGuide.md`. + +Protocol-based clients are allowed only when an existing Core protocol, platform abstraction, legacy integration, or explicit documentation requires them. + +### Token access + +Token access must be mediated by the current `TokenManager` pattern. + +Current codebase evidence: + +- `TokenManager`: `Projects/Domain/Auth/Interface/Sources/TokenManager.swift` (`DomainAuthInterface`, `public actor TokenManager`, `DependencyValues.tokenManager`) +- Token storage interface: `Projects/Core/Storage/Interface/Sources/TokenStorageProtocol.swift` (`CoreStorageInterface`, `TokenStorageProtocol`, `TokenStorageClient`, `DependencyValues.tokenStorage`) +- Keychain implementation: `Projects/Core/Storage/Sources/KeychainTokenStorage.swift` (`CoreStorage`) +- Current auth header pattern: `Projects/Domain/Auth/Sources/AuthInterceptor.swift` uses `TokenManager` +- Current App/root wiring: `Projects/App/Sources/View/TwixApp.swift` configures the live token storage dependency + +Do not read/write token persistence directly from Features, Reducers, Views, ordinary Clients, or request-building code. Do not use `@Dependency(\.tokenStorage)`, `TokenStorageClient`, `TokenStorageProtocol`, `KeychainTokenStorage`, Keychain, or UserDefaults directly for token access outside the allowed exceptions. + +Allowed direct TokenStorage usage is limited to TokenManager internals, Core Storage interface/implementation, App/root dependency wiring, tests/mocks, and approved auth infrastructure that depends on `TokenManager` rather than `TokenStorage` directly. Do not introduce another token/header path without owner approval. + +--- + +## Implementation quality gate + +Before non-trivial implementation, verify that the planned structure is not only compliant with documented team architecture, but also clean, maintainable, and appropriate for the change. + +Check architecture fit before writing code: + +- Clean Architecture boundaries are preserved. +- Interface/Implementation split is respected where applicable. +- Dependency direction remains correct; higher-level modules must not depend on lower-level implementation details in the wrong direction. +- New code is placed in the correct module, feature, layer, and target. +- TCA `State`, `Action`, and `Reducer` ownership is clear and belongs to the feature that owns the behavior. +- Side effects go through dependencies and TCA Effects, not directly through views or reducers. +- Public API is minimal; expose only what other modules actually need. +- Coupling is not increased unnecessarily between features, domains, clients, factories, routes, or models. +- Existing clients, factories, routes, models, and dependency keys are reused when appropriate; do not create duplicates for the same responsibility. +- The change does not create avoidable future refactoring cost, such as temporary abstractions becoming public contracts or feature-specific logic leaking into shared layers. + +Stop and ask before introducing: + +- new architectural patterns +- new module-boundary exceptions +- new global clients/factories/routes/models +- new shared abstractions that are not clearly required by current use cases +- changes that contradict existing docs or nearby code patterns + +After implementation, summarize: + +- the architecture decisions made +- why the chosen module/layer placement is appropriate +- any dependency-direction or public-API risks +- any follow-up cleanup or verification that remains + +--- + +## File organization guardrails + +Default rule: + +- Prefer One Type Per File for major types. + +Documented exceptions: + +- Keep TCA `State` and `Action` nested inside the Reducer. +- Keep private helper types with their owner. +- Keep very small helper types with their owner when splitting would reduce cohesion. +- Preserve access levels and module boundaries when moving code. + +Interface-specific rule: + +- Interface modules are public boundaries, but still prefer One Type Per File for new or significantly modified Interface modules. +- Existing `Interface/Sources/Source.swift` files may remain as legacy/compatibility patterns. +- Follow nearby existing code patterns unless they weaken the public boundary or make the public API unclear. + +--- + +## Build and verification + +Tuist is canonical for setup/generation: + +```bash +tuist install +tuist generate +tuist clean +``` + +Use `tuist clean` only when regeneration cleanup is needed. `tuist build` is not the standard command. + +CI/PR-level verification uses Fastlane: + +```bash +bundle exec fastlane ios ci_pr +``` + +Use `fastlane ios ci_pr` only when Bundler is not available. If a repo-local command documents a shorter `fastlane ci_pr` convention, follow that documented convention; otherwise prefer the Bundler command above. + +Do not invent direct `xcodebuild` scheme, destination, or configuration values. Direct `xcodebuild` may be used only when scheme, destination, and configuration are explicitly documented or provided for a direct-xcodebuild-specific task. If verification cannot be run, report the verification limit. + +### Testing + +Tests are not currently established as a normal requirement. + +- Do not create tests unless explicitly requested. +- Do not claim tests were run if no test target or command exists. +- For risky logic changes, propose a test plan. +- Report verification limits caused by missing tests or missing commands. + +--- + +## Editing policy + +- Do not edit files until the requested scope is clear. +- Prefer minimal diffs over rewrites. +- Do not rewrite unrelated sections. +- Verify feasibility before modifying architecture documents. +- Keep public interfaces stable unless the user explicitly requests breaking changes. +- Do not delete legacy documents unless explicitly approved. +- Do not create Pi skills or Pi extensions unless explicitly requested. + +--- + +## Known documentation issues / unresolved items + +These issues are known from the current docs. Do not silently fix them unless asked. + +Intentional docs cleanup decisions: `Rules.md` was migrated into `docs/Reference/ProjectRules.md` and deleted; `Prompt.md` was replaced by Pi skills/workflows and deleted; `docs/Checklists.md` was replaced by `docs/Reference/Checklists.md` and deleted. This was owner-approved for that cleanup only; future legacy document deletions still require explicit approval. + +- Direct `xcodebuild` values are intentionally not documented as the normal verification path; ask if a direct-xcodebuild-specific task requires them. +- SwiftLint follows the Tuist-configured script documented in `docs/Reference/ProjectRules.md`. +- Some existing documentation references may still point to docs that are not currently present. + +When these affect a task, ask for confirmation before proceeding. diff --git a/Claude.md b/Claude.md index 3a4dd7c5..64802ae9 100644 --- a/Claude.md +++ b/Claude.md @@ -1,142 +1,37 @@ # Claude Code 가이드 -> 이 파일은 Claude Code CLI에서 프로젝트 맥락을 빠르게 파악하기 위한 가이드입니다. +> Claude Code CLI에서 이 저장소를 작업할 때 사용하는 얇은 진입점입니다. -## 📌 빠른 참조 +## 먼저 읽기 -- [팀 규칙](./Rules.md) - 반드시 지켜야 할 팀 합의사항 +- 공통 에이전트 기준: @AGENTS.md +- `@AGENTS.md` import/reference가 동작하지 않는 환경에서는 [AGENTS.md](./AGENTS.md)를 먼저 읽으세요. ---- - -## 🎯 프로젝트 요약 - -- **아키텍처**: SwiftUI + TCA + Micro Features Architecture -- **빌드 시스템**: Tuist -- **핵심 원칙**: Interface/Implementation 분리, Dependency Injection, ViewFactory 패턴, TokenManager 단일 중재(직접 TokenStorage 접근 금지) +`AGENTS.md`가 Pi, Codex CLI, Claude Code에 공통으로 적용되는 기준입니다. 이 파일은 Claude Code 전용 메모만 유지합니다. --- -## 📚 문서 구조 - -모든 문서는 **docs/** 폴더에 계층적으로 구성되어 있습니다. - -### 처음 배울 때 -1. [빠른 시작](./docs/QuickStart.md) - TCA 기본 개념 (10분) -2. [아키텍처 개요](./docs/Architecture/Overview.md) - 전체 구조 - -### Feature 개발할 때 -1. [팀 규칙](./Rules.md) - DocC, Reducer, ViewFactory 규칙 -2. [네트워크 통신](./docs/Guides/NetworkGuide.md) - API 호출 -3. [NavigationStack](./docs/Guides/NavigationStack.md) - 화면 전환 - -### 코드 작성 시 참고 -1. [네이밍 규칙](./docs/Reference/NamingConventions.md) - Action, File 네이밍 -2. [체크리스트](./docs/Reference/Checklists.md) - Feature 구현 체크리스트 -3. [파일 구조화 규칙](./docs/Reference/FileOrganization.md) - 파일 분리 및 구조화 - ---- - -## 🏗️ 현재 구현된 Feature - -### Auth Feature -- **위치**: `Projects/Feature/Auth/` -- **역할**: Apple 로그인 -- **플로우**: 로그인 성공 → `.delegate(.loginSucceeded)` → MainTab 전환 +## Claude Code 작업 메모 -### MainTab Feature -- **위치**: `Projects/Feature/Sources/` -- **역할**: 메인 탭 화면 (홈/통계/커플/마이페이지) +- 작업을 시작하기 전에 `AGENTS.md`의 문서 조회 순서와 편집 정책을 따르세요. +- 팀 규칙이 필요한 작업은 [docs/Reference/ProjectRules.md](./docs/Reference/ProjectRules.md)를 함께 확인하세요. +- 상세 구현은 작업 종류에 맞는 `docs/*.md`를 확인하세요. +- 누락되었거나 링크가 깨진 문서는 추정하지 말고 사용자에게 확인하세요. --- -## 🔧 자주 사용하는 명령어 - -### Tuist -```bash -tuist generate # 프로젝트 생성 -tuist install # 의존성 설치 -tuist clean # 캐시 정리 -``` - -### Git -```bash -# 커밋 규칙 -feat: 새로운 기능 추가 -fix: 버그 수정 -refactor: 코드 리팩토링 -chore: 빌드 설정, 패키지 등 -docs: 문서 수정 -``` - ---- - -## 📖 상세 문서 찾기 - -모든 상세 문서는 [README.md](./README.md#-문서-구조)에서 찾을 수 있습니다. - -### 아키텍처 -- [아키텍처 개요](./docs/Architecture/Overview.md) -- [Interface/Implementation 분리](./docs/Architecture/InterfaceImplementation.md) -- [Reducer 패턴](./docs/Architecture/ReducerPattern.md) -- [Dependency Injection](./docs/Architecture/DependencyInjection.md) -- [ViewFactory 패턴](./docs/Architecture/ViewFactory.md) - -### 가이드 -- [빠른 시작](./docs/QuickStart.md) -- [네트워크 통신](./docs/Guides/NetworkGuide.md) -- [NavigationStack](./docs/Guides/NavigationStack.md) -- [복잡한 State 관리](./docs/Guides/StateManagement.md) -- [테스트 작성](./docs/Guides/Testing.md) +## Claude Code 사용 예시 -### 레퍼런스 -- [네이밍 규칙](./docs/Reference/NamingConventions.md) -- [체크리스트](./docs/Reference/Checklists.md) -- [파일 구조화 규칙](./docs/Reference/FileOrganization.md) - -### 예제 -- [Auth Feature](./docs/Examples/Auth.md) -- [MainTab Feature](./docs/Examples/MainTab.md) - ---- - -## 💡 Claude Code 사용 팁 - -### 작업 시작 전 -``` -"README.md 읽고 [작업 내용] 해줘" +```text +"AGENTS.md와 docs/Reference/ProjectRules.md를 읽고 [작업 내용] 해줘" "docs/Guides/NetworkGuide.md 참고해서 API Client 만들어줘" +"이 Reducer가 AGENTS.md와 ProjectRules.md 규칙을 잘 따르는지 확인해줘" ``` -### 규칙 확인 -``` -"Rules.md 기반으로 Feature 만들어줘" -``` - -### 코드 리뷰 -``` -"Auth Feature 코드 리뷰해줘" -"이 Reducer가 Rules.md 규칙을 잘 따르는지 확인해줘" -``` - ---- - -## 🗂️ 구버전 문서 - -- `Claude_OLD.md` - 계층화 이전의 모놀리식 문서 (백업용) - --- -**문서 버전**: 2.0 (계층적 구조) -**마지막 업데이트**: 2026-01-12 -**작성자**: Claude Code Assistant - ---- - -## 📝 참고사항 - -이 문서는 Claude Code가 프로젝트 맥락을 빠르게 파악하기 위한 **요약본**입니다. +## 참고 -**상세 내용은 각 문서를 참고하세요:** -- 전체 개요: [README.md](./README.md) -- 팀 규칙: [Rules.md](./Rules.md) -- 가이드: [docs/](./docs/) +- 이 파일은 Claude Code 전용 진입점입니다. +- 프로젝트의 공통 기준은 `AGENTS.md`에 있습니다. +- 상세 기술 문서는 `docs/*.md`에 있습니다. diff --git a/Prompt.md b/Prompt.md deleted file mode 100644 index 8918c138..00000000 --- a/Prompt.md +++ /dev/null @@ -1,216 +0,0 @@ -You are an iOS architecture engineer and build-system operator. - -Environment: -- Platform: iOS only -- Minimum target: iOS 17 -- UI: SwiftUI -- State: TCA 1.23 -- Architecture: Clean Architecture + Micro Feature Architecture (MFA) -- Build tool: xcodebuild -- Project docs are authoritative - -Rules: -- Do NOT invent architecture rules -- Do NOT modify documents unless feasibility is verified -- Prefer minimal diffs over rewrites -- All outputs must be deterministic and reproducible -- Ask for missing files instead of assuming - -Language Policy: -- Think and reason in English -- All sections in OUTPUT FORMAT must be written in Korean -- Code, file names, symbols, and commands must remain in English - -Output format is mandatory. No extra commentary. - ---- - -TASK: Architecture Change Validator & Document Editor [MODE 1] - -You are given: -1) Architecture documents (Markdown) -2) My requested change - -Process: -PHASE 1 — FeASIBILITY CHECK -- Validate if this change is technically valid under: - - SwiftUI + TCA 1.23 - - Clean Architecture - - Micro Feature Architecture - - iOS 17 runtime + toolchain -- Identify violations, contradictions, or untestable constraints - -PHASE 2 — DECISION -- If invalid: - - Output only: REJECTED + technical reasons + minimum viable alternative -- If valid: - - Proceed to PHASE 3 - -PHASE 3 — DOCUMENT PATCH -- Apply minimal diffs to the documents -- Preserve structure, tone, and conventions -- Do not reword unrelated sections - -INPUTS: ---- -[ARCHITECTURE DOCS] -./Claude.md ---- -[REQUESTED CHANGE] -./RequestedChange.md - ---- -OUTPUT FORMAT (STRICT): -STATUS: ACCEPTED | REJECTED - -FEASIBILITY_REPORT: -- Constraint Check: -- TCA Compatibility: -- MFA Impact: -- Build/Test Impact: - -DOCUMENT_PATCH: -```diff -(unified diff here) - - ---- -TASK: Architecture Compliance Auditor [MODE 2] - -You are given: -- Architecture rules (Markdown) -- Swift source code - -Process: -PHASE 1 — RULE EXTRACTION -- Derive enforceable rules from docs: - - Module boundaries - - Dependency direction - - TCA patterns (Reducer, State, Action, Environment, Scope) - - MFA constraints (Interface vs Sources vs Domain) - -PHASE 2 — CODE AUDIT -- Map each file to: - - Layer - - Feature - - Dependency direction -- Flag violations with: - - Rule reference - - File - - Line range - - Impact - -PHASE 3 — REFACTOR -- Fix violations with minimal architectural disturbance -- Keep public interfaces stable unless explicitly forbidden - -INPUTS: ---- -[ARCHITECTURE_DOCS] -./Claude.md ---- -[SOURCE_CODE] -./ ---- - -OUTPUT FORMAT (STRICT): -RULES_DERIVED: -- R1: -- R2: - -VIOLATIONS: -- ID: - Rule: - File: - Lines: - Problem: - Severity: - -PATCH: -```diff -(unified diff) - -POST_REFACTOR_CHECK: -- Build Safety: - TCA Integrity: - Dependency Direction: - ---- - -TASK: Feature Implementation Pipeline [MODE 3] - -You are given: -- Architecture docs -- Feature requirements -- Project structure - -Process: -PHASE 1 — ARCHITECTURE FIT -- Identify: - - Feature module - - Domain contracts - - Reducer scope - - Dependency injection path - -PHASE 2 — DESIGN -- Define: - - State - - Action - - Reducer - - UseCase - - Interfaces -- Ensure MFA boundaries are preserved - -PHASE 3 — IMPLEMENTATION -- Generate Swift code -- Respect: - - TCA 1.23 APIs - - iOS 17 SDK - - Module visibility rules - -PHASE 4 — BUILD PLAN -- Produce xcodebuild command: - - scheme - - destination - - configuration -- Predict failure points - -INPUTS: ---- -[ARCHITECTURE_DOCS] -./Claude.md ---- -[FEATURE_REQUEST] -./RequestedChange.md ---- -[PROJECT_TREE] ---- - -OUTPUT FORMAT (STRICT): -ARCHITECTURE_MAP: -- Feature: -- Domain: -- Interfaces: -- Dependencies: - -DESIGN: -- State: -- Action: -- Reducer: -- UseCase: - -CODE: -```swift -(files separated by // MARK: FileName) - -BUILD: - xcodebuild \ - -scheme \ - -destination 'platform=iOS Simulator,name=iPhone 15' \ - -configuration Debug \ - build - -RISK_REPORT: -- Compile Risk: - Dependency Risk: - TCA Misuse Risk: \ No newline at end of file diff --git a/Rules.md b/Rules.md deleted file mode 100644 index 08c1b944..00000000 --- a/Rules.md +++ /dev/null @@ -1,185 +0,0 @@ -Rules - -## 목적 -팀 아키텍처 규칙과 결정사항을 간단하고 실행 가능한 형태로 정리합니다. - -## DocC 문서화 기준 -대상: Core / Domain / Feature / Shared 모듈의 Interface 계층 - -**문서화 대상** -- Interface의 public 타입(struct, enum, class 등)에 대한 간단한 설명 (필수) -- Shared의 경우 Interface가 없으므로 public 타입에 한해서 문서화 (필수) -- public 함수는 사용 예시 코드까지 작성 (필수) - -**문서화 제외** -- enum case, 변수/프로퍼티: 문서화 주석 작성 안 함 -- App 계층: internal 타입이므로 문서화 불필요 -- Implementation 계층: public이 아닌 한 문서화 불필요 - -**엄격 적용** -- 문서화 제외 항목은 예외 없이 문서화를 금지합니다. -- public API(타입/함수) 문서화 누락은 규칙 위반입니다. - -예시 -```swift -/// 앱 전체에서 사용하는 네트워크 요청 프로토콜입니다. -/// -/// ## 사용 예시 -/// ```swift -/// let provider: NetworkProviderProtocol = NetworkProvider() -/// let user: User = try await provider.request(endpoint: UserEndpoint.profile) -/// ``` -public protocol NetworkProviderProtocol { - /// 공통 엔드포인트를 통해 서버에 데이터를 요청합니다. - func request(endpoint: Endpoint) async throws -> T -} -``` - -## Feature 모듈 구조 -모든 Feature는 Interface / Implementation 분리 구조를 유지합니다. - -```text -Feature - ├── FeatureOnboarding - ├── FeatureProfile - ├── FeatureCrew - └── Sources (Feature Root) -``` - -### 예외 Feature (App 직접 Path 관리) -- Auth / Onboarding / MainTab은 App에서 직접 Path를 관리하는 중간 관리자 Feature로 취급합니다. -- 위 Feature는 Interface/Implementation 분리 및 ViewFactory 강제 규칙에서 예외입니다. -- App은 위 Feature를 `makeView(_:)` 없이 직접 조립할 수 있습니다. -- 위 Feature는 내부 하위 Feature 조립 시 Implementation 모듈을 직접 import 할 수 있습니다. -- 위 Feature는 자식 Feature를 Interface-only `makeView(_:)` 대신 직접 생성할 수 있습니다. -- 그 외 Feature는 Interface 모듈만 import하며 `makeView(_:)` 또는 동등한 factory로만 조립합니다. - -## Navigation 규칙 -프로젝트 전체에서 **통일된 Navigation 패턴**을 사용합니다. - -### 사용 패턴: Route enum + `[Route]` 배열 -- `enum Route: Hashable` + `[Route]` 배열 사용 -- Child State는 Optional로 관리, `.ifLet`으로 Reducer 연결 -- 예시: `state.routes.append(.codeInput)` - -### 사용하지 않는 패턴: TCA 공식 StackState -- `StackState` + `@Reducer enum Path` 사용 안 함 -- 이유: `@Reducer enum Path` 매크로가 Interface/Implementation 분리 구조에서 동작하지 않음 -- 예외 Feature도 코드 일관성을 위해 동일한 패턴 사용 - -상세 가이드는 `docs/Guides/NavigationStack.md` 참고 - -## Reducer 생성 규칙 -- Interface에는 Reducer의 시그니처만 둡니다. (body는 외부 Reduce 주입) -- Implementation에서 실제 Reduce를 구성하는 init을 제공합니다. -- 다른 Feature에서 Reducer를 사용할 때는 Interface 타입만 의존합니다. - -Interface 예시 -```swift -@Reducer -public struct CounterReducer { - let reducer: Reduce - public init(reducer: Reduce) { self.reducer = reducer } - public var body: some ReducerOf { reducer } -} -``` - -Implementation 예시 -```swift -extension CounterReducer { - public init() { - self.init(reducer: Reduce { state, action in - // 실제 로직 - return .none - }) - } -} -``` - -## Feature Root에서의 조립 -Feature Root(Sources)에서 각 Feature의 구현체를 조립합니다. - -- Root가 구현 모듈을 직접 의존하고 Reducer/View를 주입합니다. -- 외부 모듈은 Interface에만 의존합니다. -- Feature Root에서 타입 재노출이 필요할 경우 **Interface 타입만 재노출**합니다. - -## ViewFactory 도입 기준 -기본 규칙: 모든 Feature에 강제하지 않습니다. - -1) Flow 단위 Feature -- Flow 내부에서만 쓰이고 외부 재사용이 없다면 Root에서 직접 조립 -- ViewFactory 생략 가능 - -2) 하위 기능 단위 Feature -- 다른 화면에서 재사용 가능성이 있으면 ViewFactory 도입 -- Interface에 Factory 정의, Sources에서 liveValue 제공 - -## 의존성 주입 규칙 (필수) -Struct + closure + TCA Dependency 스타일을 기본으로 사용합니다. - -- 모든 모듈은 TCA Dependency Container를 사용합니다. -- 계층 간 연결(Feature <-> Domain)은 Interface 모듈만 import합니다. -- liveValue는 Implementation 모듈에서 제공하며, 조립은 App/Feature Root에서 `.withDependency`로 명시합니다. -- Implementation 모듈 내부에서 다른 모듈의 의존성을 조립하지 않습니다. -- Core/Network, Core/Storage는 singleton을 사용하지 않고 TCA Dependency로 주입 가능한 인스턴스형으로 제공합니다. - -Interface 예시 -```swift -public struct DetailFactory: Sendable { - public var makeView: @MainActor (StoreOf) -> AnyView - public init(makeView: @escaping @MainActor (StoreOf) -> AnyView) { - self.makeView = makeView - } -} - -extension DetailFactory: TestDependencyKey { - public static let testValue = Self { _ in - assertionFailure("DetailFactory.makeView is unimplemented") - return AnyView(EmptyView()) - } -} -``` - -Sources 예시 -```swift -extension DetailFactory: DependencyKey { - public static let liveValue = Self { store in - AnyView(DetailView(store: store)) - } -} -``` - -사용 예시 -```swift -@Dependency(\.detailFactory) var detailFactory -detailFactory.makeView(store: store.scope(state: \.detail, action: \.detail)) -``` - -## SwiftLint 규칙 (필수) -SwiftLint 경고를 가능한 한 최소화해야 합니다. - -- 새로운 코드에서는 SwiftLint 경고가 발생하지 않도록 작성합니다. -- 변경으로 인해 경고가 증가하지 않도록 합니다. -- 불가피한 경우에만 제한적으로 `swiftlint:disable`을 사용하고, 범위를 최소화합니다. - -## 코드 스타일 규칙 (필수) -메소드의 매개 변수가 2개 이상일 때는 개행하여 가독성을 높입니다. - -예시 -```swift -public func example( - a: Int, - b: Int -) -> ReturnType { ... } -``` - -## 외부 의존성 참조 규칙 (필수) -서로 다른 계층(Feature/Domain/Core) 간 참조는 Interface만으로 해결 가능한지 먼저 검증합니다. - -- Interface만으로 해결 가능하면 해당 방식만 사용합니다. -- Interface만으로 불가능한 경우에만 implements를 허용하며, 불가능한 이유를 문서화합니다. -- 전체 모듈 참조(예: `.domain`, `.core`)로 대체하는 결정은 원칙적으로 지양하며, 구조적 필요성이 명확할 때만 허용합니다. - -## TCA Dependency + Interface 규칙 메모 -Interface에 TestDependencyKey를 두면 MFA 규칙상 Testing 모듈 분리 원칙과 충돌 가능성이 있으므로, -팀 합의로 허용하거나 Testing 모듈로 대체하는 방안을 추후 결정합니다. diff --git a/docs/Architecture/Overview.md b/docs/Architecture/Overview.md index 99b0331d..b2932c95 100644 --- a/docs/Architecture/Overview.md +++ b/docs/Architecture/Overview.md @@ -23,10 +23,10 @@ Projects/ │ └── Resources/ │ ├── Feature/ # 기능 모듈 (UI + 비즈니스 로직) -│ ├── Auth/ -│ ├── Onboarding/ -│ ├── MainTab/ -│ └── Sources/ # Feature Root +│ ├── Auth/ # 현재 App 직접 조립 예외 Feature +│ ├── Onboarding/ # 현재 App 직접 조립 예외 Feature +│ ├── MainTab/ # 현재 App 직접 조립 예외 Feature +│ └── Sources/ # 현재 Feature Root / re-export layer │ ├── Domain/ # 도메인 로직 │ └── Auth/ @@ -52,6 +52,9 @@ Projects/ ### Feature 계층 - UI + 비즈니스 로직 - Interface/Sources 분리 +- Interface 모듈은 외부에 노출되는 public boundary입니다. +- Sources 모듈은 구현 세부사항을 숨기는 implementation layer입니다. +- 다른 Feature/App/상위 조립 계층은 일반적으로 implementation Sources가 아니라 Interface 모듈에 의존합니다. - 독립적으로 실행 가능 (Example 타겟) ### Domain 계층 @@ -75,11 +78,15 @@ Projects/ ### 1. Interface/Implementation 분리 ``` -Feature/Auth/ -├── Interface/Sources/ # 타입 정의만 (public) -└── Sources/ # 실제 구현 (internal) +Feature/{Feature}/ +├── Interface/Sources/ # 외부 공개 계약(public boundary) +└── Sources/ # 실제 구현(implementation details) ``` +Interface 모듈은 public reducer/state/action, client, factory, dependency key 등 외부 조립에 필요한 공개 계약을 제공합니다. Sources 모듈은 View, live 구현, reducer 세부 로직 등 구현 세부사항을 숨깁니다. + +소비자는 특별한 예외가 없는 한 implementation Sources가 아니라 Interface 모듈에 의존해야 합니다. 구현 모듈을 직접 import하는 것은 모듈 경계를 약화시키므로, 문서화된 예외 또는 명시적 승인 없이 새로 도입하지 않습니다. + **장점**: - 빌드 시간 최적화 - 의존성 최소화 @@ -87,6 +94,9 @@ Feature/Auth/ **예외 (App 직접 조립 Feature)**: - Auth / Onboarding / MainTab은 App에서 직접 Path를 관리하는 중간 관리자 Feature로 취급합니다. +- 현재 경로는 각각 `Projects/Feature/Auth/`, `Projects/Feature/Onboarding/`, `Projects/Feature/MainTab/`입니다. +- 현재 `Projects/Feature/Sources/Source.swift`는 Feature Root / re-export layer로 사용됩니다. +- 위 경로는 현재 코드베이스 관찰값이며, 그 자체가 모든 신규 구조의 이상적인 형태임을 의미하지는 않습니다. - 위 Feature는 Interface/Implementation 분리 및 ViewFactory 강제 규칙에서 예외입니다. - App은 위 Feature를 `makeView(_:)` 없이 직접 조립할 수 있습니다. - 위 Feature는 내부 하위 Feature 조립 시 Implementation 모듈을 직접 import 할 수 있습니다. @@ -101,7 +111,10 @@ Feature/Auth/ 모든 의존성은 TCA Dependency Container로 주입합니다. - 모든 모듈에서 TCA Dependency Container를 사용합니다. - 계층 간 연결은 Interface 모듈만 노출하며, liveValue는 Implementation 모듈에서 제공합니다. +- 서로 다른 계층(Feature / Domain / Core) 간 참조는 Interface만으로 해결 가능한지 먼저 검증합니다. +- Interface만으로 불가능한 implementation 의존은 명확한 구조적 이유가 있을 때만 허용합니다. - 의존성 조립은 App/Feature Root에서 `.withDependency`로 명시적으로 수행합니다. +- Feature Root에서 타입 재노출이 필요할 경우 public boundary를 해치지 않도록 Interface 타입 재노출을 우선합니다. ```swift @Dependency(\.authLoginClient) var authLoginClient ``` @@ -114,6 +127,20 @@ View를 직접 노출하지 않고 Factory로 생성: authViewFactory.makeView(store) ``` +### 4. Token 접근 경계 + +토큰 접근은 현재 `TokenManager` 패턴을 통해 중재합니다. + +- `TokenManager`: `Projects/Domain/Auth/Interface/Sources/TokenManager.swift` +- Token storage interface: `Projects/Core/Storage/Interface/Sources/TokenStorageProtocol.swift` +- Keychain implementation: `Projects/Core/Storage/Sources/KeychainTokenStorage.swift` +- 현재 Authorization header 처리 패턴: `Projects/Domain/Auth/Sources/AuthInterceptor.swift`가 `TokenManager`를 사용합니다. +- 현재 App/root wiring: `Projects/App/Sources/View/TwixApp.swift`에서 live token storage dependency를 설정합니다. + +Feature, Reducer, View, 일반 Client, request-building code는 `TokenStorageClient`, `TokenStorageProtocol`, `KeychainTokenStorage`, Keychain, UserDefaults 등 token persistence에 직접 접근하지 않습니다. 토큰 조회/저장/삭제/refresh-state 전환 및 access token 조회는 `TokenManager`를 통해 수행합니다. + +직접 TokenStorage 사용은 `TokenManager` 내부, Core Storage interface/implementation, App/root dependency wiring, tests/mocks, 그리고 `TokenManager`에 의존하는 승인된 auth infrastructure로 제한합니다. + --- ## 데이터 흐름 @@ -150,10 +177,10 @@ authViewFactory.makeView(store) ## 다음 단계 -- [Reducer 패턴](./ReducerPattern.md) - Reducer 구현 방법 -- [Dependency Injection](./DependencyInjection.md) - 의존성 주입 -- [ViewFactory 패턴](./ViewFactory.md) - ViewFactory 구현 -- [팀 규칙](../../Rules.md) - 팀 합의사항 +- [구현 체크리스트](../Reference/Checklists.md) - Feature 구현 확인 항목 +- [파일 구조화 규칙](../Reference/FileOrganization.md) - 파일 분리 및 Interface 파일 정책 +- [네이밍 규칙](../Reference/NamingConventions.md) - Action, File, 타입 네이밍 +- [프로젝트 규칙](../Reference/ProjectRules.md) - 팀 합의사항 --- diff --git a/docs/Checklists.md b/docs/Checklists.md deleted file mode 100644 index 1adc329b..00000000 --- a/docs/Checklists.md +++ /dev/null @@ -1,34 +0,0 @@ -# Feature 구현 체크리스트 - -## Interface 구현 -Auth / MainTab / Onboarding은 예외 Feature로 취급되어 이 체크리스트를 강제하지 않습니다. - -- [ ] State struct 정의 (public) -- [ ] State.init() 정의 (public) -- [ ] Action enum 정의 (public) -- [ ] Reducer struct 정의 (public) -- [ ] Client struct 정의 (필요 시) -- [ ] ViewFactory struct 정의 (필요 시) -- [ ] TestDependencyKey 구현 -- [ ] DependencyValues 확장 -- [ ] Client liveValue 구현 -- [ ] ViewFactory liveValue 구현 -- [ ] DocC 문서 작성 - -## Sources 구현 - -- [ ] Reducer.init() 구현 (public) -- [ ] Reducer body 로직 작성 -- [ ] View 구현 (internal) - -## 테스트 -현재 단계에서는 테스트 항목을 적용하지 않습니다. - -- [ ] Reducer 유닛 테스트 -- [ ] Client Mock 구현 -- [ ] Integration 테스트 -- [ ] Preview 작성 (Live, Mock, Error) - ---- - -**작성일**: 2026-01-12 diff --git a/docs/Examples/NavigationStackExample.swift b/docs/Examples/NavigationStackExample.swift index 58d15b21..df3ae6c5 100644 --- a/docs/Examples/NavigationStackExample.swift +++ b/docs/Examples/NavigationStackExample.swift @@ -1,9 +1,17 @@ // MARK: - NavigationStack 패턴 완전한 예제 // 이 파일은 학습용 예제입니다. 실제 프로젝트에서는 Feature 모듈로 분리하세요. +// 프로젝트 canonical [Route] 배열 NavigationStack 패턴을 따릅니다. import ComposableArchitecture import SwiftUI +// MARK: - Route + +enum HomeRoute: Hashable { + case detail + case settings +} + // MARK: - 1️⃣ Home Feature (Root) @Reducer @@ -11,57 +19,69 @@ struct HomeReducer { @ObservableState struct State: Equatable { var items: [Item] = Item.samples - var path = StackState() // ✨ NavigationStack! + var routes: [HomeRoute] = [] + var detail: DetailReducer.State? + var settings: SettingsReducer.State? - // Stack에 들어갈 수 있는 화면들을 Enum으로 정의 - @CasePathable - enum Path: Equatable { - case detail(DetailReducer.State) - case settings(SettingsReducer.State) + mutating func syncChildStatesWithRoutes() { + if !routes.contains(.detail) { + detail = nil + } + if !routes.contains(.settings) { + settings = nil + } } } - enum Action { - case itemTapped(Item) // 항목 클릭 - case settingsButtonTapped // 설정 버튼 클릭 - case path(StackActionOf) // ✨ Stack 액션 (자식 Reducer들의 액션 포함) - - @CasePathable - enum Path { - case detail(DetailReducer.Action) - case settings(SettingsReducer.Action) - } + enum Action: BindableAction { + case binding(BindingAction) + case itemTapped(Item) + case settingsButtonTapped + case detail(DetailReducer.Action) + case settings(SettingsReducer.Action) } var body: some ReducerOf { + BindingReducer() + Reduce { state, action in switch action { + case .binding: + // System back/pop mutates NavigationStack(path:) directly. + // Keep optional child states in sync with the remaining routes. + state.syncChildStatesWithRoutes() + return .none + case .itemTapped(let item): - // ✨ Push: Stack에 Detail 화면 추가 - state.path.append(.detail(DetailReducer.State(item: item))) + state.detail = DetailReducer.State(item: item) + state.routes.append(.detail) return .none case .settingsButtonTapped: - // ✨ Push: Stack에 Settings 화면 추가 - state.path.append(.settings(SettingsReducer.State())) + state.settings = SettingsReducer.State() + state.routes.append(.settings) return .none - case .path(.element(id: _, action: .detail(.settingsButtonTapped))): - // Detail 화면에서 설정 버튼 클릭 → Settings 추가 - state.path.append(.settings(SettingsReducer.State())) + case .detail(.settingsButtonTapped): + state.settings = SettingsReducer.State() + state.routes.append(.settings) return .none - case .path(.element(id: _, action: .settings(.delegate(.logoutRequested)))): - // Settings에서 로그아웃 요청 → 모든 Stack Pop - state.path.removeAll() + case .settings(.delegate(.logoutRequested)): + state.routes.removeAll() + state.syncChildStatesWithRoutes() return .none - case .path: + case .detail, .settings: return .none } } - // ✨ forEach: Stack의 각 화면을 해당 Reducer와 연결 - .forEach(\.path, action: \.path) + .ifLet(\.detail, action: \.detail) { + DetailReducer() + } + .ifLet(\.settings, action: \.settings) { + SettingsReducer() + } } } @@ -69,7 +89,7 @@ struct HomeView: View { @Bindable var store: StoreOf var body: some View { - NavigationStack(path: $store.scope(state: \.path, action: \.path)) { + NavigationStack(path: $store.routes) { List { ForEach(store.items) { item in Button { @@ -92,14 +112,24 @@ struct HomeView: View { Image(systemName: "gearshape") } } - } destination: { store in - // ✨ Stack의 각 케이스에 따라 View 렌더링 - switch store.case { - case .detail(let detailStore): - DetailView(store: detailStore) - - case .settings(let settingsStore): - SettingsView(store: settingsStore) + .navigationDestination(for: HomeRoute.self) { route in + switch route { + case .detail: + if let detailStore = store.scope( + state: \.detail, + action: \.detail + ) { + DetailView(store: detailStore) + } + + case .settings: + if let settingsStore = store.scope( + state: \.settings, + action: \.settings + ) { + SettingsView(store: settingsStore) + } + } } } } @@ -133,7 +163,7 @@ struct DetailReducer { } case .settingsButtonTapped: - // Parent가 처리 (HomeReducer에서 .path 액션으로 받음) + // Parent가 처리 (HomeReducer에서 child action으로 받음) return .none case .detailsResponse(let details): diff --git a/docs/Guides/NetworkGuide.md b/docs/Guides/NetworkGuide.md index c50af6a4..052a5c6e 100644 --- a/docs/Guides/NetworkGuide.md +++ b/docs/Guides/NetworkGuide.md @@ -21,12 +21,20 @@ ### URLSession → TCA Client 변환 과정 ``` -1. Interface에 Client Protocol 정의 -2. Sources에 URLSession 기반 구현 +1. Interface에 struct-based TCA Client 계약 정의 +2. Sources에 URLSession/NetworkProvider 기반 live 구현 3. Reducer에서 @Dependency로 주입 4. Effect로 비동기 호출 ``` +Feature dependency는 **struct-based TCA Client**를 기본으로 사용합니다. 새 Feature Client마다 protocol을 만들지 않습니다. + +Protocol-based client/abstraction은 다음 경우에만 사용합니다. +- 기존 Core protocol이 이미 존재하는 경우 +- 플랫폼 abstraction에 protocol이 필요한 경우 +- legacy integration이 protocol을 요구하는 경우 +- 명시적인 문서/요구사항이 protocol을 요구하는 경우 + ### 왜 Client로 래핑하나? | 항목 | 직접 URLSession 사용 | TCA Client 패턴 | @@ -40,6 +48,8 @@ ## 현재 프로젝트 구조 +아래 `NetworkProviderProtocol`, `Endpoint`는 Core Network의 infrastructure-level protocol입니다. 이는 Feature별 dependency도 protocol로 만들어야 한다는 뜻이 아닙니다. Feature Reducer가 주입받는 dependency는 일반적으로 struct-based TCA Client입니다. + ``` Projects/Core/Network/ ├── Interface/Sources/ @@ -114,8 +124,10 @@ extension NetworkError { ### 전체 구현 예시 +Feature dependency는 아래처럼 `struct`로 정의합니다. 같은 책임의 protocol을 추가로 만들지 않습니다. + ```swift -// 1️⃣ Interface에 Client 정의 +// 1️⃣ Interface에 struct-based TCA Client 정의 public struct PostsClient { public var fetchPosts: @Sendable () async throws -> [Post] public var fetchPost: @Sendable (_ id: Int) async throws -> Post @@ -818,6 +830,10 @@ case .incrementRetryCount: ## 고급: NetworkProvider를 Dependency로 주입 +이 섹션은 Core Network infrastructure를 TCA Dependency로 감싸는 고급 패턴입니다. 기존 `NetworkProviderProtocol` 같은 infrastructure-level protocol을 보존하는 경우에 사용합니다. + +일반적인 Feature dependency는 여전히 struct-based TCA Client를 우선합니다. 새 Feature Client마다 protocol을 만들기 위한 패턴으로 사용하지 않습니다. + NetworkProvider 자체도 Dependency로 주입하여 테스트 시 Mock으로 교체 가능합니다. ### 1. NetworkProvider를 Dependency로 등록 @@ -923,6 +939,7 @@ func testFetchPosts() async throws { - [ ] Endpoint 정의 (baseURL, path, method, headers 등) - [ ] Client struct 정의 (Interface) +- [ ] 새 protocol을 만들지 않았는지 확인 (기존 Core/platform/legacy boundary가 필요한 경우 제외) - [ ] TestDependencyKey 구현 (assertionFailure) - [ ] Live Implementation (NetworkProvider 사용) - [ ] Mock Implementation (Preview/테스트용) @@ -1269,17 +1286,41 @@ struct PostsListView: View { ### 핵심 포인트 -1. **Client 패턴** - 3가지 구현 (live, test, mock)으로 유연성 확보 -2. **Effect 사용** - `.run { send in ... }` 패턴으로 비동기 처리 -3. **에러 처리** - Result 타입으로 성공/실패 분기 -4. **Mock 주입** - `withDependencies`로 Preview 및 테스트에서 Mock 사용 -5. **취소 가능** - `.cancellable(id:)`로 중복 요청 방지 +1. **Struct-based TCA Client** - Feature dependency의 기본 형태 +2. **Client 패턴** - 3가지 구현 (live, test, mock)으로 유연성 확보 +3. **Infrastructure protocol 보존** - 기존 Core/platform/legacy boundary가 있을 때만 protocol 사용 +4. **Effect 사용** - `.run { send in ... }` 패턴으로 비동기 처리 +5. **에러 처리** - Result 타입으로 성공/실패 분기 +6. **Mock 주입** - `withDependencies`로 Preview 및 테스트에서 Mock 사용 +7. **취소 가능** - `.cancellable(id:)`로 중복 요청 방지 + +## 인증 토큰 처리 + +Authorization header가 필요한 요청은 token storage나 Keychain을 직접 읽지 않습니다. + +현재 승인된 패턴: + +- `TokenManager`: `Projects/Domain/Auth/Interface/Sources/TokenManager.swift` +- `AuthInterceptor`: `Projects/Domain/Auth/Sources/AuthInterceptor.swift` +- `AuthInterceptor`는 `TokenManager`에서 access token을 읽고, refresh flow는 `AuthClient.refreshToken` 경로로 위임합니다. +- App/root wiring은 `Projects/App/Sources/View/TwixApp.swift`에서 live token storage dependency를 설정합니다. + +금지: + +- Feature Client나 Network request code에서 `@Dependency(\.tokenStorage)` 직접 사용 +- `TokenStorageClient`, `TokenStorageProtocol`, `KeychainTokenStorage`, Keychain, UserDefaults를 Authorization header 생성을 위해 직접 읽기 +- Feature Client마다 token refresh logic을 중복 구현 +- owner 승인 없이 새로운 token/header path 도입 + +직접 TokenStorage 사용은 `TokenManager` 내부, Core Storage interface/implementation, App/root dependency wiring, tests/mocks로 제한합니다. 인증 인프라를 추가해야 한다면 `TokenStorage`가 아니라 `TokenManager`에 의존하도록 설계합니다. + +--- ### 다음 단계 - [ ] 실제 프로젝트에 API Client 구현 - [ ] 여러 Endpoint 추가 -- [ ] 인증 토큰 처리 (Authorization Header) +- [ ] 인증 토큰 처리는 `TokenManager` + `AuthInterceptor` 패턴 확인 - [ ] 캐싱 전략 구현 - [ ] 오프라인 대응 diff --git a/docs/QuickStart.md b/docs/QuickStart.md index 697e591d..9d2b5d13 100644 --- a/docs/QuickStart.md +++ b/docs/QuickStart.md @@ -2,6 +2,15 @@ > 10분 만에 TCA 기본 개념을 이해하고 첫 Feature를 만들어봅시다 +이 문서는 TCA와 Feature 구조를 이해하기 위한 **입문용 튜토리얼**입니다. 예제는 설명을 위해 단순화되어 있으므로, production 구현 전에는 canonical docs를 기준으로 검증하세요. + +- Architecture / module boundary: [Architecture/Overview.md](./Architecture/Overview.md) +- Implementation checklist: [Reference/Checklists.md](./Reference/Checklists.md) +- File organization: [Reference/FileOrganization.md](./Reference/FileOrganization.md) +- Naming: [Reference/NamingConventions.md](./Reference/NamingConventions.md) +- Navigation: [Guides/NavigationStack.md](./Guides/NavigationStack.md) +- Network / client patterns: [Guides/NetworkGuide.md](./Guides/NetworkGuide.md) + ## 📋 목차 1. [TCA 핵심 개념 (5분)](#tca-핵심-개념) @@ -35,7 +44,7 @@ struct State: Equatable { **핵심**: - 화면에 표시되는 모든 데이터 -- `Equitable` 준수 필수 +- `Equatable` 준수 필수 - `@ObservableState` 매크로로 SwiftUI 자동 구독 ### 2. Action - 발생 가능한 모든 이벤트 @@ -348,14 +357,16 @@ case .onAppear: ### 실제 프로젝트에서 Feature 만들기 +아래 구조는 개념 설명용 예시입니다. 실제 production 구현에서는 Interface 모듈을 public boundary로 유지하고, 새로 만들거나 크게 수정하는 Interface 모듈은 One Type Per File을 우선합니다. 기존 `Interface/Sources/Source.swift`는 legacy/compatibility 패턴으로 남아 있을 수 있습니다. + ``` Projects/Feature/Counter/ -├── Interface/Sources/Source.swift # Public API -├── Sources/CounterReducer.swift # 로직 구현 -└── Sources/CounterView.swift # View (internal) +├── Interface/Sources/CounterReducer.swift # Public API 예시 +├── Sources/CounterReducer.swift # 로직 구현 +└── Sources/CounterView.swift # View (internal) ``` -**Interface/Sources/Source.swift**: +**Interface/Sources/CounterReducer.swift**: ```swift import ComposableArchitecture @@ -413,18 +424,14 @@ extension CounterReducer { ### 📚 더 배우기 1. **아키텍처 이해** - - [아키텍처 개요](../Architecture/Overview.md) - 전체 구조 - - [Reducer 패턴](../Architecture/ReducerPattern.md) - Reducer 심화 - - [Dependency Injection](../Architecture/DependencyInjection.md) - 의존성 주입 + - [아키텍처 개요](./Architecture/Overview.md) - 전체 구조와 module boundary + - [구현 체크리스트](./Reference/Checklists.md) - production 구현 전 확인 항목 + - [파일 구조화 규칙](./Reference/FileOrganization.md) - 파일 분리 및 Interface 파일 정책 + - [네이밍 규칙](./Reference/NamingConventions.md) - Action, File 네이밍 2. **실전 가이드** - - [네트워크 통신](./Guides/NetworkGuide.md) - API 호출 + - [네트워크 통신](./Guides/NetworkGuide.md) - API 호출과 TCA Client 패턴 - [NavigationStack](./Guides/NavigationStack.md) - 화면 전환 - - [테스트 작성](./Guides/Testing.md) - Reducer 테스트 - -3. **예제 분석** - - [Auth Feature](./Examples/Auth.md) - 실제 로그인 Feature - - [MainTab Feature](./Examples/MainTab.md) - 탭 구조 ### 🛠️ 직접 해보기 diff --git a/docs/Reference/Checklists.md b/docs/Reference/Checklists.md index 7ea512f5..346069e6 100644 --- a/docs/Reference/Checklists.md +++ b/docs/Reference/Checklists.md @@ -2,9 +2,36 @@ > Feature를 구현할 때 빠뜨리지 말아야 할 항목들 +이 문서는 Feature 구현 시 사용하는 **canonical checklist**입니다. 중복된 축약 체크리스트 대신 이 문서를 기준으로 확인합니다. + +--- + +## 구현 품질 Gate + +비단순 구현을 시작하기 전에 구조가 팀 아키텍처를 기계적으로 따르는 수준을 넘어, 유지보수 가능한 형태인지 확인합니다. + +- [ ] 변경 코드가 올바른 module / layer / feature에 위치하는가? +- [ ] Interface 모듈은 public boundary로 유지되는가? +- [ ] Sources 모듈의 구현 세부사항이 외부로 새지 않는가? +- [ ] 소비자가 implementation Sources가 아니라 Interface 모듈에 의존하는가? +- [ ] 의존성 방향이 역전되거나 순환 의존성을 만들지 않는가? +- [ ] TCA State / Action / Reducer 소유권이 해당 Feature에 명확히 있는가? +- [ ] Side effect는 Dependency와 Effect를 통해 처리되는가? +- [ ] 토큰 접근이 필요한 경우 `TokenManager`를 사용하고, Feature/Reducer/View/일반 Client에서 `TokenStorage`/Keychain에 직접 접근하지 않았는가? +- [ ] Authorization header 또는 token refresh logic을 중복 구현하지 않았는가? +- [ ] public API는 필요한 최소 범위인가? +- [ ] Feature 간 불필요한 coupling을 만들지 않는가? +- [ ] 동일 책임의 Client / Factory / Route / Model을 중복 생성하지 않았는가? +- [ ] 새로운 architecture pattern 또는 예외가 필요하다면 구현 전에 승인을 받았는가? +- [ ] 동작 또는 아키텍처가 바뀌면 관련 문서 업데이트가 필요한지 확인했는가? + +--- + ## Interface 구현 체크리스트 Auth / MainTab / Onboarding은 예외 Feature로 취급되어 이 체크리스트를 강제하지 않습니다. +Interface 모듈은 외부 소비자가 의존하는 public boundary입니다. 새로 만들거나 크게 수정하는 Interface 모듈은 One Type Per File을 우선합니다. 기존 `Interface/Sources/Source.swift` 파일은 legacy/compatibility 패턴으로 유지할 수 있습니다. + ### Reducer - [ ] `@Reducer` 매크로 추가 @@ -36,6 +63,7 @@ Auth / MainTab / Onboarding은 예외 Feature로 취급되어 이 체크리스 ### Client (필요 시) - [ ] `public struct {Domain}Client` 정의 +- [ ] Feature dependency는 struct-based TCA Client를 기본으로 사용 - [ ] 메서드 프로퍼티 정의 (`@Sendable` 클로저) - [ ] `public init` 생성자 정의 - [ ] `TestDependencyKey` extension 추가 @@ -61,6 +89,7 @@ Auth / MainTab / Onboarding은 예외 Feature로 취급되어 이 체크리스 - [ ] `self.init(reducer: Reduce { ... })` 호출 - [ ] 모든 Action에 대한 case 처리 - [ ] State 변경 후 Effect 반환 +- [ ] 비동기/외부 side effect는 Dependency와 Effect를 통해 처리 ### View @@ -92,12 +121,14 @@ Auth / MainTab / Onboarding은 예외 Feature로 취급되어 이 체크리스 ## 문서화 체크리스트 +상세 DocC 기준은 [ProjectRules.md](./ProjectRules.md)를 따릅니다. + ### DocC 주석 -- [ ] public 타입에 `///` 주석 추가 -- [ ] 간단 설명 (1-2문장) -- [ ] `## 사용 예시` 섹션 추가 -- [ ] 코드 블록 (```swift```) 추가 +- [ ] Interface 계층의 public 타입에 `///` 주석 추가 +- [ ] Shared 모듈은 public 타입에 `///` 주석 추가 +- [ ] public 함수는 `## 사용 예시`와 코드 블록 (```swift```) 추가 +- [ ] enum case, 변수/프로퍼티에는 불필요한 문서화 주석을 추가하지 않음 - [ ] 복잡한 로직은 `## 동작 원리` 섹션 추가 ### 예시 @@ -149,30 +180,41 @@ public struct AuthLoginClient { } --- -## 테스트 체크리스트 -현재 단계에서는 테스트 항목을 적용하지 않습니다. +## 테스트 정책 -### Reducer 테스트 +현재 테스트는 일반 요구사항으로 정착되어 있지 않습니다. 테스트 아키텍처를 임의로 만들지 않습니다. -- [ ] `@Test` 함수 작성 -- [ ] `TestStore` 생성 -- [ ] `withDependencies` Mock 주입 -- [ ] `await store.send(action)` - Action 전송 -- [ ] State 변경 검증 -- [ ] `await store.receive(action)` - Effect 응답 검증 -- [ ] 최종 State 검증 +- [ ] 명시적으로 요청받지 않았다면 새 테스트를 만들지 않음 +- [ ] 테스트 타겟 또는 실행 명령이 없으면 테스트를 실행했다고 주장하지 않음 +- [ ] 위험한 로직 변경은 테스트 계획을 제안 +- [ ] 테스트 부재로 검증이 제한되는 경우 결과 보고에 명시 -### Client Mock +### 향후 테스트 추가 시 참고 항목 -- [ ] `mockSuccess` 구현 -- [ ] `mockFailure` 구현 -- [ ] 다양한 시나리오 Mock (필요 시) +- [ ] Reducer 유닛 테스트 (`TestStore`) +- [ ] Client Mock (`mockSuccess`, `mockFailure` 등) +- [ ] Integration 테스트 +- [ ] Preview 시나리오 (Live, Mock, Error, Loading, Empty) --- -## Tuist 프로젝트 설정 체크리스트 +## Tuist 설정 / 생성 체크리스트 + +Tuist는 setup/generation의 canonical tool입니다. `tuist build`는 사용하지 않습니다. CI/PR 수준 검증은 Fastlane을 사용합니다. + +- [ ] 필요한 경우 `tuist install` 실행 +- [ ] 프로젝트 파일 재생성이 필요한 경우 `tuist generate` 실행 +- [ ] regeneration 문제가 있을 때만 `tuist clean` 고려 +- [ ] CI/PR 수준 검증이 필요한 경우 `bundle exec fastlane ios ci_pr` 실행 +- [ ] Bundler를 사용할 수 없는 경우에만 `fastlane ios ci_pr` 사용 +- [ ] direct `xcodebuild` scheme / destination / configuration을 추측하지 않음 +- [ ] `xcodebuild`는 올바른 scheme / destination / configuration이 문서화되었거나 제공된 경우에만 사용 +- [ ] Fastlane 또는 빌드 명령 실행이 불가능한 경우 검증 제한을 결과 보고에 명시 + 현재 단계에서는 테스트/Testing 타겟 추가를 필수로 보지 않습니다. +> Unresolved: `TestDependencyKey`를 Interface에 두는 현재 패턴은 Testing 모듈 분리 원칙과 충돌할 가능성이 있습니다. 새 패턴을 도입하거나 기존 패턴을 변경하기 전에는 owner 확인이 필요합니다. + ### Project.swift - [ ] `.feature(interface:)` 타겟 정의 @@ -208,8 +250,16 @@ public struct AuthLoginClient { } ### 아키텍처 - [ ] Interface/Sources 분리 올바른가? (예외 Feature: Auth / MainTab / Onboarding 제외) +- [ ] Interface가 public boundary로 유지되는가? +- [ ] Sources의 implementation detail이 외부로 노출되지 않는가? +- [ ] 의존성 방향이 올바른가? +- [ ] 올바른 Feature / module / layer에 배치되었는가? - [ ] public/internal 접근 제어자 올바른가? +- [ ] public API가 최소인가? +- [ ] 기존 Client / Factory / Route / Model과 책임이 중복되지 않는가? - [ ] Dependency 올바르게 주입했는가? +- [ ] 토큰 조회/저장/삭제/refresh-state 전환은 `TokenManager`를 통해 수행되는가? +- [ ] `@Dependency(\.tokenStorage)`, `TokenStorageClient`, `TokenStorageProtocol`, `KeychainTokenStorage`, Keychain, UserDefaults를 Feature/Reducer/View/일반 Client에서 직접 사용하지 않았는가? - [ ] Reducer는 순수 함수인가? (Side Effect 없는가?) ### 네이밍 @@ -239,16 +289,19 @@ public struct AuthLoginClient { } --- -## 배포 전 최종 체크리스트 +## 완료 전 최종 체크리스트 -- [ ] 모든 테스트 통과 -- [ ] SwiftLint 경고 없음 +- [ ] CI/PR 수준 검증이 필요한 경우 `bundle exec fastlane ios ci_pr`를 실제로 실행했는가? +- [ ] Bundler를 사용할 수 없는 경우 `fastlane ios ci_pr`로 검증했는가? +- [ ] Fastlane/빌드/테스트 명령을 실행할 수 없는 경우 검증 제한을 보고했는가? +- [ ] 테스트를 만들거나 실행하지 않은 경우 그렇게 명시했는가? - [ ] 불필요한 로그 제거 - [ ] 주석 처리된 코드 제거 - [ ] TODO 주석 확인 -- [ ] DocC 문서 완성 -- [ ] Example 앱 정상 동작 -- [ ] Preview 모두 정상 렌더링 +- [ ] 필요한 DocC 문서 완성 +- [ ] SwiftLint 경고가 증가하지 않았는지 확인 (Tuist-configured script: `Tuist/ProjectDescriptionHelpers/Scripts/SwiftLintScript.swift`) +- [ ] Example 앱/Preview 확인이 필요한 변경인지 판단했는가? +- [ ] 동작 또는 아키텍처 변경 시 관련 문서 업데이트 필요성을 확인했는가? --- diff --git a/docs/Reference/FileOrganization.md b/docs/Reference/FileOrganization.md index 8c088dc7..f5dd600c 100644 --- a/docs/Reference/FileOrganization.md +++ b/docs/Reference/FileOrganization.md @@ -8,6 +8,8 @@ **기본 규칙**: 하나의 파일에는 하나의 주요 타입만 정의합니다. +Interface 모듈도 예외가 아닙니다. Interface 모듈은 public API를 외부에 노출하는 boundary이지만, 이것이 모든 public 타입을 반드시 하나의 `Source.swift` 파일에 모아야 한다는 뜻은 아닙니다. + **목적**: - 파일 이름만으로 내용 파악 가능 - 코드 탐색 및 유지보수 용이 @@ -74,6 +76,19 @@ struct AppRootReducer { **이유**: TCA 표준 패턴이며, State/Action/Reducer는 하나의 단위로 이해되어야 함 +### 4. Interface 모듈은 public boundary + +**Interface 모듈은 외부 소비자가 의존하는 public boundary입니다.** + +- public reducer/state/action, client, factory, dependency key 등 외부 조립에 필요한 계약을 노출합니다. +- Sources 모듈의 View, live 구현, reducer 세부 로직 등 implementation details를 숨깁니다. +- 소비자는 특별한 예외가 없는 한 implementation Sources가 아니라 Interface 모듈에 의존해야 합니다. +- 새로 만들거나 크게 수정하는 Interface 모듈은 One Type Per File을 우선 적용합니다. +- 기존 `Interface/Sources/Source.swift` 파일은 legacy/compatibility 패턴으로 유지할 수 있습니다. +- 기존 모듈을 수정할 때는 주변 패턴을 따르되, public API가 불명확해지거나 architecture boundary를 약화시키면 One Type Per File로 정리합니다. + +이전 문서의 `Interface/Sources/Source.swift` 예시는 “Interface 모듈을 통해 public interface 타입을 노출한다”는 의미였으며, 모든 public 타입을 하나의 파일에 강제한다는 의미가 아닙니다. + --- ## 🎯 분리 vs 유지 결정 기준 @@ -315,20 +330,22 @@ Projects/Core/Logging/Sources/ └── PulseNetworkLogViewProvider.swift ``` -### 3. Protocol/Implementation 패턴 +### 3. Interface/Implementation 패턴 -이미 적용된 Interface/Implementation 분리: +Interface 모듈은 public boundary, Sources 모듈은 implementation layer입니다. 새로 만들거나 크게 수정하는 Interface 모듈은 One Type Per File을 우선합니다. ``` Projects/Core/Network/ ├── Interface/ │ └── Sources/ -│ ├── NetworkProviderProtocol.swift # Protocol 정의 -│ └── NetworkClient.swift # TCA Client +│ ├── NetworkProviderProtocol.swift # public protocol/contract +│ └── NetworkClient.swift # public TCA Client └── Sources/ └── NetworkProvider.swift # 실제 구현 ``` +기존 `Interface/Sources/Source.swift` 파일은 compatibility 목적으로 유지할 수 있지만, 신규 public 타입을 추가할 때는 주변 패턴과 public API 명확성을 함께 고려합니다. + --- ## ⚠️ 주의사항 @@ -422,8 +439,9 @@ import Foundation - [ ] 불필요한 import를 제거했는가? ### 분리 후 -- [ ] `tuist generate` 성공하는가? -- [ ] `tuist build` 성공하는가? +- [ ] 필요한 경우 `tuist generate` 성공하는가? +- [ ] 빌드 검증이 필요한 경우, 알려진 scheme/destination/configuration으로 검증했는가? +- [ ] 빌드 명령을 알 수 없는 경우 검증 제한을 보고했는가? - [ ] 기존 코드가 정상 작동하는가? - [ ] Public API가 변경 없이 작동하는가? - [ ] Git status로 의도한 파일만 변경되었는지 확인했는가? diff --git a/docs/Reference/NamingConventions.md b/docs/Reference/NamingConventions.md index a1bc30d6..4d33d9e0 100644 --- a/docs/Reference/NamingConventions.md +++ b/docs/Reference/NamingConventions.md @@ -72,7 +72,7 @@ public enum Action: BindableAction { case delegate(Delegate) // MARK: - Navigation (Coordinator에서 사용) - case path(StackActionOf) + case routeChanged([FeatureRoute]) } ``` @@ -82,7 +82,7 @@ public enum Action: BindableAction { - **User Action**: 사용자 인터랙션 (`~Tapped`, `~Changed`, `~Selected`) - **Update State**: 상태 업데이트 응답 (`~Completed`, `~Dismissed`) - **Delegate**: 부모에게 전달하는 이벤트 -- **Navigation**: Coordinator의 path 액션 (필요시) +- **Navigation**: Coordinator의 route/path 변경 액션 (필요시). 이 프로젝트는 `[Route]` 배열 기반 NavigationStack 패턴을 사용합니다. - **Child Action**: 자식 Reducer 액션 (필요시) ### Delegate: `delegate(<결과>)` @@ -112,8 +112,16 @@ case notificationReceived(Notification) ### Interface 모듈 +Interface 모듈은 외부 소비자가 의존하는 public boundary입니다. 이전 `Source.swift` 예시는 public interface 타입을 Interface 모듈을 통해 노출한다는 의미였으며, 모든 public 타입을 하나의 파일에 강제한다는 의미가 아닙니다. + +새로 만들거나 크게 수정하는 Interface 모듈은 One Type Per File을 우선합니다. 기존 `Interface/Sources/Source.swift` 파일은 legacy/compatibility 패턴으로 유지할 수 있습니다. + ``` -Interface/Sources/Source.swift # 모든 public 타입 정의 (하나의 파일) +Interface/Sources/{Feature}Reducer.swift # public Reducer / State / Action +Interface/Sources/{Feature}Factory.swift # public ViewFactory 또는 factory +Interface/Sources/{Domain}Client.swift # public TCA Client +Interface/Sources/{Feature}Route.swift # public Route enum (필요 시) +Interface/Sources/Source.swift # 기존 compatibility/re-export 파일 (필요 시) ``` ### Sources 모듈 @@ -142,6 +150,21 @@ Testing/Sources/{Feature}Fixtures.swift # Test Fixtures --- +## 코드 스타일 + +메서드의 매개변수가 2개 이상일 때는 개행하여 가독성을 높입니다. + +```swift +public func example( + a: Int, + b: Int +) -> ReturnType { + // ... +} +``` + +--- + ## 변수/프로퍼티 네이밍 ### State 프로퍼티 diff --git a/docs/Reference/ProjectRules.md b/docs/Reference/ProjectRules.md new file mode 100644 index 00000000..68050484 --- /dev/null +++ b/docs/Reference/ProjectRules.md @@ -0,0 +1,205 @@ +# 프로젝트 규칙 + +> 팀 아키텍처 결정과 공통 구현 규칙을 정리한 canonical reference입니다. + +상세 구현 체크리스트는 [Checklists.md](./Checklists.md)를, 파일 분리 기준은 [FileOrganization.md](./FileOrganization.md)를, 네이밍 규칙은 [NamingConventions.md](./NamingConventions.md)를 함께 확인하세요. + +--- + +## DocC 문서화 기준 + +대상: Core / Domain / Feature / Shared 모듈의 public API + +### 문서화 대상 + +- Interface 계층의 public 타입(struct, enum, class 등): 간단 설명 필수 +- Shared 모듈은 Interface가 없으므로 public 타입에 한해 문서화 필수 +- public 함수: 사용 예시 코드 작성 필수 + +### 문서화 제외 + +- enum case, 변수/프로퍼티: 문서화 주석 작성 안 함 +- App 계층: internal 타입이므로 문서화 불필요 +- Implementation 계층: public이 아닌 한 문서화 불필요 + +### 적용 원칙 + +- 문서화 제외 항목은 불필요한 주석을 추가하지 않습니다. +- public API 타입/함수의 문서화 누락은 규칙 위반입니다. + +--- + +## Feature 조립 규칙 + +상세 구조는 [Architecture/Overview.md](../Architecture/Overview.md)를 따릅니다. + +- 일반 Feature는 Interface / Sources 분리 구조를 유지합니다. +- 외부 모듈은 일반적으로 Interface 모듈에만 의존합니다. +- Sources 모듈은 View, live 구현, reducer 세부 로직 등 implementation details를 숨깁니다. +- Feature Root 또는 App 조립 계층은 필요한 구현체를 조립하고 dependency를 명시적으로 주입합니다. +- Feature Root에서 타입 재노출이 필요할 경우 public boundary를 해치지 않도록 Interface 타입 재노출을 우선합니다. + +### 예외 Feature + +다음 Feature는 App 직접 조립 예외로 문서화되어 있습니다. + +- Auth +- Onboarding +- MainTab + +이 예외 Feature들은 App에서 `makeView(_:)` 없이 직접 조립할 수 있고, 내부 하위 Feature 조립 시 implementation 모듈을 직접 import 할 수 있습니다. + +--- + +## Reducer 생성 규칙 + +- Interface에는 Reducer의 public signature를 둡니다. +- Implementation에서는 실제 `Reduce`를 구성하는 기본 initializer를 제공합니다. +- 다른 Feature에서 Reducer를 사용할 때는 Interface 타입 의존을 우선합니다. + +```swift +@Reducer +public struct CounterReducer { + public let reducer: Reduce + + public init(reducer: Reduce) { + self.reducer = reducer + } + + public var body: some ReducerOf { + reducer + } +} +``` + +```swift +extension CounterReducer { + public init() { + self.init(reducer: Reduce { state, action in + // 실제 로직 + return .none + }) + } +} +``` + +--- + +## ViewFactory 도입 기준 + +ViewFactory는 모든 Feature에 강제하지 않습니다. + +- Flow 단위 Feature가 내부에서만 사용되고 외부 재사용 가능성이 낮으면 Root에서 직접 조립할 수 있습니다. +- 다른 화면/Feature에서 재사용될 하위 기능 단위 Feature는 ViewFactory 또는 동등한 factory를 Interface에 정의하고 Sources에서 live 구현을 제공합니다. +- 예외 Feature(Auth / Onboarding / MainTab)는 별도 조립 규칙을 따릅니다. + +--- + +## 의존성 주입 규칙 + +- Struct + closure + TCA Dependency 스타일을 기본으로 사용합니다. +- 모든 모듈은 TCA Dependency Container를 사용합니다. +- Feature 간 또는 Feature/Domain/Core 간 연결은 가능한 한 Interface 모듈만 import합니다. +- `liveValue`는 Implementation 모듈에서 제공합니다. +- 조립은 App 또는 Feature Root에서 `.withDependency`로 명시합니다. +- Implementation 모듈 내부에서 다른 모듈의 의존성을 임의로 조립하지 않습니다. +- Core/Network, Core/Storage는 singleton 직접 접근 대신 TCA Dependency로 주입 가능한 인스턴스형 구조를 사용합니다. + +--- + +## 외부 의존성 참조 규칙 + +서로 다른 계층(Feature / Domain / Core) 간 참조는 Interface만으로 해결 가능한지 먼저 검증합니다. + +- Interface만으로 해결 가능하면 Interface 의존만 사용합니다. +- Interface만으로 불가능한 경우에만 implementation 의존을 검토하고, 불가능한 이유를 문서화합니다. +- 전체 모듈 참조(예: `.domain`, `.core`)로 대체하는 결정은 지양하며, 구조적 필요성이 명확할 때만 허용합니다. + +--- + +## Token 접근 규칙 + +토큰 접근은 현재 `TokenManager` 패턴을 통해 중재합니다. + +현재 코드베이스 기준 위치: + +- `TokenManager`: `Projects/Domain/Auth/Interface/Sources/TokenManager.swift` +- Token storage interface: `Projects/Core/Storage/Interface/Sources/TokenStorageProtocol.swift` +- Keychain implementation: `Projects/Core/Storage/Sources/KeychainTokenStorage.swift` +- 현재 Authorization header 처리 패턴: `Projects/Domain/Auth/Sources/AuthInterceptor.swift`가 `TokenManager`를 사용 +- 현재 App/root wiring: `Projects/App/Sources/View/TwixApp.swift`에서 live token storage dependency 설정 + +금지: + +- Feature / Reducer / View / 일반 Client에서 `@Dependency(\.tokenStorage)` 직접 사용 +- Feature / Reducer / View / 일반 Client / request-building code에서 `TokenStorageClient`, `TokenStorageProtocol`, `KeychainTokenStorage`, Keychain, UserDefaults 등 token persistence 직접 접근 +- Authorization header 구성을 위해 storage를 직접 읽기 +- Feature client에서 token refresh logic 중복 구현 +- owner 승인 없이 새로운 token/header path 도입 + +허용: + +- `TokenManager` 내부 +- Core Storage interface/implementation +- App/root dependency wiring +- tests/mocks +- `AuthInterceptor`처럼 `TokenManager`에 의존하는 승인된 auth infrastructure + +위 경로는 현재 코드베이스 기준입니다. `TokenManager`의 장기적 모듈 위치를 고정하는 의미는 아닙니다. + +--- + +## SwiftLint 규칙 + +SwiftLint 경고를 가능한 한 최소화합니다. + +- 새로운 코드에서는 SwiftLint 경고가 발생하지 않도록 작성합니다. +- 변경으로 인해 경고가 증가하지 않도록 합니다. +- 불가피한 경우에만 제한적으로 `swiftlint:disable`을 사용하고, 범위를 최소화합니다. +- SwiftLint 실행은 Tuist에 설정된 script를 따릅니다: `Tuist/ProjectDescriptionHelpers/Scripts/SwiftLintScript.swift` +- 별도 standalone SwiftLint 명령을 임의로 만들지 않습니다. + +--- + +## 코드 스타일 규칙 + +메서드의 매개변수가 2개 이상일 때는 개행하여 가독성을 높입니다. + +```swift +public func example( + a: Int, + b: Int +) -> ReturnType { + // ... +} +``` + +--- + +## 검증 정책 + +Tuist는 setup/generation 용도로 사용합니다. + +```bash +tuist install +tuist generate +tuist clean +``` + +- `tuist clean`은 regeneration cleanup이 필요할 때만 사용합니다. +- `tuist build`는 표준 검증 명령이 아닙니다. +- CI/PR 수준 검증은 `bundle exec fastlane ios ci_pr`를 우선 사용합니다. +- Bundler를 사용할 수 없는 경우에만 `fastlane ios ci_pr`를 사용합니다. +- direct `xcodebuild`는 scheme / destination / configuration이 명시적으로 문서화되었거나 제공된 direct-xcodebuild-specific task에서만 사용합니다. +- direct `xcodebuild` 값을 추측하지 않습니다. +- 검증을 실행할 수 없는 경우 결과 보고에 검증 한계를 명시합니다. + +--- + +## Unresolved + +### TestDependencyKey와 Testing 모듈 분리 + +Interface에 `TestDependencyKey`를 두는 현재 패턴은 MFA의 Testing 모듈 분리 원칙과 충돌할 가능성이 있습니다. + +현재 문서와 예제는 `TestDependencyKey`를 Interface에 두는 패턴을 포함하지만, 장기적으로 팀 합의에 따라 Testing 모듈로 대체할 수 있습니다. 새 패턴을 도입하거나 기존 패턴을 변경하기 전에는 owner 확인이 필요합니다. From de25ba16e014ee6d630731aa28847a4e1c386a92 Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Sat, 16 May 2026 11:35:15 +0900 Subject: [PATCH 003/100] =?UTF-8?q?chore:=20Pi=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=8A=A4=ED=82=AC=20=EC=B6=94=EA=B0=80=20-=20#305?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pi/skills/docs-refactor/SKILL.md | 100 ++++++++++ .pi/skills/final-review/SKILL.md | 304 ++++++++++++++++++++++++++++++ .pi/skills/fix-review/SKILL.md | 171 +++++++++++++++++ .pi/skills/review-twix/SKILL.md | 110 +++++++++++ 4 files changed, 685 insertions(+) create mode 100644 .pi/skills/docs-refactor/SKILL.md create mode 100644 .pi/skills/final-review/SKILL.md create mode 100644 .pi/skills/fix-review/SKILL.md create mode 100644 .pi/skills/review-twix/SKILL.md diff --git a/.pi/skills/docs-refactor/SKILL.md b/.pi/skills/docs-refactor/SKILL.md new file mode 100644 index 00000000..ab407e0a --- /dev/null +++ b/.pi/skills/docs-refactor/SKILL.md @@ -0,0 +1,100 @@ +--- +name: docs-refactor +description: Use this skill for documentation refactoring, architecture rule changes, canonical docs cleanup, AGENTS.md updates, and reference cleanup in the Twix iOS repository. +--- + +# docs-refactor + +## 목적 + +프로젝트 문서를 최소 diff로 정리하고, 아키텍처 규칙 변경/정리/이동을 안전하게 수행합니다. + +이 skill은 기존 임시 MODE 1 문서 정리 흐름을 대체합니다. + +## 사용 시점 + +- 문서 중복 제거 +- canonical docs 정리 +- AGENTS.md 업데이트 +- architecture rule 변경 검토 +- stale reference / deleted-file reference 정리 +- compatibility redirect 제거 또는 이동 계획 +- 문서 간 모순 해소 + +## 기준 문서 + +우선순위: + +1. `AGENTS.md` +2. `docs/Architecture/Overview.md` +3. `docs/Reference/ProjectRules.md` +4. `docs/Reference/Checklists.md` +5. `docs/Reference/FileOrganization.md` +6. `docs/Reference/NamingConventions.md` +7. `docs/Guides/NavigationStack.md` +8. `docs/Guides/NetworkGuide.md` +9. `docs/QuickStart.md`는 튜토리얼로만 취급 + +`CLAUDE.md`는 Claude Code 진입점이며 아키텍처 source of truth가 아닙니다. + +## 절차 + +1. 요청 범위를 확인합니다. +2. 관련 문서를 읽습니다. +3. 필요한 경우 `rg`로 참조를 확인합니다. +4. 문서 변경의 기술적 타당성을 먼저 검증합니다. +5. broad documentation edit 전에는 계획을 먼저 제시합니다. +6. 승인 후 최소 diff로 수정합니다. +7. 수정 후 다음을 검증합니다. + - broken links + - stale references + - duplicate docs + - deleted-file references + - unresolved contradictions + - AGENTS.md와 canonical docs 간 불일치 + +## 금지 사항 + +- 스타일만을 위한 대규모 rewrite 금지 +- 아키텍처 규칙 invent 금지 +- source code 수정 금지, 단 사용자가 명시적으로 요청한 경우는 예외 +- implementation skill 생성 금지 +- TypeScript extension 생성 금지 +- `CLAUDE.md`를 아키텍처 source of truth로 취급 금지 + +## 검증 정책 + +- Setup/generation: `tuist install`, `tuist generate`, 필요 시 `tuist clean` +- CI/PR verification: `bundle exec fastlane ios ci_pr` +- Bundler 사용 불가 시: `fastlane ios ci_pr` +- direct `xcodebuild`는 scheme / destination / configuration이 명시적으로 문서화되었거나 제공된 경우에만 사용합니다. +- 검증을 실행할 수 없으면 검증 한계를 보고합니다. + +## 출력 형식 + +한국어로 보고합니다. + +```text +상태: + +검토한 문서: +- + +타당성 판단: +- + +수정 파일: +- + +변경 요약: +- + +삭제/이동/병합한 규칙: +- + +남은 결정 사항: +- + +review-twix에 전달할 영향: +- +``` diff --git a/.pi/skills/final-review/SKILL.md b/.pi/skills/final-review/SKILL.md new file mode 100644 index 00000000..af84d112 --- /dev/null +++ b/.pi/skills/final-review/SKILL.md @@ -0,0 +1,304 @@ +--- +name: final-review +description: Use this skill before opening a PR to run final review, Fastlane CI verification, commit preparation, approved commit execution, and PR draft generation. +--- + +# final-review + +## 목적 + +Twix iOS 저장소에서 PR 생성 전 또는 집중 구현/리팩터링 완료 후 사용하는 pre-PR finalization skill입니다. + +이 skill은 구현 skill이 아닙니다. 기본적으로 scope 점검, 최종 리뷰, 검증, 실패 분석, 커밋 준비/실행, PR 초안 생성을 수행합니다. 명시 요청 없이는 코드를 수정하거나 리팩터링하지 않으며, PR을 열거나 push하지 않습니다. + +## 기준 문서 + +항상 다음 기준을 따릅니다. + +1. `AGENTS.md` +2. `docs/Reference/Checklists.md` +3. `docs/Reference/ProjectRules.md` +4. `.pi/skills/review-twix/SKILL.md`가 있으면 review standard로 사용 + +`review-twix`의 세부 규칙을 이 skill에 중복하지 않습니다. 최종 리뷰 단계에서는 `review-twix` 기준을 적용합니다. + +## 금지 사항 + +- push 금지 +- PR 생성/open 금지 +- force push 금지 +- 명시 요청 없는 amend / rebase / reset / stash 금지 +- 명시 요청 없는 파일 수정 금지 +- 명시 요청 없는 리팩터링 금지 +- 명시 요청 없는 문서 수정 금지 +- 테스트 생성 금지 +- 구현 작업 생성 금지 +- TypeScript extension 생성 금지 +- 실패한 검증 숨김 금지 +- secrets, env files, credentials, 예상하지 못한 generated files 커밋 금지 +- tool 또는 generation source를 식별하는 commit metadata 추가 금지 + +## 검증 정책 + +우선 검증 명령: + +```bash +bundle exec fastlane ios ci_pr +``` + +Bundler를 사용할 수 없는 경우에만 fallback: + +```bash +fastlane ios ci_pr +``` + +- `tuist build`는 표준 검증 명령으로 사용하지 않습니다. +- direct `xcodebuild` scheme / destination / configuration을 invent하지 않습니다. +- direct `xcodebuild`는 명시적으로 문서화되었거나 제공된 경우에만 사용합니다. +- Fastlane을 로컬에서 실행할 수 없으면 정확한 검증 한계를 보고하고, 검증이 통과했다고 말하지 않습니다. + +## 커밋 정책 + +커밋 전 반드시 확인합니다. + +```bash +git branch --show-current +git status --short +git diff --stat +git diff --cached --stat +``` + +- 관련 없는 파일을 커밋에 포함하지 않습니다. +- 파일을 blind stage하지 않습니다. +- unstaged changes가 있으면 staging 전에 요약합니다. +- 이미 staged changes가 있으면 사용자의 staging 의도를 보존합니다. +- staged/unstaged 변경이 여러 commit scope를 암시하면 중단하고 확인합니다. +- review에서 blocking issue가 있으면 커밋하지 않습니다. +- Fastlane 검증 실패 시 커밋하지 않습니다. +- 예상하지 못한 파일, secret, env, credential, generated file 변경이 있으면 커밋하지 않습니다. +- 사용자 승인이 없으면 커밋하지 않습니다. 단, 사용자가 요청에서 명시적으로 커밋까지 지시한 경우는 예외입니다. + +## 커밋 메시지 정책 + +형식: + +```text +: - # +``` + +허용 type: + +- `feat` +- `fix` +- `refactor` +- `docs` +- `test` +- `chore` + +규칙: + +- summary는 간결한 한국어로 작성합니다. +- 사용자가 명시적으로 요청하지 않으면 commit body를 추가하지 않습니다. +- footer, co-author metadata, tool attribution, generation/source marker를 추가하지 않습니다. +- issue number를 invent하지 않습니다. +- 현재 branch name에서 numeric issue suffix를 추출합니다. +- 예: `feat/#302/TWI-86` → commit suffix는 `#302` +- `TWI-86` 같은 Linear issue key는 workflow/integration 용도로 존재할 수 있지만 commit suffix를 대체하지 않습니다. +- 여러 issue-like identifier가 있으면 numeric `#` segment를 우선합니다. +- numeric issue number가 없으면 커밋 전에 사용자에게 issue number를 요청합니다. +- 여러 unrelated changes가 있으면 commit split을 권장합니다. + +Type 선택 기준: + +- `feat`: 사용자에게 보이는 새 기능 +- `fix`: 버그 수정 +- `refactor`: 동작 보존 구조 변경 +- `docs`: 문서만 변경 +- `test`: 테스트만 변경 +- `chore`: 유지보수/config/tooling 변경 + +## Phase 1 — Scope and branch status + +- `AGENTS.md`를 읽습니다. +- 현재 branch name을 확인합니다. +- `git status --short`로 상태를 확인합니다. +- changed files와 diff summary를 확인합니다. +- staged / unstaged / mixed 상태를 구분합니다. +- 다음 정보를 바탕으로 의도된 PR/change scope를 추론합니다. + - branch name + - changed files + - diff summary + - 필요한 경우 existing commit messages +- diff가 하나의 coherent PR scope인지 점검합니다. +- 여러 unrelated scope가 있으면 split commit 또는 split PR을 권장합니다. +- branch name에서 numeric issue suffix를 추출합니다. +- 예: `feat/#302/TWI-86`이면 `#302`를 commit issue suffix로 사용합니다. +- `TWI-86` 같은 Linear key는 workflow/integration metadata로 취급하며 commit suffix로 사용하지 않습니다. +- 다음 경우 중단하고 사용자에게 확인합니다. + - 관련 없거나 위험한 파일이 있음 + - numeric issue number를 찾을 수 없음 + - branch naming이 모호함 + - staged/unstaged 변경이 여러 commit scope를 암시함 + +## Phase 2 — Final review + +- `review-twix` 기준을 적용합니다. +- 사용자가 더 넓은 리뷰를 요청하지 않는 한 changed files / diff만 리뷰합니다. +- 다음 리스크를 확인합니다. + - architecture fit + - TCA / MFA boundaries + - Interface/Sources split + - navigation pattern + - network/client rules + - TokenManager / TokenStorage rules + - dependency direction + - public API growth + - docs impact + - unexpected generated / secrets / env files +- blocking / non-blocking issue를 구분해 보고합니다. +- 명시 요청 없이는 수정하지 않습니다. + +## Phase 3 — Verification + +- 우선 `bundle exec fastlane ios ci_pr`를 실행합니다. +- Bundler를 사용할 수 없는 경우 `fastlane ios ci_pr`를 실행합니다. +- pass/fail을 보고합니다. +- 실행할 수 없으면 정확한 한계를 보고합니다. +- undocumented `xcodebuild` 명령으로 대체하지 않습니다. + +## Phase 3B — Verification failure analysis + +검증이 실패하면 자동으로 커밋 단계로 진행하지 않습니다. + +- failure output을 분석합니다. +- 실패 유형을 분류합니다. + - compile + - lint + - test + - dependency + - signing/provisioning + - script/tooling + - unknown +- 관련 가능성이 높은 파일 또는 모듈을 식별합니다. +- 원인을 다음 중 하나로 구분합니다. + - current diff caused + - environment/tooling + - pre-existing failure + - unknown +- 최소 다음 조치를 제안합니다. +- 명시 요청 없이는 파일을 수정하지 않습니다. +- 실패한 검증을 숨기지 않습니다. +- 적절한 경우 retry command를 포함합니다. + +## Phase 4 — Commit preparation + +- 다음을 확인합니다. + - `git status --short` + - `git diff --stat` + - staged file이 있으면 `git diff --cached --stat` +- 이미 staged files가 있으면 사용자의 staging 의도를 보존합니다. +- 파일을 blind stage하지 않습니다. +- 커밋 대상 파일을 요약합니다. +- 관련 없는 파일을 확인합니다. +- secrets, env files, credentials, 예상하지 못한 generated files를 확인합니다. +- 다음 형식으로 커밋 메시지를 제안합니다. + +```text +: - # +``` + +- 필요 시 split commit을 권장합니다. +- 이미 명시적으로 커밋 권한을 받은 경우가 아니면 커밋 전 승인을 요청합니다. + +## Phase 5 — Commit + +- 승인된 파일만 stage합니다. +- 승인된 메시지로 commit합니다. +- 명시 요청이 없으면 commit body를 추가하지 않습니다. +- footer, 외부 authorship, tool metadata를 추가하지 않습니다. +- amend, rebase, reset, stash, force push, push, PR open은 명시 요청 없이는 수행하지 않습니다. +- 커밋 후 다음을 보고합니다. + - commit hash + - committed files + - verification status + - remaining uncommitted files + +## Phase 6 — PR draft + +PR 제목과 설명 초안을 생성합니다. PR을 열거나 push하지 않습니다. + +PR 초안은 다음을 바탕으로 작성합니다. + +- branch name +- committed 또는 pending diff +- review result +- verification result +- known risks + +PR 제목: + +- 저장소 convention이 명확히 영어를 요구하지 않는 한 간결한 한국어로 작성합니다. + +PR 설명에는 다음을 포함합니다. + +- 요약 +- 변경 사항 +- 검증 결과 +- 리뷰 포인트 +- 리스크 / 후속 작업 +- 관련 이슈 + +관련 이슈: + +- numeric issue suffix가 있으면 `#302`처럼 사용합니다. +- `TWI-86` 같은 Linear key는 branch/workflow 맥락상 유용할 때만 언급하며 numeric issue reference를 대체하지 않습니다. + +## 최종 보고 형식 + +한국어로 작성합니다. + +```text +변경 범위: +- + +브랜치 / 이슈 번호: +- + +Scope 점검 결과: +- + +최종 리뷰 결과: +- + +검증 결과: +- + +검증 실패 분석: +- + +커밋 대상 파일: +- + +제안 커밋 메시지: +- + +커밋 여부: +- + +PR 제목 초안: +- + +PR 설명 초안: +- 요약: +- 변경 사항: +- 검증 결과: +- 리뷰 포인트: +- 리스크 / 후속 작업: +- 관련 이슈: + +남은 리스크: +- + +다음 PR 전 확인 사항: +- +``` diff --git a/.pi/skills/fix-review/SKILL.md b/.pi/skills/fix-review/SKILL.md new file mode 100644 index 00000000..7fddd24d --- /dev/null +++ b/.pi/skills/fix-review/SKILL.md @@ -0,0 +1,171 @@ +--- +name: fix-review +description: Use this skill to apply explicitly approved review-twix findings with minimal diffs, without broad implementation or reinterpretation. +--- + +# fix-review + +## 목적 + +`fix-review`는 `review-twix`가 보고한 finding 중 사용자가 명시적으로 선택하거나 승인한 항목만 최소 diff로 수정합니다. + +이 skill은 review 결과에 대한 후속 실행 skill입니다. broad implementation skill이 아니며, 프로젝트 전체를 독자적으로 재해석하지 않습니다. + +## 다른 skill과의 관계 + +- `review-twix`: 문제를 찾고 기본적으로 보고만 수행합니다. +- `fix-review`: 승인된 review finding만 수정합니다. +- `final-review`: 최종 리뷰, 검증, 커밋 준비/실행 승인, PR 초안 생성을 수행합니다. +- `docs-refactor`: 문서 아키텍처/규칙 변경을 다룹니다. + +문서 전용 finding이 승인된 경우 `docs-refactor`의 지침을 참고할 수 있지만, `docs-refactor` 자체를 중복하지 않습니다. + +## 기준 문서 + +항상 먼저 읽습니다. + +1. `AGENTS.md` + +필요한 경우에만 관련 canonical docs를 추가로 읽습니다. + +- `review-twix` report: primary input +- `docs/Reference/Checklists.md`: implementation checklist +- `docs/Reference/ProjectRules.md`: project rules +- task-specific canonical docs + +`CLAUDE.md`는 architecture source of truth로 취급하지 않습니다. + +## 입력 기대값 + +사용자는 다음 중 하나 이상을 제공하거나 참조해야 합니다. + +- finding ID: 예) `R1`, `R2` +- severity filter: 예) `High only` +- 명시 승인 문구: 예) “fix R1 and R2” +- 허용 scope: 예) docs only, source only, specific files only + +## 기본 동작 + +- finding이 명시적으로 선택되거나 승인되지 않으면 수정하지 않습니다. +- 사용자가 “fix all”이라고 하면, scope가 작고 명확한 경우가 아니면 먼저 finding 요약과 확인 요청을 합니다. +- owner decision이 필요한 finding은 추측하지 않습니다. +- broad refactor가 필요한 finding은 파일을 수정하지 않고 계획을 제안합니다. +- finding이 `AGENTS.md` 또는 canonical docs와 충돌하면 중단하고 보고합니다. + +## 수정 규칙 + +- 최소 diff를 적용합니다. +- 기술적 의미를 보존합니다. +- style-only rewrite를 하지 않습니다. +- 새 architecture pattern을 도입하지 않습니다. +- 명시 승인 없이 public interface를 변경하지 않습니다. +- 명시 요청 없이 테스트를 만들지 않습니다. +- build command를 invent하지 않습니다. +- `TokenStorage`, Keychain, UserDefaults에 직접 접근하지 않습니다. +- `StackState`, `StackActionOf`를 도입하지 않습니다. +- Feature client는 기본적으로 protocol-based로 만들지 않습니다. +- 명시 요청 없이 삭제된 legacy docs를 복원하지 않습니다. +- 관련 없는 파일을 수정하지 않습니다. + +## 금지 사항 + +- general implementation 수행 금지 +- final-review 대체 금지 +- commit 금지 +- push 금지 +- PR 생성 금지 +- TypeScript extension 생성 금지 +- 승인되지 않은 finding opportunistic fix 금지 + +## Workflow + +### Phase 1 — Parse approved findings + +- 사용자가 선택한 review finding ID를 식별합니다. +- 허용 파일/scope를 식별합니다. +- 각 finding을 다음 중 하나로 분류합니다. + - documentation fix + - source code fix + - example code fix + - verification/config fix + - owner-decision required + - broad refactor +- 승인 scope가 모호하면 중단하고 확인합니다. + +### Phase 2 — Plan fixes + +- 승인된 각 finding별 최소 수정안을 제안합니다. +- 변경 예상 파일을 나열합니다. +- 위험을 식별합니다. +- 3개 초과 파일 변경 또는 broad refactor가 필요하면 편집 전에 확인을 요청합니다. + +### Phase 3 — Apply fixes + +- 승인된 finding 해결에 필요한 파일만 수정합니다. +- diff를 focused하게 유지합니다. +- 기존 convention을 보존합니다. +- 승인되지 않은 finding은 함께 수정하지 않습니다. + +### Phase 4 — Self-check + +- 수정한 finding을 다시 점검합니다. +- 원래 문제가 해결되었는지 확인합니다. +- 금지 패턴이 도입되지 않았는지 확인합니다. +- 관련 없는 파일이 변경되지 않았는지 확인합니다. + +### Phase 5 — Report + +한국어로 보고합니다. + +포함 항목: + +- 수정한 finding +- 수정하지 않은 finding과 이유 +- 변경 파일 +- 변경 요약 +- 자체 점검 결과 +- 검증 결과 / 검증 한계 +- 후속으로 `review-twix`에 다시 맡길 항목 + +## 검증 정책 + +Fastlane은 기본 실행하지 않습니다. + +검증 요청이 있으면 다음을 사용합니다. + +```bash +bundle exec fastlane ios ci_pr +``` + +Bundler를 사용할 수 없는 경우에만 fallback을 사용합니다. + +```bash +fastlane ios ci_pr +``` + +`tuist build`를 사용하지 않습니다. `xcodebuild` scheme/destination/configuration을 invent하지 않습니다. + +## 출력 형식 + +```text +수정한 finding: +- + +수정하지 않은 finding과 이유: +- + +변경 파일: +- + +변경 요약: +- + +자체 점검 결과: +- + +검증 결과 / 검증 한계: +- + +후속으로 review-twix에 다시 맡길 항목: +- +``` diff --git a/.pi/skills/review-twix/SKILL.md b/.pi/skills/review-twix/SKILL.md new file mode 100644 index 00000000..4608b557 --- /dev/null +++ b/.pi/skills/review-twix/SKILL.md @@ -0,0 +1,110 @@ +--- +name: review-twix +description: Use this skill for Twix iOS code, diff, PR, and architecture compliance review. Default behavior is report-only unless edits are explicitly requested. +--- + +# review-twix + +## 목적 + +Twix iOS 코드, diff, PR, 문서 변경을 아키텍처/TCA/MFA 관점에서 리뷰합니다. + +이 skill은 기존 임시 MODE 2 코드/아키텍처 리뷰 흐름을 대체합니다. 기본 동작은 **보고 전용**이며, 명시 요청 없이는 파일을 수정하지 않습니다. + +## 사용 시점 + +- PR 리뷰 +- 구현 전 아키텍처 적합성 검토 +- 구현 후 리스크 점검 +- 코드가 canonical docs를 따르는지 확인 +- docs-refactor 또는 향후 구현 작업에 넘길 구조화된 발견 사항 생성 + +## 기준 문서 + +우선순위: + +1. `AGENTS.md` +2. `docs/Architecture/Overview.md` +3. `docs/Reference/ProjectRules.md` +4. `docs/Reference/Checklists.md` +5. `docs/Reference/FileOrganization.md` +6. `docs/Reference/NamingConventions.md` +7. `docs/Guides/NavigationStack.md` +8. `docs/Guides/NetworkGuide.md` +9. `docs/QuickStart.md`는 튜토리얼로만 취급 + +`CLAUDE.md`는 Claude Code 진입점이며 아키텍처 source of truth가 아닙니다. + +## 리뷰 항목 + +- Clean Architecture boundary +- MFA Interface/Sources split +- dependency direction +- 올바른 module / feature / layer 배치 +- One Type Per File 기본 원칙 +- TCA State / Action / Reducer ownership +- side effects through dependencies and Effects +- minimal public API +- duplicate clients / factories / routes / models +- struct-based TCA Clients by default +- protocol overgeneration 금지 +- `[Route]` 배열 NavigationStack 패턴 +- `StackState` / `StackActionOf` recommended usage 금지 +- TokenManager / TokenStorage rule +- direct Keychain / UserDefaults / token persistence access 금지 +- duplicated Authorization/header/refresh logic 금지 +- testing/build verification limits + +## 검증 정책 + +- Setup/generation: `tuist install`, `tuist generate`, 필요 시 `tuist clean` +- CI/PR verification: `bundle exec fastlane ios ci_pr` +- Bundler 사용 불가 시: `fastlane ios ci_pr` +- direct `xcodebuild`는 scheme / destination / configuration이 명시적으로 문서화되었거나 제공된 경우에만 사용합니다. +- 검증을 실행할 수 없으면 검증 한계를 보고합니다. + +## 금지 사항 + +- 명시 요청 없이 파일 수정 금지 +- 자동 refactor 금지 +- 아키텍처 규칙 invent 금지 +- implementation skill 생성 금지 +- TypeScript extension 생성 금지 +- `CLAUDE.md`를 아키텍처 source of truth로 취급 금지 + +## 출력 형식 + +한국어로 보고합니다. + +```text +리뷰 범위: +- + +적용한 규칙: +- + +발견한 문제: +- ID: + 심각도: + 파일: + 위치: + 규칙/문서: + 문제: + 영향도: + 권장 수정: + +바로 수정 가능한 항목: +- + +확인 필요한 항목: +- + +검증 결과 / 검증 한계: +- + +docs-refactor에 전달할 문서 이슈: +- + +향후 구현 작업에 전달할 수정 후보: +- +``` From 5b2baa75017039fb38acd262e9e4bf2186ec0a9e Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Sat, 16 May 2026 11:35:27 +0900 Subject: [PATCH 004/100] =?UTF-8?q?chore:=20Claude=20handoff=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EB=8F=84=EA=B5=AC=20=EC=B6=94=EA=B0=80=20-=20#305?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 +- .pi/skills/handoff-twix/SKILL.md | 377 +++++++++++++++++++++++++++ Scripts/run-claude-implementation.sh | 68 +++++ Scripts/smoke-test-claude-handoff.sh | 128 +++++++++ 4 files changed, 577 insertions(+), 1 deletion(-) create mode 100644 .pi/skills/handoff-twix/SKILL.md create mode 100755 Scripts/run-claude-implementation.sh create mode 100755 Scripts/smoke-test-claude-handoff.sh diff --git a/.gitignore b/.gitignore index 83a9476e..53137686 100644 --- a/.gitignore +++ b/.gitignore @@ -176,4 +176,7 @@ Icon Network Trash Folder Temporary Items .apdisk -src/SupportingFiles/Booket/GoogleService-Info.plist \ No newline at end of file +src/SupportingFiles/Booket/GoogleService-Info.plist + +# Temporary files AI agent uses +.agent/handoff \ No newline at end of file diff --git a/.pi/skills/handoff-twix/SKILL.md b/.pi/skills/handoff-twix/SKILL.md new file mode 100644 index 00000000..d8625f7f --- /dev/null +++ b/.pi/skills/handoff-twix/SKILL.md @@ -0,0 +1,377 @@ +--- +name: handoff-twix +description: Use this skill to coordinate concise low-token handoffs between Pi, Claude Code, review-twix, fix-review, and final-review in the Twix iOS repository. +--- + +# handoff-twix + +## 목적 + +`handoff-twix`는 Pi, Claude Code, `review-twix`, `fix-review`, `final-review` 사이의 low-token handoff를 조율합니다. + +이 skill은 implementation skill이 아닙니다. 명시 요청이 없는 한 Pi가 feature를 직접 구현하지 않습니다. Pi가 계획과 handoff 파일을 만들고, Claude Code가 구현하며, Pi가 review/fix/finalize를 이어갈 수 있도록 간결한 handoff 파일과 runner를 사용합니다. + +## Implementation agent + +Claude Code가 고정 implementation agent입니다. + +- Preferred runner: `Scripts/run-claude-implementation.sh` +- Claude invocation: `claude -p` +- `--bare`는 현재 사용하지 않습니다. +- timeout은 사용하지 않습니다. +- budget은 `--max-budget-usd 5.00`입니다. +- permission mode는 `--permission-mode acceptEdits`입니다. +- manual handoff는 Claude runner를 사용할 수 없거나 사용자가 명시적으로 선택한 경우의 fallback입니다. + +Codex는 이 skill의 implementation orchestration 대상이 아닙니다. + +Claude implementation은 Fastlane/build verification을 실행하지 않습니다. Pi의 `final-review`가 verification, commit, PR draft를 담당합니다. + +## Primary workflow + +기본 동작은 end-to-end handoff입니다. 사용자가 전체 handoff를 요청하면 plan 작성부터 Claude Code handoff, 구현 후 review, 승인된 safe fix, 필요 시 final-review handoff까지 한 흐름으로 조율합니다. + +1. Pi가 concise implementation plan을 만듭니다. +2. Pi가 Claude Code용 handoff 파일을 작성합니다. +3. Claude Code가 runner 또는 manual handoff로 구현합니다. +4. Claude Code가 concise implementation result 파일을 작성합니다. +5. Pi가 `git diff`를 source of truth로 삼아 `review-twix` 기준으로 결과를 리뷰합니다. +6. 요청/승인 시 Pi가 `fix-review` 기준으로 승인된 safe finding만 수정합니다. +7. 요청 시 Pi가 `final-review`로 verification, commit, PR draft를 넘깁니다. + +개별 Mode A-E는 명시적으로 요청할 때 사용하는 partial workflow입니다. 전체 흐름을 진행하려면 Default workflow를 우선합니다. + +## 기준 문서 + +항상 먼저 읽습니다. + +1. `AGENTS.md` + +필요한 경우에만 canonical docs를 읽습니다. + +- `docs/Reference/Checklists.md` +- `docs/Reference/ProjectRules.md` +- `docs/Guides/NavigationStack.md` +- `docs/Guides/NetworkGuide.md` +- task-specific canonical docs + +규칙: + +- 긴 docs 내용을 handoff 파일에 복사하지 않습니다. +- handoff 파일은 canonical docs를 path로 참조합니다. +- `CLAUDE.md`를 architecture source of truth로 취급하지 않습니다. +- 삭제된 `Prompt.md`를 사용하지 않습니다. +- 구현 후에는 `git diff`를 source of truth로 사용합니다. +- `AGENTS.md` 또는 project docs를 길게 재진술하지 않습니다. + +## Handoff directory + +사용 경로: + +```text +.agent/handoff/ +``` + +예상 파일: + +```text +.agent/handoff/PLAN.md +.agent/handoff/IMPLEMENTATION_REQUEST.md +.agent/handoff/IMPLEMENTATION_RESULT.md +.agent/handoff/REVIEW_REPORT.md +.agent/handoff/FIX_REPORT.md +.agent/handoff/claude.out +.agent/handoff/claude.err +``` + +## 공통 원칙 + +- handoff 파일은 간결하게 유지합니다. +- 파일을 작성했다면 chat에는 큰 plan을 출력하지 않습니다. +- verbose chat output보다 structured handoff file을 우선합니다. +- full diff를 출력하지 않습니다. 요청 시에만 출력합니다. +- 다른 agent가 같은 session context를 갖고 있다고 가정하지 않습니다. +- file path와 concise instruction만 전달합니다. + +## Default workflow — End-to-end handoff + +사용 시점: 사용자가 full handoff process를 요청할 때. + +Process: + +1. `.agent/handoff/PLAN.md`를 작성합니다. +2. `.agent/handoff/IMPLEMENTATION_REQUEST.md`를 작성합니다. +3. Claude Code implementation 방식을 결정합니다. + - preferred: `Scripts/run-claude-implementation.sh` + - fallback: manual handoff to Claude Code +4. runner 실행이 요청된 경우: + - `Scripts/run-claude-implementation.sh`를 사용합니다. + - twix-gate confirmation을 존중합니다. + - runner는 `claude -p`를 사용합니다. + - runner는 `--bare`를 사용하지 않습니다. + - runner는 timeout을 사용하지 않습니다. + - runner는 budget `5.00 USD`를 사용합니다. + - runner는 Claude에게 Read/Edit/Write와 limited read-only bash만 허용합니다. + - runner는 git add/commit/push, Fastlane, xcodebuild, `tuist clean`, destructive commands를 금지합니다. +5. manual handoff가 선택된 경우: + - handoff 파일 작성 후 중단합니다. + - Claude Code에게 전달할 정확하고 간결한 instruction을 제공합니다. +6. Claude implementation 완료 후: + - 있으면 `.agent/handoff/IMPLEMENTATION_RESULT.md`를 읽습니다. + - result file에 `STATUS: DONE | BLOCKED | NO_CHANGES | FAILED` 중 하나가 있는지 확인합니다. + - `git diff`를 inspect합니다. + - `git diff`를 source of truth로 사용합니다. +7. `review-twix` standard를 실행합니다. +8. `.agent/handoff/REVIEW_REPORT.md`를 작성합니다. +9. finding이 있으면: + - blocking / non-blocking으로 분류합니다. + - `fix-review`로 안전하게 수정 가능한지 분류합니다. +10. 사용자가 safe finding 자동 수정을 이미 승인한 경우: + - 승인되고 safe로 분류된 finding만 `fix-review`로 수정합니다. + - `.agent/handoff/FIX_REPORT.md`를 작성합니다. +11. auto-fix 승인이 없으면: + - 중단하고 어떤 finding을 수정할지 질문합니다. +12. 요청된 경우 `final-review`로 hand off합니다. +13. `handoff-twix` 내부에서는 commit, push, PR 생성을 하지 않습니다. + +Important approval policy: + +- Plan approval은 commit approval이 아닙니다. +- Handoff execution approval은 git commit approval이 아닙니다. +- Claude runner 실행 approval은 git commit approval이 아닙니다. +- `fix-review` approval은 `final-review` commit approval과 별개입니다. +- broad refactor, owner-decision finding, public API change, risky architecture change는 approval을 위해 중단합니다. + +## Claude runner behavior + +`Scripts/run-claude-implementation.sh`는 repository root에서 실행합니다. + +Runner responsibilities: + +- `.agent/handoff/IMPLEMENTATION_REQUEST.md` 존재 확인 +- `.agent/handoff/` 생성 보장 +- `uuidgen`이 있으면 UUID session id 생성, 없으면 timestamp fallback 사용 +- Claude stdout을 `.agent/handoff/claude.out`에 저장 +- Claude stderr를 `.agent/handoff/claude.err`에 저장 +- Claude 종료 후 `.agent/handoff/IMPLEMENTATION_RESULT.md` 존재 확인 +- result file에 `STATUS:` line이 있는지 확인 +- session id, exit code, result status, output files를 짧게 출력 +- Claude 실패, result file 누락, `STATUS:` 누락 시 non-zero 반환 + +Claude must write: + +```text +.agent/handoff/IMPLEMENTATION_RESULT.md +``` + +Required result status: + +```text +STATUS: DONE | BLOCKED | NO_CHANGES | FAILED +``` + +## Partial workflows / explicit subcommands + +아래 Mode A-E는 사용자가 명시적으로 요청할 때 사용하는 optional partial workflow입니다. 정상적인 필수 순서가 아니며, full handoff 요청에는 Default workflow를 우선 적용합니다. + +예시 요청: + +- “Run full handoff with Claude Code” +- “Create handoff files only” +- “Run Claude implementation runner” +- “Continue after Claude implementation” +- “Review implementation result” +- “Apply approved fixes” +- “Proceed to final-review” + +## Mode A — Create Claude implementation handoff + +사용 시점: 사용자가 Claude Code용 작업 계획 파일만 만들라고 요청할 때. + +Process: + +1. `AGENTS.md`를 읽습니다. +2. 관련 파일만 inspect합니다. +3. architecture fit을 식별합니다. +4. `.agent/handoff/PLAN.md`를 작성합니다. +5. `.agent/handoff/IMPLEMENTATION_REQUEST.md`를 작성합니다. +6. 구현하지 않습니다. +7. 최종 chat output은 짧게 작성 파일만 알립니다. + +`PLAN.md` 포함 항목: + +- Goal +- Scope +- Relevant canonical docs +- Expected files or modules +- Architecture constraints +- Forbidden patterns +- Verification expectation +- Open questions + +`IMPLEMENTATION_REQUEST.md` 포함 항목: + +- `AGENTS.md` 먼저 읽기 +- `PLAN.md` 읽기 +- minimal diffs로 구현 +- 구현 후 `IMPLEMENTATION_RESULT.md` 작성 +- `IMPLEMENTATION_RESULT.md`에 `STATUS: DONE | BLOCKED | NO_CHANGES | FAILED` 포함 +- 긴 설명 출력 금지 +- `StackState`/`StackActionOf` 사용 금지 +- `TokenStorage` 직접 접근 금지 +- `xcodebuild` command invent 금지 +- git add/commit/push 금지 +- Fastlane/build verification 실행 금지 +- `tuist clean` 실행 금지 + +## Mode B — Run or prepare Claude implementation + +사용 시점: 사용자가 handoff를 기반으로 Claude Code 구현을 실행하거나 manual handoff instruction을 원할 때. + +Process: + +1. `IMPLEMENTATION_REQUEST.md`를 prompt source로 사용합니다. +2. preferred runner는 `Scripts/run-claude-implementation.sh`입니다. +3. runner 실행은 명시 요청과 twix-gate confirmation을 필요로 합니다. +4. runner를 사용할 수 없으면 manual handoff instruction을 제공합니다. +5. unknown Claude Code command를 hardcode하지 않습니다. 이 repo의 runner가 canonical command입니다. + +## Mode C — Review implementation result + +사용 시점: 사용자가 Claude Code 작업이 끝났고 review만 이어서 하라고 말할 때. + +Process: + +1. 있으면 `.agent/handoff/IMPLEMENTATION_RESULT.md`를 읽습니다. +2. `STATUS:` line을 확인합니다. +3. `git diff`를 inspect합니다. +4. 변경 diff에 `review-twix` standard를 적용합니다. +5. `.agent/handoff/REVIEW_REPORT.md`를 작성합니다. +6. 요청이 없으면 긴 review를 chat에 출력하지 않습니다. +7. blocking issue가 있으면 짧은 요약을 보고하고 `fix-review` 실행 여부를 묻습니다. + +## Mode D — Apply approved fixes + +사용 시점: 사용자가 review finding 수정을 승인했을 때. + +Process: + +1. `REVIEW_REPORT.md`를 primary input으로 사용합니다. +2. `fix-review` standard를 적용합니다. +3. 승인된 finding만 수정합니다. +4. `.agent/handoff/FIX_REPORT.md`를 작성합니다. +5. commit하지 않습니다. + +## Mode E — Finalize + +사용 시점: 사용자가 PR finalization으로 진행하라고 요청할 때. + +Process: + +1. `final-review`로 hand off합니다. +2. `final-review`가 verification, commit, PR draft를 처리합니다. +3. `final-review` workflow를 중복하지 않습니다. + +## Token efficiency rules + +- chat output을 짧게 유지합니다. +- file output을 우선합니다. +- 요청 없이 full diff를 출력하지 않습니다. +- end-to-end mode에서는 stage summary와 file path만 chat에 표시합니다. +- 요청 없이 full `PLAN.md`, full `REVIEW_REPORT.md`, full `IMPLEMENTATION_RESULT.md`, full diff를 출력하지 않습니다. +- `AGENTS.md` 또는 docs 전문을 붙여넣지 않습니다. +- 다른 agent에게 긴 summary 출력을 요구하지 않습니다. +- Claude Code는 verbose chat reply 대신 result file을 작성하도록 요청합니다. + +## External agent rules + +- Claude Code만 implementation agent로 사용합니다. +- Preferred execution path는 `Scripts/run-claude-implementation.sh`입니다. +- Manual Claude Code handoff는 fallback입니다. +- 사용자가 달리 말하지 않는 한 Pi가 review/fix/final-review 책임을 유지합니다. +- Claude Code가 같은 session context를 갖고 있다고 가정하지 않습니다. +- file path와 concise instruction만 전달합니다. + +## Safety + +- twix-gate approval gate를 존중합니다. +- 명시 사용자 승인 없이 Claude runner를 실행하지 않습니다. +- commit하지 않습니다. +- push하지 않습니다. +- PR을 열지 않습니다. +- permission을 우회하지 않습니다. +- dangerous auto-approval flag를 사용하지 않습니다. +- Claude를 full-auto/yolo/bypass mode로 실행하지 않습니다. +- plan/handoff/fix approval을 final-review commit approval로 간주하지 않습니다. +- Claude implementation은 verification/commit/PR draft를 수행하지 않습니다. + +## 출력 형식 + +한국어로 보고합니다. + +### End-to-end mode + +```text +진행 단계: +- + +작성/갱신한 handoff 파일: +- + +구현 agent: +- Claude Code + +리뷰 결과: +- + +자동 수정: +- + +다음 단계: +- +``` + +### Mode A + +```text +작성한 handoff 파일: +- + +Claude Code에게 줄 다음 명령: +- + +열린 질문: +- + +토큰 절약 방식: +- +``` + +### Mode C + +```text +리뷰 결과 요약: +- + +REVIEW_REPORT.md 위치: +- + +blocking 이슈: +- + +fix-review 실행 여부 질문: +- +``` + +### Mode D + +```text +수정한 finding: +- + +FIX_REPORT.md 위치: +- + +남은 이슈: +- +``` diff --git a/Scripts/run-claude-implementation.sh b/Scripts/run-claude-implementation.sh new file mode 100755 index 00000000..3dcb2279 --- /dev/null +++ b/Scripts/run-claude-implementation.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$REPO_ROOT" + +HANDOFF_DIR=".agent/handoff" +REQUEST_FILE="$HANDOFF_DIR/IMPLEMENTATION_REQUEST.md" +RESULT_FILE="$HANDOFF_DIR/IMPLEMENTATION_RESULT.md" +OUT_FILE="$HANDOFF_DIR/claude.out" +ERR_FILE="$HANDOFF_DIR/claude.err" + +if [[ ! -f "$REQUEST_FILE" ]]; then + echo "Missing required file: $REQUEST_FILE" >&2 + exit 1 +fi + +mkdir -p "$HANDOFF_DIR" + +if command -v uuidgen >/dev/null 2>&1; then + SESSION_ID="$(uuidgen)" +else + SESSION_ID="$(date +%Y%m%d%H%M%S)" +fi + +set +e +claude -p \ + --session-id "$SESSION_ID" \ + --no-session-persistence \ + --permission-mode acceptEdits \ + --allowedTools "Read Edit Write Bash(git diff*) Bash(git status*) Bash(git log*) Bash(git branch*) Bash(rg *) Bash(find *) Bash(ls *) Bash(pwd)" \ + --disallowedTools "Bash(git push*) Bash(git add*) Bash(git commit*) Bash(rm -rf*) Bash(git reset --hard*) Bash(git clean*) Bash(sudo*) Bash(xcodebuild*) Bash(fastlane*) Bash(bundle exec fastlane*) Bash(tuist clean*)" \ + --max-budget-usd 5.00 \ + --output-format json \ + --append-system-prompt "Keep stdout concise. Do not stage files, commit, push, open PRs, run full CI, run xcodebuild, run Fastlane, or run tuist clean. Write .agent/handoff/IMPLEMENTATION_RESULT.md with STATUS: DONE, BLOCKED, NO_CHANGES, or FAILED." \ + < "$REQUEST_FILE" \ + > "$OUT_FILE" \ + 2> "$ERR_FILE" +CLAUDE_EXIT_CODE=$? +set -e + +RESULT_STATUS="" +if [[ -f "$RESULT_FILE" ]]; then + RESULT_STATUS="$(grep -E '^STATUS:' "$RESULT_FILE" | head -n 1 | sed 's/^STATUS:[[:space:]]*//')" +fi + +echo "Claude implementation summary:" +echo "- session id: $SESSION_ID" +echo "- exit code: $CLAUDE_EXIT_CODE" +echo "- result status: ${RESULT_STATUS:-MISSING}" +echo "- output files:" +echo " - $OUT_FILE" +echo " - $ERR_FILE" +echo " - $RESULT_FILE" + +if [[ $CLAUDE_EXIT_CODE -ne 0 ]]; then + exit "$CLAUDE_EXIT_CODE" +fi + +if [[ ! -f "$RESULT_FILE" ]]; then + echo "Missing required result file: $RESULT_FILE" >&2 + exit 1 +fi + +if [[ -z "$RESULT_STATUS" ]]; then + echo "Missing STATUS line in $RESULT_FILE" >&2 + exit 1 +fi diff --git a/Scripts/smoke-test-claude-handoff.sh b/Scripts/smoke-test-claude-handoff.sh new file mode 100755 index 00000000..6ddc052f --- /dev/null +++ b/Scripts/smoke-test-claude-handoff.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$REPO_ROOT" + +HANDOFF_DIR=".agent/handoff" +REQUEST_FILE="$HANDOFF_DIR/IMPLEMENTATION_REQUEST.md" +RESULT_FILE="$HANDOFF_DIR/IMPLEMENTATION_RESULT.md" +OUT_FILE="$HANDOFF_DIR/claude.out" +ERR_FILE="$HANDOFF_DIR/claude.err" +TARGET_FILE="$HANDOFF_DIR/SMOKE_TEST_TARGET.md" +RUNNER="Scripts/run-claude-implementation.sh" + +mkdir -p "$HANDOFF_DIR" + +if [[ ! -x "$RUNNER" ]]; then + echo "실패: runner가 없거나 실행 권한이 없습니다: $RUNNER" >&2 + exit 1 +fi + +status_without_handoff() { + git status --porcelain --untracked-files=all \ + | grep -vE '^(.. )?\.agent/handoff/' \ + || true +} + +BEFORE_STATUS="$(status_without_handoff)" +BEFORE_DIFF_HASH="$(git diff -- . ':(exclude).agent/handoff/**' | shasum | awk '{print $1}')" + +rm -f \ + "$REQUEST_FILE" \ + "$RESULT_FILE" \ + "$OUT_FILE" \ + "$ERR_FILE" \ + "$TARGET_FILE" + +cat > "$REQUEST_FILE" <<'REQUEST' +Read AGENTS.md first. + +This is a smoke test for the Pi → Claude Code handoff runner. + +Allowed change: +- Create or update only .agent/handoff/SMOKE_TEST_TARGET.md +- Write exactly this single line to it: + smoke test completed + +Required result file: +- Write .agent/handoff/IMPLEMENTATION_RESULT.md +- Include exactly one STATUS line: + STATUS: DONE + +Do not edit any other files. +Do not modify source code, docs, tests, AGENTS.md, CLAUDE.md, or skill files. +Do not run build/test/Fastlane/xcodebuild/tuist. +Do not git add, git commit, or git push. +Keep output minimal. +REQUEST + +RUNNER_EXIT=0 +set +e +"$RUNNER" +RUNNER_EXIT=$? +set -e + +SMOKE_FAILED=0 +FAIL_REASONS=() + +if [[ $RUNNER_EXIT -ne 0 ]]; then + SMOKE_FAILED=1 + FAIL_REASONS+=("runner exit code가 0이 아닙니다: $RUNNER_EXIT") +fi + +if [[ ! -f "$TARGET_FILE" ]]; then + SMOKE_FAILED=1 + FAIL_REASONS+=("SMOKE_TEST_TARGET.md가 생성되지 않았습니다") +fi + +if [[ ! -f "$RESULT_FILE" ]]; then + SMOKE_FAILED=1 + FAIL_REASONS+=("IMPLEMENTATION_RESULT.md가 생성되지 않았습니다") +elif ! grep -qE '^STATUS: DONE$' "$RESULT_FILE"; then + SMOKE_FAILED=1 + FAIL_REASONS+=("IMPLEMENTATION_RESULT.md에 'STATUS: DONE'이 없습니다") +fi + +if [[ ! -f "$OUT_FILE" ]]; then + SMOKE_FAILED=1 + FAIL_REASONS+=("claude.out이 생성되지 않았습니다") +fi + +if [[ ! -f "$ERR_FILE" ]]; then + SMOKE_FAILED=1 + FAIL_REASONS+=("claude.err이 생성되지 않았습니다") +fi + +AFTER_STATUS="$(status_without_handoff)" +AFTER_DIFF_HASH="$(git diff -- . ':(exclude).agent/handoff/**' | shasum | awk '{print $1}')" + +if [[ "$BEFORE_STATUS" != "$AFTER_STATUS" || "$BEFORE_DIFF_HASH" != "$AFTER_DIFF_HASH" ]]; then + SMOKE_FAILED=1 + FAIL_REASONS+=(".agent/handoff/ 밖의 git 상태 또는 tracked diff가 변경되었습니다") +fi + +if [[ $SMOKE_FAILED -eq 0 ]]; then + echo "성공: Claude handoff runner smoke test 통과" +else + echo "실패: Claude handoff runner smoke test 실패" >&2 + for reason in "${FAIL_REASONS[@]}"; do + echo "- $reason" >&2 + done +fi + +echo "생성된 파일:" +echo "- $REQUEST_FILE" +echo "- $TARGET_FILE" +echo "- $RESULT_FILE" +echo "- $OUT_FILE" +echo "- $ERR_FILE" +echo "runner exit result: $RUNNER_EXIT" +echo "다음 수동 확인 명령:" +echo "- git status --short" +echo "- git diff --name-only" +echo "- sed -n '1,80p' $RESULT_FILE" + +if [[ $SMOKE_FAILED -ne 0 ]]; then + exit 1 +fi From efb61a5f2047a8e0e6c630ea2d6f8606ff5cbf95 Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Sat, 16 May 2026 11:35:31 +0900 Subject: [PATCH 005/100] =?UTF-8?q?chore:=20Pi=20=EC=9E=91=EC=97=85=20?= =?UTF-8?q?=EC=8A=B9=EC=9D=B8=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20#305?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pi/extensions/twix-gate.ts | 341 ++++++++++++++++++++++++++++++++++++ 1 file changed, 341 insertions(+) create mode 100644 .pi/extensions/twix-gate.ts diff --git a/.pi/extensions/twix-gate.ts b/.pi/extensions/twix-gate.ts new file mode 100644 index 00000000..81862178 --- /dev/null +++ b/.pi/extensions/twix-gate.ts @@ -0,0 +1,341 @@ +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import path from "node:path"; + +type RiskLevel = "safe" | "moderate" | "sensitive" | "commit" | "blocked"; + +type Decision = + | { level: "safe" } + | { + level: "moderate" | "sensitive" | "commit"; + title: string; + message: string; + cacheKey?: string; + } + | { level: "blocked"; reason: string }; + +const EXTRA_SENSITIVE_PATHS = [ + "AGENTS.md", + "CLAUDE.md", + "docs/Reference/ProjectRules.md", + "docs/Reference/Checklists.md", + "docs/Architecture/Overview.md", + ".pi/skills/", + ".pi/extensions/", + "Projects/Domain/Auth/Interface/Sources/TokenManager.swift", + "Projects/Core/Storage/Interface/Sources/TokenStorageProtocol.swift", + "Projects/Core/Storage/Sources/KeychainTokenStorage.swift", + "Projects/Domain/Auth/Sources/AuthInterceptor.swift", + "fastlane/Fastfile", + "fastlane/README.md", + "Tuist/", + "Tuist.swift", + "Workspace.swift", +]; + +export default function (pi: ExtensionAPI) { + const sessionApproved = new Set(); + let executionApproved = false; + + pi.on("tool_call", async (event, ctx) => { + let decision: Decision = { level: "safe" }; + + if (event.toolName === "read") { + return undefined; + } + + if (event.toolName === "bash") { + decision = classifyBash(getCommand(event.input)); + } else if (event.toolName === "write" || event.toolName === "edit") { + decision = classifyFileTool(event.toolName, getPath(event.input)); + } + + const result = await enforceDecision(decision, ctx, sessionApproved, executionApproved); + if (result.executionApproved) executionApproved = true; + return result.block; + }); +} + +function getCommand(input: unknown): string { + if (typeof input === "object" && input !== null && "command" in input) { + const command = (input as { command?: unknown }).command; + return typeof command === "string" ? command.trim() : ""; + } + return ""; +} + +function getPath(input: unknown): string { + if (typeof input === "object" && input !== null && "path" in input) { + const filePath = (input as { path?: unknown }).path; + return typeof filePath === "string" ? filePath : ""; + } + return ""; +} + +function normalizePath(filePath: string): string { + const normalized = path.normalize(filePath).replace(/\\/g, "/"); + return normalized.replace(/^\.\//, ""); +} + +function isSecretPath(filePath: string): boolean { + const p = normalizePath(filePath).toLowerCase(); + const base = path.posix.basename(p); + + return ( + base === ".env" || + base.startsWith(".env.") || + /(^|\/)(secrets?|credentials?)(\/|\.|$)/i.test(p) || + /(^|\/)(provisioning|signing|certificates?|keychains?)(\/|\.|$)/i.test(p) || + /\.(p12|mobileprovision|cer|cert|key|pem)$/i.test(p) + ); +} + +function isExtraSensitivePath(filePath: string): boolean { + const p = normalizePath(filePath); + return EXTRA_SENSITIVE_PATHS.some((sensitive) => { + const normalizedSensitive = normalizePath(sensitive); + return normalizedSensitive.endsWith("/") + ? p === normalizedSensitive.slice(0, -1) || p.startsWith(normalizedSensitive) + : p === normalizedSensitive; + }); +} + +function sensitiveReason(filePath: string): string { + const p = normalizePath(filePath); + + if (p === "AGENTS.md" || p === "CLAUDE.md") return "agent baseline / Claude entry point"; + if (p.startsWith("docs/")) return "canonical project documentation"; + if (p.startsWith(".pi/skills/")) return "Pi skill definition"; + if (p.startsWith(".pi/extensions/")) return "Pi extension definition"; + if (p.includes("TokenManager") || p.includes("TokenStorage") || p.includes("KeychainTokenStorage")) { + return "token storage/auth boundary"; + } + if (p.includes("AuthInterceptor")) return "authorization header / token refresh infrastructure"; + if (p.startsWith("fastlane/") || p === "fastlane/Fastfile") return "Fastlane verification/deploy configuration"; + if (p.startsWith("Tuist/") || p === "Tuist.swift" || p === "Workspace.swift") return "Tuist project configuration"; + return "extra sensitive project path"; +} + +function hasShellControl(command: string): boolean { + return /(;|&&|\|\||\||`|\$\(|\bsh\s+-c\b|\bbash\s+-c\b)/.test(command); +} + +function isReadOnlyCommand(command: string): boolean { + const c = command.trim(); + return ( + /^(pwd)\s*$/.test(c) || + /^(ls)(\s+[^;&|`$()]*)?$/.test(c) || + /^(find)(\s+[^;&|`$()]*)?$/.test(c) || + /^(grep)(\s+[^;&|`$()]*)?$/.test(c) || + /^(rg)(\s+[^;&|`$()]*)?$/.test(c) || + /^git\s+status(\s+--short)?\s*$/.test(c) || + /^git\s+diff\s*$/.test(c) || + /^git\s+diff\s+--stat\s*$/.test(c) || + /^git\s+diff\s+--cached\s+--stat\s*$/.test(c) || + /^git\s+branch\s+--show-current\s*$/.test(c) + ); +} + +function classifyBash(command: string): Decision { + if (!command) return { level: "safe" }; + if (isReadOnlyCommand(command)) return { level: "safe" }; + + if (/^git\s+push\b/.test(command)) { + return { level: "blocked", reason: "twix-gate blocks git push. Push only outside Pi or after extension policy is changed." }; + } + if (/^git\s+reset\s+--hard\b/.test(command)) { + return { level: "blocked", reason: "twix-gate blocks git reset --hard. Use a non-destructive review/revert plan instead." }; + } + if (/^git\s+clean\s+(-[A-Za-z]*f[A-Za-z]*d|-[A-Za-z]*d[A-Za-z]*f)\b/.test(command)) { + return { level: "blocked", reason: "twix-gate blocks git clean -fd because it deletes untracked files." }; + } + if (/\brm\s+[^\n]*(-[A-Za-z]*r[A-Za-z]*f|-[A-Za-z]*f[A-Za-z]*r|--recursive)\b/.test(command)) { + return { level: "blocked", reason: "twix-gate blocks recursive rm. Use targeted deletion after explicit approval." }; + } + if (/^sudo\b/.test(command) || /\bsudo\b/.test(command)) { + return { level: "blocked", reason: "twix-gate blocks sudo commands in this repository." }; + } + if (/^chmod\s+(-R\s+)?(777|[-+][^\s]*w)/.test(command) || /^chown\s+-R\b/.test(command)) { + return { level: "blocked", reason: "twix-gate blocks broad chmod/chown changes." }; + } + if (/^xcodebuild\b/.test(command)) { + return { + level: "blocked", + reason: "twix-gate blocks direct xcodebuild unless scheme/destination/configuration were explicitly provided. Use bundle exec fastlane ios ci_pr for normal verification.", + }; + } + + if (/^git\s+add\s+(\.|-A|--all)\s*$/.test(command)) { + return sensitive("Sensitive git add confirmation", command, "This stages broad file sets. Session allow is unavailable. Confirm only after reviewing git status and diff."); + } + if (/^git\s+add\b/.test(command)) { + return moderate("Git add confirmation", command, "This stages files for commit. Confirm the file set is intended.", `bash:${command}`); + } + if (/^git\s+commit\b/.test(command)) { + return { + level: "commit", + title: "Git commit confirmation", + message: `Command: ${command}\n\nCommit approval is separate from plan/execution approval. This records repository history. Confirm only if the user approved both file set and commit message.`, + }; + } + if (/^(bundle\s+exec\s+)?fastlane\s+ios\s+ci_pr\s*$/.test(command)) { + return moderate( + "Run CI/PR verification?", + command, + "This runs Fastlane CI/PR verification and may take time.", + `bash:${command}`, + ); + } + if (/^tuist\s+clean\s*$/.test(command)) { + return moderate("Tuist clean confirmation", command, "This clears Tuist cache/generated state. Continue?", "bash:tuist clean"); + } + if (/^rm\s+/.test(command)) { + return moderate("File deletion confirmation", command, "This deletes files. Confirm the target is correct."); + } + + if (hasShellControl(command)) { + return moderate("Complex bash command confirmation", command, "This command uses shell control operators. Confirm it is safe to run."); + } + + return { level: "safe" }; +} + +function classifyFileTool(toolName: string, filePath: string): Decision { + const p = normalizePath(filePath); + if (!p) return { level: "blocked", reason: `twix-gate blocked ${toolName}: missing path.` }; + + if (isSecretPath(p)) { + return { level: "blocked", reason: `twix-gate blocks edits to secrets/env/credentials/provisioning/signing/keychain paths: ${p}` }; + } + + if (isExtraSensitivePath(p)) { + return { + level: "sensitive", + title: "Sensitive file edit confirmation", + message: `Tool: ${toolName}\nPath: ${p}\nReason: ${sensitiveReason(p)}\n\nSession allow is unavailable for sensitive paths. Confirm only if the user explicitly approved this sensitive edit.`, + }; + } + + return { + level: "moderate", + title: "File edit confirmation", + message: `Tool: ${toolName}\nPath: ${p}\n\nThis modifies a repository file. Session approval lasts only for this Pi session.`, + cacheKey: `${toolName}:${p}`, + }; +} + +function moderate(title: string, command: string, detail: string, cacheKey?: string): Decision { + return { + level: "moderate", + title, + message: `Command: ${command}\n\n${detail}\n\nSession approval lasts only for this Pi session.`, + cacheKey, + }; +} + +function sensitive(title: string, command: string, detail: string): Decision { + return { + level: "sensitive", + title, + message: `Command: ${command}\n\n${detail}`, + }; +} + +async function enforceDecision( + decision: Decision, + ctx: ExtensionContext, + sessionApproved: Set, + executionApproved: boolean, +): Promise<{ block?: { block: true; reason?: string }; executionApproved?: boolean }> { + if (decision.level === "safe") return {}; + if (decision.level === "blocked") return { block: { block: true, reason: decision.reason } }; + + if (!executionApproved) { + const execution = await askExecutionCheckpoint(decision, ctx); + if (execution.block) return { block: execution.block }; + executionApproved = execution.executionApproved ?? executionApproved; + } + + if (decision.level === "moderate" && decision.cacheKey && sessionApproved.has(decision.cacheKey)) { + return { executionApproved }; + } + + if (!ctx.hasUI) { + return { block: { block: true, reason: `twix-gate requires confirmation but UI is unavailable: ${decision.title}` } }; + } + + if (decision.level === "moderate") { + const choice = await selectOrConfirm( + ctx, + decision.title, + `${decision.message}\n\nAllow this action?`, + ["Allow once", "Allow this action/path/command for this session", "Deny"], + ); + + if (choice === "Deny") { + return { block: { block: true, reason: `Blocked by twix-gate: ${decision.title}` }, executionApproved }; + } + if (choice === "Allow this action/path/command for this session" && decision.cacheKey) { + sessionApproved.add(decision.cacheKey); + } + return { executionApproved }; + } + + const title = decision.level === "commit" ? "⚠️ Git commit confirmation" : `⚠️ ${decision.title}`; + const extra = decision.level === "commit" + ? "\n\nCommit approval is separate from plan/execution approval. Session allow is unavailable for commits." + : "\n\nSession allow is unavailable for sensitive actions."; + const choice = await selectOrConfirm(ctx, title, `${decision.message}${extra}\n\nAllow this action?`, ["Allow once", "Deny"]); + + if (choice !== "Allow once") { + return { block: { block: true, reason: `Blocked by twix-gate: ${decision.title}` }, executionApproved }; + } + + return { executionApproved }; +} + +async function askExecutionCheckpoint( + decision: Exclude, + ctx: ExtensionContext, +): Promise<{ block?: { block: true; reason?: string }; executionApproved?: boolean }> { + if (!ctx.hasUI) { + return { block: { block: true, reason: "twix-gate requires execution approval but UI is unavailable." } }; + } + + const choice = await selectOrConfirm( + ctx, + "Start non-read-only execution?", + [ + "The agent is about to start a non-read-only action.", + "This checkpoint is separate from plan approval.", + "It does not override sensitive, commit, or blocked checks.", + "Action:", + decision.message, + ].join("\n\n"), + ["Allow once", "Start execution for this session", "Deny"], + ); + + if (choice === "Deny") { + return { block: { block: true, reason: "Blocked by twix-gate: execution checkpoint denied." } }; + } + + return { executionApproved: choice === "Start execution for this session" }; +} + +async function selectOrConfirm( + ctx: ExtensionContext, + title: string, + message: string, + choices: string[], +): Promise { + const ui = ctx.ui as ExtensionContext["ui"] & { + select?: (prompt: string, options: string[]) => Promise; + }; + + if (typeof ui.select === "function") { + const choice = await ui.select(`${title}\n\n${message}`, choices); + return choice ?? "Deny"; + } + + const ok = await ctx.ui.confirm(title, `${message}\n\n${choices.includes("Allow once") ? "Allow once?" : "Allow?"}`); + return ok ? "Allow once" : "Deny"; +} From 3dfbb44c40cfd26fe5f2d4df2c2ea5fd51ff124c Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Sat, 16 May 2026 12:00:04 +0900 Subject: [PATCH 006/100] =?UTF-8?q?docs:=20=EC=97=90=EC=9D=B4=EC=A0=84?= =?UTF-8?q?=ED=8A=B8=20=EC=8A=A4=ED=82=AC=20=EC=9B=8C=ED=81=AC=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=95=EB=A6=AC=20-=20#305?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/skills/docs-refactor/SKILL.md | 10 ++ .claude/skills/final-review/SKILL.md | 10 ++ .claude/skills/fix-review/SKILL.md | 10 ++ .claude/skills/review-twix/SKILL.md | 10 ++ .codex/skills/docs-refactor/SKILL.md | 10 ++ .codex/skills/final-review/SKILL.md | 10 ++ .codex/skills/fix-review/SKILL.md | 10 ++ .codex/skills/review-twix/SKILL.md | 10 ++ .pi/skills/handoff-twix/SKILL.md | 70 ++++++++++++ .pi/skills/plan-twix/SKILL.md | 152 ++++++++++++++++++++++++++ AGENTS.md | 18 ++- Claude.md | 2 + 12 files changed, 320 insertions(+), 2 deletions(-) create mode 100644 .claude/skills/docs-refactor/SKILL.md create mode 100644 .claude/skills/final-review/SKILL.md create mode 100644 .claude/skills/fix-review/SKILL.md create mode 100644 .claude/skills/review-twix/SKILL.md create mode 100644 .codex/skills/docs-refactor/SKILL.md create mode 100644 .codex/skills/final-review/SKILL.md create mode 100644 .codex/skills/fix-review/SKILL.md create mode 100644 .codex/skills/review-twix/SKILL.md create mode 100644 .pi/skills/plan-twix/SKILL.md diff --git a/.claude/skills/docs-refactor/SKILL.md b/.claude/skills/docs-refactor/SKILL.md new file mode 100644 index 00000000..48937334 --- /dev/null +++ b/.claude/skills/docs-refactor/SKILL.md @@ -0,0 +1,10 @@ +--- +name: docs-refactor +description: Use this skill for documentation refactoring, architecture rule changes, canonical docs cleanup, AGENTS.md updates, and reference cleanup in the Twix iOS repository. +--- + +# docs-refactor + +Canonical instructions live at `.pi/skills/docs-refactor/SKILL.md`. + +When this skill matches the task, read that file completely and follow it. Do not duplicate or reinterpret the instructions here. diff --git a/.claude/skills/final-review/SKILL.md b/.claude/skills/final-review/SKILL.md new file mode 100644 index 00000000..44deb2f8 --- /dev/null +++ b/.claude/skills/final-review/SKILL.md @@ -0,0 +1,10 @@ +--- +name: final-review +description: Use this skill before opening a PR to run final review, Fastlane CI verification, commit preparation, approved commit execution, and PR draft generation. +--- + +# final-review + +Canonical instructions live at `.pi/skills/final-review/SKILL.md`. + +When this skill matches the task, read that file completely and follow it. Do not duplicate or reinterpret the instructions here. diff --git a/.claude/skills/fix-review/SKILL.md b/.claude/skills/fix-review/SKILL.md new file mode 100644 index 00000000..f83bb0f5 --- /dev/null +++ b/.claude/skills/fix-review/SKILL.md @@ -0,0 +1,10 @@ +--- +name: fix-review +description: Use this skill to apply explicitly approved review-twix findings with minimal diffs, without broad implementation or reinterpretation. +--- + +# fix-review + +Canonical instructions live at `.pi/skills/fix-review/SKILL.md`. + +When this skill matches the task, read that file completely and follow it. Do not duplicate or reinterpret the instructions here. diff --git a/.claude/skills/review-twix/SKILL.md b/.claude/skills/review-twix/SKILL.md new file mode 100644 index 00000000..43a2b006 --- /dev/null +++ b/.claude/skills/review-twix/SKILL.md @@ -0,0 +1,10 @@ +--- +name: review-twix +description: Use this skill for Twix iOS code, diff, PR, and architecture compliance review. Default behavior is report-only unless edits are explicitly requested. +--- + +# review-twix + +Canonical instructions live at `.pi/skills/review-twix/SKILL.md`. + +When this skill matches the task, read that file completely and follow it. Do not duplicate or reinterpret the instructions here. diff --git a/.codex/skills/docs-refactor/SKILL.md b/.codex/skills/docs-refactor/SKILL.md new file mode 100644 index 00000000..48937334 --- /dev/null +++ b/.codex/skills/docs-refactor/SKILL.md @@ -0,0 +1,10 @@ +--- +name: docs-refactor +description: Use this skill for documentation refactoring, architecture rule changes, canonical docs cleanup, AGENTS.md updates, and reference cleanup in the Twix iOS repository. +--- + +# docs-refactor + +Canonical instructions live at `.pi/skills/docs-refactor/SKILL.md`. + +When this skill matches the task, read that file completely and follow it. Do not duplicate or reinterpret the instructions here. diff --git a/.codex/skills/final-review/SKILL.md b/.codex/skills/final-review/SKILL.md new file mode 100644 index 00000000..44deb2f8 --- /dev/null +++ b/.codex/skills/final-review/SKILL.md @@ -0,0 +1,10 @@ +--- +name: final-review +description: Use this skill before opening a PR to run final review, Fastlane CI verification, commit preparation, approved commit execution, and PR draft generation. +--- + +# final-review + +Canonical instructions live at `.pi/skills/final-review/SKILL.md`. + +When this skill matches the task, read that file completely and follow it. Do not duplicate or reinterpret the instructions here. diff --git a/.codex/skills/fix-review/SKILL.md b/.codex/skills/fix-review/SKILL.md new file mode 100644 index 00000000..f83bb0f5 --- /dev/null +++ b/.codex/skills/fix-review/SKILL.md @@ -0,0 +1,10 @@ +--- +name: fix-review +description: Use this skill to apply explicitly approved review-twix findings with minimal diffs, without broad implementation or reinterpretation. +--- + +# fix-review + +Canonical instructions live at `.pi/skills/fix-review/SKILL.md`. + +When this skill matches the task, read that file completely and follow it. Do not duplicate or reinterpret the instructions here. diff --git a/.codex/skills/review-twix/SKILL.md b/.codex/skills/review-twix/SKILL.md new file mode 100644 index 00000000..43a2b006 --- /dev/null +++ b/.codex/skills/review-twix/SKILL.md @@ -0,0 +1,10 @@ +--- +name: review-twix +description: Use this skill for Twix iOS code, diff, PR, and architecture compliance review. Default behavior is report-only unless edits are explicitly requested. +--- + +# review-twix + +Canonical instructions live at `.pi/skills/review-twix/SKILL.md`. + +When this skill matches the task, read that file completely and follow it. Do not duplicate or reinterpret the instructions here. diff --git a/.pi/skills/handoff-twix/SKILL.md b/.pi/skills/handoff-twix/SKILL.md index d8625f7f..7f76e85d 100644 --- a/.pi/skills/handoff-twix/SKILL.md +++ b/.pi/skills/handoff-twix/SKILL.md @@ -11,6 +11,76 @@ description: Use this skill to coordinate concise low-token handoffs between Pi, 이 skill은 implementation skill이 아닙니다. 명시 요청이 없는 한 Pi가 feature를 직접 구현하지 않습니다. Pi가 계획과 handoff 파일을 만들고, Claude Code가 구현하며, Pi가 review/fix/finalize를 이어갈 수 있도록 간결한 handoff 파일과 runner를 사용합니다. +## Invocation behavior + +`handoff-twix`는 누가 작업을 수행할지 조율하는 orchestration skill입니다. 사용자가 handoff를 명시적으로 요청한 경우, 의미상 가장 가까운 다른 skill로 collapse하거나 silent switch하지 않습니다. 명시 요청이 없는 한 Pi가 직접 구현/문서 rewrite를 수행하지 않습니다. + +Rules: + +1. 사용자가 concrete task 없이 `/skill:handoff-twix`만 호출한 경우: + - task를 추론하지 않습니다. + - `docs-refactor`, `review-twix`, `fix-review`, `final-review`로 전환하지 않습니다. + - 어떤 handoff workflow를 원하는지 질문합니다. + - 다음 선택지를 간결하게 제시합니다. + - full handoff with Claude Code + - create handoff files only + - continue after Claude implementation + - review implementation result + - apply approved fixes + - proceed to final-review + +2. 사용자가 `/skill:handoff-twix`와 concrete task/command를 함께 제공한 경우: + - matching handoff workflow를 즉시 수행합니다. + - 필수 정보가 빠진 경우가 아니면 redundant clarification을 묻지 않습니다. + - `handoff-twix` 책임 범위 안에 머무릅니다. + - 사용자가 명시적으로 요청하지 않는 한 Pi가 직접 requested change를 구현하지 않습니다. + - full handoff가 요청되면 Pi는 handoff 파일을 만들고 Claude Code runner를 사용하거나 준비합니다. + - partial mode가 요청되면 해당 partial mode만 수행합니다. + +3. 요청 task가 의미상 `docs-refactor`, `review-twix`, `fix-review`, `final-review`에 가까운 경우: + - 사용자가 `handoff-twix` full handoff를 요청했다면 해당 skill로 silent switch하지 않습니다. + - full handoff mode에서 해당 skills는 Claude implementation을 대체하지 않고 references/phases로만 사용합니다. + - `docs-refactor`, `review-twix`, `fix-review`, `final-review`는 적절한 phase에서만 사용하거나 사용자가 해당 partial workflow를 명시적으로 요청한 경우에만 사용합니다. + +4. task가 `handoff-twix` scope 밖인 경우: + - `handoff-twix` scope 밖이라고 말합니다. + - 적절한 skill을 제안하되, 사용자가 요청하지 않으면 자동 전환하지 않습니다. + +5. 사용자가 이미 다음을 제공한 경우 “진행할까요?”를 묻지 않습니다. + - workflow type + - implementation agent 또는 default Claude Code runner + - task + - handoff 파일 작성에 충분한 scope + +6. 다음처럼 필수 detail이 빠진 경우에만 질문합니다. + - task가 제공되지 않음 + - full handoff와 files-only가 모호함 + - external Claude Code 실행이 요청되었지만 approval이 필요함 + - task에 owner decision이 필요함 + - broad refactor 또는 public API change가 암시됨 + +Examples: + +- Example A — skill only + - User: `/skill:handoff-twix` + - Expected behavior: 어떤 handoff workflow를 실행할지 질문합니다. `docs-refactor`나 implementation을 시작하지 않습니다. + +- Example B — full handoff + - User: `/skill:handoff-twix` + `Run full handoff with Claude Code: make docs-refactor/review-twix/fix-review/final-review usable by Codex/Claude Code through shared workflow docs.` + - Expected behavior: `PLAN.md`와 `IMPLEMENTATION_REQUEST.md`를 작성한 뒤 approval gates에 따라 Claude Code runner를 준비/실행합니다. Pi가 직접 docs를 수정하지 않습니다. + +- Example C — files only + - User: `/skill:handoff-twix` + `Create handoff files only for this task: ...` + - Expected behavior: `PLAN.md`와 `IMPLEMENTATION_REQUEST.md`를 작성한 뒤 중단합니다. + +- Example D — continue + - User: `/skill:handoff-twix` + `Claude implementation is done. Continue from IMPLEMENTATION_RESULT.md and git diff.` + - Expected behavior: result file과 `git diff`를 읽고 `review-twix` standard를 적용한 뒤 `REVIEW_REPORT.md`를 작성합니다. + +- Example E — outside scope + - User: handoff 없이 직접 docs rewrite를 요청합니다. + - Expected behavior: 사용자가 full handoff를 원하는 것이 아니라면 `docs-refactor`가 더 적절하다고 말합니다. + ## Implementation agent Claude Code가 고정 implementation agent입니다. diff --git a/.pi/skills/plan-twix/SKILL.md b/.pi/skills/plan-twix/SKILL.md new file mode 100644 index 00000000..a46fd9e6 --- /dev/null +++ b/.pi/skills/plan-twix/SKILL.md @@ -0,0 +1,152 @@ +--- +name: plan-twix +description: Use this skill to create concise implementation plans for Twix iOS work, save them to handoff files, and track plan approval before handoff-twix implementation. +--- + +# plan-twix + +## 목적 + +`plan-twix`는 Twix iOS 작업을 위한 concise implementation plan을 작성하고 approval state를 추적합니다. + +이 skill은 독립적으로 사용할 수 있고, `handoff-twix` 전에 사용할 수도 있습니다. + +- `.agent/handoff/PLAN.md`를 작성합니다. +- `.agent/handoff/PLAN_APPROVAL.md`를 생성 또는 갱신합니다. +- 사용자가 plan을 approve / revise / reject할 수 있게 합니다. +- 코드를 구현하지 않습니다. +- `.agent/handoff/PLAN.md`와 `.agent/handoff/PLAN_APPROVAL.md` 외의 project file을 수정하지 않습니다. + +## Planner choice + +Preferred planner: + +- Codex / GPT-5.5 when available + +Fallback planner: + +- Pi internal planning + +Optional fallback: + +- Claude Code plan mode may be mentioned only as an optional fallback. +- Claude Code는 `handoff-twix`의 fixed implementation agent이므로 default planner로 사용하지 않습니다. + +Planning and implementation separation: + +- Codex/GPT-5.5 plans. +- Claude Code implements through `handoff-twix`. +- Pi reviews/fixes/finalizes through review/fix/final workflows. + +## Invocation behavior + +1. `/skill:plan-twix`가 concrete task 없이 호출된 경우: + - task를 추론하지 않습니다. + - 어떤 작업을 계획할지 질문합니다. + +2. concrete task와 함께 호출된 경우: + - `AGENTS.md`를 읽습니다. + - 필요한 경우에만 relevant canonical docs를 읽습니다. + - docs 내용을 plan에 길게 복사하지 않습니다. + - `.agent/handoff/PLAN.md`를 작성합니다. + - `.agent/handoff/PLAN_APPROVAL.md`를 `STATUS: PENDING`으로 생성 또는 갱신합니다. + - 한국어로 짧게 요약합니다. + - 사용자에게 approve / revise / reject 중 선택하라고 요청합니다. + +3. 사용자가 plan을 approve한 경우: + - `.agent/handoff/PLAN_APPROVAL.md`를 `STATUS: APPROVED`로 갱신합니다. + - 필요한 경우 `.agent/handoff/PLAN.md`의 status를 `STATUS: APPROVED`로 갱신합니다. + - 구현을 시작하지 않습니다. + - 이제 `handoff-twix`를 실행할 수 있다고 안내합니다. + +4. 사용자가 revise를 요청한 경우: + - `.agent/handoff/PLAN.md`를 갱신합니다. + - `.agent/handoff/PLAN_APPROVAL.md`는 `STATUS: PENDING`으로 유지합니다. + - 구현을 시작하지 않습니다. + +5. 사용자가 reject한 경우: + - `.agent/handoff/PLAN_APPROVAL.md`를 `STATUS: REJECTED`로 갱신합니다. + - 필요한 경우 `.agent/handoff/PLAN.md`의 status를 `STATUS: REJECTED`로 갱신합니다. + - 진행하지 않습니다. + +## PLAN.md required format + +```text +STATUS: PROPOSED | APPROVED | BLOCKED | REJECTED +# Plan +## Goal +## Scope +## Relevant canonical docs +## Expected files/modules +## Architecture constraints +## Forbidden patterns +## Verification expectation +## Open questions +## Handoff notes +``` + +## PLAN_APPROVAL.md required format + +```text +STATUS: PENDING | APPROVED | REJECTED +# Plan Approval +## Decision +## Approved plan file +## Notes +``` + +## Planning rules + +- Plan approval은 implementation approval이 아닙니다. +- Plan approval은 commit approval이 아닙니다. +- `handoff-twix`는 implementation에 여전히 Claude Code runner를 사용해야 합니다. +- `final-review`는 verification, commit, PR draft를 계속 담당해야 합니다. +- plan이 broad refactor, public API change, new architecture exception, owner decision을 암시하면: + - `STATUS: BLOCKED`로 표시하거나 + - `Open questions`에 명시합니다. +- `xcodebuild` command를 invent하지 않습니다. +- Verification expectation은 Pi/final-review의 Fastlane 기준을 사용합니다. + - `bundle exec fastlane ios ci_pr` + - fallback: `fastlane ios ci_pr` +- direct `TokenStorage`, Keychain, UserDefaults 접근을 제안하지 않습니다. +- `StackState`, `StackActionOf`를 제안하지 않습니다. +- feature client를 기본적으로 protocol-based로 제안하지 않습니다. +- Interface public type을 강제로 `Source.swift`에 몰아넣는 계획을 제안하지 않습니다. +- 프로젝트 문서의 긴 내용을 plan에 복사하지 않고 path로 참조합니다. +- 최소 diff, module boundary, dependency direction, public API 최소화를 우선합니다. + +## Relationship to handoff-twix + +- `handoff-twix`는 `.agent/handoff/PLAN_APPROVAL.md`가 `STATUS: APPROVED`일 때만 `.agent/handoff/PLAN.md`를 reuse해야 합니다. +- `PLAN.md`가 있어도 approval이 pending/rejected이면 `handoff-twix`는 중단하고 plan approval 또는 revision을 요청해야 합니다. +- 이 skill은 명시 요청이 없는 한 `.agent/handoff/IMPLEMENTATION_REQUEST.md`를 만들지 않습니다. +- 이 skill은 implementation agent를 실행하지 않습니다. + +## Output format + +한국어로 보고합니다. + +```text +작성한 파일: +- + +planner: +- + +plan status: +- + +핵심 계획 요약: +- + +열린 질문: +- + +승인 선택지: +- approve +- revise +- reject + +다음 단계: +- +``` diff --git a/AGENTS.md b/AGENTS.md index 9fff2b10..14885ed0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,12 +48,26 @@ Claude Code-specific guide: Do not treat Claude-specific workflow tips as universal agent rules unless also stated here or in technical docs. -### `.pi/skills/` +### Reusable agent skills -Reusable Pi skills: +Canonical skill instructions live under `.pi/skills/`. + +Cross-agent skills: - `docs-refactor`: documentation refactoring and architecture rule cleanup - `review-twix`: Twix iOS architecture/code review +- `fix-review`: apply explicitly approved review findings with minimal diffs +- `final-review`: pre-PR review, verification, commit preparation, and PR draft + +Agent-specific access: + +- Pi uses `.pi/skills/{name}/SKILL.md` directly. +- Claude Code uses `.claude/skills/{name}/SKILL.md` thin links that point back to the canonical `.pi/skills/` files. +- Codex CLI uses `.codex/skills/{name}/SKILL.md` thin links that point back to the canonical `.pi/skills/` files. + +Pi-only skill: + +- `handoff-twix` remains Pi-only because it orchestrates Pi-specific handoff, runner, review/fix/final-review flow. ### `docs/*.md` diff --git a/Claude.md b/Claude.md index 64802ae9..b8f363b4 100644 --- a/Claude.md +++ b/Claude.md @@ -16,6 +16,8 @@ - 작업을 시작하기 전에 `AGENTS.md`의 문서 조회 순서와 편집 정책을 따르세요. - 팀 규칙이 필요한 작업은 [docs/Reference/ProjectRules.md](./docs/Reference/ProjectRules.md)를 함께 확인하세요. - 상세 구현은 작업 종류에 맞는 `docs/*.md`를 확인하세요. +- 재사용 skill은 `.claude/skills/{name}/SKILL.md`에서 진입하되, 실제 원본 지침은 해당 파일이 가리키는 `.pi/skills/{name}/SKILL.md`를 읽으세요. +- `handoff-twix`는 Pi 전용 orchestration skill이므로 Claude Code skill로 사용하지 않습니다. - 누락되었거나 링크가 깨진 문서는 추정하지 말고 사용자에게 확인하세요. --- From 5594c1f5451f6170157c3438cea3b5cd4d03e250 Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Sun, 17 May 2026 14:24:09 +0900 Subject: [PATCH 007/100] =?UTF-8?q?chore:=20UI=20=EB=A0=8C=EB=8D=94?= =?UTF-8?q?=EB=A7=81=20=EC=84=B1=EB=8A=A5=20=EC=B8=A1=EC=A0=95=20=EC=9D=B8?= =?UTF-8?q?=ED=94=84=EB=9D=BC=20=EC=A4=80=EB=B9=84=20-=20#308?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 +- .../PhotoLogUpdateReactionResponseDTO.swift | 5 + .../Auth/Example/Sources/AuthApp.swift | 7 + .../Sources/AuthExampleSmokeTests.swift | 9 ++ Projects/Feature/Auth/Project.swift | 3 +- .../Example/Sources/GoalDetailApp.swift | 7 + .../Sources/GoalDetailExampleView.swift | 16 ++- .../GoalDetailExampleNavigationTests.swift | 19 +++ .../Sources/GoalDetailExampleSmokeTests.swift | 9 ++ Projects/Feature/GoalDetail/Project.swift | 38 +++--- .../Sources/Detail/GoalDetailView.swift | 3 + .../GoalDetail/Testing/Sources/Source.swift | 5 +- .../Home/Example/Sources/HomeApp.swift | 120 ++++++++++++++++-- .../Sources/HomeExampleNavigationTests.swift | 23 ++++ .../Sources/HomeExampleScrollTests.swift | 16 +++ .../Sources/HomeExampleSmokeTests.swift | 9 ++ Projects/Feature/Home/Project.swift | 12 +- .../Feature/Home/Sources/Home/HomeView.swift | 5 +- .../Sources/Root/HomeCoordinatorView.swift | 3 + .../Feature/Home/Testing/Sources/Source.swift | 5 +- .../Example/Sources/MainTabExampleApp.swift | 7 + .../Example/Sources/MainTabExampleView.swift | 16 ++- .../Sources/MainTabExampleSmokeTests.swift | 9 ++ Projects/Feature/MainTab/Project.swift | 3 +- .../Example/Sources/MakeGoalApp.swift | 28 ++-- .../Sources/MakeGoalExampleSmokeTests.swift | 9 ++ Projects/Feature/MakeGoal/Project.swift | 3 +- .../MakeGoal/Testing/Sources/Source.swift | 5 +- .../Example/Sources/NotificationApp.swift | 33 +++-- .../NotificationExampleSmokeTests.swift | 9 ++ Projects/Feature/Notification/Project.swift | 3 +- .../Example/Sources/OnboardingApp.swift | 4 + .../Sources/OnboardingExampleSmokeTests.swift | 9 ++ Projects/Feature/Onboarding/Project.swift | 3 +- .../Onboarding/Testing/Sources/Source.swift | 5 +- .../Example/Sources/ProofPhotoApp.swift | 69 ++++++++-- .../Sources/ProofPhotoExampleSmokeTests.swift | 9 ++ Projects/Feature/ProofPhoto/Project.swift | 10 +- .../ProofPhoto/Testing/Sources/Source.swift | 5 +- .../Example/Sources/SettingsApp.swift | 7 + .../Sources/SettingsExampleSmokeTests.swift | 9 ++ Projects/Feature/Settings/Project.swift | 5 +- .../Stats/Example/Sources/StatsApp.swift | 44 ++++++- .../Sources/StatsExampleNavigationTests.swift | 21 +++ .../Sources/StatsExampleScrollTests.swift | 16 +++ .../Sources/StatsExampleSmokeTests.swift | 9 ++ Projects/Feature/Stats/Project.swift | 4 +- .../Coordinator/StatsCoordinatorView.swift | 2 + .../Stats/Sources/Stats/StatsView.swift | 3 + .../Stats/Testing/Sources/Source.swift | 5 +- .../Shared/PerfTestingSupport/Project.swift | 19 +++ .../Sources/UITestMode.swift | 44 +++++++ .../Sources/View+PerfAccessibility.swift | 27 ++++ .../UITests/Sources/XCTestCase+Perf.swift | 25 ++++ .../Sources/XCUIApplication+Perf.swift | 20 +++ Scripts/verify-perf-targets.sh | 48 +++++++ .../InfoPlist/InfoPlist+Defaults.swift | 16 +++ Tuist/ProjectDescriptionHelpers/Module.swift | 1 + .../Project/Project+MakeModule.swift | 28 +++- .../Dependency/TargetDependency+Modules.swift | 8 ++ .../Target/Target+Feature.swift | 73 ++++++++++- .../Target/Target+Shared.swift | 19 +++ docs/perf-infra/README.md | 103 +++++++++++++++ docs/perf-infra/inventory.md | 18 +++ .../reports/_workspace/smell-inventory.md | 80 ++++++++++++ 65 files changed, 1127 insertions(+), 85 deletions(-) create mode 100644 Projects/Feature/Auth/ExampleUITests/Sources/AuthExampleSmokeTests.swift create mode 100644 Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleNavigationTests.swift create mode 100644 Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleSmokeTests.swift create mode 100644 Projects/Feature/Home/ExampleUITests/Sources/HomeExampleNavigationTests.swift create mode 100644 Projects/Feature/Home/ExampleUITests/Sources/HomeExampleScrollTests.swift create mode 100644 Projects/Feature/Home/ExampleUITests/Sources/HomeExampleSmokeTests.swift create mode 100644 Projects/Feature/MainTab/ExampleUITests/Sources/MainTabExampleSmokeTests.swift create mode 100644 Projects/Feature/MakeGoal/ExampleUITests/Sources/MakeGoalExampleSmokeTests.swift create mode 100644 Projects/Feature/Notification/ExampleUITests/Sources/NotificationExampleSmokeTests.swift create mode 100644 Projects/Feature/Onboarding/ExampleUITests/Sources/OnboardingExampleSmokeTests.swift create mode 100644 Projects/Feature/ProofPhoto/ExampleUITests/Sources/ProofPhotoExampleSmokeTests.swift create mode 100644 Projects/Feature/Settings/ExampleUITests/Sources/SettingsExampleSmokeTests.swift create mode 100644 Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleNavigationTests.swift create mode 100644 Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleScrollTests.swift create mode 100644 Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleSmokeTests.swift create mode 100644 Projects/Shared/PerfTestingSupport/Project.swift create mode 100644 Projects/Shared/PerfTestingSupport/Sources/UITestMode.swift create mode 100644 Projects/Shared/PerfTestingSupport/Sources/View+PerfAccessibility.swift create mode 100644 Projects/Shared/PerfTestingSupport/UITests/Sources/XCTestCase+Perf.swift create mode 100644 Projects/Shared/PerfTestingSupport/UITests/Sources/XCUIApplication+Perf.swift create mode 100755 Scripts/verify-perf-targets.sh create mode 100644 docs/perf-infra/README.md create mode 100644 docs/perf-infra/inventory.md create mode 100644 docs/perf-infra/reports/_workspace/smell-inventory.md diff --git a/.gitignore b/.gitignore index 53137686..4c65a1e2 100644 --- a/.gitignore +++ b/.gitignore @@ -177,6 +177,5 @@ Network Trash Folder Temporary Items .apdisk src/SupportingFiles/Booket/GoogleService-Info.plist - -# Temporary files AI agent uses -.agent/handoff \ No newline at end of file +# Performance traces (large, generated) +.perf/traces/ diff --git a/Projects/Domain/PhotoLog/Interface/Sources/DTO/PhotoLogUpdateReactionResponseDTO.swift b/Projects/Domain/PhotoLog/Interface/Sources/DTO/PhotoLogUpdateReactionResponseDTO.swift index c81d584f..0eed0fab 100644 --- a/Projects/Domain/PhotoLog/Interface/Sources/DTO/PhotoLogUpdateReactionResponseDTO.swift +++ b/Projects/Domain/PhotoLog/Interface/Sources/DTO/PhotoLogUpdateReactionResponseDTO.swift @@ -10,4 +10,9 @@ import Foundation public struct PhotoLogUpdateReactionResponseDTO: Decodable { public let photologId: Int64 public let reaction: String + + public init(photologId: Int64, reaction: String) { + self.photologId = photologId + self.reaction = reaction + } } diff --git a/Projects/Feature/Auth/Example/Sources/AuthApp.swift b/Projects/Feature/Auth/Example/Sources/AuthApp.swift index 23f39000..db92cc6e 100644 --- a/Projects/Feature/Auth/Example/Sources/AuthApp.swift +++ b/Projects/Feature/Auth/Example/Sources/AuthApp.swift @@ -7,10 +7,15 @@ import ComposableArchitecture import FeatureAuth +import SharedPerfTestingSupport import SwiftUI @main struct AuthApp: App { + init() { + UITestMode.configureApplication() + } + var body: some Scene { WindowGroup { AuthView( @@ -19,6 +24,8 @@ struct AuthApp: App { reducer: { AuthReducer() } ) ) + .perfRoot("auth") + .perfReadyMarker("auth") } } } diff --git a/Projects/Feature/Auth/ExampleUITests/Sources/AuthExampleSmokeTests.swift b/Projects/Feature/Auth/ExampleUITests/Sources/AuthExampleSmokeTests.swift new file mode 100644 index 00000000..ebc0c9d6 --- /dev/null +++ b/Projects/Feature/Auth/ExampleUITests/Sources/AuthExampleSmokeTests.swift @@ -0,0 +1,9 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class AuthExampleSmokeTests: XCTestCase { + func testExampleRendersReadyState() { + _ = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("auth") + } +} diff --git a/Projects/Feature/Auth/Project.swift b/Projects/Feature/Auth/Project.swift index a216e45f..d3bf1613 100644 --- a/Projects/Feature/Auth/Project.swift +++ b/Projects/Feature/Auth/Project.swift @@ -39,6 +39,7 @@ let project = Project.makeModule( .external(dependency: .ComposableArchitecture) ] ) - ) + ), + .feature(exampleUITests: .auth) ] ) diff --git a/Projects/Feature/GoalDetail/Example/Sources/GoalDetailApp.swift b/Projects/Feature/GoalDetail/Example/Sources/GoalDetailApp.swift index 7b1c98bb..1bbd820a 100644 --- a/Projects/Feature/GoalDetail/Example/Sources/GoalDetailApp.swift +++ b/Projects/Feature/GoalDetail/Example/Sources/GoalDetailApp.swift @@ -10,12 +10,19 @@ import SwiftUI import ComposableArchitecture import CoreCaptureSession import CoreCaptureSessionInterface +import SharedPerfTestingSupport @main struct GoalDetailApp: App { + init() { + UITestMode.configureApplication() + } + var body: some Scene { WindowGroup { GoalDetailExampleView() + .perfRoot("goal-detail") + .perfReadyMarker("goal-detail") } } } diff --git a/Projects/Feature/GoalDetail/Example/Sources/GoalDetailExampleView.swift b/Projects/Feature/GoalDetail/Example/Sources/GoalDetailExampleView.swift index b998ff87..ef1dcf2a 100644 --- a/Projects/Feature/GoalDetail/Example/Sources/GoalDetailExampleView.swift +++ b/Projects/Feature/GoalDetail/Example/Sources/GoalDetailExampleView.swift @@ -5,14 +5,17 @@ // Created by 정지훈 on 1/23/26. // +import AVFoundation import SwiftUI import ComposableArchitecture import CoreCaptureSession +import CoreCaptureSessionInterface import FeatureGoalDetail import FeatureGoalDetailInterface import FeatureProofPhoto import FeatureProofPhotoInterface +import SharedPerfTestingSupport import SharedDesignSystem struct GoalDetailExampleView: View { @@ -29,7 +32,7 @@ struct GoalDetailExampleView: View { proofPhotoReducer: ProofPhotoReducer() ) }, withDependencies: { - $0.captureSessionClient = .liveValue + $0.captureSessionClient = UITestMode.isEnabled ? .perfMock : .liveValue $0.proofPhotoFactory = .liveValue $0.goalClient = .previewValue } @@ -41,3 +44,14 @@ struct GoalDetailExampleView: View { #Preview { GoalDetailExampleView() } + +private extension CaptureSessionClient { + static let perfMock = Self( + fetchIsAuthorized: { true }, + setUpCaptureSession: { _ in AVCaptureSession() }, + stopRunning: {}, + capturePhoto: { Data() }, + switchCamera: { _ in }, + switchFlash: { _ in } + ) +} diff --git a/Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleNavigationTests.swift b/Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleNavigationTests.swift new file mode 100644 index 00000000..673d0493 --- /dev/null +++ b/Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleNavigationTests.swift @@ -0,0 +1,19 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class GoalDetailExampleNavigationTests: XCTestCase { + func testPrimaryCtaPresentsProofPhoto() { + let app = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("goal-detail") + + let primaryCta = app.descendants(matching: .any)["feature.goal-detail.primary-cta"] + XCTAssertTrue(primaryCta.waitForExistence(timeout: 5), "primary-cta not found") + primaryCta.tap() + + let destinationReady = app.descendants(matching: .any)["feature.goal-detail-to-proof-photo.ready"] + XCTAssertTrue( + destinationReady.waitForExistence(timeout: 10), + "goal-detail-to-proof-photo ready marker did not appear" + ) + } +} diff --git a/Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleSmokeTests.swift b/Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleSmokeTests.swift new file mode 100644 index 00000000..a6475fb2 --- /dev/null +++ b/Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleSmokeTests.swift @@ -0,0 +1,9 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class GoalDetailExampleSmokeTests: XCTestCase { + func testExampleRendersReadyState() { + _ = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("goal-detail") + } +} diff --git a/Projects/Feature/GoalDetail/Project.swift b/Projects/Feature/GoalDetail/Project.swift index f406f3d1..88ad786b 100644 --- a/Projects/Feature/GoalDetail/Project.swift +++ b/Projects/Feature/GoalDetail/Project.swift @@ -24,6 +24,7 @@ let project = Project.makeModule( .core(interface: .captureSession), .domain(interface: .photoLog), .shared(implements: .designSystem), + .shared(implements: .perfTestingSupport), .shared(implements: .util), .external(dependency: .ComposableArchitecture) ] @@ -46,25 +47,26 @@ let project = Project.makeModule( ) ), - .feature( - example: .goalDetail, - config: .init( - infoPlist: .extendingDefault( - with: Project.Environment.InfoPlist.launchScreen.merging( - [ - "NSCameraUsageDescription": "UseCamera" - ], - uniquingKeysWith: { current, _ in current } - ) - ), - dependencies: [ - .shared(implements: .designSystem), - .feature(implements: .goalDetail), - .feature(implements: .proofPhoto), - .core(implements: .captureSession), - .external(dependency: .ComposableArchitecture) + .feature( + example: .goalDetail, + config: .init( + infoPlist: .extendingDefault( + with: Project.Environment.InfoPlist.launchScreen.merging( + [ + "NSCameraUsageDescription": "UseCamera" + ], + uniquingKeysWith: { current, _ in current } + ) + ), + dependencies: [ + .shared(implements: .designSystem), + .feature(implements: .goalDetail), + .feature(implements: .proofPhoto), + .core(implements: .captureSession), + .external(dependency: .ComposableArchitecture) ] ) - ) + ), + .feature(exampleUITests: .goalDetail) ] ) diff --git a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift index 5a4bf592..7417eb46 100644 --- a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift +++ b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift @@ -12,6 +12,7 @@ import ComposableArchitecture import FeatureGoalDetailInterface import FeatureProofPhotoInterface import SharedDesignSystem +import SharedPerfTestingSupport import Kingfisher @@ -102,6 +103,7 @@ public struct GoalDetailView: View { content: { IfLetStore(store.scope(state: \.proofPhoto, action: \.proofPhoto)) { store in proofPhotoFactory.makeView(store) + .perfReadyMarker("goal-detail-to-proof-photo") } } ) @@ -369,6 +371,7 @@ private extension GoalDetailView { store.send(.bottomButtonTapped) } ) + .perfControl(slug: "goal-detail", element: "primary-cta") } @ViewBuilder diff --git a/Projects/Feature/GoalDetail/Testing/Sources/Source.swift b/Projects/Feature/GoalDetail/Testing/Sources/Source.swift index 89510740..a4003b50 100644 --- a/Projects/Feature/GoalDetail/Testing/Sources/Source.swift +++ b/Projects/Feature/GoalDetail/Testing/Sources/Source.swift @@ -5,4 +5,7 @@ // Created by Jihun on 01/21/26. // -/// Remove Or Edit +/// Stable perf seed names for the GoalDetail example app. +public enum GoalDetailPerfSeed { + public static let `default` = "default" +} diff --git a/Projects/Feature/Home/Example/Sources/HomeApp.swift b/Projects/Feature/Home/Example/Sources/HomeApp.swift index 8276367b..ca7c992d 100644 --- a/Projects/Feature/Home/Example/Sources/HomeApp.swift +++ b/Projects/Feature/Home/Example/Sources/HomeApp.swift @@ -1,17 +1,121 @@ -// -// HomeView.swift -// -// -// Created by Jihun on 01/26/26. -// - +import AVFoundation +import ComposableArchitecture +import CoreCaptureSession +import CoreCaptureSessionInterface +import DomainGoalInterface +import DomainNotificationInterface +import Foundation +import FeatureGoalDetail +import FeatureGoalDetailInterface +import FeatureHome +import FeatureHomeInterface +import FeatureMakeGoal +import FeatureMakeGoalInterface +import FeatureNotification +import FeatureNotificationInterface +import FeatureProofPhoto +import FeatureProofPhotoInterface +import FeatureSettings +import FeatureSettingsInterface +import FeatureStats +import FeatureStatsInterface +import SharedPerfTestingSupport import SwiftUI @main struct HomeApp: App { + init() { + UITestMode.configureApplication() + } + var body: some Scene { WindowGroup { - Text("Hello Twix") + HomeCoordinatorView( + store: Store( + initialState: HomeCoordinator.State(), + reducer: { + HomeCoordinator( + goalDetailReducer: GoalDetailReducer( + proofPhotoReducer: ProofPhotoReducer() + ), + statsDetailReducer: StatsDetailReducer(), + proofPhotoReducer: ProofPhotoReducer(), + makeGoalReducer: MakeGoalReducer(), + editGoalListReducer: EditGoalListReducer(), + settingsReducer: SettingsReducer(), + notificationReducer: NotificationReducer() + ) + }, + withDependencies: { + $0.goalClient = HomeApp.goalClient(for: UITestMode.seedName) + $0.notificationClient = .previewValue + $0.captureSessionClient = UITestMode.isEnabled ? .perfMock : .liveValue + $0.proofPhotoFactory = .liveValue + $0.goalDetailFactory = .liveValue + $0.statsDetailFactory = .liveValue + $0.makeGoalFactory = .liveValue + $0.settingsFactory = .liveValue + $0.notificationFactory = .liveValue + } + ) + ) + .perfRoot("home") + .perfReadyMarker("home") + } + } +} + +private extension HomeApp { + static func goalClient(for seed: String) -> GoalClient { + guard UITestMode.isEnabled, seed == "scroll-50" else { + return .previewValue + } + var client = GoalClient.previewValue + client.fetchGoals = { _ in + GoalList( + hasEverRegisteredGoal: true, + goals: (1...50).map(perfScrollGoal(index:)) + ) } + return client + } + + static func perfScrollGoal(index: Int) -> Goal { + let id = Int64(index) + let icon: String = index.isMultiple(of: 2) ? "ICON_EXERCISE" : "ICON_BOOK" + let myVerification = Goal.Verification( + photologId: id * 10 + 1, + isCompleted: index.isMultiple(of: 3), + imageURL: nil, + emoji: nil + ) + let yourVerification = Goal.Verification( + photologId: id * 10 + 2, + isCompleted: index.isMultiple(of: 4), + imageURL: nil, + emoji: nil + ) + return Goal( + id: id, + goalIcon: icon, + title: "Perf scroll item #\(index)", + myVerification: myVerification, + yourVerification: yourVerification, + repeatCycle: .daily, + repeatCount: 1, + startDate: "2026-02-01", + endDate: nil + ) } } + +private extension CaptureSessionClient { + static let perfMock = Self( + fetchIsAuthorized: { true }, + setUpCaptureSession: { _ in AVCaptureSession() }, + stopRunning: {}, + capturePhoto: { Data() }, + switchCamera: { _ in }, + switchFlash: { _ in } + ) +} diff --git a/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleNavigationTests.swift b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleNavigationTests.swift new file mode 100644 index 00000000..fa786d34 --- /dev/null +++ b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleNavigationTests.swift @@ -0,0 +1,23 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class HomeExampleNavigationTests: XCTestCase { + func testTappingCellPushesDestination() { + let app = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("home") + + let firstCell = app.descendants(matching: .any) + .matching(NSPredicate(format: "identifier BEGINSWITH 'feature.home.cell.'")) + .firstMatch + XCTAssertTrue(firstCell.waitForExistence(timeout: 5), "no Home cell found") + firstCell.tap() + + let destinationReady = app.descendants(matching: .any) + .matching(NSPredicate(format: "identifier BEGINSWITH 'feature.home-to-'")) + .firstMatch + XCTAssertTrue( + destinationReady.waitForExistence(timeout: 10), + "no destination ready marker (home-to-goal-detail or home-to-stats-detail) appeared" + ) + } +} diff --git a/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleScrollTests.swift b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleScrollTests.swift new file mode 100644 index 00000000..172a249b --- /dev/null +++ b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleScrollTests.swift @@ -0,0 +1,16 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class HomeExampleScrollTests: XCTestCase { + func testScrollFiftyCells() { + let app = XCUIApplication.launchForPerf(seed: "scroll-50") + waitForFeatureReady("home") + + let feed = app.descendants(matching: .any)["feature.home.feed"] + XCTAssertTrue(feed.waitForExistence(timeout: 5), "feature.home.feed not found") + + for _ in 0..<5 { + feed.swipeUp() + } + } +} diff --git a/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleSmokeTests.swift b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleSmokeTests.swift new file mode 100644 index 00000000..590c5fa5 --- /dev/null +++ b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleSmokeTests.swift @@ -0,0 +1,9 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class HomeExampleSmokeTests: XCTestCase { + func testExampleRendersReadyState() { + _ = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("home") + } +} diff --git a/Projects/Feature/Home/Project.swift b/Projects/Feature/Home/Project.swift index ecb166ef..cb3c033f 100644 --- a/Projects/Feature/Home/Project.swift +++ b/Projects/Feature/Home/Project.swift @@ -41,6 +41,7 @@ let project = Project.makeModule( .feature(interface: .home), .core(interface: .analytics), .shared(implements: .designSystem), + .shared(implements: .perfTestingSupport), .shared(implements: .util), .external(dependency: .ComposableArchitecture) ] @@ -69,11 +70,20 @@ let project = Project.makeModule( .feature(interface: .common), .feature(implements: .home), .feature(interface: .home), + .feature(implements: .goalDetail), + .feature(implements: .makeGoal), + .feature(implements: .notification), + .feature(implements: .proofPhoto), + .feature(implements: .settings), + .feature(implements: .stats), + .core(implements: .captureSession), .domain(interface: .goal), + .domain(interface: .notification), .shared(implements: .designSystem), .external(dependency: .ComposableArchitecture) ] ) - ) + ), + .feature(exampleUITests: .home) ] ) diff --git a/Projects/Feature/Home/Sources/Home/HomeView.swift b/Projects/Feature/Home/Sources/Home/HomeView.swift index 932e9e26..1273d03d 100644 --- a/Projects/Feature/Home/Sources/Home/HomeView.swift +++ b/Projects/Feature/Home/Sources/Home/HomeView.swift @@ -11,6 +11,7 @@ import ComposableArchitecture import FeatureHomeInterface import FeatureProofPhotoInterface import SharedDesignSystem +import SharedPerfTestingSupport /// 홈 화면을 렌더링하는 View입니다. /// @@ -207,11 +208,13 @@ private extension HomeView { LazyVStack(spacing: 16) { ForEach(store.items) { item in goalCard(for: item) + .perfCell(slug: "home", stableId: item.id) } } .padding(.top, 12) + .perfFeed("home") } - + func goalCard(for item: HomeGoalItem) -> some View { GoalCardView( item: item.card, diff --git a/Projects/Feature/Home/Sources/Root/HomeCoordinatorView.swift b/Projects/Feature/Home/Sources/Root/HomeCoordinatorView.swift index e80eae05..f32f776f 100644 --- a/Projects/Feature/Home/Sources/Root/HomeCoordinatorView.swift +++ b/Projects/Feature/Home/Sources/Root/HomeCoordinatorView.swift @@ -14,6 +14,7 @@ import FeatureNotificationInterface import FeatureMakeGoalInterface import FeatureSettingsInterface import FeatureStatsInterface +import SharedPerfTestingSupport /// Home Feature의 NavigationStack을 제공하는 Root View입니다. /// @@ -54,12 +55,14 @@ public struct HomeCoordinatorView: View { IfLetStore(store.scope(state: \.goalDetail, action: \.goalDetail)) { store in goalDetailFactory.makeView(store) .toolbar(.hidden, for: .tabBar) + .perfReadyMarker("home-to-goal-detail") } case .statsDetail: IfLetStore(store.scope(state: \.statsDetail, action: \.statsDetail)) { store in statsDetailFactory.makeView(store) .toolbar(.hidden, for: .tabBar) + .perfReadyMarker("home-to-stats-detail") } case .editGoalList: diff --git a/Projects/Feature/Home/Testing/Sources/Source.swift b/Projects/Feature/Home/Testing/Sources/Source.swift index 147ab9c1..02f65c13 100644 --- a/Projects/Feature/Home/Testing/Sources/Source.swift +++ b/Projects/Feature/Home/Testing/Sources/Source.swift @@ -5,4 +5,7 @@ // Created by Jihun on 01/26/26. // -/// Remove Or Edit +/// Stable perf seed names for the Home example app. +public enum HomePerfSeed { + public static let `default` = "default" +} diff --git a/Projects/Feature/MainTab/Example/Sources/MainTabExampleApp.swift b/Projects/Feature/MainTab/Example/Sources/MainTabExampleApp.swift index 2e49e85f..a5ea26da 100644 --- a/Projects/Feature/MainTab/Example/Sources/MainTabExampleApp.swift +++ b/Projects/Feature/MainTab/Example/Sources/MainTabExampleApp.swift @@ -6,12 +6,19 @@ // import SwiftUI +import SharedPerfTestingSupport @main struct MainTabExampleApp: App { + init() { + UITestMode.configureApplication() + } + var body: some Scene { WindowGroup { MainTabExampleView() + .perfRoot("main-tab") + .perfReadyMarker("main-tab") } } } diff --git a/Projects/Feature/MainTab/Example/Sources/MainTabExampleView.swift b/Projects/Feature/MainTab/Example/Sources/MainTabExampleView.swift index 0cefbeeb..2c026e4f 100644 --- a/Projects/Feature/MainTab/Example/Sources/MainTabExampleView.swift +++ b/Projects/Feature/MainTab/Example/Sources/MainTabExampleView.swift @@ -5,14 +5,17 @@ // Created by 정지훈 on 1/28/26. // +import AVFoundation import SwiftUI import ComposableArchitecture import Feature import CoreCaptureSession +import CoreCaptureSessionInterface import DomainGoalInterface import FeatureMakeGoal import FeatureMakeGoalInterface +import SharedPerfTestingSupport struct MainTabExampleView: View { var body: some View { @@ -23,7 +26,7 @@ struct MainTabExampleView: View { MainTabReducer() }, withDependencies: { $0.goalClient = .previewValue - $0.captureSessionClient = .liveValue + $0.captureSessionClient = UITestMode.isEnabled ? .perfMock : .liveValue $0.proofPhotoFactory = .liveValue $0.goalDetailFactory = .liveValue $0.makeGoalFactory = .liveValue @@ -36,3 +39,14 @@ struct MainTabExampleView: View { #Preview { MainTabExampleView() } + +private extension CaptureSessionClient { + static let perfMock = Self( + fetchIsAuthorized: { true }, + setUpCaptureSession: { _ in AVCaptureSession() }, + stopRunning: {}, + capturePhoto: { Data() }, + switchCamera: { _ in }, + switchFlash: { _ in } + ) +} diff --git a/Projects/Feature/MainTab/ExampleUITests/Sources/MainTabExampleSmokeTests.swift b/Projects/Feature/MainTab/ExampleUITests/Sources/MainTabExampleSmokeTests.swift new file mode 100644 index 00000000..88621a30 --- /dev/null +++ b/Projects/Feature/MainTab/ExampleUITests/Sources/MainTabExampleSmokeTests.swift @@ -0,0 +1,9 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class MainTabExampleSmokeTests: XCTestCase { + func testExampleRendersReadyState() { + _ = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("main-tab") + } +} diff --git a/Projects/Feature/MainTab/Project.swift b/Projects/Feature/MainTab/Project.swift index 4ae1062c..d4158fd9 100644 --- a/Projects/Feature/MainTab/Project.swift +++ b/Projects/Feature/MainTab/Project.swift @@ -40,6 +40,7 @@ let project = Project.makeModule( .core(implements: .captureSession) ] ) - ) + ), + .feature(exampleUITests: .mainTab) ] ) diff --git a/Projects/Feature/MakeGoal/Example/Sources/MakeGoalApp.swift b/Projects/Feature/MakeGoal/Example/Sources/MakeGoalApp.swift index 15d74e86..6e054739 100644 --- a/Projects/Feature/MakeGoal/Example/Sources/MakeGoalApp.swift +++ b/Projects/Feature/MakeGoal/Example/Sources/MakeGoalApp.swift @@ -1,17 +1,29 @@ -// -// MakeGoalView.swift -// -// -// Created by Jihun on 02/22/26. -// - +import ComposableArchitecture +import DomainGoalInterface +import FeatureMakeGoal +import FeatureMakeGoalInterface +import SharedPerfTestingSupport import SwiftUI @main struct MakeGoalApp: App { + init() { + UITestMode.configureApplication() + } + var body: some Scene { WindowGroup { - Text("Hello Twix") + MakeGoalView( + store: Store( + initialState: MakeGoalReducer.State(mode: .add(.book)), + reducer: { MakeGoalReducer() }, + withDependencies: { + $0.goalClient = .previewValue + } + ) + ) + .perfRoot("make-goal") + .perfReadyMarker("make-goal") } } } diff --git a/Projects/Feature/MakeGoal/ExampleUITests/Sources/MakeGoalExampleSmokeTests.swift b/Projects/Feature/MakeGoal/ExampleUITests/Sources/MakeGoalExampleSmokeTests.swift new file mode 100644 index 00000000..9527ddb3 --- /dev/null +++ b/Projects/Feature/MakeGoal/ExampleUITests/Sources/MakeGoalExampleSmokeTests.swift @@ -0,0 +1,9 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class MakeGoalExampleSmokeTests: XCTestCase { + func testExampleRendersReadyState() { + _ = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("make-goal") + } +} diff --git a/Projects/Feature/MakeGoal/Project.swift b/Projects/Feature/MakeGoal/Project.swift index 3e67707c..35d276ac 100644 --- a/Projects/Feature/MakeGoal/Project.swift +++ b/Projects/Feature/MakeGoal/Project.swift @@ -60,6 +60,7 @@ let project = Project.makeModule( .external(dependency: .ComposableArchitecture) ] ) - ) + ), + .feature(exampleUITests: .makeGoal) ] ) diff --git a/Projects/Feature/MakeGoal/Testing/Sources/Source.swift b/Projects/Feature/MakeGoal/Testing/Sources/Source.swift index bc1712c4..f7da3195 100644 --- a/Projects/Feature/MakeGoal/Testing/Sources/Source.swift +++ b/Projects/Feature/MakeGoal/Testing/Sources/Source.swift @@ -5,4 +5,7 @@ // Created by Jihun on 02/22/26. // -/// Remove Or Edit +/// Stable perf seed names for the MakeGoal example app. +public enum MakeGoalPerfSeed { + public static let `default` = "default" +} diff --git a/Projects/Feature/Notification/Example/Sources/NotificationApp.swift b/Projects/Feature/Notification/Example/Sources/NotificationApp.swift index 3d216b9d..c8af5da8 100644 --- a/Projects/Feature/Notification/Example/Sources/NotificationApp.swift +++ b/Projects/Feature/Notification/Example/Sources/NotificationApp.swift @@ -9,10 +9,15 @@ import ComposableArchitecture import FeatureNotification import FeatureNotificationInterface import SharedDesignSystem +import SharedPerfTestingSupport import SwiftUI @main struct NotificationApp: App { + init() { + UITestMode.configureApplication() + } + var body: some Scene { WindowGroup { NotificationView( @@ -25,6 +30,8 @@ struct NotificationApp: App { } ) ) + .perfRoot("notification") + .perfReadyMarker("notification") } } } @@ -32,6 +39,8 @@ struct NotificationApp: App { // MARK: - Mock Data extension NotificationApp { + static let referenceDate = Date(timeIntervalSince1970: 1_772_496_000) + static let mockNotifications: IdentifiedArrayOf = [ NotificationItem( id: 1, @@ -40,7 +49,7 @@ extension NotificationApp { message: "닉네임길어도될까님과 연결됐어요!", deepLink: nil, isRead: false, - createdAt: Date() + createdAt: referenceDate ), NotificationItem( id: 2, @@ -49,7 +58,7 @@ extension NotificationApp { message: "닉네임길어도될까님의 오늘 하루가 등록됐어요. 확인해 볼까요?", deepLink: nil, isRead: false, - createdAt: Date() + createdAt: referenceDate ), NotificationItem( id: 3, @@ -58,7 +67,7 @@ extension NotificationApp { message: "닉네임길어도될까님이 찔렀어요! 오늘 하루도 파이팅~", deepLink: nil, isRead: false, - createdAt: Date() + createdAt: referenceDate ), NotificationItem( id: 4, @@ -67,7 +76,7 @@ extension NotificationApp { message: "닉네임길어도될까님이 끝냄 인증샷을 올렸네요! 보러 가봐요!", deepLink: nil, isRead: false, - createdAt: Date() + createdAt: referenceDate ), NotificationItem( id: 5, @@ -76,7 +85,7 @@ extension NotificationApp { message: "닉네임길어도될까님이 내게 반응을 남겼어요. 보러 가봐요!", deepLink: nil, isRead: true, - createdAt: Calendar.current.date(byAdding: .day, value: -1, to: Date()) ?? Date() + createdAt: Calendar.current.date(byAdding: .day, value: -1, to: referenceDate) ?? referenceDate ), NotificationItem( id: 6, @@ -85,7 +94,7 @@ extension NotificationApp { message: "축하해요! 오늘도 열심히 산 우리에게 박수~", deepLink: nil, isRead: true, - createdAt: Calendar.current.date(byAdding: .day, value: -2, to: Date()) ?? Date() + createdAt: Calendar.current.date(byAdding: .day, value: -2, to: referenceDate) ?? referenceDate ), NotificationItem( id: 7, @@ -94,7 +103,7 @@ extension NotificationApp { message: "축하해요! 오늘도 열심히 산 우리에게 박수~", deepLink: nil, isRead: true, - createdAt: Calendar.current.date(byAdding: .day, value: -3, to: Date()) ?? Date() + createdAt: Calendar.current.date(byAdding: .day, value: -3, to: referenceDate) ?? referenceDate ), NotificationItem( id: 8, @@ -103,7 +112,7 @@ extension NotificationApp { message: "축하해요! 오늘도 열심히 산 우리에게 박수~", deepLink: nil, isRead: true, - createdAt: Calendar.current.date(byAdding: .day, value: -5, to: Date()) ?? Date() + createdAt: Calendar.current.date(byAdding: .day, value: -5, to: referenceDate) ?? referenceDate ), NotificationItem( id: 9, @@ -112,7 +121,7 @@ extension NotificationApp { message: "축하해요! 오늘도 열심히 산 우리에게 박수~", deepLink: nil, isRead: true, - createdAt: Calendar.current.date(byAdding: .day, value: -7, to: Date()) ?? Date() + createdAt: Calendar.current.date(byAdding: .day, value: -7, to: referenceDate) ?? referenceDate ), NotificationItem( id: 10, @@ -121,7 +130,7 @@ extension NotificationApp { message: "축하해요! 오늘도 열심히 산 우리에게 박수~", deepLink: nil, isRead: true, - createdAt: Calendar.current.date(byAdding: .day, value: -10, to: Date()) ?? Date() + createdAt: Calendar.current.date(byAdding: .day, value: -10, to: referenceDate) ?? referenceDate ), // 14일 초과 - 필터링되어 표시되지 않음 NotificationItem( @@ -131,7 +140,7 @@ extension NotificationApp { message: "이 알림은 15일 전이라 표시되지 않아요", deepLink: nil, isRead: true, - createdAt: Calendar.current.date(byAdding: .day, value: -15, to: Date()) ?? Date() + createdAt: Calendar.current.date(byAdding: .day, value: -15, to: referenceDate) ?? referenceDate ), NotificationItem( id: 12, @@ -140,7 +149,7 @@ extension NotificationApp { message: "이 알림은 20일 전이라 표시되지 않아요", deepLink: nil, isRead: true, - createdAt: Calendar.current.date(byAdding: .day, value: -20, to: Date()) ?? Date() + createdAt: Calendar.current.date(byAdding: .day, value: -20, to: referenceDate) ?? referenceDate ) ] } diff --git a/Projects/Feature/Notification/ExampleUITests/Sources/NotificationExampleSmokeTests.swift b/Projects/Feature/Notification/ExampleUITests/Sources/NotificationExampleSmokeTests.swift new file mode 100644 index 00000000..bd4835ca --- /dev/null +++ b/Projects/Feature/Notification/ExampleUITests/Sources/NotificationExampleSmokeTests.swift @@ -0,0 +1,9 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class NotificationExampleSmokeTests: XCTestCase { + func testExampleRendersReadyState() { + _ = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("notification") + } +} diff --git a/Projects/Feature/Notification/Project.swift b/Projects/Feature/Notification/Project.swift index e4b68425..a18600d4 100644 --- a/Projects/Feature/Notification/Project.swift +++ b/Projects/Feature/Notification/Project.swift @@ -37,6 +37,7 @@ let project = Project.makeModule( .external(dependency: .ComposableArchitecture) ] ) - ) + ), + .feature(exampleUITests: .notification) ] ) diff --git a/Projects/Feature/Onboarding/Example/Sources/OnboardingApp.swift b/Projects/Feature/Onboarding/Example/Sources/OnboardingApp.swift index b1070814..749ccf87 100644 --- a/Projects/Feature/Onboarding/Example/Sources/OnboardingApp.swift +++ b/Projects/Feature/Onboarding/Example/Sources/OnboardingApp.swift @@ -9,6 +9,7 @@ import ComposableArchitecture import DomainNotificationInterface import DomainOnboardingInterface import FeatureOnboarding +import SharedPerfTestingSupport import SwiftUI @main @@ -16,6 +17,7 @@ struct OnboardingApp: App { let store: StoreOf init() { + UITestMode.configureApplication() self.store = Store( initialState: OnboardingCoordinator.State( myInviteCode: "KDJ34923" @@ -31,6 +33,8 @@ struct OnboardingApp: App { var body: some Scene { WindowGroup { OnboardingCoordinatorView(store: store) + .perfRoot("onboarding") + .perfReadyMarker("onboarding") .onOpenURL { url in if let code = parseInviteCode(from: url) { store.send(.deepLinkReceived(code: code)) diff --git a/Projects/Feature/Onboarding/ExampleUITests/Sources/OnboardingExampleSmokeTests.swift b/Projects/Feature/Onboarding/ExampleUITests/Sources/OnboardingExampleSmokeTests.swift new file mode 100644 index 00000000..1c54e9ad --- /dev/null +++ b/Projects/Feature/Onboarding/ExampleUITests/Sources/OnboardingExampleSmokeTests.swift @@ -0,0 +1,9 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class OnboardingExampleSmokeTests: XCTestCase { + func testExampleRendersReadyState() { + _ = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("onboarding") + } +} diff --git a/Projects/Feature/Onboarding/Project.swift b/Projects/Feature/Onboarding/Project.swift index 6cf71a21..b755e4ce 100644 --- a/Projects/Feature/Onboarding/Project.swift +++ b/Projects/Feature/Onboarding/Project.swift @@ -54,6 +54,7 @@ let project = Project.makeModule( .external(dependency: .ComposableArchitecture) ] ) - ) + ), + .feature(exampleUITests: .onboarding) ] ) diff --git a/Projects/Feature/Onboarding/Testing/Sources/Source.swift b/Projects/Feature/Onboarding/Testing/Sources/Source.swift index 2edc37e7..adceace1 100644 --- a/Projects/Feature/Onboarding/Testing/Sources/Source.swift +++ b/Projects/Feature/Onboarding/Testing/Sources/Source.swift @@ -5,4 +5,7 @@ // Created by Jihun on 12/29/25. // -/// Remove Or Edit Or Edit +/// Stable perf seed names for the Onboarding example app. +public enum OnboardingPerfSeed { + public static let `default` = "default" +} diff --git a/Projects/Feature/ProofPhoto/Example/Sources/ProofPhotoApp.swift b/Projects/Feature/ProofPhoto/Example/Sources/ProofPhotoApp.swift index 381ce2cd..71c59862 100644 --- a/Projects/Feature/ProofPhoto/Example/Sources/ProofPhotoApp.swift +++ b/Projects/Feature/ProofPhoto/Example/Sources/ProofPhotoApp.swift @@ -1,17 +1,70 @@ -// -// ProofPhotoView.swift -// -// -// Created by Jihun on 01/25/26. -// - +import AVFoundation +import ComposableArchitecture +import CoreCaptureSession +import CoreCaptureSessionInterface +import CoreCrashlyticsInterface +import DomainPhotoLogInterface +import FeatureProofPhoto +import FeatureProofPhotoInterface +import SharedPerfTestingSupport import SwiftUI @main struct ProofPhotoApp: App { + init() { + UITestMode.configureApplication() + } + var body: some Scene { WindowGroup { - Text("Hello Twix") + ProofPhotoView( + store: Store( + initialState: ProofPhotoReducer.State( + goalId: 1, + verificationDate: "2026-02-07" + ), + reducer: { ProofPhotoReducer() }, + withDependencies: { + $0.captureSessionClient = UITestMode.isEnabled ? .perfMock : .liveValue + $0.photoLogClient = .perfMock + $0.crashlyticsClient = .previewValue + } + ) + ) + .perfRoot("proof-photo") + .perfReadyMarker("proof-photo") } } } + +private extension CaptureSessionClient { + static let perfMock = Self( + fetchIsAuthorized: { true }, + setUpCaptureSession: { _ in AVCaptureSession() }, + stopRunning: {}, + capturePhoto: { Data() }, + switchCamera: { _ in }, + switchFlash: { _ in } + ) +} + +private extension PhotoLogClient { + static let perfMock = Self( + fetchUploadURL: { _ in .init(uploadUrl: "", fileName: "") }, + uploadImageData: { _, _ in }, + createPhotoLog: { request in + .init( + photologId: 1, + goalId: request.goalId, + imageUrl: "", + comment: request.comment, + verificationDate: request.verificationDate + ) + }, + updateReaction: { _, request in + .init(photologId: 1, reaction: request.reaction) + }, + updatePhotoLog: { _, _ in }, + deletePhotoLog: { _ in } + ) +} diff --git a/Projects/Feature/ProofPhoto/ExampleUITests/Sources/ProofPhotoExampleSmokeTests.swift b/Projects/Feature/ProofPhoto/ExampleUITests/Sources/ProofPhotoExampleSmokeTests.swift new file mode 100644 index 00000000..e48c8541 --- /dev/null +++ b/Projects/Feature/ProofPhoto/ExampleUITests/Sources/ProofPhotoExampleSmokeTests.swift @@ -0,0 +1,9 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class ProofPhotoExampleSmokeTests: XCTestCase { + func testExampleRendersReadyState() { + _ = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("proof-photo") + } +} diff --git a/Projects/Feature/ProofPhoto/Project.swift b/Projects/Feature/ProofPhoto/Project.swift index 08d967ee..5c8aabdb 100644 --- a/Projects/Feature/ProofPhoto/Project.swift +++ b/Projects/Feature/ProofPhoto/Project.swift @@ -51,9 +51,15 @@ let project = Project.makeModule( example: .proofPhoto, config: .init( dependencies: [ - .feature(interface: .proofPhoto) + .feature(interface: .proofPhoto), + .feature(implements: .proofPhoto), + .core(implements: .captureSession), + .core(interface: .crashlytics), + .domain(interface: .photoLog), + .external(dependency: .ComposableArchitecture) ] ) - ) + ), + .feature(exampleUITests: .proofPhoto) ] ) diff --git a/Projects/Feature/ProofPhoto/Testing/Sources/Source.swift b/Projects/Feature/ProofPhoto/Testing/Sources/Source.swift index 408f31ec..e5e32790 100644 --- a/Projects/Feature/ProofPhoto/Testing/Sources/Source.swift +++ b/Projects/Feature/ProofPhoto/Testing/Sources/Source.swift @@ -5,4 +5,7 @@ // Created by Jihun on 01/25/26. // -/// Remove Or Edit +/// Stable perf seed names for the ProofPhoto example app. +public enum ProofPhotoPerfSeed { + public static let `default` = "default" +} diff --git a/Projects/Feature/Settings/Example/Sources/SettingsApp.swift b/Projects/Feature/Settings/Example/Sources/SettingsApp.swift index 3d73a502..7cb1aa51 100644 --- a/Projects/Feature/Settings/Example/Sources/SettingsApp.swift +++ b/Projects/Feature/Settings/Example/Sources/SettingsApp.swift @@ -11,10 +11,15 @@ import DomainOnboardingInterface import FeatureSettings import FeatureSettingsInterface import SharedDesignSystem +import SharedPerfTestingSupport import SwiftUI @main struct SettingsApp: App { + init() { + UITestMode.configureApplication() + } + var body: some Scene { WindowGroup { SettingsView( @@ -32,6 +37,8 @@ struct SettingsApp: App { } ) ) + .perfRoot("settings") + .perfReadyMarker("settings") } } } diff --git a/Projects/Feature/Settings/ExampleUITests/Sources/SettingsExampleSmokeTests.swift b/Projects/Feature/Settings/ExampleUITests/Sources/SettingsExampleSmokeTests.swift new file mode 100644 index 00000000..90b0dd94 --- /dev/null +++ b/Projects/Feature/Settings/ExampleUITests/Sources/SettingsExampleSmokeTests.swift @@ -0,0 +1,9 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class SettingsExampleSmokeTests: XCTestCase { + func testExampleRendersReadyState() { + _ = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("settings") + } +} diff --git a/Projects/Feature/Settings/Project.swift b/Projects/Feature/Settings/Project.swift index 2b8ff29c..cd0b84de 100644 --- a/Projects/Feature/Settings/Project.swift +++ b/Projects/Feature/Settings/Project.swift @@ -45,6 +45,7 @@ let project = Project.makeModule( .external(dependency: .ComposableArchitecture) ] ) - ) + ), + .feature(exampleUITests: .settings) ] -) \ No newline at end of file +) diff --git a/Projects/Feature/Stats/Example/Sources/StatsApp.swift b/Projects/Feature/Stats/Example/Sources/StatsApp.swift index efe4574b..ebcb6b1f 100644 --- a/Projects/Feature/Stats/Example/Sources/StatsApp.swift +++ b/Projects/Feature/Stats/Example/Sources/StatsApp.swift @@ -18,9 +18,15 @@ import FeatureStats import FeatureStatsInterface import FeatureProofPhoto import FeatureProofPhotoInterface +import Foundation +import SharedPerfTestingSupport @main struct StatsApp: App { + init() { + UITestMode.configureApplication() + } + var body: some Scene { WindowGroup { StatsCoordinatorView( @@ -37,13 +43,49 @@ struct StatsApp: App { ) }, withDependencies: { - $0.statsClient = .previewValue + $0.statsClient = StatsApp.statsClient(for: UITestMode.seedName) $0.goalDetailFactory = .liveValue $0.makeGoalFactory = .liveValue $0.goalClient = .previewValue } ) ) + .perfRoot("stats") + .perfReadyMarker("stats") + } + } +} + +private extension StatsApp { + static func statsClient(for seed: String) -> StatsClient { + guard UITestMode.isEnabled, seed == "scroll-50" else { + return .previewValue + } + var client = StatsClient.previewValue + client.fetchStats = { _, _ in + Stats( + myNickname: "현수", + partnerNickname: "민정", + stats: (1...50).map { index in + Stats.StatsItem( + goalId: Int64(index), + icon: index.isMultiple(of: 2) ? "ICON_BOOK" : "ICON_HEALTH", + goalName: "Perf scroll item #\(index)", + monthlyCount: index % 30, + totalCount: nil, + stamp: index.isMultiple(of: 3) ? "CLOVER" : "FLOWER", + myStamp: .init( + completedCount: index % 12, + stampColors: [.pink200, .orange400, .purple400] + ), + partnerStamp: .init( + completedCount: index % 9, + stampColors: [.green400, .orange400, .yellow400] + ) + ) + } + ) } + return client } } diff --git a/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleNavigationTests.swift b/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleNavigationTests.swift new file mode 100644 index 00000000..30b0bb71 --- /dev/null +++ b/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleNavigationTests.swift @@ -0,0 +1,21 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class StatsExampleNavigationTests: XCTestCase { + func testTappingCellPushesStatsDetail() { + let app = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("stats") + + let firstCell = app.descendants(matching: .any) + .matching(NSPredicate(format: "identifier BEGINSWITH 'feature.stats.cell.'")) + .firstMatch + XCTAssertTrue(firstCell.waitForExistence(timeout: 5), "no Stats cell found") + firstCell.tap() + + let destinationReady = app.descendants(matching: .any)["feature.stats-to-stats-detail.ready"] + XCTAssertTrue( + destinationReady.waitForExistence(timeout: 10), + "stats-to-stats-detail ready marker did not appear" + ) + } +} diff --git a/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleScrollTests.swift b/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleScrollTests.swift new file mode 100644 index 00000000..1e5adc0e --- /dev/null +++ b/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleScrollTests.swift @@ -0,0 +1,16 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class StatsExampleScrollTests: XCTestCase { + func testScrollFiftyCells() { + let app = XCUIApplication.launchForPerf(seed: "scroll-50") + waitForFeatureReady("stats") + + let feed = app.descendants(matching: .any)["feature.stats.feed"] + XCTAssertTrue(feed.waitForExistence(timeout: 5), "feature.stats.feed not found") + + for _ in 0..<5 { + feed.swipeUp() + } + } +} diff --git a/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleSmokeTests.swift b/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleSmokeTests.swift new file mode 100644 index 00000000..3acd39b1 --- /dev/null +++ b/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleSmokeTests.swift @@ -0,0 +1,9 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class StatsExampleSmokeTests: XCTestCase { + func testExampleRendersReadyState() { + _ = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("stats") + } +} diff --git a/Projects/Feature/Stats/Project.swift b/Projects/Feature/Stats/Project.swift index 86cb3b12..9f16849f 100644 --- a/Projects/Feature/Stats/Project.swift +++ b/Projects/Feature/Stats/Project.swift @@ -32,6 +32,7 @@ let project = Project.makeModule( .domain(interface: .stats), .core(interface: .analytics), .shared(implements: .designSystem), + .shared(implements: .perfTestingSupport), .external(dependency: .ComposableArchitecture) ] ) @@ -72,6 +73,7 @@ let project = Project.makeModule( .external(dependency: .ComposableArchitecture) ] ) - ) + ), + .feature(exampleUITests: .stats) ] ) diff --git a/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinatorView.swift b/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinatorView.swift index f358ffec..4e2715d0 100644 --- a/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinatorView.swift +++ b/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinatorView.swift @@ -11,6 +11,7 @@ import ComposableArchitecture import FeatureGoalDetailInterface import FeatureMakeGoalInterface import FeatureStatsInterface +import SharedPerfTestingSupport /// Stats Feature의 루트 화면을 렌더링하는 Coordinator View입니다. public struct StatsCoordinatorView: View { @@ -46,6 +47,7 @@ public struct StatsCoordinatorView: View { IfLetStore(store.scope(state: \.statsDetail, action: \.statsDetail)) { store in StatsDetailView(store: store) .toolbar(.hidden, for: .tabBar) + .perfReadyMarker("stats-to-stats-detail") } case .goalDetail: diff --git a/Projects/Feature/Stats/Sources/Stats/StatsView.swift b/Projects/Feature/Stats/Sources/Stats/StatsView.swift index 8d010adc..a186112b 100644 --- a/Projects/Feature/Stats/Sources/Stats/StatsView.swift +++ b/Projects/Feature/Stats/Sources/Stats/StatsView.swift @@ -10,6 +10,7 @@ import SwiftUI import ComposableArchitecture import FeatureStatsInterface import SharedDesignSystem +import SharedPerfTestingSupport struct StatsView: View { @Bindable public var store: StoreOf @@ -81,10 +82,12 @@ private extension StatsView { store.send(.statsCardTapped(goalId: goalId)) } ) + .perfCell(slug: "stats", stableId: item.goalId) } } .padding(.top, store.isOngoing ? 12 : 20) .padding([.horizontal, .bottom], 20) + .perfFeed("stats") } .background(Color.Gray.gray50) } diff --git a/Projects/Feature/Stats/Testing/Sources/Source.swift b/Projects/Feature/Stats/Testing/Sources/Source.swift index 5bf96b27..c2ec005a 100644 --- a/Projects/Feature/Stats/Testing/Sources/Source.swift +++ b/Projects/Feature/Stats/Testing/Sources/Source.swift @@ -5,4 +5,7 @@ // Created by Jihun on 02/18/26. // -/// Remove Or Edit +/// Stable perf seed names for the Stats example app. +public enum StatsPerfSeed { + public static let `default` = "default" +} diff --git a/Projects/Shared/PerfTestingSupport/Project.swift b/Projects/Shared/PerfTestingSupport/Project.swift new file mode 100644 index 00000000..123fcc19 --- /dev/null +++ b/Projects/Shared/PerfTestingSupport/Project.swift @@ -0,0 +1,19 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project.makeModule( + name: Module.Shared.name + Module.Shared.perfTestingSupport.rawValue, + targets: [ + .shared( + implements: .perfTestingSupport, + config: .init( + dependencies: [ + .external(dependency: .ComposableArchitecture) + ] + ) + ), + .sharedPerfTestingSupportUITests( + config: .init() + ) + ] +) diff --git a/Projects/Shared/PerfTestingSupport/Sources/UITestMode.swift b/Projects/Shared/PerfTestingSupport/Sources/UITestMode.swift new file mode 100644 index 00000000..586ee7cc --- /dev/null +++ b/Projects/Shared/PerfTestingSupport/Sources/UITestMode.swift @@ -0,0 +1,44 @@ +import ComposableArchitecture +import SwiftUI + +/// Launch argument contract shared by Example apps and perf UITests. +public enum UITestMode { + private static let arguments = ProcessInfo.processInfo.arguments + + public static var isEnabled: Bool { + arguments.contains("-UITEST") + } + + public static var seedName: String { + value(after: "-UITEST_SEED") ?? "default" + } + + public static var disablesAnimations: Bool { + arguments.contains("-UITEST_DISABLE_ANIMATIONS") + } + + public static var waitsForReady: Bool { + arguments.contains("-UITEST_WAIT_READY") + } + + public static func configureApplication() { + guard isEnabled, disablesAnimations else { return } + UIView.setAnimationsEnabled(false) + } + + public static func dependencyValues( + _ update: @escaping (inout DependencyValues) -> Void + ) -> (inout DependencyValues) -> Void { + { values in + guard isEnabled else { return } + update(&values) + } + } + + private static func value(after key: String) -> String? { + guard let index = arguments.firstIndex(of: key) else { return nil } + let valueIndex = arguments.index(after: index) + guard arguments.indices.contains(valueIndex) else { return nil } + return arguments[valueIndex] + } +} diff --git a/Projects/Shared/PerfTestingSupport/Sources/View+PerfAccessibility.swift b/Projects/Shared/PerfTestingSupport/Sources/View+PerfAccessibility.swift new file mode 100644 index 00000000..08b71dd2 --- /dev/null +++ b/Projects/Shared/PerfTestingSupport/Sources/View+PerfAccessibility.swift @@ -0,0 +1,27 @@ +import SwiftUI + +public extension View { + func perfRoot(_ slug: String) -> some View { + accessibilityIdentifier("feature.\(slug).root") + } + + func perfFeed(_ slug: String) -> some View { + accessibilityIdentifier("feature.\(slug).feed") + } + + func perfCell(slug: String, stableId: CustomStringConvertible) -> some View { + accessibilityIdentifier("feature.\(slug).cell.\(stableId)") + } + + func perfControl(slug: String, element: String) -> some View { + accessibilityIdentifier("feature.\(slug).\(element)") + } + + func perfReadyMarker(_ slug: String) -> some View { + overlay(alignment: .topLeading) { + Color.clear + .frame(width: 1, height: 1) + .accessibilityIdentifier("feature.\(slug).ready") + } + } +} diff --git a/Projects/Shared/PerfTestingSupport/UITests/Sources/XCTestCase+Perf.swift b/Projects/Shared/PerfTestingSupport/UITests/Sources/XCTestCase+Perf.swift new file mode 100644 index 00000000..b469d0e8 --- /dev/null +++ b/Projects/Shared/PerfTestingSupport/UITests/Sources/XCTestCase+Perf.swift @@ -0,0 +1,25 @@ +import XCTest + +public func waitForFeatureReady( + _ slug: String, + timeout: TimeInterval = 10, + file: StaticString = #filePath, + line: UInt = #line +) { + let app = XCUIApplication() + let ready = app.descendants(matching: .any)["feature.\(slug).ready"] + XCTAssertTrue( + ready.waitForExistence(timeout: timeout), + "Timed out waiting for feature.\(slug).ready", + file: file, + line: line + ) +} + +public var defaultPerfMetrics: [XCTMetric] { + [ + XCTClockMetric(), + XCTMemoryMetric(), + XCTCPUMetric() + ] +} diff --git a/Projects/Shared/PerfTestingSupport/UITests/Sources/XCUIApplication+Perf.swift b/Projects/Shared/PerfTestingSupport/UITests/Sources/XCUIApplication+Perf.swift new file mode 100644 index 00000000..f30e55c2 --- /dev/null +++ b/Projects/Shared/PerfTestingSupport/UITests/Sources/XCUIApplication+Perf.swift @@ -0,0 +1,20 @@ +import XCTest + +public extension XCUIApplication { + static func launchForPerf( + seed: String, + disableAnimations: Bool = true + ) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments.append("-UITEST") + app.launchArguments.append(contentsOf: ["-UITEST_SEED", seed]) + app.launchArguments.append("-UITEST_WAIT_READY") + + if disableAnimations { + app.launchArguments.append("-UITEST_DISABLE_ANIMATIONS") + } + + app.launch() + return app + } +} diff --git a/Scripts/verify-perf-targets.sh b/Scripts/verify-perf-targets.sh new file mode 100755 index 00000000..e6554693 --- /dev/null +++ b/Scripts/verify-perf-targets.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +DEVICE_NAME="Jiyong의 iPhone" +TIME_LIMIT="${TIME_LIMIT:-5s}" +OUTPUT_DIR="${OUTPUT_DIR:-/tmp/twix-perf-traces}" + +if [[ -z "$DEVICE_NAME" ]]; then + echo "error: set DEVICE_NAME to a connected iOS device name." >&2 + exit 64 +fi + +mkdir -p "$OUTPUT_DIR" + +features=( + "auth:org.yapp.twix.example.auth" + "goal-detail:org.yapp.twix.example.goal-detail" + "home:org.yapp.twix.example.home" + "main-tab:org.yapp.twix.example.main-tab" + "make-goal:org.yapp.twix.example.make-goal" + "notification:org.yapp.twix.example.notification" + "onboarding:org.yapp.twix.example.onboarding" + "proof-photo:org.yapp.twix.example.proof-photo" + "settings:org.yapp.twix.example.settings" + "stats:org.yapp.twix.example.stats" +) + +for item in "${features[@]}"; do + slug="${item%%:*}" + bundle_id="${item#*:}" + output="$OUTPUT_DIR/$slug.trace" + + rm -rf "$output" + echo "recording $slug ($bundle_id)" + xcrun xctrace record \ + --device "$DEVICE_NAME" \ + --template "Time Profiler" \ + --time-limit "$TIME_LIMIT" \ + --output "$output" \ + --launch "$bundle_id" \ + -- \ + -UITEST \ + -UITEST_SEED default \ + -UITEST_DISABLE_ANIMATIONS \ + -UITEST_WAIT_READY +done + +echo "traces written to $OUTPUT_DIR" diff --git a/Tuist/ProjectDescriptionHelpers/InfoPlist/InfoPlist+Defaults.swift b/Tuist/ProjectDescriptionHelpers/InfoPlist/InfoPlist+Defaults.swift index 84976f58..6e18f064 100644 --- a/Tuist/ProjectDescriptionHelpers/InfoPlist/InfoPlist+Defaults.swift +++ b/Tuist/ProjectDescriptionHelpers/InfoPlist/InfoPlist+Defaults.swift @@ -36,4 +36,20 @@ public extension InfoPlist { return self } } + + func mergingExampleDisplayName(_ displayName: String) -> InfoPlist { + switch self { + case .default: + return .extendingDefault(with: ["CFBundleDisplayName": .string(displayName)]) + + case .extendingDefault(let dict): + let merged = dict.merging( + ["CFBundleDisplayName": .string(displayName)] + ) { current, _ in current } + return .extendingDefault(with: merged) + + default: + return self + } + } } diff --git a/Tuist/ProjectDescriptionHelpers/Module.swift b/Tuist/ProjectDescriptionHelpers/Module.swift index 7a264bba..3359abfc 100644 --- a/Tuist/ProjectDescriptionHelpers/Module.swift +++ b/Tuist/ProjectDescriptionHelpers/Module.swift @@ -112,6 +112,7 @@ public extension Module { case util = "Util" case thirdPartyLib = "ThirdPartyLib" case designSystem = "DesignSystem" + case perfTestingSupport = "PerfTestingSupport" /// Shared 타겟 이름의 기본 prefix입니다. public static let name: String = "Shared" diff --git a/Tuist/ProjectDescriptionHelpers/Project/Project+MakeModule.swift b/Tuist/ProjectDescriptionHelpers/Project/Project+MakeModule.swift index 68b8967e..c383b7b8 100644 --- a/Tuist/ProjectDescriptionHelpers/Project/Project+MakeModule.swift +++ b/Tuist/ProjectDescriptionHelpers/Project/Project+MakeModule.swift @@ -17,6 +17,32 @@ import ProjectDescription /// /// **Project.swift**에서 사용됩니다. public extension Project { + private static var defaultModuleSettings: Settings { + .settings( + configurations: [ + .debug(name: "Debug"), + .release(name: "Release"), + .release( + name: "Profile", + settings: [ + "DEBUG_INFORMATION_FORMAT": "dwarf-with-dsym", + "COPY_PHASE_STRIP": "NO", + "STRIP_INSTALLED_PRODUCT": "NO", + "CONFIGURATION_BUILD_DIR": "$(BUILD_DIR)/Release$(EFFECTIVE_PLATFORM_NAME)", + "FRAMEWORK_SEARCH_PATHS": [ + "$(inherited)", + "$(BUILD_DIR)/Release$(EFFECTIVE_PLATFORM_NAME)" + ], + "LIBRARY_SEARCH_PATHS": [ + "$(inherited)", + "$(BUILD_DIR)/Release$(EFFECTIVE_PLATFORM_NAME)" + ] + ] + ) + ] + ) + } + /// `Project`모듈을 생성합니다. /// 내부적으로 `Project.init`과 1:1로 매핑됩니다. /// - Parameters: @@ -53,7 +79,7 @@ public extension Project { classPrefix: classPrefix, options: options, packages: packages, - settings: settings, + settings: settings ?? defaultModuleSettings, targets: targets, schemes: schemes, fileHeaderTemplate: fileHeaderTemplate, diff --git a/Tuist/ProjectDescriptionHelpers/Target/Dependency/TargetDependency+Modules.swift b/Tuist/ProjectDescriptionHelpers/Target/Dependency/TargetDependency+Modules.swift index 02e559cd..4c821f52 100644 --- a/Tuist/ProjectDescriptionHelpers/Target/Dependency/TargetDependency+Modules.swift +++ b/Tuist/ProjectDescriptionHelpers/Target/Dependency/TargetDependency+Modules.swift @@ -196,4 +196,12 @@ public extension TargetDependency { static func shared(interface module: Module.Shared) -> Self { return .project(target: Module.Shared.name + module.rawValue + "Interface", path: .shared(implementation: module)) } + + /// Perf UITest 타겟에서만 사용하는 XCTest 지원 모듈입니다. + static var sharedPerfTestingSupportUITests: Self { + return .project( + target: Module.Shared.name + Module.Shared.perfTestingSupport.rawValue + "UITests", + path: .shared(implementation: .perfTestingSupport) + ) + } } diff --git a/Tuist/ProjectDescriptionHelpers/Target/Target+Feature.swift b/Tuist/ProjectDescriptionHelpers/Target/Target+Feature.swift index 9338dd98..1ce0c74d 100644 --- a/Tuist/ProjectDescriptionHelpers/Target/Target+Feature.swift +++ b/Tuist/ProjectDescriptionHelpers/Target/Target+Feature.swift @@ -8,6 +8,33 @@ import ProjectDescription public extension Target { + private static func featureExampleSlug(_ module: Module.Feature) -> String { + switch module { + case .auth: + return "auth" + case .goalDetail: + return "goal-detail" + case .home: + return "home" + case .mainTab: + return "main-tab" + case .makeGoal: + return "make-goal" + case .notification: + return "notification" + case .onboarding: + return "onboarding" + case .proofPhoto: + return "proof-photo" + case .settings: + return "settings" + case .stats: + return "stats" + case .common: + return "common" + } + } + /// Feature 모듈의 루트 타겟을 생성합니다. /// - Parameter config: 기본 설정을 담고 있는 `TargetConfig`입니다. Feature 루트 타겟에 맞게 일부 값이 수정됩니다. /// - Returns: Feature 루트 타겟 설정이 적용된 `Target` @@ -81,28 +108,62 @@ public extension Target { newConfig.name = exampleName newConfig.sources = .exampleSources newConfig.product = .app - newConfig.bundleId = Project.Environment.BundleId.bundlePrefix + newConfig.bundleId = Project.Environment.BundleId.bundlePrefix + ".example." + featureExampleSlug(module) newConfig.destinations = .iOS newConfig.resources = ["Resources/**"] newConfig.productName = exampleName + newConfig.dependencies.append(.shared(implements: .perfTestingSupport)) if let infoPlist = newConfig.infoPlist { - newConfig.infoPlist = infoPlist.mergingLaunchScreenDefaults() + newConfig.infoPlist = infoPlist + .mergingLaunchScreenDefaults() + .mergingExampleDisplayName("Example: \(module.rawValue)") } else { newConfig.infoPlist = .extendingDefault( with: Project.Environment.InfoPlist.launchScreen ) + .mergingExampleDisplayName("Example: \(module.rawValue)") } - // Example 앱 타겟에 코드 사이닝 설정 추가 (match Development) + // Example 앱은 perf 측정용 독립 번들 ID를 automatic signing으로 관리합니다. + newConfig.settings = .settings( + base: [ + "CODE_SIGN_STYLE": "Automatic", + "DEVELOPMENT_TEAM": "\(Project.Environment.BundleId.teamId)", + "TARGETED_DEVICE_FAMILY": "1", + "SUPPORTS_MACCATALYST": "NO", + "SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD": "NO", + "DEBUG_INFORMATION_FORMAT": "dwarf-with-dsym" + ] + ) + + return makeTarget(config: newConfig) + } + + /// Feature 예제 앱의 smoke UITest 타겟을 생성합니다. + static func feature(exampleUITests module: Module.Feature, config: TargetConfig = .init()) -> Self { + var newConfig = config + let exampleName = Module.Feature.name + module.rawValue + "Example" + newConfig.name = exampleName + "UITests" + newConfig.product = .uiTests + newConfig.sources = "ExampleUITests/Sources/**" + newConfig.bundleId = Project.Environment.BundleId.bundlePrefix + + ".example." + + featureExampleSlug(module) + + ".uitests" + newConfig.destinations = .iOS + newConfig.dependencies = [ + .target(name: exampleName), + .sharedPerfTestingSupportUITests + ] + newConfig.dependencies newConfig.settings = .settings( base: [ - "CODE_SIGN_STYLE": "Manual", + "CODE_SIGN_STYLE": "Automatic", "DEVELOPMENT_TEAM": "\(Project.Environment.BundleId.teamId)", - "PROVISIONING_PROFILE_SPECIFIER": "match Development \(Project.Environment.BundleId.bundlePrefix)", "TARGETED_DEVICE_FAMILY": "1", "SUPPORTS_MACCATALYST": "NO", - "SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD": "NO" + "SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD": "NO", + "DEBUG_INFORMATION_FORMAT": "dwarf-with-dsym" ] ) diff --git a/Tuist/ProjectDescriptionHelpers/Target/Target+Shared.swift b/Tuist/ProjectDescriptionHelpers/Target/Target+Shared.swift index 9f6faa0b..31e71cba 100644 --- a/Tuist/ProjectDescriptionHelpers/Target/Target+Shared.swift +++ b/Tuist/ProjectDescriptionHelpers/Target/Target+Shared.swift @@ -34,4 +34,23 @@ public extension Target { return makeTarget(config: newConfig) } + + /// Shared PerfTestingSupport의 XCTest 전용 지원 타겟을 생성합니다. + /// 앱 런타임 모듈과 XCTest import 경계를 분리하기 위한 타겟입니다. + static func sharedPerfTestingSupportUITests(config: TargetConfig) -> Self { + var newConfig = config + newConfig.name = Module.Shared.name + Module.Shared.perfTestingSupport.rawValue + "UITests" + newConfig.product = .staticFramework + newConfig.sources = "UITests/Sources/**" + newConfig.dependencies = [ + .shared(implements: .perfTestingSupport) + ] + newConfig.dependencies + newConfig.settings = .settings( + base: [ + "ENABLE_TESTING_SEARCH_PATHS": "YES" + ] + ) + + return makeTarget(config: newConfig) + } } diff --git a/docs/perf-infra/README.md b/docs/perf-infra/README.md new file mode 100644 index 00000000..dd8065a4 --- /dev/null +++ b/docs/perf-infra/README.md @@ -0,0 +1,103 @@ +# UI Rendering Perf Infrastructure + +이 문서는 Feature Example 앱에 seed 기반 UITest와 `xctrace` 측정을 추가하기 위한 공통 인프라 사용법입니다. 현재 범위는 smoke UITest와 Time Profiler launch 검증까지이며, 실제 `measure(metrics:)` 성능 시나리오는 다음 작업에서 추가합니다. + +## Targets + +측정 대상 Example 앱: + +| Feature | Example scheme | Bundle ID | UITest target | +| --- | --- | --- | --- | +| Auth | `FeatureAuthExample` | `org.yapp.twix.example.auth` | `FeatureAuthExampleUITests` | +| GoalDetail | `FeatureGoalDetailExample` | `org.yapp.twix.example.goal-detail` | `FeatureGoalDetailExampleUITests` | +| Home | `FeatureHomeExample` | `org.yapp.twix.example.home` | `FeatureHomeExampleUITests` | +| MainTab | `FeatureMainTabExample` | `org.yapp.twix.example.main-tab` | `FeatureMainTabExampleUITests` | +| MakeGoal | `FeatureMakeGoalExample` | `org.yapp.twix.example.make-goal` | `FeatureMakeGoalExampleUITests` | +| Notification | `FeatureNotificationExample` | `org.yapp.twix.example.notification` | `FeatureNotificationExampleUITests` | +| Onboarding | `FeatureOnboardingExample` | `org.yapp.twix.example.onboarding` | `FeatureOnboardingExampleUITests` | +| ProofPhoto | `FeatureProofPhotoExample` | `org.yapp.twix.example.proof-photo` | `FeatureProofPhotoExampleUITests` | +| Settings | `FeatureSettingsExample` | `org.yapp.twix.example.settings` | `FeatureSettingsExampleUITests` | +| Stats | `FeatureStatsExample` | `org.yapp.twix.example.stats` | `FeatureStatsExampleUITests` | + +`Common`은 Example target이 없는 의도된 예외입니다. + +## Launch Contract + +Example 앱은 `SharedPerfTestingSupport.UITestMode`를 통해 다음 launch arguments를 읽습니다. + +```text +-UITEST +-UITEST_SEED +-UITEST_DISABLE_ANIMATIONS +-UITEST_WAIT_READY +``` + +공통 helper: + +```swift +let app = XCUIApplication.launchForPerf(seed: "default") +waitForFeatureReady("home") +``` + +Seed별 fixture가 필요한 경우: + +- Example 앱 내부에서 `UITestMode.seedName`을 switch합니다. +- 기존 `Testing` 모듈이 있는 Feature는 `Feature...Testing`에 seed 이름 또는 mock helper를 추가합니다. +- Testing 모듈이 없는 Auth, MainTab, Notification, Settings는 새 Testing 모듈을 만들지 말고 Example target 내부 fixture를 사용합니다. + +## Accessibility Contract + +식별자 형식: + +```text +feature..root +feature..feed +feature..cell. +feature.. +feature..ready +``` + +현재 smoke UITest는 Feature당 정확히 하나이며 `feature..ready`만 기다립니다. 성능 시나리오를 추가할 때 화면별 feed, cell, control identifier를 확장합니다. + +## Build Configuration + +Tuist 모듈 프로젝트는 `Debug`, `Release`, `Profile` configuration을 생성합니다. `Profile`은 Release 계열이며 Time Profiler 분석을 위해 다음 값을 유지합니다. + +```text +DEBUG_INFORMATION_FORMAT = dwarf-with-dsym +COPY_PHASE_STRIP = NO +STRIP_INSTALLED_PRODUCT = NO +``` + +Production 앱 signing은 기존 manual/match 설정을 유지합니다. Example 앱과 Example UITest target만 automatic signing을 사용합니다. + +## Verification Commands + +먼저 프로젝트를 생성합니다. + +```bash +tuist generate +``` + +이 작업은 direct `xcodebuild`를 사용하는 perf infra 검증이므로 scheme/configuration/destination 형식을 명시합니다. 실기기 destination 값은 로컬 기기에 맞게 지정해야 합니다. + +```bash +xcodebuild test \ + -scheme FeatureHomeExample \ + -configuration Profile \ + -destination 'platform=iOS,name=' \ + -only-testing:FeatureHomeExampleUITests +``` + +전체 Example의 Time Profiler launch smoke는 실기기에서만 실행합니다. + +```bash +DEVICE_NAME='' Scripts/verify-perf-targets.sh +``` + +SwiftUI template 기반 profiling은 simulator에서 신뢰할 수 없으므로 실기기 검증만 지원합니다. + +## Known Issues + +- `FeatureOnboardingExample`은 entitlements를 사용합니다. Automatic signing에서 associated domains 등 provisioning 누락이 발생하면 target, 누락 entitlement, Xcode signing error를 이 문서에 추가하고 owner가 Apple Developer portal capability를 확인해야 합니다. +- 이 인프라는 launch/readiness smoke만 제공합니다. 실제 render scenario, baseline/after 비교, 리포트 생성, 성능 개선은 별도 작업입니다. diff --git a/docs/perf-infra/inventory.md b/docs/perf-infra/inventory.md new file mode 100644 index 00000000..7744f573 --- /dev/null +++ b/docs/perf-infra/inventory.md @@ -0,0 +1,18 @@ +# UI Rendering Perf Infrastructure Inventory + +작성일: 2026-05-17 + +| Feature | Example 존재 | 현재 Bundle ID | 측정 대상 | 비고 | +| --- | --- | --- | --- | --- | +| Auth | 있음 | `org.yapp.twix.example.auth` | 예 | 기존 `org.yapp.twix` 충돌을 고유 Bundle ID로 분리 | +| Common | 없음(의도) | N/A | 아니오 | 매니페스트에 Example target 없음 | +| GoalDetail | 있음 | `org.yapp.twix.example.goal-detail` | 예 | 기존 `org.yapp.twix` 충돌을 고유 Bundle ID로 분리 | +| Home | 있음 | `org.yapp.twix.example.home` | 예 | placeholder Example을 실제 `HomeCoordinatorView` wiring으로 교체 | +| MainTab | 있음 | `org.yapp.twix.example.main-tab` | 예 | 기존 `org.yapp.twix` 충돌을 고유 Bundle ID로 분리 | +| MakeGoal | 있음 | `org.yapp.twix.example.make-goal` | 예 | placeholder Example을 실제 `MakeGoalView` wiring으로 교체 | +| Notification | 있음 | `org.yapp.twix.example.notification` | 예 | `Date()` 기반 mock을 deterministic reference date로 교체 | +| Onboarding | 있음 | `org.yapp.twix.example.onboarding` | 예 | entitlements/provisioning 검증 필요 | +| ProofPhoto | 있음 | `org.yapp.twix.example.proof-photo` | 예 | placeholder Example을 실제 `ProofPhotoView` wiring으로 교체 | +| Settings | 있음 | `org.yapp.twix.example.settings` | 예 | 기존 `org.yapp.twix` 충돌을 고유 Bundle ID로 분리 | +| Stats | 있음 | `org.yapp.twix.example.stats` | 예 | 기존 `org.yapp.twix` 충돌을 고유 Bundle ID로 분리 | + diff --git a/docs/perf-infra/reports/_workspace/smell-inventory.md b/docs/perf-infra/reports/_workspace/smell-inventory.md new file mode 100644 index 00000000..076d05e8 --- /dev/null +++ b/docs/perf-infra/reports/_workspace/smell-inventory.md @@ -0,0 +1,80 @@ +# Smell Inventory — Home / Stats / GoalDetail + +작성일: 2026-05-17 +범위: `Projects/Feature/{Home,Stats,GoalDetail}/{Sources,Interface/Sources}` +출처: 정적 스캔 only (Phase 1). **측정 데이터로 검증되지 않은 휴리스틱 후보** — Phase 3 baseline trace 와 cross-check 후 최종 fix 대상 확정. + +## 기준 (Phase 0 확정 8개) + +1. **거대 body**: `var body` 30+ statements 또는 파일 200+ lines +2. **AnyView** inline 사용 (Factory 패턴 제외) +3. **IfLetStore** (TCA 1.7+ deprecated) +4. **ForEach** stable id 누락 +5. **광범위 WithViewStore / Store observation** +6. **body 안 `store.scope` 또는 expensive computed property** +7. **`@StateObject`/`@ObservedObject` ObservableObject** (TCA 환경 anti-pattern) +8. **body 경로 무거운 computed property** (날짜 포맷/정렬/필터) + +`✓` = smell 확인, `–` = 해당 없음, `?` = 검증 필요(Phase 3 데이터 의존). + +## View / Coordinator 인벤토리 + +| File | 1. body | 2. AnyView | 3. IfLetStore | 4. ForEach key | 5. WithViewStore | 6. body scope/heavy | 7. ObservableObject | 8. heavy computed | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Home/Sources/Root/HomeCoordinatorView.swift | ✓ (body 92 lines, ~50+ stmt, 9 case switch) | – | ✓ (lines 54, 60, 66, 72) | – | – | ✓ (body 안 `store.scope` 12회. settings scope 5번 중복 호출) | – | – | +| Home/Sources/Home/HomeView.swift | ✓ (body 61 lines, 10+ modifier chain) | – | ✓ (line 93, proofPhoto fullScreenCover) | – (ForEach store.items: Identifiable) | – | – (scope 1회만, fullScreenCover content) | – | – | +| Home/Sources/Goal/EditGoalListView.swift | ✓ borderline (body 46, 10+ modifier chain) | – | – | – (ForEach store.cards: Identifiable) | – | – | – | – | +| Home/Sources/Goal/AddGoalListView.swift | – (body 7) | – | – | – (`\.self` on `GoalCategory` enum — stable) | – | – | – | – | +| Stats/Sources/Coordinator/StatsCoordinatorView.swift | – (body 26) | – | ✓ (lines 46, 52, 58) | – | – | ✓ (body 안 `store.scope` 4회) | – | – | +| Stats/Sources/Stats/StatsView.swift | – (body 26) | – | – | – (`id: \.self.goalId` — explicit stable) | – | – | – | – | +| Stats/Sources/Detail/StatsDetailView.swift | ✓ (body 49 + 273 lines, 10+ modifier chain) | ✓ (line 117 — inline `AnyView(dateImageBackground(...))` in calendar `dateCellBackground:` closure, 셀당 1회) | – | – (`id: \.title` — 안정적이라 가정) | – | ✓ (body 경로 `completedDate(for:)` → `formattedAPIDateString()` + dict lookup, 캘린더 셀당 1회) | – | ✓ (`completedDate(for:)` Phase 6 의 일부, 셀 N개 × 매 render) | +| GoalDetail/Sources/Detail/GoalDetailView.swift | ✓ (body 55 + 579 lines, 12+ modifier chain) | – | ✓ (line 103, proofPhoto fullScreenCover) | – | – | – (scope 1회만) | ✓ (`@StateObject myEmojiFlyingReactionEmitter`, line 38) | – borderline (`isSEDevice` reads `UIScreen.main.bounds.height` — 저렴하지만 modifier 인자에서 호출됨) | +| GoalDetail/Sources/Detail/ReactionBarView.swift | – (body 17) | – | – | – (`\.self` on `ReactionEmoji` enum — stable) | – | – | ✓ (`@StateObject flyingReactionEmitter`, line 16) | – | +| GoalDetail/Sources/Detail/FlyingReactionSupport.swift | – (FlyingReactionOverlay body 14) | – | – | – | – | – | – (`FlyingReactionEmitter` 정의 자체. ObservableObject 클래스이지만 selection criterion 7 은 "deep hierarchy 에 퍼진 사용처" 기준) | – | + +## 확정 fix 후보 (smell 확실 + 영향 큼) + +A. **HomeCoordinatorView IfLetStore × 4 + 중복 scope** + - `IfLetStore` 4건은 `@Bindable store` 기반 `if let store = store.scope(...)` 패턴으로 치환 (이미 settings/notification case 들이 같은 패턴 사용) + - settings scope 5번 중복 호출은 switch 바깥에서 한 번 unwrapping 후 sub-case 처리로 정리 가능. 단 case 별 destination 이 달라 코드 가독성과 trade-off 가 있으므로 baseline 후 영향 확인 후 결정 + +B. **HomeView IfLetStore × 1 (line 93)** + - 단일 IfLetStore → `if let scopedStore = store.scope(...)` 치환. 기계적 작업 + +C. **GoalDetailView IfLetStore × 1 (line 103)** + - 동일 패턴 + +D. **StatsCoordinatorView IfLetStore × 3** + - 동일 패턴 + +E. **StatsDetailView inline AnyView (line 117) + body 경로 heavy method** + - `dateCellBackground:` 가 캘린더 셀당 호출. 현재 closure 가 `AnyView` 로 type-erase 하고 `completedDate(for:)` 가 매번 dict 조회 + String 포맷팅 + - 개선 1: `dateCellBackground:` 의 closure signature 자체가 `AnyView` 를 요구한다면 (TXCalendar API), 그건 디자인시스템 쪽이라 이번 범위 밖. 디자인시스템 API 가 generic 받으면 AnyView 제거 가능 — 확인 필요 + - 개선 2: `store.completedDateByKey` 가 이미 dict 라면 `formattedAPIDateString()` 캐싱 또는 `[Components: ImageBackground]` precompute 로 셀당 비용 절감 + - **이건 측정으로 우선 확인** (Phase 3 trace 에서 hot 으로 나오면 fix) + +F. **`@StateObject FlyingReactionEmitter` × 2 (GoalDetailView, ReactionBarView)** + - `FlyingReactionEmitter` 는 `@Published reactions: [...]` 를 가진 ObservableObject. 사용처는 overlay 의 `FlyingReactionOverlay(reactions: emitter.reactions, ...)` 형태로 단일 child 에 전달 + - SwiftUI 가 `@StateObject` change 를 owner View body 전체로 전파 → ReactionBarView/GoalDetailView body 가 reactions 배열 갱신마다 재계산 + - 개선: `FlyingReactionEmitter` 를 `@Observable` (Swift 5.9+, iOS 17) 로 전환하면 unused property 변경은 body 재계산을 일으키지 않음. 단 본 클래스가 `@Published reactions` 하나만 노출하므로 효과는 제한적 + - 대안: `reactions` 를 `@State` 로 분리 + emit 함수를 free function/struct 로 — 큰 변경 + - **측정 우선** (Phase 3 의 `_printChanges()` 로 body 재계산 횟수 확인 후 결정) + +## 잠재 후보 (측정 의존, 회색 영역) + +- **HomeView body 의 modifier 체인 10+**: cold launch / scroll 둘 다에서 보일 가능성. 측정에서 SwiftUI body slot 비중이 높으면 일부 modifier 를 sub-view 로 분리해볼 만함 +- **GoalDetailView 의 modifier 체인 12+ 및 다수 `@State`**: 6개 `@State` + 1개 `@StateObject`. cold launch 비용 확인 대상 +- **`isSEDevice` (UIScreen.main 접근)**: body 안 두 곳 사용. 매번 main thread bound. 측정에서 보이면 init 시 1회 캐싱 + +## Smell 아닌 것 확인됨 + +- **WithViewStore**: 0건. 프로젝트는 `@ObservableState` + `@Bindable store` 모던 패턴. State observation 광범위 smell 없음 +- **ForEach key 누락**: 모든 ForEach 가 Identifiable 또는 stable id 사용. enum `\.self` 사용분도 Hashable enum 이라 안정 +- **Factory 패턴 AnyView (GoalDetailFactory, StatsDetailFactory, *Factory+Live)**: 아키텍처 결정. 이번 범위 밖 +- **`FlyingReactionEmitter` 클래스 정의 자체**: ObservableObject 이지만 사용처 fan-out 이 좁아 criterion 7 의 "deep hierarchy 광범위 구독" 에 해당 안 됨. (단 owner View body 재계산은 criterion 6/7 의 그레이 영역으로 위 F 에 다시 잡혔음) + +## 다음 단계 의존성 + +- A, B, C, D (IfLetStore deprecation 4 파일 9 건) — Phase 3 baseline 후 fix 진입. trace 없이도 기계적이라 안전한 1차 후보. +- E, F — Phase 3 baseline 의 Top User-Code Frame 및 `_printChanges()` 결과를 본 뒤 진입. +- 잠재 후보 — 측정 결과 hot 으로 나올 때만 진입. From f8e1ddc0e8278c5ae106533dbc04e3b9132eab0b Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Sun, 17 May 2026 14:26:03 +0900 Subject: [PATCH 008/100] =?UTF-8?q?test:=20=EC=BD=9C=EB=93=9C=20=EB=9F=B0?= =?UTF-8?q?=EC=B9=98=EC=99=80=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=84=B1?= =?UTF-8?q?=EB=8A=A5=20=EC=B8=A1=EC=A0=95=20=EB=B8=94=EB=A1=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20#308?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GoalDetailExampleColdLaunchTests.swift | 15 +++++++++++++++ .../Sources/HomeExampleColdLaunchTests.swift | 15 +++++++++++++++ .../Sources/HomeExampleScrollTests.swift | 12 +++++++++--- .../Sources/StatsExampleColdLaunchTests.swift | 15 +++++++++++++++ .../Sources/StatsExampleScrollTests.swift | 12 +++++++++--- 5 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleColdLaunchTests.swift create mode 100644 Projects/Feature/Home/ExampleUITests/Sources/HomeExampleColdLaunchTests.swift create mode 100644 Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleColdLaunchTests.swift diff --git a/Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleColdLaunchTests.swift b/Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleColdLaunchTests.swift new file mode 100644 index 00000000..28042356 --- /dev/null +++ b/Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleColdLaunchTests.swift @@ -0,0 +1,15 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class GoalDetailExampleColdLaunchTests: XCTestCase { + func testColdLaunch() { + measure(metrics: [ + XCTApplicationLaunchMetric(), + XCTMemoryMetric(), + XCTCPUMetric() + ]) { + _ = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("goal-detail", timeout: 30) + } + } +} diff --git a/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleColdLaunchTests.swift b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleColdLaunchTests.swift new file mode 100644 index 00000000..9b6493af --- /dev/null +++ b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleColdLaunchTests.swift @@ -0,0 +1,15 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class HomeExampleColdLaunchTests: XCTestCase { + func testColdLaunch() { + measure(metrics: [ + XCTApplicationLaunchMetric(), + XCTMemoryMetric(), + XCTCPUMetric() + ]) { + _ = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("home", timeout: 30) + } + } +} diff --git a/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleScrollTests.swift b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleScrollTests.swift index 172a249b..d8d1fcd7 100644 --- a/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleScrollTests.swift +++ b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleScrollTests.swift @@ -4,13 +4,19 @@ import XCTest final class HomeExampleScrollTests: XCTestCase { func testScrollFiftyCells() { let app = XCUIApplication.launchForPerf(seed: "scroll-50") - waitForFeatureReady("home") + waitForFeatureReady("home", timeout: 30) let feed = app.descendants(matching: .any)["feature.home.feed"] XCTAssertTrue(feed.waitForExistence(timeout: 5), "feature.home.feed not found") - for _ in 0..<5 { - feed.swipeUp() + measure(metrics: [ + XCTClockMetric(), + XCTMemoryMetric(), + XCTCPUMetric() + ]) { + for _ in 0..<5 { + feed.swipeUp() + } } } } diff --git a/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleColdLaunchTests.swift b/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleColdLaunchTests.swift new file mode 100644 index 00000000..73a1b918 --- /dev/null +++ b/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleColdLaunchTests.swift @@ -0,0 +1,15 @@ +import SharedPerfTestingSupportUITests +import XCTest + +final class StatsExampleColdLaunchTests: XCTestCase { + func testColdLaunch() { + measure(metrics: [ + XCTApplicationLaunchMetric(), + XCTMemoryMetric(), + XCTCPUMetric() + ]) { + _ = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("stats", timeout: 30) + } + } +} diff --git a/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleScrollTests.swift b/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleScrollTests.swift index 1e5adc0e..aa051973 100644 --- a/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleScrollTests.swift +++ b/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleScrollTests.swift @@ -4,13 +4,19 @@ import XCTest final class StatsExampleScrollTests: XCTestCase { func testScrollFiftyCells() { let app = XCUIApplication.launchForPerf(seed: "scroll-50") - waitForFeatureReady("stats") + waitForFeatureReady("stats", timeout: 30) let feed = app.descendants(matching: .any)["feature.stats.feed"] XCTAssertTrue(feed.waitForExistence(timeout: 5), "feature.stats.feed not found") - for _ in 0..<5 { - feed.swipeUp() + measure(metrics: [ + XCTClockMetric(), + XCTMemoryMetric(), + XCTCPUMetric() + ]) { + for _ in 0..<5 { + feed.swipeUp() + } } } } From 56b5b639d3c3dbf220b57b172eedfca237eb2732 Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Sun, 17 May 2026 14:46:22 +0900 Subject: [PATCH 009/100] =?UTF-8?q?test:=20=EC=BD=9C=EB=93=9C=20=EB=9F=B0?= =?UTF-8?q?=EC=B9=98=20=EC=A7=80=ED=91=9C=EB=A5=BC=20XCTClockMetric?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B5=90=EC=B2=B4=20-=20#308?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/GoalDetailExampleColdLaunchTests.swift | 2 +- .../ExampleUITests/Sources/HomeExampleColdLaunchTests.swift | 2 +- .../ExampleUITests/Sources/StatsExampleColdLaunchTests.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleColdLaunchTests.swift b/Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleColdLaunchTests.swift index 28042356..37cec90a 100644 --- a/Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleColdLaunchTests.swift +++ b/Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleColdLaunchTests.swift @@ -4,7 +4,7 @@ import XCTest final class GoalDetailExampleColdLaunchTests: XCTestCase { func testColdLaunch() { measure(metrics: [ - XCTApplicationLaunchMetric(), + XCTClockMetric(), XCTMemoryMetric(), XCTCPUMetric() ]) { diff --git a/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleColdLaunchTests.swift b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleColdLaunchTests.swift index 9b6493af..dc359fea 100644 --- a/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleColdLaunchTests.swift +++ b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleColdLaunchTests.swift @@ -4,7 +4,7 @@ import XCTest final class HomeExampleColdLaunchTests: XCTestCase { func testColdLaunch() { measure(metrics: [ - XCTApplicationLaunchMetric(), + XCTClockMetric(), XCTMemoryMetric(), XCTCPUMetric() ]) { diff --git a/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleColdLaunchTests.swift b/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleColdLaunchTests.swift index 73a1b918..33b90ee2 100644 --- a/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleColdLaunchTests.swift +++ b/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleColdLaunchTests.swift @@ -4,7 +4,7 @@ import XCTest final class StatsExampleColdLaunchTests: XCTestCase { func testColdLaunch() { measure(metrics: [ - XCTApplicationLaunchMetric(), + XCTClockMetric(), XCTMemoryMetric(), XCTCPUMetric() ]) { From 78215b4313177ff23dac782335be2e0158d6c7b2 Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Sun, 17 May 2026 14:59:01 +0900 Subject: [PATCH 010/100] =?UTF-8?q?docs:=20Phase=203=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=20=EC=84=B1=EB=8A=A5=20=EC=A7=80=ED=91=9C=20=EC=A0=95=EB=A6=AC?= =?UTF-8?q?=20-=20#308?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reports/_workspace/baseline-device.md | 77 +++++++++++++++++++ .../reports/_workspace/baseline-simulator.md | 75 ++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 docs/perf-infra/reports/_workspace/baseline-device.md create mode 100644 docs/perf-infra/reports/_workspace/baseline-simulator.md diff --git a/docs/perf-infra/reports/_workspace/baseline-device.md b/docs/perf-infra/reports/_workspace/baseline-device.md new file mode 100644 index 00000000..094f1a7e --- /dev/null +++ b/docs/perf-infra/reports/_workspace/baseline-device.md @@ -0,0 +1,77 @@ +# Baseline — Device (Jiyong의 iPhone / Profile) + +git: `414bd77` (tag `baseline-render-pass-1`) +recorded: 2026-05-17T05:58:31Z + +## 환경 +- Device: Jiyong의 iPhone (UDID 00008110-00096DC42632801E) +- Configuration: Profile + +## 시나리오 7개 통과 / 1개 fixture 이슈 + +goal-detail-nav: 시뮬레이터와 동일하게 fixture 가 `isCompleted=true && isEditing=false` 로 떠 `bottomButton` 미렌더. 실기기에서도 동일 원인으로 skip. + +## 메트릭 + +### home-cold + +| metric | unit | avg | min | max | stddev | +|---|---|---|---|---|---| +| Memory Physical | kB | -150.733 | -638.976 | -16.384 | 273.274 | +| CPU Instructions Retired | kI | 241440.798 | 238097.692 | 248884.724 | 4548.963 | +| Clock Monotonic Time | s | 3.585 | 3.580 | 3.591 | 0.005 | +| CPU Cycles | kC | 167760.104 | 160705.136 | 171429.984 | 4106.200 | +| CPU Time | s | 0.129 | 0.126 | 0.134 | 0.004 | +| Memory Peak Physical | kB | 15015.266 | 14828.488 | 15533.000 | 291.847 | + +### home-scroll + +| metric | unit | avg | min | max | stddev | +|---|---|---|---|---|---| +| Memory Physical | kB | 85.197 | -32.768 | 163.840 | 78.061 | +| CPU Instructions Retired | kI | 3753244.208 | 3655730.192 | 4119814.358 | 204957.731 | +| Clock Monotonic Time | s | 8.180 | 6.260 | 14.220 | 3.445 | +| CPU Cycles | kC | 1354406.082 | 1334529.826 | 1408104.247 | 30292.098 | +| CPU Time | s | 0.803 | 0.795 | 0.809 | 0.006 | +| Memory Peak Physical | kB | 15339.645 | 15188.912 | 15565.744 | 152.556 | + +### home-nav + +| (no measure metrics) | | | | | | + +### stats-cold + +| metric | unit | avg | min | max | stddev | +|---|---|---|---|---|---| +| Memory Physical | kB | -147.456 | -524.288 | 16.384 | 217.358 | +| CPU Instructions Retired | kI | 246593.613 | 240047.717 | 257215.214 | 7332.838 | +| Clock Monotonic Time | s | 3.593 | 3.518 | 3.682 | 0.060 | +| CPU Cycles | kC | 168322.002 | 156149.886 | 185529.097 | 13787.423 | +| CPU Time | s | 0.127 | 0.117 | 0.138 | 0.009 | +| Memory Peak Physical | kB | 15018.566 | 14812.128 | 15516.640 | 287.211 | + +### stats-scroll + +| metric | unit | avg | min | max | stddev | +|---|---|---|---|---|---| +| Memory Physical | kB | 39.322 | -163.840 | 147.456 | 118.487 | +| CPU Instructions Retired | kI | 3361527.430 | 3182261.716 | 3656675.168 | 241946.243 | +| Clock Monotonic Time | s | 9.116 | 6.164 | 14.069 | 4.040 | +| CPU Cycles | kC | 1299780.222 | 1273744.973 | 1336138.412 | 30156.601 | +| CPU Time | s | 0.811 | 0.787 | 0.834 | 0.019 | +| Memory Peak Physical | kB | 15660.771 | 15532.976 | 15811.504 | 100.598 | + +### stats-nav + +| (no measure metrics) | | | | | | + +### goal-detail-cold + +| metric | unit | avg | min | max | stddev | +|---|---|---|---|---|---| +| Memory Physical | kB | -111.411 | -344.064 | 0.000 | 154.306 | +| CPU Instructions Retired | kI | 196821.231 | 194186.385 | 200441.984 | 2926.160 | +| Clock Monotonic Time | s | 3.553 | 3.527 | 3.570 | 0.017 | +| CPU Cycles | kC | 152998.432 | 148684.243 | 159690.249 | 5408.908 | +| CPU Time | s | 0.128 | 0.121 | 0.136 | 0.007 | +| Memory Peak Physical | kB | 15057.840 | 14877.616 | 15418.288 | 254.875 | diff --git a/docs/perf-infra/reports/_workspace/baseline-simulator.md b/docs/perf-infra/reports/_workspace/baseline-simulator.md new file mode 100644 index 00000000..5ba5a28e --- /dev/null +++ b/docs/perf-infra/reports/_workspace/baseline-simulator.md @@ -0,0 +1,75 @@ +# Baseline — Simulator (iPhone 17 / iOS 26.2 / Profile) + +git: `414bd77` (tag `baseline-render-pass-1`) +recorded: 2026-05-17T05:58:32Z + +## 환경 +- Xcode 26.2, Simulator iPhone 17, iOS 26.2 +- Configuration: Profile + +## 시나리오 7개 통과 / 1개 fixture 이슈 (goal-detail-nav skipped) + +## 메트릭 + +### home-cold + +| metric | unit | avg | min | max | stddev | +|---|---|---|---|---|---| +| Memory Physical | kB | 101.581 | 32.768 | 163.840 | 65.944 | +| CPU Instructions Retired | kI | 142316.978 | 139439.520 | 145076.956 | 2367.092 | +| Clock Monotonic Time | s | 4.694 | 4.565 | 4.797 | 0.117 | +| CPU Cycles | kC | 116020.552 | 112718.930 | 122278.874 | 4044.905 | +| CPU Time | s | 0.043 | 0.041 | 0.046 | 0.002 | +| Memory Peak Physical | kB | 37700.966 | 37510.912 | 37904.128 | 174.010 | + +### home-scroll + +| metric | unit | avg | min | max | stddev | +|---|---|---|---|---|---| +| Memory Physical | kB | 271.974 | 98.304 | 442.368 | 132.902 | +| CPU Instructions Retired | kI | 1551257.374 | 1540122.901 | 1562745.883 | 10248.991 | +| Clock Monotonic Time | s | 6.717 | 5.161 | 12.878 | 3.444 | +| CPU Cycles | kC | 986122.005 | 966685.627 | 1027841.029 | 25652.606 | +| CPU Time | s | 0.347 | 0.340 | 0.353 | 0.005 | +| Memory Peak Physical | kB | 40030.963 | 39477.184 | 40476.608 | 378.148 | + +### home-nav + +| (no measure metrics) | | | | | | + +### stats-cold + +| metric | unit | avg | min | max | stddev | +|---|---|---|---|---|---| +| Memory Physical | kB | 88.474 | -49.152 | 196.608 | 97.344 | +| CPU Instructions Retired | kI | 143329.549 | 142336.088 | 144503.486 | 834.026 | +| Clock Monotonic Time | s | 4.645 | 4.558 | 4.689 | 0.052 | +| CPU Cycles | kC | 117512.554 | 113309.515 | 120036.485 | 2646.737 | +| CPU Time | s | 0.043 | 0.040 | 0.045 | 0.002 | +| Memory Peak Physical | kB | 38143.462 | 37986.176 | 38264.704 | 105.927 | + +### stats-scroll + +| metric | unit | avg | min | max | stddev | +|---|---|---|---|---|---| +| Memory Physical | kB | 176.947 | 98.304 | 311.296 | 92.246 | +| CPU Instructions Retired | kI | 1565371.102 | 1560326.489 | 1572087.146 | 4808.852 | +| Clock Monotonic Time | s | 7.593 | 5.034 | 13.201 | 3.700 | +| CPU Cycles | kC | 983575.089 | 973506.874 | 997764.910 | 11942.133 | +| CPU Time | s | 0.341 | 0.334 | 0.351 | 0.006 | +| Memory Peak Physical | kB | 39680.154 | 39362.304 | 39935.744 | 218.160 | + +### stats-nav + +| (no measure metrics) | | | | | | + +### goal-detail-cold + +| metric | unit | avg | min | max | stddev | +|---|---|---|---|---|---| +| Memory Physical | kB | 114.688 | 0.000 | 212.992 | 89.739 | +| CPU Instructions Retired | kI | 141588.305 | 138411.271 | 145211.373 | 2431.765 | +| Clock Monotonic Time | s | 4.549 | 4.474 | 4.590 | 0.046 | +| CPU Cycles | kC | 115889.575 | 111345.809 | 124028.728 | 4781.314 | +| CPU Time | s | 0.044 | 0.041 | 0.046 | 0.002 | +| Memory Peak Physical | kB | 37786.291 | 37560.192 | 37953.408 | 144.885 | From 883b9af862ed9efc80e2c5ccea3fbb4bcd2f871d Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Sun, 17 May 2026 15:02:20 +0900 Subject: [PATCH 011/100] =?UTF-8?q?refactor:=20HomeCoordinatorView=20IfLet?= =?UTF-8?q?Store=20=EC=A0=9C=EA=B1=B0=20-=20#308?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/Sources/Root/HomeCoordinatorView.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Projects/Feature/Home/Sources/Root/HomeCoordinatorView.swift b/Projects/Feature/Home/Sources/Root/HomeCoordinatorView.swift index f32f776f..3640d315 100644 --- a/Projects/Feature/Home/Sources/Root/HomeCoordinatorView.swift +++ b/Projects/Feature/Home/Sources/Root/HomeCoordinatorView.swift @@ -52,28 +52,28 @@ public struct HomeCoordinatorView: View { .navigationDestination(for: HomeRoute.self) { route in switch route { case .detail: - IfLetStore(store.scope(state: \.goalDetail, action: \.goalDetail)) { store in - goalDetailFactory.makeView(store) + if let goalDetailStore = store.scope(state: \.goalDetail, action: \.goalDetail) { + goalDetailFactory.makeView(goalDetailStore) .toolbar(.hidden, for: .tabBar) .perfReadyMarker("home-to-goal-detail") } case .statsDetail: - IfLetStore(store.scope(state: \.statsDetail, action: \.statsDetail)) { store in - statsDetailFactory.makeView(store) + if let statsDetailStore = store.scope(state: \.statsDetail, action: \.statsDetail) { + statsDetailFactory.makeView(statsDetailStore) .toolbar(.hidden, for: .tabBar) .perfReadyMarker("home-to-stats-detail") } case .editGoalList: - IfLetStore(store.scope(state: \.editGoalList, action: \.editGoalList)) { store in - EditGoalListView(store: store) + if let editGoalListStore = store.scope(state: \.editGoalList, action: \.editGoalList) { + EditGoalListView(store: editGoalListStore) .toolbar(.hidden, for: .tabBar) } case .makeGoal: - IfLetStore(store.scope(state: \.makeGoal, action: \.makeGoal)) { store in - makeGoalFactory.makeView(store) + if let makeGoalStore = store.scope(state: \.makeGoal, action: \.makeGoal) { + makeGoalFactory.makeView(makeGoalStore) .toolbar(.hidden, for: .tabBar) } From f85c65bc6d884788e94b9515914ab40c1a197c75 Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Sun, 17 May 2026 15:03:19 +0900 Subject: [PATCH 012/100] =?UTF-8?q?refactor:=20HomeView=20IfLetStore=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20-=20#308?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/Feature/Home/Sources/Home/HomeView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Projects/Feature/Home/Sources/Home/HomeView.swift b/Projects/Feature/Home/Sources/Home/HomeView.swift index 1273d03d..03d6f873 100644 --- a/Projects/Feature/Home/Sources/Home/HomeView.swift +++ b/Projects/Feature/Home/Sources/Home/HomeView.swift @@ -91,8 +91,8 @@ public struct HomeView: View { isPresented: $store.isProofPhotoPresented, onDismiss: { store.send(.proofPhotoDismissed) }, ) { - IfLetStore(store.scope(state: \.proofPhoto, action: \.proofPhoto)) { store in - proofPhotoFactory.makeView(store) + if let proofPhotoStore = store.scope(state: \.proofPhoto, action: \.proofPhoto) { + proofPhotoFactory.makeView(proofPhotoStore) } } .cameraPermissionAlert( From 1c5e397d71fb29481dac5146f249371cf9918b9a Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Sun, 17 May 2026 15:04:11 +0900 Subject: [PATCH 013/100] =?UTF-8?q?refactor:=20GoalDetailView=20IfLetStore?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=20-=20#308?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Feature/GoalDetail/Sources/Detail/GoalDetailView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift index 7417eb46..1b75c14d 100644 --- a/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift +++ b/Projects/Feature/GoalDetail/Sources/Detail/GoalDetailView.swift @@ -101,8 +101,8 @@ public struct GoalDetailView: View { isPresented: $store.isPresentedProofPhoto, onDismiss: { store.send(.proofPhotoDismissed) }, content: { - IfLetStore(store.scope(state: \.proofPhoto, action: \.proofPhoto)) { store in - proofPhotoFactory.makeView(store) + if let proofPhotoStore = store.scope(state: \.proofPhoto, action: \.proofPhoto) { + proofPhotoFactory.makeView(proofPhotoStore) .perfReadyMarker("goal-detail-to-proof-photo") } } From e786fc91c5eb3eeb05fed1032821ac9cc6b8dca0 Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Sun, 17 May 2026 15:05:08 +0900 Subject: [PATCH 014/100] =?UTF-8?q?refactor:=20StatsCoordinatorView=20IfLe?= =?UTF-8?q?tStore=20=EC=A0=9C=EA=B1=B0=20-=20#308?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Coordinator/StatsCoordinatorView.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinatorView.swift b/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinatorView.swift index 4e2715d0..7309a7c4 100644 --- a/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinatorView.swift +++ b/Projects/Feature/Stats/Sources/Coordinator/StatsCoordinatorView.swift @@ -44,21 +44,21 @@ public struct StatsCoordinatorView: View { .navigationDestination(for: StatsRoute.self) { route in switch route { case .statsDetail: - IfLetStore(store.scope(state: \.statsDetail, action: \.statsDetail)) { store in - StatsDetailView(store: store) + if let statsDetailStore = store.scope(state: \.statsDetail, action: \.statsDetail) { + StatsDetailView(store: statsDetailStore) .toolbar(.hidden, for: .tabBar) .perfReadyMarker("stats-to-stats-detail") } - + case .goalDetail: - IfLetStore(store.scope(state: \.goalDetail, action: \.goalDetail)) { store in - goalDetailFactory.makeView(store) + if let goalDetailStore = store.scope(state: \.goalDetail, action: \.goalDetail) { + goalDetailFactory.makeView(goalDetailStore) .toolbar(.hidden, for: .tabBar) } case .makeGoal: - IfLetStore(store.scope(state: \.makeGoal, action: \.makeGoal)) { store in - makeGoalFactory.makeView(store) + if let makeGoalStore = store.scope(state: \.makeGoal, action: \.makeGoal) { + makeGoalFactory.makeView(makeGoalStore) .toolbar(.hidden, for: .tabBar) } } From f8456e87e3f667cc6f1f168bb392ccc341118d2e Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Sun, 17 May 2026 20:12:03 +0900 Subject: [PATCH 015/100] =?UTF-8?q?test:=20Home=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EC=A7=80=EC=97=B0=20=EC=B8=A1=EC=A0=95=20?= =?UTF-8?q?=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20=EC=B6=94=EA=B0=80=20-?= =?UTF-8?q?=20#308?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HomeExampleActionLatencyTests.swift | 53 +++++++++++++++++++ .../Feature/Home/Sources/Home/HomeView.swift | 40 ++++++++++++++ .../Sources/View+PerfAccessibility.swift | 11 ++++ .../UITests/Sources/XCTestCase+Perf.swift | 48 +++++++++++++++++ 4 files changed, 152 insertions(+) create mode 100644 Projects/Feature/Home/ExampleUITests/Sources/HomeExampleActionLatencyTests.swift diff --git a/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleActionLatencyTests.swift b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleActionLatencyTests.swift new file mode 100644 index 00000000..520855df --- /dev/null +++ b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleActionLatencyTests.swift @@ -0,0 +1,53 @@ +import SharedPerfTestingSupportUITests +import XCTest + +/// Pass 3 PRIMARY action-latency scenario for FeatureHomeExample. +/// +/// Toggles `HomeReducer.State.calendarDate` between two adjacent months via +/// the production `.setCalendarDate` action (dispatched from a PERF-only +/// hidden button inside `HomeView`). Each toggle exercises the HomeView +/// read-set: calendarMonthTitle / calendarWeeks / isRefreshHidden change and +/// items refetch, which today invalidates the whole HomeView body. After +/// Pass 3 Phase E Commit 3 (read-set split), this scenario should only +/// invalidate the calendar + nav sub-views. +final class HomeExampleActionLatencyTests: XCTestCase { + func testActionLatency_calendarMonthToggle() { + let app = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("home", timeout: 30) + + let nextButton = app.descendants(matching: .any)["feature.home.perf.calendar-next"] + let prevButton = app.descendants(matching: .any)["feature.home.perf.calendar-prev"] + XCTAssertTrue(nextButton.waitForExistence(timeout: 5), "PERF calendar-next button missing") + XCTAssertTrue(prevButton.exists, "PERF calendar-prev button missing") + + // Pin to a known base month so the cycle is deterministic across runs. + // The PERF buttons mutate from the current calendarDate, so we read the + // first observed marker to establish the cycle. + let baseMarker = app.descendants(matching: .any) + .matching(NSPredicate(format: "identifier BEGINSWITH 'feature.home.marker.calendar-month.'")) + .firstMatch + XCTAssertTrue(baseMarker.waitForExistence(timeout: 5), "initial calendar-month marker missing") + let baseIdentifier = baseMarker.identifier + let baseValue = baseIdentifier.replacingOccurrences( + of: "feature.home.marker.calendar-month.", + with: "" + ) + let baseParts = baseValue.split(separator: "-").compactMap { Int($0) } + guard baseParts.count == 2 else { + XCTFail("unexpected base marker identifier: \(baseIdentifier)") + return + } + let baseYear = baseParts[0] + let baseMonth = baseParts[1] + let nextYear = baseMonth == 12 ? baseYear + 1 : baseYear + let nextMonth = baseMonth == 12 ? 1 : baseMonth + 1 + let nextValue = "\(nextYear)-\(nextMonth)" + + measureActionLatency(repetitions: 5) { + nextButton.tap() + awaitPerfMarker(slug: "home", key: "calendar-month", value: nextValue) + prevButton.tap() + awaitPerfMarker(slug: "home", key: "calendar-month", value: baseValue) + } + } +} diff --git a/Projects/Feature/Home/Sources/Home/HomeView.swift b/Projects/Feature/Home/Sources/Home/HomeView.swift index 03d6f873..62f8ab7f 100644 --- a/Projects/Feature/Home/Sources/Home/HomeView.swift +++ b/Projects/Feature/Home/Sources/Home/HomeView.swift @@ -43,6 +43,9 @@ public struct HomeView: View { public var body: some View { VStack(spacing: 0) { + if UITestMode.isEnabled { + perfActionHarness + } navigationBar calendar if store.hasCards { @@ -52,6 +55,11 @@ public struct HomeView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .perfStateMarker( + slug: "home", + key: "calendar-month", + value: "\(store.calendarDate.year)-\(store.calendarDate.month)" + ) .onAppear { store.send(.onAppear) } @@ -269,4 +277,36 @@ private extension HomeView { .padding(.trailing, 86) .ignoresSafeArea() } + + /// PERF-only controls used by Pass 3 same-screen state-change scenarios. + /// Production builds never enter this branch because `UITestMode.isEnabled` + /// requires the `-UITEST` launch argument. Buttons use a `Text` label + /// (44pt minimum hit target) so `XCUIElement.tap()` can resolve a valid + /// hit point. Visual opacity is `0.05` (effectively invisible) while + /// keeping the accessibility frame valid. + @ViewBuilder + var perfActionHarness: some View { + HStack(spacing: 0) { + Button { + var next = store.calendarDate + next.goToNextMonth() + store.send(.setCalendarDate(next)) + } label: { + Text(verbatim: "▶") + .frame(width: 44, height: 44) + } + .accessibilityIdentifier("feature.home.perf.calendar-next") + + Button { + var prev = store.calendarDate + prev.goToPreviousMonth() + store.send(.setCalendarDate(prev)) + } label: { + Text(verbatim: "◀") + .frame(width: 44, height: 44) + } + .accessibilityIdentifier("feature.home.perf.calendar-prev") + } + .opacity(0.05) + } } diff --git a/Projects/Shared/PerfTestingSupport/Sources/View+PerfAccessibility.swift b/Projects/Shared/PerfTestingSupport/Sources/View+PerfAccessibility.swift index 08b71dd2..8d2fc336 100644 --- a/Projects/Shared/PerfTestingSupport/Sources/View+PerfAccessibility.swift +++ b/Projects/Shared/PerfTestingSupport/Sources/View+PerfAccessibility.swift @@ -24,4 +24,15 @@ public extension View { .accessibilityIdentifier("feature.\(slug).ready") } } + + /// Exposes a deterministic accessibility marker whose identifier changes when + /// `value` changes. UITests can `waitForExistence` on a specific value to + /// detect that SwiftUI has reflected a state mutation. + func perfStateMarker(slug: String, key: String, value: String) -> some View { + overlay(alignment: .topLeading) { + Color.clear + .frame(width: 1, height: 1) + .accessibilityIdentifier("feature.\(slug).marker.\(key).\(value)") + } + } } diff --git a/Projects/Shared/PerfTestingSupport/UITests/Sources/XCTestCase+Perf.swift b/Projects/Shared/PerfTestingSupport/UITests/Sources/XCTestCase+Perf.swift index b469d0e8..5618f8ce 100644 --- a/Projects/Shared/PerfTestingSupport/UITests/Sources/XCTestCase+Perf.swift +++ b/Projects/Shared/PerfTestingSupport/UITests/Sources/XCTestCase+Perf.swift @@ -16,6 +16,28 @@ public func waitForFeatureReady( ) } +/// Waits for a `perfStateMarker(slug:key:value:)` to exist. Each unique value +/// produces a unique accessibility identifier, so `waitForExistence` can be +/// used to detect that SwiftUI has reflected a specific state mutation. +public func awaitPerfMarker( + slug: String, + key: String, + value: String, + timeout: TimeInterval = 5, + file: StaticString = #filePath, + line: UInt = #line +) { + let app = XCUIApplication() + let identifier = "feature.\(slug).marker.\(key).\(value)" + let marker = app.descendants(matching: .any)[identifier] + XCTAssertTrue( + marker.waitForExistence(timeout: timeout), + "Timed out waiting for marker \(identifier)", + file: file, + line: line + ) +} + public var defaultPerfMetrics: [XCTMetric] { [ XCTClockMetric(), @@ -23,3 +45,29 @@ public var defaultPerfMetrics: [XCTMetric] { XCTCPUMetric() ] } + +/// Metrics tuned for same-screen state-change action latency measurements. +/// Excludes memory delta (dominated by SwiftUI internals and not the action path). +public var actionLatencyMetrics: [XCTMetric] { + [ + XCTClockMetric(), + XCTCPUMetric() + ] +} + +public extension XCTestCase { + /// Wraps `measure(metrics:)` and repeats the supplied closure `repetitions` + /// times per iteration. Use for action-latency scenarios where each action + /// alone is too short to amortize XCTest measurement overhead. + func measureActionLatency( + metrics: [XCTMetric] = actionLatencyMetrics, + repetitions: Int = 5, + _ body: () -> Void + ) { + measure(metrics: metrics) { + for _ in 0.. Date: Sun, 17 May 2026 20:40:19 +0900 Subject: [PATCH 016/100] =?UTF-8?q?test:=20Home=20=ED=86=A0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=94=84=EB=A1=9C=EB=B8=8C=20=EC=8B=9C=EB=82=98?= =?UTF-8?q?=EB=A6=AC=EC=98=A4=20=EC=B6=94=EA=B0=80=20-=20#308?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HomeExampleActionLatencyTests.swift | 55 +++++++-- .../Home/Sources/Home/HomeReducer+Impl.swift | 2 +- .../Feature/Home/Sources/Home/HomeView.swift | 108 ++++++++++++++---- .../Sources/PerfCounters.swift | 43 +++++++ .../Sources/UITestMode.swift | 7 +- .../Sources/View+PerfAccessibility.swift | 20 ++++ .../UITests/Sources/XCTestCase+Perf.swift | 21 ++++ 7 files changed, 221 insertions(+), 35 deletions(-) create mode 100644 Projects/Shared/PerfTestingSupport/Sources/PerfCounters.swift diff --git a/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleActionLatencyTests.swift b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleActionLatencyTests.swift index 520855df..02811352 100644 --- a/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleActionLatencyTests.swift +++ b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleActionLatencyTests.swift @@ -1,16 +1,48 @@ import SharedPerfTestingSupportUITests import XCTest -/// Pass 3 PRIMARY action-latency scenario for FeatureHomeExample. +/// Pass 3 action-latency scenarios for FeatureHomeExample. /// -/// Toggles `HomeReducer.State.calendarDate` between two adjacent months via -/// the production `.setCalendarDate` action (dispatched from a PERF-only -/// hidden button inside `HomeView`). Each toggle exercises the HomeView -/// read-set: calendarMonthTitle / calendarWeeks / isRefreshHidden change and -/// items refetch, which today invalidates the whole HomeView body. After -/// Pass 3 Phase E Commit 3 (read-set split), this scenario should only -/// invalidate the calendar + nav sub-views. +/// PRIMARY (`testActionLatency_toastShowDismiss`): toggles `HomeReducer.State.toast` +/// between `nil` and `.warning(message:)` via PERF-only buttons in `HomeView`. +/// Production HomeView does not observe `toast` (the field is consumed by +/// `MainTabView` in the production app shell), so a `PerfToastPresentationHarness` +/// modifier conditionally adds the observation only when `UITestMode.isEnabled`. +/// This is a list-content-independent presentation-only state change, so the +/// Pass 3 Phase E Commit 3 read-set split should narrow this observation into +/// a presentation sub-view rather than the parent HomeView body. +/// +/// SECONDARY (`testActionLatency_calendarMonthToggle`): toggles `calendarDate` +/// via `.setCalendarDate`. Real production state change observed by the +/// calendar sub-view but ALSO triggers `calendarWeeks`/`items` cascade. Useful +/// for measuring read-set split effect on cascading invalidation. +/// +/// Reported metrics (`Clock Monotonic Time`) are **bundle latency** for +/// `repetitions: 5` iterations of show+dismiss (or next+prev). Per-action +/// latency is `bundle / 5 / 2` (each repetition is two state changes). final class HomeExampleActionLatencyTests: XCTestCase { + func testActionLatency_toastShowDismiss() { + let app = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("home", timeout: 30) + + let showButton = app.descendants(matching: .any)["feature.home.perf.toast-show"] + let dismissButton = app.descendants(matching: .any)["feature.home.perf.toast-dismiss"] + XCTAssertTrue(showButton.waitForExistence(timeout: 5), "PERF toast-show button missing") + XCTAssertTrue(dismissButton.exists, "PERF toast-dismiss button missing") + awaitPerfMarker(slug: "home", key: "toast", value: "hidden", timeout: 5) + + measureActionLatency(repetitions: 5) { + showButton.tap() + awaitPerfMarker(slug: "home", key: "toast", value: "visible") + dismissButton.tap() + awaitPerfMarker(slug: "home", key: "toast", value: "hidden") + } + + let rebuildProxy = readPerfCounter(slug: "home", key: "home.view.rebuild.proxy") + XCTAssertGreaterThan(rebuildProxy, 0, "home.view.rebuild.proxy counter never incremented") + print("[perf-counters] home.view.rebuild.proxy=\(rebuildProxy)") + } + func testActionLatency_calendarMonthToggle() { let app = XCUIApplication.launchForPerf(seed: "default") waitForFeatureReady("home", timeout: 30) @@ -20,9 +52,6 @@ final class HomeExampleActionLatencyTests: XCTestCase { XCTAssertTrue(nextButton.waitForExistence(timeout: 5), "PERF calendar-next button missing") XCTAssertTrue(prevButton.exists, "PERF calendar-prev button missing") - // Pin to a known base month so the cycle is deterministic across runs. - // The PERF buttons mutate from the current calendarDate, so we read the - // first observed marker to establish the cycle. let baseMarker = app.descendants(matching: .any) .matching(NSPredicate(format: "identifier BEGINSWITH 'feature.home.marker.calendar-month.'")) .firstMatch @@ -49,5 +78,9 @@ final class HomeExampleActionLatencyTests: XCTestCase { prevButton.tap() awaitPerfMarker(slug: "home", key: "calendar-month", value: baseValue) } + + let rebuildProxy = readPerfCounter(slug: "home", key: "home.view.rebuild.proxy") + XCTAssertGreaterThan(rebuildProxy, 0, "home.view.rebuild.proxy counter never incremented") + print("[perf-counters] home.view.rebuild.proxy=\(rebuildProxy)") } } diff --git a/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift b/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift index 5bf7f5ea..01955e40 100644 --- a/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift +++ b/Projects/Feature/Home/Sources/Home/HomeReducer+Impl.swift @@ -327,7 +327,7 @@ extension HomeReducer { case let .setCalendarDate(date): guard date != state.calendarDate else { return .none } - + let now = state.nowDate let today = TXCalendarDate( year: now.year, diff --git a/Projects/Feature/Home/Sources/Home/HomeView.swift b/Projects/Feature/Home/Sources/Home/HomeView.swift index 62f8ab7f..ebfe4ded 100644 --- a/Projects/Feature/Home/Sources/Home/HomeView.swift +++ b/Projects/Feature/Home/Sources/Home/HomeView.swift @@ -30,7 +30,7 @@ public struct HomeView: View { @Bindable public var store: StoreOf @Dependency(\.proofPhotoFactory) var proofPhotoFactory @State private var emptyScrollHeight: CGFloat = 0 - + /// HomeView를 생성합니다. /// /// ## 사용 예시 @@ -40,11 +40,18 @@ public struct HomeView: View { public init(store: StoreOf) { self.store = store } - + public var body: some View { VStack(spacing: 0) { + // PERF-only state-change harness (toast primary, calendar secondary). + // KNOWN LIMITATION: placed as VStack child rather than overlay + // because overlay placement produced `hit point {-1, -1}` for some + // buttons on iOS 26.2 simulator. This shifts the production layout + // ~44pt down ONLY in UITest mode; baseline and after both have the + // same shift, so DELTAs remain valid. See plan amendment B. if UITestMode.isEnabled { perfActionHarness + PerfRebuildProxyPing("home.view.rebuild.proxy") } navigationBar calendar @@ -55,10 +62,10 @@ public struct HomeView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .perfStateMarker( + .modifier(PerfToastPresentationHarness(toast: $store.toast)) + .perfCounterMarkers( slug: "home", - key: "calendar-month", - value: "\(store.calendarDate.year)-\(store.calendarDate.month)" + keys: ["home.view.rebuild.proxy"] ) .onAppear { store.send(.onAppear) @@ -128,7 +135,7 @@ private extension HomeView { } ) } - + var calendar: some View { TXCalendar( mode: .weekly, @@ -145,8 +152,16 @@ private extension HomeView { } ) .frame(maxWidth: .infinity, maxHeight: 76) + // Calendar-month marker lives inside the calendar sub-view so reading + // `store.calendarDate` for the marker value does NOT add a new read to + // the parent HomeView body. + .perfStateMarker( + slug: "home", + key: "calendar-month", + value: "\(store.calendarDate.year)-\(store.calendarDate.month)" + ) } - + var content: some View { ScrollView { Group { @@ -193,14 +208,14 @@ private extension HomeView { } } } - + var headerRow: some View { HStack(spacing: 0) { Text(store.goalSectionTitle) .typography(.b1_14b) - + Spacer() - + Button { store.send(.editButtonTapped) } label: { @@ -211,7 +226,7 @@ private extension HomeView { } .frame(height: 24) } - + var cardList: some View { LazyVStack(spacing: 16) { ForEach(store.items) { item in @@ -237,7 +252,7 @@ private extension HomeView { actionRight: { store.send(.yourCardTapped(item.card)) } ) } - + @ViewBuilder var goalEmptyView: some View { Group { @@ -246,7 +261,7 @@ private extension HomeView { Image.Illustration.scare .resizable() .frame(width: 164, height: 164) - + Text("이 날은 목표가 없어요!") .typography(.t2_16b) .foregroundStyle(Color.Gray.gray400) @@ -255,12 +270,12 @@ private extension HomeView { VStack(spacing: 0) { Image.Illustration.emptyPoke .frame(height: 116) - + Text("첫 목표를 세워볼까요?") .typography(.t2_16b) .foregroundStyle(Color.Gray.gray400) .padding(.top, 16) - + Text("+ 버튼을 눌러 목표를 추가해보세요") .typography(.c1_12r) .foregroundStyle(Color.Gray.gray300) @@ -270,7 +285,7 @@ private extension HomeView { } .frame(maxWidth: .infinity, maxHeight: .infinity) } - + var emptyArrow: some View { Image.Illustration.arrow .padding(.bottom, 71 + 58) @@ -280,13 +295,28 @@ private extension HomeView { /// PERF-only controls used by Pass 3 same-screen state-change scenarios. /// Production builds never enter this branch because `UITestMode.isEnabled` - /// requires the `-UITEST` launch argument. Buttons use a `Text` label - /// (44pt minimum hit target) so `XCUIElement.tap()` can resolve a valid - /// hit point. Visual opacity is `0.05` (effectively invisible) while - /// keeping the accessibility frame valid. + /// requires the `-UITEST` launch argument. Placed inside `.overlay` so it + /// does not shift production layout. Buttons use a Text label (44x44) so + /// `XCUIElement.tap()` can resolve a valid hit point. @ViewBuilder var perfActionHarness: some View { HStack(spacing: 0) { + Button { + store.send(.showToast(.warning(message: "perf-toast"))) + } label: { + Text(verbatim: "T") + .frame(width: 44, height: 44) + } + .accessibilityIdentifier("feature.home.perf.toast-show") + + Button { + store.toast = nil + } label: { + Text(verbatim: "X") + .frame(width: 44, height: 44) + } + .accessibilityIdentifier("feature.home.perf.toast-dismiss") + Button { var next = store.calendarDate next.goToNextMonth() @@ -310,3 +340,41 @@ private extension HomeView { .opacity(0.05) } } + +// MARK: - PERF Toast Presentation Harness + +/// PERF-only modifier that observes `store.toast` and exposes a deterministic +/// state-change marker. In production this modifier returns `content` +/// unchanged so HomeView's read-set never includes `toast`. The toast field +/// is already displayed at MainTab level in production, so any UITest-only +/// rendering of toast here would not cause double-display. +/// +/// Avoids `.txToast(item:)` because its 3-second auto-dismiss would add +/// non-deterministic state changes during measurement. The lightweight +/// overlay captures the same observation cost (HomeView body re-evaluating +/// on `toast` mutation) without auto-dismiss noise. +/// +/// After Pass 3 Commit 3 (read-set split) this modifier should be attached to +/// the presentation sub-view instead of the parent HomeView so the observation +/// stays scoped to the presentation layer. +private struct PerfToastPresentationHarness: ViewModifier { + @Binding var toast: TXToastType? + + func body(content: Content) -> some View { + if UITestMode.isEnabled { + content + .overlay(alignment: .bottom) { + if toast != nil { + Color.clear.frame(width: 1, height: 1) + } + } + .perfStateMarker( + slug: "home", + key: "toast", + value: toast == nil ? "hidden" : "visible" + ) + } else { + content + } + } +} diff --git a/Projects/Shared/PerfTestingSupport/Sources/PerfCounters.swift b/Projects/Shared/PerfTestingSupport/Sources/PerfCounters.swift new file mode 100644 index 00000000..7d24a529 --- /dev/null +++ b/Projects/Shared/PerfTestingSupport/Sources/PerfCounters.swift @@ -0,0 +1,43 @@ +import Foundation +import SwiftUI + +/// Process-wide counters for Pass 3 direct instrumentation. Counters are only +/// mutated when `UITestMode.isEnabled` is true, so production builds pay only +/// for a single boolean check on each call. Counter values are surfaced to +/// UITests via accessibility markers (see `perfCounterMarkers(slug:keys:)`). +public enum PerfCounters { + private static let lock = NSLock() + private static var values: [String: Int] = [:] + + /// Increments the named counter. No-op in production. + public static func increment(_ key: String) { + guard UITestMode.isEnabled else { return } + lock.lock() + values[key, default: 0] += 1 + lock.unlock() + } + + /// Reads the current value of a counter. Always returns 0 in production. + public static func value(for key: String) -> Int { + guard UITestMode.isEnabled else { return 0 } + lock.lock() + defer { lock.unlock() } + return values[key, default: 0] + } +} + +/// Proxy counter for SwiftUI view rebuild frequency. Increments on every +/// `init` because SwiftUI re-instantiates the View struct whenever its parent +/// body re-evaluates. This is a **proxy**, not an exact body-eval count — +/// SwiftUI may skip body closure evaluation for structurally identical views +/// even when they are re-initialized. Treat the counter as an upper bound / +/// invalidation-frequency signal, not an exact body invocation count. +public struct PerfRebuildProxyPing: View { + public init(_ key: String) { + PerfCounters.increment(key) + } + + public var body: some View { + Color.clear.frame(width: 0, height: 0) + } +} diff --git a/Projects/Shared/PerfTestingSupport/Sources/UITestMode.swift b/Projects/Shared/PerfTestingSupport/Sources/UITestMode.swift index 586ee7cc..5e0ff256 100644 --- a/Projects/Shared/PerfTestingSupport/Sources/UITestMode.swift +++ b/Projects/Shared/PerfTestingSupport/Sources/UITestMode.swift @@ -5,9 +5,10 @@ import SwiftUI public enum UITestMode { private static let arguments = ProcessInfo.processInfo.arguments - public static var isEnabled: Bool { - arguments.contains("-UITEST") - } + /// Cached so production code on the increment fast-path of `PerfCounters` + /// pays a single `let` read instead of scanning `ProcessInfo.arguments` + /// every call. + public static let isEnabled: Bool = arguments.contains("-UITEST") public static var seedName: String { value(after: "-UITEST_SEED") ?? "default" diff --git a/Projects/Shared/PerfTestingSupport/Sources/View+PerfAccessibility.swift b/Projects/Shared/PerfTestingSupport/Sources/View+PerfAccessibility.swift index 8d2fc336..66a89f34 100644 --- a/Projects/Shared/PerfTestingSupport/Sources/View+PerfAccessibility.swift +++ b/Projects/Shared/PerfTestingSupport/Sources/View+PerfAccessibility.swift @@ -35,4 +35,24 @@ public extension View { .accessibilityIdentifier("feature.\(slug).marker.\(key).\(value)") } } + + /// Exposes one accessibility marker per `PerfCounters` key. Each marker's + /// identifier embeds the current counter value, e.g. + /// `feature.home.counter.goal-section-title.42`. UITests can enumerate + /// `feature..counter.*` after a scenario completes to capture deltas. + /// The markers are evaluated when the surrounding view body re-renders; + /// trigger a body re-render via a state-change marker before reading. + func perfCounterMarkers(slug: String, keys: [String]) -> some View { + overlay(alignment: .topLeading) { + VStack(spacing: 0) { + ForEach(keys, id: \.self) { key in + Color.clear + .frame(width: 1, height: 1) + .accessibilityIdentifier( + "feature.\(slug).counter.\(key).\(PerfCounters.value(for: key))" + ) + } + } + } + } } diff --git a/Projects/Shared/PerfTestingSupport/UITests/Sources/XCTestCase+Perf.swift b/Projects/Shared/PerfTestingSupport/UITests/Sources/XCTestCase+Perf.swift index 5618f8ce..bd94e74b 100644 --- a/Projects/Shared/PerfTestingSupport/UITests/Sources/XCTestCase+Perf.swift +++ b/Projects/Shared/PerfTestingSupport/UITests/Sources/XCTestCase+Perf.swift @@ -38,6 +38,27 @@ public func awaitPerfMarker( ) } +/// Reads the latest value of a `PerfCounters` counter via its accessibility +/// marker (see `perfCounterMarkers(slug:keys:)`). Returns `-1` if no marker is +/// present (e.g. counter never written, or view body has not yet re-evaluated +/// after the increment). Trigger a body re-render via a state-change marker +/// before reading to ensure the marker reflects the latest counter value. +public func readPerfCounter(slug: String, key: String) -> Int { + let app = XCUIApplication() + let prefix = "feature.\(slug).counter.\(key)." + let query = app.descendants(matching: .any).matching( + NSPredicate(format: "identifier BEGINSWITH %@", prefix) + ) + for index in 0.. Date: Sun, 17 May 2026 21:27:22 +0900 Subject: [PATCH 017/100] =?UTF-8?q?docs:=20Home=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EB=B8=8C=20=EC=B8=A1=EC=A0=95=20=EA=B8=B0=EC=A4=80=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20-=20#308?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HomeExampleActionLatencyTests.swift | 86 ------------- .../HomeExampleRenderingProbeTests.swift | 121 ++++++++++++++++++ .../Feature/Home/Sources/Home/HomeView.swift | 38 ++++-- .../Sources/PerfCounters.swift | 28 ++-- .../Sources/View+PerfAccessibility.swift | 12 +- .../UITests/Sources/XCTestCase+Perf.swift | 24 +++- 6 files changed, 195 insertions(+), 114 deletions(-) delete mode 100644 Projects/Feature/Home/ExampleUITests/Sources/HomeExampleActionLatencyTests.swift create mode 100644 Projects/Feature/Home/ExampleUITests/Sources/HomeExampleRenderingProbeTests.swift diff --git a/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleActionLatencyTests.swift b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleActionLatencyTests.swift deleted file mode 100644 index 02811352..00000000 --- a/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleActionLatencyTests.swift +++ /dev/null @@ -1,86 +0,0 @@ -import SharedPerfTestingSupportUITests -import XCTest - -/// Pass 3 action-latency scenarios for FeatureHomeExample. -/// -/// PRIMARY (`testActionLatency_toastShowDismiss`): toggles `HomeReducer.State.toast` -/// between `nil` and `.warning(message:)` via PERF-only buttons in `HomeView`. -/// Production HomeView does not observe `toast` (the field is consumed by -/// `MainTabView` in the production app shell), so a `PerfToastPresentationHarness` -/// modifier conditionally adds the observation only when `UITestMode.isEnabled`. -/// This is a list-content-independent presentation-only state change, so the -/// Pass 3 Phase E Commit 3 read-set split should narrow this observation into -/// a presentation sub-view rather than the parent HomeView body. -/// -/// SECONDARY (`testActionLatency_calendarMonthToggle`): toggles `calendarDate` -/// via `.setCalendarDate`. Real production state change observed by the -/// calendar sub-view but ALSO triggers `calendarWeeks`/`items` cascade. Useful -/// for measuring read-set split effect on cascading invalidation. -/// -/// Reported metrics (`Clock Monotonic Time`) are **bundle latency** for -/// `repetitions: 5` iterations of show+dismiss (or next+prev). Per-action -/// latency is `bundle / 5 / 2` (each repetition is two state changes). -final class HomeExampleActionLatencyTests: XCTestCase { - func testActionLatency_toastShowDismiss() { - let app = XCUIApplication.launchForPerf(seed: "default") - waitForFeatureReady("home", timeout: 30) - - let showButton = app.descendants(matching: .any)["feature.home.perf.toast-show"] - let dismissButton = app.descendants(matching: .any)["feature.home.perf.toast-dismiss"] - XCTAssertTrue(showButton.waitForExistence(timeout: 5), "PERF toast-show button missing") - XCTAssertTrue(dismissButton.exists, "PERF toast-dismiss button missing") - awaitPerfMarker(slug: "home", key: "toast", value: "hidden", timeout: 5) - - measureActionLatency(repetitions: 5) { - showButton.tap() - awaitPerfMarker(slug: "home", key: "toast", value: "visible") - dismissButton.tap() - awaitPerfMarker(slug: "home", key: "toast", value: "hidden") - } - - let rebuildProxy = readPerfCounter(slug: "home", key: "home.view.rebuild.proxy") - XCTAssertGreaterThan(rebuildProxy, 0, "home.view.rebuild.proxy counter never incremented") - print("[perf-counters] home.view.rebuild.proxy=\(rebuildProxy)") - } - - func testActionLatency_calendarMonthToggle() { - let app = XCUIApplication.launchForPerf(seed: "default") - waitForFeatureReady("home", timeout: 30) - - let nextButton = app.descendants(matching: .any)["feature.home.perf.calendar-next"] - let prevButton = app.descendants(matching: .any)["feature.home.perf.calendar-prev"] - XCTAssertTrue(nextButton.waitForExistence(timeout: 5), "PERF calendar-next button missing") - XCTAssertTrue(prevButton.exists, "PERF calendar-prev button missing") - - let baseMarker = app.descendants(matching: .any) - .matching(NSPredicate(format: "identifier BEGINSWITH 'feature.home.marker.calendar-month.'")) - .firstMatch - XCTAssertTrue(baseMarker.waitForExistence(timeout: 5), "initial calendar-month marker missing") - let baseIdentifier = baseMarker.identifier - let baseValue = baseIdentifier.replacingOccurrences( - of: "feature.home.marker.calendar-month.", - with: "" - ) - let baseParts = baseValue.split(separator: "-").compactMap { Int($0) } - guard baseParts.count == 2 else { - XCTFail("unexpected base marker identifier: \(baseIdentifier)") - return - } - let baseYear = baseParts[0] - let baseMonth = baseParts[1] - let nextYear = baseMonth == 12 ? baseYear + 1 : baseYear - let nextMonth = baseMonth == 12 ? 1 : baseMonth + 1 - let nextValue = "\(nextYear)-\(nextMonth)" - - measureActionLatency(repetitions: 5) { - nextButton.tap() - awaitPerfMarker(slug: "home", key: "calendar-month", value: nextValue) - prevButton.tap() - awaitPerfMarker(slug: "home", key: "calendar-month", value: baseValue) - } - - let rebuildProxy = readPerfCounter(slug: "home", key: "home.view.rebuild.proxy") - XCTAssertGreaterThan(rebuildProxy, 0, "home.view.rebuild.proxy counter never incremented") - print("[perf-counters] home.view.rebuild.proxy=\(rebuildProxy)") - } -} diff --git a/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleRenderingProbeTests.swift b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleRenderingProbeTests.swift new file mode 100644 index 00000000..08be84af --- /dev/null +++ b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleRenderingProbeTests.swift @@ -0,0 +1,121 @@ +import SharedPerfTestingSupportUITests +import XCTest + +/// Pass 3 **probe-only** UITests for FeatureHomeExample. +/// +/// IMPORTANT — these tests are NOT UI Rendering benchmarks. +/// +/// - The XCTest `Clock Monotonic Time` / `CPU Instructions Retired` numbers +/// reported by `measureActionLatency` include XCUI tap synthesis, marker +/// polling, accessibility-tree synchronization, and app/test process IPC. +/// They do not isolate SwiftUI rendering cost. +/// - The authoritative UI Rendering metric is an Xcode Instruments / xctrace +/// `Time Profiler` (or `SwiftUI`) trace recorded on a real device. The +/// UITest is only the deterministic driver that drives the app through the +/// same UI steps during recording. +/// - `PerfRebuildProxyPing` is a proxy signal for view rebuild frequency +/// (View struct init). It is not an exact SwiftUI body-evaluation counter. +/// - The `PerfToastPresentationHarness` modifier conditionally adds +/// `store.toast` observation to HomeView **only when UITestMode.isEnabled**. +/// Production HomeView does not observe `toast`. The probe scenario is +/// therefore an artificial path used to exercise observation scoping +/// experiments; it is NOT representative of the user's real rendering path. +/// - The PERF action harness sits as the first VStack child in HomeView and +/// shifts the production layout by ~44pt only in UITest mode. This is a +/// known limitation (see plan amendment B). The harness must NOT be mixed +/// into authoritative rendering scenarios (e.g. feed scroll) where layout +/// shift affects scroll geometry / LazyVStack materialization. +/// +/// Treat the numbers below as **driver/probe sanity metrics**. Do not cite +/// them as UI Rendering improvement evidence. +/// +/// ## Reading the measured metric +/// +/// `measureActionLatency(repetitions: 5)` reports the time for the **bundle** +/// of 5 repetitions per `measure` iteration. To derive per-state-change +/// latency: `bundle / repetitions / (state changes per repetition)`. For +/// these probes each repetition performs 2 state changes (show+dismiss or +/// next+prev), so per-action latency = `bundle / 5 / 2`. +final class HomeExampleRenderingProbeTests: XCTestCase { + + /// Probe: toggle `store.toast` via PERF-only buttons and confirm the + /// `PerfToastPresentationHarness` marker + `home.view.rebuild.proxy` + /// counter respond. Not an authoritative rendering metric. + func testProbe_toastShowDismiss_markerAndCounter() { + let app = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("home", timeout: 30) + + let showButton = app.descendants(matching: .any)["feature.home.perf.toast-show"] + let dismissButton = app.descendants(matching: .any)["feature.home.perf.toast-dismiss"] + XCTAssertTrue(showButton.waitForExistence(timeout: 5), "PERF toast-show button missing") + XCTAssertTrue(dismissButton.exists, "PERF toast-dismiss button missing") + awaitPerfMarker(slug: "home", key: "toast", value: "hidden", timeout: 5) + + // Probe-only driver metric. See class doc-comment. + measureActionLatency(repetitions: 5) { + showButton.tap() + awaitPerfMarker(slug: "home", key: "toast", value: "visible") + dismissButton.tap() + awaitPerfMarker(slug: "home", key: "toast", value: "hidden") + } + + let rebuildProxy = readPerfCounter(slug: "home", key: "home.view.rebuild.proxy") + XCTAssertGreaterThan( + rebuildProxy, + 0, + "home.view.rebuild.proxy counter never incremented (proxy signal, not exact body count)" + ) + print("[perf-probe-counters] home.view.rebuild.proxy=\(rebuildProxy)") + } + + /// Probe: toggle `calendarDate` via PERF-only buttons and confirm the + /// calendar sub-view `perfStateMarker` responds. Triggers a real + /// production cascade (calendarWeeks / items refetch), so this probe + /// includes more than a pure presentation-only state change. Not an + /// authoritative rendering metric. + func testProbe_calendarMonthToggle_markerAndCounter() { + let app = XCUIApplication.launchForPerf(seed: "default") + waitForFeatureReady("home", timeout: 30) + + let nextButton = app.descendants(matching: .any)["feature.home.perf.calendar-next"] + let prevButton = app.descendants(matching: .any)["feature.home.perf.calendar-prev"] + XCTAssertTrue(nextButton.waitForExistence(timeout: 5), "PERF calendar-next button missing") + XCTAssertTrue(prevButton.exists, "PERF calendar-prev button missing") + + let baseMarker = app.descendants(matching: .any) + .matching(NSPredicate(format: "identifier BEGINSWITH 'feature.home.marker.calendar-month.'")) + .firstMatch + XCTAssertTrue(baseMarker.waitForExistence(timeout: 5), "initial calendar-month marker missing") + let baseIdentifier = baseMarker.identifier + let baseValue = baseIdentifier.replacingOccurrences( + of: "feature.home.marker.calendar-month.", + with: "" + ) + let baseParts = baseValue.split(separator: "-").compactMap { Int($0) } + guard baseParts.count == 2 else { + XCTFail("unexpected base marker identifier: \(baseIdentifier)") + return + } + let baseYear = baseParts[0] + let baseMonth = baseParts[1] + let nextYear = baseMonth == 12 ? baseYear + 1 : baseYear + let nextMonth = baseMonth == 12 ? 1 : baseMonth + 1 + let nextValue = "\(nextYear)-\(nextMonth)" + + // Probe-only driver metric. See class doc-comment. + measureActionLatency(repetitions: 5) { + nextButton.tap() + awaitPerfMarker(slug: "home", key: "calendar-month", value: nextValue) + prevButton.tap() + awaitPerfMarker(slug: "home", key: "calendar-month", value: baseValue) + } + + let rebuildProxy = readPerfCounter(slug: "home", key: "home.view.rebuild.proxy") + XCTAssertGreaterThan( + rebuildProxy, + 0, + "home.view.rebuild.proxy counter never incremented (proxy signal, not exact body count)" + ) + print("[perf-probe-counters] home.view.rebuild.proxy=\(rebuildProxy)") + } +} diff --git a/Projects/Feature/Home/Sources/Home/HomeView.swift b/Projects/Feature/Home/Sources/Home/HomeView.swift index ebfe4ded..41a6a62e 100644 --- a/Projects/Feature/Home/Sources/Home/HomeView.swift +++ b/Projects/Feature/Home/Sources/Home/HomeView.swift @@ -293,11 +293,22 @@ private extension HomeView { .ignoresSafeArea() } - /// PERF-only controls used by Pass 3 same-screen state-change scenarios. - /// Production builds never enter this branch because `UITestMode.isEnabled` - /// requires the `-UITEST` launch argument. Placed inside `.overlay` so it - /// does not shift production layout. Buttons use a Text label (44x44) so - /// `XCUIElement.tap()` can resolve a valid hit point. + /// PERF-only controls used by Pass 3 **probe scenarios** (toast / calendar + /// month toggle). Production builds never enter this branch because + /// `UITestMode.isEnabled` requires the `-UITEST` launch argument. Buttons + /// use a 44x44 Text label so `XCUIElement.tap()` can resolve a valid hit + /// point. + /// + /// **Known limitation**: this harness is the first child of HomeView's + /// VStack and shifts the production layout by ~44pt in UITest mode. + /// `.overlay` placement (which would be layout-neutral) produced + /// non-deterministic `hit point {-1, -1}` for some buttons on iOS 26.2 + /// simulator. Baseline and after both share the shift, so DELTAs from + /// probe scenarios remain comparable. **The harness must NOT be mixed + /// into authoritative rendering scenarios** (e.g. feed scroll) where the + /// 44pt shift would affect scroll geometry, visible cell count, or + /// LazyVStack materialization range. Rendering scenarios should run in a + /// separate launch mode that does not activate this harness. @ViewBuilder var perfActionHarness: some View { HStack(spacing: 0) { @@ -343,11 +354,18 @@ private extension HomeView { // MARK: - PERF Toast Presentation Harness -/// PERF-only modifier that observes `store.toast` and exposes a deterministic -/// state-change marker. In production this modifier returns `content` -/// unchanged so HomeView's read-set never includes `toast`. The toast field -/// is already displayed at MainTab level in production, so any UITest-only -/// rendering of toast here would not cause double-display. +/// PERF-only modifier used by Pass 3 **probe scenarios**. Observes +/// `store.toast` and exposes a deterministic state-change marker. In +/// production this modifier returns `content` unchanged so HomeView's +/// read-set never includes `toast`. +/// +/// **Probe context**: production HomeView does not observe `toast` (the +/// field is displayed at the MainTab level in the production app shell). +/// This modifier adds an artificial UITEST-only observation path so the +/// toast probe scenario can exercise observation scoping experiments. The +/// scenario is therefore **not representative of the user's real rendering +/// path**, and the resulting XCTest numbers must not be cited as UI +/// Rendering improvement evidence. /// /// Avoids `.txToast(item:)` because its 3-second auto-dismiss would add /// non-deterministic state changes during measurement. The lightweight diff --git a/Projects/Shared/PerfTestingSupport/Sources/PerfCounters.swift b/Projects/Shared/PerfTestingSupport/Sources/PerfCounters.swift index 7d24a529..93a21a68 100644 --- a/Projects/Shared/PerfTestingSupport/Sources/PerfCounters.swift +++ b/Projects/Shared/PerfTestingSupport/Sources/PerfCounters.swift @@ -1,10 +1,15 @@ import Foundation import SwiftUI -/// Process-wide counters for Pass 3 direct instrumentation. Counters are only -/// mutated when `UITestMode.isEnabled` is true, so production builds pay only -/// for a single boolean check on each call. Counter values are surfaced to -/// UITests via accessibility markers (see `perfCounterMarkers(slug:keys:)`). +/// Process-wide **probe-only** counters for Pass 3 direct instrumentation. +/// Counters are only mutated when `UITestMode.isEnabled` is true, so +/// production builds pay only for a single boolean check on each call. +/// Counter values are surfaced to UITests via accessibility markers (see +/// `perfCounterMarkers(slug:keys:)`). +/// +/// These counters are sanity signals for the UITest driver / harness, not +/// authoritative SwiftUI rendering metrics. The authoritative metric is an +/// Xcode Instruments / xctrace trace. public enum PerfCounters { private static let lock = NSLock() private static var values: [String: Int] = [:] @@ -26,12 +31,17 @@ public enum PerfCounters { } } -/// Proxy counter for SwiftUI view rebuild frequency. Increments on every +/// **Proxy** counter for SwiftUI view rebuild frequency. Increments on every /// `init` because SwiftUI re-instantiates the View struct whenever its parent -/// body re-evaluates. This is a **proxy**, not an exact body-eval count — -/// SwiftUI may skip body closure evaluation for structurally identical views -/// even when they are re-initialized. Treat the counter as an upper bound / -/// invalidation-frequency signal, not an exact body invocation count. +/// rebuilds the child node. This is a **proxy signal, not an exact SwiftUI +/// body-evaluation counter** — SwiftUI may skip body closure evaluation for +/// structurally identical views even when they are re-initialized, and +/// counter timing is affected by marker refresh and accessibility update +/// scheduling. +/// +/// Treat the value as a coarse invalidation-frequency signal. Do not cite it +/// as "body evaluation count" in any rendering performance report. Use +/// Xcode Instruments / xctrace traces for authoritative analysis. public struct PerfRebuildProxyPing: View { public init(_ key: String) { PerfCounters.increment(key) diff --git a/Projects/Shared/PerfTestingSupport/Sources/View+PerfAccessibility.swift b/Projects/Shared/PerfTestingSupport/Sources/View+PerfAccessibility.swift index 66a89f34..c65cc323 100644 --- a/Projects/Shared/PerfTestingSupport/Sources/View+PerfAccessibility.swift +++ b/Projects/Shared/PerfTestingSupport/Sources/View+PerfAccessibility.swift @@ -38,10 +38,14 @@ public extension View { /// Exposes one accessibility marker per `PerfCounters` key. Each marker's /// identifier embeds the current counter value, e.g. - /// `feature.home.counter.goal-section-title.42`. UITests can enumerate - /// `feature..counter.*` after a scenario completes to capture deltas. - /// The markers are evaluated when the surrounding view body re-renders; - /// trigger a body re-render via a state-change marker before reading. + /// `feature.home.counter.home.view.rebuild.proxy.42`. UITests can enumerate + /// `feature..counter.*` after a scenario completes to capture + /// deltas. The markers are evaluated when the surrounding view body + /// re-renders; trigger a body re-render via a state-change marker before + /// reading. + /// + /// **Probe-only**. The counter values are sanity signals for the UITest + /// driver, not authoritative SwiftUI rendering metrics. func perfCounterMarkers(slug: String, keys: [String]) -> some View { overlay(alignment: .topLeading) { VStack(spacing: 0) { diff --git a/Projects/Shared/PerfTestingSupport/UITests/Sources/XCTestCase+Perf.swift b/Projects/Shared/PerfTestingSupport/UITests/Sources/XCTestCase+Perf.swift index bd94e74b..eae37373 100644 --- a/Projects/Shared/PerfTestingSupport/UITests/Sources/XCTestCase+Perf.swift +++ b/Projects/Shared/PerfTestingSupport/UITests/Sources/XCTestCase+Perf.swift @@ -67,8 +67,12 @@ public var defaultPerfMetrics: [XCTMetric] { ] } -/// Metrics tuned for same-screen state-change action latency measurements. -/// Excludes memory delta (dominated by SwiftUI internals and not the action path). +/// Metrics tuned for **probe-only** driver/marker sanity measurements. +/// Excludes memory delta (dominated by SwiftUI internals and not the action +/// path). These numbers include XCUI tap synthesis, marker polling, +/// accessibility-tree synchronization, and app/test process IPC — they do +/// not isolate SwiftUI rendering cost and must not be cited as the +/// authoritative UI Rendering metric. public var actionLatencyMetrics: [XCTMetric] { [ XCTClockMetric(), @@ -77,9 +81,19 @@ public var actionLatencyMetrics: [XCTMetric] { } public extension XCTestCase { - /// Wraps `measure(metrics:)` and repeats the supplied closure `repetitions` - /// times per iteration. Use for action-latency scenarios where each action - /// alone is too short to amortize XCTest measurement overhead. + /// **Probe-only** helper. Wraps `measure(metrics:)` and repeats the + /// supplied closure `repetitions` times per iteration to amortize XCTest + /// measurement overhead. + /// + /// The measured `Clock Monotonic Time` is the **bundle latency** for all + /// `repetitions` of `body`. To derive per-action latency you must divide + /// by `repetitions` and by the number of state changes per repetition. + /// + /// The reported numbers are a driver/marker sanity signal — they include + /// XCUI tap synthesis, marker polling, accessibility synchronization, and + /// app/test process IPC. They are **not** the authoritative UI Rendering + /// metric. Use Xcode Instruments / xctrace `Time Profiler` (or `SwiftUI`) + /// traces recorded on a real device for any final rendering comparison. func measureActionLatency( metrics: [XCTMetric] = actionLatencyMetrics, repetitions: Int = 5, From 24e93f6d61485866ec6cc84fda4f7d0f9b756a18 Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Mon, 18 May 2026 12:12:44 +0900 Subject: [PATCH 018/100] =?UTF-8?q?test:=20Home=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EB=B8=8C=20=ED=95=98=EB=84=A4=EC=8A=A4=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=EB=B6=84=EB=A6=AC=20-=20#308?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 7 +- .../HomeExampleRenderingProbeTests.swift | 17 ++-- .../Feature/Home/Sources/Home/HomeView.swift | 91 ++++++++++++------- .../Sources/UITestMode.swift | 14 +++ .../Sources/XCUIApplication+Perf.swift | 20 ++++ 5 files changed, 109 insertions(+), 40 deletions(-) diff --git a/.gitignore b/.gitignore index 4c65a1e2..6cf19827 100644 --- a/.gitignore +++ b/.gitignore @@ -177,5 +177,8 @@ Network Trash Folder Temporary Items .apdisk src/SupportingFiles/Booket/GoogleService-Info.plist -# Performance traces (large, generated) -.perf/traces/ +# Performance traces and local probe workspace (large, generated) +.perf/ + +# Claude Code scheduled-task runtime lock (per-machine, not shared) +.claude/scheduled_tasks.lock diff --git a/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleRenderingProbeTests.swift b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleRenderingProbeTests.swift index 08be84af..0ff0ae15 100644 --- a/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleRenderingProbeTests.swift +++ b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleRenderingProbeTests.swift @@ -21,10 +21,15 @@ import XCTest /// therefore an artificial path used to exercise observation scoping /// experiments; it is NOT representative of the user's real rendering path. /// - The PERF action harness sits as the first VStack child in HomeView and -/// shifts the production layout by ~44pt only in UITest mode. This is a -/// known limitation (see plan amendment B). The harness must NOT be mixed -/// into authoritative rendering scenarios (e.g. feed scroll) where layout -/// shift affects scroll geometry / LazyVStack materialization. +/// shifts the production layout by ~44pt **only when the probe scenario is +/// active** (`-UITEST_PROBE_SCENARIO`). Plain UITest launches and +/// `-UITEST_RENDERING_SCENARIO` launches do not activate the harness, so +/// production layout / scroll geometry is preserved for those modes. Even +/// inside the probe scenario, residual effects of the harness on SwiftUI +/// layout pass, accessibility tree, scroll geometry, and LazyVStack +/// materialization cannot be fully ruled out — probe DELTAs should be +/// interpreted cautiously and any authoritative claim must be verified +/// with an Instruments trace from a rendering-scenario launch. /// /// Treat the numbers below as **driver/probe sanity metrics**. Do not cite /// them as UI Rendering improvement evidence. @@ -42,7 +47,7 @@ final class HomeExampleRenderingProbeTests: XCTestCase { /// `PerfToastPresentationHarness` marker + `home.view.rebuild.proxy` /// counter respond. Not an authoritative rendering metric. func testProbe_toastShowDismiss_markerAndCounter() { - let app = XCUIApplication.launchForPerf(seed: "default") + let app = XCUIApplication.launchForPerf(seed: "default", scenario: .probe) waitForFeatureReady("home", timeout: 30) let showButton = app.descendants(matching: .any)["feature.home.perf.toast-show"] @@ -74,7 +79,7 @@ final class HomeExampleRenderingProbeTests: XCTestCase { /// includes more than a pure presentation-only state change. Not an /// authoritative rendering metric. func testProbe_calendarMonthToggle_markerAndCounter() { - let app = XCUIApplication.launchForPerf(seed: "default") + let app = XCUIApplication.launchForPerf(seed: "default", scenario: .probe) waitForFeatureReady("home", timeout: 30) let nextButton = app.descendants(matching: .any)["feature.home.perf.calendar-next"] diff --git a/Projects/Feature/Home/Sources/Home/HomeView.swift b/Projects/Feature/Home/Sources/Home/HomeView.swift index 41a6a62e..38514501 100644 --- a/Projects/Feature/Home/Sources/Home/HomeView.swift +++ b/Projects/Feature/Home/Sources/Home/HomeView.swift @@ -43,13 +43,18 @@ public struct HomeView: View { public var body: some View { VStack(spacing: 0) { - // PERF-only state-change harness (toast primary, calendar secondary). + // PERF probe harness (toast / calendar month toggle drivers). + // Activated ONLY for probe scenarios (`-UITEST_PROBE_SCENARIO`), + // not for plain UITest launches and not for rendering scenarios. // KNOWN LIMITATION: placed as VStack child rather than overlay - // because overlay placement produced `hit point {-1, -1}` for some - // buttons on iOS 26.2 simulator. This shifts the production layout - // ~44pt down ONLY in UITest mode; baseline and after both have the - // same shift, so DELTAs remain valid. See plan amendment B. - if UITestMode.isEnabled { + // because overlay placement produced `hit point {-1, -1}` for + // some buttons on iOS 26.2 simulator. This shifts the production + // layout ~44pt down only when the probe scenario is active. + // DELTAs across probe baseline vs after are believed comparable + // since both share the shift, but SwiftUI layout pass / + // accessibility tree / scroll geometry side-effects of the + // harness are not fully ruled out — see plan amendment B. + if UITestMode.isProbeScenario { perfActionHarness PerfRebuildProxyPing("home.view.rebuild.proxy") } @@ -63,10 +68,7 @@ public struct HomeView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .modifier(PerfToastPresentationHarness(toast: $store.toast)) - .perfCounterMarkers( - slug: "home", - keys: ["home.view.rebuild.proxy"] - ) + .modifier(PerfHomeCounterMarkersHarness()) .onAppear { store.send(.onAppear) } @@ -136,8 +138,9 @@ private extension HomeView { ) } + @ViewBuilder var calendar: some View { - TXCalendar( + let calendarView = TXCalendar( mode: .weekly, currentDate: $store.calendarDate, weeks: store.calendarWeeks, @@ -152,14 +155,19 @@ private extension HomeView { } ) .frame(maxWidth: .infinity, maxHeight: 76) - // Calendar-month marker lives inside the calendar sub-view so reading - // `store.calendarDate` for the marker value does NOT add a new read to - // the parent HomeView body. - .perfStateMarker( - slug: "home", - key: "calendar-month", - value: "\(store.calendarDate.year)-\(store.calendarDate.month)" - ) + + if UITestMode.isProbeScenario { + // Calendar-month marker lives inside the calendar sub-view so + // reading `store.calendarDate` for the marker value does NOT add + // a new read to the parent HomeView body. Probe-only. + calendarView.perfStateMarker( + slug: "home", + key: "calendar-month", + value: "\(store.calendarDate.year)-\(store.calendarDate.month)" + ) + } else { + calendarView + } } var content: some View { @@ -295,20 +303,23 @@ private extension HomeView { /// PERF-only controls used by Pass 3 **probe scenarios** (toast / calendar /// month toggle). Production builds never enter this branch because - /// `UITestMode.isEnabled` requires the `-UITEST` launch argument. Buttons - /// use a 44x44 Text label so `XCUIElement.tap()` can resolve a valid hit - /// point. + /// `UITestMode.isProbeScenario` requires the `-UITEST_PROBE_SCENARIO` + /// launch argument. Buttons use a 44x44 Text label so `XCUIElement.tap()` + /// can resolve a valid hit point. /// /// **Known limitation**: this harness is the first child of HomeView's - /// VStack and shifts the production layout by ~44pt in UITest mode. - /// `.overlay` placement (which would be layout-neutral) produced - /// non-deterministic `hit point {-1, -1}` for some buttons on iOS 26.2 - /// simulator. Baseline and after both share the shift, so DELTAs from - /// probe scenarios remain comparable. **The harness must NOT be mixed - /// into authoritative rendering scenarios** (e.g. feed scroll) where the - /// 44pt shift would affect scroll geometry, visible cell count, or - /// LazyVStack materialization range. Rendering scenarios should run in a - /// separate launch mode that does not activate this harness. + /// VStack and shifts the production layout by ~44pt when the probe + /// scenario is active. `.overlay` placement (which would be + /// layout-neutral) produced non-deterministic `hit point {-1, -1}` for + /// some buttons on iOS 26.2 simulator. Probe baseline and probe after + /// both share the shift, so the 1st-order risk to probe DELTA comparison + /// is reduced — but residual effects of the harness on SwiftUI layout + /// pass, accessibility tree, scroll geometry, and LazyVStack + /// materialization are **not fully ruled out**. Interpret probe DELTAs + /// cautiously and verify any authoritative conclusion with an Instruments + /// trace. **The harness must NOT be mixed into authoritative rendering + /// scenarios** (e.g. feed scroll); rendering scenarios launch via + /// `-UITEST_RENDERING_SCENARIO` which keeps this harness disabled. @ViewBuilder var perfActionHarness: some View { HStack(spacing: 0) { @@ -379,7 +390,7 @@ private struct PerfToastPresentationHarness: ViewModifier { @Binding var toast: TXToastType? func body(content: Content) -> some View { - if UITestMode.isEnabled { + if UITestMode.isProbeScenario { content .overlay(alignment: .bottom) { if toast != nil { @@ -396,3 +407,19 @@ private struct PerfToastPresentationHarness: ViewModifier { } } } + +/// Wraps `perfCounterMarkers` so the counter accessibility overlays only +/// attach in probe scenarios. Rendering / smoke launches see no marker +/// overlays at all. +private struct PerfHomeCounterMarkersHarness: ViewModifier { + func body(content: Content) -> some View { + if UITestMode.isProbeScenario { + content.perfCounterMarkers( + slug: "home", + keys: ["home.view.rebuild.proxy"] + ) + } else { + content + } + } +} diff --git a/Projects/Shared/PerfTestingSupport/Sources/UITestMode.swift b/Projects/Shared/PerfTestingSupport/Sources/UITestMode.swift index 5e0ff256..4cee8f2a 100644 --- a/Projects/Shared/PerfTestingSupport/Sources/UITestMode.swift +++ b/Projects/Shared/PerfTestingSupport/Sources/UITestMode.swift @@ -10,6 +10,20 @@ public enum UITestMode { /// every call. public static let isEnabled: Bool = arguments.contains("-UITEST") + /// True when launched for a **probe scenario** (driver/marker/counter + /// sanity test, e.g. `HomeExampleRenderingProbeTests`). Activates the + /// PERF action harness and probe markers / counters in `HomeView`. Do + /// not enable for authoritative rendering scenarios — the harness shifts + /// HomeView layout by ~44pt and that may affect SwiftUI layout pass, + /// scroll geometry, and LazyVStack materialization. + public static let isProbeScenario: Bool = arguments.contains("-UITEST_PROBE_SCENARIO") + + /// True when launched for an **authoritative rendering scenario** driven + /// by Xcode Instruments / xctrace (e.g. home-heavy feed scroll). Keeps + /// the PERF probe harness disabled so the production layout / scroll + /// geometry is preserved during trace recording. + public static let isRenderingScenario: Bool = arguments.contains("-UITEST_RENDERING_SCENARIO") + public static var seedName: String { value(after: "-UITEST_SEED") ?? "default" } diff --git a/Projects/Shared/PerfTestingSupport/UITests/Sources/XCUIApplication+Perf.swift b/Projects/Shared/PerfTestingSupport/UITests/Sources/XCUIApplication+Perf.swift index f30e55c2..e3b98b3d 100644 --- a/Projects/Shared/PerfTestingSupport/UITests/Sources/XCUIApplication+Perf.swift +++ b/Projects/Shared/PerfTestingSupport/UITests/Sources/XCUIApplication+Perf.swift @@ -1,8 +1,24 @@ import XCTest public extension XCUIApplication { + /// Selects which Pass 3 measurement mode the app launches in. + /// `nil` keeps the app in plain UITest mode without any PERF harness or + /// probe markers — appropriate for smoke / navigation / scroll tests + /// that should not see harness-induced layout shifts. + enum PerfScenarioKind: String { + /// Activates the PERF action harness, probe markers, and proxy + /// counters. For driver/marker sanity tests only — NOT for + /// authoritative rendering measurements. + case probe = "-UITEST_PROBE_SCENARIO" + /// Keeps the PERF harness disabled while still being a UITest + /// driver. Intended for xctrace / Instruments recording of real + /// rendering scenarios (e.g. home-heavy feed scroll). + case rendering = "-UITEST_RENDERING_SCENARIO" + } + static func launchForPerf( seed: String, + scenario: PerfScenarioKind? = nil, disableAnimations: Bool = true ) -> XCUIApplication { let app = XCUIApplication() @@ -14,6 +30,10 @@ public extension XCUIApplication { app.launchArguments.append("-UITEST_DISABLE_ANIMATIONS") } + if let scenario { + app.launchArguments.append(scenario.rawValue) + } + app.launch() return app } From 5fabeb2d20f14cf9bfed90271667f8846c5799ef Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Mon, 18 May 2026 12:19:14 +0900 Subject: [PATCH 019/100] =?UTF-8?q?test:=20Home=20=ED=94=BC=EB=93=9C=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EB=A0=8C=EB=8D=94=EB=A7=81=20?= =?UTF-8?q?=EB=93=9C=EB=9D=BC=EC=9D=B4=EB=B2=84=20=EC=B6=94=EA=B0=80=20-?= =?UTF-8?q?=20#308?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/Example/Sources/HomeApp.swift | 17 ++++- .../HomeExampleFeedScrollRenderingTests.swift | 71 +++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 Projects/Feature/Home/ExampleUITests/Sources/HomeExampleFeedScrollRenderingTests.swift diff --git a/Projects/Feature/Home/Example/Sources/HomeApp.swift b/Projects/Feature/Home/Example/Sources/HomeApp.swift index ca7c992d..63ad890c 100644 --- a/Projects/Feature/Home/Example/Sources/HomeApp.swift +++ b/Projects/Feature/Home/Example/Sources/HomeApp.swift @@ -67,14 +67,27 @@ struct HomeApp: App { private extension HomeApp { static func goalClient(for seed: String) -> GoalClient { - guard UITestMode.isEnabled, seed == "scroll-50" else { + guard UITestMode.isEnabled else { return .previewValue } + switch seed { + case "scroll-50": + return perfGoalClient(count: 50) + case "home-heavy": + // First authoritative rendering driver dataset. 200 cells is + // large enough to force multiple scroll passes and exercise + // LazyVStack materialization without making the recording window + // unbounded. + return perfGoalClient(count: 200) + default: return .previewValue } + } + + static func perfGoalClient(count: Int) -> GoalClient { var client = GoalClient.previewValue client.fetchGoals = { _ in GoalList( hasEverRegisteredGoal: true, - goals: (1...50).map(perfScrollGoal(index:)) + goals: (1...count).map(perfScrollGoal(index:)) ) } return client diff --git a/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleFeedScrollRenderingTests.swift b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleFeedScrollRenderingTests.swift new file mode 100644 index 00000000..1b96b0b7 --- /dev/null +++ b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleFeedScrollRenderingTests.swift @@ -0,0 +1,71 @@ +import SharedPerfTestingSupportUITests +import XCTest + +/// Pass 3 **rendering driver** UITest for FeatureHomeExample. +/// +/// This is the first authoritative rendering scenario for the home feed. +/// It is NOT a benchmark. The XCTest pass/fail status and any timing the +/// XCTest harness happens to print are not the rendering metric — they only +/// indicate whether the deterministic UI script completed. +/// +/// ## Intended use +/// +/// 1. Start an Instruments / xctrace recording (Time Profiler or SwiftUI +/// template) on a real device against the `FeatureHomeExample` bundle id. +/// 2. Launch this test against the same device. The test launches with +/// `-UITEST` + `-UITEST_RENDERING_SCENARIO` + `-UITEST_SEED home-heavy`, +/// so the PERF probe harness (`perfActionHarness`, +/// `PerfRebuildProxyPing`, calendar marker, `PerfToastPresentationHarness`, +/// counter markers) is NOT activated and the production layout is +/// preserved. +/// 3. Stop the trace when the test reports completion. +/// 4. Compare before/after traces in Instruments — that comparison is the +/// authoritative rendering metric. +/// +/// ## Determinism +/// +/// - Single seed (`home-heavy` → 200 cells, fixed per-index content) so the +/// visible item set is reproducible across runs. +/// - Fixed number of `swipeUp()` calls so the recording window covers the +/// same logical workload each run. +/// - `-UITEST_DISABLE_ANIMATIONS` reduces frame-to-frame variance from +/// animation timing. +/// - No XCTest `measure(metrics:)`. The driver runs once per launch. +/// +/// ## Guardrails +/// +/// - Asserts that the probe harness identifiers are absent, to catch the +/// bug where someone accidentally activates `-UITEST_PROBE_SCENARIO` and +/// pollutes a rendering trace with the 44pt layout shift. +final class HomeExampleFeedScrollRenderingTests: XCTestCase { + + /// Drives the home-heavy feed scroll. Not a benchmark — use Instruments + /// for the rendering metric. See class doc. + func testRendering_homeHeavyFeedScroll() { + let app = XCUIApplication.launchForPerf( + seed: "home-heavy", + scenario: .rendering + ) + waitForFeatureReady("home", timeout: 30) + + // Guardrail: rendering scenarios must NOT activate the PERF probe + // harness, otherwise the 44pt layout shift would change scroll + // geometry / LazyVStack materialization range and contaminate the + // trace. If these identifiers exist, someone passed + // `-UITEST_PROBE_SCENARIO` by accident. + let probeToastShow = app.descendants(matching: .any)["feature.home.perf.toast-show"] + XCTAssertFalse( + probeToastShow.exists, + "PERF probe harness is active under a rendering scenario launch. The trace this driver produces would be polluted by the 44pt layout shift. Re-check launchForPerf(scenario:) arguments." + ) + + let feed = app.descendants(matching: .any)["feature.home.feed"] + XCTAssertTrue(feed.waitForExistence(timeout: 10), "feature.home.feed not found") + + // Fixed-count scroll drive. The deterministic workload, not a + // benchmark. Instruments recording is the authoritative metric. + for _ in 0..<20 { + feed.swipeUp() + } + } +} From 0fbd8f831bfa4fd4e1aa5e9e1abd64c8a883b170 Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Mon, 18 May 2026 12:27:58 +0900 Subject: [PATCH 020/100] =?UTF-8?q?docs:=20Instruments=20=EB=93=9C?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=9F=B0=20=EA=B2=B0=EA=B3=BC=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20-=20#308?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reports/_workspace/pass3-dryrun.md | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 docs/perf-infra/reports/_workspace/pass3-dryrun.md diff --git a/docs/perf-infra/reports/_workspace/pass3-dryrun.md b/docs/perf-infra/reports/_workspace/pass3-dryrun.md new file mode 100644 index 00000000..82e84621 --- /dev/null +++ b/docs/perf-infra/reports/_workspace/pass3-dryrun.md @@ -0,0 +1,135 @@ +# Pass 3 — Phase D Instruments dry-run + +Single trace recorded to verify the end-to-end pipeline before collecting +before/after rendering traces. **Not** a baseline. **Not** rendering +improvement evidence. + +## Conditions + +| field | value | +|---|---| +| date | 2026-05-18 | +| device | Jiyong의 iPhone (iOS 26.4.2, UDID `00008110-00096DC42632801E`) | +| scheme | FeatureHomeExample | +| configuration | Profile | +| bundle id | `org.yapp.twix.example.home` | +| driver commit | `c0bf8e2` | +| UITest | `HomeExampleFeedScrollRenderingTests.testRendering_homeHeavyFeedScroll` | +| launch arguments | `-UITEST -UITEST_SEED home-heavy -UITEST_WAIT_READY -UITEST_DISABLE_ANIMATIONS -UITEST_RENDERING_SCENARIO` | +| seed | `home-heavy` (200 cells) | +| scroll | 20× swipeUp over `feature.home.feed` | +| probe harness | OFF (guardrail XCTAssertFalse passed — `feature.home.perf.toast-show` absent) | +| xctrace template | Time Profiler | +| xctrace mode | `--attach FeatureHomeExample` | +| time limit | 30s | +| UITest wall time | 63.0s (full driver) | +| trace path | `/tmp/twix-perf-traces/pass3-dryrun/device/home-heavy-feed-scroll.trace` | +| trace bundle size | 20MB | + +## Pipeline verification + +- ✅ UITest passed (0 failures, 63.0s) +- ✅ Probe harness guardrail held — rendering launch did not activate + `perfActionHarness`, `PerfRebuildProxyPing`, calendar marker, + `PerfToastPresentationHarness`, or `perfCounterMarkers`. +- ✅ xctrace attached to the running app (PID 11647) without race. +- ✅ Trace bundle on disk, complete (`Trace1.run`, `corespace`, + `instrument_data`, `shared_data`, `symbols`, `form.template`, etc.). +- ✅ MCP `analyze_trace` parsed the trace and produced a top-user-code + frame table. + +## Sequencing recipe (for the next agent) + +The dry-run used a manual two-step sequence because xctrace must attach +to a running process. Reproduce as follows for before/after collections: + +```bash +# 1. Start the UITest driver in the background. It launches the app with +# the correct launch arguments and drives the scroll. +xcodebuild test-without-building \ + -workspace Twix.xcworkspace \ + -scheme FeatureHomeExample \ + -configuration Profile \ + -destination 'platform=iOS,id=00008110-00096DC42632801E' \ + -only-testing:FeatureHomeExampleUITests/HomeExampleFeedScrollRenderingTests/testRendering_homeHeavyFeedScroll \ + >/tmp/twix-perf-uitest.log 2>&1 & + +# 2. Wait for the driver to launch the app and reach the scroll phase. +# The dry-run reached `Synthesize event` for the first swipe by ~t=14s +# wall clock from test start. +until grep -q 'Synthesize event' /tmp/twix-perf-uitest.log; do sleep 1; done + +# 3. Attach xctrace. The recording window should cover the scroll phase +# (~50s from first swipe to last). Stop early or extend as needed. +xcrun xctrace record \ + --device 00008110-00096DC42632801E \ + --template 'Time Profiler' \ + --time-limit 45s \ + --attach FeatureHomeExample \ + --output /tmp/twix-perf-traces//device/home-heavy-feed-scroll.trace + +# 4. Wait for the UITest to finish (xcodebuild will exit on its own once +# the test completes — the driver is single-test). +wait +``` + +`xctrace` exits with `Target app exited, ending recording...` when the +UITest tears down the app. This is the normal end of a single-driver run. + +## Top user-code frames inside the 30s trace + +(MCP analyzer reported an 8.32s analyzed window over 6 threads — small, +because the driver is mostly waiting on accessibility idle between +swipes. Real before/after collections may want a longer window or a +denser scroll pattern.) + +``` +-[UIView(CALayerDelegate) layoutSublayersOfLayer:] 18ms (0.2%) UIKit +DisplayList.ViewUpdater.updateInheritedView(container:from:parentState:) 10ms (0.1%) SwiftUI +-[UIViewController __updateContentOverlayInsetsWithOurRect:...] 8ms (0.1%) UIKit +DisplayList.ViewUpdater.Platform.updateItemView(_:index:item:state:) 7ms (0.1%) SwiftUI +-[UINavigationController _calculateTopLayoutInfoForViewController:] 6ms (0.1%) UIKit +-[UIScrollView setContentOffset:] 6ms (0.1%) UIKit +-[UIView(Geometry) setFrame:] 6ms (0.1%) UIKit +-[UIScrollView _smoothScrollSyncWithUpdateTime:] 5ms (0.1%) UIKit +HostingScrollView.updateContext(_:) 5ms (0.1%) SwiftUI +UpdatedHostingScrollView.updateValue() 4ms (0.0%) SwiftUI +``` + +## Observations (dry-run only, **not** rendering conclusions) + +1. The pipeline works end-to-end on real device. +2. Top frames inside the dry-run window are UIKit/SwiftUI framework code, + not Home user code. This mirrors the Pass 2 cold-launch observation — + but the dry-run window is small (~8s analyzed). A real before/after + collection should: + - run a denser scroll (e.g., increase swipe count, reduce wait between + swipes, or use a flick gesture) so a higher fraction of the window + is user-code work, AND/OR + - record a longer window (60s+) to widen the sample, AND/OR + - use the `SwiftUI` template (where available) for view-tree work. +3. The driver's per-swipe pause (`Wait for ... to idle`) dominates wall + clock. The recorded user-code time inside the window is small enough + that interpretation must be cautious — Pass 3 fixes should be picked + based on the broader trace, not on this dry-run sample. + +## What this dry-run does NOT establish + +- This is **not** a Pass 3 rendering baseline. +- The 8.32s analyzed window is too small to declare any frame "hot". +- No before/after comparison is possible from a single trace. +- The numbers above are pipeline-validation only. + +## Next step + +1. Pick the scroll cadence and recording template for the authoritative + before/after collection (likely Time Profiler + a denser scroll). +2. Tag the current HEAD (`c0bf8e2` or its successor before any Phase E + fix) as the rendering before-baseline. Suggested tag name: + `pass3-rendering-before`. +3. Collect the before trace using the sequencing recipe above. Save to + `/tmp/twix-perf-traces/pass3-before/device/`. +4. Apply Phase E commits one at a time. +5. After each fix, collect an after trace using the same sequencing + recipe with the same device / template / driver / seed. +6. Use `mcp__xctrace-analyzer__compare_traces` for the diff. From d1aa316057cbb99c9cf72bf2a9b9eaefb63f6027 Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Mon, 18 May 2026 12:40:05 +0900 Subject: [PATCH 021/100] =?UTF-8?q?test:=20Home=20=ED=94=BC=EB=93=9C=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EB=A0=8C=EB=8D=94=EB=A7=81=20?= =?UTF-8?q?=EB=93=9C=EB=9D=BC=EC=9D=B4=EB=B2=84=20=EA=B0=9C=EC=84=A0=20-?= =?UTF-8?q?=20#308?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HomeExampleFeedScrollRenderingTests.swift | 62 +++++++++++-------- 1 file changed, 36 insertions(+), 26 deletions(-) diff --git a/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleFeedScrollRenderingTests.swift b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleFeedScrollRenderingTests.swift index 1b96b0b7..87bbe207 100644 --- a/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleFeedScrollRenderingTests.swift +++ b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleFeedScrollRenderingTests.swift @@ -3,39 +3,41 @@ import XCTest /// Pass 3 **rendering driver** UITest for FeatureHomeExample. /// -/// This is the first authoritative rendering scenario for the home feed. -/// It is NOT a benchmark. The XCTest pass/fail status and any timing the -/// XCTest harness happens to print are not the rendering metric — they only -/// indicate whether the deterministic UI script completed. +/// This is a deterministic UI driver, **not a benchmark**. The XCTest +/// pass/fail status and any timing the XCTest harness happens to print are +/// not the rendering metric — they only indicate whether the deterministic +/// UI script completed. The authoritative rendering metric is a real-device +/// xctrace / Instruments trace recorded while this driver runs. /// /// ## Intended use /// -/// 1. Start an Instruments / xctrace recording (Time Profiler or SwiftUI -/// template) on a real device against the `FeatureHomeExample` bundle id. -/// 2. Launch this test against the same device. The test launches with -/// `-UITEST` + `-UITEST_RENDERING_SCENARIO` + `-UITEST_SEED home-heavy`, -/// so the PERF probe harness (`perfActionHarness`, -/// `PerfRebuildProxyPing`, calendar marker, `PerfToastPresentationHarness`, -/// counter markers) is NOT activated and the production layout is -/// preserved. +/// 1. Launch this test against a real device. It launches with +/// `-UITEST` + `-UITEST_RENDERING_SCENARIO` + `-UITEST_SEED home-heavy` +/// and `disableAnimations: false`, so the PERF probe harness stays gated +/// off and animations behave like production. +/// 2. Attach `xcrun xctrace record --attach FeatureHomeExample` (Time +/// Profiler or SwiftUI template) once the driver enters the scroll +/// phase (UITest log shows `Synthesize event`). /// 3. Stop the trace when the test reports completion. -/// 4. Compare before/after traces in Instruments — that comparison is the +/// 4. Compare before/after traces in Instruments. That comparison is the /// authoritative rendering metric. /// /// ## Determinism /// -/// - Single seed (`home-heavy` → 200 cells, fixed per-index content) so the -/// visible item set is reproducible across runs. -/// - Fixed number of `swipeUp()` calls so the recording window covers the -/// same logical workload each run. -/// - `-UITEST_DISABLE_ANIMATIONS` reduces frame-to-frame variance from -/// animation timing. +/// - Single seed: `home-heavy` → 200 deterministic cells (`HomeApp.swift`). +/// - Fixed coordinate-based drag pattern (25 up + 25 down = 50 +/// interactions). Coordinate-based drag is denser and produces less +/// accessibility idle wait between gestures than `swipeUp()`, so a +/// higher fraction of the recording window is actual scroll work. +/// - `disableAnimations: false` so SwiftUI animation timing reflects +/// production. (Smoke / probe scenarios use the default `true` for +/// stability; rendering scenarios must NOT inherit that setting.) /// - No XCTest `measure(metrics:)`. The driver runs once per launch. /// /// ## Guardrails /// -/// - Asserts that the probe harness identifiers are absent, to catch the -/// bug where someone accidentally activates `-UITEST_PROBE_SCENARIO` and +/// - Asserts that probe harness identifiers are absent, to catch the bug +/// where someone accidentally activates `-UITEST_PROBE_SCENARIO` and /// pollutes a rendering trace with the 44pt layout shift. final class HomeExampleFeedScrollRenderingTests: XCTestCase { @@ -44,7 +46,8 @@ final class HomeExampleFeedScrollRenderingTests: XCTestCase { func testRendering_homeHeavyFeedScroll() { let app = XCUIApplication.launchForPerf( seed: "home-heavy", - scenario: .rendering + scenario: .rendering, + disableAnimations: false ) waitForFeatureReady("home", timeout: 30) @@ -62,10 +65,17 @@ final class HomeExampleFeedScrollRenderingTests: XCTestCase { let feed = app.descendants(matching: .any)["feature.home.feed"] XCTAssertTrue(feed.waitForExistence(timeout: 10), "feature.home.feed not found") - // Fixed-count scroll drive. The deterministic workload, not a - // benchmark. Instruments recording is the authoritative metric. - for _ in 0..<20 { - feed.swipeUp() + // Coordinate-based dense drag drive. Pressing-and-dragging between + // two normalized points produces a deterministic gesture without + // the trailing accessibility idle wait that swipeUp() inserts. + // 25 up + 25 down = 50 interactions per recording window. + let top = feed.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.20)) + let bottom = feed.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85)) + for _ in 0..<25 { + bottom.press(forDuration: 0.01, thenDragTo: top) + } + for _ in 0..<25 { + top.press(forDuration: 0.01, thenDragTo: bottom) } } } From e7c44238d1cef9e5943ec94c9acead7df8578da9 Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Mon, 18 May 2026 12:48:29 +0900 Subject: [PATCH 022/100] =?UTF-8?q?docs:=20Pass=203=20=EB=A0=8C=EB=8D=94?= =?UTF-8?q?=EB=A7=81=20=EA=B8=B0=EC=A4=80=EC=84=A0=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?-=20#308?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reports/_workspace/pass3-before.md | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 docs/perf-infra/reports/_workspace/pass3-before.md diff --git a/docs/perf-infra/reports/_workspace/pass3-before.md b/docs/perf-infra/reports/_workspace/pass3-before.md new file mode 100644 index 00000000..463fafe4 --- /dev/null +++ b/docs/perf-infra/reports/_workspace/pass3-before.md @@ -0,0 +1,117 @@ +# Pass 3 — Before baseline (rendering) + +Authoritative rendering baseline trace set captured before any Phase E +optimization commit. Three repetitions on real device for noise-floor +estimation. + +## Conditions + +| field | value | +|---|---| +| date | 2026-05-18 | +| baseline tag | `pass3-rendering-before` | +| baseline commit | `ffc9ab9` | +| device | Jiyong의 iPhone (iOS 26.4.2, UDID `00008110-00096DC42632801E`) | +| scheme | FeatureHomeExample | +| configuration | Profile | +| bundle id | `org.yapp.twix.example.home` | +| seed | `home-heavy` (200 deterministic cells) | +| driver | `HomeExampleFeedScrollRenderingTests.testRendering_homeHeavyFeedScroll` | +| launch args | `-UITEST -UITEST_SEED home-heavy -UITEST_WAIT_READY -UITEST_RENDERING_SCENARIO` (no `-UITEST_DISABLE_ANIMATIONS`, no `-UITEST_PROBE_SCENARIO`) | +| scroll | 25× bottom→top drag (press 0.01s) + 25× top→bottom drag = 50 interactions | +| xctrace template | Time Profiler | +| xctrace mode | `--attach FeatureHomeExample` | +| time limit | 60s | +| reps | 3 | +| trace paths | `/tmp/twix-perf-traces/pass3-before/device/home-heavy-feed-scroll-rep{1,2,3}.trace` | + +## Per-rep stats (Time Profiler exec time captured inside 60s window) + +| rep | exec time | threads | avg fn | max fn | trace size | +|---|---:|---:|---:|---:|---:| +| 1 | 49.54s | 7 | 1.25ms | 81ms | 28MB | +| 2 | 54.69s | 8 | 1.31ms | 72ms | 30MB | +| 3 | 53.87s | 7 | 1.28ms | 84ms | 29MB | + +UITest driver wall time on device: ~63s (consistent with single-rep dry-run). + +## Top user-code frames (Time Profiler symbolicated) + +Frames are framework-attributed top-of-stack samples within the captured +window. All values are total time (samples × 1ms). + +| frame | rep1 | rep2 | rep3 | +|---|---:|---:|---:| +| `-[UIView(CALayerDelegate) layoutSublayersOfLayer:]` | 64 ms | 61 ms | 67 ms | +| `DisplayList.ViewUpdater.Platform.updateItemView(_:index:item:state:)` | 26 | 23 | 28 | +| `DisplayList.ViewUpdater.updateInheritedView(container:from:parentState:)` | 24 | 21 | 25 | +| `-[UIViewController __updateContentOverlayInsetsWithOurRect:...]` | 22 | 24 | 25 | +| `HostingScrollView.updateContext(_:)` | 16 | 17 | 17 | +| `-[UIScrollView setContentOffset:]` | 14 | 15 | 15 | +| `-[UINavigationController _calculateEdgeInsetsForChildViewController:...]` | 14 | 16 | 14 | +| `UpdatedHostingScrollView.updateValue()` | 17 | 15 | 14 | +| `-[UIView(Geometry) setFrame:]` | 12 | – | – | +| `-[UIViewController _contentScrollViewHeuristic]` | 12 | – | 13 | +| `-[UIViewController _updateContentOverlayInsetsFromParentIfNecessary]` | – | 13 | 13 | +| `-[UIScrollView _smoothScrollSyncWithUpdateTime:]` | – | – | 11 | + +Sum of top-10 user-attributed frames ≈ 200 ms over ~50 s captured +window ≈ **0.4 % of trace**. No user-code frame (HomeView, HomeGoalItem, +HomeReducer) appears in the top 10 in any rep. + +## Rep-to-rep noise floor + +`mcp__xctrace-analyzer__compare_traces` (rep1 baseline vs rep2 current): + +- Total time change: **+10.4 %** (rep1 49.5s → rep2 54.7s). +- 5 regressions / 4 improvements among top-tier frames. +- Largest single-frame swing: `__RawDictionaryStorage.find(_:)` +54% + (24 ms → 37 ms). This is Swift stdlib hashing, not user code — well + within run-to-run variance. + +**Implication**: any Pass 3 fix needs a delta clearly larger than +~10 % at the trace-total-time level (or larger than ~10 % at a specific +user-attributable frame) to be distinguishable from baseline noise on +this device. Otherwise add more reps for tighter stats, or compare on +specific frames the fix is known to touch. + +## Honest observations (baseline only, no fix evidence) + +1. Even with dense coordinate-based scroll over 200 deterministic cells + and animations enabled, **top user-attributed frames are entirely + UIKit + SwiftUI framework code**. Home user code (HomeView body, + HomeGoalItem ==, HomeReducer getters) does not show up in the top + 10 of any rep. +2. The Pass 2 finding (cold launch top-frame share dominated by + framework) reproduces in scroll workload too. +3. The 60s window captured ~50s of CPU-active time per rep, meaning the + driver / OS / framework keeps the CPU mostly busy — the recording + window is well-utilized. +4. Pass 3 fix selection should be guided by these baseline traces. + Fixes that target frames NOT in this top-10 list will be hard to + verify with the current driver / template. If a planned fix targets + a user-code path that is invisible here, consider: + - re-recording with the `SwiftUI` template to surface view-tree work + more directly; + - increasing scroll density / count to amplify any user-code work; + - or accepting that the fix is structural cleanup, not measurable + rendering improvement. + +## What this baseline does NOT establish + +- It is not a verdict on whether any specific Pass 3 fix will help. +- It is not a final report — Pass 3 fixes are not applied yet. +- It does not measure memory / allocations / leaks (Time Profiler + template only). Separate templates required for those signals. + +## Next step + +1. Apply Phase E commits one at a time on top of `pass3-rendering-before`. +2. After each commit, repeat the 3-rep collection into + `/tmp/twix-perf-traces/pass3-after-/device/`. +3. Compare with `mcp__xctrace-analyzer__compare_traces`. Look for + - changes in the top-10 frame list (new entries or disappearances), + - >15 % total-time change (clearly above noise floor), + - >15 % single-frame change on frames the fix is known to touch. +4. If Phase E commits do not move the trace, record that as honest + "architecture cleanup, no measurable rendering improvement". From 382bde148774f8e4d96a2f3b284ab80a142c62de Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Mon, 18 May 2026 13:08:51 +0900 Subject: [PATCH 023/100] =?UTF-8?q?test:=20Home=20=EC=BA=98=EB=A6=B0?= =?UTF-8?q?=EB=8D=94=20=EC=A3=BC=EC=B0=A8=20=EC=8A=A4=EC=99=80=EC=9D=B4?= =?UTF-8?q?=ED=94=84=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=EB=93=9C=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EB=B2=84=20=EC=B6=94=EA=B0=80=20-=20#308?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HomeExampleFeedScrollRenderingTests.swift | 42 +++++++++++++++++++ .../Feature/Home/Sources/Home/HomeView.swift | 1 + 2 files changed, 43 insertions(+) diff --git a/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleFeedScrollRenderingTests.swift b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleFeedScrollRenderingTests.swift index 87bbe207..475f697f 100644 --- a/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleFeedScrollRenderingTests.swift +++ b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleFeedScrollRenderingTests.swift @@ -41,6 +41,48 @@ import XCTest /// pollutes a rendering trace with the 44pt layout shift. final class HomeExampleFeedScrollRenderingTests: XCTestCase { + /// Drives a same-screen state-change rendering scenario. Each calendar + /// swipe dispatches the production `weekCalendarSwipe` action, which + /// cascades through `.setCalendarDate` → `calendarWeeks` rebuild + + /// `.fetchGoals` → 200-cell list reload + LazyVStack re-render. No + /// navigation, no scroll-position change, no PERF harness. The + /// authoritative metric is the Instruments / xctrace trace recorded + /// while this driver runs. Not a benchmark. + func testRendering_homeHeavyCalendarWeekSweep() { + let app = XCUIApplication.launchForPerf( + seed: "home-heavy", + scenario: .rendering, + disableAnimations: false + ) + waitForFeatureReady("home", timeout: 30) + + let probeToastShow = app.descendants(matching: .any)["feature.home.perf.toast-show"] + XCTAssertFalse( + probeToastShow.exists, + "PERF probe harness is active under a rendering scenario launch. The trace would be polluted by the 44pt layout shift. Re-check launchForPerf(scenario:) arguments." + ) + + // `feature.home.calendar` may be present on multiple descendants + // because the TXCalendar composite propagates the accessibility + // identifier to its internal cells. The first match in document + // order is the calendar container — that's the swipe target. + let calendar = app.descendants(matching: .any) + .matching(identifier: "feature.home.calendar") + .firstMatch + XCTAssertTrue(calendar.waitForExistence(timeout: 10), "feature.home.calendar not found") + + // Horizontal drag on the calendar bar fires onSwipe -> reducer + // `weekCalendarSwipe` -> `setCalendarDate(...)`. Each tick triggers + // calendarWeeks regeneration + items refetch + cardList re-render. + // 20 left + 20 right = 40 deterministic same-screen state changes. + let left = calendar.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.5)) + let right = calendar.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5)) + for _ in 0..<20 { + right.press(forDuration: 0.01, thenDragTo: left) + left.press(forDuration: 0.01, thenDragTo: right) + } + } + /// Drives the home-heavy feed scroll. Not a benchmark — use Instruments /// for the rendering metric. See class doc. func testRendering_homeHeavyFeedScroll() { diff --git a/Projects/Feature/Home/Sources/Home/HomeView.swift b/Projects/Feature/Home/Sources/Home/HomeView.swift index 38514501..23fddd35 100644 --- a/Projects/Feature/Home/Sources/Home/HomeView.swift +++ b/Projects/Feature/Home/Sources/Home/HomeView.swift @@ -155,6 +155,7 @@ private extension HomeView { } ) .frame(maxWidth: .infinity, maxHeight: 76) + .perfControl(slug: "home", element: "calendar") if UITestMode.isProbeScenario { // Calendar-month marker lives inside the calendar sub-view so From cbf1ca1362cae8ba4a3f9bbad6b2b2831fc59648 Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Mon, 18 May 2026 13:23:20 +0900 Subject: [PATCH 024/100] =?UTF-8?q?fix:=20Home=20=ED=94=BC=EB=93=9C=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EB=93=9C=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B2=84=20=EC=A2=8C=ED=91=9C=20=EB=B3=B4=EC=A0=95=20-=20#308?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HomeExampleFeedScrollRenderingTests.swift | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleFeedScrollRenderingTests.swift b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleFeedScrollRenderingTests.swift index 475f697f..2bbbf4d5 100644 --- a/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleFeedScrollRenderingTests.swift +++ b/Projects/Feature/Home/ExampleUITests/Sources/HomeExampleFeedScrollRenderingTests.swift @@ -107,12 +107,23 @@ final class HomeExampleFeedScrollRenderingTests: XCTestCase { let feed = app.descendants(matching: .any)["feature.home.feed"] XCTAssertTrue(feed.waitForExistence(timeout: 10), "feature.home.feed not found") - // Coordinate-based dense drag drive. Pressing-and-dragging between - // two normalized points produces a deterministic gesture without - // the trailing accessibility idle wait that swipeUp() inserts. - // 25 up + 25 down = 50 interactions per recording window. - let top = feed.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.20)) - let bottom = feed.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85)) + // Coordinate-based dense drag drive. + // + // IMPORTANT: normalize coordinates against the *window*, NOT the + // `feed` element. `feed` is the LazyVStack inside a ScrollView and + // its accessibility frame reports the *content* size (200 cells + // ≈ 16,000pt tall on this fixture). Drag origins normalized to + // that frame land far below the visible viewport and the OS + // delivers them to Springboard, which backgrounds the app between + // every drag and contaminates the recording with system activity. + // + // Window-normalized coordinates with safe dy values stay inside + // the visible feed area (navbar + calendar bar live above dy 0.30, + // home indicator lives below dy 0.85). + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5), "no window") + let top = window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.35)) + let bottom = window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.80)) for _ in 0..<25 { bottom.press(forDuration: 0.01, thenDragTo: top) } From 556132c816e5077b855bb5c57b469ff3ca6c8eb9 Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Mon, 18 May 2026 14:06:16 +0900 Subject: [PATCH 025/100] =?UTF-8?q?test:=20GoalDetail=20=EB=A6=AC=EC=95=A1?= =?UTF-8?q?=EC=85=98=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=EC=8B=9C=EB=82=98?= =?UTF-8?q?=EB=A6=AC=EC=98=A4=20=EC=B6=94=EA=B0=80=20-=20#308?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/GoalDetailExampleView.swift | 42 ++++++- .../GoalDetailExampleRenderingTests.swift | 105 ++++++++++++++++++ Projects/Feature/GoalDetail/Project.swift | 1 + .../Sources/Detail/ReactionBarView.swift | 2 + .../Sources/View+PerfAccessibility.swift | 15 ++- 5 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleRenderingTests.swift diff --git a/Projects/Feature/GoalDetail/Example/Sources/GoalDetailExampleView.swift b/Projects/Feature/GoalDetail/Example/Sources/GoalDetailExampleView.swift index ef1dcf2a..c4b41da1 100644 --- a/Projects/Feature/GoalDetail/Example/Sources/GoalDetailExampleView.swift +++ b/Projects/Feature/GoalDetail/Example/Sources/GoalDetailExampleView.swift @@ -11,6 +11,7 @@ import SwiftUI import ComposableArchitecture import CoreCaptureSession import CoreCaptureSessionInterface +import DomainPhotoLogInterface import FeatureGoalDetail import FeatureGoalDetailInterface import FeatureProofPhoto @@ -23,7 +24,11 @@ struct GoalDetailExampleView: View { GoalDetailView( store: Store( initialState: GoalDetailReducer.State( - currentUser: .mySelf, + // `.you` so `ReactionBarView` is visible + // (`isShowReactionBar = !isFrontMyCard && isCompleted`). + // Pass 3 GoalDetail rendering scenarios exercise the + // reaction bar; with `.mySelf` the bar never appears. + currentUser: .you, id: 1, verificationDate: "2026-02-07" ), @@ -35,6 +40,11 @@ struct GoalDetailExampleView: View { $0.captureSessionClient = UITestMode.isEnabled ? .perfMock : .liveValue $0.proofPhotoFactory = .liveValue $0.goalClient = .previewValue + // Local no-op mock for the reaction update path. Without + // it, `reactionEmojiTapped` would hit a real network + // client and either crash (testValue) or fan out to the + // server. Rendering scenarios must stay local. + $0.photoLogClient = .perfMock } ) ) @@ -55,3 +65,33 @@ private extension CaptureSessionClient { switchFlash: { _ in } ) } + +private extension PhotoLogClient { + /// Local no-op mock for Pass 3 rendering scenarios. Each closure returns + /// an empty success response without touching the network. Only + /// `updateReaction` is exercised by the reaction rapid-fire scenario; + /// the others are stubs to satisfy the struct's required initializer. + static let perfMock = Self( + fetchUploadURL: { _ in + PhotoLogUploadURLResponseDTO(uploadUrl: "", fileName: "") + }, + uploadImageData: { _, _ in }, + createPhotoLog: { _ in + PhotoLogCreateResponseDTO( + photologId: 0, + goalId: 0, + imageUrl: "", + comment: "", + verificationDate: "" + ) + }, + updateReaction: { photologId, request in + PhotoLogUpdateReactionResponseDTO( + photologId: photologId, + reaction: request.reaction + ) + }, + updatePhotoLog: { _, _ in }, + deletePhotoLog: { _ in } + ) +} diff --git a/Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleRenderingTests.swift b/Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleRenderingTests.swift new file mode 100644 index 00000000..6112028c --- /dev/null +++ b/Projects/Feature/GoalDetail/ExampleUITests/Sources/GoalDetailExampleRenderingTests.swift @@ -0,0 +1,105 @@ +import SharedPerfTestingSupportUITests +import XCTest + +/// Pass 3 **rendering driver** UITests for FeatureGoalDetailExample. +/// +/// These tests are NOT benchmarks. They drive deterministic UI activity so +/// that a real-device xctrace recording (Time Profiler + Animation Hitches) +/// captures the GoalDetail rendering path. XCTest pass/fail and any timing +/// the harness prints are not the metric. +/// +/// ## Intended use +/// +/// 1. Launch on a real device. The test launches with +/// `-UITEST` + `-UITEST_RENDERING_SCENARIO` + `-UITEST_SEED default` +/// and `disableAnimations: false`. The GoalDetail Example app does not +/// have a PERF probe harness today, but rendering scenarios still +/// require the rendering launch flag so any future probe additions +/// stay gated off. +/// 2. Attach `xcrun xctrace record --attach FeatureGoalDetailExample` +/// once the driver has the GoalDetail view ready (UITest log shows +/// `feature.goal-detail.ready` exists). +/// 3. Stop the trace when the test reports completion. +/// +/// ## Scenarios +/// +/// - `testRendering_goalDetailInitialRender` — launch + idle window so the +/// trace covers initial render + `FlyingReactionOverlay.TimelineView` +/// continuously ticking at 60 Hz on an empty `reactions` array. +/// - `testRendering_goalDetailReactionRapidFire` — cycles through all five +/// `ReactionEmoji` cases, dispatching `.reactionEmojiTapped` for each. +/// Each tap mutates `state.selectedReactionEmoji`, fans out 20 flying +/// particles via the overlay, and posts to a local no-op +/// `photoLogClient.updateReaction` mock injected by +/// `GoalDetailExampleView`. +/// +/// ## Determinism +/// +/// - `goalClient.previewValue` returns a deterministic GoalDetail item; the +/// Example launches with `currentUser: .you` so the reaction bar is +/// guaranteed visible. +/// - PhotoLogClient is a local in-process mock (`PhotoLogClient.perfMock`) +/// so no network call is issued. +final class GoalDetailExampleRenderingTests: XCTestCase { + + /// Drives initial render + 7s idle window. Captures FlyingReactionOverlay + /// TimelineView's 60 Hz idle cost — relevant to the GoalDetail "무겁게 + /// 느껴진다" VoC. + func testRendering_goalDetailInitialRender() { + let app = XCUIApplication.launchForPerf( + seed: "default", + scenario: .rendering, + disableAnimations: false + ) + waitForFeatureReady("goal-detail", timeout: 30) + + // 7s idle window. xctrace recording should cover this entirely. + Thread.sleep(forTimeInterval: 7.0) + } + + /// Cycles through all five reaction emojis and taps each repeatedly. + /// Different emoji per tap so `state.selectedReactionEmoji != reactionEmoji` + /// guard always passes and the reducer actually mutates state. Each tap + /// emits 20 flying particles for ~0.85–1.35s, so consecutive taps keep + /// FlyingReactionOverlay continuously busy. + func testRendering_goalDetailReactionRapidFire() { + let app = XCUIApplication.launchForPerf( + seed: "default", + scenario: .rendering, + disableAnimations: false + ) + waitForFeatureReady("goal-detail", timeout: 30) + + // 5 ReactionEmoji rawValues. Cycling these guarantees each tap + // passes the `selectedReactionEmoji != reactionEmoji` guard so the + // reducer mutates state on every tap (not just on the first). + let reactionIdentifiers = [ + "feature.goal-detail.reaction-ICON_HAPPY", + "feature.goal-detail.reaction-ICON_TROUBLE", + "feature.goal-detail.reaction-ICON_LOVE", + "feature.goal-detail.reaction-ICON_DOUBT", + "feature.goal-detail.reaction-ICON_FUCK" + ] + var firstReactionExists = false + for identifier in reactionIdentifiers { + let element = app.descendants(matching: .any).matching(identifier: identifier).firstMatch + if !firstReactionExists { + firstReactionExists = element.waitForExistence(timeout: 10) + XCTAssertTrue(firstReactionExists, "Reaction bar not visible: \(identifier) missing") + } else { + XCTAssertTrue(element.exists, "Missing reaction identifier: \(identifier)") + } + } + + // 8 cycles × 5 emojis = 40 taps total. Each tap triggers state + // mutation + 20 particle emit + async no-op photoLogClient call. + for _ in 0..<8 { + for identifier in reactionIdentifiers { + app.descendants(matching: .any) + .matching(identifier: identifier) + .firstMatch + .tap() + } + } + } +} diff --git a/Projects/Feature/GoalDetail/Project.swift b/Projects/Feature/GoalDetail/Project.swift index 88ad786b..7c4f2f35 100644 --- a/Projects/Feature/GoalDetail/Project.swift +++ b/Projects/Feature/GoalDetail/Project.swift @@ -63,6 +63,7 @@ let project = Project.makeModule( .feature(implements: .goalDetail), .feature(implements: .proofPhoto), .core(implements: .captureSession), + .domain(interface: .photoLog), .external(dependency: .ComposableArchitecture) ] ) diff --git a/Projects/Feature/GoalDetail/Sources/Detail/ReactionBarView.swift b/Projects/Feature/GoalDetail/Sources/Detail/ReactionBarView.swift index fa9095fd..c03921a2 100644 --- a/Projects/Feature/GoalDetail/Sources/Detail/ReactionBarView.swift +++ b/Projects/Feature/GoalDetail/Sources/Detail/ReactionBarView.swift @@ -8,6 +8,7 @@ import SwiftUI import SharedDesignSystem +import SharedPerfTestingSupport struct ReactionBarView: View { let selectedEmoji: ReactionEmoji? @@ -66,6 +67,7 @@ private extension ReactionBarView { } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(selectedEmoji == emoji ? Color.Gray.gray300 : Color.clear) + .perfControl(slug: "goal-detail", element: "reaction-\(emoji.rawValue)") if emoji != ReactionEmoji.allCases.last { Rectangle() diff --git a/Projects/Shared/PerfTestingSupport/Sources/View+PerfAccessibility.swift b/Projects/Shared/PerfTestingSupport/Sources/View+PerfAccessibility.swift index c65cc323..fae41d60 100644 --- a/Projects/Shared/PerfTestingSupport/Sources/View+PerfAccessibility.swift +++ b/Projects/Shared/PerfTestingSupport/Sources/View+PerfAccessibility.swift @@ -1,8 +1,21 @@ import SwiftUI public extension View { + /// Exposes a root-level accessibility marker for the feature. + /// + /// IMPORTANT: applied as an overlay (Color.clear 1x1) rather than a + /// direct `.accessibilityIdentifier` on the receiver. `accessibilityIdentifier` + /// on a parent SwiftUI view propagates to descendant accessibility + /// elements that don't have their own — so a direct identifier here + /// would override child `perfControl(slug:element:)` / accessibility + /// identifiers everywhere in the feature tree. The overlay pattern + /// keeps the marker scoped to the inserted Color.clear element only. func perfRoot(_ slug: String) -> some View { - accessibilityIdentifier("feature.\(slug).root") + overlay(alignment: .topLeading) { + Color.clear + .frame(width: 1, height: 1) + .accessibilityIdentifier("feature.\(slug).root") + } } func perfFeed(_ slug: String) -> some View { From b0d43edba066c717c39aa0ba89193a17790eebe0 Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Mon, 18 May 2026 17:14:15 +0900 Subject: [PATCH 026/100] =?UTF-8?q?test:=20ProofPhoto=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EC=A0=84=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=EC=8B=9C?= =?UTF-8?q?=EB=82=98=EB=A6=AC=EC=98=A4=20=EC=B6=94=EA=B0=80=20-=20#308?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Example/Sources/ProofPhotoApp.swift | 82 +++++++++--- .../ProofPhotoExampleRenderingTests.swift | 118 ++++++++++++++++++ .../Sources/ProofPhoto/ProofPhotoView.swift | 49 +++++--- 3 files changed, 218 insertions(+), 31 deletions(-) create mode 100644 Projects/Feature/ProofPhoto/ExampleUITests/Sources/ProofPhotoExampleRenderingTests.swift diff --git a/Projects/Feature/ProofPhoto/Example/Sources/ProofPhotoApp.swift b/Projects/Feature/ProofPhoto/Example/Sources/ProofPhotoApp.swift index 71c59862..924e09e7 100644 --- a/Projects/Feature/ProofPhoto/Example/Sources/ProofPhotoApp.swift +++ b/Projects/Feature/ProofPhoto/Example/Sources/ProofPhotoApp.swift @@ -8,32 +8,84 @@ import FeatureProofPhoto import FeatureProofPhotoInterface import SharedPerfTestingSupport import SwiftUI +import UIKit @main struct ProofPhotoApp: App { + /// Stored at App level so it survives `body` re-evaluations. The seed + /// branching only injects fixture data — no captureSession / network + /// changes — so we keep a single Store instance for the whole scene. + private let store: StoreOf + init() { UITestMode.configureApplication() + self.store = Store( + initialState: ProofPhotoReducer.State( + goalId: 1, + verificationDate: "2026-02-07" + ), + reducer: { ProofPhotoReducer() }, + withDependencies: { + $0.captureSessionClient = UITestMode.isEnabled ? .perfMock : .liveValue + $0.photoLogClient = .perfMock + $0.crashlyticsClient = .previewValue + } + ) } var body: some Scene { WindowGroup { - ProofPhotoView( - store: Store( - initialState: ProofPhotoReducer.State( - goalId: 1, - verificationDate: "2026-02-07" - ), - reducer: { ProofPhotoReducer() }, - withDependencies: { - $0.captureSessionClient = UITestMode.isEnabled ? .perfMock : .liveValue - $0.photoLogClient = .perfMock - $0.crashlyticsClient = .previewValue - } + ProofPhotoView(store: store) + .perfRoot("proof-photo") + .perfReadyMarker("proof-photo") + .onAppear { + // Pre-load a deterministic fixture image so the + // rendering scenarios measure the preview + comment + // path without depending on the real Photos picker or + // camera capture. Dispatched via the production + // `.galleryPhotoLoaded` action — same code path a real + // gallery selection takes — so the measurement reflects + // the actual preview render flow. + guard UITestMode.isEnabled, + UITestMode.seedName == "proof-photo-prefilled" else { return } + store.send(.galleryPhotoLoaded(imageData: Self.perfFixtureImageData())) + } + } + } + + /// Deterministic fixture image generated at runtime. Avoids checking a + /// binary asset into the repo. 1024×1024 JPEG with a procedural + /// gradient + grid pattern so it has enough non-trivial pixels to + /// stress the preview pipeline (UIImage decode + SwiftUI image render + /// + `scaledToFill` + rounded clip). + private static func perfFixtureImageData() -> Data { + let size = CGSize(width: 1024, height: 1024) + let renderer = UIGraphicsImageRenderer(size: size) + let image = renderer.image { context in + let cg = context.cgContext + // Vertical gradient + for y in stride(from: 0, to: Int(size.height), by: 4) { + let progress = CGFloat(y) / size.height + let color = UIColor( + red: 0.20 + progress * 0.55, + green: 0.40, + blue: 0.80 - progress * 0.45, + alpha: 1.0 ) - ) - .perfRoot("proof-photo") - .perfReadyMarker("proof-photo") + cg.setFillColor(color.cgColor) + cg.fill(CGRect(x: 0, y: y, width: Int(size.width), height: 4)) + } + // Diagonal grid to add high-frequency detail (more decode work) + cg.setStrokeColor(UIColor.white.withAlphaComponent(0.35).cgColor) + cg.setLineWidth(1) + let step: CGFloat = 64 + for offset in stride(from: -size.height, through: size.width, by: step) { + cg.move(to: CGPoint(x: offset, y: 0)) + cg.addLine(to: CGPoint(x: offset + size.height, y: size.height)) + } + cg.strokePath() } + return image.jpegData(compressionQuality: 0.9) ?? Data() } } diff --git a/Projects/Feature/ProofPhoto/ExampleUITests/Sources/ProofPhotoExampleRenderingTests.swift b/Projects/Feature/ProofPhoto/ExampleUITests/Sources/ProofPhotoExampleRenderingTests.swift new file mode 100644 index 00000000..cd5acc81 --- /dev/null +++ b/Projects/Feature/ProofPhoto/ExampleUITests/Sources/ProofPhotoExampleRenderingTests.swift @@ -0,0 +1,118 @@ +import SharedPerfTestingSupportUITests +import XCTest + +/// Pass 3 **rendering driver** UITests for FeatureProofPhotoExample. +/// +/// These tests are NOT benchmarks. They drive deterministic UI activity so +/// that a real-device xctrace recording (Time Profiler + Animation Hitches) +/// captures the ProofPhoto preview + comment rendering path BEFORE the +/// upload step. XCTest pass/fail is not the metric. +/// +/// ## Intended use +/// +/// 1. Launch on a real device with seed `proof-photo-prefilled`. The +/// `ProofPhotoApp` injects a deterministic 1024×1024 JPEG fixture via +/// the production `.galleryPhotoLoaded` action so `store.imageData` +/// is populated without invoking the OS Photos picker. The fixture +/// image is generated procedurally at runtime — no binary asset in +/// the repo. +/// 2. Attach `xcrun xctrace record --attach FeatureProofPhotoExample` +/// once `feature.proof-photo.ready` exists. +/// 3. Stop the trace when the test reports completion. +/// +/// ## Scope +/// +/// - Measures the local preview + comment rendering path only. +/// - Does NOT use the real Photos picker. +/// - Does NOT use the camera. +/// - Does NOT trigger server upload (`photoLogClient` is a local no-op +/// mock injected by `ProofPhotoApp`). +/// - Does NOT change the image pipeline (no downsampling, no compression +/// refactor) — those are Phase 2 follow-up if needed. +/// +/// ## Scenarios +/// +/// - `testRendering_proofPhotoPreviewWithFixtureImage` — preview render +/// stable + 6s idle window (captures any TimelineView-driven cursor +/// work inside the comment overlay and any image-render side effects). +/// - `testRendering_proofPhotoCommentTyping` — tap comment circle to +/// focus + type 5 ASCII characters one by one. Each character mutates +/// `store.commentText`, re-renders the comment circle, and the cursor +/// `TimelineView` runs while focused. +final class ProofPhotoExampleRenderingTests: XCTestCase { + + /// Drives preview render + 6s idle. Use Instruments to compare + /// before/after image-decode / SwiftUI image-render cost. + func testRendering_proofPhotoPreviewWithFixtureImage() { + let app = XCUIApplication.launchForPerf( + seed: "proof-photo-prefilled", + scenario: .rendering, + disableAnimations: false + ) + waitForFeatureReady("proof-photo", timeout: 30) + + let preview = app.descendants(matching: .any) + .matching(identifier: "feature.proof-photo.preview") + .firstMatch + XCTAssertTrue( + preview.waitForExistence(timeout: 10), + "feature.proof-photo.preview not visible — fixture image probably not loaded" + ) + + Thread.sleep(forTimeInterval: 6.0) + } + + /// Focuses the comment circle and types 5 ASCII characters. Each + /// character is delivered separately so the trace covers the + /// per-keystroke rendering path (commentText mutation → text circle + /// re-render + cursor TimelineView tick). + func testRendering_proofPhotoCommentTyping() { + let app = XCUIApplication.launchForPerf( + seed: "proof-photo-prefilled", + scenario: .rendering, + disableAnimations: false + ) + waitForFeatureReady("proof-photo", timeout: 30) + + // Wait for the preview, which is the gate for the comment overlay + // to be visible (`shouldShowCommentOverlay = (captureSession != nil + // || hasImage) && rectFrame != .zero`). + let preview = app.descendants(matching: .any) + .matching(identifier: "feature.proof-photo.preview") + .firstMatch + XCTAssertTrue(preview.waitForExistence(timeout: 10), "preview missing") + + let commentCircle = app.descendants(matching: .any) + .matching(identifier: "feature.proof-photo.comment-circle") + .firstMatch + XCTAssertTrue( + commentCircle.waitForExistence(timeout: 10), + "feature.proof-photo.comment-circle not visible" + ) + commentCircle.tap() + + // Type 5 ASCII characters via the focused TextField inside the + // TXCommentCircle. ASCII chosen over 한글 to avoid IME instability + // on simulator / device localization differences. + for character in "abcde" { + app.typeText(String(character)) + } + + // Verify the typed text actually reached `store.commentText`. The + // marker `feature.proof-photo.marker.comment-text.` is + // overlay-mirrored from the live state in ProofPhotoView. On a + // real device whose current keyboard input mode is not ASCII the + // typeText() calls above may be absorbed by the IME — the test + // must fail honestly in that case so the trace is not collected + // against an empty / wrong commentText. NOT optional. + let typedMarker = app.descendants(matching: .any) + .matching(identifier: "feature.proof-photo.marker.comment-text.abcde") + .firstMatch + XCTAssertTrue( + typedMarker.waitForExistence(timeout: 10), + "store.commentText never became 'abcde' — typing did not reach the field (likely IME / keyboard input mode). Scenario is not baseline-ready until this passes on the target device." + ) + + Thread.sleep(forTimeInterval: 2.0) + } +} diff --git a/Projects/Feature/ProofPhoto/Sources/ProofPhoto/ProofPhotoView.swift b/Projects/Feature/ProofPhoto/Sources/ProofPhoto/ProofPhotoView.swift index 2b9f2d54..cca5114d 100644 --- a/Projects/Feature/ProofPhoto/Sources/ProofPhoto/ProofPhotoView.swift +++ b/Projects/Feature/ProofPhoto/Sources/ProofPhoto/ProofPhotoView.swift @@ -70,6 +70,19 @@ public struct ProofPhotoView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .observeKeyboardFrame($keyboardFrame) .background(Color.Gray.gray500) + .overlay(alignment: .topLeading) { + // Pass 3 rendering driver correctness marker. Mirrors + // `store.commentText` into a 1x1 Color.clear accessibility + // identifier so a UITest can verify the comment typing + // scenario actually delivered keystrokes (the on-device + // keyboard input mode may absorb ASCII input). Layout-neutral, + // production-visible identifier only — no behavior change. + Color.clear + .frame(width: 1, height: 1) + .accessibilityIdentifier( + "feature.proof-photo.marker.comment-text.\(store.commentText)" + ) + } .onAppear { store.send(.onAppear) } @@ -127,24 +140,27 @@ private extension ProofPhotoView { @ViewBuilder var photoPreview: some View { - if store.hasImage, - let imageData = store.imageData, - let image = UIImage(data: imageData) { - previewContainer { - Image(uiImage: image) - .resizable() - .scaledToFill() - } - } else if let session = store.captureSession { - previewContainer { - CameraPreview(session: session) + Group { + if store.hasImage, + let imageData = store.imageData, + let image = UIImage(data: imageData) { + previewContainer { + Image(uiImage: image) + .resizable() + .scaledToFill() + } + } else if let session = store.captureSession { + previewContainer { + CameraPreview(session: session) + } + } else { + Rectangle() + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 76)) } - } else { - Rectangle() - .frame(maxWidth: .infinity) - .aspectRatio(1, contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: 76)) } + .accessibilityIdentifier("feature.proof-photo.preview") } var previewTopControls: some View { @@ -349,6 +365,7 @@ private extension ProofPhotoView { store.send(.focusChanged(isFocused)) } ) + .accessibilityIdentifier("feature.proof-photo.comment-circle") } } From 36766b2224641f06e07a7eb6eb1d33b0971f33ed Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Mon, 18 May 2026 17:29:18 +0900 Subject: [PATCH 027/100] =?UTF-8?q?test:=20Stats=20=EB=A0=8C=EB=8D=94?= =?UTF-8?q?=EB=A7=81=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20#308?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Stats/Example/Sources/StatsApp.swift | 16 ++- .../Sources/StatsExampleRenderingTests.swift | 97 +++++++++++++++++++ 2 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleRenderingTests.swift diff --git a/Projects/Feature/Stats/Example/Sources/StatsApp.swift b/Projects/Feature/Stats/Example/Sources/StatsApp.swift index ebcb6b1f..6a9dad27 100644 --- a/Projects/Feature/Stats/Example/Sources/StatsApp.swift +++ b/Projects/Feature/Stats/Example/Sources/StatsApp.swift @@ -58,15 +58,27 @@ struct StatsApp: App { private extension StatsApp { static func statsClient(for seed: String) -> StatsClient { - guard UITestMode.isEnabled, seed == "scroll-50" else { + guard UITestMode.isEnabled else { return .previewValue } + switch seed { + case "scroll-50": + return perfStatsClient(count: 50) + case "stats-heavy": + // 200 deterministic stats items so the rendering driver can + // exercise multiple LazyVStack materialization windows. Mirrors + // the home-heavy seed scale used for Home rendering. + return perfStatsClient(count: 200) + default: return .previewValue } + } + + static func perfStatsClient(count: Int) -> StatsClient { var client = StatsClient.previewValue client.fetchStats = { _, _ in Stats( myNickname: "현수", partnerNickname: "민정", - stats: (1...50).map { index in + stats: (1...count).map { index in Stats.StatsItem( goalId: Int64(index), icon: index.isMultiple(of: 2) ? "ICON_BOOK" : "ICON_HEALTH", diff --git a/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleRenderingTests.swift b/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleRenderingTests.swift new file mode 100644 index 00000000..99a7376e --- /dev/null +++ b/Projects/Feature/Stats/ExampleUITests/Sources/StatsExampleRenderingTests.swift @@ -0,0 +1,97 @@ +import SharedPerfTestingSupportUITests +import XCTest + +/// Pass 3 **rendering driver** UITests for FeatureStatsExample. +/// +/// These tests are NOT benchmarks. They drive deterministic UI activity so +/// that a real-device xctrace recording (Time Profiler + Animation Hitches) +/// captures the Stats rendering path. XCTest pass/fail is correctness only. +/// +/// ## Intended use +/// +/// 1. Launch on a real device with seed `stats-heavy` (200 deterministic +/// stats items). The driver launches with `-UITEST_RENDERING_SCENARIO` +/// + `disableAnimations: false` so animations behave like production. +/// 2. Attach `xcrun xctrace record --attach FeatureStatsExample` once +/// `feature.stats.ready` exists (initial render) or after the +/// `Synthesize event` log line (scroll). +/// 3. Stop the trace when the test reports completion. +/// +/// ## Scenarios +/// +/// - `testRendering_statsHeavyInitialRender` — launch + 7s idle window. +/// Captures the initial LazyVStack materialization + idle cost on a +/// 200-cell list. +/// - `testRendering_statsHeavyScroll` — coordinate-based dense drag on +/// the visible viewport. Coordinates are anchored on the window +/// (NOT on `feature.stats.feed`, whose accessibility frame reports +/// LazyVStack content size and could land drags off-screen and bleed +/// to SpringBoard — same root cause as the Home feed-scroll fix). +/// +/// ## Determinism +/// +/// - Single seed (`stats-heavy` → 200 fixed-content cells via the new +/// `perfStatsClient(count:)` branch). +/// - Fixed coordinate-based drag pattern (25 down→up + 25 up→down = 50 +/// interactions per recording window). +/// - `disableAnimations: false` to reflect production animation timing. +/// - No XCTest `measure(metrics:)`. The driver runs once per launch. +/// +/// ## Separation from existing tests +/// +/// `StatsExampleScrollTests.testScrollFiftyCells` uses `measure(metrics:)` +/// and the smaller `scroll-50` seed; it remains as a probe-style sanity +/// signal but is NOT the authoritative rendering metric. The tests in this +/// file are the authoritative driver paths for xctrace. +final class StatsExampleRenderingTests: XCTestCase { + + /// Drives heavy initial render + 7s idle window. + func testRendering_statsHeavyInitialRender() { + let app = XCUIApplication.launchForPerf( + seed: "stats-heavy", + scenario: .rendering, + disableAnimations: false + ) + waitForFeatureReady("stats", timeout: 30) + + let feed = app.descendants(matching: .any)["feature.stats.feed"] + XCTAssertTrue( + feed.waitForExistence(timeout: 10), + "feature.stats.feed not found — stats-heavy seed probably not delivered" + ) + + Thread.sleep(forTimeInterval: 7.0) + } + + /// Drives 50 coordinate-based drags on the visible viewport. Window- + /// normalized so the drag stays inside the safe scroll area; never + /// resolves to the LazyVStack content-size frame. + func testRendering_statsHeavyScroll() { + let app = XCUIApplication.launchForPerf( + seed: "stats-heavy", + scenario: .rendering, + disableAnimations: false + ) + waitForFeatureReady("stats", timeout: 30) + + let feed = app.descendants(matching: .any)["feature.stats.feed"] + XCTAssertTrue(feed.waitForExistence(timeout: 10), "feature.stats.feed not found") + + // IMPORTANT: anchor coordinates on `app.windows.firstMatch`, NOT on + // `feed`. The feed's accessibility frame reports LazyVStack + // content size (very tall with 200 cells) — feed-normalized dy + // 0.20/0.85 would land far below the visible viewport and the OS + // would deliver drags to SpringBoard. Window-normalized stays in + // the visible scroll area. + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5), "no window") + let top = window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.35)) + let bottom = window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.80)) + for _ in 0..<25 { + bottom.press(forDuration: 0.01, thenDragTo: top) + } + for _ in 0..<25 { + top.press(forDuration: 0.01, thenDragTo: bottom) + } + } +} From af07cc420730286f1db71f20ef45a866a1a7f7ed Mon Sep 17 00:00:00 2001 From: Jiyong Jung Date: Mon, 18 May 2026 17:33:14 +0900 Subject: [PATCH 028/100] =?UTF-8?q?docs:=20Pass=203=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=EC=84=A0=20=EC=88=98=EC=A7=91=20=EA=B3=84=ED=9A=8D=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=20-=20#308?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pass3-baseline-collection-plan.md | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 docs/perf-infra/reports/_workspace/pass3-baseline-collection-plan.md diff --git a/docs/perf-infra/reports/_workspace/pass3-baseline-collection-plan.md b/docs/perf-infra/reports/_workspace/pass3-baseline-collection-plan.md new file mode 100644 index 00000000..d5aa1cc2 --- /dev/null +++ b/docs/perf-infra/reports/_workspace/pass3-baseline-collection-plan.md @@ -0,0 +1,200 @@ +# Pass 3 — Official baseline collection plan + +Stabilization step before collecting the 48-trace official baseline. +Authoritative metric is real-device xctrace (Time Profiler + Animation +Hitches). XCTest pass/fail is correctness only. SwiftUI template is +Phase 2 follow-up, NOT in Phase 1. + +## Official baseline tag + +- **Tag**: `pass3-rendering-before` +- **Target commit**: the final Phase 1 measurement infra commit (the + baseline-collection-plan doc commit, which is the last commit before + collection starts). +- Existing contaminated tag positions are invalidated. + +## Environment + +| field | value | +|---|---| +| device | `Jiyong의 iPhone` (UDID `00008110-00096DC42632801E`, iOS 26.4.2) | +| Xcode | 26.2 SDK | +| configuration | `Profile` (all targets) | +| host | macOS Darwin 25.2.0 | +| trace template (1) | `Time Profiler` | +| trace template (2) | `Animation Hitches` | +| xctrace mode | `--attach ` | +| time-limit per trace | 50s (Animation Hitches) / 60s (Time Profiler), per scenario | +| reps per (scenario, template) | 3 | +| total | 8 × 2 × 3 = **48 traces** | +| trace output root | `/tmp/twix-perf-traces/pass3-before//