@deeeed/metamask-harness 0.58.0 → 0.60.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +48 -0
  3. package/adapters/extension/build-handoff.cjs +4 -0
  4. package/adapters/extension/inject.mjs +0 -1
  5. package/adapters/extension/live.sh +26 -25
  6. package/adapters/extension/pin-remote-flags.cjs +69 -30
  7. package/adapters/manifest.json +4 -4
  8. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +16 -0
  9. package/adapters/mobile/bridge-runtime/setup-wallet.sh +69 -2
  10. package/adapters/shared/resolve-slot-ports-core.mjs +47 -19
  11. package/dist/adapters/extension/feature-flags.js +41 -0
  12. package/dist/adapters/extension/runtime-decision.js +2 -2
  13. package/dist/adapters/extension/runtime.js +4 -3
  14. package/dist/adapters/extension/verify.js +608 -0
  15. package/dist/adapters/mobile/feature-flags.js +127 -0
  16. package/dist/adapters/resolve-slot-ports.js +2 -0
  17. package/dist/adapters.js +6 -1
  18. package/dist/cli.js +1 -1
  19. package/dist/command-contract.js +6 -3
  20. package/dist/commands/doctor.js +9 -1
  21. package/dist/commands/fixtures.js +45 -3
  22. package/dist/commands/launch/extension.js +28 -6
  23. package/dist/commands/launch/index.js +128 -17
  24. package/dist/commands/launch/mobile.js +26 -2
  25. package/dist/commands/mobile-device-view.js +8 -1
  26. package/dist/commands/prepare.js +20 -3
  27. package/dist/commands/run-engine.js +10 -1
  28. package/dist/commands/runtime-launch.js +10 -1
  29. package/dist/commands/status-probe.js +14 -1
  30. package/dist/commands/status.js +13 -0
  31. package/dist/doctor.js +9 -1
  32. package/dist/feature-flags.js +193 -0
  33. package/dist/harness.js +3 -15
  34. package/dist/mm-harness-cli.js +15 -2
  35. package/library/actions/core/feature_flags/_unsupported.mjs +29 -0
  36. package/library/actions/core/feature_flags/clear.mjs +8 -0
  37. package/library/actions/core/feature_flags/read.mjs +8 -0
  38. package/library/actions/core/feature_flags/set.mjs +8 -0
  39. package/library/actions/extension/feature_flags/clear.mjs +48 -0
  40. package/library/actions/extension/feature_flags/read.mjs +27 -0
  41. package/library/actions/extension/feature_flags/set.mjs +36 -0
  42. package/library/actions/extension/feature_flags/state.mjs +129 -0
  43. package/library/actions/harness-exports.mjs +14 -0
  44. package/library/actions/mobile/feature_flags/clear.mjs +20 -0
  45. package/library/actions/mobile/feature_flags/controller.mjs +120 -0
  46. package/library/actions/mobile/feature_flags/read.mjs +20 -0
  47. package/library/actions/mobile/feature_flags/set.mjs +25 -0
  48. package/library/actions/mobile/wallet/reset.mjs +22 -1
  49. package/library/manifests/core.action-manifest.json +90 -0
  50. package/library/manifests/extension.action-manifest.json +70 -0
  51. package/library/manifests/mobile.action-manifest.json +90 -0
  52. package/package.json +1 -1
  53. package/adapters/extension/verify.sh +0 -572
@@ -11,7 +11,6 @@ import { recipeHarnessPath, recipeRuntimeDir, recipeWatchLogCandidates } from ".
11
11
  const WEBPACK_DIRECT_INPUTS = ["package.json", "yarn.lock", ".yarnrc.yml", ".tool-versions"];
12
12
  const WEBPACK_RECURSIVE_INPUTS = ["development/webpack"];
13
13
  const WEBPACK_CACHE_DIR = "node_modules/.cache/webpack";
14
- const WATCH_LOG_CANDIDATES = recipeWatchLogCandidates();
15
14
  function git(target, args) {
16
15
  try {
17
16
  return execFileSync("git", ["-C", target, ...args], {
@@ -95,7 +94,7 @@ function webpackCacheCheck(target) {
95
94
  return { cachePresent: true, status, hasBaseline: true };
96
95
  }
97
96
  function buildLogCheck(target, watchLog) {
98
- const candidates = (watchLog ? [path.isAbsolute(watchLog) ? watchLog : path.join(target, watchLog)] : WATCH_LOG_CANDIDATES.map((rel) => path.join(target, rel))).filter((file) => fs.existsSync(file));
97
+ const candidates = (watchLog ? [path.isAbsolute(watchLog) ? watchLog : path.join(target, watchLog)] : recipeWatchLogCandidates().map((rel) => path.join(target, rel))).filter((file) => fs.existsSync(file));
99
98
  if (!candidates.length) return { status: "no-watch" };
100
99
  const newestFirst = [...candidates].sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
101
100
  const lines = fs.readFileSync(newestFirst[0], "utf8").split("\n");
@@ -314,6 +313,7 @@ function normalizedRuntimeManifest(file) {
314
313
  delete manifest._flags.remoteFeatureFlags;
315
314
  if (Object.keys(manifest._flags).length === 0) delete manifest._flags;
316
315
  }
316
+ delete manifest._harness;
317
317
  return JSON.stringify(manifest);
318
318
  } catch {
319
319
  return null;
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import { createRequire } from "node:module";
4
4
  import path from "node:path";
5
5
  import { importRecipeHarnessRuntimeBrowserExtension, importRecipeHarnessRuntimeCdp, recipeHarnessPath, recipeRuntimeDir, resolveLocalProtocolRoot, runnerDir } from "../../paths.js";
6
+ import { poolFilesForHost } from "../resolve-slot-ports.js";
6
7
  import { profileProcessPids } from "./validation-process-ownership.js";
7
8
  const require2 = createRequire(import.meta.url);
8
9
  const { detachedLaunchUnprovenPath, hasDetachedLaunchUnproven, shellQuote } = require2(
@@ -543,9 +544,9 @@ function resolveExtensionSlot(projectRoot, requestedSlot) {
543
544
  }
544
545
  const poolsDir = path.join(protocolRoot, "pool");
545
546
  if (!fs.existsSync(poolsDir)) return null;
546
- for (const file of fs.readdirSync(poolsDir)) {
547
- if (!file.endsWith(".json") || file.includes(".bak")) continue;
548
- const pool = JSON.parse(fs.readFileSync(path.join(poolsDir, file), "utf8"));
547
+ const pools = poolFilesForHost(poolsDir);
548
+ for (const { data } of [...pools.local, ...pools.other]) {
549
+ const pool = data;
549
550
  for (const rawSlot of pool.slots ?? []) {
550
551
  if (!rawSlot || typeof rawSlot !== "object") continue;
551
552
  const slot = rawSlot;
@@ -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
+ };