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.
- package/README.md +11 -9
- package/package.json +1 -1
- package/src/lib/package-name.mjs +72 -0
- package/src/scaffold.mjs +7 -2
- package/template/.claude/settings.json +30 -0
- package/template/CLAUDE.md +48 -6
- package/template/composeApp/build.gradle.kts +44 -0
- package/template/composeApp/src/androidDebug/AndroidManifest.xml +9 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/PlatformBehaviorSeamTest.kt +277 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/RuntimeStateSeamTest.kt +308 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/AlarmAsserts.kt +152 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ConfigControl.kt +124 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/DozeControl.kt +113 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NetworkControl.kt +137 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NotificationAsserts.kt +163 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/PermissionControl.kt +132 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ProcessControl.kt +217 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/Shell.kt +79 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/SystemState.kt +113 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/TimeWarp.kt +114 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +5 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +1 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +1 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +1 -1
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +58 -0
- package/template/docs/ARCHITECTURE.md +41 -2
- package/template/docs/TESTING.md +165 -0
- package/template/gradle/libs.versions.toml +15 -0
- package/template/manifest.json +1 -0
- package/template/qa/evidence/schema.json +20 -2
- package/template/qa/lib/affected-tests.mjs +147 -0
- package/template/qa/lib/device-lease.mjs +249 -0
- package/template/qa/lib/evidence-level.mjs +117 -0
- package/template/qa/lib/spec-coverage.mjs +54 -3
- package/template/qa/lib/step-cache.mjs +221 -0
- package/template/qa/receipt-check.mjs +22 -2
- package/template/qa/scaffold-feature.mjs +20 -1
- package/template/qa/verify.mjs +637 -56
- package/template/qa/watch.mjs +622 -0
- package/template/specs/app-base.spec.md +11 -0
package/template/qa/verify.mjs
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// The verify lane — this project's single verification gate.
|
|
3
3
|
//
|
|
4
|
-
// node qa/verify.mjs [--profile scaffold|local|ci] [--json]
|
|
4
|
+
// node qa/verify.mjs [--profile scaffold|local|ci|release] [--fast] [--json]
|
|
5
5
|
//
|
|
6
6
|
// Runs every verification step this project carries, aggregates a typed
|
|
7
7
|
// PASS/FAIL verdict, and writes the evidence receipt to qa/evidence/latest.json.
|
|
8
|
+
// `--fast` is the INNER LOOP: the resolved profile minus the device/release
|
|
9
|
+
// tier, with unchanged pure-Node steps reused from the step cache (CACHED)
|
|
10
|
+
// and unit tests scoped to the working-tree change — its receipt records
|
|
11
|
+
// mode "fast" and can never satisfy the done-gate.
|
|
8
12
|
// The receipt is COMMITTED with your change (see CLAUDE.md — a change is not
|
|
9
13
|
// done without it). Binary artifacts under qa-artifacts/ are never committed;
|
|
10
14
|
// the receipt references them by path + sha256.
|
|
@@ -17,6 +21,8 @@
|
|
|
17
21
|
// scaffold — spec coverage + build + unit tests (what `create-cmp --verify` proves at stamp time)
|
|
18
22
|
// local — everything; device-dependent steps SKIP when no device is attached
|
|
19
23
|
// ci — everything; SKIPs are recorded so the pipeline stays honest
|
|
24
|
+
// release — everything ci proves PLUS the release-APK smoke (releaseSmoke): the
|
|
25
|
+
// ship-time profile, run before cutting a release, never per-change
|
|
20
26
|
|
|
21
27
|
import { execSync, spawnSync } from "node:child_process";
|
|
22
28
|
import { createHash } from "node:crypto";
|
|
@@ -27,9 +33,13 @@ import { fileURLToPath } from "node:url";
|
|
|
27
33
|
import { computeInputsHash } from "./lib/inputs-hash.mjs";
|
|
28
34
|
import { compareTokenDrift } from "./lib/token-drift.mjs";
|
|
29
35
|
import { evaluateApprovalsGate } from "./lib/approvals.mjs";
|
|
30
|
-
import { scanCitations, scanSpecClauses, walkFiles } from "./lib/spec-coverage.mjs";
|
|
36
|
+
import { clauseTierCoverage, scanCitations, scanSpecClauses, walkFiles } from "./lib/spec-coverage.mjs";
|
|
31
37
|
import { evaluateComponentStoryParity } from "./lib/component-stories.mjs";
|
|
32
38
|
import { evaluateReachability } from "./lib/reachability.mjs";
|
|
39
|
+
import { evidenceLevel } from "./lib/evidence-level.mjs";
|
|
40
|
+
import { memoizeStep } from "./lib/step-cache.mjs";
|
|
41
|
+
import { changedWorkingTreePaths, deriveAffectedFilter } from "./lib/affected-tests.mjs";
|
|
42
|
+
import { acquireDeviceLease, releaseDeviceLease, formatHolder } from "./lib/device-lease.mjs";
|
|
33
43
|
import { ARCH_DOC_REL_PATH, SECTION_IDS, regenerateArchDoc } from "./lib/arch-doc.mjs";
|
|
34
44
|
|
|
35
45
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -42,7 +52,7 @@ const ARTIFACTS_DIR = path.join(ROOT, "qa-artifacts");
|
|
|
42
52
|
// killed). Same refusal-over-fabrication stance as qa/approve.mjs, which
|
|
43
53
|
// refuses an unknown artifact by name rather than guessing: an unknown
|
|
44
54
|
// argument here is refused by name, not swallowed into "run everything".
|
|
45
|
-
const USAGE = `node qa/verify.mjs [--profile scaffold|local|ci] [--json] [--help]
|
|
55
|
+
const USAGE = `node qa/verify.mjs [--profile scaffold|local|ci|release] [--fast] [--json] [--help]
|
|
46
56
|
|
|
47
57
|
The verify lane — this project's single verification gate. Runs every
|
|
48
58
|
verification step this project carries, aggregates a typed PASS/FAIL
|
|
@@ -50,7 +60,22 @@ verdict, and writes the evidence receipt to qa/evidence/latest.json (commit
|
|
|
50
60
|
it with your change — see CLAUDE.md). Exit code: 0 = PASS, 1 = FAIL.
|
|
51
61
|
|
|
52
62
|
Flags:
|
|
53
|
-
--profile <scaffold|local|ci>
|
|
63
|
+
--profile <scaffold|local|ci|release>
|
|
64
|
+
which step set to run (default: local)
|
|
65
|
+
--fast INNER LOOP ONLY — run the resolved profile
|
|
66
|
+
minus the device/release tier (releaseBuild,
|
|
67
|
+
tokenDrift, e2eSmoke, androidChecks,
|
|
68
|
+
releaseSmoke), unconditionally, device
|
|
69
|
+
attached or not. Also reuses the pure-Node
|
|
70
|
+
steps' last PASS when their inputs are
|
|
71
|
+
unchanged (verdict CACHED), lets Gradle's
|
|
72
|
+
up-to-date checks stand (no --rerun), and
|
|
73
|
+
scopes unit tests to the working-tree change
|
|
74
|
+
(broad-impact changes run everything). The
|
|
75
|
+
receipt records mode "fast", derives no
|
|
76
|
+
evidence rung, and can NEVER satisfy the
|
|
77
|
+
done-gate — run the full lane once before
|
|
78
|
+
you call it done
|
|
54
79
|
--json print the receipt as JSON instead of the
|
|
55
80
|
human-readable step-by-step log
|
|
56
81
|
--help, -h print this usage and exit 0 without
|
|
@@ -62,6 +87,9 @@ Profiles:
|
|
|
62
87
|
local everything; device-dependent steps SKIP when no device is
|
|
63
88
|
attached
|
|
64
89
|
ci everything; SKIPs are recorded so the pipeline stays honest
|
|
90
|
+
release everything ci proves PLUS the release-APK smoke (releaseSmoke) —
|
|
91
|
+
the ship-time profile; run it before cutting a release, never
|
|
92
|
+
per-change
|
|
65
93
|
`;
|
|
66
94
|
|
|
67
95
|
const rawArgs = process.argv.slice(2);
|
|
@@ -71,7 +99,7 @@ if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
|
|
|
71
99
|
process.exit(0);
|
|
72
100
|
}
|
|
73
101
|
|
|
74
|
-
const RECOGNIZED_FLAGS = new Set(["--profile", "--json"]);
|
|
102
|
+
const RECOGNIZED_FLAGS = new Set(["--profile", "--json", "--fast"]);
|
|
75
103
|
for (let i = 0; i < rawArgs.length; i += 1) {
|
|
76
104
|
const arg = rawArgs[i];
|
|
77
105
|
if (arg === "--profile") {
|
|
@@ -86,9 +114,23 @@ for (let i = 0; i < rawArgs.length; i += 1) {
|
|
|
86
114
|
const args = rawArgs;
|
|
87
115
|
const profile = args.includes("--profile") ? args[args.indexOf("--profile") + 1] : "local";
|
|
88
116
|
const asJson = args.includes("--json");
|
|
117
|
+
const fast = args.includes("--fast");
|
|
118
|
+
const mode = fast ? "fast" : "full";
|
|
89
119
|
|
|
90
120
|
const GRADLEW = process.platform === "win32" ? "gradlew.bat" : "./gradlew";
|
|
91
121
|
|
|
122
|
+
// ── `--rerun` is scoped to FULL mode ────────────────────────────────────────
|
|
123
|
+
// `--rerun` exists for evidence integrity (see stepUnitTests's comment): it
|
|
124
|
+
// stops Gradle's build cache replaying a PASS recorded against a different
|
|
125
|
+
// tree into a receipt that claims tests executed. That mechanism belongs to
|
|
126
|
+
// the runs that produce integrity-bearing artifacts — and a --fast run does
|
|
127
|
+
// not: its receipt already declares itself non-evidence (mode "fast", no
|
|
128
|
+
// evidence rung, refused by qa/receipt-check.mjs), so forcing execution there
|
|
129
|
+
// paid an integrity tax to protect an artifact with nothing to protect. Fast
|
|
130
|
+
// mode therefore omits the flag and lets Gradle's up-to-date/cache machinery
|
|
131
|
+
// do its job; full mode keeps it, byte-identical to before.
|
|
132
|
+
const RERUN = fast ? "" : " --rerun";
|
|
133
|
+
|
|
92
134
|
function sh(cmd, opts = {}) {
|
|
93
135
|
const started = Date.now();
|
|
94
136
|
// maxBuffer: first-run Gradle output easily exceeds spawnSync's 1MB default,
|
|
@@ -182,19 +224,31 @@ function tryGitLines(cmd) {
|
|
|
182
224
|
}
|
|
183
225
|
}
|
|
184
226
|
|
|
227
|
+
// Recursive: desktopTest writes TEST-*.xml flat, but connected (instrumented) results
|
|
228
|
+
// land one directory level down per device (build/outputs/androidTest-results/connected/
|
|
229
|
+
// debug/<device>/TEST-*.xml) — both shapes are summarized by the same walk.
|
|
185
230
|
function junitSummary(dir) {
|
|
186
231
|
if (!fs.existsSync(dir)) return null;
|
|
187
232
|
let tests = 0, failures = 0, errors = 0, skipped = 0;
|
|
188
|
-
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
233
|
+
const walk = (d) => {
|
|
234
|
+
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
|
235
|
+
const p = path.join(d, entry.name);
|
|
236
|
+
if (entry.isDirectory()) {
|
|
237
|
+
walk(p);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (!entry.name.startsWith("TEST-") || !entry.name.endsWith(".xml")) continue;
|
|
241
|
+
const xml = fs.readFileSync(p, "utf8");
|
|
242
|
+
const m = xml.match(/<testsuite[^>]*tests="(\d+)"[^>]*skipped="(\d+)"[^>]*failures="(\d+)"[^>]*errors="(\d+)"/);
|
|
243
|
+
if (m) {
|
|
244
|
+
tests += Number(m[1]);
|
|
245
|
+
skipped += Number(m[2]);
|
|
246
|
+
failures += Number(m[3]);
|
|
247
|
+
errors += Number(m[4]);
|
|
248
|
+
}
|
|
196
249
|
}
|
|
197
|
-
}
|
|
250
|
+
};
|
|
251
|
+
walk(dir);
|
|
198
252
|
return { tests, failures, errors, skipped };
|
|
199
253
|
}
|
|
200
254
|
|
|
@@ -204,6 +258,103 @@ function deviceAttached() {
|
|
|
204
258
|
return res.out.split("\n").slice(1).some((l) => /\tdevice$/.test(l.trim().replace(/\s+/g, "\t")));
|
|
205
259
|
}
|
|
206
260
|
|
|
261
|
+
// ── The machine-global device lease (qa/lib/device-lease.mjs) ───────────────
|
|
262
|
+
// LANE_MARKER above is per-PROJECT; the device is machine-GLOBAL. A scratch app
|
|
263
|
+
// in /tmp and the real app each stamp their own marker and still share the one
|
|
264
|
+
// emulator — nothing stopped two lanes (or a lane and a live console session)
|
|
265
|
+
// driving it at once, which is the observed wedged-adbd / `device offline` /
|
|
266
|
+
// crossed-app-state failure class. Every device-touching step below takes the
|
|
267
|
+
// lease before touching the device.
|
|
268
|
+
//
|
|
269
|
+
// SCOPE DECISION — once per run, not per step: the lease is acquired lazily by
|
|
270
|
+
// the FIRST device step that actually reaches the device and held until the
|
|
271
|
+
// lane exits (released in the same `finally` as LANE_MARKER). A single run must
|
|
272
|
+
// not thrash acquire/release between adjacent device steps, and holding through
|
|
273
|
+
// the desktop steps interleaved among them (a11y sits between tokenDrift and
|
|
274
|
+
// e2eSmoke) costs nothing — nothing else should drive the device mid-lane
|
|
275
|
+
// anyway, which is the whole point.
|
|
276
|
+
//
|
|
277
|
+
// ON CONTENTION THE STEP RETURNS SKIP — NEVER FAIL: nothing is broken; another
|
|
278
|
+
// run legitimately holds the device. This composes with the evidence ladder
|
|
279
|
+
// (qa/lib/evidence-level.mjs): a SKIPped device step simply does not buy its
|
|
280
|
+
// rung, so contention visibly DEGRADES the evidence level (L2 falls back to L1)
|
|
281
|
+
// instead of corrupting the run with a false red — that degradation being
|
|
282
|
+
// honest and visible is exactly why SKIP is the right verdict.
|
|
283
|
+
let laneDeviceLease = null;
|
|
284
|
+
|
|
285
|
+
/** Serials of devices currently in `device` state (same parse as deviceAttached). */
|
|
286
|
+
function attachedDeviceSerials() {
|
|
287
|
+
const res = sh("adb devices", { timeout: 10_000 });
|
|
288
|
+
if (!res.ok) return [];
|
|
289
|
+
return res.out
|
|
290
|
+
.split("\n")
|
|
291
|
+
.slice(1)
|
|
292
|
+
.map((l) => l.trim())
|
|
293
|
+
.filter(Boolean)
|
|
294
|
+
.map((l) => l.split(/\s+/))
|
|
295
|
+
.filter(([, state]) => state === "device")
|
|
296
|
+
.map(([serial]) => serial);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Acquire (or confirm) the lane's device lease for a device step.
|
|
301
|
+
* Returns null when the lane holds the device; otherwise the SKIP result the
|
|
302
|
+
* step should return verbatim. The serial leased is the one the lane will
|
|
303
|
+
* actually drive: the single attached device, or ANDROID_SERIAL when several
|
|
304
|
+
* are attached (adb/Gradle/Maestro honor the same variable). Ambiguity is
|
|
305
|
+
* SKIPped by name — leasing a guess would protect the wrong device.
|
|
306
|
+
*/
|
|
307
|
+
function leaseDeviceForStep(stepName) {
|
|
308
|
+
if (laneDeviceLease) return null; // already held for this run
|
|
309
|
+
const serials = attachedDeviceSerials();
|
|
310
|
+
if (serials.length === 0) return null; // each step's own guard SKIPs "no device" with its precise reason
|
|
311
|
+
let serial = serials[0];
|
|
312
|
+
if (serials.length > 1) {
|
|
313
|
+
const chosen = process.env.ANDROID_SERIAL;
|
|
314
|
+
if (chosen && serials.includes(chosen)) {
|
|
315
|
+
serial = chosen;
|
|
316
|
+
} else {
|
|
317
|
+
return {
|
|
318
|
+
name: stepName,
|
|
319
|
+
verdict: "SKIP",
|
|
320
|
+
reason: `${serials.length} devices attached (${serials.join(", ")}) — the lane cannot tell which one it would drive, so it leases none rather than guessing. Set ANDROID_SERIAL to the device this lane should own, or detach the extras.`,
|
|
321
|
+
durationMs: 0,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const res = acquireDeviceLease({ serial, holder: `verify lane ${stepName}`, root: ROOT });
|
|
326
|
+
if (!res.ok) {
|
|
327
|
+
return {
|
|
328
|
+
name: stepName,
|
|
329
|
+
verdict: "SKIP",
|
|
330
|
+
reason: `device ${serial} is held by ${formatHolder(res.heldBy)} — device evidence is batched, not concurrent; wait for it or run once when it finishes`,
|
|
331
|
+
durationMs: 0,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
if (res.reclaimed) {
|
|
335
|
+
console.error(`· reclaimed a dead device lease on ${serial} (was ${formatHolder(res.reclaimed)})`);
|
|
336
|
+
}
|
|
337
|
+
laneDeviceLease = res.handle;
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Settle adb before handing the device to whatever drives it next (Maestro, the
|
|
342
|
+
// instrumented runner). An install task returning 0 means the package manager accepted
|
|
343
|
+
// the APK — NOT that the device is ready to be driven: a reinstall over a running app
|
|
344
|
+
// briefly drops the emulator's adb transport. `adb devices` still says `device`, but a
|
|
345
|
+
// fresh adb client (Maestro's dadb, Gradle's ddmlib) gets `device offline` and dies
|
|
346
|
+
// before the first assertion (observed 4/4 when the live-inspector tier ran earlier in
|
|
347
|
+
// the lane — its port-forward traffic widens the window — and 0/4 when it was skipped).
|
|
348
|
+
// wait-for-device blocks only while the transport is actually down; the kill/start pair
|
|
349
|
+
// ahead of it clears a stale server-side transport entry that survives the device coming
|
|
350
|
+
// back. Neither weakens any assertion — every downstream check still passes on its own
|
|
351
|
+
// merits.
|
|
352
|
+
function settleAdb() {
|
|
353
|
+
sh("adb kill-server");
|
|
354
|
+
sh("adb start-server");
|
|
355
|
+
sh("adb wait-for-device");
|
|
356
|
+
}
|
|
357
|
+
|
|
207
358
|
// ── Steps ──────────────────────────────────────────────────────────────────
|
|
208
359
|
// Each returns { name, verdict, reason?, durationMs, details? }. Failure
|
|
209
360
|
// reasons are worded for an AI collaborator to act on.
|
|
@@ -229,6 +380,12 @@ function stepSpecCoverage() {
|
|
|
229
380
|
const orphanTags = tags.filter((t) => !clauses.has(t.id) || clauses.get(t.id).withdrawn);
|
|
230
381
|
|
|
231
382
|
if (orphanClauses.length === 0 && orphanTags.length === 0) {
|
|
383
|
+
// Tier visibility, not a gate (industry rule: instrument before you police). A clause
|
|
384
|
+
// cited only from desktop-tier tests can still hide a platform-behavior bug — both
|
|
385
|
+
// production apps shipped alarm/notification defects behind clauses that were
|
|
386
|
+
// "covered" by JVM tests androidMain never ran under. The line names them; the
|
|
387
|
+
// instrumented seam (androidChecks) is where such clauses earn a citation.
|
|
388
|
+
const tiers = clauseTierCoverage(clauses, tags);
|
|
232
389
|
return {
|
|
233
390
|
name: "specCoverage",
|
|
234
391
|
verdict: "PASS",
|
|
@@ -238,6 +395,7 @@ function stepSpecCoverage() {
|
|
|
238
395
|
withdrawn: [...clauses.values()].filter((c) => c.withdrawn).length,
|
|
239
396
|
tags: tags.length,
|
|
240
397
|
files: files.length,
|
|
398
|
+
tierNote: tiers.summaryLine,
|
|
241
399
|
},
|
|
242
400
|
};
|
|
243
401
|
}
|
|
@@ -358,6 +516,82 @@ function stepArchDoc() {
|
|
|
358
516
|
};
|
|
359
517
|
}
|
|
360
518
|
|
|
519
|
+
// Schema-history gate — pure Node + git, no Gradle, same grouping as the other
|
|
520
|
+
// evidence checks. Room's exportSchema writes one <version>.json per database per
|
|
521
|
+
// target under composeApp/schemas/. Every version EXCEPT the current highest is a
|
|
522
|
+
// frozen historical record of a database that shipped: migrations are written and
|
|
523
|
+
// validated against those exact bytes, so a regeneration that rewrites them
|
|
524
|
+
// silently corrupts the baseline every future migration is proven against. Only
|
|
525
|
+
// the highest version is the live, in-progress schema — free to change or appear
|
|
526
|
+
// (that IS the current change). This gate exists because schema regeneration
|
|
527
|
+
// looks like harmless build output right up until a shipped user's upgrade fails.
|
|
528
|
+
function stepSchemaHistory() {
|
|
529
|
+
const started = Date.now();
|
|
530
|
+
const elapsed = () => Date.now() - started;
|
|
531
|
+
const schemasRel = path.join("composeApp", "schemas");
|
|
532
|
+
const schemasRoot = path.join(ROOT, schemasRel);
|
|
533
|
+
|
|
534
|
+
if (!fs.existsSync(schemasRoot)) {
|
|
535
|
+
return { name: "schemaHistory", verdict: "SKIP", reason: "no exported Room schemas (composeApp/schemas/ absent) — nothing frozen to guard", durationMs: elapsed() };
|
|
536
|
+
}
|
|
537
|
+
const gitTop = tryGit("rev-parse --show-toplevel");
|
|
538
|
+
if (!gitTop || !tryGit("rev-parse HEAD")) {
|
|
539
|
+
return { name: "schemaHistory", verdict: "SKIP", reason: "no git history yet — schema versions have no committed baseline to be frozen against", durationMs: elapsed() };
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Every directory holding versioned schema JSONs, with its highest version on disk.
|
|
543
|
+
const versionFile = /^(\d+)\.json$/;
|
|
544
|
+
const maxVersionByDir = new Map(); // absolute dir path -> highest N among its N.json files
|
|
545
|
+
const walkSchemas = (dir) => {
|
|
546
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
547
|
+
const p = path.join(dir, entry.name);
|
|
548
|
+
if (entry.isDirectory()) walkSchemas(p);
|
|
549
|
+
else {
|
|
550
|
+
const m = entry.name.match(versionFile);
|
|
551
|
+
if (m) maxVersionByDir.set(dir, Math.max(maxVersionByDir.get(dir) ?? 0, Number(m[1])));
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
walkSchemas(schemasRoot);
|
|
556
|
+
|
|
557
|
+
// Tracked schema files whose committed bytes no longer match the tree (staged or
|
|
558
|
+
// unstaged; deletions included). Paths come back relative to the git toplevel.
|
|
559
|
+
// Untracked files never appear here — a brand-new version file is by definition
|
|
560
|
+
// not yet frozen history.
|
|
561
|
+
const dirtyFiles = tryGitLines(`diff --name-only HEAD -- "${schemasRel.replace(/\\/g, "/")}"`);
|
|
562
|
+
|
|
563
|
+
const violations = [];
|
|
564
|
+
for (const rel of dirtyFiles) {
|
|
565
|
+
const abs = path.resolve(gitTop, rel);
|
|
566
|
+
const m = path.basename(abs).match(versionFile);
|
|
567
|
+
if (!m) continue; // not a versioned schema JSON
|
|
568
|
+
const version = Number(m[1]);
|
|
569
|
+
const dirMax = maxVersionByDir.get(path.dirname(abs));
|
|
570
|
+
// The highest version currently on disk is the live schema — dirty is fine.
|
|
571
|
+
// Anything else (a lower version, or a file whose whole directory is gone)
|
|
572
|
+
// is rewritten/deleted history.
|
|
573
|
+
if (dirMax !== undefined && version === dirMax) continue;
|
|
574
|
+
violations.push(rel);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (violations.length === 0) {
|
|
578
|
+
return { name: "schemaHistory", verdict: "PASS", durationMs: elapsed(), details: { schemaDirs: maxVersionByDir.size } };
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const lines = [
|
|
582
|
+
"Historical Room schema files were modified or deleted — these are frozen records of shipped databases, and regeneration must never rewrite them (migrations are validated against these exact bytes). Only the current highest version may change. Restore each file:",
|
|
583
|
+
];
|
|
584
|
+
for (const rel of violations) lines.push(` git checkout -- ${rel}`);
|
|
585
|
+
lines.push("If you intended a schema change, bump the database version so a NEW <version>.json is exported instead of overwriting history.");
|
|
586
|
+
return {
|
|
587
|
+
name: "schemaHistory",
|
|
588
|
+
verdict: "FAIL",
|
|
589
|
+
reason: lines.join("\n"),
|
|
590
|
+
durationMs: elapsed(),
|
|
591
|
+
details: { schemaDirs: maxVersionByDir.size, violations },
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
361
595
|
function stepBuild() {
|
|
362
596
|
const res = shGradle(`${GRADLEW} :composeApp:assembleDebug --console=plain`);
|
|
363
597
|
return {
|
|
@@ -398,9 +632,12 @@ function stepReleaseBuild() {
|
|
|
398
632
|
// Runs a filtered slice of the JVM test tier and names the verdict after the gate it proves.
|
|
399
633
|
// The full suite already ran in unitTests; the filtered slices stay cheap (compilation is
|
|
400
634
|
// cached) while `--rerun` forces the tests themselves to EXECUTE — see stepUnitTests.
|
|
635
|
+
// In fast mode the flag is omitted (RERUN, defined with the mode flags above): the
|
|
636
|
+
// integrity mechanism belongs to the runs that produce integrity-bearing artifacts, and a
|
|
637
|
+
// fast receipt has already declared itself non-evidence.
|
|
401
638
|
function gradleTestStep(name, testsFilter, failHint) {
|
|
402
639
|
return () => {
|
|
403
|
-
const res = shGradle(`${GRADLEW} :composeApp:desktopTest --
|
|
640
|
+
const res = shGradle(`${GRADLEW} :composeApp:desktopTest${RERUN} --tests "${testsFilter}" --console=plain`);
|
|
404
641
|
return {
|
|
405
642
|
name,
|
|
406
643
|
verdict: res.ok ? "PASS" : "FAIL",
|
|
@@ -417,17 +654,61 @@ function stepUnitTests() {
|
|
|
417
654
|
// restore a PASS recorded against a *different* tree state (deterministic re-scaffolds
|
|
418
655
|
// produce byte-identical sources, and golden baselines aren't compile inputs), so the
|
|
419
656
|
// receipt would attest tests that never executed. Compilation stays cached — only the
|
|
420
|
-
// test execution is forced.
|
|
421
|
-
|
|
657
|
+
// test execution is forced. Scoped to FULL mode (see RERUN above): the integrity
|
|
658
|
+
// mechanism belongs to the runs that produce integrity-bearing artifacts, and a fast
|
|
659
|
+
// receipt is already declared non-evidence.
|
|
660
|
+
//
|
|
661
|
+
// Fast mode additionally scopes the suite to tests plausibly affected by the
|
|
662
|
+
// working-tree change (qa/lib/affected-tests.mjs): changed .kt files map to
|
|
663
|
+
// `--tests "*<segment>*"` patterns, with a mandatory blast-radius escape hatch (build
|
|
664
|
+
// files, DI, theme, shared components, qa/, anything outside composeApp/src → full
|
|
665
|
+
// suite) and fail-open on every uncertain case (no git, unmappable change). FALSE
|
|
666
|
+
// NEGATIVES ARE ACCEPTABLE HERE AND ONLY HERE: the full, unfiltered suite runs at the
|
|
667
|
+
// checkpoint (the full lane), where done is actually decided. The filter that ran is
|
|
668
|
+
// reported in the step's note and recorded in the (fast-only) receipt, so a filtered
|
|
669
|
+
// run can never be mistaken for the full suite.
|
|
670
|
+
let note;
|
|
671
|
+
let testsArgs = "";
|
|
672
|
+
let affected = null;
|
|
673
|
+
if (fast) {
|
|
674
|
+
const changed = changedWorkingTreePaths(ROOT);
|
|
675
|
+
if (changed === null) {
|
|
676
|
+
note = "full suite — git unavailable, cannot derive the change (fail open)";
|
|
677
|
+
} else {
|
|
678
|
+
const filter = deriveAffectedFilter(changed);
|
|
679
|
+
if (filter.mode === "filtered") {
|
|
680
|
+
testsArgs = filter.patterns.map((p) => ` --tests "${p}"`).join("");
|
|
681
|
+
note = `affected: ${filter.patterns.join(", ")} — ${filter.sourcePaths.length} changed source file(s)`;
|
|
682
|
+
affected = { patterns: filter.patterns, changedFiles: filter.sourcePaths.length };
|
|
683
|
+
} else {
|
|
684
|
+
note = `full suite — ${filter.reason}`;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
let res = shGradle(`${GRADLEW} :composeApp:desktopTest${RERUN}${testsArgs} --console=plain`);
|
|
689
|
+
if (fast && testsArgs && !res.ok && /No tests found for given includes/.test(res.out)) {
|
|
690
|
+
// The heuristic filter matched no test class at all (e.g. a feature with no tests
|
|
691
|
+
// yet). That is the harness's guess being wrong, not the app — fall back to the
|
|
692
|
+
// full suite in-lane rather than false-redding on our own filter. (RERUN is empty
|
|
693
|
+
// here by construction — this branch only exists in fast mode.)
|
|
694
|
+
const retry = shGradle(`${GRADLEW} :composeApp:desktopTest${RERUN} --console=plain`);
|
|
695
|
+
retry.durationMs += res.durationMs;
|
|
696
|
+
res = retry;
|
|
697
|
+
note = "full suite — the affected-test filter matched no tests (fell back)";
|
|
698
|
+
affected = null;
|
|
699
|
+
}
|
|
422
700
|
const summary = junitSummary(path.join(ROOT, "composeApp/build/test-results/desktopTest"));
|
|
701
|
+
let details = summary ?? undefined;
|
|
702
|
+
if (affected) details = { ...(summary ?? {}), affected };
|
|
423
703
|
return {
|
|
424
704
|
name: "unitTests",
|
|
425
705
|
verdict: res.ok ? "PASS" : "FAIL",
|
|
426
706
|
reason: res.ok
|
|
427
707
|
? undefined
|
|
428
708
|
: `desktopTest failed (${summary ? `${summary.failures + summary.errors} of ${summary.tests} tests` : "see output"}). Fix the failing behavior — do not delete or weaken tests to pass:\n${res.out.split("\n").filter((l) => /FAILED|error:/i.test(l)).slice(0, 12).join("\n")}`,
|
|
709
|
+
note,
|
|
429
710
|
durationMs: res.durationMs,
|
|
430
|
-
details
|
|
711
|
+
details,
|
|
431
712
|
};
|
|
432
713
|
}
|
|
433
714
|
|
|
@@ -503,6 +784,10 @@ function stepTokenDrift() {
|
|
|
503
784
|
durationMs: elapsed(),
|
|
504
785
|
});
|
|
505
786
|
|
|
787
|
+
// Machine-global lease before the first device touch (contention = SKIP).
|
|
788
|
+
const leaseSkip = leaseDeviceForStep("tokenDrift");
|
|
789
|
+
if (leaseSkip) return { ...leaseSkip, durationMs: elapsed() };
|
|
790
|
+
|
|
506
791
|
sh(`adb forward tcp:${INSPECTOR_PORT} tcp:${INSPECTOR_PORT}`);
|
|
507
792
|
try {
|
|
508
793
|
let health = curlJson(`http://127.0.0.1:${INSPECTOR_PORT}/inspect/health`);
|
|
@@ -561,32 +846,37 @@ function maestroAvailable() {
|
|
|
561
846
|
return sh("maestro --version", { timeout: 15_000 }).ok;
|
|
562
847
|
}
|
|
563
848
|
|
|
564
|
-
|
|
849
|
+
// The e2e guard trio, shared by every step that drives the smoke flow on a device.
|
|
850
|
+
// Returns null when the harness is fully available, else the SKIP result for [name].
|
|
851
|
+
function maestroGuards(name) {
|
|
565
852
|
if (!fs.existsSync(path.join(ROOT, "qa/e2e"))) {
|
|
566
|
-
return { name
|
|
853
|
+
return { name, verdict: "SKIP", reason: "e2e harness not included in this project (--no-e2e)", durationMs: 0 };
|
|
567
854
|
}
|
|
568
855
|
if (!deviceAttached()) {
|
|
569
|
-
return { name
|
|
856
|
+
return { name, verdict: "SKIP", reason: "no Android device/emulator attached (adb)", durationMs: 0 };
|
|
570
857
|
}
|
|
571
858
|
if (!maestroAvailable()) {
|
|
572
|
-
return { name
|
|
859
|
+
return { name, verdict: "SKIP", reason: "maestro CLI not installed — curl -fsSL https://get.maestro.mobile.dev | bash", durationMs: 0 };
|
|
573
860
|
}
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
861
|
+
return null;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Drives qa/e2e/smoke.yaml against whatever build is installed, with the device hardened
|
|
865
|
+
// for headless/CI automation. Shared by e2eSmoke (debug APK) and releaseSmoke (release
|
|
866
|
+
// APK) so the hardening and the honesty sweep can never drift apart between variants.
|
|
867
|
+
// Without the hardening, a slow or loaded emulator produces false reds that have nothing
|
|
868
|
+
// to do with the app:
|
|
869
|
+
// - hide_error_dialogs=1 stops Android popping ANR/crash dialogs (e.g. SystemUI under load)
|
|
870
|
+
// that steal focus over the app — a Maestro assert would then see only the dialog;
|
|
871
|
+
// - MAESTRO_DRIVER_STARTUP_TIMEOUT gives the UiAutomator2 driver a generous budget to come
|
|
872
|
+
// up on a slow emulator (the built-in default gives up too early under load).
|
|
873
|
+
// Both are benign, reversible, and only touch the device while the lane is driving it —
|
|
874
|
+
// hide_error_dialogs is restored to its pre-run value (or deleted, returning the device
|
|
875
|
+
// to its default) in the finally below, on every exit path.
|
|
876
|
+
// hide_error_dialogs suppresses the OS dialog, NEVER the underlying event — so after the
|
|
877
|
+
// run we grep the device log for ANR/crash lines the dialog would have shown, and FAIL on
|
|
878
|
+
// them. The eyes must report what automation stability had to hide.
|
|
879
|
+
function runMaestroSmoke(name, priorDurationMs) {
|
|
590
880
|
const prevHideErrorDialogs = sh("adb shell settings get global hide_error_dialogs").out.trim();
|
|
591
881
|
sh("adb shell settings put global hide_error_dialogs 1");
|
|
592
882
|
sh("adb logcat -c"); // clear so the post-run dump only reflects this run
|
|
@@ -594,10 +884,10 @@ function stepE2eSmoke() {
|
|
|
594
884
|
const res = sh("maestro test qa/e2e/smoke.yaml", { env: { ...process.env, MAESTRO_DRIVER_STARTUP_TIMEOUT: "120000" } });
|
|
595
885
|
if (!res.ok) {
|
|
596
886
|
return {
|
|
597
|
-
name
|
|
887
|
+
name,
|
|
598
888
|
verdict: "FAIL",
|
|
599
889
|
reason: `Maestro smoke failed (flow cites the SHELL spec clauses it proves):\n${res.out.split("\n").slice(-15).join("\n")}`,
|
|
600
|
-
durationMs:
|
|
890
|
+
durationMs: priorDurationMs + res.durationMs,
|
|
601
891
|
};
|
|
602
892
|
}
|
|
603
893
|
const anrDump = sh("adb logcat -d -b system,crash,main");
|
|
@@ -605,13 +895,13 @@ function stepE2eSmoke() {
|
|
|
605
895
|
if (anrDump.ok && anrRe.test(anrDump.out)) {
|
|
606
896
|
const anrLines = anrDump.out.split("\n").filter((l) => anrRe.test(l)).slice(0, 10).join("\n");
|
|
607
897
|
return {
|
|
608
|
-
name
|
|
898
|
+
name,
|
|
609
899
|
verdict: "FAIL",
|
|
610
900
|
reason: `Maestro smoke passed, but the device log shows an ANR/crash during the run (hide_error_dialogs only suppresses the OS dialog, never the underlying event):\n${anrLines}`,
|
|
611
|
-
durationMs:
|
|
901
|
+
durationMs: priorDurationMs + res.durationMs,
|
|
612
902
|
};
|
|
613
903
|
}
|
|
614
|
-
return { name
|
|
904
|
+
return { name, verdict: "PASS", durationMs: priorDurationMs + res.durationMs };
|
|
615
905
|
} finally {
|
|
616
906
|
if (prevHideErrorDialogs && prevHideErrorDialogs !== "null") {
|
|
617
907
|
sh(`adb shell settings put global hide_error_dialogs ${prevHideErrorDialogs}`);
|
|
@@ -621,18 +911,210 @@ function stepE2eSmoke() {
|
|
|
621
911
|
}
|
|
622
912
|
}
|
|
623
913
|
|
|
914
|
+
function stepE2eSmoke() {
|
|
915
|
+
const guard = maestroGuards("e2eSmoke");
|
|
916
|
+
if (guard) return guard;
|
|
917
|
+
// Machine-global lease before the first device touch (contention = SKIP).
|
|
918
|
+
const leaseSkip = leaseDeviceForStep("e2eSmoke");
|
|
919
|
+
if (leaseSkip) return leaseSkip;
|
|
920
|
+
const install = shGradle(`${GRADLEW} :composeApp:installDebug --console=plain`);
|
|
921
|
+
if (!install.ok) {
|
|
922
|
+
return { name: "e2eSmoke", verdict: "FAIL", reason: "installDebug failed — the APK could not be installed on the attached device", durationMs: install.durationMs };
|
|
923
|
+
}
|
|
924
|
+
settleAdb();
|
|
925
|
+
return runMaestroSmoke("e2eSmoke", install.durationMs);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Instrumented behavior tier (composeApp/src/androidInstrumentedTest) — the one step
|
|
929
|
+
// whose evidence crosses the process boundary. Alarms, notification channels,
|
|
930
|
+
// full-screen intents, PendingIntent identity, and audio routing are OS facts:
|
|
931
|
+
// desktopTest is a JVM, golden trees are structure, the conformance suite is static,
|
|
932
|
+
// and the Maestro smoke taps UI without asserting anything about the shade or the
|
|
933
|
+
// alarm table. Nine escaped platform-semantics defects across two real apps trace to
|
|
934
|
+
// exactly this blind spot; the hand-built precursor of this step caught two bugs the
|
|
935
|
+
// week it landed. `connectedDebugAndroidTest` builds, installs, and runs the
|
|
936
|
+
// instrumented suite in the app's real process on the attached device.
|
|
937
|
+
//
|
|
938
|
+
// SKIP (never FAIL) on missing infrastructure — no device, or no instrumented sources
|
|
939
|
+
// yet — mirroring e2eSmoke's stance: absence of the tier is recorded honestly, only
|
|
940
|
+
// broken behavior fails.
|
|
941
|
+
function stepAndroidChecks() {
|
|
942
|
+
const started = Date.now();
|
|
943
|
+
const instrumentedDir = path.join(ROOT, "composeApp/src/androidInstrumentedTest");
|
|
944
|
+
const hasSources = fs.existsSync(instrumentedDir) &&
|
|
945
|
+
walkFiles(instrumentedDir, [".kt"]).length > 0;
|
|
946
|
+
if (!hasSources) {
|
|
947
|
+
return {
|
|
948
|
+
name: "androidChecks",
|
|
949
|
+
verdict: "SKIP",
|
|
950
|
+
reason: "no instrumented tests (composeApp/src/androidInstrumentedTest has no Kotlin sources)",
|
|
951
|
+
durationMs: Date.now() - started,
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
if (!deviceAttached()) {
|
|
955
|
+
return {
|
|
956
|
+
name: "androidChecks",
|
|
957
|
+
verdict: "SKIP",
|
|
958
|
+
reason: "no Android device/emulator attached (adb) — instrumented behavior needs the real process boundary",
|
|
959
|
+
durationMs: Date.now() - started,
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
// Machine-global lease before the first device touch (contention = SKIP).
|
|
963
|
+
const leaseSkip = leaseDeviceForStep("androidChecks");
|
|
964
|
+
if (leaseSkip) return { ...leaseSkip, durationMs: Date.now() - started };
|
|
965
|
+
// Settle before Gradle's own install+drive: earlier lane steps (tokenDrift's
|
|
966
|
+
// port-forwards, e2eSmoke's reinstall) can leave the transport stale — see settleAdb.
|
|
967
|
+
settleAdb();
|
|
968
|
+
// `--rerun` for the same evidence-integrity reason as stepUnitTests: the receipt must
|
|
969
|
+
// attest tests that EXECUTED on this tree, never a replayed up-to-date verdict.
|
|
970
|
+
const res = shGradle(`${GRADLEW} :composeApp:connectedDebugAndroidTest --rerun --console=plain`);
|
|
971
|
+
const summary = junitSummary(path.join(ROOT, "composeApp/build/outputs/androidTest-results/connected"));
|
|
972
|
+
return {
|
|
973
|
+
name: "androidChecks",
|
|
974
|
+
verdict: res.ok ? "PASS" : "FAIL",
|
|
975
|
+
reason: res.ok
|
|
976
|
+
? undefined
|
|
977
|
+
: `connectedDebugAndroidTest failed (${summary ? `${summary.failures + summary.errors} of ${summary.tests} tests` : "see output"}) — an on-device behavior claim is broken. Fix the behavior, not the test:\n${res.out.split("\n").filter((l) => /FAILED|error:|failed/i.test(l)).slice(0, 12).join("\n")}`,
|
|
978
|
+
durationMs: Date.now() - started,
|
|
979
|
+
details: summary ?? undefined,
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// Release-APK smoke — the behavior half of stepReleaseBuild. assembleRelease proves R8
|
|
984
|
+
// and the build graph COMPILE; two real bugs were only findable by *running* the release
|
|
985
|
+
// variant (R8 behavior differs from debug). Installs the release APK and drives the same
|
|
986
|
+
// Maestro smoke flow against it. Ship-time cost by design: this step exists only in the
|
|
987
|
+
// `release` profile, never per-change.
|
|
988
|
+
//
|
|
989
|
+
// Honesty notes, both deliberate:
|
|
990
|
+
// - A template-fresh app has NO release signingConfig (the keystore belongs to whoever
|
|
991
|
+
// ships), and an unsigned APK cannot be installed. That is a SKIP naming what to
|
|
992
|
+
// configure, never a FAIL — a fresh scaffold must not red-bar on a keystore it was
|
|
993
|
+
// never given.
|
|
994
|
+
// - This step reinstalls NOTHING afterwards: the release build stays on the device,
|
|
995
|
+
// which is the honest state ("what is installed is what was last proven"). The next
|
|
996
|
+
// debug install over it will hit INSTALL_FAILED_UPDATE_INCOMPATIBLE (release and debug
|
|
997
|
+
// signatures differ) — run `adb uninstall <applicationId>` first; the same applies in
|
|
998
|
+
// reverse here, so that raw Gradle error is translated into the actionable message.
|
|
999
|
+
function stepReleaseSmoke() {
|
|
1000
|
+
const guard = maestroGuards("releaseSmoke");
|
|
1001
|
+
if (guard) return guard;
|
|
1002
|
+
|
|
1003
|
+
let gradleText = "";
|
|
1004
|
+
try {
|
|
1005
|
+
gradleText = fs.readFileSync(path.join(ROOT, "composeApp/build.gradle.kts"), "utf8");
|
|
1006
|
+
} catch {
|
|
1007
|
+
gradleText = "";
|
|
1008
|
+
}
|
|
1009
|
+
const applicationId = gradleText.match(/applicationId\s*=\s*"([^"]+)"/)?.[1] ?? "<applicationId>";
|
|
1010
|
+
if (!/signingConfig/.test(gradleText)) {
|
|
1011
|
+
return {
|
|
1012
|
+
name: "releaseSmoke",
|
|
1013
|
+
verdict: "SKIP",
|
|
1014
|
+
reason:
|
|
1015
|
+
"release APK is unsigned — no signingConfig in composeApp/build.gradle.kts. To enable the release smoke: create a keystore (keytool -genkeypair), declare android.signingConfigs { create(\"release\") { … } } from a gitignored keystore.properties, and set buildTypes.release.signingConfig. The keystore is yours to keep out of the repo.",
|
|
1016
|
+
durationMs: 0,
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Machine-global lease before the first device touch (contention = SKIP).
|
|
1021
|
+
// After the signing check on purpose: an unsigned template SKIPs on the
|
|
1022
|
+
// keystore without ever needing the device.
|
|
1023
|
+
const leaseSkip = leaseDeviceForStep("releaseSmoke");
|
|
1024
|
+
if (leaseSkip) return leaseSkip;
|
|
1025
|
+
|
|
1026
|
+
const install = shGradle(`${GRADLEW} :composeApp:installRelease --console=plain`);
|
|
1027
|
+
if (!install.ok) {
|
|
1028
|
+
if (/INSTALL_FAILED_UPDATE_INCOMPATIBLE/.test(install.out)) {
|
|
1029
|
+
return {
|
|
1030
|
+
name: "releaseSmoke",
|
|
1031
|
+
verdict: "FAIL",
|
|
1032
|
+
reason: `installRelease refused: the device holds a build with a different signature (usually the debug build from an earlier lane step). Android never installs across signatures — run \`adb uninstall ${applicationId}\` and re-run the release profile. This is a device-state conflict, not a build defect.`,
|
|
1033
|
+
durationMs: install.durationMs,
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
if (/SigningConfig|not signed|INSTALL_PARSE_FAILED_NO_CERTIFICATES/i.test(install.out)) {
|
|
1037
|
+
return {
|
|
1038
|
+
name: "releaseSmoke",
|
|
1039
|
+
verdict: "SKIP",
|
|
1040
|
+
reason: "release APK is not installable — signing is not fully configured (see composeApp/build.gradle.kts signingConfigs). Configure a release keystore to enable the release smoke.",
|
|
1041
|
+
durationMs: install.durationMs,
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
return {
|
|
1045
|
+
name: "releaseSmoke",
|
|
1046
|
+
verdict: "FAIL",
|
|
1047
|
+
reason: `installRelease failed — the shippable APK could not be installed:\n${install.out.split("\n").filter((l) => /error|FAILURE|INSTALL_/i.test(l)).slice(0, 12).join("\n")}`,
|
|
1048
|
+
durationMs: install.durationMs,
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
settleAdb();
|
|
1052
|
+
return runMaestroSmoke("releaseSmoke", install.durationMs);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
624
1055
|
// ── Lane ───────────────────────────────────────────────────────────────────
|
|
625
1056
|
|
|
1057
|
+
// Device-dependent steps, in lane order. Used twice: receipt STRENGTH (which
|
|
1058
|
+
// on-device steps actually PASSed — see below, where the receipt is built) and
|
|
1059
|
+
// the --fast exclusion (with releaseBuild added), so the "device/slow tier"
|
|
1060
|
+
// can never mean two different lists.
|
|
1061
|
+
const DEVICE_STEPS = ["e2eSmoke", "tokenDrift", "androidChecks", "releaseSmoke"];
|
|
1062
|
+
|
|
1063
|
+
// ── Fast-mode memoization of the pure-Node steps (qa/lib/step-cache.mjs) ────
|
|
1064
|
+
// These five steps run no Gradle, shell out to nothing, and are pure functions
|
|
1065
|
+
// of files on disk — so in FAST mode an unchanged input set reuses the last
|
|
1066
|
+
// PASS as verdict "CACHED" (rendered distinctly; only a PASS is ever reused,
|
|
1067
|
+
// a cached FAIL/SKIP always re-runs). THE FULL LANE NEVER CONSULTS THE CACHE —
|
|
1068
|
+
// deliberately: it keeps the integrity property absolute rather than "absolute
|
|
1069
|
+
// unless a cache says otherwise". A full run still WRITES entries so the next
|
|
1070
|
+
// fast run benefits. schemaHistory is NOT here even though it runs no Gradle:
|
|
1071
|
+
// it shells out to git and its verdict depends on HEAD state, not only file
|
|
1072
|
+
// bytes — memoizing it on a content hash could go silently stale.
|
|
1073
|
+
//
|
|
1074
|
+
// Each input set is the step's ACTUAL read surface, over-declared where cheap
|
|
1075
|
+
// (a too-broad set only costs cache misses; a too-narrow one is a
|
|
1076
|
+
// silently-stale gate — the worst possible bug here):
|
|
1077
|
+
// specCoverage reads specs/*.spec.md + citations under composeApp/src
|
|
1078
|
+
// and qa/e2e (qa/lib/spec-coverage.mjs)
|
|
1079
|
+
// approvals reads qa/approvals.json + every governed artifact file:
|
|
1080
|
+
// specs/, docs/features/, docs/ARCHITECTURE.md, and the
|
|
1081
|
+
// exemplar/theme/components Kotlin under composeApp/src
|
|
1082
|
+
// (qa/lib/approvals.mjs listGovernedArtifacts)
|
|
1083
|
+
// componentStories reads commonMain presentation/components and desktopMain
|
|
1084
|
+
// inspector sources — both under composeApp/src
|
|
1085
|
+
// reachability reads commonMain Kotlin (composeApp/src) + the unrouted
|
|
1086
|
+
// declarations in docs/features/
|
|
1087
|
+
// archDoc reads docs/ARCHITECTURE.md, docs/adr/, specs/intent.md
|
|
1088
|
+
// (over-declared to all of specs/), and every source-set's
|
|
1089
|
+
// Kotlin under composeApp/src (qa/lib/arch-doc.mjs)
|
|
1090
|
+
const MEMOIZED_STEP_INPUTS = {
|
|
1091
|
+
specCoverage: ["specs", "composeApp/src", "qa/e2e"],
|
|
1092
|
+
approvals: ["qa/approvals.json", "specs", "docs/features", "docs/ARCHITECTURE.md", "composeApp/src"],
|
|
1093
|
+
componentStories: ["composeApp/src"],
|
|
1094
|
+
reachability: ["composeApp/src", "docs/features"],
|
|
1095
|
+
archDoc: ["docs/ARCHITECTURE.md", "docs/adr", "specs", "composeApp/src"],
|
|
1096
|
+
};
|
|
1097
|
+
|
|
1098
|
+
const memoized = (stepName, stepFn) => () =>
|
|
1099
|
+
memoizeStep({ fast, root: ROOT, stepName, inputs: MEMOIZED_STEP_INPUTS[stepName], run: stepFn });
|
|
1100
|
+
|
|
1101
|
+
const stepSpecCoverageMemo = memoized("specCoverage", stepSpecCoverage);
|
|
1102
|
+
const stepApprovalsMemo = memoized("approvals", stepApprovals);
|
|
1103
|
+
const stepComponentStoriesMemo = memoized("componentStories", stepComponentStories);
|
|
1104
|
+
const stepReachabilityMemo = memoized("reachability", stepReachability);
|
|
1105
|
+
const stepArchDocMemo = memoized("archDoc", stepArchDoc);
|
|
1106
|
+
|
|
626
1107
|
const stepsForProfile = {
|
|
627
1108
|
// scaffold: what `create-cmp --verify` proves at stamp time — specCoverage,
|
|
628
1109
|
// the full JVM tier (unit + conformance + golden + UI tests) plus the Android build.
|
|
629
|
-
scaffold: [
|
|
1110
|
+
scaffold: [stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepArchDocMemo, stepSchemaHistory, stepBuild, stepUnitTests],
|
|
630
1111
|
local: [
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
1112
|
+
stepSpecCoverageMemo,
|
|
1113
|
+
stepApprovalsMemo,
|
|
1114
|
+
stepComponentStoriesMemo,
|
|
1115
|
+
stepReachabilityMemo,
|
|
1116
|
+
stepArchDocMemo,
|
|
1117
|
+
stepSchemaHistory,
|
|
636
1118
|
stepBuild,
|
|
637
1119
|
// Release stays OUT of `scaffold`: stamp-time --verify promises a green first build, and
|
|
638
1120
|
// an R8 pass would add minutes to every scaffold to re-prove what this step proves here.
|
|
@@ -644,44 +1126,126 @@ const stepsForProfile = {
|
|
|
644
1126
|
stepTokenDrift,
|
|
645
1127
|
stepA11y,
|
|
646
1128
|
stepE2eSmoke,
|
|
1129
|
+
// androidChecks joins local BY the file's own convention, not despite it: local's
|
|
1130
|
+
// contract (see USAGE) is "everything; device-dependent steps SKIP when no device is
|
|
1131
|
+
// attached" — device presence is the opt-in, exactly as e2eSmoke and tokenDrift
|
|
1132
|
+
// already work. A developer with no device attached pays nothing here; one who
|
|
1133
|
+
// attached an emulator has already opted into the device tier's cost. Hiding this
|
|
1134
|
+
// step in ci-only would make local's documented contract a lie and re-open the gap
|
|
1135
|
+
// this tier closes (androidMain test-invisible in the profile people actually run).
|
|
1136
|
+
// Last on purpose: the cheap desktop verdicts and the smoke land first.
|
|
1137
|
+
stepAndroidChecks,
|
|
647
1138
|
],
|
|
648
1139
|
};
|
|
649
1140
|
stepsForProfile.ci = stepsForProfile.local;
|
|
1141
|
+
// release = everything ci proves PLUS the release-APK behavior smoke. The expensive
|
|
1142
|
+
// proofs are profile-tiered by decision: per-change stays fast (local/ci pay for the
|
|
1143
|
+
// release COMPILE via releaseBuild, already in the set), and the release-variant
|
|
1144
|
+
// *behavior* cost lands once, at ship time. releaseSmoke runs last so the device ends
|
|
1145
|
+
// the run holding the exact build that was proven.
|
|
1146
|
+
stepsForProfile.release = [...stepsForProfile.ci, stepReleaseSmoke];
|
|
650
1147
|
|
|
651
1148
|
if (!stepsForProfile[profile]) {
|
|
652
|
-
console.error(`Unknown profile "${profile}" — use scaffold | local | ci.`);
|
|
1149
|
+
console.error(`Unknown profile "${profile}" — use scaffold | local | ci | release.`);
|
|
653
1150
|
process.exit(2);
|
|
654
1151
|
}
|
|
655
1152
|
|
|
1153
|
+
// ── --fast: the inner loop, mechanically unable to claim done ───────────────
|
|
1154
|
+
// The genuinely slow tier is device/release work — every DEVICE_STEPS entry
|
|
1155
|
+
// (Gradle install + emulator + Maestro + instrumented runner) plus
|
|
1156
|
+
// releaseBuild (R8 + lintVital, the slow release COMPILE). --fast filters
|
|
1157
|
+
// that tier out of whatever profile resolved, UNCONDITIONALLY — device
|
|
1158
|
+
// attached or not — so a small change gets its did-I-break-anything-obvious
|
|
1159
|
+
// signal in JVM time. The rest of the profile still runs — but cheaply: the
|
|
1160
|
+
// pure-Node steps reuse an unchanged PASS from the step cache (CACHED — see
|
|
1161
|
+
// the memoization block above), the Gradle test steps drop --rerun (see
|
|
1162
|
+
// RERUN above), and unitTests scopes itself to the working-tree change
|
|
1163
|
+
// (see stepUnitTests). The loophole is closed at the receipt, not by
|
|
1164
|
+
// convention: mode "fast" is
|
|
1165
|
+
// recorded, no evidence rung is derived (qa/lib/evidence-level.mjs), and
|
|
1166
|
+
// qa/receipt-check.mjs refuses a fast receipt as done evidence.
|
|
1167
|
+
const FAST_EXCLUDED_NAMES = [...DEVICE_STEPS, "releaseBuild"];
|
|
1168
|
+
const STEP_FN_BY_NAME = {
|
|
1169
|
+
e2eSmoke: stepE2eSmoke,
|
|
1170
|
+
tokenDrift: stepTokenDrift,
|
|
1171
|
+
androidChecks: stepAndroidChecks,
|
|
1172
|
+
releaseSmoke: stepReleaseSmoke,
|
|
1173
|
+
releaseBuild: stepReleaseBuild,
|
|
1174
|
+
};
|
|
1175
|
+
for (const name of FAST_EXCLUDED_NAMES) {
|
|
1176
|
+
if (!STEP_FN_BY_NAME[name]) {
|
|
1177
|
+
// Drift guard: a new device-tier step must be mapped here or --fast would silently run it.
|
|
1178
|
+
console.error(`internal: fast-excluded step "${name}" has no entry in STEP_FN_BY_NAME — fix qa/verify.mjs`);
|
|
1179
|
+
process.exit(2);
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
const FAST_EXCLUDED_FNS = new Set(FAST_EXCLUDED_NAMES.map((name) => STEP_FN_BY_NAME[name]));
|
|
1183
|
+
const laneSteps = fast
|
|
1184
|
+
? stepsForProfile[profile].filter((fn) => !FAST_EXCLUDED_FNS.has(fn))
|
|
1185
|
+
: stepsForProfile[profile];
|
|
1186
|
+
const fastExcluded = fast
|
|
1187
|
+
? FAST_EXCLUDED_NAMES.filter((name) => stepsForProfile[profile].includes(STEP_FN_BY_NAME[name]))
|
|
1188
|
+
: [];
|
|
1189
|
+
|
|
1190
|
+
if (fast) {
|
|
1191
|
+
console.error(
|
|
1192
|
+
[
|
|
1193
|
+
"⚡⚡ FAST MODE — INNER LOOP ONLY, NOT THE DONE-GATE ⚡⚡",
|
|
1194
|
+
` skipping the device/release tier: ${fastExcluded.join(", ") || "(none in this profile)"}`,
|
|
1195
|
+
' this run\'s receipt records mode "fast", earns no evidence rung, and can NEVER satisfy "done"',
|
|
1196
|
+
" run the full lane once (node qa/verify.mjs) before you finish",
|
|
1197
|
+
].join("\n"),
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
656
1201
|
// Stamp the lane marker for the run's duration (coexistence defense 1 above);
|
|
657
1202
|
// always removed, even on a failing step, so the eyes only ever defer briefly.
|
|
658
1203
|
fs.mkdirSync(path.dirname(LANE_MARKER), { recursive: true });
|
|
659
1204
|
fs.writeFileSync(LANE_MARKER, `${process.pid} ${new Date().toISOString()}\n`);
|
|
660
1205
|
const steps = [];
|
|
661
1206
|
try {
|
|
662
|
-
for (const step of
|
|
1207
|
+
for (const step of laneSteps) {
|
|
663
1208
|
const result = step();
|
|
664
1209
|
steps.push(result);
|
|
665
1210
|
if (!asJson) {
|
|
666
|
-
|
|
667
|
-
|
|
1211
|
+
// CACHED (fast mode only — see the memoization block above) renders with
|
|
1212
|
+
// its own mark and "unchanged since" note so a reused verdict is never
|
|
1213
|
+
// mistakable for a fresh execution.
|
|
1214
|
+
const mark = result.verdict === "PASS" ? "✓" : result.verdict === "CACHED" ? "⚡" : result.verdict === "SKIP" ? "→" : "✗";
|
|
1215
|
+
console.log(`${mark} ${result.name}: ${result.verdict}${result.note ? ` (${result.note})` : ""}${result.reason ? ` — ${result.reason.split("\n")[0]}` : ""}`);
|
|
668
1216
|
}
|
|
669
1217
|
if (result.name === "build" && result.verdict === "FAIL") break; // nothing downstream is meaningful
|
|
670
1218
|
}
|
|
671
1219
|
} finally {
|
|
672
1220
|
fs.rmSync(LANE_MARKER, { force: true });
|
|
1221
|
+
// The device lease (if a device step took it) is held to the very end of the
|
|
1222
|
+
// run — see the scope decision at leaseDeviceForStep. Release is idempotent
|
|
1223
|
+
// and never deletes a foreign holder's lease.
|
|
1224
|
+
if (laneDeviceLease) releaseDeviceLease(laneDeviceLease);
|
|
673
1225
|
}
|
|
674
1226
|
|
|
1227
|
+
// CACHED counts as PASS for the lane verdict (it IS a prior PASS, reused only
|
|
1228
|
+
// in fast mode on an unchanged input set) — but it stays CACHED on the
|
|
1229
|
+
// receipt, visibly distinct, so a fast receipt can never be read as if every
|
|
1230
|
+
// step freshly executed.
|
|
675
1231
|
const verdict = steps.some((s) => s.verdict === "FAIL") ? "FAIL" : "PASS";
|
|
676
1232
|
|
|
677
1233
|
// Receipt STRENGTH — a desktop-only green and an on-device green are different
|
|
678
1234
|
// claims, and the difference should never live only in the SKIP lines. Device-
|
|
679
1235
|
// dependent steps that actually RAN (PASSed) are named on the receipt and in the
|
|
680
1236
|
// verdict line: "PASS (on-device: e2eSmoke)" vs "PASS (desktop-only)".
|
|
681
|
-
|
|
1237
|
+
// (DEVICE_STEPS itself is defined above the lane — it also drives --fast.)
|
|
682
1238
|
const onDeviceSteps = steps.filter((s) => DEVICE_STEPS.includes(s.name) && s.verdict === "PASS").map((s) => s.name);
|
|
683
1239
|
const strengthLabel = onDeviceSteps.length ? `on-device: ${onDeviceSteps.join("+")}` : "desktop-only";
|
|
684
1240
|
|
|
1241
|
+
// Receipt RUNG — the evidence ladder (qa/lib/evidence-level.mjs): the coarse,
|
|
1242
|
+
// named grade (L0 scaffold / L1 desktop / L2 device / L3 release) DERIVED from
|
|
1243
|
+
// which steps actually ran and PASSed. The strength string above stays as the
|
|
1244
|
+
// fine print; the rung is added alongside, never in place of it. null on FAIL —
|
|
1245
|
+
// a failed lane has no rung. null on a --fast run too: the inner loop is a
|
|
1246
|
+
// signal, never evidence, so a fast receipt derives NO rung at all.
|
|
1247
|
+
const level = evidenceLevel(steps, profile, { mode });
|
|
1248
|
+
|
|
685
1249
|
// Artifacts: hash whatever the run left under qa-artifacts/ (never committed).
|
|
686
1250
|
const artifacts = [];
|
|
687
1251
|
if (fs.existsSync(ARTIFACTS_DIR)) {
|
|
@@ -707,6 +1271,10 @@ const inputs = computeInputsHash(ROOT);
|
|
|
707
1271
|
const receipt = {
|
|
708
1272
|
schema: "cmp-evidence/1",
|
|
709
1273
|
profile,
|
|
1274
|
+
// "full" is the done-gate; "fast" (--fast) excluded the device/release tier
|
|
1275
|
+
// and is REFUSED by qa/receipt-check.mjs — a fast run can never end a session
|
|
1276
|
+
// as "done". Receipts predating this field are treated as full.
|
|
1277
|
+
mode,
|
|
710
1278
|
verdict,
|
|
711
1279
|
commit: {
|
|
712
1280
|
sha: tryGit("rev-parse HEAD"),
|
|
@@ -718,6 +1286,7 @@ const receipt = {
|
|
|
718
1286
|
},
|
|
719
1287
|
steps,
|
|
720
1288
|
strength: { onDeviceSteps },
|
|
1289
|
+
evidenceLevel: level,
|
|
721
1290
|
artifacts,
|
|
722
1291
|
toolVersions: {
|
|
723
1292
|
node: process.version,
|
|
@@ -732,7 +1301,19 @@ fs.writeFileSync(path.join(EVIDENCE_DIR, "latest.json"), `${JSON.stringify(recei
|
|
|
732
1301
|
// studio console's Evidence audit trail reconstructs the full history from the
|
|
733
1302
|
// git log of this file — every commit is one verified, attributed state.
|
|
734
1303
|
|
|
735
|
-
if (asJson)
|
|
736
|
-
|
|
1304
|
+
if (asJson) {
|
|
1305
|
+
console.log(JSON.stringify(receipt, null, 2));
|
|
1306
|
+
if (fast) {
|
|
1307
|
+
console.error(`⚡⚡ FAST MODE verdict: ${verdict} — INNER LOOP ONLY, not done. Skipped: ${fastExcluded.join(", ") || "(none)"}. Run the full lane (node qa/verify.mjs) before you finish.`);
|
|
1308
|
+
}
|
|
1309
|
+
} else if (fast) {
|
|
1310
|
+
// Deliberately NOT the full lane's verdict-line shape: fast-green must never
|
|
1311
|
+
// be mistakable for done-green.
|
|
1312
|
+
console.log(
|
|
1313
|
+
`\n${verdict === "PASS" ? "⚡⚡" : "❌"} verify lane [FAST — INNER LOOP ONLY, NOT DONE]: ${verdict} (skipped device/release tier: ${fastExcluded.join(", ") || "none"}) — this fast receipt satisfies no done-gate; run the full lane (node qa/verify.mjs) once before you finish`,
|
|
1314
|
+
);
|
|
1315
|
+
} else {
|
|
1316
|
+
console.log(`\n${verdict === "PASS" ? "✅" : "❌"} verify lane: ${verdict}${level ? ` · ${level.rung} ${level.name}` : ""} (${strengthLabel}) — receipt written to qa/evidence/latest.json (commit it with your change)`);
|
|
1317
|
+
}
|
|
737
1318
|
|
|
738
1319
|
process.exit(verdict === "PASS" ? 0 : 1);
|