@deeeed/metamask-harness 0.58.0 → 0.59.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/CHANGELOG.md +11 -0
- package/adapters/extension/inject.mjs +0 -1
- package/adapters/extension/live.sh +11 -14
- package/adapters/manifest.json +4 -4
- package/adapters/shared/resolve-slot-ports-core.mjs +47 -19
- package/dist/adapters/extension/runtime-decision.js +1 -2
- package/dist/adapters/extension/runtime.js +4 -3
- package/dist/adapters/extension/verify.js +608 -0
- package/dist/adapters/resolve-slot-ports.js +2 -0
- package/dist/cli.js +1 -1
- package/dist/harness.js +3 -15
- package/package.json +1 -1
- package/adapters/extension/verify.sh +0 -572
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import http from "node:http";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { readRuntimeContextField, resolveRuntimeContextPath } from "../../harness.js";
|
|
7
|
+
import { recipeHarnessPath, recipeHarnessRoot, recipeRuntimeDir, runnerDir } from "../../paths.js";
|
|
8
|
+
import { resolveExtensionSlotPorts } from "../slot-ports.js";
|
|
9
|
+
const USAGE = "Usage: mm-harness verify --adapter extension -- [--target <metamask-extension>] [--out <recipes-dir>] [--cdp-port <port>] [--static-only] [--artifacts-dir <dir>]";
|
|
10
|
+
const EXTENSION_ID_PATTERN = /^[a-p]{32}$/u;
|
|
11
|
+
function parseExtensionVerifyArgs(argv) {
|
|
12
|
+
let target = process.cwd();
|
|
13
|
+
let out;
|
|
14
|
+
let cdpPort = "";
|
|
15
|
+
let artifactsDir;
|
|
16
|
+
let staticOnly = false;
|
|
17
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
18
|
+
const arg = argv[i];
|
|
19
|
+
const needsValue = () => {
|
|
20
|
+
if (i + 1 >= argv.length) return void 0;
|
|
21
|
+
i += 1;
|
|
22
|
+
return argv[i];
|
|
23
|
+
};
|
|
24
|
+
switch (arg) {
|
|
25
|
+
case "--target": {
|
|
26
|
+
const value = needsValue();
|
|
27
|
+
if (value === void 0) return { kind: "error", message: "Missing value for --target", exitCode: 2 };
|
|
28
|
+
target = value;
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
case "--out": {
|
|
32
|
+
const value = needsValue();
|
|
33
|
+
if (value === void 0) return { kind: "error", message: "Missing value for --out", exitCode: 2 };
|
|
34
|
+
out = value;
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
case "--cdp-port": {
|
|
38
|
+
const value = needsValue();
|
|
39
|
+
if (value === void 0) return { kind: "error", message: "Missing value for --cdp-port", exitCode: 2 };
|
|
40
|
+
cdpPort = value;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
case "--artifacts-dir": {
|
|
44
|
+
const value = needsValue();
|
|
45
|
+
if (value === void 0) return { kind: "error", message: "Missing value for --artifacts-dir", exitCode: 2 };
|
|
46
|
+
artifactsDir = value;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
case "--static-only":
|
|
50
|
+
staticOnly = true;
|
|
51
|
+
break;
|
|
52
|
+
case "-h":
|
|
53
|
+
case "--help":
|
|
54
|
+
return { kind: "help", usage: USAGE };
|
|
55
|
+
default:
|
|
56
|
+
return { kind: "error", message: `Unknown arg: ${arg}`, exitCode: 2 };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (cdpPort !== "" && !isNumericCdpPort(cdpPort)) {
|
|
60
|
+
return { kind: "error", message: `Invalid --cdp-port (must be numeric): ${cdpPort}`, exitCode: 2 };
|
|
61
|
+
}
|
|
62
|
+
return { kind: "options", options: { target, out, cdpPort, artifactsDir, staticOnly } };
|
|
63
|
+
}
|
|
64
|
+
function isNumericCdpPort(port) {
|
|
65
|
+
return port.length > 0 && /^[0-9]+$/u.test(port);
|
|
66
|
+
}
|
|
67
|
+
function resolveHarnessOut(target, out) {
|
|
68
|
+
if (!out) return void 0;
|
|
69
|
+
if (out.split(/[\\/]+/u).includes("..")) return void 0;
|
|
70
|
+
const root = path.resolve(target);
|
|
71
|
+
const resolved = path.resolve(root, out);
|
|
72
|
+
const relative = path.relative(root, resolved);
|
|
73
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return void 0;
|
|
74
|
+
let realRelative;
|
|
75
|
+
try {
|
|
76
|
+
realRelative = path.relative(fs.realpathSync(root), fs.realpathSync(resolved));
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (error.code === "ENOENT") return resolved;
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
if (!realRelative || realRelative.startsWith("..") || path.isAbsolute(realRelative)) return void 0;
|
|
82
|
+
return resolved;
|
|
83
|
+
}
|
|
84
|
+
function classifyExtensionRuntimeOwner(liveMode, cdpReachable, readinessStatus) {
|
|
85
|
+
if (liveMode === "static-only") return "static-only";
|
|
86
|
+
if (liveMode === "missing-cdp") return "none";
|
|
87
|
+
if (!cdpReachable) return "none";
|
|
88
|
+
return readinessStatus === "pass" ? "compatible-external-or-harness" : "incompatible-external-or-stale";
|
|
89
|
+
}
|
|
90
|
+
function buildExtensionVerifySummary(input) {
|
|
91
|
+
const readiness = input.checks.find((check) => check.name === "live extension readiness");
|
|
92
|
+
const cdp = input.readinessReport?.cdp;
|
|
93
|
+
const cdpTarget = cdp ? {
|
|
94
|
+
selectedExtensionId: cdp.selectedExtensionId || null,
|
|
95
|
+
markerExtensionId: input.markerExtensionId,
|
|
96
|
+
markerMatched: cdp.markerMatched ?? null,
|
|
97
|
+
markerRepaired: cdp.markerRepaired ?? null,
|
|
98
|
+
extensionIds: cdp.extensionIds || [],
|
|
99
|
+
targetCount: cdp.targetCount ?? null,
|
|
100
|
+
browser: cdp.browser || input.cdpHolder?.browser || null
|
|
101
|
+
} : null;
|
|
102
|
+
return {
|
|
103
|
+
adapter: "extension",
|
|
104
|
+
status: input.status,
|
|
105
|
+
liveMode: input.liveMode,
|
|
106
|
+
runtimeClassification: {
|
|
107
|
+
runtimeOwner: classifyExtensionRuntimeOwner(
|
|
108
|
+
input.liveMode,
|
|
109
|
+
Boolean(input.cdpHolder?.cdpReachable),
|
|
110
|
+
readiness?.status
|
|
111
|
+
),
|
|
112
|
+
recipeControllable: readiness?.status === "pass",
|
|
113
|
+
startedByVerify: false
|
|
114
|
+
},
|
|
115
|
+
cleanupOwnership: {
|
|
116
|
+
mayStop: false,
|
|
117
|
+
reason: "extension verify inspects the supplied CDP runtime; wrapper/preflight ownership must be recorded by the caller before stopping processes"
|
|
118
|
+
},
|
|
119
|
+
gitStatus: input.gitStatus,
|
|
120
|
+
distFreshness: input.distFreshness,
|
|
121
|
+
runtimePolicy: {
|
|
122
|
+
runtimeReusePolicy: "reuse a running harness-compatible CDP target when possible; wrapper auto-start must use a cached/watch-only prepare path unless the human explicitly permits a rebuild"
|
|
123
|
+
},
|
|
124
|
+
fixtureStatus: input.fixtureStatus,
|
|
125
|
+
cdpHolder: input.cdpHolder,
|
|
126
|
+
ensureReady: input.ensureReady,
|
|
127
|
+
cdpTarget,
|
|
128
|
+
checks: input.checks,
|
|
129
|
+
generatedAt: input.generatedAt
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function deriveDistFreshness(decision) {
|
|
133
|
+
const dist = decision.checks?.dist ?? { status: "unknown" };
|
|
134
|
+
const modified = dist.modified;
|
|
135
|
+
const message = dist.source === "release-artifact" ? `release artifact ${dist.manifestVersion ?? "?"} sha256=${dist.artifactSha256 ?? "?"} matches the loaded runtime snapshot.` : dist.status === "fresh" ? "dist id matches HEAD; no uncommitted source." : dist.status === "stale" ? dist.reason === "uncommitted-source" ? `${modified ? modified.length : "some"} uncommitted source file(s); rebuild or commit.` : `dist id ${dist.distGitId ?? "?"} != HEAD ${dist.head ?? "?"}; rebuild.` : dist.status === "no-build" ? "no dist/chrome build." : "no git id in dist or not a git checkout; cannot prove parity.";
|
|
136
|
+
return { ...dist, message };
|
|
137
|
+
}
|
|
138
|
+
function deriveBuildHealth(decision) {
|
|
139
|
+
const buildLog = decision.checks?.releaseArtifact?.status === "valid" ? { status: "no-watch", source: "release-artifact" } : decision.checks?.buildLog ?? { status: "unknown" };
|
|
140
|
+
const message = buildLog.status === "ok" ? "webpack compiled." : buildLog.status === "no-watch" ? "no webpack watch log; build-health n/a (e.g. one-shot build)." : buildLog.status === "building" ? "webpack has not reported a successful compile yet." : buildLog.status === "errors" ? buildLog.reason === "stale-cache" ? "webpack build failing on a stale cache (ENOENT on a deduped module). Run `mm-harness runtime-launch --adapter extension --target <repo> --cdp-port <port>` to auto-clear the cache and rebuild." : "webpack build has errors; fix the source/build before validating." : "build-health unknown.";
|
|
141
|
+
return { status: buildLog.status, reason: buildLog.reason, excerpt: buildLog.excerpt, message };
|
|
142
|
+
}
|
|
143
|
+
function extensionFixtureStatus(target, runtimeDir) {
|
|
144
|
+
const candidates = [
|
|
145
|
+
`${runtimeDir}/wallet-fixture.json`,
|
|
146
|
+
"test/e2e/seeder/withFixtures.js",
|
|
147
|
+
"test/e2e/fixtures",
|
|
148
|
+
"fixtures"
|
|
149
|
+
].map((rel2) => path.join(target, rel2));
|
|
150
|
+
let found;
|
|
151
|
+
let stat;
|
|
152
|
+
for (const file of candidates) {
|
|
153
|
+
stat = fs.statSync(file, { throwIfNoEntry: false });
|
|
154
|
+
if (stat) {
|
|
155
|
+
found = file;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const profileHints = [];
|
|
160
|
+
if (fs.existsSync(path.join(target, runtimeDir, "extension.id"))) {
|
|
161
|
+
profileHints.push({ path: `${runtimeDir}/extension.id`, type: "extension-id" });
|
|
162
|
+
}
|
|
163
|
+
if (!found || !stat) {
|
|
164
|
+
return {
|
|
165
|
+
status: "MISSING_FIXTURES",
|
|
166
|
+
message: "Fixture status: MISSING_FIXTURES. This run may depend on an inherited browser/profile state. Prefer a prepared debug profile or fixture seed before spending time repairing state manually.",
|
|
167
|
+
profileHints
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
const isFile = stat.isFile();
|
|
171
|
+
let sha256 = null;
|
|
172
|
+
let validJson = null;
|
|
173
|
+
let hasWalletPassword = false;
|
|
174
|
+
let mobileAccountShape = false;
|
|
175
|
+
const rel = path.relative(target, found);
|
|
176
|
+
const isWalletFixture = rel === `${runtimeDir}/wallet-fixture.json`;
|
|
177
|
+
if (isFile) {
|
|
178
|
+
const bytes = fs.readFileSync(found);
|
|
179
|
+
sha256 = crypto.createHash("sha256").update(bytes).digest("hex");
|
|
180
|
+
if (found.endsWith(".json")) {
|
|
181
|
+
try {
|
|
182
|
+
const parsed = JSON.parse(bytes.toString("utf8"));
|
|
183
|
+
validJson = true;
|
|
184
|
+
const accounts = Array.isArray(parsed.accounts) ? parsed.accounts : [];
|
|
185
|
+
hasWalletPassword = typeof parsed.password === "string" && parsed.password.length > 0;
|
|
186
|
+
mobileAccountShape = accounts.some((account) => account?.type === "mnemonic") && accounts.filter((account) => account?.type === "privateKey").length >= 2;
|
|
187
|
+
} catch {
|
|
188
|
+
validJson = false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const status = validJson === false ? "STALE_OR_INVALID" : isWalletFixture && hasWalletPassword ? "READY" : "PROFILE_HINTS";
|
|
193
|
+
return {
|
|
194
|
+
status,
|
|
195
|
+
path: rel,
|
|
196
|
+
type: isFile ? "file" : "directory",
|
|
197
|
+
sha256,
|
|
198
|
+
modifiedAt: stat.mtime.toISOString(),
|
|
199
|
+
profileHints,
|
|
200
|
+
hasWalletPassword,
|
|
201
|
+
mobileAccountShape,
|
|
202
|
+
message: validJson === false ? `Fixture status: STALE_OR_INVALID (${rel}). Fix before relying on a clean sandbox.` : status === "READY" ? `Fixture status: READY (${rel}).` : `Fixture status: PROFILE_HINTS (${rel}); no Mobile-shaped wallet fixture was found for automatic account parity validation.`
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
function capture(command, args) {
|
|
206
|
+
try {
|
|
207
|
+
return execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
208
|
+
} catch {
|
|
209
|
+
return "";
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function getCdpJson(port, route) {
|
|
213
|
+
return new Promise((resolve) => {
|
|
214
|
+
const request = http.get(`http://127.0.0.1:${port}${route}`, { timeout: 1e3 }, (response) => {
|
|
215
|
+
let data = "";
|
|
216
|
+
response.on("data", (chunk) => {
|
|
217
|
+
data += chunk;
|
|
218
|
+
});
|
|
219
|
+
response.on("end", () => {
|
|
220
|
+
try {
|
|
221
|
+
resolve(JSON.parse(data));
|
|
222
|
+
} catch {
|
|
223
|
+
resolve(null);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
request.on("timeout", () => {
|
|
228
|
+
request.destroy();
|
|
229
|
+
resolve(null);
|
|
230
|
+
});
|
|
231
|
+
request.on("error", () => resolve(null));
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
async function cdpHolder(port) {
|
|
235
|
+
const pid = capture("lsof", [`-iTCP:${port}`, "-sTCP:LISTEN", "-t"]).split("\n")[0] ?? "";
|
|
236
|
+
const command = /^[0-9]+$/u.test(pid) ? capture("ps", ["-p", pid, "-o", "command="]) : "";
|
|
237
|
+
const version = await getCdpJson(port, "/json/version");
|
|
238
|
+
const targets = await getCdpJson(port, "/json/list");
|
|
239
|
+
const extensionTargets = Array.isArray(targets) ? targets.filter((target) => String(target.url || "").startsWith("chrome-extension://")).length : 0;
|
|
240
|
+
return {
|
|
241
|
+
port,
|
|
242
|
+
listening: Boolean(pid),
|
|
243
|
+
pid: pid || null,
|
|
244
|
+
command: command || null,
|
|
245
|
+
cdpReachable: Boolean(version),
|
|
246
|
+
browser: version?.Browser || null,
|
|
247
|
+
targetCount: Array.isArray(targets) ? targets.length : null,
|
|
248
|
+
extensionTargets
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
function spawnToFiles(command, args, options) {
|
|
252
|
+
const open = (file) => {
|
|
253
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
254
|
+
return fs.openSync(file, "w");
|
|
255
|
+
};
|
|
256
|
+
const outFd = options.stdout === void 0 ? "ignore" : open(options.stdout);
|
|
257
|
+
const errFd = options.stderr === void 0 || options.stderr === "ignore" ? "ignore" : options.stderr === "stdout" ? outFd : open(options.stderr);
|
|
258
|
+
try {
|
|
259
|
+
const result = spawnSync(command, args, {
|
|
260
|
+
cwd: options.cwd,
|
|
261
|
+
env: process.env,
|
|
262
|
+
stdio: ["ignore", outFd, errFd]
|
|
263
|
+
});
|
|
264
|
+
if (result.error) {
|
|
265
|
+
if (typeof errFd === "number") fs.writeSync(errFd, `${result.error.message}
|
|
266
|
+
`);
|
|
267
|
+
return 127;
|
|
268
|
+
}
|
|
269
|
+
return result.status ?? 1;
|
|
270
|
+
} finally {
|
|
271
|
+
if (typeof outFd === "number") fs.closeSync(outFd);
|
|
272
|
+
if (typeof errFd === "number" && errFd !== outFd) fs.closeSync(errFd);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
function readJsonFile(file) {
|
|
276
|
+
try {
|
|
277
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
278
|
+
} catch {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function gitStatusSummary(target, harnessRootExclude) {
|
|
283
|
+
const runGit = (args) => {
|
|
284
|
+
try {
|
|
285
|
+
return execFileSync("git", ["-C", target, ...args], {
|
|
286
|
+
encoding: "utf8",
|
|
287
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
288
|
+
}).trim();
|
|
289
|
+
} catch {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
const statusShort = runGit(["status", "--short", "--", ".", `:(exclude)${harnessRootExclude}`]);
|
|
294
|
+
const dirty = statusShort ? statusShort.split("\n").filter(Boolean) : [];
|
|
295
|
+
return {
|
|
296
|
+
branch: runGit(["branch", "--show-current"]),
|
|
297
|
+
head: runGit(["rev-parse", "--short", "HEAD"]),
|
|
298
|
+
dirtyCount: dirty.length,
|
|
299
|
+
dirtyPreview: dirty.slice(0, 25)
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
function utcStamp(date) {
|
|
303
|
+
return `${date.toISOString().replace(/[-:]/gu, "").split(".")[0]}Z`;
|
|
304
|
+
}
|
|
305
|
+
const defaultIo = {
|
|
306
|
+
out: (line) => process.stdout.write(`${line}
|
|
307
|
+
`),
|
|
308
|
+
err: (line) => process.stderr.write(`${line}
|
|
309
|
+
`)
|
|
310
|
+
};
|
|
311
|
+
async function runExtensionVerify(argv, io = defaultIo) {
|
|
312
|
+
const parsed = parseExtensionVerifyArgs(argv);
|
|
313
|
+
if (parsed.kind === "help") {
|
|
314
|
+
io.out(parsed.usage);
|
|
315
|
+
return 0;
|
|
316
|
+
}
|
|
317
|
+
if (parsed.kind === "error") {
|
|
318
|
+
io.err(parsed.message);
|
|
319
|
+
return parsed.exitCode;
|
|
320
|
+
}
|
|
321
|
+
const { staticOnly } = parsed.options;
|
|
322
|
+
const target = path.resolve(parsed.options.target);
|
|
323
|
+
if (!fs.existsSync(target)) {
|
|
324
|
+
io.err(`Refusing extension verify: target checkout does not exist: ${target}`);
|
|
325
|
+
io.err(" Next: pass --target <metamask-extension checkout>");
|
|
326
|
+
return 1;
|
|
327
|
+
}
|
|
328
|
+
const runtimeDir = recipeRuntimeDir();
|
|
329
|
+
const harnessRoot = recipeHarnessRoot();
|
|
330
|
+
const harnessRel = `${harnessRoot}/extension`;
|
|
331
|
+
const harnessDir = recipeHarnessPath(target, "extension");
|
|
332
|
+
const runnerBin = path.join(harnessDir, "runner/bin/mm-harness");
|
|
333
|
+
let smokeRecipe = path.join(harnessDir, "runner/recipes/extension/runner/smoke.recipe.json");
|
|
334
|
+
if (parsed.options.out) {
|
|
335
|
+
const outAbs = resolveHarnessOut(target, parsed.options.out);
|
|
336
|
+
if (!outAbs) {
|
|
337
|
+
io.err(`extension verify: --out '${parsed.options.out}' did not resolve to a safe path under the target.`);
|
|
338
|
+
return 2;
|
|
339
|
+
}
|
|
340
|
+
const canonical = path.join(outAbs, "extension/runner/smoke.recipe.json");
|
|
341
|
+
const legacy = path.join(outAbs, "runner/smoke.extension.recipe.json");
|
|
342
|
+
if (fs.existsSync(canonical) && fs.existsSync(legacy)) {
|
|
343
|
+
io.err(`extension verify: --out '${parsed.options.out}' declares runner.smoke for extension in both canonical and legacy paths.`);
|
|
344
|
+
io.err(`Next: Remove ${legacy} and rerun verification.`);
|
|
345
|
+
return 2;
|
|
346
|
+
}
|
|
347
|
+
if (fs.existsSync(canonical)) {
|
|
348
|
+
smokeRecipe = canonical;
|
|
349
|
+
} else if (fs.existsSync(legacy)) {
|
|
350
|
+
smokeRecipe = legacy;
|
|
351
|
+
} else {
|
|
352
|
+
io.err(`extension verify: --out '${parsed.options.out}' has no extension runner.smoke recipe.`);
|
|
353
|
+
io.err(`Next: Add extension/runner/smoke.recipe.json under ${outAbs} or omit --out.`);
|
|
354
|
+
return 2;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const artifacts = parsed.options.artifactsDir ? path.resolve(parsed.options.artifactsDir) : path.join(harnessDir, "verify", utcStamp(/* @__PURE__ */ new Date()));
|
|
358
|
+
fs.mkdirSync(path.join(artifacts, "logs"), { recursive: true });
|
|
359
|
+
const extensionIdFile = path.join(target, runtimeDir, "extension.id");
|
|
360
|
+
const installedScripts = path.join(harnessDir, "scripts");
|
|
361
|
+
const readinessMjs = [
|
|
362
|
+
path.join(installedScripts, "readiness.mjs"),
|
|
363
|
+
path.join(runnerDir, "adapters/extension/readiness.mjs")
|
|
364
|
+
].find((candidate) => fs.existsSync(candidate));
|
|
365
|
+
if (!readinessMjs) {
|
|
366
|
+
io.err(`extension verify: readiness.mjs missing next to ${installedScripts}; reinstall the runner.`);
|
|
367
|
+
return 1;
|
|
368
|
+
}
|
|
369
|
+
const refreshExtensionId = () => {
|
|
370
|
+
if (fs.existsSync(extensionIdFile)) {
|
|
371
|
+
process.env.RECIPE_HARNESS_EXTENSION_ID = fs.readFileSync(extensionIdFile, "utf8").replace(/\s+/gu, "");
|
|
372
|
+
} else {
|
|
373
|
+
delete process.env.RECIPE_HARNESS_EXTENSION_ID;
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
const contextPath = resolveRuntimeContextPath(target);
|
|
377
|
+
const contextExtensionId = fs.existsSync(contextPath) ? readRuntimeContextField(contextPath, "extensionId") : void 0;
|
|
378
|
+
if (contextExtensionId !== void 0 && EXTENSION_ID_PATTERN.test(contextExtensionId)) {
|
|
379
|
+
fs.mkdirSync(path.dirname(extensionIdFile), { recursive: true });
|
|
380
|
+
fs.writeFileSync(extensionIdFile, `${contextExtensionId}
|
|
381
|
+
`);
|
|
382
|
+
process.env.RECIPE_HARNESS_EXTENSION_ID = contextExtensionId;
|
|
383
|
+
} else {
|
|
384
|
+
refreshExtensionId();
|
|
385
|
+
}
|
|
386
|
+
let cdpPort = parsed.options.cdpPort;
|
|
387
|
+
if (!cdpPort) {
|
|
388
|
+
resolveExtensionSlotPorts(target);
|
|
389
|
+
cdpPort = process.env.CDP_PORT ?? "";
|
|
390
|
+
if (cdpPort !== "" && !isNumericCdpPort(cdpPort)) {
|
|
391
|
+
io.err(`Invalid inferred --cdp-port (must be numeric): ${cdpPort}`);
|
|
392
|
+
return 2;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
let status = "pass";
|
|
396
|
+
const checks = [];
|
|
397
|
+
const checkFile = (rel) => {
|
|
398
|
+
const ok = fs.existsSync(path.join(target, rel));
|
|
399
|
+
checks.push({ name: rel, status: ok ? "pass" : "fail" });
|
|
400
|
+
if (!ok) status = "fail";
|
|
401
|
+
};
|
|
402
|
+
checkFile(`${harnessRel}/manifest.json`);
|
|
403
|
+
checkFile(`${harnessRel}/action-manifest.json`);
|
|
404
|
+
checkFile(`${harnessRel}/runner/bin/mm-harness`);
|
|
405
|
+
checkFile(`${harnessRel}/runner/manifests/extension.action-manifest.json`);
|
|
406
|
+
const decisionLog = path.join(artifacts, "logs/runtime-decision.json");
|
|
407
|
+
const decisionCode = spawnToFiles(runnerBin, ["runtime-decision", "--adapter", "extension", "--target", target, "--json"], {
|
|
408
|
+
stdout: decisionLog,
|
|
409
|
+
stderr: "ignore"
|
|
410
|
+
});
|
|
411
|
+
if (decisionCode !== 0) fs.writeFileSync(decisionLog, "{}");
|
|
412
|
+
const decisionRaw = fs.readFileSync(decisionLog, "utf8");
|
|
413
|
+
if (decisionRaw === "" || decisionRaw.replace(/\n+$/u, "") === "{}") {
|
|
414
|
+
io.err("recipe-harness verify: runtime-decision returned empty/{} \u2014 dist-freshness and build-health cannot be derived from the runner and will degrade to WARN (runtime-proof gate NOT fully validated). Check the runner/build state.");
|
|
415
|
+
}
|
|
416
|
+
const distFreshnessLog = path.join(artifacts, "logs/dist-freshness.json");
|
|
417
|
+
const buildHealthLog = path.join(artifacts, "logs/build-health.json");
|
|
418
|
+
try {
|
|
419
|
+
const decision = readJsonFile(decisionLog) ?? {};
|
|
420
|
+
fs.writeFileSync(distFreshnessLog, JSON.stringify(deriveDistFreshness(decision)));
|
|
421
|
+
fs.writeFileSync(buildHealthLog, JSON.stringify(deriveBuildHealth(decision)));
|
|
422
|
+
} catch {
|
|
423
|
+
fs.writeFileSync(distFreshnessLog, '{"status":"unknown","message":"dist-freshness probe error"}');
|
|
424
|
+
fs.writeFileSync(buildHealthLog, '{"status":"unknown","message":"build-health probe error"}');
|
|
425
|
+
}
|
|
426
|
+
const readField = (file, key) => {
|
|
427
|
+
const value = readJsonFile(file)?.[key];
|
|
428
|
+
return value === void 0 || value === null ? "" : String(value);
|
|
429
|
+
};
|
|
430
|
+
const distStatus = readField(distFreshnessLog, "status");
|
|
431
|
+
io.err(`dist-freshness: ${distStatus || "unknown"} \u2014 ${readField(distFreshnessLog, "message")}`);
|
|
432
|
+
if (distStatus === "fresh") {
|
|
433
|
+
checks.push({ name: "dist-freshness", status: "pass" });
|
|
434
|
+
} else if (distStatus === "stale") {
|
|
435
|
+
if (!staticOnly) {
|
|
436
|
+
checks.push({ name: "dist-freshness", status: "fail", detail: "see logs/dist-freshness.json" });
|
|
437
|
+
status = "fail";
|
|
438
|
+
} else {
|
|
439
|
+
checks.push({ name: "dist-freshness", status: "warn", detail: "stale (static-only); see logs/dist-freshness.json" });
|
|
440
|
+
}
|
|
441
|
+
} else {
|
|
442
|
+
checks.push({ name: "dist-freshness", status: "warn", detail: `${distStatus || "unknown"}; see logs/dist-freshness.json` });
|
|
443
|
+
}
|
|
444
|
+
const buildStatus = readField(buildHealthLog, "status");
|
|
445
|
+
io.err(`build-health: ${buildStatus || "unknown"} \u2014 ${readField(buildHealthLog, "message")}`);
|
|
446
|
+
if (buildStatus === "ok" || buildStatus === "no-watch") {
|
|
447
|
+
checks.push({ name: "build-health", status: "pass" });
|
|
448
|
+
} else if (buildStatus === "errors") {
|
|
449
|
+
if (!staticOnly) {
|
|
450
|
+
checks.push({ name: "build-health", status: "fail", detail: "see logs/build-health.json" });
|
|
451
|
+
status = "fail";
|
|
452
|
+
} else {
|
|
453
|
+
checks.push({ name: "build-health", status: "warn", detail: "build errors (static-only); see logs/build-health.json" });
|
|
454
|
+
}
|
|
455
|
+
} else if (buildStatus === "building") {
|
|
456
|
+
checks.push({ name: "build-health", status: "warn", detail: "still compiling; see logs/build-health.json" });
|
|
457
|
+
} else {
|
|
458
|
+
checks.push({ name: "build-health", status: "warn", detail: `${buildStatus || "unknown"}; see logs/build-health.json` });
|
|
459
|
+
}
|
|
460
|
+
let liveMode = "static-only";
|
|
461
|
+
let fixtureStatus = null;
|
|
462
|
+
let holder = null;
|
|
463
|
+
if (!staticOnly) {
|
|
464
|
+
fixtureStatus = extensionFixtureStatus(target, runtimeDir);
|
|
465
|
+
fs.writeFileSync(path.join(artifacts, "logs/fixture-status.json"), `${JSON.stringify(fixtureStatus)}
|
|
466
|
+
`);
|
|
467
|
+
io.err(fixtureStatus.message || fixtureStatus.status);
|
|
468
|
+
checks.push({ name: "fixture/profile status", status: fixtureStatus.status === "READY" ? "pass" : "warn" });
|
|
469
|
+
if (!cdpPort) {
|
|
470
|
+
fs.writeFileSync(
|
|
471
|
+
path.join(artifacts, "logs/live-missing-cdp.log"),
|
|
472
|
+
"Live extension verify could not infer a checkout CDP port. Run from a prepared slot checkout or pass --cdp-port.\n"
|
|
473
|
+
);
|
|
474
|
+
checks.push({
|
|
475
|
+
name: "live runtime CDP port",
|
|
476
|
+
status: "fail",
|
|
477
|
+
detail: "missing CDP port (checkout-scoped inference failed)"
|
|
478
|
+
});
|
|
479
|
+
status = "fail";
|
|
480
|
+
liveMode = "missing-cdp";
|
|
481
|
+
} else {
|
|
482
|
+
liveMode = "live";
|
|
483
|
+
holder = await cdpHolder(cdpPort);
|
|
484
|
+
fs.writeFileSync(path.join(artifacts, "logs/cdp-holder.json"), `${JSON.stringify(holder)}
|
|
485
|
+
`);
|
|
486
|
+
const ensureReadyLog = path.join(artifacts, "logs/extension-ensure-ready.json");
|
|
487
|
+
const ensureReadyCode = spawnToFiles(
|
|
488
|
+
runnerBin,
|
|
489
|
+
["ensure-ready", "--adapter", "extension", "--target", target, "--cdp-port", cdpPort, "--json"],
|
|
490
|
+
{ stdout: ensureReadyLog, stderr: "stdout" }
|
|
491
|
+
);
|
|
492
|
+
if (ensureReadyCode === 0) {
|
|
493
|
+
checks.push({ name: "home-tab convergence", status: "pass", detail: "see logs/extension-ensure-ready.json" });
|
|
494
|
+
refreshExtensionId();
|
|
495
|
+
} else {
|
|
496
|
+
checks.push({ name: "home-tab convergence", status: "fail", detail: "see logs/extension-ensure-ready.json" });
|
|
497
|
+
reportEnsureReadyFailure(ensureReadyLog, io);
|
|
498
|
+
status = "fail";
|
|
499
|
+
}
|
|
500
|
+
const readinessLog = path.join(artifacts, "logs/extension-readiness.json");
|
|
501
|
+
const readinessCode = spawnToFiles(
|
|
502
|
+
process.execPath,
|
|
503
|
+
[readinessMjs, "--target", target, "--cdp-port", cdpPort, "--json"],
|
|
504
|
+
{ stdout: readinessLog, stderr: "stdout" }
|
|
505
|
+
);
|
|
506
|
+
if (readinessCode === 0) {
|
|
507
|
+
checks.push({ name: "live extension readiness", status: "pass" });
|
|
508
|
+
refreshExtensionId();
|
|
509
|
+
} else {
|
|
510
|
+
checks.push({ name: "live extension readiness", status: "fail", detail: "see logs/extension-readiness.json" });
|
|
511
|
+
status = "fail";
|
|
512
|
+
}
|
|
513
|
+
const manifestCode = spawnToFiles(runnerBin, ["actions", "--raw", "--adapter", "extension"], {
|
|
514
|
+
cwd: target,
|
|
515
|
+
stdout: path.join(artifacts, "logs/runner-manifest.json"),
|
|
516
|
+
stderr: path.join(artifacts, "logs/runner-manifest.err")
|
|
517
|
+
});
|
|
518
|
+
if (manifestCode === 0) {
|
|
519
|
+
checks.push({ name: "runner manifest", status: "pass" });
|
|
520
|
+
} else {
|
|
521
|
+
checks.push({ name: "runner manifest", status: "fail", detail: "see logs/runner-manifest.err" });
|
|
522
|
+
status = "fail";
|
|
523
|
+
}
|
|
524
|
+
const smokeCode = spawnToFiles(
|
|
525
|
+
runnerBin,
|
|
526
|
+
[
|
|
527
|
+
"run",
|
|
528
|
+
smokeRecipe,
|
|
529
|
+
"--adapter",
|
|
530
|
+
"extension",
|
|
531
|
+
"--target",
|
|
532
|
+
target,
|
|
533
|
+
"--cdp-port",
|
|
534
|
+
cdpPort,
|
|
535
|
+
"--artifacts-dir",
|
|
536
|
+
path.join(artifacts, "runner-smoke"),
|
|
537
|
+
"--json"
|
|
538
|
+
],
|
|
539
|
+
{ cwd: target, stdout: path.join(artifacts, "logs/runner-smoke.log"), stderr: "stdout" }
|
|
540
|
+
);
|
|
541
|
+
if (smokeCode === 0) {
|
|
542
|
+
checks.push({ name: "runner v1 smoke", status: "pass" });
|
|
543
|
+
} else {
|
|
544
|
+
checks.push({ name: "runner v1 smoke", status: "fail", detail: "see logs/runner-smoke.log" });
|
|
545
|
+
status = "fail";
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const isGitCheckout = spawnToFiles("git", ["-C", target, "rev-parse", "--git-dir"], {}) === 0;
|
|
550
|
+
if (isGitCheckout) {
|
|
551
|
+
spawnToFiles("git", ["-C", target, "status", "--short", "--", ".", `:(exclude)${harnessRoot}`], {
|
|
552
|
+
stdout: path.join(artifacts, "logs/product-diff-excluding-harness.log"),
|
|
553
|
+
stderr: "stdout"
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
let markerExtensionId = null;
|
|
557
|
+
try {
|
|
558
|
+
const value = fs.readFileSync(extensionIdFile, "utf8").trim();
|
|
559
|
+
if (EXTENSION_ID_PATTERN.test(value)) markerExtensionId = value;
|
|
560
|
+
} catch {
|
|
561
|
+
}
|
|
562
|
+
const summary = buildExtensionVerifySummary({
|
|
563
|
+
status,
|
|
564
|
+
liveMode,
|
|
565
|
+
gitStatus: gitStatusSummary(target, harnessRoot),
|
|
566
|
+
distFreshness: readJsonFile(distFreshnessLog),
|
|
567
|
+
fixtureStatus: readJsonFile(path.join(artifacts, "logs/fixture-status.json")),
|
|
568
|
+
cdpHolder: readJsonFile(path.join(artifacts, "logs/cdp-holder.json")),
|
|
569
|
+
ensureReady: readJsonFile(path.join(artifacts, "logs/extension-ensure-ready.json")),
|
|
570
|
+
readinessReport: readJsonFile(path.join(artifacts, "logs/extension-readiness.json")),
|
|
571
|
+
markerExtensionId,
|
|
572
|
+
checks,
|
|
573
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
574
|
+
});
|
|
575
|
+
fs.writeFileSync(path.join(artifacts, "summary.json"), `${JSON.stringify(summary, null, 2)}
|
|
576
|
+
`);
|
|
577
|
+
fs.writeFileSync(
|
|
578
|
+
path.join(artifacts, "artifact-manifest.json"),
|
|
579
|
+
`${JSON.stringify({ artifacts: fs.readdirSync(artifacts).map((name) => ({ path: name })) }, null, 2)}
|
|
580
|
+
`
|
|
581
|
+
);
|
|
582
|
+
io.out(`Extension harness verify ${status}: ${path.join(artifacts, "summary.json")}`);
|
|
583
|
+
return status === "pass" ? 0 : 1;
|
|
584
|
+
}
|
|
585
|
+
function reportEnsureReadyFailure(file, io) {
|
|
586
|
+
const report = readJsonFile(file);
|
|
587
|
+
if (!report) return;
|
|
588
|
+
const code = report.health?.errorCode;
|
|
589
|
+
const findings = Array.isArray(report.health?.findings) ? report.health.findings : [];
|
|
590
|
+
if (code) {
|
|
591
|
+
const finding = findings[0] ?? "Extension UI is not interactive.";
|
|
592
|
+
io.err(finding.startsWith(`${code}:`) ? finding : `${code}: ${finding}`);
|
|
593
|
+
} else if (findings[0]) {
|
|
594
|
+
io.err(findings[0]);
|
|
595
|
+
}
|
|
596
|
+
if (report.health?.userAction) io.err(`Next: ${report.health.userAction}`);
|
|
597
|
+
}
|
|
598
|
+
export {
|
|
599
|
+
buildExtensionVerifySummary,
|
|
600
|
+
classifyExtensionRuntimeOwner,
|
|
601
|
+
deriveBuildHealth,
|
|
602
|
+
deriveDistFreshness,
|
|
603
|
+
extensionFixtureStatus,
|
|
604
|
+
isNumericCdpPort,
|
|
605
|
+
parseExtensionVerifyArgs,
|
|
606
|
+
resolveHarnessOut,
|
|
607
|
+
runExtensionVerify
|
|
608
|
+
};
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
formatExtensionIdentityPorts,
|
|
4
4
|
formatExtensionSlotPorts,
|
|
5
5
|
inferSlotSuffix,
|
|
6
|
+
poolFilesForHost,
|
|
6
7
|
realRepoPath,
|
|
7
8
|
resolveDefaultExtensionPorts,
|
|
8
9
|
resolveExtensionRuntimeContext,
|
|
@@ -17,6 +18,7 @@ export {
|
|
|
17
18
|
formatExtensionSlotPorts,
|
|
18
19
|
formatKvLines,
|
|
19
20
|
inferSlotSuffix,
|
|
21
|
+
poolFilesForHost,
|
|
20
22
|
realRepoPath,
|
|
21
23
|
resolveDefaultExtensionPorts,
|
|
22
24
|
resolveExtensionRuntimeContext,
|
package/dist/cli.js
CHANGED
|
@@ -103,7 +103,7 @@ async function main(argv) {
|
|
|
103
103
|
if (command === "completion-candidates") return handleCompletionCandidates(argv.slice(1));
|
|
104
104
|
if (command === "provision") return handleProvision(parseArgs(argv.slice(1), command));
|
|
105
105
|
if (OVERLAY_COMMANDS.includes(command)) {
|
|
106
|
-
if (command === "verify") {
|
|
106
|
+
if (command === "verify" && process.env.MM_HARNESS_SKIP_OVERLAY_ENSURE !== "1") {
|
|
107
107
|
try {
|
|
108
108
|
const { options } = parseArgs(argv.slice(1), command);
|
|
109
109
|
const target = targetPath(options);
|
package/dist/harness.js
CHANGED
|
@@ -259,19 +259,7 @@ Next: mm-harness install --platform core --target ${target}`
|
|
|
259
259
|
}
|
|
260
260
|
return { command: delegate, prefixArgs: ["doctor", "--adapter", "core"] };
|
|
261
261
|
}
|
|
262
|
-
|
|
263
|
-
return { inProcess: "mobile-verify" };
|
|
264
|
-
}
|
|
265
|
-
const installedScript = path.join(recipeHarnessPath(target, adapter), "scripts", `${action}.sh`);
|
|
266
|
-
if (fs.existsSync(installedScript)) {
|
|
267
|
-
return { command: installedScript, prefixArgs: [] };
|
|
268
|
-
}
|
|
269
|
-
const command = resolveEntry(
|
|
270
|
-
runnerDir,
|
|
271
|
-
[`adapters/${adapter}/${action}.sh`, `scripts/${adapter}/${action}.sh`],
|
|
272
|
-
"file"
|
|
273
|
-
);
|
|
274
|
-
return { command, prefixArgs: [] };
|
|
262
|
+
return { inProcess: adapter === "mobile" ? "mobile-verify" : "extension-verify" };
|
|
275
263
|
}
|
|
276
264
|
async function handleHarness(argv) {
|
|
277
265
|
const action = argv[0];
|
|
@@ -340,10 +328,10 @@ async function handleHarness(argv) {
|
|
|
340
328
|
}
|
|
341
329
|
const start = Date.now();
|
|
342
330
|
if ("inProcess" in dispatch) {
|
|
343
|
-
const
|
|
331
|
+
const runVerify = dispatch.inProcess === "mobile-verify" ? (await import("./adapters/mobile/verify.js")).runMobileVerify : (await import("./adapters/extension/verify.js")).runExtensionVerify;
|
|
344
332
|
let exitCode;
|
|
345
333
|
try {
|
|
346
|
-
exitCode = await
|
|
334
|
+
exitCode = await runVerify(forwardArgs, {
|
|
347
335
|
out: quiet ? () => {
|
|
348
336
|
} : (line) => {
|
|
349
337
|
(json ? process.stderr : process.stdout).write(`${line}
|