@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.
- package/CHANGELOG.md +23 -0
- package/README.md +12 -1
- package/dist/cli-commands.js +1 -1
- package/dist/cli.js +2 -0
- package/dist/command-contract.js +441 -0
- package/dist/command-journal.js +225 -0
- package/dist/commands/call.js +40 -17
- package/dist/commands/check.js +9 -3
- package/dist/commands/device-target.js +27 -12
- package/dist/commands/doctor.js +19 -6
- package/dist/commands/fixtures.js +1 -1
- package/dist/commands/last.js +52 -0
- package/dist/commands/launch/index.js +156 -59
- package/dist/commands/manifest.js +147 -9
- package/dist/commands/parse-args.js +2 -0
- package/dist/commands/provision.js +10 -3
- package/dist/commands/run-engine.js +34 -11
- package/dist/commands/run-report.js +12 -3
- package/dist/commands/run.js +194 -39
- package/dist/commands/shared.js +11 -1
- package/dist/commands/status.js +1 -1
- package/dist/commands/stop.js +7 -2
- package/dist/harness.js +16 -4
- package/dist/json-stream.js +57 -0
- package/dist/mm-harness-cli.js +114 -3
- package/dist/run-diagnostics.js +271 -0
- package/dist/runner.js +32 -1
- package/docs/CLI-ERGONOMICS-AUDIT.md +32 -0
- package/docs/CLI-ERGONOMICS-HUMAN-QA.md +104 -0
- package/docs/CLI-SPEC.md +63 -19
- package/docs/MENTAL-MODEL.md +2 -2
- package/docs/UX-PRINCIPLES.md +2 -0
- package/package.json +2 -1
package/dist/commands/stop.js
CHANGED
|
@@ -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(
|
|
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
|
+
};
|
package/dist/mm-harness-cli.js
CHANGED
|
@@ -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
|
};
|