create-cmp-cli 0.13.0 → 0.14.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/package.json +6 -2
- package/packages/harness/package.json +38 -0
- package/packages/harness/src/approve.mjs +247 -0
- package/packages/harness/src/arch-doc.mjs +69 -0
- package/packages/harness/src/comment.mjs +76 -0
- package/packages/harness/src/lib/a11y.mjs +113 -0
- package/packages/harness/src/lib/affected-tests.mjs +147 -0
- package/packages/harness/src/lib/approvals.mjs +1403 -0
- package/packages/harness/src/lib/arch-doc.mjs +451 -0
- package/packages/harness/src/lib/audit-cadence.mjs +290 -0
- package/packages/harness/src/lib/comments.mjs +252 -0
- package/packages/harness/src/lib/component-stories.mjs +183 -0
- package/packages/harness/src/lib/determinism.mjs +179 -0
- package/packages/harness/src/lib/device-lease.mjs +249 -0
- package/packages/harness/src/lib/evidence-badge.mjs +158 -0
- package/packages/harness/src/lib/evidence-level.mjs +117 -0
- package/packages/harness/src/lib/feature-brief.mjs +324 -0
- package/packages/harness/src/lib/flight-recorder.mjs +332 -0
- package/packages/harness/src/lib/harness-lock.mjs +147 -0
- package/packages/harness/src/lib/harness-region.mjs +159 -0
- package/packages/harness/src/lib/inputs-hash.mjs +194 -0
- package/packages/harness/src/lib/reachability.mjs +211 -0
- package/packages/harness/src/lib/receipt-validate.mjs +234 -0
- package/packages/harness/src/lib/render.mjs +254 -0
- package/packages/harness/src/lib/spec-coverage.mjs +131 -0
- package/packages/harness/src/lib/step-cache.mjs +221 -0
- package/packages/harness/src/lib/token-drift.mjs +94 -0
- package/packages/harness/src/lib/tree.mjs +108 -0
- package/packages/harness/src/preview-gallery.mjs +122 -0
- package/packages/harness/src/receipt-check.mjs +96 -0
- package/packages/harness/src/record-audit.mjs +83 -0
- package/packages/harness/src/refusal-demo.mjs +498 -0
- package/packages/harness/src/retrospective.mjs +51 -0
- package/packages/harness/src/scaffold-feature.mjs +723 -0
- package/packages/harness/src/setup-hooks.mjs +33 -0
- package/packages/harness/src/verify.mjs +1723 -0
- package/packages/harness/src/walkthrough.mjs +499 -0
- package/packages/harness/src/watch.mjs +622 -0
- package/packages/receipts/package.json +36 -0
- package/packages/receipts/src/index.mjs +16 -0
- package/packages/receipts/src/inputs-hash.mjs +194 -0
- package/packages/receipts/src/receipt-validate.mjs +234 -0
- package/src/commands/upgrade.mjs +115 -1
- package/src/lib/harness-upgrade.mjs +193 -5
- package/src/scaffold.mjs +60 -1
- package/template/AGENTS.md +5 -0
- package/template/CLAUDE.md +30 -0
- package/template/gitignore +8 -0
- package/template/qa/lib/harness-lock.mjs +147 -0
- package/template/qa/lib/harness-region.mjs +159 -0
- package/template/qa/lib/inputs-hash.mjs +1 -1
- package/template/qa/lib/receipt-validate.mjs +1 -1
- package/template/qa/preview-gallery.mjs +17 -2
- package/template/qa/verify.mjs +110 -2
- 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 +0 -2
- package/template/.gradle/vcs-1/gc.properties +0 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// component-stories.mjs — the component ↔ story parity gate (the IMP-1
|
|
2
|
+
// screen↔registry parity idea, applied at component granularity).
|
|
3
|
+
//
|
|
4
|
+
// Every `@Composable fun` in `composeApp/src/commonMain/**/presentation/
|
|
5
|
+
// components/*.kt` must have a preview-registry story whose id is
|
|
6
|
+
// `component.<kebab-case-of-the-composable-name>` (AppHeader →
|
|
7
|
+
// "component.app-header"), registered in the desktopMain inspector sources
|
|
8
|
+
// (ComponentStories.kt, or PreviewRegistry.kt for generated conditional
|
|
9
|
+
// components like PlaceholderScreen). The gate fails BOTH directions, like
|
|
10
|
+
// specCoverage: a component with no story (the render pipeline is blind to
|
|
11
|
+
// it) and a story id with no component (a stale story surviving a rename).
|
|
12
|
+
//
|
|
13
|
+
// Detection is a pragmatic source scan, not a Kotlin front-end — the same
|
|
14
|
+
// stance as the console's components scan (inspector/mcp/src/lib/
|
|
15
|
+
// components.mjs, whose @Composable-window heuristic and kebab derivation
|
|
16
|
+
// this file mirrors; keep the two in sync).
|
|
17
|
+
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
|
|
21
|
+
/** PascalCase/camelCase → kebab-case: AppHeader → app-header, ListItemCard → list-item-card. */
|
|
22
|
+
export function kebabCase(name) {
|
|
23
|
+
return String(name)
|
|
24
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
25
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
|
|
26
|
+
.toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The registry story id a component of this name must register. */
|
|
30
|
+
export function componentStoryId(name) {
|
|
31
|
+
return `component.${kebabCase(name)}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// How far past an `@Composable` occurrence to look for the `fun Name(` it
|
|
35
|
+
// governs — mirrors the console scan's FUN_SEARCH_WINDOW.
|
|
36
|
+
const FUN_SEARCH_WINDOW = 500;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Every `@Composable fun Name(` declaration name in one file's text —
|
|
40
|
+
* includes private/internal composables (the console's Components page lists
|
|
41
|
+
* them, so the parity gate covers them too).
|
|
42
|
+
* @param {string} text
|
|
43
|
+
* @returns {string[]}
|
|
44
|
+
*/
|
|
45
|
+
export function findComposableNames(text) {
|
|
46
|
+
const names = [];
|
|
47
|
+
const composableRe = /@Composable\b/g;
|
|
48
|
+
let m;
|
|
49
|
+
while ((m = composableRe.exec(text))) {
|
|
50
|
+
const window = text.slice(m.index, m.index + FUN_SEARCH_WINDOW);
|
|
51
|
+
const funMatch = window.match(/fun\s+(?:<[^>]*>\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*(?:<[^>]*>)?\s*\(/);
|
|
52
|
+
if (funMatch) names.push(funMatch[1]);
|
|
53
|
+
}
|
|
54
|
+
return names;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function walkDirs(root, wanted) {
|
|
58
|
+
const out = [];
|
|
59
|
+
(function walk(dir) {
|
|
60
|
+
let entries;
|
|
61
|
+
try {
|
|
62
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
63
|
+
} catch {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
for (const e of entries) {
|
|
67
|
+
if (!e.isDirectory()) continue;
|
|
68
|
+
const p = path.join(dir, e.name);
|
|
69
|
+
if (e.name === wanted) out.push(p);
|
|
70
|
+
else walk(p);
|
|
71
|
+
}
|
|
72
|
+
})(root);
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function ktFilesIn(dir) {
|
|
77
|
+
let entries;
|
|
78
|
+
try {
|
|
79
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
80
|
+
} catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
return entries
|
|
84
|
+
.filter((e) => e.isFile() && e.name.endsWith(".kt"))
|
|
85
|
+
.map((e) => path.join(dir, e.name));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function walkKtFilesDeep(dir) {
|
|
89
|
+
const out = [];
|
|
90
|
+
let entries;
|
|
91
|
+
try {
|
|
92
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
93
|
+
} catch {
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
for (const e of entries) {
|
|
97
|
+
const p = path.join(dir, e.name);
|
|
98
|
+
if (e.isDirectory()) out.push(...walkKtFilesDeep(p));
|
|
99
|
+
else if (e.name.endsWith(".kt")) out.push(p);
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const STORY_ID_RE = /"component\.([a-z0-9][a-z0-9.-]*)"/g;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Evaluate component ↔ story parity for a project root.
|
|
108
|
+
* SKIPs (never fails) when the surface doesn't exist: no components dir, or
|
|
109
|
+
* no desktopMain inspector dir (the `--no-inspector` scaffold has no preview
|
|
110
|
+
* registry to hold stories).
|
|
111
|
+
* @param {string} root project root (contains composeApp/)
|
|
112
|
+
* @returns {{verdict: "PASS"|"FAIL"|"SKIP", reason?: string, details?: object}}
|
|
113
|
+
*/
|
|
114
|
+
export function evaluateComponentStoryParity(root) {
|
|
115
|
+
const commonRoot = path.join(root, "composeApp", "src", "commonMain", "kotlin");
|
|
116
|
+
const desktopRoot = path.join(root, "composeApp", "src", "desktopMain", "kotlin");
|
|
117
|
+
|
|
118
|
+
const componentsDirs = walkDirs(commonRoot, "presentation")
|
|
119
|
+
.map((p) => path.join(p, "components"))
|
|
120
|
+
.filter((p) => fs.existsSync(p));
|
|
121
|
+
if (componentsDirs.length === 0) {
|
|
122
|
+
return { verdict: "SKIP", reason: "no presentation/components directory under commonMain — nothing to check" };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const inspectorDirs = walkDirs(desktopRoot, "inspector");
|
|
126
|
+
if (inspectorDirs.length === 0) {
|
|
127
|
+
return {
|
|
128
|
+
verdict: "SKIP",
|
|
129
|
+
reason: "no desktopMain inspector sources (preview harness not included) — no story registry to check against",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Components: every @Composable in the registry dir (direct children only —
|
|
134
|
+
// the same surface the console's Components page scans).
|
|
135
|
+
const components = []; // { name, file }
|
|
136
|
+
for (const dir of componentsDirs) {
|
|
137
|
+
for (const file of ktFilesIn(dir)) {
|
|
138
|
+
const rel = path.relative(root, file).split(path.sep).join("/");
|
|
139
|
+
for (const name of findComposableNames(fs.readFileSync(file, "utf8"))) {
|
|
140
|
+
components.push({ name, file: rel });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Stories: every quoted "component.<kebab>" id in the inspector sources.
|
|
146
|
+
const storyIds = new Set();
|
|
147
|
+
for (const dir of inspectorDirs) {
|
|
148
|
+
for (const file of walkKtFilesDeep(dir)) {
|
|
149
|
+
const text = fs.readFileSync(file, "utf8");
|
|
150
|
+
for (const match of text.matchAll(STORY_ID_RE)) {
|
|
151
|
+
storyIds.add(`component.${match[1]}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const expectedIds = new Map(components.map((c) => [componentStoryId(c.name), c]));
|
|
157
|
+
const missing = [...expectedIds.entries()].filter(([id]) => !storyIds.has(id));
|
|
158
|
+
const orphans = [...storyIds].filter((id) => !expectedIds.has(id)).sort();
|
|
159
|
+
|
|
160
|
+
const details = {
|
|
161
|
+
components: components.length,
|
|
162
|
+
stories: storyIds.size,
|
|
163
|
+
missing: missing.map(([id]) => id),
|
|
164
|
+
orphans,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
if (missing.length === 0 && orphans.length === 0) {
|
|
168
|
+
return { verdict: "PASS", details };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const lines = ["Component ↔ story parity broken — the components registry and its preview stories have drifted apart:"];
|
|
172
|
+
for (const [id, c] of missing) {
|
|
173
|
+
lines.push(
|
|
174
|
+
` [${c.name}] ${c.file} — no component story registered. Add ScreenPreview("${id}", …) to composeApp/src/desktopMain/**/inspector/ComponentStories.kt (kebab-case of the composable name).`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
for (const id of orphans) {
|
|
178
|
+
lines.push(
|
|
179
|
+
` ["${id}"] story id has no matching @Composable in presentation/components — remove the stale story or fix the id.`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return { verdict: "FAIL", reason: lines.join("\n"), details };
|
|
183
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// determinism.mjs — the comparison half of the lane's determinism probe.
|
|
2
|
+
//
|
|
3
|
+
// ARCH-13 statically bans ambient time reads (Clock.System, LocalDate.now,
|
|
4
|
+
// TimeZone.currentSystemDefault) in APP code — but a library the app calls
|
|
5
|
+
// can still read the wall clock, and a golden test can still depend on the
|
|
6
|
+
// machine's timezone through a seam the static net cannot see. This project
|
|
7
|
+
// family has already been bitten: a golden tree green at 23:00 and red by
|
|
8
|
+
// morning, because a ViewModel was constructed without its injected clock.
|
|
9
|
+
//
|
|
10
|
+
// The probe (verify.mjs stepDeterminism) runs the JVM test tier TWICE under
|
|
11
|
+
// maximally-shifted timezones and fails iff the two runs' OUTCOMES differ.
|
|
12
|
+
// This module owns the two judgments that make that comparison honest:
|
|
13
|
+
//
|
|
14
|
+
// - WHAT COUNTS AS AN OUTCOME: a test's verdict (pass/fail/error/skip)
|
|
15
|
+
// and its failure output — never its duration. Durations are not parsed
|
|
16
|
+
// at all, so a timing wobble is structurally incapable of tripping the
|
|
17
|
+
// probe (the brief-level rule "duration is not a difference" is enforced
|
|
18
|
+
// by construction, not by filtering).
|
|
19
|
+
//
|
|
20
|
+
// - WHAT THE FAILURE MESSAGE MUST SAY: which test, which lane step owns
|
|
21
|
+
// it, and the observable difference between the two runs — never a bare
|
|
22
|
+
// "nondeterministic". A probe whose red is unactionable just teaches
|
|
23
|
+
// people to turn it off.
|
|
24
|
+
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The two probe timezones — chosen so the two legs NEVER share a calendar
|
|
30
|
+
* date, at any instant:
|
|
31
|
+
*
|
|
32
|
+
* Etc/GMT+12 = UTC-12 (POSIX sign convention: Etc/GMT+N means UTC-N)
|
|
33
|
+
* Etc/GMT-14 = UTC+14 (the highest real-world offset, Line Islands)
|
|
34
|
+
*
|
|
35
|
+
* The offsets are 26 hours apart — more than a full day — so the two legs'
|
|
36
|
+
* local dates differ at EVERY moment of every day, and any date-derived
|
|
37
|
+
* value (a "today" default, a day-boundary bucket, a formatted date in a
|
|
38
|
+
* golden tree) is guaranteed to differ between the legs. A UTC-vs-UTC+14
|
|
39
|
+
* pair would NOT have this property: those legs share a date for ten hours
|
|
40
|
+
* of every day, so the probe's power would depend on what time you ran it —
|
|
41
|
+
* the exact class of flakiness it exists to hunt.
|
|
42
|
+
*/
|
|
43
|
+
export const DETERMINISM_TIMEZONES = [
|
|
44
|
+
{ tz: "Etc/GMT+12", label: "UTC-12" },
|
|
45
|
+
{ tz: "Etc/GMT-14", label: "UTC+14" },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const XML_ENTITIES = { "<": "<", ">": ">", """: '"', "'": "'", "&": "&" };
|
|
49
|
+
|
|
50
|
+
function unescapeXml(s) {
|
|
51
|
+
return s
|
|
52
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
|
|
53
|
+
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number(dec)))
|
|
54
|
+
.replace(/&(lt|gt|quot|apos|amp);/g, (m) => XML_ENTITIES[m]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function attr(attrs, name) {
|
|
58
|
+
const m = attrs.match(new RegExp(`${name}="([^"]*)"`));
|
|
59
|
+
return m ? unescapeXml(m[1]) : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Parse one Gradle JUnit results directory into per-test outcomes.
|
|
64
|
+
* DELIBERATELY parses only verdict-bearing content: testcase identity,
|
|
65
|
+
* status, and failure/error text. `time="…"` attributes are never read, so
|
|
66
|
+
* two runs that differ only in duration produce identical outcome maps.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} dir a test-results directory (TEST-*.xml files, flat)
|
|
69
|
+
* @returns {Record<string, {status: "pass"|"fail"|"error"|"skip", messages: string[]}>}
|
|
70
|
+
* keyed by `classname.name`; empty object when the directory is absent
|
|
71
|
+
* (the caller decides what an empty leg means — this parser never guesses)
|
|
72
|
+
*/
|
|
73
|
+
export function parseJUnitOutcomes(dir) {
|
|
74
|
+
const outcomes = {};
|
|
75
|
+
if (!fs.existsSync(dir)) return outcomes;
|
|
76
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
77
|
+
if (!entry.startsWith("TEST-") || !entry.endsWith(".xml")) continue;
|
|
78
|
+
const xml = fs.readFileSync(path.join(dir, entry), "utf8");
|
|
79
|
+
const caseRe = /<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g;
|
|
80
|
+
for (const m of xml.matchAll(caseRe)) {
|
|
81
|
+
const attrs = m[1];
|
|
82
|
+
const body = m[2] ?? "";
|
|
83
|
+
const classname = attr(attrs, "classname") ?? "";
|
|
84
|
+
const name = attr(attrs, "name") ?? "";
|
|
85
|
+
if (!classname && !name) continue;
|
|
86
|
+
let status = "pass";
|
|
87
|
+
const messages = [];
|
|
88
|
+
const childRe = /<(failure|error)\b([^>]*?)(?:\/>|>([\s\S]*?)<\/\1>)/g;
|
|
89
|
+
for (const c of body.matchAll(childRe)) {
|
|
90
|
+
status = c[1] === "error" ? "error" : "fail";
|
|
91
|
+
const message = attr(c[2], "message");
|
|
92
|
+
const text = c[3] ? unescapeXml(c[3]).trim() : "";
|
|
93
|
+
messages.push(message ?? text.split("\n")[0] ?? "");
|
|
94
|
+
}
|
|
95
|
+
if (status === "pass" && /<skipped\b/.test(body)) status = "skip";
|
|
96
|
+
outcomes[`${classname}.${name}`] = { status, messages };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return outcomes;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Which lane step owns a test class — so the probe's failure message names
|
|
104
|
+
* the step a reader would re-run, not just a class name. The patterns are
|
|
105
|
+
* the same filters the lane's own gradleTestStep calls use.
|
|
106
|
+
* @param {string} classname fully-qualified test class
|
|
107
|
+
* @returns {"goldenTrees"|"conformance"|"a11y"|"unitTests"}
|
|
108
|
+
*/
|
|
109
|
+
export function laneStepForTestClass(classname) {
|
|
110
|
+
if (/GoldenTreeTest$/.test(classname)) return "goldenTrees";
|
|
111
|
+
if (/ArchitectureConformanceTest$/.test(classname)) return "conformance";
|
|
112
|
+
if (/A11yConformanceTest$/.test(classname)) return "a11y";
|
|
113
|
+
return "unitTests";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function classnameOf(testId) {
|
|
117
|
+
// testId is `classname.name`; the class is everything before the last dot
|
|
118
|
+
// segment that starts the (possibly backticked, space-bearing) test name.
|
|
119
|
+
// Kotlin test names contain dots rarely but spaces often — the classname
|
|
120
|
+
// never contains a space, so split at the first segment containing one,
|
|
121
|
+
// falling back to the last dot.
|
|
122
|
+
const spaceIdx = testId.indexOf(" ");
|
|
123
|
+
const scope = spaceIdx === -1 ? testId : testId.slice(0, spaceIdx);
|
|
124
|
+
const lastDot = scope.lastIndexOf(".");
|
|
125
|
+
return lastDot === -1 ? testId : testId.slice(0, lastDot);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Compare two legs' outcomes. Returns one entry per observable difference,
|
|
130
|
+
* each carrying everything the failure message must name: the test, the
|
|
131
|
+
* owning lane step, and what differed between the legs.
|
|
132
|
+
*
|
|
133
|
+
* Kinds:
|
|
134
|
+
* verdict-flip different status (pass/fail/error/skip)
|
|
135
|
+
* only-in-one-leg the test executed in one leg only
|
|
136
|
+
* failure-text-changed failed in BOTH legs, but with different output —
|
|
137
|
+
* a date-dependent assertion message is still a
|
|
138
|
+
* timezone leak even when both legs are red
|
|
139
|
+
*
|
|
140
|
+
* @param {Record<string, {status: string, messages: string[]}>} a leg A outcomes
|
|
141
|
+
* @param {Record<string, {status: string, messages: string[]}>} b leg B outcomes
|
|
142
|
+
* @param {string} labelA human label for leg A (e.g. "TZ=Etc/GMT+12 (UTC-12)")
|
|
143
|
+
* @param {string} labelB human label for leg B
|
|
144
|
+
* @returns {Array<{test: string, step: string, kind: string, detail: string}>}
|
|
145
|
+
*/
|
|
146
|
+
export function compareOutcomes(a, b, labelA, labelB) {
|
|
147
|
+
const diffs = [];
|
|
148
|
+
const ids = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
|
|
149
|
+
for (const id of ids) {
|
|
150
|
+
const step = laneStepForTestClass(classnameOf(id));
|
|
151
|
+
const inA = a[id];
|
|
152
|
+
const inB = b[id];
|
|
153
|
+
if (!inA || !inB) {
|
|
154
|
+
const where = inA ? labelA : labelB;
|
|
155
|
+
const missing = inA ? labelB : labelA;
|
|
156
|
+
diffs.push({ test: id, step, kind: "only-in-one-leg", detail: `executed under ${where} but produced no result under ${missing}` });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (inA.status !== inB.status) {
|
|
160
|
+
const firstLine = (inA.status === "pass" ? inB : inA).messages[0]?.split("\n")[0] ?? "";
|
|
161
|
+
diffs.push({
|
|
162
|
+
test: id,
|
|
163
|
+
step,
|
|
164
|
+
kind: "verdict-flip",
|
|
165
|
+
detail: `${inA.status.toUpperCase()} under ${labelA}, ${inB.status.toUpperCase()} under ${labelB}${firstLine ? `: ${firstLine}` : ""}`,
|
|
166
|
+
});
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (inA.status !== "pass" && inA.messages.join("\n") !== inB.messages.join("\n")) {
|
|
170
|
+
diffs.push({
|
|
171
|
+
test: id,
|
|
172
|
+
step,
|
|
173
|
+
kind: "failure-text-changed",
|
|
174
|
+
detail: `failed under both, with different output — ${labelA}: "${inA.messages[0]?.split("\n")[0] ?? ""}" vs ${labelB}: "${inB.messages[0]?.split("\n")[0] ?? ""}"`,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return diffs;
|
|
179
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// The machine-global device lease — mutual exclusion for the ONE Android
|
|
2
|
+
// device/emulator a machine typically has.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS: the lane marker (composeApp/build/.cmp-lane-in-progress) is
|
|
5
|
+
// per-PROJECT, but the device is machine-GLOBAL. A scratch app stamped in /tmp
|
|
6
|
+
// and the real app are different roots sharing one emulator: each stamps its
|
|
7
|
+
// own marker, neither sees the other, and two concurrent device drivers produce
|
|
8
|
+
// exactly the observed failure class — wedged adbd, `device offline` while
|
|
9
|
+
// `adb devices` looks fine, crossed app state between sessions, false-red runs.
|
|
10
|
+
// The lease is keyed by the DEVICE (its adb serial), not the project, so the
|
|
11
|
+
// primitive finally matches the scarce resource it protects.
|
|
12
|
+
//
|
|
13
|
+
// ── ON-DISK CONTRACT ────────────────────────────────────────────────────────
|
|
14
|
+
// This exact contract is implemented independently by the create-cmp
|
|
15
|
+
// inspector MCP (inspector/mcp/src/lib/device-lease.mjs in the create-cmp
|
|
16
|
+
// repo — a check-only reader for connect_live / navigate_and_inspect). The two
|
|
17
|
+
// codebases ship separately and cannot import each other, so the contract
|
|
18
|
+
// lives verbatim in BOTH file headers, each pointing at the other. Changing
|
|
19
|
+
// anything below means changing it there too.
|
|
20
|
+
//
|
|
21
|
+
// Location <os.tmpdir()>/create-cmp/device-leases/<sanitized-serial>.json
|
|
22
|
+
// tmpdir on purpose: a lease must never survive a reboot.
|
|
23
|
+
// Sanitizing serial chars outside [A-Za-z0-9._-] become "_"
|
|
24
|
+
// ("emulator-5554" → emulator-5554.json,
|
|
25
|
+
// "192.168.1.5:5555" → 192.168.1.5_5555.json).
|
|
26
|
+
// Shape { "pid": number, "holder": string, "root": string,
|
|
27
|
+
// "serial": string, "acquiredAt": ISO-8601 string }
|
|
28
|
+
// `holder` is a human/agent-readable label naming WHO is driving
|
|
29
|
+
// ("verify lane e2eSmoke", "connect_live", "fleet-check scratch
|
|
30
|
+
// lane"); `root` is the holder's project root.
|
|
31
|
+
// Staleness a lease is DEAD (silently reclaimable) when EITHER
|
|
32
|
+
// - its pid is not alive — process.kill(pid, 0) throws ESRCH.
|
|
33
|
+
// EPERM means the process EXISTS under another user: ALIVE.
|
|
34
|
+
// - OR acquiredAt is older than MAX_LEASE_AGE_MS.
|
|
35
|
+
// An unparseable lease file (torn write from a crashed holder)
|
|
36
|
+
// counts as dead. Readers treat dead as free; only acquirers
|
|
37
|
+
// delete/overwrite.
|
|
38
|
+
// Writes atomic — temp file in the same directory + rename. After the
|
|
39
|
+
// rename the acquirer re-reads and confirms its OWN pid is in
|
|
40
|
+
// the file: two simultaneous renames resolve last-writer-wins,
|
|
41
|
+
// and the loser reports contention instead of believing it holds
|
|
42
|
+
// the device.
|
|
43
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
44
|
+
//
|
|
45
|
+
// Dependency-free Node. Every function takes an optional { dir } override so
|
|
46
|
+
// tests exercise real files in a temp dir without touching the machine's
|
|
47
|
+
// actual leases, and an optional { killImpl } so the EPERM-means-alive branch
|
|
48
|
+
// is testable without a foreign-user process.
|
|
49
|
+
|
|
50
|
+
import crypto from "node:crypto";
|
|
51
|
+
import fs from "node:fs";
|
|
52
|
+
import os from "node:os";
|
|
53
|
+
import path from "node:path";
|
|
54
|
+
|
|
55
|
+
// 30 minutes: the longest legitimate single holder is a full release-profile
|
|
56
|
+
// device phase on a cold emulator (installDebug + Maestro smoke +
|
|
57
|
+
// connectedDebugAndroidTest + installRelease + release smoke), observed in the
|
|
58
|
+
// low tens of minutes — a live holder is protected by the pid check anyway, so
|
|
59
|
+
// this cap only decides how long a crashed holder whose pid was RECYCLED by an
|
|
60
|
+
// unrelated long-lived process can wedge the device. 30 min bounds that to
|
|
61
|
+
// roughly one lane-length: long enough never to reclaim under a healthy run,
|
|
62
|
+
// short enough that the machine heals itself within the hour.
|
|
63
|
+
export const MAX_LEASE_AGE_MS = 30 * 60 * 1000;
|
|
64
|
+
|
|
65
|
+
/** Serial → safe file stem: anything outside [A-Za-z0-9._-] becomes "_". */
|
|
66
|
+
export function sanitizeSerial(serial) {
|
|
67
|
+
return String(serial).replace(/[^A-Za-z0-9._-]/g, "_");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The machine-global lease directory (override with { dir } in tests only). */
|
|
71
|
+
export function leaseDir(dir) {
|
|
72
|
+
return dir || path.join(os.tmpdir(), "create-cmp", "device-leases");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Absolute path of the lease file for one serial. */
|
|
76
|
+
export function leasePath(serial, { dir } = {}) {
|
|
77
|
+
return path.join(leaseDir(dir), `${sanitizeSerial(serial)}.json`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Is a pid alive? ESRCH = no such process → dead. EPERM = the process exists
|
|
82
|
+
* but belongs to another user → ALIVE (killing rights are not liveness).
|
|
83
|
+
* Any other error is treated as alive — when in doubt, never steal a lease.
|
|
84
|
+
*/
|
|
85
|
+
export function pidAlive(pid, { killImpl = process.kill.bind(process) } = {}) {
|
|
86
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
87
|
+
try {
|
|
88
|
+
killImpl(pid, 0);
|
|
89
|
+
return true;
|
|
90
|
+
} catch (err) {
|
|
91
|
+
return !(err && err.code === "ESRCH");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function readLeaseFile(file) {
|
|
96
|
+
try {
|
|
97
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
98
|
+
} catch {
|
|
99
|
+
return null; // missing OR unparseable — both mean "no live lease here"
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function describeLease(lease, nowMs) {
|
|
104
|
+
const acquiredMs = Date.parse(lease.acquiredAt);
|
|
105
|
+
return {
|
|
106
|
+
holder: lease.holder ?? "unknown",
|
|
107
|
+
pid: lease.pid ?? null,
|
|
108
|
+
root: lease.root ?? null,
|
|
109
|
+
acquiredAt: lease.acquiredAt ?? null,
|
|
110
|
+
ageMs: Number.isFinite(acquiredMs) ? Math.max(0, nowMs - acquiredMs) : Number.POSITIVE_INFINITY,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function leaseDead(lease, nowMs, { killImpl } = {}) {
|
|
115
|
+
if (!pidAlive(lease.pid, { ...(killImpl ? { killImpl } : {}) })) return true;
|
|
116
|
+
const acquiredMs = Date.parse(lease.acquiredAt);
|
|
117
|
+
if (!Number.isFinite(acquiredMs)) return true; // no readable birth time — unverifiable, dead
|
|
118
|
+
return nowMs - acquiredMs >= MAX_LEASE_AGE_MS;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** `"verify lane e2eSmoke" (pid 4711, /tmp/scratch-x, 2m ago)` — for reasons/errors. */
|
|
122
|
+
export function formatHolder(heldBy) {
|
|
123
|
+
if (!heldBy) return "an unknown holder";
|
|
124
|
+
const age =
|
|
125
|
+
!Number.isFinite(heldBy.ageMs) ? "age unknown"
|
|
126
|
+
: heldBy.ageMs < 60_000 ? `${Math.max(1, Math.round(heldBy.ageMs / 1000))}s ago`
|
|
127
|
+
: `${Math.round(heldBy.ageMs / 60_000)}m ago`;
|
|
128
|
+
return `"${heldBy.holder}" (pid ${heldBy.pid ?? "?"}, ${heldBy.root ?? "unknown root"}, ${age})`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Read the live lease on a serial, if any. Dead/stale leases read as null
|
|
133
|
+
* (free) — reading never deletes; reclaim-by-overwrite is the acquirer's job.
|
|
134
|
+
*
|
|
135
|
+
* @returns {{holder,pid,root,acquiredAt,ageMs}|null}
|
|
136
|
+
*/
|
|
137
|
+
export function readDeviceLease(serial, { dir, killImpl, now = Date.now } = {}) {
|
|
138
|
+
const lease = readLeaseFile(leasePath(serial, { dir }));
|
|
139
|
+
if (!lease) return null;
|
|
140
|
+
const nowMs = now();
|
|
141
|
+
if (leaseDead(lease, nowMs, { killImpl })) return null;
|
|
142
|
+
return describeLease(lease, nowMs);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Acquire the machine-global lease on one device serial.
|
|
147
|
+
*
|
|
148
|
+
* @param {{serial: string, holder: string, root: string, dir?: string,
|
|
149
|
+
* killImpl?: Function, now?: () => number}} opts
|
|
150
|
+
* @returns {{ok: true, handle: {file, serial, pid, acquiredAt}, reclaimed: object|null}
|
|
151
|
+
* | {ok: false, heldBy: {holder, pid, root, acquiredAt, ageMs}}}
|
|
152
|
+
* `reclaimed` names the dead lease this acquire silently replaced (a crashed
|
|
153
|
+
* run must never wedge the machine forever) so the acquiring run can note it
|
|
154
|
+
* in its own output.
|
|
155
|
+
*/
|
|
156
|
+
export function acquireDeviceLease({ serial, holder, root, dir, killImpl, now = Date.now } = {}) {
|
|
157
|
+
if (!serial) throw new Error("acquireDeviceLease: serial is required");
|
|
158
|
+
const d = leaseDir(dir);
|
|
159
|
+
fs.mkdirSync(d, { recursive: true });
|
|
160
|
+
const file = leasePath(serial, { dir });
|
|
161
|
+
const nowMs = now();
|
|
162
|
+
|
|
163
|
+
let reclaimed = null;
|
|
164
|
+
const existing = readLeaseFile(file);
|
|
165
|
+
if (existing) {
|
|
166
|
+
if (!leaseDead(existing, nowMs, { killImpl })) {
|
|
167
|
+
return { ok: false, heldBy: describeLease(existing, nowMs) };
|
|
168
|
+
}
|
|
169
|
+
reclaimed = describeLease(existing, nowMs); // dead — reclaim silently
|
|
170
|
+
} else if (fs.existsSync(file)) {
|
|
171
|
+
reclaimed = { holder: "unreadable lease (torn write)", pid: null, root: null, acquiredAt: null, ageMs: Number.POSITIVE_INFINITY };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const lease = {
|
|
175
|
+
pid: process.pid,
|
|
176
|
+
holder: String(holder || "unknown"),
|
|
177
|
+
root: String(root || process.cwd()),
|
|
178
|
+
serial: String(serial),
|
|
179
|
+
acquiredAt: new Date(nowMs).toISOString(),
|
|
180
|
+
};
|
|
181
|
+
// Atomic claim: temp file + rename means no reader ever sees a half-written
|
|
182
|
+
// lease, and two simultaneous acquirers cannot interleave bytes.
|
|
183
|
+
const tmp = path.join(d, `.${sanitizeSerial(serial)}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`);
|
|
184
|
+
fs.writeFileSync(tmp, `${JSON.stringify(lease, null, 2)}\n`);
|
|
185
|
+
fs.renameSync(tmp, file);
|
|
186
|
+
|
|
187
|
+
// Last-writer-wins detection: both racers reached the rename; whoever's bytes
|
|
188
|
+
// survived owns the device. Re-read and confirm it is US — the loser reports
|
|
189
|
+
// contention instead of driving a device someone else holds.
|
|
190
|
+
const confirm = readLeaseFile(file);
|
|
191
|
+
if (!confirm || confirm.pid !== lease.pid || confirm.acquiredAt !== lease.acquiredAt || confirm.holder !== lease.holder) {
|
|
192
|
+
return {
|
|
193
|
+
ok: false,
|
|
194
|
+
heldBy: confirm
|
|
195
|
+
? describeLease(confirm, now())
|
|
196
|
+
: { holder: "unknown (lease vanished mid-acquire)", pid: null, root: null, acquiredAt: null, ageMs: Number.POSITIVE_INFINITY },
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
return { ok: true, handle: { file, serial: lease.serial, pid: lease.pid, acquiredAt: lease.acquiredAt }, reclaimed };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Release a held lease. Idempotent and safe: missing file is fine, and a file
|
|
204
|
+
* that no longer carries OUR pid+acquiredAt belongs to a newer holder (they
|
|
205
|
+
* reclaimed us as stale, or won a race) — another holder's lease is NEVER
|
|
206
|
+
* deleted.
|
|
207
|
+
*/
|
|
208
|
+
export function releaseDeviceLease(handle) {
|
|
209
|
+
if (!handle || !handle.file) return;
|
|
210
|
+
const current = readLeaseFile(handle.file);
|
|
211
|
+
if (!current) return; // already gone (or unreadable — not provably ours, leave it)
|
|
212
|
+
if (current.pid !== handle.pid || current.acquiredAt !== handle.acquiredAt) return; // someone else's now
|
|
213
|
+
try {
|
|
214
|
+
fs.rmSync(handle.file, { force: true });
|
|
215
|
+
} catch {
|
|
216
|
+
/* releasing is best-effort; staleness reclaim is the backstop */
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Run `fn(handle)` under the lease, releasing in a finally. Sync or async fn.
|
|
222
|
+
* A refused acquire is returned as-is ({ok:false, heldBy}) — the caller decides
|
|
223
|
+
* what contention means (the verify lane turns it into a SKIP, never a FAIL).
|
|
224
|
+
*/
|
|
225
|
+
export function withDeviceLease(opts, fn) {
|
|
226
|
+
const res = acquireDeviceLease(opts);
|
|
227
|
+
if (!res.ok) return res;
|
|
228
|
+
let out;
|
|
229
|
+
try {
|
|
230
|
+
out = fn(res.handle);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
releaseDeviceLease(res.handle);
|
|
233
|
+
throw err;
|
|
234
|
+
}
|
|
235
|
+
if (out && typeof out.then === "function") {
|
|
236
|
+
return out.then(
|
|
237
|
+
(value) => {
|
|
238
|
+
releaseDeviceLease(res.handle);
|
|
239
|
+
return { ok: true, result: value, reclaimed: res.reclaimed };
|
|
240
|
+
},
|
|
241
|
+
(err) => {
|
|
242
|
+
releaseDeviceLease(res.handle);
|
|
243
|
+
throw err;
|
|
244
|
+
},
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
releaseDeviceLease(res.handle);
|
|
248
|
+
return { ok: true, result: out, reclaimed: res.reclaimed };
|
|
249
|
+
}
|