create-cmp-cli 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +65 -27
  2. package/bin/create-cmp.mjs +2 -1
  3. package/options.schema.json +5 -2
  4. package/package.json +2 -2
  5. package/src/commands/create.mjs +26 -3
  6. package/src/lib/toggle.mjs +2 -2
  7. package/template/.claude/settings.json +12 -0
  8. package/template/.claude/skills/add-feature/SKILL.md +131 -0
  9. package/template/.claude/skills/add-repository/SKILL.md +106 -0
  10. package/template/.claude/skills/add-screen/SKILL.md +130 -0
  11. package/template/.github/workflows/verify.yml +30 -8
  12. package/template/.gradle/8.11.1/checksums/checksums.lock +0 -0
  13. package/template/.gradle/8.11.1/fileChanges/last-build.bin +0 -0
  14. package/template/.gradle/8.11.1/fileHashes/fileHashes.lock +0 -0
  15. package/template/.gradle/8.11.1/gc.properties +0 -0
  16. package/template/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
  17. package/template/.gradle/buildOutputCleanup/cache.properties +2 -0
  18. package/template/.gradle/vcs-1/gc.properties +0 -0
  19. package/template/CHANGELOG.md +14 -0
  20. package/template/CLAUDE.md +70 -0
  21. package/template/CONTRIBUTING.md +39 -0
  22. package/template/README.md +103 -0
  23. package/template/composeApp/build.gradle.kts +15 -8
  24. package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +4 -0
  25. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +10 -2
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +10 -0
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +6 -2
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +1 -0
  29. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/Screen.kt +2 -1
  30. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +26 -0
  31. package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +37 -0
  32. package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +104 -0
  33. package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +29 -0
  34. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/A11yConformanceTest.kt +64 -0
  35. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +143 -0
  36. package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeGoldenTreeTest.kt +78 -0
  37. package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +77 -0
  38. package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/navigation/AppShellTest.kt +79 -0
  39. package/template/composeApp/src/desktopTest/kotlin/com/example/app/testing/ComposeTestExt.kt +16 -0
  40. package/template/composeApp/src/desktopTest/kotlin/com/example/app/testing/StructuralTree.kt +63 -0
  41. package/template/docs/ARCHITECTURE.md +51 -0
  42. package/template/docs/TESTING.md +64 -0
  43. package/template/docs/adr/0001-adopt-the-create-cmp-harness-conventions.md +32 -0
  44. package/template/docs/adr/template.md +17 -0
  45. package/template/gitignore +8 -1
  46. package/template/gradle/libs.versions.toml +2 -0
  47. package/template/manifest.json +11 -8
  48. package/template/qa/e2e/README.md +29 -0
  49. package/template/qa/e2e/smoke.yaml +35 -0
  50. package/template/qa/evidence/schema.json +56 -0
  51. package/template/qa/golden/home.json +15 -0
  52. package/template/qa/lib/inputs-hash.mjs +112 -0
  53. package/template/qa/lib/token-drift.mjs +94 -0
  54. package/template/qa/receipt-check.mjs +113 -0
  55. package/template/qa/refusal-demo.mjs +491 -0
  56. package/template/qa/scaffold-feature.mjs +488 -0
  57. package/template/qa/verify.mjs +467 -0
  58. package/template/specs/README.md +33 -0
  59. package/template/specs/app-base.spec.md +30 -0
  60. package/template/specs/home.spec.md +18 -0
  61. package/template/qa/appium/README.md +0 -23
  62. package/template/qa/appium/lib/appium-client.mjs +0 -225
  63. package/template/qa/appium/package.json +0 -8
  64. package/template/qa/appium/run-android-smoke.mjs +0 -39
  65. package/template/tests/appium/cmp/conftest.py +0 -96
  66. package/template/tests/appium/cmp/test_smoke.py +0 -17
@@ -0,0 +1,63 @@
1
+ package __PACKAGE__.testing
2
+
3
+ import androidx.compose.ui.semantics.SemanticsNode
4
+ import androidx.compose.ui.semantics.SemanticsProperties
5
+ import androidx.compose.ui.semantics.getOrNull
6
+
7
+ /**
8
+ * Serializes a Compose semantics tree to deterministic, diffable JSON — the golden-tree
9
+ * format the verify lane's `goldenTrees` step compares against the committed baselines
10
+ * in `qa/golden/`. (NB: no glob patterns in KDoc — Kotlin block comments nest.)
11
+ *
12
+ * Structure only, no pixels and no absolute geometry: testTag, role, text, and children.
13
+ * That keeps baselines stable across platforms/densities while still catching structural
14
+ * regressions (nodes appearing/disappearing/reordering, text changes, role changes).
15
+ */
16
+ object StructuralTree {
17
+
18
+ fun serialize(root: SemanticsNode): String = buildString {
19
+ appendNode(root, 0)
20
+ append('\n')
21
+ }
22
+
23
+ private fun StringBuilder.appendNode(node: SemanticsNode, depth: Int) {
24
+ val indent = " ".repeat(depth)
25
+ val tag = node.config.getOrNull(SemanticsProperties.TestTag)
26
+ val role = node.config.getOrNull(SemanticsProperties.Role)?.toString()
27
+ val text = node.config.getOrNull(SemanticsProperties.Text)?.joinToString(" ") { it.text }
28
+ val desc = node.config.getOrNull(SemanticsProperties.ContentDescription)?.joinToString(" ")
29
+
30
+ append(indent).append("{")
31
+ val fields = buildList {
32
+ tag?.let { add(""""tag": ${json(it)}""") }
33
+ role?.let { add(""""role": ${json(it)}""") }
34
+ text?.let { add(""""text": ${json(it)}""") }
35
+ desc?.let { add(""""contentDescription": ${json(it)}""") }
36
+ }
37
+ append(fields.joinToString(", "))
38
+
39
+ val children = node.children.filter { it.isMeaningful() }
40
+ if (children.isEmpty()) {
41
+ append("}")
42
+ } else {
43
+ if (fields.isNotEmpty()) append(", ")
44
+ append("\"children\": [\n")
45
+ children.forEachIndexed { i, child ->
46
+ appendNode(child, depth + 1)
47
+ if (i < children.lastIndex) append(",")
48
+ append('\n')
49
+ }
50
+ append(indent).append("]}")
51
+ }
52
+ }
53
+
54
+ /** Nodes carrying no signal (pure layout wrappers) are elided so baselines stay tight. */
55
+ private fun SemanticsNode.isMeaningful(): Boolean =
56
+ config.getOrNull(SemanticsProperties.TestTag) != null ||
57
+ config.getOrNull(SemanticsProperties.Text) != null ||
58
+ config.getOrNull(SemanticsProperties.ContentDescription) != null ||
59
+ config.getOrNull(SemanticsProperties.Role) != null ||
60
+ children.any { it.isMeaningful() }
61
+
62
+ private fun json(s: String): String = "\"${s.replace("\\", "\\\\").replace("\"", "\\\"")}\""
63
+ }
@@ -0,0 +1,51 @@
1
+ # Architecture
2
+
3
+ Clean Architecture, three layers, one rule: **dependencies point inward.**
4
+
5
+ ```
6
+ ┌─────────────────────────────────────────────────────┐
7
+ │ presentation Screens (Compose) · ViewModels │
8
+ │ └─ depends on domain only │
9
+ ├─────────────────────────────────────────────────────┤
10
+ │ domain models · repository INTERFACES · │
11
+ │ use cases — imports nothing app-internal │
12
+ ├─────────────────────────────────────────────────────┤
13
+ │ data repository implementations · │
14
+ │ remote/local sources │
15
+ └─────────────────────────────────────────────────────┘
16
+ di/ wires implementations to interfaces (Koin)
17
+ ```
18
+
19
+ - `presentation` never imports `data`. ViewModels call **use cases**, not repositories.
20
+ - `domain` is pure Kotlin — no Compose, no Koin, no platform types.
21
+ - `data` implements the domain's repository interfaces; sources stay behind them.
22
+
23
+ ## Data flow (unidirectional)
24
+
25
+ `Screen` collects `StateFlow<UiState>` from its ViewModel → user intent calls a ViewModel
26
+ function → the ViewModel invokes a use case → repository → sources → new immutable `UiState`
27
+ is emitted. No state lives in composables beyond UI-local concerns.
28
+
29
+ ## The exemplar: the `home` feature
30
+
31
+ `presentation/home` + `domain/{model,repository,usecase}` + `data/remote` is the **reference
32
+ implementation** of the pattern — including its tests (`commonTest`). To add a feature, mirror
33
+ it exactly:
34
+
35
+ 1. Domain: model + repository interface + use case (+ tests).
36
+ 2. Data: repository implementation (+ test through the domain contract).
37
+ 3. Presentation: `<Feature>Screen` (testTag-rooted) + `<Feature>ViewModel` with
38
+ `StateFlow<UiState>` (+ test using a fake from `testing/fakes/`).
39
+ 4. DI: register in `di/AppModule.kt`.
40
+ 5. Navigation: add the route in `presentation/navigation/`.
41
+ 6. Run `node qa/verify.mjs` — done means PASS + committed receipt.
42
+
43
+ ## Conventions
44
+
45
+ - **Theme tokens** (`presentation/theme/`) are the only source of design values — no hardcoded
46
+ colors/spacing/radii in screens.
47
+ - **testTags** on every screen root and interactive element (`TestTagAutomation` exposes them
48
+ to E2E tooling on both platforms).
49
+ - **Insets** are owned by `BaseScreen` — new screens compose inside it and never re-solve
50
+ edge-to-edge padding.
51
+ - Significant decisions get an ADR in [`docs/adr/`](./adr/) — see the template there.
@@ -0,0 +1,64 @@
1
+ # Testing
2
+
3
+ The pyramid this project uses, bottom-up. The `home` feature's tests are the exemplar —
4
+ copy their shape.
5
+
6
+ | Layer | Where | Run |
7
+ |---|---|---|
8
+ | Unit (majority) | `composeApp/src/commonTest` | `./gradlew :composeApp:desktopTest` |
9
+ | Conformance gates (ARCH clauses) | `composeApp/src/desktopTest/…/conformance` | same task |
10
+ | Screen behavior — Compose UI Test (spec-cited) | `composeApp/src/desktopTest/…/presentation` | same task |
11
+ | Golden trees (structure) | `qa/golden/` + `HomeGoldenTreeTest` | same task |
12
+ <!-- >>> cmp:feature e2e -->
13
+ | E2E smoke (few) | `qa/e2e/*.yaml` (Maestro) | `maestro test qa/e2e/smoke.yaml` |
14
+ <!-- <<< cmp:feature e2e -->
15
+ | The lane (all of it) | `qa/verify.mjs` | `node qa/verify.mjs` |
16
+
17
+ Every durable test cites the spec clause it verifies (`// SPEC: HOME-02` — see
18
+ [`specs/`](../specs/README.md)); **new behavior begins as a spec clause.** The lane's
19
+ `specCoverage` step enforces this: it fails on orphan clauses (no citing test) and orphan tags
20
+ (no matching clause, or one citing a withdrawn clause).
21
+
22
+ ## Unit conventions
23
+
24
+ - **Frameworks:** `kotlin-test` assertions · `kotlinx-coroutines-test` (`runTest`,
25
+ `StandardTestDispatcher`) · **Turbine** for Flow/StateFlow.
26
+ - **Fakes, never mocks.** Every repository/source interface gets a hand-written fake in
27
+ `commonTest/…/testing/fakes/` — configurable (`shouldFail`, seeded data) and
28
+ call-recording. Mocking frameworks are banned: they're JVM-only in KMP and hide bad seams.
29
+ - **Style:** Arrange-Act-Assert; behavior-named backtick tests
30
+ (`` `emits error message when repository fails` ``); one behavior per test; no shared
31
+ mutable state between tests.
32
+ - **ViewModels:** install a `StandardTestDispatcher` as Main (`@BeforeTest setMain` /
33
+ `@AfterTest resetMain`) because `viewModelScope` launches on Main; assert state with
34
+ `state.test { … }` (Turbine).
35
+ - **Suspend/delay:** always under `runTest` — virtual time makes `delay` free.
36
+
37
+ ## What every new piece of code brings
38
+
39
+ | You added | You also add |
40
+ |---|---|
41
+ | a ViewModel | a `*ViewModelTest` (states: loading, success, failure, retry) |
42
+ | a use case | a `*UseCaseTest` (behavior + failure propagation) |
43
+ | a repository impl | a test through its DOMAIN interface |
44
+ | a screen | a testTag root (E2E reachable) |
45
+
46
+ Never delete, weaken, or `@Ignore` a failing test to get green. Fix the behavior — or if the
47
+ test is genuinely wrong, change it and say so explicitly in your PR/summary.
48
+
49
+ <!-- >>> cmp:feature e2e -->
50
+ ## E2E
51
+
52
+ Maestro flows (`qa/e2e/*.yaml`) cover boot + bottom-nav — install the free CLI once
53
+ (`curl -fsSL "https://get.maestro.mobile.dev" | bash`). Selectors go by **testTag** (`id:` —
54
+ surfaced as resource-ids via `TestTagAutomation`), never by display text. One flow per
55
+ journey, spec-clause cited; keep the E2E tip small — behavior belongs in unit tests.
56
+ <!-- <<< cmp:feature e2e -->
57
+
58
+ ## The verify lane
59
+
60
+ `node qa/verify.mjs` is the definition of done: spec coverage → build → unit tests →
61
+ (conformance, golden trees, token drift, a11y — as they ship) → E2E smoke when a device is
62
+ attached. It writes the evidence receipt to `qa/evidence/latest.json`; **commit the receipt
63
+ with your change.** SKIPped steps are recorded honestly — green-with-gaps is visible, never
64
+ silent.
@@ -0,0 +1,32 @@
1
+ # ADR-0001: Adopt the create-cmp harness conventions
2
+
3
+ - **Status:** accepted
4
+ - **Date:** (scaffold date)
5
+
6
+ ## Context
7
+
8
+ This project was generated by create-cmp, which ships an opinionated, mechanically-enforced
9
+ set of conventions: Clean Architecture with inward-pointing dependencies, a frozen and
10
+ CI-verified dependency version set (Kotlin/KSP/Compose/Room/AGP move as one), unit tests with
11
+ hand-written fakes (no mocking frameworks), testTag-addressable screens, theme-token-only
12
+ design values, and a verify lane whose committed evidence receipt is the definition of done.
13
+ AI collaborators are bound to these via `CLAUDE.md`.
14
+
15
+ ## Decision
16
+
17
+ We keep the harness conventions as the project's baseline. Deviations are made consciously:
18
+ each one gets its own ADR superseding the relevant part of this record.
19
+
20
+ ## Consequences
21
+
22
+ - Any contributor (human or AI) can rely on the exemplar `home` feature as the canonical
23
+ pattern, and on `node qa/verify.mjs` as the single gate.
24
+ - Upgrades stay on proven-green version sets (`npx create-cmp-cli upgrade`) rather than
25
+ ad-hoc bumps.
26
+ - The cost: less per-developer freedom in structure and tooling — accepted in exchange for a
27
+ codebase that stays verifiable at AI speed.
28
+
29
+ This ADR also records *why ADRs*: significant decisions die in chat threads and PR comments;
30
+ a one-page record in the repo is the industry-standard fix
31
+ ([MADR](https://adr.github.io/madr/)-style, trimmed). Copy `template.md` to
32
+ `NNNN-<slug>.md` for the next decision.
@@ -0,0 +1,17 @@
1
+ # ADR-NNNN: <short decision title>
2
+
3
+ - **Status:** proposed | accepted | superseded by ADR-XXXX
4
+ - **Date:** YYYY-MM-DD
5
+
6
+ ## Context
7
+
8
+ What forces are at play? What problem does this decision answer? 2–5 sentences.
9
+
10
+ ## Decision
11
+
12
+ What we chose, stated actively: "We will …". One paragraph.
13
+
14
+ ## Consequences
15
+
16
+ What becomes easier, what becomes harder, what we accept. Include the alternatives rejected
17
+ and the one-line reason each lost.
@@ -2,7 +2,10 @@
2
2
  .gradle
3
3
  /local.properties
4
4
  .DS_Store
5
- /build
5
+ # Gradle build outputs — unanchored so module dirs (composeApp/build/, …) match too,
6
+ # not just a root-level build/. Anchored `/build` misses composeApp/build/, which would
7
+ # then get committed and destabilise the evidence receipt's inputs hash.
8
+ build/
6
9
  /captures
7
10
  .externalNativeBuild
8
11
  .cxx
@@ -11,6 +14,10 @@
11
14
  # Kotlin
12
15
  .kotlin/
13
16
 
17
+ # Verify-lane binary evidence (screenshots, reports, page sources). The RECEIPT
18
+ # (qa/evidence/latest.json) IS committed — these artifacts are hashed into it instead.
19
+ qa-artifacts/
20
+
14
21
  # iOS / CocoaPods / XcodeGen
15
22
  iosApp/Pods/
16
23
  iosApp/Podfile.lock
@@ -86,6 +86,8 @@ android-desugar-jdk = { module = "com.android.tools:desugar_jdk_libs", version =
86
86
  kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
87
87
  koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" }
88
88
  kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version = "1.9.0" }
89
+ # Turbine — Flow/StateFlow assertion library (CashApp). KMP-safe; the exemplar ViewModel tests use it.
90
+ turbine = { module = "app.cash.turbine:turbine", version = "1.2.0" }
89
91
 
90
92
  [plugins]
91
93
  kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
@@ -25,6 +25,8 @@
25
25
  ],
26
26
  "packageSourceRoots": [
27
27
  "composeApp/src/commonMain/kotlin",
28
+ "composeApp/src/commonTest/kotlin",
29
+ "composeApp/src/desktopTest/kotlin",
28
30
  "composeApp/src/androidMain/kotlin",
29
31
  "composeApp/src/androidDebug/kotlin",
30
32
  "composeApp/src/androidRelease/kotlin",
@@ -81,13 +83,12 @@
81
83
  ],
82
84
  "notes": "When off: strip `room` marker blocks in build.gradle.kts (ksp + room plugins), composeApp/build.gradle.kts (room/sqlite deps, kspAndroid/kspIos/kspDesktop room.compiler, the room {} schema block), AppApplication.kt, KoinHelper.kt and DesktopModule.kt (AppDatabase single + appContext). Delete the data/local dirs (incl. the desktopMain one when dev-client is on). The example `home` feature does NOT depend on Room (its ItemRepositoryImpl is in-memory), so it keeps building with Room off."
83
85
  },
84
- "appium": {
86
+ "e2e": {
85
87
  "enabledByDefault": true,
86
88
  "paths": [
87
- "qa/appium",
88
- "tests/appium"
89
+ "qa/e2e"
89
90
  ],
90
- "notes": "Appium harness (Python conftest + smoke, JS client + smoke runner). No in-file markers; toggled by deleting the listed paths."
91
+ "notes": "Device-level E2E harness — Maestro flows (qa/e2e/*.yaml; replaced the Appium Node+pytest runners 2026-07-06). Feature key renamed from `appium` to `e2e` in 0.3.0 (old `--no-appium` CLI flag kept as a deprecated alias for `--no-e2e`). testTag selectors via TestTagAutomation resource-ids. No in-file markers except the docs sections; toggled by deleting qa/e2e."
91
92
  },
92
93
  "inspector": {
93
94
  "enabledByDefault": true,
@@ -100,15 +101,17 @@
100
101
  "dev-client": {
101
102
  "enabledByDefault": true,
102
103
  "paths": [
103
- "composeApp/src/desktopMain",
104
+ "composeApp/src/desktopMain/kotlin/com/example/app/main.kt",
105
+ "composeApp/src/desktopMain/kotlin/com/example/app/di/DesktopModule.kt",
104
106
  "docs/dev-client.md"
105
107
  ],
106
- "notes": "Desktop dev-client: a jvm(\"desktop\") target inside composeApp running the shared commonMain UI in a phone-sized JVM window (main.kt: __APP_NAME__ dev-client, 411x891dp), with Compose Hot Reload 1.1.1 attached (task hotRunDesktop; plain run task needs no JBR). GitLive Firebase 2.1.0 publishes JVM artifacts so commonMain deps resolve for the desktop target, but the dev-client NEVER initializes/contacts Firebase desktopMain DI (DesktopModule.kt) binds only NetworkMonitor (always-online) + Room (BundledSQLiteDriver into the OS temp dir); the example ItemRepositoryImpl is in-memory on every platform. When off: delete the desktopMain dir + docs/dev-client.md AND strip the `dev-client` marker blocks in build.gradle.kts (hot-reload plugin apply false), settings.gradle.kts (foojay-resolver-convention for JBR auto-provisioning), composeApp/build.gradle.kts (hot-reload plugin, jvm(\"desktop\") target, desktopMain deps, kspDesktop room.compiler row nested inside the room block, compose.desktop application block). The catalog keeps the declared coordinates by design (declared-but-unused)."
108
+ "notes": "Desktop dev-client: the hot-reload WINDOW on top of the always-present JVM tier. IMPORTANT (changed 2026-07-06): the jvm(\"desktop\") target, desktopMain deps, platform actuals (NetworkMonitor/DatabaseBuilder/TestTagAutomation .desktop.kt), and the kspDesktop room.compiler row are HARNESS INFRASTRUCTURE and are NOT gated by this feature the verify lane's unit/conformance/golden/UI tests run on :composeApp:desktopTest in every feature combination. This feature gates only the interactive window: main.kt (411x891dp __APP_NAME__ dev-client window) + DesktopModule.kt (initDesktopKoin), the compose-hot-reload plugin (build.gradle.kts + composeApp/build.gradle.kts markers), the settings.gradle.kts foojay-resolver (JBR auto-provisioning), and the compose.desktop application block. The dev-client NEVER initializes/contacts Firebase desktop DI binds NetworkMonitor (always-online) + Room (BundledSQLiteDriver into the OS temp dir); the example ItemRepositoryImpl is in-memory on every platform."
107
109
  }
108
110
  },
109
111
  "verify": {
110
- "android": "./gradlew :composeApp:assembleDebug",
111
- "androidSmoke": "./gradlew :composeApp:installDebug && node qa/appium/run-android-smoke.mjs",
112
+ "android": "node qa/verify.mjs --profile scaffold",
113
+ "androidBuildOnly": "./gradlew :composeApp:assembleDebug",
114
+ "androidSmoke": "./gradlew :composeApp:installDebug && maestro test qa/e2e/smoke.yaml",
112
115
  "iosLink": "./gradlew :composeApp:linkDebugFrameworkIosSimulatorArm64",
113
116
  "ios": "cd iosApp && export LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 && xcodegen generate && pod install && SIM_UDID=$(xcrun simctl list devices available | grep -Eo '[0-9A-F-]{36}' | head -1) && xcodebuild -workspace iosApp.xcworkspace -scheme iosApp -sdk iphonesimulator -configuration Debug ARCHS=arm64 ONLY_ACTIVE_ARCH=YES EXCLUDED_ARCHS=x86_64 -destination \"id=$SIM_UDID\" build",
114
117
  "iosNote": "On Apple Silicon, the KMP embedAndSignAppleFrameworkForXcode prebuild phase produces ONLY the active-arch (arm64) slice of ComposeApp.framework. A `-destination generic/platform=iOS Simulator` build demands a universal arm64+x86_64 binary and FAILS at CreateUniversalBinary. Constrain to arm64 (ARCHS=arm64 EXCLUDED_ARCHS=x86_64) and target a concrete arm64 simulator UDID — matching the linkDebugFrameworkIosSimulatorArm64 gate."
@@ -0,0 +1,29 @@
1
+ # E2E flows — Maestro
2
+
3
+ Thin device-level smoke: does the real app boot and do the critical journeys work. Behavior
4
+ lives in unit tests; screen behavior in Compose UI Tests; structure in golden trees — keep
5
+ this layer small.
6
+
7
+ ## Setup (one-time)
8
+
9
+ ```bash
10
+ curl -fsSL "https://get.maestro.mobile.dev" | bash # Apache-2.0, free CLI
11
+ ```
12
+
13
+ ## Run
14
+
15
+ ```bash
16
+ # Android: emulator/device attached, debug build installed
17
+ ./gradlew :composeApp:installDebug
18
+ maestro test qa/e2e/smoke.yaml
19
+
20
+ # The verify lane runs this automatically when maestro + a device are present:
21
+ node qa/verify.mjs
22
+ ```
23
+
24
+ ## Conventions
25
+
26
+ - **Selectors by testTag** (`id:` — TestTagAutomation surfaces tags as resource-ids on
27
+ Android and accessibility ids on iOS); visible text only for content assertions.
28
+ - Every flow cites the spec clauses it verifies (`# SPEC: SHELL-01`).
29
+ - One flow per journey; deterministic start (`clearState: true`).
@@ -0,0 +1,35 @@
1
+ # E2E smoke — Maestro flow. SPEC: SHELL-01, SHELL-02.
2
+ #
3
+ # Proves the real app boots on a device/emulator and the bottom-nav shell works.
4
+ # Selectors go by testTag (surfaced as resource-ids on Android via TestTagAutomation),
5
+ # falling back to visible text only for content assertions.
6
+ #
7
+ # Run: maestro test qa/e2e/smoke.yaml (device/emulator attached)
8
+ # The verify lane's e2eSmoke step runs this automatically when maestro + a device are present.
9
+ appId: __PACKAGE__
10
+ ---
11
+ - launchApp:
12
+ clearState: true
13
+
14
+ # SPEC: SHELL-01 — the app boots and the first tab renders inside the shell, bottom nav visible.
15
+ # Cold-start after clearState can take longer than a bare assert's default window on a slow/CI
16
+ # emulator (first Compose frame under load). Waiting for the first frame IS the SHELL-01 boot
17
+ # proof, so wait explicitly with a generous budget rather than asserting immediately.
18
+ - extendedWaitUntil:
19
+ visible:
20
+ id: "home_title"
21
+ timeout: 60000
22
+ - assertVisible:
23
+ id: "app_bottom_nav"
24
+
25
+ # SPEC: SHELL-02 — switching tabs keeps the shell
26
+ - tapOn: "Profile"
27
+ - assertVisible:
28
+ id: "profile_title"
29
+ - assertVisible:
30
+ id: "app_bottom_nav"
31
+
32
+ # and back
33
+ - tapOn: "Home"
34
+ - assertVisible:
35
+ id: "home_title"
@@ -0,0 +1,56 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "cmp-evidence/1",
4
+ "title": "create-cmp verify-lane evidence receipt",
5
+ "type": "object",
6
+ "required": ["schema", "profile", "verdict", "commit", "steps", "artifacts", "toolVersions", "generatedAt"],
7
+ "properties": {
8
+ "schema": { "const": "cmp-evidence/1" },
9
+ "profile": { "enum": ["scaffold", "local", "ci"] },
10
+ "verdict": { "enum": ["PASS", "FAIL"] },
11
+ "commit": {
12
+ "type": "object",
13
+ "required": ["sha", "dirty"],
14
+ "properties": {
15
+ "sha": { "type": ["string", "null"], "description": "Parent HEAD at run time (the receipt is part of the NEXT commit)" },
16
+ "dirty": { "type": "array", "items": { "type": "string" } }
17
+ }
18
+ },
19
+ "inputs": {
20
+ "type": "object",
21
+ "description": "Content-hash binding of the receipt to the verified surface (ADR-0005). Absent on receipts predating evidence binding.",
22
+ "required": ["hash", "fileCount"],
23
+ "properties": {
24
+ "hash": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
25
+ "fileCount": { "type": "number" }
26
+ }
27
+ },
28
+ "steps": {
29
+ "type": "array",
30
+ "items": {
31
+ "type": "object",
32
+ "required": ["name", "verdict", "durationMs"],
33
+ "properties": {
34
+ "name": { "type": "string" },
35
+ "verdict": { "enum": ["PASS", "FAIL", "SKIP"] },
36
+ "reason": { "type": "string" },
37
+ "durationMs": { "type": "number" },
38
+ "details": { "type": "object" }
39
+ }
40
+ }
41
+ },
42
+ "artifacts": {
43
+ "type": "array",
44
+ "items": {
45
+ "type": "object",
46
+ "required": ["path", "sha256"],
47
+ "properties": {
48
+ "path": { "type": "string" },
49
+ "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }
50
+ }
51
+ }
52
+ },
53
+ "toolVersions": { "type": "object" },
54
+ "generatedAt": { "type": "string", "format": "date-time" }
55
+ }
56
+ }
@@ -0,0 +1,15 @@
1
+ {"children": [
2
+ {"children": [
3
+ {"tag": "home_title", "text": "Home"},
4
+ {"children": [
5
+ {"children": [
6
+ {"text": "Golden first"},
7
+ {"text": "Structural baseline row one"}
8
+ ]},
9
+ {"children": [
10
+ {"text": "Golden second"},
11
+ {"text": "Structural baseline row two"}
12
+ ]}
13
+ ]}
14
+ ]}
15
+ ]}
@@ -0,0 +1,112 @@
1
+ // Shared primitive: a content hash of the "verified surface" — every tracked
2
+ // file whose content can change the verify lane's verdict, minus the lane's
3
+ // own outputs. Both qa/verify.mjs (writes inputs.hash into the receipt) and
4
+ // qa/receipt-check.mjs (recomputes it to test validity) import this module so
5
+ // there is exactly one definition of the surface and the algorithm.
6
+ //
7
+ // See docs/adr/0005-evidence-binding-by-inputs-hash.md for the why.
8
+
9
+ import { execSync } from "node:child_process";
10
+ import { createHash } from "node:crypto";
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+
14
+ // Directories / files INCLUDED in the verified surface (relative to project ROOT).
15
+ // Principle: every tracked file whose content can change the lane's verdict.
16
+ export const VERIFIED_SURFACE = [
17
+ "composeApp",
18
+ "specs",
19
+ "qa",
20
+ "gradle/libs.versions.toml",
21
+ "build.gradle.kts",
22
+ "settings.gradle.kts",
23
+ "gradle.properties",
24
+ ];
25
+
26
+ // Paths EXCLUDED even though they fall under an included surface dir above —
27
+ // these are lane OUTPUTS, not inputs. Including them would make the hash
28
+ // depend on the lane's own prior output (or, for qa-artifacts, on binary
29
+ // scratch that is deliberately never committed).
30
+ const EXCLUDED_PREFIXES = ["qa/evidence", "qa-artifacts"];
31
+
32
+ function isExcluded(relPath) {
33
+ return EXCLUDED_PREFIXES.some((prefix) => relPath === prefix || relPath.startsWith(`${prefix}/`));
34
+ }
35
+
36
+ // The verified surface is the set of files that WILL be committed: tracked files
37
+ // PLUS untracked-but-not-ignored files (`--others --exclude-standard`). A freshly
38
+ // generated feature's files are untracked when the lane runs and the receipt is
39
+ // written, yet they land in the very same commit as the receipt — so they must be
40
+ // hashed, or the committed receipt would never attest its own commit (and CI's
41
+ // receipt-matches-HEAD gate would false-fail on every change). Gitignored scratch
42
+ // (build outputs, qa-artifacts) is still excluded via --exclude-standard.
43
+ function tryGitLsFiles(root) {
44
+ try {
45
+ const out = execSync("git ls-files -z --cached --others --exclude-standard", { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
46
+ return out.split("\0").filter(Boolean);
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ // Dependency-free recursive walk, used when git is unavailable (non-git scaffold).
53
+ function walkAllFiles(dir) {
54
+ const out = [];
55
+ if (!fs.existsSync(dir)) return out;
56
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
57
+ const p = path.join(dir, entry.name);
58
+ if (entry.isDirectory()) out.push(...walkAllFiles(p));
59
+ else if (entry.isFile()) out.push(p);
60
+ }
61
+ return out;
62
+ }
63
+
64
+ // Resolve the verified surface to a flat, sorted list of paths (relative to
65
+ // root, POSIX-style `/` separators) that currently exist on disk.
66
+ function resolveSurfaceFiles(root) {
67
+ const gitFiles = tryGitLsFiles(root);
68
+
69
+ if (gitFiles) {
70
+ return gitFiles
71
+ .map((p) => p.split(path.sep).join("/"))
72
+ .filter((relPath) => VERIFIED_SURFACE.some((surface) => relPath === surface || relPath.startsWith(`${surface}/`)))
73
+ .filter((relPath) => !isExcluded(relPath))
74
+ .filter((relPath) => fs.existsSync(path.join(root, relPath)) && fs.statSync(path.join(root, relPath)).isFile());
75
+ }
76
+
77
+ // Fallback: no git available — walk the surface directories directly so a
78
+ // non-git scaffold still produces a stable hash.
79
+ const collected = [];
80
+ for (const surface of VERIFIED_SURFACE) {
81
+ const abs = path.join(root, surface);
82
+ if (!fs.existsSync(abs)) continue;
83
+ const stat = fs.statSync(abs);
84
+ if (stat.isFile()) {
85
+ collected.push(surface);
86
+ } else if (stat.isDirectory()) {
87
+ for (const file of walkAllFiles(abs)) {
88
+ collected.push(path.relative(root, file).split(path.sep).join("/"));
89
+ }
90
+ }
91
+ }
92
+ return collected.filter((relPath) => !isExcluded(relPath));
93
+ }
94
+
95
+ /**
96
+ * Compute the sha256 hash of the verified surface for the project rooted at `root`.
97
+ * Deterministic: same tree (same file paths + same file bytes) → same hash.
98
+ * @param {string} root absolute path to the project root
99
+ * @returns {{ hash: string, fileCount: number }}
100
+ */
101
+ export function computeInputsHash(root) {
102
+ const files = [...new Set(resolveSurfaceFiles(root))].sort((a, b) => a.localeCompare(b));
103
+
104
+ const overall = createHash("sha256");
105
+ for (const relPath of files) {
106
+ const bytes = fs.readFileSync(path.join(root, relPath));
107
+ const fileSha = createHash("sha256").update(bytes).digest("hex");
108
+ overall.update(`${relPath}\0${fileSha}\n`);
109
+ }
110
+
111
+ return { hash: overall.digest("hex"), fileCount: files.length };
112
+ }