@deeeed/metamask-harness 0.15.2 → 0.17.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.
@@ -5,7 +5,8 @@ import {
5
5
  parseArgs,
6
6
  optionFlag,
7
7
  optionString,
8
- resolveAdapter
8
+ resolveAdapter,
9
+ shellQuote
9
10
  } from "./parse-args.js";
10
11
  async function handleStop(argv) {
11
12
  const { options } = parseArgs(argv, "stop");
@@ -22,6 +23,8 @@ async function handleStop(argv) {
22
23
  if (stop.kind === "headless") {
23
24
  return usageOut(json, "stop", stop.message, stop.userAction);
24
25
  }
26
+ const next = adapter === "extension" ? `${surface.hints.launch} --target ${shellQuote(target)}` : void 0;
27
+ const userAction = `mm-harness status --target ${shellQuote(target)} --json`;
25
28
  if (json) {
26
29
  console.log(
27
30
  JSON.stringify(
@@ -33,7 +36,8 @@ async function handleStop(argv) {
33
36
  status: stop.status === 0 ? "pass" : "fail",
34
37
  ...stop.signalled !== void 0 ? { signalled: stop.signalled } : {},
35
38
  exitCode: stop.status,
36
- ...stop.output ? { output: stop.output } : {}
39
+ ...stop.output ? { output: stop.output } : {},
40
+ ...stop.status === 0 ? next ? { next } : {} : { error: { code: "DEV_SERVER_STOP_FAILED", message: stop.summary, userAction } }
37
41
  },
38
42
  null,
39
43
  2
@@ -43,6 +47,7 @@ async function handleStop(argv) {
43
47
  if (stop.output) process.stderr.write(`${stop.output}
44
48
  `);
45
49
  console.error(`${color(stop.status === 0 ? "ok" : "err", stop.status === 0 ? "\u2713" : "\u2717")} ${stop.summary}`);
50
+ if (stop.status !== 0 || next) console.error(` Next: ${stop.status === 0 ? next : userAction}`);
46
51
  }
47
52
  return stop.status;
48
53
  }
package/dist/harness.js CHANGED
@@ -50,6 +50,12 @@ function failureHint(adapter, action) {
50
50
  const helper = adapter === "mobile" ? "mm-harness launch ios # or launch android" : "mm-harness launch";
51
51
  return `Read the error above for the specific cause. To (re)start the runtime, run: ${helper}`;
52
52
  }
53
+ function successNext(adapter, action, target) {
54
+ const base = `--adapter ${adapter} --target ${shellQuote(target)}`;
55
+ if (action === "install") return `mm-harness verify ${base}`;
56
+ if (action === "cleanup") return `mm-harness install ${base}`;
57
+ return `mm-harness status --target ${shellQuote(target)} --json`;
58
+ }
53
59
  function hasArg(args, needle) {
54
60
  return args.some((arg) => arg === needle || arg.startsWith(`${needle}=`));
55
61
  }
@@ -347,7 +353,8 @@ async function handleMobileLive(target, forwardArgs, json, autoDetected) {
347
353
  )
348
354
  );
349
355
  } else if (exitCode === 0) {
350
- console.error(`\u2713 live mobile passed (${elapsed}s)`);
356
+ console.error(`\u2713 live mobile passed (${elapsed}s)
357
+ Next: ${successNext("mobile", "live", target)}`);
351
358
  } else {
352
359
  console.error(
353
360
  `\u2717 live mobile failed (exit ${exitCode}, ${elapsed}s)
@@ -492,7 +499,8 @@ async function handleHarness(argv) {
492
499
  )
493
500
  );
494
501
  } else if (exitCode === 0) {
495
- console.error(`\u2713 ${harnessAction} ${adapter} passed (${seconds}s)`);
502
+ console.error(`\u2713 ${harnessAction} ${adapter} passed (${seconds}s)
503
+ Next: ${successNext(adapter, harnessAction, target)}`);
496
504
  } else {
497
505
  console.error(`\u2717 ${harnessAction} ${adapter} failed (exit ${exitCode}, ${seconds}s)
498
506
  ${failureHint(adapter, harnessAction)}`);
@@ -522,8 +530,10 @@ async function handleRunwayInstall(adapter, target, forward, json) {
522
530
  resolveOnly: hasArg(forward, "--resolve-only"),
523
531
  rerunCommand
524
532
  });
533
+ const next = result.status === "pass" && adapter === "mobile" && result.resolveOnly !== true ? `mm-harness launch ${String(result.platform ?? "ios")} --adapter mobile --target ${shellQuote(target)}` : void 0;
534
+ const output = next ? { ...result, next } : result;
525
535
  if (json) {
526
- console.log(JSON.stringify(result, null, 2));
536
+ console.log(JSON.stringify(output, null, 2));
527
537
  } else if (result.status === "pass") {
528
538
  const cache = typeof result.cache === "object" && result.cache ? result.cache : void 0;
529
539
  const simulator = typeof result.simulator === "object" && result.simulator ? result.simulator : void 0;
@@ -534,6 +544,7 @@ async function handleRunwayInstall(adapter, target, forward, json) {
534
544
  const action = result.skipped ? "already provisioned" : "installed Runway app";
535
545
  console.error(`\u2713 ${action} for ${adapter} ${result.platform ?? ""} simulator=${simulator?.name ?? "unknown"} cache=${cache?.status ?? "skip"}`);
536
546
  }
547
+ if (next) console.error(` Next: ${next}`);
537
548
  } else {
538
549
  console.error(`\u2717 mm-harness install --runway: ${result.error?.message ?? "runway install failed"}
539
550
  Next: ${result.error?.userAction ?? rerunCommand}`);
@@ -578,7 +589,8 @@ function harnessSummary(action, adapter, target, status, exitCode, autoDetected,
578
589
  // Error contract: every --json failure carries a stable machine code + human
579
590
  // message (CLI-SPEC.md §5.1). userAction is included when present so callers
580
591
  // can surface the reachable escape without parsing the human message.
581
- ...status === "fail" && error ? { error } : {}
592
+ ...status === "fail" && error ? { error } : {},
593
+ ...status === "pass" && adapter ? { next: successNext(adapter, action, target) } : {}
582
594
  });
583
595
  }
584
596
  export {
@@ -0,0 +1,57 @@
1
+ class JsonStreamWriter {
2
+ enabled;
3
+ command;
4
+ completed = false;
5
+ writeLine;
6
+ constructor(command, enabled, output = process.stdout) {
7
+ this.command = command;
8
+ this.enabled = enabled;
9
+ const write = output.write.bind(output);
10
+ this.writeLine = (line) => {
11
+ write(`${line}
12
+ `);
13
+ };
14
+ }
15
+ isolateStdout() {
16
+ if (!this.enabled) return () => void 0;
17
+ const original = process.stdout.write;
18
+ const redirect = ((chunk, encoding, callback) => process.stderr.write(chunk, encoding, callback));
19
+ process.stdout.write = redirect;
20
+ return () => {
21
+ if (process.stdout.write === redirect) process.stdout.write = original;
22
+ };
23
+ }
24
+ emit(event, fields = {}) {
25
+ if (!this.enabled || this.completed) return;
26
+ this.writeLine(JSON.stringify({
27
+ schemaVersion: 1,
28
+ command: this.command,
29
+ event,
30
+ ...fields,
31
+ ts: (/* @__PURE__ */ new Date()).toISOString()
32
+ }));
33
+ }
34
+ phase(phase, fields = {}) {
35
+ this.emit("phase", { phase, ...fields });
36
+ }
37
+ node(nodeId, action, status) {
38
+ this.emit("node", { nodeId, action, status });
39
+ }
40
+ mutation(mutation) {
41
+ this.emit("mutation", { mutation });
42
+ }
43
+ recovery(code) {
44
+ this.emit("recovery", { code });
45
+ }
46
+ error(error) {
47
+ this.emit("error", { error });
48
+ }
49
+ complete(status, exitCode, fields = {}) {
50
+ if (!this.enabled || this.completed) return;
51
+ this.emit("complete", { status, exitCode, ...fields });
52
+ this.completed = true;
53
+ }
54
+ }
55
+ export {
56
+ JsonStreamWriter
57
+ };
@@ -4,10 +4,17 @@ import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { Command } from "commander";
6
6
  import { color } from "./cli-color.js";
7
+ import { withCommandJournal } from "./command-journal.js";
8
+ import { JsonStreamWriter } from "./json-stream.js";
7
9
  import { handleUpdate, maybeNudge } from "./commands/update.js";
8
10
  import { handleCallHelp } from "./commands/call.js";
9
11
  import { getAdapterSurface } from "./adapters/surface.js";
10
12
  import { detectAdapter } from "./harness.js";
13
+ import {
14
+ PUBLIC_COMMAND_CONTRACTS,
15
+ publicCommandNames,
16
+ validatePublicInvocation
17
+ } from "./command-contract.js";
11
18
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
12
19
  globalThis.__MM_HARNESS_WRAPPER__ = true;
13
20
  const { main: recipeMain } = await import("./cli.js");
@@ -71,11 +78,14 @@ Example:
71
78
  name: "actions",
72
79
  summary: "List the action vocabulary + field schemas (--raw dumps the raw action registry JSON).",
73
80
  example: "mm-harness actions --adapter mobile",
74
- helpText: `mm-harness actions [flags]
81
+ helpText: `mm-harness actions [query] [flags]
75
82
 
76
83
  List the action vocabulary + field schemas for the checkout adapter.
77
84
 
85
+ query Search names, categories, fields, and descriptions (typo-tolerant)
78
86
  --action <name> Describe one action; fuzzy-resolves like call (short or full name)
87
+ --categories List compact action categories and counts
88
+ --category <name> List only one category (for example ui, wallet, or perps)
79
89
  --adapter <mobile|extension|core> Target adapter (auto-detected inside a checkout)
80
90
  --target <path> Checkout path (default: cwd)
81
91
  --raw Dump raw action registry JSON
@@ -83,6 +93,9 @@ Example:
83
93
 
84
94
  Example:
85
95
  mm-harness actions --adapter mobile
96
+ mm-harness actions positions --adapter mobile
97
+ mm-harness actions --adapter mobile --categories --json
98
+ mm-harness actions --adapter mobile --category ui --json
86
99
  mm-harness actions --adapter mobile --action assert_orders
87
100
  mm-harness actions --adapter extension --raw`
88
101
  },
@@ -172,12 +185,29 @@ Example:
172
185
  --library <name=path> Add/override a recipe-library source (repeatable)
173
186
  --heal <off|infra-only|auto> Healing policy (default: infra-only); auto-ensures the overlay
174
187
  --json Machine-readable output
188
+ --json-stream Line-flushed JSONL progress + terminal event
175
189
  --record-video=full-run Record a video of the run
176
190
 
177
191
  Example:
178
192
  mm-harness run recipe.json --plan --adapter mobile
179
193
  mm-harness run recipe.json --adapter extension --artifacts-dir ./out`
180
194
  },
195
+ {
196
+ name: "last",
197
+ summary: "Show the last significant command, verdict, timestamps, and evidence paths for this checkout.",
198
+ example: "mm-harness last --json",
199
+ helpText: `mm-harness last [flags]
200
+
201
+ Read the atomic per-checkout resumability journal. Discovery commands do not
202
+ replace it, and an interrupted process remains recorded as verdict=running.
203
+
204
+ --target <path> Checkout path (default: cwd)
205
+ --runtime-dir <dir> Runtime dir containing last-command.json
206
+ --json Machine-readable envelope
207
+
208
+ Example:
209
+ mm-harness last --json`
210
+ },
181
211
  {
182
212
  name: "doctor",
183
213
  summary: "Readiness check for a checkout \u2014 no app launch. --fix repairs the overlay/runtime without launching.",
@@ -375,6 +405,7 @@ Example:
375
405
  --adapter <mobile|extension> Target adapter (auto-detected inside a checkout)
376
406
  --target <path> Checkout path (default: cwd)
377
407
  --json Machine-readable summary (recovered[] / mutations[] / phase)
408
+ --json-stream Line-flushed JSONL progress + terminal event
378
409
 
379
410
  Example:
380
411
  mm-harness launch ios
@@ -473,6 +504,7 @@ Example:
473
504
  --cdp-port <port> finalize: CDP port of the running extension
474
505
  --extension-dir <path> finalize: loaded extension dist (e.g. dist/chrome)
475
506
  --extension-id-file <path> finalize: optional file to read/write the resolved extension id
507
+ --action-manifest <path> Extension set: override the wallet action manifest
476
508
  --adapter <mobile|extension> Target adapter (auto-detected inside a checkout)
477
509
  --target <path> Checkout path (default: cwd)
478
510
  --device <udid|serial|name> Mobile only: target this device for sync/set
@@ -487,6 +519,28 @@ Example:
487
519
  mm-harness fixtures finalize --fixture wallet-fixture.json --state fixture-state.json --cdp-port 6661 --extension-dir dist/chrome`
488
520
  }
489
521
  ];
522
+ const PUBLIC_COMMAND_EXAMPLES = Object.fromEntries(
523
+ REAL.map((command) => [command.name, command.example])
524
+ );
525
+ function assertPublicContractMatchesSurface() {
526
+ const registered = REAL.map((command) => command.name).sort();
527
+ const contracted = publicCommandNames().sort();
528
+ if (registered.join("\n") !== contracted.join("\n")) {
529
+ throw new Error(
530
+ `public command contract drift: registered=[${registered.join(", ")}], contracted=[${contracted.join(", ")}]`
531
+ );
532
+ }
533
+ for (const command of REAL) {
534
+ const registeredAliases = [...command.aliases ?? []].sort();
535
+ const contractedAliases = [...PUBLIC_COMMAND_CONTRACTS[command.name]?.aliases ?? []].sort();
536
+ if (registeredAliases.join("\n") !== contractedAliases.join("\n")) {
537
+ throw new Error(
538
+ `public command alias contract drift for ${command.name}: registered=[${registeredAliases.join(", ")}], contracted=[${contractedAliases.join(", ")}]`
539
+ );
540
+ }
541
+ }
542
+ }
543
+ assertPublicContractMatchesSurface();
490
544
  const RETIRED = [
491
545
  {
492
546
  name: "live",
@@ -516,7 +570,7 @@ const HELP_GROUPS = [
516
570
  {
517
571
  title: "PROVE",
518
572
  blurb: "run recipes and inspect readiness",
519
- commands: ["run", "doctor", "check", "checklist", "recipe-quality"]
573
+ commands: ["run", "last", "doctor", "check", "checklist", "recipe-quality"]
520
574
  },
521
575
  {
522
576
  title: "RUNTIME OVERLAY",
@@ -639,7 +693,7 @@ for (const command of REAL) {
639
693
  process.exit(await handleUpdate(rawArgv.slice(1)));
640
694
  }
641
695
  const argv = command.name === "actions" && rawArgv.includes("--raw") ? translateActionsRaw(rawArgv) : rawArgv;
642
- process.exit(await delegate(argv));
696
+ process.exit(await withCommandJournal(command.name, rawArgv, () => delegate(argv)));
643
697
  });
644
698
  }
645
699
  const HIDDEN = [
@@ -692,10 +746,67 @@ function isCallActionHelp(argv) {
692
746
  const scope = divider === -1 ? argv : argv.slice(0, divider);
693
747
  return scope.includes("--help") || scope.includes("-h");
694
748
  }
749
+ function jsonRequestedBeforePassthrough(argv) {
750
+ const divider = argv.indexOf("--");
751
+ const scope = divider === -1 ? argv : argv.slice(0, divider);
752
+ return scope.some((argument) => argument === "--json" || argument.startsWith("--json="));
753
+ }
754
+ function jsonStreamRequestedBeforePassthrough(argv) {
755
+ const divider = argv.indexOf("--");
756
+ const scope = divider === -1 ? argv : argv.slice(0, divider);
757
+ return scope.includes("--json-stream");
758
+ }
759
+ function emitUsageError(error, json, jsonStream) {
760
+ if (jsonStream) {
761
+ const stream = new JsonStreamWriter(error.command, true);
762
+ stream.error({ code: error.code, message: error.message, userAction: error.userAction });
763
+ stream.complete("fail", 2);
764
+ } else if (json) {
765
+ process.stdout.write(
766
+ `${JSON.stringify(
767
+ {
768
+ schemaVersion: 1,
769
+ command: error.command,
770
+ status: "fail",
771
+ error: {
772
+ code: error.code,
773
+ message: error.message,
774
+ userAction: error.userAction
775
+ },
776
+ exitCode: 2
777
+ },
778
+ null,
779
+ 2
780
+ )}
781
+ `
782
+ );
783
+ } else {
784
+ const scope = error.command === "mm-harness" ? "" : ` ${error.command}`;
785
+ process.stderr.write(`\u2717 mm-harness${scope}: ${error.message}
786
+ Next: ${error.userAction}
787
+ `);
788
+ }
789
+ process.exit(2);
790
+ }
695
791
  if (rawArgv.length === 0) {
696
792
  process.stdout.write(groupedHelp());
697
793
  process.exit(0);
698
794
  }
795
+ const preflightBypass = /* @__PURE__ */ new Set([
796
+ ...HIDDEN,
797
+ "completions",
798
+ ...RETIRED.map((command) => command.name)
799
+ ]);
800
+ if (!preflightBypass.has(rawArgv[0] ?? "")) {
801
+ const usageError = validatePublicInvocation(rawArgv, PUBLIC_COMMAND_EXAMPLES);
802
+ if (usageError) {
803
+ emitUsageError(
804
+ usageError,
805
+ jsonRequestedBeforePassthrough(rawArgv),
806
+ jsonStreamRequestedBeforePassthrough(rawArgv)
807
+ );
808
+ }
809
+ }
699
810
  if (hasPassthroughHelp(rawArgv)) {
700
811
  process.exit(await delegate(rawArgv));
701
812
  }
@@ -0,0 +1,271 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { spawn, spawnSync } from "node:child_process";
5
+ import { getAdapterSurface } from "./adapters/surface.js";
6
+ import { runnerDir } from "./paths.js";
7
+ const MAX_CAPTURE_BYTES = 512 * 1024;
8
+ const MAX_FINDINGS = 20;
9
+ const MAX_PREVIEW_CHARS = 320;
10
+ async function beginRunDiagnostics(adapter, projectRoot) {
11
+ const source = getAdapterSurface(adapter).appLogSource(projectRoot);
12
+ if (!source) return null;
13
+ const mobileIssueBuffer = adapter === "mobile" && armMobileIssueBuffer(projectRoot) ? { projectRoot } : void 0;
14
+ const extensionConsoleCapture = adapter === "extension" ? await startExtensionConsoleCapture(source, projectRoot) : void 0;
15
+ const stat = safeStat(source.path);
16
+ return {
17
+ source,
18
+ offset: stat?.size ?? 0,
19
+ ...stat ? { inode: stat.ino } : {},
20
+ ...mobileIssueBuffer ? { mobileIssueBuffer } : {},
21
+ ...extensionConsoleCapture ? { extensionConsoleCapture } : {}
22
+ };
23
+ }
24
+ function finishRunDiagnostics(baseline, result) {
25
+ if (!baseline) return result;
26
+ try {
27
+ stopRunDiagnostics(baseline);
28
+ const bufferedIssues = baseline.mobileIssueBuffer ? collectMobileIssueBuffer(baseline.mobileIssueBuffer.projectRoot) : void 0;
29
+ const diagnostics = collectRunDiagnostics(baseline, bufferedIssues);
30
+ const artifactsDir = path.dirname(result.summaryPath);
31
+ const diagnosticsPath = path.join(artifactsDir, "diagnostics.json");
32
+ fs.writeFileSync(diagnosticsPath, `${JSON.stringify(diagnostics, null, 2)}
33
+ `);
34
+ indexDiagnosticArtifact(result.artifactManifestPath);
35
+ indexDiagnosticSummary(result.summaryPath, diagnostics);
36
+ return {
37
+ ...result,
38
+ diagnosticsPath,
39
+ sideFindings: {
40
+ status: diagnostics.status,
41
+ note: diagnostics.note,
42
+ counts: diagnostics.counts
43
+ }
44
+ };
45
+ } catch (error) {
46
+ const detail = error instanceof Error ? error.message : String(error);
47
+ console.warn(`WARN: run diagnostics were unavailable: ${detail}`);
48
+ return {
49
+ ...result,
50
+ sideFindings: {
51
+ status: "unavailable",
52
+ note: "Run diagnostics could not be collected; the recipe result is unchanged.",
53
+ counts: { total: 0, warning: 0, error: 0, exception: 0 }
54
+ }
55
+ };
56
+ }
57
+ }
58
+ function stopRunDiagnostics(baseline) {
59
+ const capture = baseline?.extensionConsoleCapture;
60
+ if (!capture || capture.exitCode !== null || capture.signalCode !== null) return;
61
+ capture.kill("SIGTERM");
62
+ }
63
+ function collectRunDiagnostics(baseline, bufferedIssues) {
64
+ const stat = safeStat(baseline.source.path);
65
+ const source = {
66
+ label: baseline.source.label,
67
+ path: baseline.source.path,
68
+ startOffset: baseline.offset,
69
+ endOffset: stat?.size ?? baseline.offset,
70
+ bytesRead: 0,
71
+ truncated: false,
72
+ inAppBuffer: bufferedIssues === void 0 ? "n/a" : bufferedIssues === null ? "unavailable" : "collected"
73
+ };
74
+ let text = "";
75
+ if (stat) {
76
+ const sameFile = baseline.inode === void 0 || baseline.inode === stat.ino;
77
+ const startOffset = sameFile && stat.size >= baseline.offset ? baseline.offset : 0;
78
+ const available = Math.max(0, stat.size - startOffset);
79
+ const bytesToRead = Math.min(available, MAX_CAPTURE_BYTES);
80
+ source.startOffset = startOffset;
81
+ source.endOffset = stat.size;
82
+ source.bytesRead = bytesToRead;
83
+ source.truncated = available > MAX_CAPTURE_BYTES;
84
+ if (bytesToRead > 0) text = readSlice(baseline.source.path, startOffset, bytesToRead);
85
+ }
86
+ const allFindings = dedupeFindings(
87
+ [
88
+ ...text.split(/\r?\n/u).map(classifyLine).filter((finding) => finding !== null),
89
+ ...(bufferedIssues ?? []).map(classifyBufferedIssue).filter((finding) => finding !== null)
90
+ ]
91
+ );
92
+ const findings = allFindings.slice(0, MAX_FINDINGS);
93
+ const counts = countFindings(allFindings);
94
+ const status = counts.total > 0 ? "review" : stat || bufferedIssues !== void 0 && bufferedIssues !== null ? "clean" : "unavailable";
95
+ const note = counts.total > 0 ? `Observed ${counts.total} distinct application warning/error event(s) during the recipe run; relation to the task is not determined.` : status === "clean" ? "No application warnings or errors were emitted during the recipe run." : "Application diagnostics were unavailable for this run.";
96
+ return {
97
+ schemaVersion: 1,
98
+ scope: "recipe-run-application",
99
+ status,
100
+ nonBlocking: true,
101
+ note,
102
+ source,
103
+ counts,
104
+ findings
105
+ };
106
+ }
107
+ function classifyLine(line) {
108
+ const trimmed = line.trim();
109
+ if (!trimmed) return null;
110
+ let level = null;
111
+ if (/\bEXCEPTION\s{2,}|\b(?:Uncaught|UnhandledPromiseRejection|FATAL EXCEPTION)\b/iu.test(trimmed)) {
112
+ level = "exception";
113
+ } else if (/^(?:ERROR\s{2,}|\[error\]\s*)/iu.test(trimmed) || /\]\s+ERROR\s{2,}/u.test(trimmed)) {
114
+ level = "error";
115
+ } else if (/^(?:WARN(?:ING)?\s{2,}|\[warn(?:ing)?\]\s*)/iu.test(trimmed) || /\]\s+WARN(?:ING)?\s{2,}/u.test(trimmed)) {
116
+ level = "warning";
117
+ }
118
+ if (!level) return null;
119
+ return makeFinding(level, trimmed);
120
+ }
121
+ function classifyBufferedIssue(value) {
122
+ if (!isRecord(value) || typeof value.text !== "string") return null;
123
+ const rawLevel = String(value.level ?? "").toLowerCase();
124
+ const level = rawLevel === "warn" || rawLevel === "warning" ? "warning" : rawLevel === "error" ? "error" : rawLevel === "exception" ? "exception" : null;
125
+ return level ? makeFinding(level, value.text) : null;
126
+ }
127
+ function makeFinding(level, text) {
128
+ const preview = redactPreview(text.trim());
129
+ const identity = preview.replace(/\b\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?\b/gu, "[TIME]").replace(/\[(?:sw|page:[^\]]+|console:[^\]]+)\]/gu, "[APP]").replace(/^(?:(?:WARN(?:ING)?|ERROR|EXCEPTION|\[TIME\]|\[APP\])\s*)+/u, "");
130
+ return {
131
+ level,
132
+ fingerprint: createHash("sha256").update(`${level}|${identity}`).digest("hex").slice(0, 12),
133
+ preview
134
+ };
135
+ }
136
+ function armMobileIssueBuffer(projectRoot) {
137
+ const armed = runMobileIssueCommand(projectRoot, "issues-arm");
138
+ if (!armed) return false;
139
+ return runMobileIssueCommand(projectRoot, "issues-collect") !== null;
140
+ }
141
+ function collectMobileIssueBuffer(projectRoot) {
142
+ const result = runMobileIssueCommand(projectRoot, "issues-collect");
143
+ return isRecord(result) && Array.isArray(result.entries) ? result.entries : null;
144
+ }
145
+ function runMobileIssueCommand(projectRoot, command) {
146
+ const bridge = path.join(runnerDir, "adapters", "mobile", "bridge-runtime", "cdp-bridge.cjs");
147
+ const result = spawnSync(process.execPath, [bridge, command], {
148
+ cwd: projectRoot,
149
+ env: { ...process.env, APP_ROOT: projectRoot, CDP_TIMEOUT: "5000" },
150
+ encoding: "utf8",
151
+ timeout: 1e4
152
+ });
153
+ if (result.status !== 0) return null;
154
+ try {
155
+ return JSON.parse(result.stdout.trim());
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+ async function startExtensionConsoleCapture(source, projectRoot) {
161
+ const cdpPort = process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT;
162
+ if (!cdpPort) return void 0;
163
+ fs.mkdirSync(path.dirname(source.path), { recursive: true });
164
+ const before = safeStat(source.path)?.size ?? 0;
165
+ const script = path.join(runnerDir, "adapters", "extension", "console-tail.mjs");
166
+ const child = spawn(process.execPath, [script, "--cdp-port", cdpPort, "--log", source.path], {
167
+ cwd: projectRoot,
168
+ stdio: "ignore"
169
+ });
170
+ const attached = await waitForExtensionAttachment(source.path, before, child, 3e3);
171
+ if (attached) {
172
+ await delay(50);
173
+ return child;
174
+ }
175
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
176
+ return void 0;
177
+ }
178
+ async function waitForExtensionAttachment(logPath, offset, child, timeoutMs) {
179
+ const deadline = Date.now() + timeoutMs;
180
+ while (Date.now() < deadline && child.exitCode === null && child.signalCode === null) {
181
+ const stat = safeStat(logPath);
182
+ if (stat && stat.size > offset) {
183
+ const appended = readSlice(logPath, offset, Math.min(stat.size - offset, 64 * 1024));
184
+ if (appended.includes("[attached]")) return true;
185
+ }
186
+ await delay(50);
187
+ }
188
+ return false;
189
+ }
190
+ function delay(ms) {
191
+ return new Promise((resolve) => setTimeout(resolve, ms));
192
+ }
193
+ function redactPreview(value) {
194
+ return value.replace(/\b(Bearer)\s+\S+/giu, "$1 [REDACTED]").replace(/\b(password|passphrase|mnemonic|seed(?:Phrase)?|privateKey|secret|token|authorization)\b\s*[:=]\s*(?:"[^"]*"|'[^']*'|\S+)/giu, "$1=[REDACTED]").replace(/\b(?:0x)?[a-f0-9]{64,}\b/giu, "[REDACTED_HEX]").replace(/(https?:\/\/[^\s?]+)\?\S+/giu, "$1?[REDACTED_QUERY]").slice(0, MAX_PREVIEW_CHARS);
195
+ }
196
+ function dedupeFindings(findings) {
197
+ const byFingerprint = /* @__PURE__ */ new Map();
198
+ for (const finding of findings) {
199
+ const existing = byFingerprint.get(finding.fingerprint);
200
+ if (existing) existing.count += 1;
201
+ else byFingerprint.set(finding.fingerprint, { ...finding, count: 1 });
202
+ }
203
+ return [...byFingerprint.values()];
204
+ }
205
+ function countFindings(findings) {
206
+ const counts = { total: findings.length, warning: 0, error: 0, exception: 0 };
207
+ for (const finding of findings) counts[finding.level] += 1;
208
+ return counts;
209
+ }
210
+ function readSlice(filePath, offset, length) {
211
+ const fd = fs.openSync(filePath, "r");
212
+ try {
213
+ const buffer = Buffer.alloc(length);
214
+ const bytesRead = fs.readSync(fd, buffer, 0, length, offset);
215
+ return buffer.subarray(0, bytesRead).toString("utf8");
216
+ } finally {
217
+ fs.closeSync(fd);
218
+ }
219
+ }
220
+ function safeStat(filePath) {
221
+ try {
222
+ return fs.statSync(filePath);
223
+ } catch {
224
+ return null;
225
+ }
226
+ }
227
+ function indexDiagnosticArtifact(manifestPath) {
228
+ const manifest = readJsonRecord(manifestPath);
229
+ if (!manifest) return;
230
+ const artifacts = Array.isArray(manifest.artifacts) ? manifest.artifacts : [];
231
+ manifest.artifacts = [
232
+ ...artifacts.filter((artifact) => !isRecord(artifact) || artifact.path !== "diagnostics.json"),
233
+ {
234
+ path: "diagnostics.json",
235
+ type: "json",
236
+ label: "Run-scoped application diagnostics",
237
+ category: "diagnostic"
238
+ }
239
+ ];
240
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}
241
+ `);
242
+ }
243
+ function indexDiagnosticSummary(summaryPath, diagnostics) {
244
+ const summary = readJsonRecord(summaryPath);
245
+ if (!summary) return;
246
+ summary.sideFindings = {
247
+ status: diagnostics.status,
248
+ nonBlocking: true,
249
+ counts: diagnostics.counts,
250
+ diagnosticsPath: "diagnostics.json"
251
+ };
252
+ fs.writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}
253
+ `);
254
+ }
255
+ function readJsonRecord(filePath) {
256
+ try {
257
+ const value = JSON.parse(fs.readFileSync(filePath, "utf8"));
258
+ return isRecord(value) ? value : null;
259
+ } catch {
260
+ return null;
261
+ }
262
+ }
263
+ function isRecord(value) {
264
+ return typeof value === "object" && value !== null && !Array.isArray(value);
265
+ }
266
+ export {
267
+ beginRunDiagnostics,
268
+ collectRunDiagnostics,
269
+ finishRunDiagnostics,
270
+ stopRunDiagnostics
271
+ };
package/dist/runner.js CHANGED
@@ -58,7 +58,7 @@ async function createMetaMaskRunner(adapter, actionManifest, options = {}) {
58
58
  const logger = options.quietStdout ? new console.Console(process.stderr) : console;
59
59
  return createRecipeRunner({
60
60
  actionManifest,
61
- adapters: [...core, ...ui, ...lifecycle, ...custom],
61
+ adapters: withActionProgress([...core, ...ui, ...lifecycle, ...custom], options.onActionEvent),
62
62
  logger,
63
63
  recording: {
64
64
  targetProvider: createMetaMaskRecordingTargetProvider(adapter)
@@ -76,6 +76,36 @@ async function createMetaMaskRunner(adapter, actionManifest, options = {}) {
76
76
  }
77
77
  });
78
78
  }
79
+ function withActionProgress(adapters, onActionEvent) {
80
+ if (!onActionEvent) return adapters;
81
+ return adapters.map((entry) => ({
82
+ ...entry,
83
+ async execute(node, context) {
84
+ const event = { nodeId: context.nodeId, action: entry.action };
85
+ const reportProgress = !isAutomaticHudProgress(entry.action, node, context.nodeId);
86
+ if (reportProgress) onActionEvent({ ...event, status: "running" });
87
+ try {
88
+ const result = await entry.execute(node, context);
89
+ if (reportProgress) {
90
+ onActionEvent({ ...event, status: result.status === "fail" ? "failed" : "passed" });
91
+ }
92
+ return result;
93
+ } catch (error) {
94
+ if (reportProgress) onActionEvent({ ...event, status: "failed" });
95
+ throw error;
96
+ }
97
+ }
98
+ }));
99
+ }
100
+ function isAutomaticHudProgress(action, node, nodeId) {
101
+ if (action !== "app.hud" || node === null || typeof node !== "object" || Array.isArray(node)) {
102
+ return false;
103
+ }
104
+ const payload = node;
105
+ if (typeof payload.action_name === "string") return true;
106
+ const progress = payload.progress;
107
+ return nodeId === "recipe-complete" && progress !== null && typeof progress === "object" && !Array.isArray(progress) && progress.complete === true;
108
+ }
79
109
  function mobileSourceAwareLifecycleAdapters(adapter, lifecycle) {
80
110
  if (adapter !== "mobile") return lifecycle;
81
111
  return lifecycle.map((entry) => ({
@@ -120,5 +150,6 @@ export {
120
150
  createMetaMaskExtensionRunner,
121
151
  createMetaMaskMobileRunner,
122
152
  createMetaMaskRunner,
153
+ isAutomaticHudProgress,
123
154
  mobileSourceAwareLifecycleAdapters
124
155
  };