create-cmp-cli 0.20.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/affected-tests.mjs +9 -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-badge.mjs +11 -0
- package/packages/harness/src/lib/evidence-level.mjs +34 -2
- 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/inputs-hash.mjs +27 -0
- package/packages/harness/src/lib/lane-runner.mjs +7 -0
- 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 +133 -39
- package/packages/harness/src/receipt-check.mjs +25 -0
- package/packages/harness/src/scaffold-feature.mjs +55 -0
- package/packages/harness/src/verify.mjs +18 -2
- package/packages/receipts/src/inputs-hash.mjs +27 -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/evidence/schema.json +6 -1
- package/template/qa/lib/affected-tests.mjs +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-badge.mjs +11 -0
- package/template/qa/lib/evidence-level.mjs +34 -2
- package/template/qa/lib/feature-brief.mjs +16 -3
- package/template/qa/lib/harness-lock.mjs +6 -1
- package/template/qa/lib/inputs-hash.mjs +27 -0
- package/template/qa/lib/lane-runner.mjs +7 -0
- 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 +133 -39
- package/template/qa/receipt-check.mjs +25 -0
- package/template/qa/scaffold-feature.mjs +55 -0
- package/template/qa/verify.mjs +18 -2
|
@@ -21,17 +21,21 @@ 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";
|
|
37
|
+
import { stepDisplayName } from "./lane-runner.mjs";
|
|
38
|
+
import { CMP_LADDER } from "./evidence-level.mjs";
|
|
35
39
|
|
|
36
40
|
/**
|
|
37
41
|
* @param {object} ctx
|
|
@@ -113,6 +117,33 @@ function deviceAttached() {
|
|
|
113
117
|
// honest and visible is exactly why SKIP is the right verdict.
|
|
114
118
|
let laneDeviceLease = null;
|
|
115
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
|
+
|
|
116
147
|
/** Serials of devices currently in `device` state (same parse as deviceAttached). */
|
|
117
148
|
function attachedDeviceSerials() {
|
|
118
149
|
const res = sh("adb devices", { timeout: 10_000 });
|
|
@@ -148,6 +179,7 @@ function leaseDeviceForStep(stepName) {
|
|
|
148
179
|
return {
|
|
149
180
|
name: stepName,
|
|
150
181
|
verdict: "SKIP",
|
|
182
|
+
skipKind: "environment",
|
|
151
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.`,
|
|
152
184
|
durationMs: 0,
|
|
153
185
|
};
|
|
@@ -158,6 +190,7 @@ function leaseDeviceForStep(stepName) {
|
|
|
158
190
|
return {
|
|
159
191
|
name: stepName,
|
|
160
192
|
verdict: "SKIP",
|
|
193
|
+
skipKind: "environment",
|
|
161
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`,
|
|
162
195
|
durationMs: 0,
|
|
163
196
|
};
|
|
@@ -381,6 +414,16 @@ function stepReachability() {
|
|
|
381
414
|
// (regenerateArchDoc); this step only adds the name/duration bookkeeping every
|
|
382
415
|
// step in this file carries, plus wording the FAIL reason for an AI
|
|
383
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
|
+
|
|
384
427
|
function stepArchDoc() {
|
|
385
428
|
const started = Date.now();
|
|
386
429
|
const elapsed = () => Date.now() - started;
|
|
@@ -787,14 +830,8 @@ function stepTokenDrift() {
|
|
|
787
830
|
const started = Date.now();
|
|
788
831
|
const elapsed = () => Date.now() - started;
|
|
789
832
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
name: "tokenDrift",
|
|
793
|
-
verdict: "SKIP",
|
|
794
|
-
reason: "no Android device/emulator attached (adb) — runtime token drift needs the live inspector tier",
|
|
795
|
-
durationMs: elapsed(),
|
|
796
|
-
};
|
|
797
|
-
}
|
|
833
|
+
const device = ensureLaneDevice("tokenDrift");
|
|
834
|
+
if (device) return { ...device, durationMs: elapsed() };
|
|
798
835
|
|
|
799
836
|
const unreachable = () => ({
|
|
800
837
|
name: "tokenDrift",
|
|
@@ -868,15 +905,17 @@ function maestroAvailable() {
|
|
|
868
905
|
// The e2e guard trio, shared by every step that drives the smoke flow on a device.
|
|
869
906
|
// Returns null when the harness is fully available, else the SKIP result for [name].
|
|
870
907
|
function maestroGuards(name) {
|
|
871
|
-
if (!fs.existsSync(path.join(ROOT,
|
|
872
|
-
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 };
|
|
873
910
|
}
|
|
874
|
-
if (
|
|
875
|
-
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 };
|
|
876
913
|
}
|
|
877
914
|
if (!maestroAvailable()) {
|
|
878
|
-
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 };
|
|
879
916
|
}
|
|
917
|
+
const device = ensureLaneDevice(name);
|
|
918
|
+
if (device) return device;
|
|
880
919
|
return null;
|
|
881
920
|
}
|
|
882
921
|
|
|
@@ -895,32 +934,59 @@ function maestroGuards(name) {
|
|
|
895
934
|
// hide_error_dialogs suppresses the OS dialog, NEVER the underlying event — so after the
|
|
896
935
|
// run we grep the device log for ANR/crash lines the dialog would have shown, and FAIL on
|
|
897
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
|
+
|
|
898
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 });
|
|
899
951
|
const prevHideErrorDialogs = sh("adb shell settings get global hide_error_dialogs").out.trim();
|
|
900
952
|
sh("adb shell settings put global hide_error_dialogs 1");
|
|
901
953
|
sh("adb logcat -c"); // clear so the post-run dump only reflects this run
|
|
902
954
|
try {
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
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;
|
|
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 };
|
|
911
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))];
|
|
912
978
|
const anrDump = sh("adb logcat -d -b system,crash,main");
|
|
913
|
-
const
|
|
914
|
-
if (
|
|
915
|
-
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) {
|
|
916
981
|
return {
|
|
917
982
|
name,
|
|
918
983
|
verdict: "FAIL",
|
|
919
|
-
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")}`,
|
|
920
985
|
durationMs: priorDurationMs + res.durationMs,
|
|
986
|
+
details: outcome.details,
|
|
921
987
|
};
|
|
922
988
|
}
|
|
923
|
-
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 } : {}) };
|
|
924
990
|
} finally {
|
|
925
991
|
if (prevHideErrorDialogs && prevHideErrorDialogs !== "null") {
|
|
926
992
|
sh(`adb shell settings put global hide_error_dialogs ${prevHideErrorDialogs}`);
|
|
@@ -966,18 +1032,13 @@ function stepAndroidChecks() {
|
|
|
966
1032
|
return {
|
|
967
1033
|
name: "androidChecks",
|
|
968
1034
|
verdict: "SKIP",
|
|
1035
|
+
skipKind: "structure",
|
|
969
1036
|
reason: "no instrumented tests (composeApp/src/androidInstrumentedTest has no Kotlin sources)",
|
|
970
1037
|
durationMs: Date.now() - started,
|
|
971
1038
|
};
|
|
972
1039
|
}
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
name: "androidChecks",
|
|
976
|
-
verdict: "SKIP",
|
|
977
|
-
reason: "no Android device/emulator attached (adb) — instrumented behavior needs the real process boundary",
|
|
978
|
-
durationMs: Date.now() - started,
|
|
979
|
-
};
|
|
980
|
-
}
|
|
1040
|
+
const device = ensureLaneDevice("androidChecks");
|
|
1041
|
+
if (device) return { ...device, durationMs: Date.now() - started };
|
|
981
1042
|
// Machine-global lease before the first device touch (contention = SKIP).
|
|
982
1043
|
const leaseSkip = leaseDeviceForStep("androidChecks");
|
|
983
1044
|
if (leaseSkip) return { ...leaseSkip, durationMs: Date.now() - started };
|
|
@@ -1152,6 +1213,7 @@ const MEMOIZED_STEP_INPUTS = {
|
|
|
1152
1213
|
approvals: ["qa/approvals.json", "specs", "docs/features", "docs/ARCHITECTURE.md", "composeApp/src"],
|
|
1153
1214
|
componentStories: ["composeApp/src"],
|
|
1154
1215
|
reachability: ["composeApp/src", "docs/features"],
|
|
1216
|
+
e2eCoverage: ["composeApp/src", "docs/features", "specs", "qa/e2e"],
|
|
1155
1217
|
archDoc: ["docs/ARCHITECTURE.md", "docs/adr", "specs", "composeApp/src"],
|
|
1156
1218
|
};
|
|
1157
1219
|
|
|
@@ -1171,12 +1233,13 @@ const stepSpecCoverageMemo = memoized("specCoverage", stepSpecCoverage);
|
|
|
1171
1233
|
const stepApprovalsMemo = memoized("approvals", stepApprovals);
|
|
1172
1234
|
const stepComponentStoriesMemo = memoized("componentStories", stepComponentStories);
|
|
1173
1235
|
const stepReachabilityMemo = memoized("reachability", stepReachability);
|
|
1236
|
+
const stepE2eCoverageMemo = memoized("e2eCoverage", stepE2eCoverage);
|
|
1174
1237
|
const stepArchDocMemo = memoized("archDoc", stepArchDoc);
|
|
1175
1238
|
|
|
1176
1239
|
const stepsForProfile = {
|
|
1177
1240
|
// scaffold: what `create-cmp --verify` proves at stamp time — specCoverage,
|
|
1178
1241
|
// the full JVM tier (unit + conformance + golden + UI tests) plus the Android build.
|
|
1179
|
-
scaffold: [stepHarnessIntegrity, stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepArchDocMemo, stepSchemaHistory, stepBuild, stepUnitTests],
|
|
1242
|
+
scaffold: [stepHarnessIntegrity, stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepE2eCoverageMemo, stepArchDocMemo, stepSchemaHistory, stepBuild, stepUnitTests],
|
|
1180
1243
|
// smoke (docs/GATE-RULES.md Rule 0, docs/PRINCIPLES.md #2): the smallest
|
|
1181
1244
|
// end-to-end lane — every pure-Node step through the REAL runner, marker,
|
|
1182
1245
|
// receipt and journal, and NO Gradle, no device, no network. Its job is to
|
|
@@ -1185,7 +1248,7 @@ const stepsForProfile = {
|
|
|
1185
1248
|
// fresh scaffold, then FAIL BY NAME on one planted spec edit, each bounded
|
|
1186
1249
|
// in seconds. Its receipt is refused as done-evidence (qa/receipt-check.mjs)
|
|
1187
1250
|
// exactly like --fast: it proves the instrument, never the change.
|
|
1188
|
-
smoke: [stepHarnessIntegrity, stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepArchDocMemo, stepSchemaHistory],
|
|
1251
|
+
smoke: [stepHarnessIntegrity, stepSpecCoverageMemo, stepApprovalsMemo, stepComponentStoriesMemo, stepReachabilityMemo, stepE2eCoverageMemo, stepArchDocMemo, stepSchemaHistory],
|
|
1189
1252
|
local: [
|
|
1190
1253
|
// First, always: every verdict below is only worth what the lane issuing
|
|
1191
1254
|
// it is worth.
|
|
@@ -1194,6 +1257,7 @@ const stepsForProfile = {
|
|
|
1194
1257
|
stepApprovalsMemo,
|
|
1195
1258
|
stepComponentStoriesMemo,
|
|
1196
1259
|
stepReachabilityMemo,
|
|
1260
|
+
stepE2eCoverageMemo,
|
|
1197
1261
|
stepArchDocMemo,
|
|
1198
1262
|
stepSchemaHistory,
|
|
1199
1263
|
stepBuild,
|
|
@@ -1246,13 +1310,35 @@ stepsForProfile.release = [...stepsForProfile.ci, stepAuditCadence, stepReleaseS
|
|
|
1246
1310
|
// nightly (evidence-economics S6 / proposal P4): the stage for proofs whose cost
|
|
1247
1311
|
// scales with the SUITE rather than with the change — the determinism probe
|
|
1248
1312
|
// today (forced on above; `--determinism` is implied), and the place any
|
|
1249
|
-
// future
|
|
1313
|
+
// future load / chaos step lands, so the placement decision is made
|
|
1250
1314
|
// once instead of per expensive step. It proves the harness and the tree's
|
|
1251
1315
|
// invariants, not a change: qa/receipt-check.mjs refuses its receipt as
|
|
1252
1316
|
// done-evidence, exactly as it refuses --fast. Same step set as ci on purpose —
|
|
1253
1317
|
// what differs is what is forced, and what the receipt is allowed to mean.
|
|
1254
1318
|
stepsForProfile.nightly = [...stepsForProfile.ci];
|
|
1255
1319
|
|
|
1320
|
+
// Which layer of the stack each step proves — stamped onto the receipt row by
|
|
1321
|
+
// the runner (lane-runner.mjs) so the Evidence pane can group by it and a
|
|
1322
|
+
// multi-pack lane (a Compose app over a Kotlin backend) reads as one lane
|
|
1323
|
+
// with per-layer tallies. Three layers for this pack: `spine` — the harness
|
|
1324
|
+
// proving itself and the governed record (integrity, spec coverage,
|
|
1325
|
+
// approvals, the architecture doc, the schema history, the audit cadence);
|
|
1326
|
+
// `compose` — the JVM tier of the app (build, tests, conformance, goldens,
|
|
1327
|
+
// a11y, release compile); `device` — anything that needs an emulator or a
|
|
1328
|
+
// physical device. Layer names are free-form strings on the wire; these are
|
|
1329
|
+
// this pack's. Derived by NAME after the lists are built so a step listed in
|
|
1330
|
+
// two profiles is tagged once, and a step nobody listed is never tagged.
|
|
1331
|
+
const SPINE_STEP_NAMES = new Set(["harnessIntegrity", "specCoverage", "approvals", "componentStories", "reachability", "e2eCoverage", "archDoc", "schemaHistory", "auditCadence", "determinism"]);
|
|
1332
|
+
function layerForStep(name) {
|
|
1333
|
+
if (DEVICE_STEPS.includes(name)) return "device";
|
|
1334
|
+
if (SPINE_STEP_NAMES.has(name)) return "spine";
|
|
1335
|
+
return "compose";
|
|
1336
|
+
}
|
|
1337
|
+
for (const fn of new Set(Object.values(stepsForProfile).flat())) {
|
|
1338
|
+
const name = stepDisplayName(fn);
|
|
1339
|
+
if (name) fn.layer = layerForStep(name);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1256
1342
|
const FAST_EXCLUDED_NAMES = [...DEVICE_STEPS, "releaseBuild"];
|
|
1257
1343
|
const STEP_FN_BY_NAME = {
|
|
1258
1344
|
e2eSmoke: stepE2eSmoke,
|
|
@@ -1275,10 +1361,18 @@ for (const name of FAST_EXCLUDED_NAMES) {
|
|
|
1275
1361
|
FAST_EXCLUDED_NAMES,
|
|
1276
1362
|
STEP_FN_BY_NAME,
|
|
1277
1363
|
stepDeterminism,
|
|
1364
|
+
// The ladder this pack's steps can earn (evidence-level.mjs). A pack that
|
|
1365
|
+
// returns none earns no rung — the spine never grades a pack by another
|
|
1366
|
+
// pack's step names.
|
|
1367
|
+
evidenceLadder: CMP_LADDER,
|
|
1278
1368
|
// The device lease is held to the very end of the run (see the scope
|
|
1279
1369
|
// decision above); the spine releases it in the runner's finally.
|
|
1280
1370
|
releaseLease: () => {
|
|
1281
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})`);
|
|
1282
1376
|
},
|
|
1283
1377
|
};
|
|
1284
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) {
|
|
@@ -31,7 +31,7 @@ import fs from "node:fs";
|
|
|
31
31
|
import path from "node:path";
|
|
32
32
|
import { fileURLToPath } from "node:url";
|
|
33
33
|
|
|
34
|
-
import { computeInputsHash } from "./lib/inputs-hash.mjs";
|
|
34
|
+
import { computeInputsHash, undeclaredTopLevel } from "./lib/inputs-hash.mjs";
|
|
35
35
|
import { evidenceLevel } from "./lib/evidence-level.mjs";
|
|
36
36
|
import { updateReadmeBadge, README_REL_PATH } from "./lib/evidence-badge.mjs";
|
|
37
37
|
import { appendFlightRecord, buildFlightEntry, neverRunTiers, readFlightJournal } from "./lib/flight-recorder.mjs";
|
|
@@ -450,7 +450,10 @@ const strengthLabel = onDeviceSteps.length ? `on-device: ${onDeviceSteps.join("+
|
|
|
450
450
|
// fine print; the rung is added alongside, never in place of it. null on FAIL —
|
|
451
451
|
// a failed lane has no rung. null on a --fast run too: the inner loop is a
|
|
452
452
|
// signal, never evidence, so a fast receipt derives NO rung at all.
|
|
453
|
-
|
|
453
|
+
// The ladder is the PACK's: a pack that declares none earns no rung (a
|
|
454
|
+
// backend graded by Compose step names was L0 by construction — wrong, not
|
|
455
|
+
// conservative).
|
|
456
|
+
const level = evidenceLevel(steps, profile, { mode, ladder: pack.evidenceLadder ?? null });
|
|
454
457
|
|
|
455
458
|
// Artifacts: hash whatever the run left under qa-artifacts/ (never committed).
|
|
456
459
|
const artifacts = [];
|
|
@@ -494,6 +497,15 @@ function harnessForReceipt() {
|
|
|
494
497
|
}
|
|
495
498
|
|
|
496
499
|
const inputs = computeInputsHash(ROOT);
|
|
500
|
+
// What the surface does NOT cover, at the top level. A surface is an allowlist,
|
|
501
|
+
// and a new top-level directory is simply unmatched: no error, silently
|
|
502
|
+
// unattested (payment-blueprint's finding, 2026-09-03). This is a REPORT on the
|
|
503
|
+
// receipt, never a gate — the Compose default deliberately leaves docs/, the
|
|
504
|
+
// README and the wrapper out — so a reader can see the gap and decide.
|
|
505
|
+
const undeclared = undeclaredTopLevel(ROOT);
|
|
506
|
+
if (undeclared.length) {
|
|
507
|
+
console.log(` ⓘ inputs: ${undeclared.length} top-level entr${undeclared.length === 1 ? "y is" : "ies are"} outside the verified surface (unattested): ${undeclared.join(", ")}`);
|
|
508
|
+
}
|
|
497
509
|
|
|
498
510
|
// The receipt. Deterministic key order; ONE volatile timestamp field.
|
|
499
511
|
// commit.sha is the parent HEAD at run time (you cannot know the sha of the
|
|
@@ -520,6 +532,10 @@ const receipt = {
|
|
|
520
532
|
inputs: {
|
|
521
533
|
hash: inputs.hash,
|
|
522
534
|
fileCount: inputs.fileCount,
|
|
535
|
+
// Top-level entries the surface leaves unattested (see above). Absent when
|
|
536
|
+
// there are none, so a receipt whose surface covers everything keeps its
|
|
537
|
+
// exact prior shape.
|
|
538
|
+
...(undeclared.length ? { undeclared } : {}),
|
|
523
539
|
},
|
|
524
540
|
steps,
|
|
525
541
|
// WHICH LANE issued this verdict. A receipt that cannot name its own harness
|
|
@@ -232,6 +232,33 @@ function resolveSurfaceFiles(root, VERIFIED_SURFACE) {
|
|
|
232
232
|
return collected.filter((relPath) => !isExcluded(relPath));
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Top-level entries (first path segment of every file git would commit) that
|
|
237
|
+
* NO surface entry covers — the files the receipt does not attest. An
|
|
238
|
+
* allowlist is silent about what it omits: a new top-level directory is simply
|
|
239
|
+
* unmatched, no error, unattested (payment-blueprint, 2026-09-03). This names
|
|
240
|
+
* the omission so the receipt can carry it and a reader can decide whether
|
|
241
|
+
* it belongs in qa/verified-surface.json. Sorted; [] when git is unavailable
|
|
242
|
+
* (the walk fallback has no notion of "what git sees") or everything is
|
|
243
|
+
* covered. Lane outputs (EXCLUDED_PREFIXES) are not "undeclared" — they are
|
|
244
|
+
* excluded by decision.
|
|
245
|
+
* @param {string} root
|
|
246
|
+
* @param {string[]} [surface] defaults to resolveVerifiedSurface(root)
|
|
247
|
+
* @returns {string[]}
|
|
248
|
+
*/
|
|
249
|
+
export function undeclaredTopLevel(root, surface = resolveVerifiedSurface(root)) {
|
|
250
|
+
const gitFiles = tryGitLsFiles(root);
|
|
251
|
+
if (!gitFiles) return [];
|
|
252
|
+
const covered = (relPath) => surface.some((entry) => relPath === entry || relPath.startsWith(`${entry}/`)) || isExcluded(relPath);
|
|
253
|
+
const out = new Set();
|
|
254
|
+
for (const raw of gitFiles) {
|
|
255
|
+
const relPath = raw.split(path.sep).join("/");
|
|
256
|
+
if (covered(relPath)) continue;
|
|
257
|
+
out.add(relPath.includes("/") ? relPath.slice(0, relPath.indexOf("/")) : relPath);
|
|
258
|
+
}
|
|
259
|
+
return [...out].sort();
|
|
260
|
+
}
|
|
261
|
+
|
|
235
262
|
/**
|
|
236
263
|
* Compute the sha256 hash of the verified surface for the project rooted at `root`.
|
|
237
264
|
* Deterministic: same tree (same file paths + same file bytes) → same hash.
|
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) {
|