create-cmp-cli 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +8 -1
- 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 +112 -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,467 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The verify lane — this project's single verification gate.
|
|
3
|
+
//
|
|
4
|
+
// node qa/verify.mjs [--profile scaffold|local|ci] [--json]
|
|
5
|
+
//
|
|
6
|
+
// Runs every verification step this project carries, aggregates a typed
|
|
7
|
+
// PASS/FAIL verdict, and writes the evidence receipt to qa/evidence/latest.json.
|
|
8
|
+
// The receipt is COMMITTED with your change (see CLAUDE.md — a change is not
|
|
9
|
+
// done without it). Binary artifacts under qa-artifacts/ are never committed;
|
|
10
|
+
// the receipt references them by path + sha256.
|
|
11
|
+
//
|
|
12
|
+
// Verdicts per step: PASS | FAIL | SKIP. The lane verdict is PASS iff no step
|
|
13
|
+
// FAILed. SKIPs are recorded with reasons — green-with-gaps is visible, never
|
|
14
|
+
// silent. Exit code: 0 = PASS, 1 = FAIL.
|
|
15
|
+
//
|
|
16
|
+
// Profiles:
|
|
17
|
+
// scaffold — spec coverage + build + unit tests (what `create-cmp --verify` proves at stamp time)
|
|
18
|
+
// local — everything; device-dependent steps SKIP when no device is attached
|
|
19
|
+
// ci — everything; SKIPs are recorded so the pipeline stays honest
|
|
20
|
+
|
|
21
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
|
|
27
|
+
import { computeInputsHash } from "./lib/inputs-hash.mjs";
|
|
28
|
+
import { compareTokenDrift } from "./lib/token-drift.mjs";
|
|
29
|
+
|
|
30
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
31
|
+
const EVIDENCE_DIR = path.join(ROOT, "qa", "evidence");
|
|
32
|
+
const ARTIFACTS_DIR = path.join(ROOT, "qa-artifacts");
|
|
33
|
+
|
|
34
|
+
const args = process.argv.slice(2);
|
|
35
|
+
const profile = args.includes("--profile") ? args[args.indexOf("--profile") + 1] : "local";
|
|
36
|
+
const asJson = args.includes("--json");
|
|
37
|
+
|
|
38
|
+
const GRADLEW = process.platform === "win32" ? "gradlew.bat" : "./gradlew";
|
|
39
|
+
|
|
40
|
+
function sh(cmd, opts = {}) {
|
|
41
|
+
const started = Date.now();
|
|
42
|
+
// maxBuffer: first-run Gradle output easily exceeds spawnSync's 1MB default,
|
|
43
|
+
// which would surface as a bogus FAIL (status null / ENOBUFS).
|
|
44
|
+
const res = spawnSync(cmd, { shell: true, cwd: ROOT, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, ...opts });
|
|
45
|
+
const ok = res.status === 0 && !res.error;
|
|
46
|
+
return { ok, status: res.status, error: res.error?.message, out: `${res.stdout ?? ""}${res.stderr ?? ""}`, durationMs: Date.now() - started };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function tryGit(cmd) {
|
|
50
|
+
try {
|
|
51
|
+
return execSync(`git ${cmd}`, { cwd: ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function junitSummary(dir) {
|
|
58
|
+
if (!fs.existsSync(dir)) return null;
|
|
59
|
+
let tests = 0, failures = 0, errors = 0, skipped = 0;
|
|
60
|
+
for (const f of fs.readdirSync(dir).filter((f) => f.startsWith("TEST-") && f.endsWith(".xml"))) {
|
|
61
|
+
const xml = fs.readFileSync(path.join(dir, f), "utf8");
|
|
62
|
+
const m = xml.match(/<testsuite[^>]*tests="(\d+)"[^>]*skipped="(\d+)"[^>]*failures="(\d+)"[^>]*errors="(\d+)"/);
|
|
63
|
+
if (m) {
|
|
64
|
+
tests += Number(m[1]);
|
|
65
|
+
skipped += Number(m[2]);
|
|
66
|
+
failures += Number(m[3]);
|
|
67
|
+
errors += Number(m[4]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { tests, failures, errors, skipped };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function deviceAttached() {
|
|
74
|
+
const res = sh("adb devices", { timeout: 10_000 });
|
|
75
|
+
if (!res.ok) return false;
|
|
76
|
+
return res.out.split("\n").slice(1).some((l) => /\tdevice$/.test(l.trim().replace(/\s+/g, "\t")));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Recursive directory walker (no glob dependency) — returns files under `dir`
|
|
80
|
+
// whose name ends with one of `exts`.
|
|
81
|
+
function walkFiles(dir, exts) {
|
|
82
|
+
const out = [];
|
|
83
|
+
if (!fs.existsSync(dir)) return out;
|
|
84
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
85
|
+
const p = path.join(dir, entry.name);
|
|
86
|
+
if (entry.isDirectory()) out.push(...walkFiles(p, exts));
|
|
87
|
+
else if (exts.some((ext) => entry.name.endsWith(ext))) out.push(p);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Steps ──────────────────────────────────────────────────────────────────
|
|
93
|
+
// Each returns { name, verdict, reason?, durationMs, details? }. Failure
|
|
94
|
+
// reasons are worded for an AI collaborator to act on.
|
|
95
|
+
|
|
96
|
+
// Spec ↔ test drift gate — pure Node, no Gradle. Parses clause ids out of
|
|
97
|
+
// specs/*.spec.md (live: `- **ID**`, withdrawn: `- ~~**ID**~~`, exempt from
|
|
98
|
+
// coverage) and `// SPEC:` / `# SPEC:` citation tags out of composeApp/src and
|
|
99
|
+
// qa/e2e, then fails on orphans in either direction.
|
|
100
|
+
function stepSpecCoverage() {
|
|
101
|
+
const started = Date.now();
|
|
102
|
+
const specsDir = path.join(ROOT, "specs");
|
|
103
|
+
if (!fs.existsSync(specsDir)) {
|
|
104
|
+
return { name: "specCoverage", verdict: "SKIP", reason: "no specs/ directory in this project", durationMs: Date.now() - started };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const CLAUSE_LINE_RE = /^-\s+(~~)?\*\*([A-Z][A-Z0-9]*-\d{2,})\*\*/;
|
|
108
|
+
const clauses = new Map(); // id -> { file, withdrawn }
|
|
109
|
+
const specFiles = fs.readdirSync(specsDir).filter((f) => f.endsWith(".spec.md")).map((f) => path.join(specsDir, f));
|
|
110
|
+
for (const f of specFiles) {
|
|
111
|
+
for (const line of fs.readFileSync(f, "utf8").split("\n")) {
|
|
112
|
+
const m = line.match(CLAUSE_LINE_RE);
|
|
113
|
+
if (!m) continue;
|
|
114
|
+
clauses.set(m[2], { file: path.relative(ROOT, f), withdrawn: Boolean(m[1]) });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const TAG_LINE_RE = /^(?:\/\/|#)\s*SPEC:/;
|
|
119
|
+
const TAG_IDS_RE = /SPEC:\s*([A-Z0-9,\s-]+)/;
|
|
120
|
+
const searchDirs = [path.join(ROOT, "composeApp/src"), path.join(ROOT, "qa/e2e")];
|
|
121
|
+
const files = searchDirs.flatMap((d) => walkFiles(d, [".kt", ".kts", ".yaml", ".yml"]));
|
|
122
|
+
const tags = [];
|
|
123
|
+
for (const f of files) {
|
|
124
|
+
fs.readFileSync(f, "utf8").split("\n").forEach((line, i) => {
|
|
125
|
+
const trimmed = line.trim();
|
|
126
|
+
if (!TAG_LINE_RE.test(trimmed)) return;
|
|
127
|
+
const m = trimmed.match(TAG_IDS_RE);
|
|
128
|
+
if (!m) return;
|
|
129
|
+
const ids = m[1].split(/[,\s]+/).map((s) => s.trim()).filter((s) => /^[A-Z][A-Z0-9]*-\d{2,}$/.test(s));
|
|
130
|
+
for (const id of ids) tags.push({ id, file: path.relative(ROOT, f), line: i + 1 });
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const citedIds = new Set(tags.map((t) => t.id));
|
|
135
|
+
const orphanClauses = [...clauses.entries()].filter(([, c]) => !c.withdrawn).filter(([id]) => !citedIds.has(id));
|
|
136
|
+
const orphanTags = tags.filter((t) => !clauses.has(t.id) || clauses.get(t.id).withdrawn);
|
|
137
|
+
|
|
138
|
+
if (orphanClauses.length === 0 && orphanTags.length === 0) {
|
|
139
|
+
return {
|
|
140
|
+
name: "specCoverage",
|
|
141
|
+
verdict: "PASS",
|
|
142
|
+
durationMs: Date.now() - started,
|
|
143
|
+
details: {
|
|
144
|
+
clauses: [...clauses.values()].filter((c) => !c.withdrawn).length,
|
|
145
|
+
withdrawn: [...clauses.values()].filter((c) => c.withdrawn).length,
|
|
146
|
+
tags: tags.length,
|
|
147
|
+
files: files.length,
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const lines = ["Spec coverage broken — the spec and the tests have drifted apart:"];
|
|
153
|
+
for (const [id, c] of orphanClauses) {
|
|
154
|
+
lines.push(` [${id}] ${c.file} — no durable test cites this clause. Write the test (tag it '// SPEC: ${id}') or withdraw the clause (strike it through).`);
|
|
155
|
+
}
|
|
156
|
+
for (const t of orphanTags) {
|
|
157
|
+
const known = clauses.get(t.id);
|
|
158
|
+
if (known?.withdrawn) {
|
|
159
|
+
lines.push(` // SPEC: ${t.id} at ${t.file}:${t.line} — the test verifies withdrawn behavior (clause ${t.id} in ${known.file} is struck through). Remove the test or un-withdraw the clause.`);
|
|
160
|
+
} else {
|
|
161
|
+
lines.push(` // SPEC: ${t.id} at ${t.file}:${t.line} — no such clause in specs/. Add the clause (AI proposes, human confirms) or fix the id.`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
name: "specCoverage",
|
|
167
|
+
verdict: "FAIL",
|
|
168
|
+
reason: lines.join("\n"),
|
|
169
|
+
durationMs: Date.now() - started,
|
|
170
|
+
details: {
|
|
171
|
+
clauses: [...clauses.values()].filter((c) => !c.withdrawn).length,
|
|
172
|
+
withdrawn: [...clauses.values()].filter((c) => c.withdrawn).length,
|
|
173
|
+
tags: tags.length,
|
|
174
|
+
files: files.length,
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function stepBuild() {
|
|
180
|
+
const res = sh(`${GRADLEW} :composeApp:assembleDebug --console=plain`);
|
|
181
|
+
return {
|
|
182
|
+
name: "build",
|
|
183
|
+
verdict: res.ok ? "PASS" : "FAIL",
|
|
184
|
+
reason: res.ok ? undefined : `assembleDebug failed — fix the build before anything else:\n${res.out.split("\n").filter((l) => /error|FAILURE/i.test(l)).slice(0, 12).join("\n")}`,
|
|
185
|
+
durationMs: res.durationMs,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Runs a filtered slice of the JVM test tier and names the verdict after the gate it proves.
|
|
190
|
+
// The full suite already ran in unitTests; these re-runs are cheap (compiled, cached) and give
|
|
191
|
+
// each gate its own named verdict + failure text in the receipt.
|
|
192
|
+
function gradleTestStep(name, testsFilter, failHint) {
|
|
193
|
+
return () => {
|
|
194
|
+
const res = sh(`${GRADLEW} :composeApp:desktopTest --tests "${testsFilter}" --console=plain`);
|
|
195
|
+
return {
|
|
196
|
+
name,
|
|
197
|
+
verdict: res.ok ? "PASS" : "FAIL",
|
|
198
|
+
reason: res.ok
|
|
199
|
+
? undefined
|
|
200
|
+
: `${failHint}\n${res.out.split("\n").filter((l) => /FAILED|\[(ARCH|SHELL|HOME)-\d+\]|error:/i.test(l)).slice(0, 15).join("\n")}`,
|
|
201
|
+
durationMs: res.durationMs,
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function stepUnitTests() {
|
|
207
|
+
const res = sh(`${GRADLEW} :composeApp:desktopTest --console=plain`);
|
|
208
|
+
const summary = junitSummary(path.join(ROOT, "composeApp/build/test-results/desktopTest"));
|
|
209
|
+
return {
|
|
210
|
+
name: "unitTests",
|
|
211
|
+
verdict: res.ok ? "PASS" : "FAIL",
|
|
212
|
+
reason: res.ok
|
|
213
|
+
? undefined
|
|
214
|
+
: `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")}`,
|
|
215
|
+
durationMs: res.durationMs,
|
|
216
|
+
details: summary ?? undefined,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const stepConformance = gradleTestStep(
|
|
221
|
+
"conformance",
|
|
222
|
+
"*ArchitectureConformanceTest",
|
|
223
|
+
"Architecture conformance violated (specs/app-base.spec.md ARCH clauses). The failing rule names the clause, files, and fix:",
|
|
224
|
+
);
|
|
225
|
+
const stepGoldenTrees = gradleTestStep(
|
|
226
|
+
"goldenTrees",
|
|
227
|
+
"*GoldenTreeTest",
|
|
228
|
+
"Golden-tree drift: a screen's rendered STRUCTURE no longer matches qa/golden/. Unintended → fix your change; intended → regenerate with UPDATE_GOLDEN=1 and declare it:",
|
|
229
|
+
);
|
|
230
|
+
const stepA11y = gradleTestStep(
|
|
231
|
+
"a11y",
|
|
232
|
+
"*A11yConformanceTest",
|
|
233
|
+
"A11y gate failed (SHELL-04): interactive nodes must expose a testTag, text, or contentDescription:",
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
// Live tokenDrift tier (harness M4-D): when a debug app + device are available,
|
|
237
|
+
// fetches the declared catalog and the live semantics tree off the debug-only
|
|
238
|
+
// inspector server (127.0.0.1:9500, see composeApp/src/androidDebug/.../
|
|
239
|
+
// InspectorHttpServer.kt) and runs compareTokenDrift() over them — real runtime
|
|
240
|
+
// drift detection, embedded in the evidence receipt.
|
|
241
|
+
//
|
|
242
|
+
// Infrastructure absence (no device, app not running) is NEVER a FAIL — only
|
|
243
|
+
// actual drift is. curl (via the existing synchronous sh() helper) stands in for
|
|
244
|
+
// an HTTP client here because every step in this lane runs synchronously; a
|
|
245
|
+
// couple of short retries cover the debug app's cold start.
|
|
246
|
+
const INSPECTOR_PORT = 9500;
|
|
247
|
+
|
|
248
|
+
function curlJson(url, timeoutSec = 5) {
|
|
249
|
+
const res = sh(`curl -s -m ${timeoutSec} -w "\\n%{http_code}" "${url}"`);
|
|
250
|
+
if (!res.ok) return { ok: false };
|
|
251
|
+
const out = res.out;
|
|
252
|
+
const idx = out.lastIndexOf("\n");
|
|
253
|
+
const code = (idx >= 0 ? out.slice(idx + 1) : "").trim();
|
|
254
|
+
const bodyText = idx >= 0 ? out.slice(0, idx) : "";
|
|
255
|
+
if (code !== "200") return { ok: false };
|
|
256
|
+
try {
|
|
257
|
+
return { ok: true, body: JSON.parse(bodyText) };
|
|
258
|
+
} catch {
|
|
259
|
+
return { ok: false };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function pollHealth(port, attempts, delaySec) {
|
|
264
|
+
let health = curlJson(`http://127.0.0.1:${port}/inspect/health`);
|
|
265
|
+
for (let tries = 1; !health.ok && tries < attempts; tries += 1) {
|
|
266
|
+
sh(`sleep ${delaySec}`);
|
|
267
|
+
health = curlJson(`http://127.0.0.1:${port}/inspect/health`);
|
|
268
|
+
}
|
|
269
|
+
return health;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function stepTokenDrift() {
|
|
273
|
+
const started = Date.now();
|
|
274
|
+
const elapsed = () => Date.now() - started;
|
|
275
|
+
|
|
276
|
+
if (!deviceAttached()) {
|
|
277
|
+
return {
|
|
278
|
+
name: "tokenDrift",
|
|
279
|
+
verdict: "SKIP",
|
|
280
|
+
reason: "no Android device/emulator attached (adb) — runtime token drift needs the live inspector tier",
|
|
281
|
+
durationMs: elapsed(),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const unreachable = () => ({
|
|
286
|
+
name: "tokenDrift",
|
|
287
|
+
verdict: "SKIP",
|
|
288
|
+
reason: "inspector endpoint not reachable on :9500 (debug app not running?) — launch the debug build to enable the live tier",
|
|
289
|
+
durationMs: elapsed(),
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
sh(`adb forward tcp:${INSPECTOR_PORT} tcp:${INSPECTOR_PORT}`);
|
|
293
|
+
try {
|
|
294
|
+
let health = curlJson(`http://127.0.0.1:${INSPECTOR_PORT}/inspect/health`);
|
|
295
|
+
if (!health.ok) {
|
|
296
|
+
// Debug app may not be running — try to launch it (best-effort: parse the
|
|
297
|
+
// applicationId out of the Android build config), then give it a moment
|
|
298
|
+
// to cold-start before giving up.
|
|
299
|
+
let applicationId = null;
|
|
300
|
+
try {
|
|
301
|
+
const gradle = fs.readFileSync(path.join(ROOT, "composeApp/build.gradle.kts"), "utf8");
|
|
302
|
+
applicationId = gradle.match(/applicationId\s*=\s*"([^"]+)"/)?.[1] ?? null;
|
|
303
|
+
} catch {
|
|
304
|
+
applicationId = null;
|
|
305
|
+
}
|
|
306
|
+
if (applicationId) {
|
|
307
|
+
sh(`adb shell am start -n ${applicationId}/.MainActivity`);
|
|
308
|
+
}
|
|
309
|
+
health = pollHealth(INSPECTOR_PORT, 5, 2);
|
|
310
|
+
}
|
|
311
|
+
if (!health.ok) return unreachable();
|
|
312
|
+
|
|
313
|
+
const designSystem = curlJson(`http://127.0.0.1:${INSPECTOR_PORT}/inspect/design-system`);
|
|
314
|
+
const tree = curlJson(`http://127.0.0.1:${INSPECTOR_PORT}/inspect/tree`);
|
|
315
|
+
if (!designSystem.ok || !tree.ok) return unreachable();
|
|
316
|
+
|
|
317
|
+
const { checked, drifted } = compareTokenDrift(designSystem.body, tree.body);
|
|
318
|
+
|
|
319
|
+
if (drifted.length === 0) {
|
|
320
|
+
return {
|
|
321
|
+
name: "tokenDrift",
|
|
322
|
+
verdict: "PASS",
|
|
323
|
+
durationMs: elapsed(),
|
|
324
|
+
details: { checked, drifted: 0 },
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const lines = ["Runtime token drift — a component's resolved value contradicts the declared design-system catalog:"];
|
|
329
|
+
for (const d of drifted) {
|
|
330
|
+
lines.push(
|
|
331
|
+
` [${d.node}] token '${d.token}' (${d.facet}) — expected ${d.expected}, resolved ${d.actual}. Update the component to use the token, or update the catalog if the token itself changed.`,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
name: "tokenDrift",
|
|
336
|
+
verdict: "FAIL",
|
|
337
|
+
reason: lines.join("\n"),
|
|
338
|
+
durationMs: elapsed(),
|
|
339
|
+
details: { checked, drifted },
|
|
340
|
+
};
|
|
341
|
+
} finally {
|
|
342
|
+
sh(`adb forward --remove tcp:${INSPECTOR_PORT}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function maestroAvailable() {
|
|
347
|
+
return sh("maestro --version", { timeout: 15_000 }).ok;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function stepE2eSmoke() {
|
|
351
|
+
if (!fs.existsSync(path.join(ROOT, "qa/e2e"))) {
|
|
352
|
+
return { name: "e2eSmoke", verdict: "SKIP", reason: "e2e harness not included in this project (--no-e2e)", durationMs: 0 };
|
|
353
|
+
}
|
|
354
|
+
if (!deviceAttached()) {
|
|
355
|
+
return { name: "e2eSmoke", verdict: "SKIP", reason: "no Android device/emulator attached (adb)", durationMs: 0 };
|
|
356
|
+
}
|
|
357
|
+
if (!maestroAvailable()) {
|
|
358
|
+
return { name: "e2eSmoke", verdict: "SKIP", reason: "maestro CLI not installed — curl -fsSL https://get.maestro.mobile.dev | bash", durationMs: 0 };
|
|
359
|
+
}
|
|
360
|
+
const install = sh(`${GRADLEW} :composeApp:installDebug --console=plain`);
|
|
361
|
+
if (!install.ok) {
|
|
362
|
+
return { name: "e2eSmoke", verdict: "FAIL", reason: "installDebug failed — the APK could not be installed on the attached device", durationMs: install.durationMs };
|
|
363
|
+
}
|
|
364
|
+
// Harden the device for headless/CI automation before driving it. Without this, a slow or
|
|
365
|
+
// loaded emulator produces false reds that have nothing to do with the app:
|
|
366
|
+
// - hide_error_dialogs=1 stops Android popping ANR/crash dialogs (e.g. SystemUI under load)
|
|
367
|
+
// that steal focus over the app — a Maestro assert would then see only the dialog;
|
|
368
|
+
// - MAESTRO_DRIVER_STARTUP_TIMEOUT gives the UiAutomator2 driver a generous budget to come
|
|
369
|
+
// up on a slow emulator (the built-in default gives up too early under load).
|
|
370
|
+
// Both are benign, reversible, and only touch the device while the lane is driving it.
|
|
371
|
+
sh("adb shell settings put global hide_error_dialogs 1");
|
|
372
|
+
const res = sh("maestro test qa/e2e/smoke.yaml", { env: { ...process.env, MAESTRO_DRIVER_STARTUP_TIMEOUT: "120000" } });
|
|
373
|
+
return {
|
|
374
|
+
name: "e2eSmoke",
|
|
375
|
+
verdict: res.ok ? "PASS" : "FAIL",
|
|
376
|
+
reason: res.ok ? undefined : `Maestro smoke failed (flow cites the SHELL spec clauses it proves):\n${res.out.split("\n").slice(-15).join("\n")}`,
|
|
377
|
+
durationMs: install.durationMs + res.durationMs,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// ── Lane ───────────────────────────────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
const stepsForProfile = {
|
|
384
|
+
// scaffold: what `create-cmp --verify` proves at stamp time — specCoverage,
|
|
385
|
+
// the full JVM tier (unit + conformance + golden + UI tests) plus the Android build.
|
|
386
|
+
scaffold: [stepSpecCoverage, stepBuild, stepUnitTests],
|
|
387
|
+
local: [
|
|
388
|
+
stepSpecCoverage,
|
|
389
|
+
stepBuild,
|
|
390
|
+
stepUnitTests,
|
|
391
|
+
stepConformance,
|
|
392
|
+
stepGoldenTrees,
|
|
393
|
+
stepTokenDrift,
|
|
394
|
+
stepA11y,
|
|
395
|
+
stepE2eSmoke,
|
|
396
|
+
],
|
|
397
|
+
};
|
|
398
|
+
stepsForProfile.ci = stepsForProfile.local;
|
|
399
|
+
|
|
400
|
+
if (!stepsForProfile[profile]) {
|
|
401
|
+
console.error(`Unknown profile "${profile}" — use scaffold | local | ci.`);
|
|
402
|
+
process.exit(2);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const steps = [];
|
|
406
|
+
for (const step of stepsForProfile[profile]) {
|
|
407
|
+
const result = step();
|
|
408
|
+
steps.push(result);
|
|
409
|
+
if (!asJson) {
|
|
410
|
+
const mark = result.verdict === "PASS" ? "✓" : result.verdict === "SKIP" ? "→" : "✗";
|
|
411
|
+
console.log(`${mark} ${result.name}: ${result.verdict}${result.reason ? ` — ${result.reason.split("\n")[0]}` : ""}`);
|
|
412
|
+
}
|
|
413
|
+
if (result.name === "build" && result.verdict === "FAIL") break; // nothing downstream is meaningful
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const verdict = steps.some((s) => s.verdict === "FAIL") ? "FAIL" : "PASS";
|
|
417
|
+
|
|
418
|
+
// Artifacts: hash whatever the run left under qa-artifacts/ (never committed).
|
|
419
|
+
const artifacts = [];
|
|
420
|
+
if (fs.existsSync(ARTIFACTS_DIR)) {
|
|
421
|
+
const walk = (dir) => {
|
|
422
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
423
|
+
const p = path.join(dir, entry.name);
|
|
424
|
+
if (entry.isDirectory()) walk(p);
|
|
425
|
+
else artifacts.push({ path: path.relative(ROOT, p), sha256: createHash("sha256").update(fs.readFileSync(p)).digest("hex") });
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
walk(ARTIFACTS_DIR);
|
|
429
|
+
artifacts.sort((a, b) => a.path.localeCompare(b.path));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Bind the receipt to the content of the verified surface (ADR-0005), NOT the
|
|
433
|
+
// parent SHA (rebase/merge-fragile). Must be computed before latest.json is
|
|
434
|
+
// written — the receipt is an output and must never hash itself.
|
|
435
|
+
const inputs = computeInputsHash(ROOT);
|
|
436
|
+
|
|
437
|
+
// The receipt. Deterministic key order; ONE volatile timestamp field.
|
|
438
|
+
// commit.sha is the parent HEAD at run time (you cannot know the sha of the
|
|
439
|
+
// commit the receipt will be part of); commit.dirty lists what was uncommitted.
|
|
440
|
+
const receipt = {
|
|
441
|
+
schema: "cmp-evidence/1",
|
|
442
|
+
profile,
|
|
443
|
+
verdict,
|
|
444
|
+
commit: {
|
|
445
|
+
sha: tryGit("rev-parse HEAD"),
|
|
446
|
+
dirty: (tryGit("status --porcelain") ?? "").split("\n").filter(Boolean).map((l) => l.slice(3)).sort(),
|
|
447
|
+
},
|
|
448
|
+
inputs: {
|
|
449
|
+
hash: inputs.hash,
|
|
450
|
+
fileCount: inputs.fileCount,
|
|
451
|
+
},
|
|
452
|
+
steps,
|
|
453
|
+
artifacts,
|
|
454
|
+
toolVersions: {
|
|
455
|
+
node: process.version,
|
|
456
|
+
platform: `${process.platform}-${process.arch}`,
|
|
457
|
+
},
|
|
458
|
+
generatedAt: new Date().toISOString(),
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
fs.mkdirSync(EVIDENCE_DIR, { recursive: true });
|
|
462
|
+
fs.writeFileSync(path.join(EVIDENCE_DIR, "latest.json"), `${JSON.stringify(receipt, null, 2)}\n`);
|
|
463
|
+
|
|
464
|
+
if (asJson) console.log(JSON.stringify(receipt, null, 2));
|
|
465
|
+
else console.log(`\n${verdict === "PASS" ? "✅" : "❌"} verify lane: ${verdict} — receipt written to qa/evidence/latest.json (commit it with your change)`);
|
|
466
|
+
|
|
467
|
+
process.exit(verdict === "PASS" ? 0 : 1);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Behavior specifications
|
|
2
|
+
|
|
3
|
+
**The spec is the source of truth for intended behavior.** Durable tests (Compose UI Tests,
|
|
4
|
+
E2E flows) are its executable projection — every clause maps to at least one test carrying its
|
|
5
|
+
id, and every durable test cites the clause it verifies.
|
|
6
|
+
|
|
7
|
+
## The workflow
|
|
8
|
+
|
|
9
|
+
1. **New behavior begins as a spec clause.** Before writing code, add a clause to the feature's
|
|
10
|
+
spec (AI proposes, human confirms). Changing behavior = changing the clause first.
|
|
11
|
+
2. Implement, mirroring the exemplar. Write/update the durable tests **tagged with the clause
|
|
12
|
+
id** (`// SPEC: HOME-02`). The `// SPEC:` tag convention applies to **all durable tests** —
|
|
13
|
+
not just Compose UI Tests and E2E flows, but `commonTest` unit tests too (ViewModel/UseCase/
|
|
14
|
+
Repository tests carry the tag on the behavior they verify). Conformance tests carry both
|
|
15
|
+
the `// SPEC:` tag comment above the `@Test` annotation **and** the clause id inside the
|
|
16
|
+
failure message's `[CLAUSE-ID]` bracket (see `violation()` in
|
|
17
|
+
`ArchitectureConformanceTest.kt`) — the tag makes the clause greppable from source, the
|
|
18
|
+
bracketed id makes it visible in a failing test's output.
|
|
19
|
+
3. `node qa/verify.mjs` — the lane's `specCoverage` step fails orphan clauses (a live clause
|
|
20
|
+
with no citing test) and orphan tags (a `// SPEC:`/`# SPEC:` tag with no matching clause, or
|
|
21
|
+
one citing a withdrawn clause). Withdrawn clauses are exempt from coverage.
|
|
22
|
+
|
|
23
|
+
## Format
|
|
24
|
+
|
|
25
|
+
One file per feature: `specs/<feature>.spec.md`. Clauses are Given/When/Then with **stable
|
|
26
|
+
ids** (`<FEATURE>-<NN>`) — ids are never renumbered or reused; a withdrawn clause is struck
|
|
27
|
+
through and kept. `app-base.spec.md` covers the base architecture and the app shell the
|
|
28
|
+
scaffold itself ships.
|
|
29
|
+
|
|
30
|
+
> **Why no Cucumber?** We adopt the Given/When/Then *grammar* but reject the Cucumber
|
|
31
|
+
> *runtime*: step-definition glue is a third artifact that drifts from both the spec and the
|
|
32
|
+
> tests. Spec and test are bound by **clause id** instead (`// SPEC:` tag + `[CLAUSE-ID]` in
|
|
33
|
+
> failure messages) — greppable, machine-checkable, no glue to maintain.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Spec: app base — architecture & shell
|
|
2
|
+
|
|
3
|
+
> Clauses the scaffold itself guarantees. ARCH clauses are enforced by the conformance gates
|
|
4
|
+
> (dependency-free source scans); SHELL clauses by UI tests and the E2E smoke flow.
|
|
5
|
+
|
|
6
|
+
## Architecture invariants
|
|
7
|
+
|
|
8
|
+
- **ARCH-01** — Given any file in `presentation`, When its imports are inspected, Then none
|
|
9
|
+
resolve into the `data` layer (presentation depends on domain only).
|
|
10
|
+
- **ARCH-02** — Given any file in `domain`, When its imports are inspected, Then none resolve
|
|
11
|
+
into `presentation`, `data`, or `di`, and none import Compose, Koin, or platform types
|
|
12
|
+
(domain is pure Kotlin).
|
|
13
|
+
- **ARCH-03** — Given any ViewModel class, When the test sources are inspected, Then a
|
|
14
|
+
corresponding `*ViewModelTest` exists (no untested presentation state).
|
|
15
|
+
- **ARCH-04** — Given any file containing a `*Screen` composable, When its source is
|
|
16
|
+
inspected, Then it declares at least one `testTag` (every screen is automation-reachable).
|
|
17
|
+
- **ARCH-05** — Given any file outside `presentation/theme`, When its source is inspected,
|
|
18
|
+
Then it constructs no literal `Color(0x…)` values (design colors come from the token
|
|
19
|
+
catalog).
|
|
20
|
+
|
|
21
|
+
## App shell
|
|
22
|
+
|
|
23
|
+
- **SHELL-01** — Given a fresh install, When the app launches, Then the first tab's screen
|
|
24
|
+
renders inside the shell with the bottom navigation visible (`app_bottom_nav`).
|
|
25
|
+
- **SHELL-02** — Given the shell is visible, When the user taps another tab in the bottom
|
|
26
|
+
nav, Then that tab's screen renders and the bottom nav stays visible.
|
|
27
|
+
- **SHELL-03** — Given any tab screen, When it renders, Then its content is laid out inside
|
|
28
|
+
the safe-area insets owned by `BaseScreen` (edge-to-edge without overlap).
|
|
29
|
+
- **SHELL-04** — Given the app renders any screen, When interactive elements are present,
|
|
30
|
+
Then each is perceivable by automation: it exposes a testTag, text, or content description.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Spec: home (the exemplar feature)
|
|
2
|
+
|
|
3
|
+
> The reference spec — new features copy this shape. Every clause id is cited by the durable
|
|
4
|
+
> test(s) that verify it (`// SPEC: HOME-NN`).
|
|
5
|
+
|
|
6
|
+
- **HOME-01** — Given the Home screen opens, When items are being loaded, Then a loading
|
|
7
|
+
indicator is shown and no items are visible.
|
|
8
|
+
- **HOME-02** — Given the repository returns items, When loading completes, Then the items are
|
|
9
|
+
listed with their title and subtitle, and no error is shown.
|
|
10
|
+
- **HOME-03** — Given the repository fails, When loading completes, Then a human-readable
|
|
11
|
+
error message is shown (`home_error`) and no items are visible.
|
|
12
|
+
- **HOME-04** — Given a load has failed, When the data source recovers and the user triggers a
|
|
13
|
+
reload, Then the error clears and the items render.
|
|
14
|
+
- **HOME-05** — Given items are listed, When the user taps an item, Then the app navigates to
|
|
15
|
+
that item's detail.
|
|
16
|
+
- **HOME-06** — Given the Home screen renders, When its structure is inspected, Then the
|
|
17
|
+
screen matches its committed golden tree (`qa/golden/home.json`) — structural regressions
|
|
18
|
+
are intentional, declared changes only.
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
# Appium harness — __APP_NAME__
|
|
2
|
-
|
|
3
|
-
On-device UI testing for the Android app via Appium 3.x + UiAutomator2.
|
|
4
|
-
|
|
5
|
-
## Prerequisites
|
|
6
|
-
- Appium 3.x server + the `uiautomator2` driver (`appium driver install uiautomator2`).
|
|
7
|
-
- A running Android emulator named `emulator-5554`.
|
|
8
|
-
- The debug build installed: `./gradlew :composeApp:installDebug`.
|
|
9
|
-
|
|
10
|
-
## Run the smoke (JS)
|
|
11
|
-
```bash
|
|
12
|
-
appium & # start the server on :4723
|
|
13
|
-
npm --prefix qa/appium run smoke
|
|
14
|
-
```
|
|
15
|
-
|
|
16
|
-
## Run the BDD suite (Python)
|
|
17
|
-
```bash
|
|
18
|
-
pip install pytest requests
|
|
19
|
-
pytest tests/appium/cmp -v
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
Both assert the generic bottom-nav shell renders (Home + Profile tabs) — the `--verify`
|
|
23
|
-
gate the scaffolder runs to prove the stamped app is alive.
|