create-cmp-cli 0.21.0 → 0.22.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/package.json +1 -1
- package/packages/harness/package.json +1 -1
- package/packages/harness/src/lib/device-provider.mjs +153 -0
- package/packages/harness/src/lib/e2e-coverage.mjs +60 -0
- package/packages/harness/src/lib/evidence-level.mjs +2 -1
- package/packages/harness/src/lib/feature-brief.mjs +16 -3
- package/packages/harness/src/lib/harness-lock.mjs +6 -1
- package/packages/harness/src/lib/spec-coverage.mjs +27 -3
- package/packages/harness/src/lib/step-outcomes.mjs +104 -0
- package/packages/harness/src/lib/steps-cmp.mjs +106 -40
- package/packages/harness/src/receipt-check.mjs +25 -0
- package/packages/harness/src/scaffold-feature.mjs +55 -0
- package/src/commands/upgrade.mjs +31 -2
- package/src/lib/tabs.mjs +18 -3
- package/src/lib/upgrade.mjs +50 -0
- package/template/.github/workflows/verify.yml +9 -0
- package/template/gitignore +9 -0
- package/template/qa/e2e/README.md +23 -4
- package/template/qa/e2e/smoke.yaml +9 -1
- package/template/qa/lib/device-provider.mjs +153 -0
- package/template/qa/lib/e2e-coverage.mjs +60 -0
- package/template/qa/lib/evidence-level.mjs +2 -1
- package/template/qa/lib/feature-brief.mjs +16 -3
- package/template/qa/lib/harness-lock.mjs +6 -1
- package/template/qa/lib/spec-coverage.mjs +27 -3
- package/template/qa/lib/step-outcomes.mjs +104 -0
- package/template/qa/lib/steps-cmp.mjs +106 -40
- package/template/qa/receipt-check.mjs +25 -0
- package/template/qa/scaffold-feature.mjs +55 -0
|
@@ -21,16 +21,18 @@ import fs from "node:fs";
|
|
|
21
21
|
import path from "node:path";
|
|
22
22
|
import { compareTokenDrift } from "./token-drift.mjs";
|
|
23
23
|
import { evaluateApprovalsGate } from "./approvals.mjs";
|
|
24
|
-
import { TIERS_SATISFYING, clauseTierCoverage, scanCitations, scanSpecClauses, walkFiles } from "./spec-coverage.mjs";
|
|
24
|
+
import { E2E_FLOW_DIR, TIERS_SATISFYING, clauseTierCoverage, listFlowFiles, scanCitations, scanSpecClauses, walkFiles } from "./spec-coverage.mjs";
|
|
25
25
|
import { evaluateComponentStoryParity } from "./component-stories.mjs";
|
|
26
26
|
import { evaluateReachability } from "./reachability.mjs";
|
|
27
|
+
import { evaluateE2eCoverage } from "./e2e-coverage.mjs";
|
|
27
28
|
import { memoizeStep } from "./step-cache.mjs";
|
|
28
29
|
import { changedWorkingTreePaths, deriveAffectedFilter } from "./affected-tests.mjs";
|
|
29
30
|
import { acquireDeviceLease, releaseDeviceLease, formatHolder } from "./device-lease.mjs";
|
|
30
31
|
import { ARCH_DOC_REL_PATH, SECTION_IDS, regenerateArchDoc } from "./arch-doc.mjs";
|
|
31
32
|
import { DETERMINISM_TIMEZONES, compareOutcomes, parseJUnitOutcomes } from "./determinism.mjs";
|
|
32
33
|
import { evaluateAuditCadence } from "./audit-cadence.mjs";
|
|
33
|
-
import { androidChecksOutcome } from "./step-outcomes.mjs";
|
|
34
|
+
import { androidChecksOutcome, deviceLogIncidents, maestroOutcome, parseMaestroJunit } from "./step-outcomes.mjs";
|
|
35
|
+
import { ensureDevice, releaseDevice } from "./device-provider.mjs";
|
|
34
36
|
import { checkHarnessIntegrity, describeIntegrity, LOCK_PATH } from "./harness-lock.mjs";
|
|
35
37
|
import { stepDisplayName } from "./lane-runner.mjs";
|
|
36
38
|
import { CMP_LADDER } from "./evidence-level.mjs";
|
|
@@ -115,6 +117,33 @@ function deviceAttached() {
|
|
|
115
117
|
// honest and visible is exactly why SKIP is the right verdict.
|
|
116
118
|
let laneDeviceLease = null;
|
|
117
119
|
|
|
120
|
+
// ── The lane's device (qa/lib/device-provider.mjs) ──────────────────────────
|
|
121
|
+
// Provisioned ONCE per run by the first device step that needs it: an attached
|
|
122
|
+
// device is used as-is; with none attached the lane boots a headless emulator
|
|
123
|
+
// (bounded) and shuts it down in the runner's finally. A device that cannot
|
|
124
|
+
// be provisioned is an ERROR row — "could not check" — never a SKIP that
|
|
125
|
+
// reads as an honest gap and is then ignored forever (2026-09-03: the whole
|
|
126
|
+
// tier had SKIPped on every receipt anyone looked at). The one SKIP left is
|
|
127
|
+
// the explicit opt-out CMP_DEVICE=none, marked skipKind "environment" so the
|
|
128
|
+
// receipt check refuses it as done-evidence.
|
|
129
|
+
let laneDevice = null;
|
|
130
|
+
|
|
131
|
+
function deviceRow(stepName, d) {
|
|
132
|
+
if (d.optOut) return { name: stepName, verdict: "SKIP", skipKind: "environment", reason: d.reason, durationMs: 0 };
|
|
133
|
+
return { name: stepName, verdict: "ERROR", reason: `could not provision a device: ${d.reason}`, durationMs: 0 };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** null when the lane has a device; otherwise the row the step returns verbatim. */
|
|
137
|
+
function ensureLaneDevice(stepName) {
|
|
138
|
+
if (!laneDevice) {
|
|
139
|
+
laneDevice = ensureDevice({ sh, log: (line) => console.error(`· ${line}`) });
|
|
140
|
+
if (laneDevice.ok && laneDevice.booted) {
|
|
141
|
+
console.error(`· booted ${laneDevice.avd} headless (${laneDevice.serial}) in ${Math.round(laneDevice.bootMs / 1000)} s — shut down when the lane exits (CMP_KEEP_DEVICE=1 keeps it)`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return laneDevice.ok ? null : deviceRow(stepName, laneDevice);
|
|
145
|
+
}
|
|
146
|
+
|
|
118
147
|
/** Serials of devices currently in `device` state (same parse as deviceAttached). */
|
|
119
148
|
function attachedDeviceSerials() {
|
|
120
149
|
const res = sh("adb devices", { timeout: 10_000 });
|
|
@@ -150,6 +179,7 @@ function leaseDeviceForStep(stepName) {
|
|
|
150
179
|
return {
|
|
151
180
|
name: stepName,
|
|
152
181
|
verdict: "SKIP",
|
|
182
|
+
skipKind: "environment",
|
|
153
183
|
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.`,
|
|
154
184
|
durationMs: 0,
|
|
155
185
|
};
|
|
@@ -160,6 +190,7 @@ function leaseDeviceForStep(stepName) {
|
|
|
160
190
|
return {
|
|
161
191
|
name: stepName,
|
|
162
192
|
verdict: "SKIP",
|
|
193
|
+
skipKind: "environment",
|
|
163
194
|
reason: `device ${serial} is held by ${formatHolder(res.heldBy)} — device evidence is batched, not concurrent; wait for it or run once when it finishes`,
|
|
164
195
|
durationMs: 0,
|
|
165
196
|
};
|
|
@@ -383,6 +414,16 @@ function stepReachability() {
|
|
|
383
414
|
// (regenerateArchDoc); this step only adds the name/duration bookkeeping every
|
|
384
415
|
// step in this file carries, plus wording the FAIL reason for an AI
|
|
385
416
|
// collaborator (name the stale/missing section, name the fix command).
|
|
417
|
+
// Every real feature has a device journey (qa/lib/e2e-coverage.mjs): a screen
|
|
418
|
+
// plus a spec means at least one live clause cited from a flow the lane runs.
|
|
419
|
+
// Pure Node. This is the gate that makes "write the Maestro flow" a lane
|
|
420
|
+
// verdict instead of a habit (Karel, 2026-09-03).
|
|
421
|
+
function stepE2eCoverage() {
|
|
422
|
+
const started = Date.now();
|
|
423
|
+
const { verdict, reason, details } = evaluateE2eCoverage(ROOT);
|
|
424
|
+
return { name: "e2eCoverage", verdict, reason, durationMs: Date.now() - started, details };
|
|
425
|
+
}
|
|
426
|
+
|
|
386
427
|
function stepArchDoc() {
|
|
387
428
|
const started = Date.now();
|
|
388
429
|
const elapsed = () => Date.now() - started;
|
|
@@ -789,14 +830,8 @@ function stepTokenDrift() {
|
|
|
789
830
|
const started = Date.now();
|
|
790
831
|
const elapsed = () => Date.now() - started;
|
|
791
832
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
name: "tokenDrift",
|
|
795
|
-
verdict: "SKIP",
|
|
796
|
-
reason: "no Android device/emulator attached (adb) — runtime token drift needs the live inspector tier",
|
|
797
|
-
durationMs: elapsed(),
|
|
798
|
-
};
|
|
799
|
-
}
|
|
833
|
+
const device = ensureLaneDevice("tokenDrift");
|
|
834
|
+
if (device) return { ...device, durationMs: elapsed() };
|
|
800
835
|
|
|
801
836
|
const unreachable = () => ({
|
|
802
837
|
name: "tokenDrift",
|
|
@@ -870,15 +905,17 @@ function maestroAvailable() {
|
|
|
870
905
|
// The e2e guard trio, shared by every step that drives the smoke flow on a device.
|
|
871
906
|
// Returns null when the harness is fully available, else the SKIP result for [name].
|
|
872
907
|
function maestroGuards(name) {
|
|
873
|
-
if (!fs.existsSync(path.join(ROOT,
|
|
874
|
-
return { name, verdict: "SKIP", reason: "e2e harness not included in this project (--no-e2e)", durationMs: 0 };
|
|
908
|
+
if (!fs.existsSync(path.join(ROOT, E2E_FLOW_DIR))) {
|
|
909
|
+
return { name, verdict: "SKIP", skipKind: "structure", reason: "e2e harness not included in this project (--no-e2e)", durationMs: 0 };
|
|
875
910
|
}
|
|
876
|
-
if (
|
|
877
|
-
return { name, verdict: "SKIP",
|
|
911
|
+
if (listFlowFiles(ROOT).length === 0) {
|
|
912
|
+
return { name, verdict: "SKIP", skipKind: "structure", reason: `${E2E_FLOW_DIR}/ holds no flows (*.yaml) — nothing to drive`, durationMs: 0 };
|
|
878
913
|
}
|
|
879
914
|
if (!maestroAvailable()) {
|
|
880
|
-
return { name, verdict: "SKIP", reason: "maestro CLI not installed — curl -fsSL https://get.maestro.mobile.dev | bash", durationMs: 0 };
|
|
915
|
+
return { name, verdict: "SKIP", skipKind: "environment", reason: "maestro CLI not installed — curl -fsSL https://get.maestro.mobile.dev | bash", durationMs: 0 };
|
|
881
916
|
}
|
|
917
|
+
const device = ensureLaneDevice(name);
|
|
918
|
+
if (device) return device;
|
|
882
919
|
return null;
|
|
883
920
|
}
|
|
884
921
|
|
|
@@ -897,32 +934,59 @@ function maestroGuards(name) {
|
|
|
897
934
|
// hide_error_dialogs suppresses the OS dialog, NEVER the underlying event — so after the
|
|
898
935
|
// run we grep the device log for ANR/crash lines the dialog would have shown, and FAIL on
|
|
899
936
|
// them. The eyes must report what automation stability had to hide.
|
|
937
|
+
// EVERY flow runs (2026-09-03). This used to run qa/e2e/smoke.yaml by name
|
|
938
|
+
// while spec coverage counted a citation from ANY yaml under qa/e2e — four
|
|
939
|
+
// hand-written per-feature flows on the showcase satisfied clauses without
|
|
940
|
+
// ever executing. The directory runs in ONE Maestro session (one driver
|
|
941
|
+
// start-up, per-flow rows from the JUnit report); listFlowFiles is the same
|
|
942
|
+
// list the coverage scan reads, so cited ⊆ executed holds by construction.
|
|
943
|
+
/** Driver start-up plus three minutes per flow: the Maestro run's own bound, whatever the journal says. */
|
|
944
|
+
const E2E_RUN_BOUND_MS = (flowCount) => 120_000 + 180_000 * Math.max(1, flowCount);
|
|
945
|
+
|
|
900
946
|
function runMaestroSmoke(name, priorDurationMs) {
|
|
947
|
+
const flows = listFlowFiles(ROOT);
|
|
948
|
+
const report = path.join(ROOT, "qa-artifacts", `maestro-${name}.xml`);
|
|
949
|
+
fs.mkdirSync(path.dirname(report), { recursive: true });
|
|
950
|
+
fs.rmSync(report, { force: true });
|
|
901
951
|
const prevHideErrorDialogs = sh("adb shell settings get global hide_error_dialogs").out.trim();
|
|
902
952
|
sh("adb shell settings put global hide_error_dialogs 1");
|
|
903
953
|
sh("adb logcat -c"); // clear so the post-run dump only reflects this run
|
|
904
954
|
try {
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
955
|
+
// Bounded on its own, not only by the step deadline: a FIRST run has no
|
|
956
|
+
// journal, so its deadline is the 30-minute ceiling — and on 2026-09-03
|
|
957
|
+
// Maestro selected the device and then sat, driver never started, app
|
|
958
|
+
// never in the foreground, under another lane's build load. Two
|
|
959
|
+
// minutes of driver start-up plus three per flow is generous for a
|
|
960
|
+
// healthy device; past it the run is killed and the row reads ERROR
|
|
961
|
+
// (StepTimeout), which is what "did not get to check" should say.
|
|
962
|
+
const runBoundMs = E2E_RUN_BOUND_MS(flows.length);
|
|
963
|
+
const res = sh(`maestro test ${E2E_FLOW_DIR} --format junit --output "${report}"`, { env: { ...process.env, MAESTRO_DRIVER_STARTUP_TIMEOUT: "120000" }, timeout: runBoundMs });
|
|
964
|
+
let xml = null;
|
|
965
|
+
try {
|
|
966
|
+
xml = fs.readFileSync(report, "utf8");
|
|
967
|
+
} catch {
|
|
968
|
+
xml = null;
|
|
913
969
|
}
|
|
970
|
+
const outcome = maestroOutcome(res, parseMaestroJunit(xml), flows);
|
|
971
|
+
if (outcome.verdict !== "PASS") {
|
|
972
|
+
return { name, verdict: outcome.verdict, reason: outcome.reason, durationMs: priorDurationMs + res.durationMs, details: outcome.details };
|
|
973
|
+
}
|
|
974
|
+
// The post-run sweep is scoped to THIS app's process(es): the flows' own
|
|
975
|
+
// appId lines say which. Another app misbehaving on a shared emulator is
|
|
976
|
+
// not this lane's red (deviceLogIncidents).
|
|
977
|
+
const appIds = [...new Set(flows.map((rel) => (fs.readFileSync(path.join(ROOT, rel), "utf8").match(/^appId:\s*(\S+)/m) || [])[1]).filter(Boolean))];
|
|
914
978
|
const anrDump = sh("adb logcat -d -b system,crash,main");
|
|
915
|
-
const
|
|
916
|
-
if (
|
|
917
|
-
const anrLines = anrDump.out.split("\n").filter((l) => anrRe.test(l)).slice(0, 10).join("\n");
|
|
979
|
+
const incidents = anrDump.ok ? deviceLogIncidents(anrDump.out, appIds) : { lines: [], scoped: appIds.length > 0 };
|
|
980
|
+
if (incidents.lines.length) {
|
|
918
981
|
return {
|
|
919
982
|
name,
|
|
920
983
|
verdict: "FAIL",
|
|
921
|
-
reason: `Maestro
|
|
984
|
+
reason: `Maestro flows passed, but the device log shows an ANR/crash in ${incidents.scoped ? appIds.join(", ") : "some process (no appId known to scope by)"} during the run (hide_error_dialogs only suppresses the OS dialog, never the underlying event):\n${incidents.lines.slice(0, 10).join("\n")}`,
|
|
922
985
|
durationMs: priorDurationMs + res.durationMs,
|
|
986
|
+
details: outcome.details,
|
|
923
987
|
};
|
|
924
988
|
}
|
|
925
|
-
return { name, verdict: "PASS", durationMs: priorDurationMs + res.durationMs };
|
|
989
|
+
return { name, verdict: "PASS", durationMs: priorDurationMs + res.durationMs, note: `${flows.length} flow${flows.length === 1 ? "" : "s"}`, details: outcome.details, ...(outcome.reason ? { reason: outcome.reason } : {}) };
|
|
926
990
|
} finally {
|
|
927
991
|
if (prevHideErrorDialogs && prevHideErrorDialogs !== "null") {
|
|
928
992
|
sh(`adb shell settings put global hide_error_dialogs ${prevHideErrorDialogs}`);
|
|
@@ -968,18 +1032,13 @@ function stepAndroidChecks() {
|
|
|
968
1032
|
return {
|
|
969
1033
|
name: "androidChecks",
|
|
970
1034
|
verdict: "SKIP",
|
|
1035
|
+
skipKind: "structure",
|
|
971
1036
|
reason: "no instrumented tests (composeApp/src/androidInstrumentedTest has no Kotlin sources)",
|
|
972
1037
|
durationMs: Date.now() - started,
|
|
973
1038
|
};
|
|
974
1039
|
}
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
name: "androidChecks",
|
|
978
|
-
verdict: "SKIP",
|
|
979
|
-
reason: "no Android device/emulator attached (adb) — instrumented behavior needs the real process boundary",
|
|
980
|
-
durationMs: Date.now() - started,
|
|
981
|
-
};
|
|
982
|
-
}
|
|
1040
|
+
const device = ensureLaneDevice("androidChecks");
|
|
1041
|
+
if (device) return { ...device, durationMs: Date.now() - started };
|
|
983
1042
|
// Machine-global lease before the first device touch (contention = SKIP).
|
|
984
1043
|
const leaseSkip = leaseDeviceForStep("androidChecks");
|
|
985
1044
|
if (leaseSkip) return { ...leaseSkip, durationMs: Date.now() - started };
|
|
@@ -1154,6 +1213,7 @@ const MEMOIZED_STEP_INPUTS = {
|
|
|
1154
1213
|
approvals: ["qa/approvals.json", "specs", "docs/features", "docs/ARCHITECTURE.md", "composeApp/src"],
|
|
1155
1214
|
componentStories: ["composeApp/src"],
|
|
1156
1215
|
reachability: ["composeApp/src", "docs/features"],
|
|
1216
|
+
e2eCoverage: ["composeApp/src", "docs/features", "specs", "qa/e2e"],
|
|
1157
1217
|
archDoc: ["docs/ARCHITECTURE.md", "docs/adr", "specs", "composeApp/src"],
|
|
1158
1218
|
};
|
|
1159
1219
|
|
|
@@ -1173,12 +1233,13 @@ const stepSpecCoverageMemo = memoized("specCoverage", stepSpecCoverage);
|
|
|
1173
1233
|
const stepApprovalsMemo = memoized("approvals", stepApprovals);
|
|
1174
1234
|
const stepComponentStoriesMemo = memoized("componentStories", stepComponentStories);
|
|
1175
1235
|
const stepReachabilityMemo = memoized("reachability", stepReachability);
|
|
1236
|
+
const stepE2eCoverageMemo = memoized("e2eCoverage", stepE2eCoverage);
|
|
1176
1237
|
const stepArchDocMemo = memoized("archDoc", stepArchDoc);
|
|
1177
1238
|
|
|
1178
1239
|
const stepsForProfile = {
|
|
1179
1240
|
// scaffold: what `create-cmp --verify` proves at stamp time — specCoverage,
|
|
1180
1241
|
// the full JVM tier (unit + conformance + golden + UI tests) plus the Android build.
|
|
1181
|
-
scaffold: [stepHarnessIntegrity, stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepArchDocMemo, stepSchemaHistory, stepBuild, stepUnitTests],
|
|
1242
|
+
scaffold: [stepHarnessIntegrity, stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepE2eCoverageMemo, stepArchDocMemo, stepSchemaHistory, stepBuild, stepUnitTests],
|
|
1182
1243
|
// smoke (docs/GATE-RULES.md Rule 0, docs/PRINCIPLES.md #2): the smallest
|
|
1183
1244
|
// end-to-end lane — every pure-Node step through the REAL runner, marker,
|
|
1184
1245
|
// receipt and journal, and NO Gradle, no device, no network. Its job is to
|
|
@@ -1187,7 +1248,7 @@ const stepsForProfile = {
|
|
|
1187
1248
|
// fresh scaffold, then FAIL BY NAME on one planted spec edit, each bounded
|
|
1188
1249
|
// in seconds. Its receipt is refused as done-evidence (qa/receipt-check.mjs)
|
|
1189
1250
|
// exactly like --fast: it proves the instrument, never the change.
|
|
1190
|
-
smoke: [stepHarnessIntegrity, stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepArchDocMemo, stepSchemaHistory],
|
|
1251
|
+
smoke: [stepHarnessIntegrity, stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepE2eCoverageMemo, stepArchDocMemo, stepSchemaHistory],
|
|
1191
1252
|
local: [
|
|
1192
1253
|
// First, always: every verdict below is only worth what the lane issuing
|
|
1193
1254
|
// it is worth.
|
|
@@ -1196,6 +1257,7 @@ const stepsForProfile = {
|
|
|
1196
1257
|
stepApprovalsMemo,
|
|
1197
1258
|
stepComponentStoriesMemo,
|
|
1198
1259
|
stepReachabilityMemo,
|
|
1260
|
+
stepE2eCoverageMemo,
|
|
1199
1261
|
stepArchDocMemo,
|
|
1200
1262
|
stepSchemaHistory,
|
|
1201
1263
|
stepBuild,
|
|
@@ -1248,7 +1310,7 @@ stepsForProfile.release = [...stepsForProfile.ci, stepAuditCadence, stepReleaseS
|
|
|
1248
1310
|
// nightly (evidence-economics S6 / proposal P4): the stage for proofs whose cost
|
|
1249
1311
|
// scales with the SUITE rather than with the change — the determinism probe
|
|
1250
1312
|
// today (forced on above; `--determinism` is implied), and the place any
|
|
1251
|
-
// future
|
|
1313
|
+
// future load / chaos step lands, so the placement decision is made
|
|
1252
1314
|
// once instead of per expensive step. It proves the harness and the tree's
|
|
1253
1315
|
// invariants, not a change: qa/receipt-check.mjs refuses its receipt as
|
|
1254
1316
|
// done-evidence, exactly as it refuses --fast. Same step set as ci on purpose —
|
|
@@ -1266,7 +1328,7 @@ stepsForProfile.nightly = [...stepsForProfile.ci];
|
|
|
1266
1328
|
// physical device. Layer names are free-form strings on the wire; these are
|
|
1267
1329
|
// this pack's. Derived by NAME after the lists are built so a step listed in
|
|
1268
1330
|
// two profiles is tagged once, and a step nobody listed is never tagged.
|
|
1269
|
-
const SPINE_STEP_NAMES = new Set(["harnessIntegrity", "specCoverage", "approvals", "componentStories", "reachability", "archDoc", "schemaHistory", "auditCadence", "determinism"]);
|
|
1331
|
+
const SPINE_STEP_NAMES = new Set(["harnessIntegrity", "specCoverage", "approvals", "componentStories", "reachability", "e2eCoverage", "archDoc", "schemaHistory", "auditCadence", "determinism"]);
|
|
1270
1332
|
function layerForStep(name) {
|
|
1271
1333
|
if (DEVICE_STEPS.includes(name)) return "device";
|
|
1272
1334
|
if (SPINE_STEP_NAMES.has(name)) return "spine";
|
|
@@ -1307,6 +1369,10 @@ for (const name of FAST_EXCLUDED_NAMES) {
|
|
|
1307
1369
|
// decision above); the spine releases it in the runner's finally.
|
|
1308
1370
|
releaseLease: () => {
|
|
1309
1371
|
if (laneDeviceLease) releaseDeviceLease(laneDeviceLease);
|
|
1372
|
+
// The emulator this lane booted goes down with the lane; an attached
|
|
1373
|
+
// device is never touched (device-provider.mjs).
|
|
1374
|
+
const down = releaseDevice(laneDevice, { sh });
|
|
1375
|
+
if (down.shutdown) console.error(`· shut down ${laneDevice.avd} (${laneDevice.serial})`);
|
|
1310
1376
|
},
|
|
1311
1377
|
};
|
|
1312
1378
|
}
|
|
@@ -108,6 +108,31 @@ function evaluate() {
|
|
|
108
108
|
profile: receipt.profile,
|
|
109
109
|
};
|
|
110
110
|
}
|
|
111
|
+
// The device tier must have RUN (2026-09-03). The lane boots a headless
|
|
112
|
+
// emulator itself, so e2eSmoke/androidChecks only SKIP for two kinds of
|
|
113
|
+
// reason: the project's own structure (no qa/e2e harness, no instrumented
|
|
114
|
+
// sources — honest, allowed) or the ENVIRONMENT (CMP_DEVICE=none, maestro
|
|
115
|
+
// not installed, a lease held elsewhere, an ambiguous serial). The second
|
|
116
|
+
// kind is a gap a human can close, and a change is not done while it stands.
|
|
117
|
+
// Receipts predating `skipKind` are read by their reason text.
|
|
118
|
+
const DEVICE_TIER = ["e2eSmoke", "androidChecks"];
|
|
119
|
+
const envSkipped = (Array.isArray(receipt.steps) ? receipt.steps : []).filter(
|
|
120
|
+
(s) =>
|
|
121
|
+
s &&
|
|
122
|
+
DEVICE_TIER.includes(s.name) &&
|
|
123
|
+
s.verdict === "SKIP" &&
|
|
124
|
+
(s.skipKind === "environment" ||
|
|
125
|
+
(!s.skipKind && /no Android device|maestro CLI not installed|is held by|devices attached|CMP_DEVICE=none/.test(String(s.reason ?? "")))),
|
|
126
|
+
);
|
|
127
|
+
if (envSkipped.length) {
|
|
128
|
+
return {
|
|
129
|
+
valid: false,
|
|
130
|
+
reason:
|
|
131
|
+
`the device tier did not run — ${envSkipped.map((s) => `${s.name}: ${String(s.reason ?? "").split("\n")[0]}`).join("; ")}. ` +
|
|
132
|
+
"The lane boots a headless emulator itself (set CMP_AVD if it cannot choose one); fix the cause and run `node qa/verify.mjs` again before finishing",
|
|
133
|
+
profile: receipt.profile,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
111
136
|
// A surface this project cannot resolve is a REFUSAL with an explanation,
|
|
112
137
|
// never an unhandled stack trace: this runs as the Stop hook on every turn
|
|
113
138
|
// end, and a crash there reads as a broken harness rather than as the
|
|
@@ -315,6 +315,55 @@ const ALL_FILES = [
|
|
|
315
315
|
{ from: path.join(ROOT, `specs/${SOURCE_f}.spec.md`), to: path.join(ROOT, `specs/${f}.spec.md`), isDefaultSpec: true, presets: ["feature", "screen"] },
|
|
316
316
|
];
|
|
317
317
|
|
|
318
|
+
// The feature's Maestro flow (2026-09-03): every feature with a screen gets
|
|
319
|
+
// qa/e2e/<feature>.yaml, because the lane now runs EVERY flow in that
|
|
320
|
+
// directory and a feature with no flow has no device-tier journey by
|
|
321
|
+
// construction. It is a SKELETON that passes — launch + shell — with the
|
|
322
|
+
// feature's screen id and its spec clauses named as the work to do; it cites
|
|
323
|
+
// nothing (a citation it has not earned would read as coverage). Demand comes
|
|
324
|
+
// from the spec: a clause marked `[tier: e2e]` fails specCoverage until a
|
|
325
|
+
// flow cites them. Written only when the project carries the e2e harness.
|
|
326
|
+
const E2E_FLOW_PATH = path.join(ROOT, "qa", "e2e", `${f}.yaml`);
|
|
327
|
+
const WRITE_E2E_FLOW = ["feature", "screen"].includes(preset) && fs.existsSync(path.join(ROOT, "qa", "e2e")) && !fs.existsSync(E2E_FLOW_PATH);
|
|
328
|
+
function e2eFlowSkeleton() {
|
|
329
|
+
const appId = (() => {
|
|
330
|
+
try {
|
|
331
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(ROOT, "create-cmp.json"), "utf8"));
|
|
332
|
+
if (typeof cfg.package === "string" && cfg.package) return cfg.package;
|
|
333
|
+
} catch {
|
|
334
|
+
/* fall through to the smoke flow's own appId */
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
const m = fs.readFileSync(path.join(ROOT, "qa", "e2e", "smoke.yaml"), "utf8").match(/^appId:\s*(\S+)/m);
|
|
338
|
+
if (m) return m[1];
|
|
339
|
+
} catch {
|
|
340
|
+
/* no smoke flow */
|
|
341
|
+
}
|
|
342
|
+
return "__PACKAGE__";
|
|
343
|
+
})();
|
|
344
|
+
return `# E2E flow — ${f}. Maestro. The lane runs every flow in qa/e2e/ (e2eSmoke).
|
|
345
|
+
#
|
|
346
|
+
# SKELETON stamped by qa/scaffold-feature.mjs: it launches the app and proves the
|
|
347
|
+
# shell, then stops. Make it the ${f} journey:
|
|
348
|
+
# 1. navigate to the screen (its title carries testTag "${f}_title");
|
|
349
|
+
# 2. assert the behaviour the clauses in specs/${f}.spec.md promise — one
|
|
350
|
+
# "# SPEC: ${F_UPPER}-NN" line above the steps that prove each clause;
|
|
351
|
+
# 3. mark clauses only a device can observe "[tier: e2e]" in the spec — until a
|
|
352
|
+
# flow cites them, specCoverage FAILs by name, which is the point.
|
|
353
|
+
# Selectors by testTag (id:), never display text. After any interaction that
|
|
354
|
+
# triggers async state, assert with extendedWaitUntil (see smoke.yaml's SETTLE RULE).
|
|
355
|
+
appId: ${appId}
|
|
356
|
+
---
|
|
357
|
+
- launchApp:
|
|
358
|
+
clearState: true
|
|
359
|
+
- extendedWaitUntil:
|
|
360
|
+
visible:
|
|
361
|
+
id: "app_bottom_nav"
|
|
362
|
+
timeout: 60000
|
|
363
|
+
# TODO(${f}): navigate to ${f} and assertVisible id: "${f}_title", then the ${F_UPPER}-NN journeys.
|
|
364
|
+
`;
|
|
365
|
+
}
|
|
366
|
+
|
|
318
367
|
const FILES = ALL_FILES.filter((file) => file.presets.includes(preset));
|
|
319
368
|
|
|
320
369
|
// Golden baseline: NOT copied (a feature's golden tree is captured fresh via
|
|
@@ -689,6 +738,12 @@ for (const file of FILES) {
|
|
|
689
738
|
fs.writeFileSync(file.to, contents);
|
|
690
739
|
filesWritten += 1;
|
|
691
740
|
}
|
|
741
|
+
if (WRITE_E2E_FLOW) {
|
|
742
|
+
fs.mkdirSync(path.dirname(E2E_FLOW_PATH), { recursive: true });
|
|
743
|
+
fs.writeFileSync(E2E_FLOW_PATH, e2eFlowSkeleton());
|
|
744
|
+
console.log(` wrote ${path.relative(ROOT, E2E_FLOW_PATH)} — the feature's Maestro flow (skeleton; the lane runs it)`);
|
|
745
|
+
console.log(` NOTE: the lane's e2eCoverage gate FAILs for ${f} until that flow is the journey and cites a ${F_UPPER}-NN clause it proves — that is the point.`);
|
|
746
|
+
}
|
|
692
747
|
|
|
693
748
|
let injectionsApplied = 0;
|
|
694
749
|
for (const result of fileResults) {
|
package/src/commands/upgrade.mjs
CHANGED
|
@@ -41,7 +41,7 @@ import { flagBool } from "../lib/args.mjs";
|
|
|
41
41
|
import { colors, ok, warn, fail, step } from "../lib/log.mjs";
|
|
42
42
|
import { consent } from "../bootstrap/exec.mjs";
|
|
43
43
|
import { loadRegistry, latestSet, getSet } from "../lib/registry.mjs";
|
|
44
|
-
import { planUpgrade, BACKUP_SUFFIX } from "../lib/upgrade.mjs";
|
|
44
|
+
import { planUpgrade, BACKUP_SUFFIX, sidecarDroppedLines, staleBackupPaths } from "../lib/upgrade.mjs";
|
|
45
45
|
import { writeHarnessLock, checkHarnessIntegrity, describeIntegrity } from "../../packages/harness/src/lib/harness-lock.mjs";
|
|
46
46
|
import { LOCAL_PATCH_PATH, stampBaseWith } from "../lib/harness-upgrade.mjs";
|
|
47
47
|
import { buildTokenMap } from "../lib/tokens.mjs";
|
|
@@ -383,11 +383,25 @@ async function harnessPlanAndApply({ flags, record, projectDir, targetDir, tmpRo
|
|
|
383
383
|
const actionable = plan.entries.filter(
|
|
384
384
|
(e) => e.write !== null || e.sidecar !== null || e.remove
|
|
385
385
|
);
|
|
386
|
+
removeStaleBackups(projectDir);
|
|
386
387
|
const result = applyHarnessPlan(projectDir, actionable);
|
|
387
388
|
for (const f of result.written) ok(`wrote ${f} ${colors.dim(`(backup: ${f}${BACKUP_SUFFIX})`)}`);
|
|
388
389
|
for (const f of result.created) ok(`created ${f}`);
|
|
389
390
|
for (const f of result.deleted) ok(`deleted ${f} ${colors.dim(`(backup: ${f}${BACKUP_SUFFIX})`)}`);
|
|
390
|
-
for (const f of result.sidecars)
|
|
391
|
+
for (const f of result.sidecars) {
|
|
392
|
+
warn(`conflict sidecar ${f} — resolve by hand, then delete it`);
|
|
393
|
+
// Name what taking the sidecar would DROP, so the obvious resolution is
|
|
394
|
+
// never a silent loss (the showcase's signing-key ignores, three upgrades running).
|
|
395
|
+
let dropped = [];
|
|
396
|
+
try {
|
|
397
|
+
dropped = sidecarDroppedLines(fs.readFileSync(path.join(projectDir, f), "utf8"), fs.readFileSync(path.join(projectDir, f + SIDECAR_SUFFIX), "utf8"));
|
|
398
|
+
} catch {
|
|
399
|
+
dropped = [];
|
|
400
|
+
}
|
|
401
|
+
if (dropped.length) {
|
|
402
|
+
warn(` the sidecar lacks ${dropped.length} line${dropped.length === 1 ? "" : "s"} your ${f} has — taking it as-is would drop: ${dropped.slice(0, 4).join(" | ")}${dropped.length > 4 ? " | …" : ""}`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
391
405
|
|
|
392
406
|
// ── Re-lock the lane ──────────────────────────────────────────────────────
|
|
393
407
|
// The machine-owned region always lands on the new engine's content, whether
|
|
@@ -576,6 +590,7 @@ export async function runUpgrade(flags, positional) {
|
|
|
576
590
|
{ path: wrapperPropsPath, content: plan.newWrapperPropertiesContent },
|
|
577
591
|
{ path: buildGradlePath, content: plan.newBuildGradleContent },
|
|
578
592
|
];
|
|
593
|
+
removeStaleBackups(projectDir);
|
|
579
594
|
for (const w of writes) {
|
|
580
595
|
if (w.content === null) continue;
|
|
581
596
|
fs.copyFileSync(w.path, w.path + BACKUP_SUFFIX);
|
|
@@ -602,3 +617,17 @@ export async function runUpgrade(flags, positional) {
|
|
|
602
617
|
);
|
|
603
618
|
process.exit(0);
|
|
604
619
|
}
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
/** Backups an EARLIER upgrade left (gitignored *.bak-upgrade) go before this one writes its own. */
|
|
623
|
+
function removeStaleBackups(projectDir) {
|
|
624
|
+
const stale = staleBackupPaths(projectDir);
|
|
625
|
+
for (const rel of stale) {
|
|
626
|
+
try {
|
|
627
|
+
fs.rmSync(path.join(projectDir, rel), { force: true });
|
|
628
|
+
} catch {
|
|
629
|
+
/* a backup that cannot be removed is left, and named below either way */
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
if (stale.length) ok(`removed ${stale.length} stale *${BACKUP_SUFFIX} file${stale.length === 1 ? "" : "s"} from earlier upgrades`);
|
|
633
|
+
}
|
package/src/lib/tabs.mjs
CHANGED
|
@@ -155,10 +155,25 @@ ${entries}
|
|
|
155
155
|
* the static template file for the default tabs.
|
|
156
156
|
* @param {ReturnType<typeof tabInfos>} infos
|
|
157
157
|
*/
|
|
158
|
+
const HOME_ITEM_BLOCK = `
|
|
159
|
+
|
|
160
|
+
# SPEC: HOME-02 — when loading completes, the items are listed: the first item's row
|
|
161
|
+
# is on screen (the exemplar's device journey; the lane's e2eCoverage gate asks every
|
|
162
|
+
# feature with a screen and a spec for at least one clause proven by a flow).
|
|
163
|
+
- extendedWaitUntil:
|
|
164
|
+
visible:
|
|
165
|
+
id: "home_item_1"
|
|
166
|
+
timeout: 30000`;
|
|
167
|
+
|
|
158
168
|
export function renderSmokeYaml(infos) {
|
|
159
169
|
const [first, ...rest] = infos;
|
|
160
170
|
const lines = [];
|
|
161
|
-
|
|
171
|
+
// The exemplar tab (`home`, HomeScreen over the stub repository) carries the
|
|
172
|
+
// one device-proven HOME clause a fresh scaffold needs to pass e2eCoverage:
|
|
173
|
+
// after loading, the first item's row is on screen. Emitted only for that
|
|
174
|
+
// tab — a placeholder tab has no clauses to prove.
|
|
175
|
+
const homeBlock = (tab) => (tab.slug === "home" ? HOME_ITEM_BLOCK : "");
|
|
176
|
+
lines.push(`# E2E smoke — Maestro flow. SPEC: SHELL-01, SHELL-02${infos.some((t) => t.slug === "home") ? ", HOME-02" : ""}.
|
|
162
177
|
#
|
|
163
178
|
# Proves the real app boots on a device/emulator and the bottom-nav shell works.
|
|
164
179
|
# Selectors go by testTag (surfaced as resource-ids on Android via TestTagAutomation),
|
|
@@ -188,7 +203,7 @@ appId: __PACKAGE__
|
|
|
188
203
|
id: "${first.slug}_title"
|
|
189
204
|
timeout: 60000
|
|
190
205
|
- assertVisible:
|
|
191
|
-
id: "app_bottom_nav"`);
|
|
206
|
+
id: "app_bottom_nav"${homeBlock(first)}`);
|
|
192
207
|
|
|
193
208
|
rest.forEach((tab, i) => {
|
|
194
209
|
lines.push("");
|
|
@@ -198,7 +213,7 @@ appId: __PACKAGE__
|
|
|
198
213
|
- assertVisible:
|
|
199
214
|
id: "${tab.slug}_title"
|
|
200
215
|
- assertVisible:
|
|
201
|
-
id: "app_bottom_nav"`);
|
|
216
|
+
id: "app_bottom_nav"${homeBlock(tab)}`);
|
|
202
217
|
});
|
|
203
218
|
|
|
204
219
|
if (rest.length > 0) {
|
package/src/lib/upgrade.mjs
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
import { parseVersions, updateTomlValues, upsertProperty, parseProperties } from "./toml.mjs";
|
|
8
8
|
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
|
|
9
11
|
export const BACKUP_SUFFIX = ".bak-upgrade";
|
|
10
12
|
|
|
11
13
|
/** Marker comment the golden template ships in libs.versions.toml. */
|
|
@@ -201,3 +203,51 @@ export function planUpgrade({ tomlContent, gradlePropertiesContent, wrapperPrope
|
|
|
201
203
|
fromOurTemplate: looksLikeOurTemplate(tomlContent),
|
|
202
204
|
};
|
|
203
205
|
}
|
|
206
|
+
|
|
207
|
+
// ── Two upgrade courtesies the showcase asked for (2026-09-03) ──────────────
|
|
208
|
+
/**
|
|
209
|
+
* Lines of YOUR file a conflict sidecar does not carry — what "take the
|
|
210
|
+
* sidecar" would silently drop. Third consecutive upgrade on the showcase:
|
|
211
|
+
* the .gitignore sidecar lacked the four signing-key ignores, and an agent
|
|
212
|
+
* resolving by taking the sidecar would leave the keystore one `git add -A`
|
|
213
|
+
* from a public repo. Blank and comment lines are not content.
|
|
214
|
+
* @param {string} yours
|
|
215
|
+
* @param {string} sidecar
|
|
216
|
+
* @returns {string[]}
|
|
217
|
+
*/
|
|
218
|
+
export function sidecarDroppedLines(yours, sidecar) {
|
|
219
|
+
const content = (text) =>
|
|
220
|
+
String(text ?? "")
|
|
221
|
+
.split("\n")
|
|
222
|
+
.map((l) => l.trim())
|
|
223
|
+
.filter((l) => l && !l.startsWith("#"));
|
|
224
|
+
const have = new Set(content(sidecar));
|
|
225
|
+
return [...new Set(content(yours).filter((l) => !have.has(l)))];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Backups (*BACKUP_SUFFIX) left by EARLIER upgrades — gitignored, so git sees
|
|
230
|
+
* them as ignored-untracked. One set per upgrade accumulated and nothing
|
|
231
|
+
* cleaned them (the showcase carried 0.19.0's and 0.20.0's). Returns root-
|
|
232
|
+
* relative paths; [] when git is unavailable (then nothing is touched).
|
|
233
|
+
* @param {string} projectDir
|
|
234
|
+
* @param {{runGit?: (args: string[], cwd: string) => string|null}} [deps]
|
|
235
|
+
* @returns {string[]}
|
|
236
|
+
*/
|
|
237
|
+
export function staleBackupPaths(projectDir, { runGit } = {}) {
|
|
238
|
+
const git =
|
|
239
|
+
runGit ??
|
|
240
|
+
((args, cwd) => {
|
|
241
|
+
try {
|
|
242
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
243
|
+
} catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
const out = git(["ls-files", "-z", "--others", "--ignored", "--exclude-standard"], projectDir);
|
|
248
|
+
if (out === null) return [];
|
|
249
|
+
return out
|
|
250
|
+
.split("\0")
|
|
251
|
+
.filter((p) => p.endsWith(BACKUP_SUFFIX))
|
|
252
|
+
.sort();
|
|
253
|
+
}
|
|
@@ -64,7 +64,16 @@ jobs:
|
|
|
64
64
|
# verdict and an evidence receipt. If the build step fails after a
|
|
65
65
|
# dependency bump, remember: Kotlin / KSP / Compose / Room / AGP move as
|
|
66
66
|
# ONE set (see the comments in gradle/libs.versions.toml).
|
|
67
|
+
# CMP_DEVICE=none is the ONE explicit opt-out of the device tier: this
|
|
68
|
+
# hosted runner has no emulator, so e2eSmoke/androidChecks SKIP with that
|
|
69
|
+
# reason on the merge-stage receipt (visible, never silent). The lane boots
|
|
70
|
+
# a headless emulator itself everywhere else — locally that is the
|
|
71
|
+
# change-stage gate, and qa/receipt-check.mjs refuses a receipt whose
|
|
72
|
+
# device tier was opted out. To prove L2 here too, drop the variable and
|
|
73
|
+
# boot an emulator first (e.g. reactivecircus/android-emulator-runner).
|
|
67
74
|
- name: Verify lane
|
|
75
|
+
env:
|
|
76
|
+
CMP_DEVICE: none
|
|
68
77
|
run: node qa/verify.mjs --profile ci
|
|
69
78
|
|
|
70
79
|
# The receipt this run produced, kept as a build artifact. The receipt
|
package/template/gitignore
CHANGED
|
@@ -44,3 +44,12 @@ qa/.plan.json
|
|
|
44
44
|
# The closed-chain trail (drive-narration N5): local because it carries raw
|
|
45
45
|
# human prompts — the committed journal for lane runs stays qa/flight-recorder.jsonl.
|
|
46
46
|
qa/.plan-history.jsonl
|
|
47
|
+
|
|
48
|
+
# Android signing. The keystore IS the app's identity on Android — it cannot be reissued —
|
|
49
|
+
# and keystore.properties holds its passwords. Never committed, never in a sidecar diff.
|
|
50
|
+
# (create-cmp-showcase, 2026-09-03: three consecutive upgrades' .gitignore sidecars lacked
|
|
51
|
+
# these four lines, leaving the key one `git add -A` from a public repo.)
|
|
52
|
+
keystore.properties
|
|
53
|
+
keystore/
|
|
54
|
+
*.jks
|
|
55
|
+
*.keystore
|
|
@@ -13,14 +13,33 @@ curl -fsSL "https://get.maestro.mobile.dev" | bash # Apache-2.0, free CLI
|
|
|
13
13
|
## Run
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
#
|
|
16
|
+
# The lane runs EVERY flow in this directory (e2eSmoke), on the DEBUG build, and
|
|
17
|
+
# boots a headless emulator itself when nothing is attached:
|
|
18
|
+
node qa/verify.mjs
|
|
19
|
+
|
|
20
|
+
# By hand, one flow, against whatever is installed:
|
|
17
21
|
./gradlew :composeApp:installDebug
|
|
18
22
|
maestro test qa/e2e/smoke.yaml
|
|
19
|
-
|
|
20
|
-
# The verify lane runs this automatically when maestro + a device are present:
|
|
21
|
-
node qa/verify.mjs
|
|
22
23
|
```
|
|
23
24
|
|
|
25
|
+
- **Every top-level `*.yaml` here runs**, in one Maestro session; the receipt's e2eSmoke row
|
|
26
|
+
lists each flow's result (`details.results`). A flow in a subfolder does not run and does
|
|
27
|
+
not count as coverage — the executed list and the coverage scan read the same list.
|
|
28
|
+
- **The device**: an attached device is used as-is. With none attached the lane boots an
|
|
29
|
+
emulator headless — `CMP_AVD`, else the doctor's `cmp_pixel`, else the only AVD — waits
|
|
30
|
+
(bounded, 4 min) and shuts it down when the lane exits (`CMP_KEEP_DEVICE=1` keeps it up).
|
|
31
|
+
A device that cannot be provisioned is an ERROR row and the lane FAILs. `CMP_DEVICE=none`
|
|
32
|
+
is the one explicit opt-out; a receipt carrying it is refused as done-evidence.
|
|
33
|
+
- **Per feature**: `qa/scaffold-feature.mjs` stamps `qa/e2e/<feature>.yaml` — a passing
|
|
34
|
+
skeleton (launch + shell) naming the screen id and the clauses to prove. It cites nothing
|
|
35
|
+
until you make it the journey. Mark clauses only a device can observe `[tier: e2e]` in
|
|
36
|
+
the spec; specCoverage then fails by name until a flow cites them.
|
|
37
|
+
- **The gate**: `e2eCoverage` (pure Node, every profile). A feature with a screen and a spec
|
|
38
|
+
must have at least one live clause cited from a flow here — so a stamped skeleton FAILs the
|
|
39
|
+
lane by name until it is the journey. A screen with no spec is a placeholder (reported);
|
|
40
|
+
a screen declared `{ "unrouted": true }` in its brief is exempt. A `screens: true` brief is
|
|
41
|
+
likewise not done until one of its clauses is proven by a flow.
|
|
42
|
+
|
|
24
43
|
## Conventions
|
|
25
44
|
|
|
26
45
|
- **Selectors by testTag** (`id:` — TestTagAutomation surfaces tags as resource-ids on
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# E2E smoke — Maestro flow. SPEC: SHELL-01, SHELL-02.
|
|
1
|
+
# E2E smoke — Maestro flow. SPEC: SHELL-01, SHELL-02, HOME-02.
|
|
2
2
|
#
|
|
3
3
|
# Proves the real app boots on a device/emulator and the bottom-nav shell works.
|
|
4
4
|
# Selectors go by testTag (surfaced as resource-ids on Android via TestTagAutomation),
|
|
@@ -30,6 +30,14 @@ appId: __PACKAGE__
|
|
|
30
30
|
- assertVisible:
|
|
31
31
|
id: "app_bottom_nav"
|
|
32
32
|
|
|
33
|
+
# SPEC: HOME-02 — when loading completes, the items are listed: the first item's row
|
|
34
|
+
# is on screen (the exemplar's device journey; the lane's e2eCoverage gate asks every
|
|
35
|
+
# feature with a screen and a spec for at least one clause proven by a flow).
|
|
36
|
+
- extendedWaitUntil:
|
|
37
|
+
visible:
|
|
38
|
+
id: "home_item_1"
|
|
39
|
+
timeout: 30000
|
|
40
|
+
|
|
33
41
|
# SPEC: SHELL-02 — switching tabs keeps the shell
|
|
34
42
|
- tapOn:
|
|
35
43
|
id: "nav_profile"
|