create-cmp-cli 0.11.0 → 0.12.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.
Files changed (40) hide show
  1. package/README.md +11 -9
  2. package/package.json +1 -1
  3. package/src/lib/package-name.mjs +72 -0
  4. package/src/scaffold.mjs +7 -2
  5. package/template/.claude/settings.json +30 -0
  6. package/template/CLAUDE.md +48 -6
  7. package/template/composeApp/build.gradle.kts +44 -0
  8. package/template/composeApp/src/androidDebug/AndroidManifest.xml +9 -0
  9. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/PlatformBehaviorSeamTest.kt +277 -0
  10. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/RuntimeStateSeamTest.kt +308 -0
  11. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/AlarmAsserts.kt +152 -0
  12. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ConfigControl.kt +124 -0
  13. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/DozeControl.kt +113 -0
  14. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NetworkControl.kt +137 -0
  15. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NotificationAsserts.kt +163 -0
  16. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/PermissionControl.kt +132 -0
  17. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ProcessControl.kt +217 -0
  18. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/Shell.kt +79 -0
  19. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/SystemState.kt +113 -0
  20. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/TimeWarp.kt +114 -0
  21. package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +5 -2
  22. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +1 -1
  23. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +1 -1
  24. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +1 -1
  25. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +58 -0
  26. package/template/docs/ARCHITECTURE.md +41 -2
  27. package/template/docs/TESTING.md +165 -0
  28. package/template/gradle/libs.versions.toml +15 -0
  29. package/template/manifest.json +1 -0
  30. package/template/qa/evidence/schema.json +20 -2
  31. package/template/qa/lib/affected-tests.mjs +147 -0
  32. package/template/qa/lib/device-lease.mjs +249 -0
  33. package/template/qa/lib/evidence-level.mjs +117 -0
  34. package/template/qa/lib/spec-coverage.mjs +54 -3
  35. package/template/qa/lib/step-cache.mjs +221 -0
  36. package/template/qa/receipt-check.mjs +22 -2
  37. package/template/qa/scaffold-feature.mjs +20 -1
  38. package/template/qa/verify.mjs +637 -56
  39. package/template/qa/watch.mjs +622 -0
  40. package/template/specs/app-base.spec.md +11 -0
package/README.md CHANGED
@@ -76,7 +76,7 @@ http://127.0.0.1:9500/inspect/remote
76
76
  A self-contained page mirrors the running app (~700ms refresh) and **click-to-tap drives the
77
77
  real thing** — clicks scale to device pixels and dispatch as taps. This is the "two audiences,
78
78
  one app" split at its purest: the human watches and drives real pixels in a browser while the
79
- agent asserts on the semantics tree (`navigate_and_inspect`, `prove_change`, `db_query`). Use
79
+ agent asserts on the semantics tree (`navigate_and_inspect`, `inspect_tree`, `db_query`). Use
80
80
  it to watch an e2e run, demo a feature, or poke at the app without touching the device. No
81
81
  install, no CORS, debug builds only — release builds contain none of this code.
82
82
 
@@ -161,11 +161,11 @@ Same engine as the CLI, conversational front door. Each skill is a guided flow,
161
161
  | `cmp-test` | "Write tests for my app." *Observes* the running app's semantics tree — what's actually on screen, what's tappable, where navigation goes — and derives the regression suite from that. Tests come from rendered reality, not guesses. |
162
162
  | `cmp-qa-prep` | "Get my test environment up." Emulator + app install + E2E smoke run, with the gotchas handled. |
163
163
 
164
- Plus the **`cmp-inspector` MCP server** (26 tools) — the machine-readable window into a running
164
+ Plus the **`cmp-inspector` MCP server** (15 tools — deliberately few; two production apps proved a lean surface gets used and a wide one gets ignored) — the machine-readable window into a running
165
165
  Compose UI that `cmp-inspect`, `cmp-test`, and the verified dev loop are built on. One tree
166
166
  contract, three sources: render a screen headlessly, connect to the live app, or read a device
167
167
  via UIAutomator. It also carries the runtime half of the agent's eyes (crashes, logs, DB state —
168
- `runtime_crashes`, `runtime_logs`, `db_schema`, `db_query`), the human-approval console
168
+ `runtime_crashes`, `runtime_logs`, `db_query`), the human-approval console
169
169
  (`approval_status`, §8 below), the console's talk-back channel (`review_comments`,
170
170
  `resolve_comment`, §9 below), and the genesis walk's design-language workbench
171
171
  (`snapshot_variant`, §8 below).
@@ -313,7 +313,7 @@ scaffolded app carries the whole loop):
313
313
  `./gradlew :composeApp:renderScreens && node qa/preview-gallery.mjs`.
314
314
 
315
315
  **The verified dev loop (the flagship).** For any UI change: snapshot the live tree → make the
316
- edit → reload → `prove_change` compares before/after structure, token drift, and a11y, and returns
316
+ edit → reload → `preview_diff` compares before/after structure, token drift, and a11y, and returns
317
317
  a verdict. The agent doesn't say "I centered the title" — it shows *"title bounds moved, tokens
318
318
  unchanged, no a11y regressions: proven clean."*
319
319
 
@@ -338,11 +338,13 @@ standalone gate. All of it works on any KMP project.
338
338
  generation and mechanical work to sub-agents with self-contained briefs, then **gates every
339
339
  hand-off through the verify lane** before accepting it. Nothing is reported done on prose —
340
340
  only on a receipt.
341
- - **The MCP tools** are how any agent *sees*: `inspect_tree`, `get_node`, `assert_token`,
342
- `layout_gaps`, `diff_against_design_system`, `find_drift`, `snapshot_save`, `snapshot_diff`,
343
- `audit_a11y`, `connect_live`, `navigate_and_inspect`, `render_tree`, `render_screen`,
344
- `prove_change`. Structure in, structure out never pixels in model context. The same eyes
345
- extend to runtime behavior (`runtime_crashes`, `runtime_logs`, `db_schema`, `db_query`), to
341
+ - **The MCP tools** are how any agent *sees*: `inspect_tree` (one tree contract — subtree by
342
+ `testTag`, wireframe rendering, layout-gap reporting as options), `connect_live`
343
+ (self-healing: device forward → health → launch → transport reset), `navigate_and_inspect`,
344
+ `render_screen`, and the preview loop (`preview`, `preview_status`, `preview_diff`).
345
+ Structure in, structure out never pixels in model context. Token drift, a11y, and golden
346
+ regressions are the verify lane's job, not interactive tools. The same eyes
347
+ extend to runtime behavior (`runtime_crashes`, `runtime_logs`, `db_query`), to
346
348
  the human side of the loop (`approval_status`, blocking on a console decision the same way
347
349
  `preview_status` blocks on a render), and to the console's talk-back channel
348
350
  (`review_comments`, `resolve_comment` — the agent observes feedback and closes the loop with a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-cmp-cli",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Create production mobile apps (Android + iOS, one Kotlin codebase) with AI — the delivery harness for Compose Multiplatform, the current generation of cross-platform (Google-backed KMP, iOS stable since May 2025). A deterministic, non-interactive generator that scaffolds a green-building app in minutes, then holds AI-driven changes to a machine-enforced verify lane with a committed evidence receipt. Every app carries a device-free UI preview loop (real screens rendered headlessly on save; changed-screen attribution and compile-error surfacing for coding agents, a live gallery for humans) plus agent-first docs (CLAUDE.md + AGENTS.md). Installs the `create-cmp` command.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,72 @@
1
+ // Semantic validation for the `package` option, beyond what the JSON-schema
2
+ // pattern in options.schema.json can express.
3
+ //
4
+ // The pattern already enforces the SHAPE of a reverse-DNS id (lowercase start,
5
+ // at least two segments, no digit-leading segments). What it cannot encode is
6
+ // that every segment also has to be a legal *Java identifier*: the value
7
+ // becomes the Android namespace and the Kotlin package, and AGP rejects a
8
+ // reserved word outright —
9
+ //
10
+ // Namespace 'com.final.proof' is not a valid Java package name as 'final'
11
+ // is a Java keyword
12
+ //
13
+ // Observed for real: `--package com.final.proof` was accepted, stamped a full
14
+ // project, and only failed at the first Gradle configure — late, and as a raw
15
+ // Gradle stack rather than an input error. Refuse it at the door instead, and
16
+ // name the offending segment.
17
+
18
+ // Java SE reserved words (JLS §3.9), plus the three reserved literals and the
19
+ // lone underscore (reserved since Java 9). `var`/`yield`/`record`/`sealed` and
20
+ // friends are contextual, not reserved — they are legal package segments, so
21
+ // they are deliberately absent.
22
+ export const JAVA_KEYWORDS = new Set([
23
+ "abstract", "assert", "boolean", "break", "byte", "case", "catch", "char",
24
+ "class", "const", "continue", "default", "do", "double", "else", "enum",
25
+ "extends", "final", "finally", "float", "for", "goto", "if", "implements",
26
+ "import", "instanceof", "int", "interface", "long", "native", "new",
27
+ "package", "private", "protected", "public", "return", "short", "static",
28
+ "strictfp", "super", "switch", "synchronized", "this", "throw", "throws",
29
+ "transient", "try", "void", "volatile", "while",
30
+ // reserved literals
31
+ "true", "false", "null",
32
+ // reserved identifier (Java 9+)
33
+ "_",
34
+ ]);
35
+
36
+ /**
37
+ * Segments of `pkg` that cannot be Java identifiers.
38
+ * @param {string} pkg
39
+ * @returns {string[]} offending segments, in source order (may repeat)
40
+ */
41
+ export function reservedSegments(pkg) {
42
+ if (typeof pkg !== "string" || pkg.length === 0) return [];
43
+ return pkg.split(".").filter((seg) => JAVA_KEYWORDS.has(seg));
44
+ }
45
+
46
+ /**
47
+ * Validate the package id's segments. Same error shape as schema.mjs's
48
+ * validate(), so callers can merge the two lists and format them together.
49
+ * @param {string} pkg
50
+ * @param {string} [path] error path label
51
+ * @returns {{ valid: boolean, errors: Array<{path: string, message: string}> }}
52
+ */
53
+ export function validatePackageName(pkg, path = "package") {
54
+ const bad = reservedSegments(pkg);
55
+ if (bad.length === 0) return { valid: true, errors: [] };
56
+ const uniq = [...new Set(bad)];
57
+ const which = uniq.map((s) => `'${s}'`).join(", ");
58
+ const lead = uniq.length > 1
59
+ ? `segments ${which} are Java keywords and cannot be package segments`
60
+ : `segment ${which} is a Java keyword and cannot be a package segment`;
61
+ return {
62
+ valid: false,
63
+ errors: [
64
+ {
65
+ path,
66
+ message:
67
+ `${lead} — Gradle will refuse the namespace. Rename it ` +
68
+ `(e.g. com.final.proof \u2192 com.finalproof).`,
69
+ },
70
+ ],
71
+ };
72
+ }
package/src/scaffold.mjs CHANGED
@@ -18,6 +18,7 @@ import path from "node:path";
18
18
  import { fileURLToPath, pathToFileURL } from "node:url";
19
19
 
20
20
  import { validate, formatErrors } from "./lib/schema.mjs";
21
+ import { validatePackageName } from "./lib/package-name.mjs";
21
22
  import { buildTokenMap, replaceTokens, replacePathTokens, isBinaryPath, slugifyAppName } from "./lib/tokens.mjs";
22
23
  import { renamePackageDirs } from "./lib/rename.mjs";
23
24
  import {
@@ -51,8 +52,12 @@ export function loadSchema(opts = {}) {
51
52
  */
52
53
  export function validateConfig(config, opts = {}) {
53
54
  const schema = loadSchema(opts);
54
- const { valid, errors } = validate(config, schema);
55
- if (!valid) {
55
+ const { errors } = validate(config, schema);
56
+ // The schema pattern proves the SHAPE of the package id; this proves its
57
+ // segments are legal Java identifiers. Merged into one list so a config with
58
+ // both problems reports both at once.
59
+ errors.push(...validatePackageName(config?.package).errors);
60
+ if (errors.length > 0) {
56
61
  const err = new Error(`Invalid config:\n${formatErrors(errors)}`);
57
62
  err.validationErrors = errors;
58
63
  throw err;
@@ -1,5 +1,35 @@
1
1
  {
2
2
  "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "matcher": "",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"This app is governed by its delivery contract (CLAUDE.md): behavior starts in specs/, done is `node qa/verify.mjs` with a committed receipt, approvals gate signed artifacts. The cmp-inspector MCP tools (preview loop, live tier) are the expected eyes — if they are absent from this session, that is a fault to diagnose (plugin disabled, session predates plugin enablement, or stale plugin copy; see cmp-doctor), not a cue to fall back to screenshots or blind adb.\"}}'"
10
+ }
11
+ ]
12
+ }
13
+ ],
14
+ "PreToolUse": [
15
+ {
16
+ "matcher": "Bash",
17
+ "hooks": [
18
+ {
19
+ "type": "command",
20
+ "command": "grep -qE 'screencap|uiautomator dump' && printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"permissionDecisionReason\":\"Reminder: raw pixels/blind taps lose structure. If cmp-inspector is connected, inspect_tree reads the semantic tree and navigate_and_inspect gives verified taps; if its tools are missing, diagnose first (cmp-doctor, Inspector MCP section).\"}}' || true"
21
+ },
22
+ {
23
+ "type": "command",
24
+ "command": "grep -qE 'connected[A-Za-z]*AndroidTest|maestro test|adb (-s [^ ]+ )?(install|uninstall)' && printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"permissionDecisionReason\":\"Reminder: device evidence is lane-owned and batched. node qa/verify.mjs sequences the device steps once, last, under a machine-global per-serial lease (qa/lib/device-lease.mjs) — the one device is scarce, slow, and fragile, so device proof is a checkpoint, never an inner loop. Driving it by hand mid-task risks colliding with a running lane (wedged adbd, device offline, false reds, crossed app state). Ad-hoc debugging stays allowed; batch the evidence into the lane.\"}}' || true"
25
+ },
26
+ {
27
+ "type": "command",
28
+ "command": "in=$(cat); printf '%s' \"$in\" | grep -qE 'node qa/verify\\.mjs' && ! printf '%s' \"$in\" | grep -q -- '--fast' && printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"permissionDecisionReason\":\"Reminder: for inner-loop iteration, node qa/verify.mjs --fast skips the device/release tier (releaseBuild, tokenDrift, e2eSmoke, androidChecks, releaseSmoke) and is much quicker. Run the full lane once, deliberately, before reporting work done — never speculatively, and never to re-confirm a result you already have. A --fast receipt never satisfies the done-gate.\"}}' || true"
29
+ }
30
+ ]
31
+ }
32
+ ],
3
33
  "Stop": [
4
34
  {
5
35
  "matcher": "",
@@ -15,7 +15,10 @@ tests, and gates the whole tree to produce the receipt, so it is slow by design;
15
15
  after every edit wastes the minutes it takes. Iterate on the fast tier, and run the lane
16
16
  once — when you believe the change is done.
17
17
 
18
- - **Inner loop — run continuously (seconds):** the preview loop (below) for UI, and
18
+ - **Inner loop — run continuously (seconds):** the preview loop (below) for UI;
19
+ `node qa/watch.mjs` for verification — a resident watcher that re-runs the fast tier
20
+ (`node qa/verify.mjs --fast`) on every save and re-prints the step table, so the
21
+ did-I-break-anything signal is free the way an IDE's errors-on-save are free; and
19
22
  `./gradlew :composeApp:desktopTest` for the unit tests your change touches. This is where
20
23
  you catch your own mistakes.
21
24
  - **Checkpoint — run once, at done:** `node qa/verify.mjs`. It writes the receipt; commit
@@ -85,9 +88,25 @@ the tree. The governed `architecture` artifact (below) hashes the document along
85
88
  - Never delete, weaken, or `@Ignore` a failing test to reach green. Fix the behavior — or,
86
89
  if the test itself is wrong, say so in your summary and justify the change.
87
90
 
91
+ **Platform behavior tests live in `composeApp/src/androidInstrumentedTest`** — when a
92
+ feature touches alarms, notifications, lock-screen intents, or audio routing, its behavior
93
+ test goes there, because no desktop tier can see those OS facts. Assertion helpers:
94
+ `NotificationAsserts`, `AlarmAsserts`, `SystemState`. **Runtime state control** — put the
95
+ device into the state your claim is about, instead of waiting for it: `TimeWarp` (clock,
96
+ timezone), `DozeControl` (forced idle), `PermissionControl`, `ProcessControl`,
97
+ `NetworkControl`, `ConfigControl` (dark mode, font scale, per-app locale). They compose —
98
+ the exemplar proves an `allowWhileIdle` alarm delivers from inside forced deep idle by
99
+ nesting a clock warp in a Doze bracket. Exemplars: `PlatformBehaviorSeamTest`,
100
+ `RuntimeStateSeamTest`. Each organ's header states what it does NOT reproduce; read it
101
+ before claiming more than it proves. The lane's `androidChecks` step runs them when a
102
+ device is attached; see `docs/TESTING.md`.
103
+
88
104
  ## Evidence
89
105
 
90
106
  `node qa/verify.mjs` writes `qa/evidence/latest.json` (schema: `qa/evidence/schema.json`).
107
+ Each PASS receipt names its **evidence rung** (L0 scaffold / L1 desktop / L2 device /
108
+ L3 release), derived from which steps actually ran and passed — never declared, and a
109
+ SKIPped step never upgrades it (see `docs/TESTING.md` §"The evidence ladder").
91
110
  Commit it with your change; git history is the audit ledger. Binary artifacts under
92
111
  `qa-artifacts/` are hashed into the receipt, never committed. The studio console's Evidence
93
112
  page reconstructs the full audit trail from the git log of `latest.json` — every commit is
@@ -305,16 +324,35 @@ and tells you what your edit changed.
305
324
  3. `preview_diff { screen }` proves the change in one call: `proven-clean` /
306
325
  `changed-with-regressions` / `no-change`. No snapshot bookkeeping.
307
326
 
308
- **Without the plugin:** `./gradlew :composeApp:renderScreens` renders every screen to
309
- `composeApp/build/previews/<id>/{screen.png, tree.json}` (`-Pscreen=<id>` for one);
310
- `node qa/preview-gallery.mjs` builds a self-contained gallery page from the output.
327
+ **If the tools are missing:** capability absence is a fault to diagnose and report — never
328
+ a silent fallback. If ToolSearch finds no `cmp-inspector` tools, STOP and tell the human
329
+ which it is: the plugin is disabled (`enabledPlugins` in `~/.claude/settings.json` or the
330
+ project settings); the session predates the plugin's enablement (MCP servers attach at
331
+ session start — restart the session; no in-session retry will surface them); or the plugin
332
+ copy is stale/broken (run cmp-doctor's inspector-MCP check group). Report before degrading.
333
+
334
+ **Degraded path** — for environments where the plugin is genuinely unavailable (CI, other
335
+ agents), and only after the fault is reported: `./gradlew :composeApp:renderScreens` renders
336
+ every screen to `composeApp/build/previews/<id>/{screen.png, tree.json}` (`-Pscreen=<id>`
337
+ for one); `node qa/preview-gallery.mjs` builds a self-contained gallery page from the
338
+ output. What this loses: on-save re-render, changed-screen attribution, compile errors
339
+ in-band, and the `preview_diff` change proof — structured feedback replaced by pixels.
311
340
 
312
341
  **Live tier — the human's live device view (standing step).** Whenever `connect_live`
313
342
  succeeds, OFFER the `remoteUrl` it returns (`http://127.0.0.1:9500/inspect/remote`) to the
314
343
  human — every time, not as a maybe. It is a self-contained browser page that mirrors the
315
344
  running app (~700ms refresh) with click-to-tap driving the real device: they watch and drive
316
- the actual app while you assert on the tree (`navigate_and_inspect` / `prove_change` /
317
- `db_query`). It is also the right way for a human to *watch* an e2e run.
345
+ the actual app while you assert on the tree (`navigate_and_inspect` its before/after delta
346
+ is the change proof live — and `inspect_tree`). It is also the right way for a human to
347
+ *watch* an e2e run.
348
+
349
+ Asserting persisted state: `db_query` reads bounded rows from the running app's database;
350
+ use it when a flow's proof is a row existing (or not) after an action, instead of shelling
351
+ into sqlite or trusting the UI.
352
+
353
+ When the app crashes or misbehaves on device: `runtime_crashes` returns persisted crashes
354
+ with cause attribution and `runtime_logs` bounded structured logcat for the app's pid; use
355
+ these before hand-grepping `adb logcat`.
318
356
 
319
357
  Screens come from `inspector/PreviewRegistry.kt` (desktopMain). The `add-feature` and
320
358
  `add-screen` stampers auto-register stamped screens at the `// cmp:anchor preview-registry`
@@ -342,8 +380,12 @@ conventions) · [`CONTRIBUTING.md`](./CONTRIBUTING.md) (workflow, Conventional C
342
380
  | Command | What |
343
381
  |---|---|
344
382
  | `node qa/verify.mjs` | The verify lane (profile `local`) — the done checkpoint, run once |
383
+ | `node qa/verify.mjs --fast` | **Inner loop — NOT the done-gate**: skips the device/release tier (`releaseBuild`, `tokenDrift`, `e2eSmoke`, `androidChecks`, `releaseSmoke`), reuses unchanged pure-Node step results (`CACHED`, content-hashed inputs), and scopes unit tests to the working-tree change (broad-impact changes — build files, DI, theme, shared components, `qa/` — run the full suite). Its receipt records `"mode": "fast"`, earns no evidence rung, and the Stop hook refuses it — run the full lane once at done |
384
+ | `node qa/watch.mjs` | **Resident inner loop — never a gate**: watches `composeApp/src`, `specs/`, `qa/` and re-runs `node qa/verify.mjs --fast` on save (debounced — a save storm is one run; defers while a verify lane or a preview render holds the project). `--once` for a single pass, `--json` for line-per-run output. The done-gate stays one deliberate full `node qa/verify.mjs` run |
345
385
  | `./gradlew :composeApp:desktopTest` | Unit tests only (fast inner loop) |
346
386
  | `node qa/setup-hooks.mjs` | Enable the pre-push receipt gate (one-time, after `git init`) |
347
387
  | `./gradlew :composeApp:assembleDebug` | Android debug build |
348
388
  | `./gradlew :composeApp:assembleRelease` | Android release build — R8 + `lintVital`, the variant the lane's `releaseBuild` step proves. Produces an **unsigned** APK; signing needs a keystore, which is yours to create and keep out of the repo. |
349
389
  | `./gradlew :composeApp:hotRunDesktop --auto` | Desktop dev-client with hot reload |
390
+ | `./gradlew :composeApp:connectedDebugAndroidTest` | Instrumented behavior tests on the attached device (the lane's `androidChecks` step) |
391
+ | `node qa/verify.mjs --profile release` | Ship-time lane: everything `ci` proves plus the release-APK Maestro smoke (`releaseSmoke`) |
@@ -161,6 +161,24 @@ kotlin {
161
161
  implementation(compose.desktop.currentOs)
162
162
  }
163
163
  }
164
+
165
+ // Android INSTRUMENTATION tier (composeApp/src/androidInstrumentedTest) — the one
166
+ // evidence tier that crosses the process boundary. Everything else in this build is
167
+ // JVM-side: desktopTest is a JVM, golden trees are structure, the conformance suite
168
+ // is static analysis, and the Maestro smoke taps UI without asserting anything about
169
+ // notifications or alarms. Alarms, notification channels, full-screen intents,
170
+ // PendingIntent identity, and audio routing are OS facts that only exist on a device
171
+ // — a fully green desktop lane is compatible with an alerting feature that never
172
+ // alerts. This source set runs via `:composeApp:connectedDebugAndroidTest` (the
173
+ // lane's `androidChecks` step; SKIPs honestly when no device is attached).
174
+ //
175
+ // Its dependencies are declared through AGP's androidTestImplementation
176
+ // configuration (the dependencies block at the bottom of this file), not here: the
177
+ // KMP source-set DSL compiles these sources but does not put the androidx.test
178
+ // artifacts on their classpath.
179
+ //
180
+ // No kotlin-test here: instrumented tests run under JUnit4 (AndroidJUnit4), so they
181
+ // assert with org.junit.Assert — a second assertion vocabulary buys nothing.
164
182
  }
165
183
  }
166
184
 
@@ -174,6 +192,9 @@ android {
174
192
  targetSdk = 35
175
193
  versionCode = 1
176
194
  versionName = "1.0.0"
195
+ // Instrumentation entry point for the on-device behavior tier
196
+ // (composeApp/src/androidInstrumentedTest — see the source-set note above).
197
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
177
198
  }
178
199
 
179
200
  packaging {
@@ -182,6 +203,20 @@ android {
182
203
  }
183
204
  }
184
205
 
206
+ // AGP resolves BUILD-TYPE source sets at src/<buildType>/, while the Kotlin
207
+ // Multiplatform plugin only remaps the `main` one to src/androidMain/. Without this
208
+ // wiring, src/androidDebug/'s manifest and resources are silently never merged —
209
+ // dead files that look live: the debug network-security config never applied, and a
210
+ // permission declared there never reached the APK. Point the debug build type at them
211
+ // explicitly. Caught when an instrumented test asserted canScheduleExactAlarms() and
212
+ // found the grant it had declared was absent on the device.
213
+ sourceSets {
214
+ getByName("debug") {
215
+ manifest.srcFile("src/androidDebug/AndroidManifest.xml")
216
+ res.srcDirs("src/androidDebug/res")
217
+ }
218
+ }
219
+
185
220
  buildFeatures {
186
221
  buildConfig = true
187
222
  }
@@ -269,6 +304,15 @@ dependencies {
269
304
  add("kspDesktop", libs.room.compiler)
270
305
  // <<< cmp:feature room
271
306
  add("coreLibraryDesugaring", libs.android.desugar.jdk)
307
+ // The instrumentation tier's runner, JUnit4 harness, and device helpers. Declared
308
+ // through AGP's own configuration rather than the KMP androidInstrumentedTest
309
+ // source-set block — the latter compiles the sources but does not put these
310
+ // artifacts on their classpath.
311
+ add("androidTestImplementation", libs.androidx.test.runner)
312
+ add("androidTestImplementation", libs.androidx.test.core)
313
+ add("androidTestImplementation", libs.androidx.test.ext.junit)
314
+ add("androidTestImplementation", libs.androidx.uiautomator)
315
+ add("androidTestImplementation", libs.junit4)
272
316
  }
273
317
 
274
318
  // Pin the generated resources accessor package so `__PACKAGE__.generated.resources.Res`
@@ -7,6 +7,15 @@
7
7
  <uses-permission android:name="android.permission.INTERNET" />
8
8
  <!-- <<< cmp:feature inspector -->
9
9
 
10
+ <!-- The instrumented-tier exact-alarm proof (PlatformBehaviorSeamTest, TimeWarp) needs
11
+ setExactAndAllowWhileIdle to actually schedule. API 31/32 gate it behind
12
+ SCHEDULE_EXACT_ALARM, which the system grants by default there; API 33+ gates it
13
+ behind USE_EXACT_ALARM, granted at install. Neither belongs in the shipped
14
+ manifest — a real app that wants exact alarms declares that choice itself; this is
15
+ the test harness's own permission, debug-only. -->
16
+ <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
17
+ <uses-permission android:name="android.permission.USE_EXACT_ALARM" />
18
+
10
19
  <application
11
20
  android:networkSecurityConfig="@xml/debug_network_security_config"
12
21
  android:usesCleartextTraffic="true" />