create-cmp-cli 0.2.0 → 0.3.0
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.
- package/README.md +65 -27
- package/bin/create-cmp.mjs +2 -1
- package/options.schema.json +5 -2
- package/package.json +2 -2
- package/src/commands/create.mjs +26 -3
- package/src/lib/toggle.mjs +2 -2
- package/template/.claude/settings.json +12 -0
- package/template/.claude/skills/add-feature/SKILL.md +131 -0
- package/template/.claude/skills/add-repository/SKILL.md +106 -0
- package/template/.claude/skills/add-screen/SKILL.md +130 -0
- package/template/.github/workflows/verify.yml +30 -8
- package/template/.gradle/8.11.1/checksums/checksums.lock +0 -0
- package/template/.gradle/8.11.1/fileChanges/last-build.bin +0 -0
- package/template/.gradle/8.11.1/fileHashes/fileHashes.lock +0 -0
- package/template/.gradle/8.11.1/gc.properties +0 -0
- package/template/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
- package/template/.gradle/buildOutputCleanup/cache.properties +2 -0
- package/template/.gradle/vcs-1/gc.properties +0 -0
- package/template/CHANGELOG.md +14 -0
- package/template/CLAUDE.md +70 -0
- package/template/CONTRIBUTING.md +39 -0
- package/template/README.md +103 -0
- package/template/composeApp/build.gradle.kts +15 -8
- package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +4 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +10 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +10 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +6 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +1 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/Screen.kt +2 -1
- package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +26 -0
- package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +37 -0
- package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +104 -0
- package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +29 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/A11yConformanceTest.kt +64 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +143 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeGoldenTreeTest.kt +78 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +77 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/navigation/AppShellTest.kt +79 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/testing/ComposeTestExt.kt +16 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/testing/StructuralTree.kt +63 -0
- package/template/docs/ARCHITECTURE.md +51 -0
- package/template/docs/TESTING.md +64 -0
- package/template/docs/adr/0001-adopt-the-create-cmp-harness-conventions.md +32 -0
- package/template/docs/adr/template.md +17 -0
- package/template/gitignore +4 -0
- package/template/gradle/libs.versions.toml +2 -0
- package/template/manifest.json +11 -8
- package/template/qa/e2e/README.md +29 -0
- package/template/qa/e2e/smoke.yaml +35 -0
- package/template/qa/evidence/schema.json +56 -0
- package/template/qa/golden/home.json +15 -0
- package/template/qa/lib/inputs-hash.mjs +105 -0
- package/template/qa/lib/token-drift.mjs +94 -0
- package/template/qa/receipt-check.mjs +113 -0
- package/template/qa/refusal-demo.mjs +491 -0
- package/template/qa/scaffold-feature.mjs +488 -0
- package/template/qa/verify.mjs +467 -0
- package/template/specs/README.md +33 -0
- package/template/specs/app-base.spec.md +30 -0
- package/template/specs/home.spec.md +18 -0
- package/template/qa/appium/README.md +0 -23
- package/template/qa/appium/lib/appium-client.mjs +0 -225
- package/template/qa/appium/package.json +0 -8
- package/template/qa/appium/run-android-smoke.mjs +0 -39
- package/template/tests/appium/cmp/conftest.py +0 -96
- 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.
|
package/template/gitignore
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
# Kotlin
|
|
12
12
|
.kotlin/
|
|
13
13
|
|
|
14
|
+
# Verify-lane binary evidence (screenshots, reports, page sources). The RECEIPT
|
|
15
|
+
# (qa/evidence/latest.json) IS committed — these artifacts are hashed into it instead.
|
|
16
|
+
qa-artifacts/
|
|
17
|
+
|
|
14
18
|
# iOS / CocoaPods / XcodeGen
|
|
15
19
|
iosApp/Pods/
|
|
16
20
|
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" }
|
package/template/manifest.json
CHANGED
|
@@ -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
|
-
"
|
|
86
|
+
"e2e": {
|
|
85
87
|
"enabledByDefault": true,
|
|
86
88
|
"paths": [
|
|
87
|
-
"qa/
|
|
88
|
-
"tests/appium"
|
|
89
|
+
"qa/e2e"
|
|
89
90
|
],
|
|
90
|
-
"notes": "
|
|
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:
|
|
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": "
|
|
111
|
-
"
|
|
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,105 @@
|
|
|
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
|
+
function tryGitLsFiles(root) {
|
|
37
|
+
try {
|
|
38
|
+
const out = execSync("git ls-files -z", { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
39
|
+
return out.split("\0").filter(Boolean);
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Dependency-free recursive walk, used when git is unavailable (non-git scaffold).
|
|
46
|
+
function walkAllFiles(dir) {
|
|
47
|
+
const out = [];
|
|
48
|
+
if (!fs.existsSync(dir)) return out;
|
|
49
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
50
|
+
const p = path.join(dir, entry.name);
|
|
51
|
+
if (entry.isDirectory()) out.push(...walkAllFiles(p));
|
|
52
|
+
else if (entry.isFile()) out.push(p);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Resolve the verified surface to a flat, sorted list of paths (relative to
|
|
58
|
+
// root, POSIX-style `/` separators) that currently exist on disk.
|
|
59
|
+
function resolveSurfaceFiles(root) {
|
|
60
|
+
const gitFiles = tryGitLsFiles(root);
|
|
61
|
+
|
|
62
|
+
if (gitFiles) {
|
|
63
|
+
return gitFiles
|
|
64
|
+
.map((p) => p.split(path.sep).join("/"))
|
|
65
|
+
.filter((relPath) => VERIFIED_SURFACE.some((surface) => relPath === surface || relPath.startsWith(`${surface}/`)))
|
|
66
|
+
.filter((relPath) => !isExcluded(relPath))
|
|
67
|
+
.filter((relPath) => fs.existsSync(path.join(root, relPath)) && fs.statSync(path.join(root, relPath)).isFile());
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Fallback: no git available — walk the surface directories directly so a
|
|
71
|
+
// non-git scaffold still produces a stable hash.
|
|
72
|
+
const collected = [];
|
|
73
|
+
for (const surface of VERIFIED_SURFACE) {
|
|
74
|
+
const abs = path.join(root, surface);
|
|
75
|
+
if (!fs.existsSync(abs)) continue;
|
|
76
|
+
const stat = fs.statSync(abs);
|
|
77
|
+
if (stat.isFile()) {
|
|
78
|
+
collected.push(surface);
|
|
79
|
+
} else if (stat.isDirectory()) {
|
|
80
|
+
for (const file of walkAllFiles(abs)) {
|
|
81
|
+
collected.push(path.relative(root, file).split(path.sep).join("/"));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return collected.filter((relPath) => !isExcluded(relPath));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Compute the sha256 hash of the verified surface for the project rooted at `root`.
|
|
90
|
+
* Deterministic: same tree (same file paths + same file bytes) → same hash.
|
|
91
|
+
* @param {string} root absolute path to the project root
|
|
92
|
+
* @returns {{ hash: string, fileCount: number }}
|
|
93
|
+
*/
|
|
94
|
+
export function computeInputsHash(root) {
|
|
95
|
+
const files = [...new Set(resolveSurfaceFiles(root))].sort((a, b) => a.localeCompare(b));
|
|
96
|
+
|
|
97
|
+
const overall = createHash("sha256");
|
|
98
|
+
for (const relPath of files) {
|
|
99
|
+
const bytes = fs.readFileSync(path.join(root, relPath));
|
|
100
|
+
const fileSha = createHash("sha256").update(bytes).digest("hex");
|
|
101
|
+
overall.update(`${relPath}\0${fileSha}\n`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { hash: overall.digest("hex"), fileCount: files.length };
|
|
105
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// token-drift.mjs — pure, dependency-free comparison of a LIVE inspector tree's
|
|
2
|
+
// resolved design-token payloads against the declared design-system catalog.
|
|
3
|
+
//
|
|
4
|
+
// Mirrors the comparison semantics of diffAgainstDesignSystem() in the cmp-inspector
|
|
5
|
+
// MCP server (inspector/mcp/src/lib/drift.mjs): for each node in the tree that carries
|
|
6
|
+
// a designToken payload ({tokens:[names], resolved:{facet:value}}), and for each
|
|
7
|
+
// declared token name on that node, resolve the expected value from the catalog
|
|
8
|
+
// (dimens checked before colors — same lookup order as the MCP) and compare it
|
|
9
|
+
// against every one of the node's resolved facet values. If NONE of them match the
|
|
10
|
+
// declared value, the node has drifted from what it claims to use.
|
|
11
|
+
//
|
|
12
|
+
// Matching is case/whitespace-normalized string equality (trim + lowercase) — the
|
|
13
|
+
// exact normalization the MCP's `normalize()` applies (so "#0A2540" == "#0a2540",
|
|
14
|
+
// but "72dp" would NOT equal "72.0dp" — the MCP does not do numeric-format
|
|
15
|
+
// normalization, so neither do we, to keep the comparison rule identical).
|
|
16
|
+
//
|
|
17
|
+
// No imports, no Node built-ins beyond what the runtime provides for free — pure
|
|
18
|
+
// object-in/object-out, unit-testable with plain objects.
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {{colors?:Record<string,string>, dimens?:Record<string,string>}} declaredCatalog
|
|
22
|
+
* @param {{root:object}|object} tree the parsed /inspect/tree document (or a bare node)
|
|
23
|
+
* @returns {{checked:number, drifted:Array<{node:string, token:string, facet:string, expected:string, actual:string}>}}
|
|
24
|
+
*/
|
|
25
|
+
export function compareTokenDrift(declaredCatalog, tree) {
|
|
26
|
+
const colors = (declaredCatalog && declaredCatalog.colors) || {};
|
|
27
|
+
const dimens = (declaredCatalog && declaredCatalog.dimens) || {};
|
|
28
|
+
|
|
29
|
+
let checked = 0;
|
|
30
|
+
const drifted = [];
|
|
31
|
+
|
|
32
|
+
for (const { node, path } of walk(tree)) {
|
|
33
|
+
const dt = node && node.designToken;
|
|
34
|
+
if (!dt || !Array.isArray(dt.tokens) || dt.tokens.length === 0) continue;
|
|
35
|
+
|
|
36
|
+
const resolved = dt.resolved && typeof dt.resolved === "object" ? dt.resolved : {};
|
|
37
|
+
const resolvedEntries = Object.entries(resolved);
|
|
38
|
+
|
|
39
|
+
for (const token of dt.tokens) {
|
|
40
|
+
let declared;
|
|
41
|
+
if (Object.prototype.hasOwnProperty.call(dimens, token)) declared = dimens[token];
|
|
42
|
+
else if (Object.prototype.hasOwnProperty.call(colors, token)) declared = colors[token];
|
|
43
|
+
else continue; // token not in the declared catalog — nothing to diff against
|
|
44
|
+
|
|
45
|
+
checked += 1;
|
|
46
|
+
|
|
47
|
+
const declaredNorm = normalize(declared);
|
|
48
|
+
const matches = resolvedEntries.some(([, v]) => normalize(v) === declaredNorm);
|
|
49
|
+
if (matches) continue;
|
|
50
|
+
|
|
51
|
+
const [facet, actual] = pickFacetForReport(resolvedEntries);
|
|
52
|
+
drifted.push({
|
|
53
|
+
node: node.testTag || path,
|
|
54
|
+
token,
|
|
55
|
+
facet,
|
|
56
|
+
expected: declared,
|
|
57
|
+
actual,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { checked, drifted };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Depth-first walk yielding every node with a stable, dotted path. Accepts either a
|
|
66
|
+
// full tree ({schemaVersion, source, root}) or a bare node — same contract as the
|
|
67
|
+
// MCP's tree.mjs walk().
|
|
68
|
+
function* walk(tree) {
|
|
69
|
+
const root = tree && tree.root ? tree.root : tree;
|
|
70
|
+
if (!root || typeof root !== "object") return;
|
|
71
|
+
yield* walkNode(root, "root");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function* walkNode(node, path) {
|
|
75
|
+
yield { node, path };
|
|
76
|
+
const children = Array.isArray(node.children) ? node.children : [];
|
|
77
|
+
for (let i = 0; i < children.length; i++) {
|
|
78
|
+
yield* walkNode(children[i], `${path}.children[${i}]`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Case-insensitive, trimmed comparison — identical to the MCP's normalize().
|
|
83
|
+
function normalize(v) {
|
|
84
|
+
return String(v == null ? "" : v).trim().toLowerCase();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Best-effort single (facet, value) to blame in the drift report. If the node
|
|
88
|
+
// resolved exactly one facet, name it directly; otherwise join every facet/value
|
|
89
|
+
// so the reader sees everything the node actually resolved.
|
|
90
|
+
function pickFacetForReport(entries) {
|
|
91
|
+
if (entries.length === 1) return [entries[0][0], String(entries[0][1])];
|
|
92
|
+
if (entries.length === 0) return ["(none)", "(no resolved values)"];
|
|
93
|
+
return [entries.map(([k]) => k).join(","), entries.map(([, v]) => String(v)).join(", ")];
|
|
94
|
+
}
|