@deeeed/metamask-harness 0.68.0 → 0.70.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 +21 -0
- package/README.md +97 -0
- package/adapters/extension/ensure-browser.sh +85 -15
- package/adapters/extension/inject.mjs +2 -0
- package/adapters/extension/launch-browser.cjs +140 -18
- package/adapters/extension/lib/macos-focus.cjs +67 -195
- package/adapters/extension/live.sh +29 -48
- package/adapters/manifest.json +20 -4
- package/adapters/shared/browser-cdp.cjs +528 -0
- package/adapters/shared/browser-resolver.cjs +996 -0
- package/adapters/shared/open-debug.mjs +6 -1
- package/dist/adapters/mobile/prepare.js +7 -4
- package/dist/adapters/mobile/runtime-decision.js +8 -10
- package/dist/command-contract.js +7 -0
- package/dist/commands/call.js +3 -0
- package/dist/commands/launch/index.js +19 -0
- package/dist/commands/list-executables.js +62 -11
- package/dist/commands/manifest.js +11 -4
- package/dist/commands/run-engine.js +8 -1
- package/dist/commands/run-report.js +96 -3
- package/dist/commands/run.js +5 -1
- package/dist/doctor.js +48 -1
- package/dist/mm-harness-cli.js +7 -0
- package/docs/QA.md +3 -0
- package/docs/RECIPES.md +10 -3
- package/library/actions/extension/platform/cdp.mjs +87 -46
- package/package.json +1 -1
- package/scripts/completions.sh +2 -2
- package/scripts/site-contrast.mjs +16 -3
|
@@ -475,8 +475,13 @@ async function openBrokeredMobileDebugger(port, shouldOpen) {
|
|
|
475
475
|
function pickExtensionTarget(targets, targetKind) {
|
|
476
476
|
const extensionTargets = targets.filter((target) => String(target.url || '').startsWith('chrome-extension://'));
|
|
477
477
|
if (targetKind === 'worker') {
|
|
478
|
+
// Branded Chrome runs component extensions with their own service workers;
|
|
479
|
+
// prefer the worker of the extension whose wallet page is open.
|
|
480
|
+
const walletPage = extensionTargets.find((target) => /\/(home|sidepanel)\.html/u.test(String(target.url || '')));
|
|
481
|
+
const walletId = process.env.RECIPE_HARNESS_EXTENSION_ID || (walletPage ? String(walletPage.url).split('/')[2] : '');
|
|
478
482
|
return (
|
|
479
|
-
extensionTargets.find((target) => target.type === 'service_worker')
|
|
483
|
+
(walletId && extensionTargets.find((target) => target.type === 'service_worker' && String(target.url).startsWith(`chrome-extension://${walletId}/`)))
|
|
484
|
+
|| extensionTargets.find((target) => target.type === 'service_worker')
|
|
480
485
|
|| targets.find((target) => target.type === 'service_worker' && String(target.url || '').startsWith('chrome-extension://'))
|
|
481
486
|
|| null
|
|
482
487
|
);
|
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { recordDepsBaseline } from "@farmslot/recipe-harness/runtime/deps-readiness";
|
|
4
4
|
import { resolveAndExportMobileToolPaths } from "../../../library/actions/mobile/platform/tool-paths.mjs";
|
|
5
5
|
import { EXIT, spawnScriptStreaming } from "../../commands/shared.js";
|
|
6
|
+
import { shellQuote } from "../../commands/parse-args.js";
|
|
6
7
|
import { runnerDir } from "../../paths.js";
|
|
7
8
|
import {
|
|
8
9
|
decideMobileReadiness,
|
|
@@ -51,12 +52,14 @@ async function prepareMobile(target, opts = {}) {
|
|
|
51
52
|
});
|
|
52
53
|
if (report.decision === "blocked") {
|
|
53
54
|
const reasons = report.reasons.join(" ");
|
|
54
|
-
const next = report.userAction ??
|
|
55
|
+
const next = report.userAction ?? `mm-harness logs --target ${shellQuote(target)} --full`;
|
|
55
56
|
const msg = `mobile prepare blocked: ${reasons}
|
|
56
57
|
Next: ${next}`;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
return {
|
|
59
|
+
status: EXIT.runtime,
|
|
60
|
+
output: msg,
|
|
61
|
+
readinessBlock: { reasonCode: report.reasonCode, message: reasons, userAction: next }
|
|
62
|
+
};
|
|
60
63
|
}
|
|
61
64
|
const iosAccessibilityChanged = platform === "ios" && report.decision !== "unknown" && enableIosAccessibility(json);
|
|
62
65
|
const rebuildNative = preflightMode === "rebuild-native" || preflightMode === "clean";
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from "@farmslot/recipe-harness/runtime/log-analysis";
|
|
13
13
|
import { probeMetroPackager } from "@farmslot/recipe-harness/runtime/metro-probe";
|
|
14
14
|
import { color } from "../../cli-color.js";
|
|
15
|
+
import { shellQuote } from "../../commands/parse-args.js";
|
|
15
16
|
import { recipeRuntimePath } from "../../paths.js";
|
|
16
17
|
import { resolveMobileToolPath } from "../../../library/actions/mobile/platform/tool-paths.mjs";
|
|
17
18
|
import { mobileProductMarkers } from "./deps-markers.js";
|
|
@@ -149,7 +150,7 @@ const launchActions = (target, clearMetro = false) => {
|
|
|
149
150
|
return actions;
|
|
150
151
|
};
|
|
151
152
|
function depsInstallCommand(resolved) {
|
|
152
|
-
return fs.existsSync(path.join(resolved, "node_modules")) ? "
|
|
153
|
+
return fs.existsSync(path.join(resolved, "node_modules")) ? "yarn setup:expo --no-build-ios --no-build-android" : "yarn install --immutable && yarn setup:expo --no-build-ios --no-build-android";
|
|
153
154
|
}
|
|
154
155
|
async function decideMobileReadiness(target, options = {}) {
|
|
155
156
|
const resolved = path.resolve(target);
|
|
@@ -157,18 +158,15 @@ async function decideMobileReadiness(target, options = {}) {
|
|
|
157
158
|
const fast = options.preflightMode === "fast";
|
|
158
159
|
const report = await computeMobileReadiness(resolved, options, fast);
|
|
159
160
|
if (fast && report.decision === "install") {
|
|
160
|
-
const standalone = depsInstallCommand(resolved);
|
|
161
161
|
return {
|
|
162
162
|
...report,
|
|
163
163
|
decision: "blocked",
|
|
164
164
|
reasonCode: "deps-not-ready",
|
|
165
165
|
reasons: [
|
|
166
|
-
|
|
167
|
-
|
|
166
|
+
...report.reasons,
|
|
167
|
+
"Quick launch does not install dependencies."
|
|
168
168
|
],
|
|
169
|
-
userAction: `
|
|
170
|
-
2. Orchestrated slot \u2014 run its deps/prepare phase.
|
|
171
|
-
After the selected action succeeds, re-run \`mm-harness launch <platform>\`.`,
|
|
169
|
+
userAction: `cd ${shellQuote(resolved)} && ${depsInstallCommand(resolved)}`,
|
|
172
170
|
actions: []
|
|
173
171
|
};
|
|
174
172
|
}
|
|
@@ -189,9 +187,9 @@ async function decideMobileReadiness(target, options = {}) {
|
|
|
189
187
|
)
|
|
190
188
|
};
|
|
191
189
|
}
|
|
192
|
-
function writeDepsNote(
|
|
190
|
+
function writeDepsNote() {
|
|
193
191
|
const stream = process.stderr;
|
|
194
|
-
const body =
|
|
192
|
+
const body = "No install baseline is recorded. Required package checks passed; timestamps alone do not establish dependency drift. Reusing the installed dependencies.";
|
|
195
193
|
stream.write(
|
|
196
194
|
`
|
|
197
195
|
${color("warn", "mm-harness note (deps):", { stream })} ${color("dim", body, { stream })}
|
|
@@ -206,7 +204,6 @@ async function computeMobileReadiness(resolved, options, fast) {
|
|
|
206
204
|
let deps = rawDeps;
|
|
207
205
|
if (rawDeps.status === "stale" && !rawDeps.hasBaseline) {
|
|
208
206
|
deps = { installed: rawDeps.installed, status: "current", hasBaseline: false };
|
|
209
|
-
writeDepsNote(resolved);
|
|
210
207
|
}
|
|
211
208
|
if (deps.status === "current") {
|
|
212
209
|
const absent = missingRequiredDeps(resolved);
|
|
@@ -325,6 +322,7 @@ async function computeMobileReadiness(resolved, options, fast) {
|
|
|
325
322
|
actions: installActions(resolved)
|
|
326
323
|
};
|
|
327
324
|
}
|
|
325
|
+
if (rawDeps.status === "stale" && !rawDeps.hasBaseline) writeDepsNote();
|
|
328
326
|
if (metro.status === "down") {
|
|
329
327
|
return {
|
|
330
328
|
schemaVersion: 1,
|
package/dist/command-contract.js
CHANGED
|
@@ -168,6 +168,7 @@ const PUBLIC_COMMAND_CONTRACTS = {
|
|
|
168
168
|
"--title": value(),
|
|
169
169
|
"--task-text": value(),
|
|
170
170
|
"--task-file": value(),
|
|
171
|
+
"--acceptance": value(),
|
|
171
172
|
"--ticket": value(),
|
|
172
173
|
"--source-ref": value(),
|
|
173
174
|
"--surface": value(),
|
|
@@ -241,6 +242,9 @@ const PUBLIC_COMMAND_CONTRACTS = {
|
|
|
241
242
|
call: {
|
|
242
243
|
options: options(HELP, JSON, TARGET, ADAPTER, MOBILE_PLATFORM, DEVICE, RUNTIME_PORTS, RECIPE_RUNTIME, {
|
|
243
244
|
"--list": bool(),
|
|
245
|
+
"--domain": value(),
|
|
246
|
+
"--source": value(),
|
|
247
|
+
"--sort": value(["domain", "name", "library"]),
|
|
244
248
|
"--arg": value()
|
|
245
249
|
}),
|
|
246
250
|
positionals: [{ label: "action" }],
|
|
@@ -253,6 +257,9 @@ const PUBLIC_COMMAND_CONTRACTS = {
|
|
|
253
257
|
run: {
|
|
254
258
|
options: options(HELP, JSON, JSON_STREAM, TARGET, ADAPTER, MOBILE_PLATFORM, DEVICE, RUNTIME_PORTS, RECIPE_RUNTIME, RUN_FUNDING, {
|
|
255
259
|
"--list": bool(),
|
|
260
|
+
"--domain": value(),
|
|
261
|
+
"--source": value(),
|
|
262
|
+
"--sort": value(["domain", "name", "library"]),
|
|
256
263
|
"--plan": bool(),
|
|
257
264
|
"--proof": bool(),
|
|
258
265
|
"--describe": bool(),
|
package/dist/commands/call.js
CHANGED
|
@@ -65,6 +65,9 @@ async function handleCall(argv) {
|
|
|
65
65
|
}
|
|
66
66
|
const { action: shortName, args, rest } = parseCallArgs(argv);
|
|
67
67
|
const { options } = parseArgs(rest, "call");
|
|
68
|
+
if (["domain", "source", "sort"].some((name) => optionString(options, name) !== void 0)) {
|
|
69
|
+
throw usageError("--domain, --source, and --sort require call --list.");
|
|
70
|
+
}
|
|
68
71
|
const json = optionFlag(options, "json");
|
|
69
72
|
if (!shortName) {
|
|
70
73
|
let example = "mm-harness call <action>";
|
|
@@ -320,6 +320,10 @@ async function handleLaunchLocked(argv, stream) {
|
|
|
320
320
|
if (attempt.status === 0) {
|
|
321
321
|
return await settleLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify, remoteFlagRequest, true, attempt.remoteFeatureFlags);
|
|
322
322
|
}
|
|
323
|
+
const readinessFailure = mobileReadinessFailure(attempt);
|
|
324
|
+
if (readinessFailure) {
|
|
325
|
+
return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, readinessFailure);
|
|
326
|
+
}
|
|
323
327
|
if (adapter === "extension" && attempt.output.includes("REMOTE_FLAG_NOT_PINNABLE:")) {
|
|
324
328
|
return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
|
|
325
329
|
code: "REMOTE_FLAG_NOT_PINNABLE",
|
|
@@ -503,6 +507,10 @@ async function handleLaunchLocked(argv, stream) {
|
|
|
503
507
|
state.recovered.push(recoveryCode);
|
|
504
508
|
return await settleLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify, remoteFlagRequest, true, attempt.remoteFeatureFlags);
|
|
505
509
|
}
|
|
510
|
+
const retryReadinessFailure = mobileReadinessFailure(attempt);
|
|
511
|
+
if (retryReadinessFailure) {
|
|
512
|
+
return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, retryReadinessFailure);
|
|
513
|
+
}
|
|
506
514
|
return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
|
|
507
515
|
code: "SAME_RECOVERY_TWICE",
|
|
508
516
|
message: `launch ${adapter}${mobileTarget ? ` ${mobileTarget}` : ""} failed again after ${recoveryCode} recovery \u2014 refusing to loop.`,
|
|
@@ -511,6 +519,17 @@ async function handleLaunchLocked(argv, stream) {
|
|
|
511
519
|
originalError: attempt.output.trim() || void 0
|
|
512
520
|
});
|
|
513
521
|
}
|
|
522
|
+
function mobileReadinessFailure(result) {
|
|
523
|
+
const block = result.readinessBlock;
|
|
524
|
+
if (!block) return void 0;
|
|
525
|
+
return {
|
|
526
|
+
code: block.reasonCode === "deps-not-ready" ? "MOBILE_DEPENDENCIES_NOT_READY" : "MOBILE_READINESS_BLOCKED",
|
|
527
|
+
message: block.message,
|
|
528
|
+
userAction: block.userAction,
|
|
529
|
+
recoverable: false,
|
|
530
|
+
exitCode: result.status
|
|
531
|
+
};
|
|
532
|
+
}
|
|
514
533
|
function extensionQuickReattachFailed(output) {
|
|
515
534
|
return /\[reattach\]|extension quick reattach|mm-harness launch --build/u.test(output);
|
|
516
535
|
}
|
|
@@ -5,13 +5,14 @@ import { importRecipeProtocol } from "../paths.js";
|
|
|
5
5
|
import { color } from "../cli-color.js";
|
|
6
6
|
import { EXIT } from "./shared.js";
|
|
7
7
|
import { optionFlag, optionString, optionStrings, resolveAdapter, shellQuoteArg } from "./parse-args.js";
|
|
8
|
-
import { describeManifestActions, renderHumanActionCatalog } from "./manifest.js";
|
|
8
|
+
import { actionDomain, describeManifestActions, renderHumanActionCatalog } from "./manifest.js";
|
|
9
9
|
import {
|
|
10
10
|
describeRunnableRecipe,
|
|
11
11
|
listRunnableRecipes,
|
|
12
12
|
resolveMetaMaskLibrarySources
|
|
13
13
|
} from "./run-engine.js";
|
|
14
14
|
const finalSegment = (name) => name.split(".").pop() ?? name;
|
|
15
|
+
const recipeDomain = (name) => name.includes(".") ? name.split(".")[0].replaceAll("_", "-") : "general";
|
|
15
16
|
function catalogDescription(description) {
|
|
16
17
|
if (!description) return "";
|
|
17
18
|
const firstSentence = description.match(/^.*?[.!?](?:\s|$)/u)?.[0]?.trim() ?? description;
|
|
@@ -20,23 +21,64 @@ function catalogDescription(description) {
|
|
|
20
21
|
async function handleListExecutables(command, options) {
|
|
21
22
|
const json = optionFlag(options, "json");
|
|
22
23
|
const { adapter } = resolveAdapter(options);
|
|
24
|
+
const domainFilter = optionString(options, "domain")?.replaceAll("_", "-");
|
|
25
|
+
const sourceFilter = optionString(options, "source");
|
|
26
|
+
const sort = optionString(options, "sort") ?? (json ? "name" : "domain");
|
|
27
|
+
const groupKey = (entry, domain) => sort === "library" ? entry.source : sort === "domain" ? domain : "";
|
|
28
|
+
const select = (available2, domainOf) => available2.filter((entry) => (!domainFilter || domainOf(entry) === domainFilter) && (!sourceFilter || entry.source === sourceFilter)).sort((left, right) => groupKey(left, domainOf(left)).localeCompare(groupKey(right, domainOf(right))) || left.name.localeCompare(right.name));
|
|
29
|
+
const out = (style, text) => color(style, text, { stream: process.stdout });
|
|
30
|
+
const stylesFor = (domains) => {
|
|
31
|
+
const palette = ["label", "accent", "ok", "warn"];
|
|
32
|
+
return new Map([...new Set(domains)].sort().map((domain, index) => [domain, palette[index % palette.length]]));
|
|
33
|
+
};
|
|
23
34
|
const librarySources = await resolveMetaMaskLibrarySources(optionStrings(options, "library"));
|
|
35
|
+
const libraryLines = () => {
|
|
36
|
+
const lines = ["Libraries loaded:"];
|
|
37
|
+
for (const source of librarySources ?? []) {
|
|
38
|
+
const name = source.name ?? path.basename(source.root);
|
|
39
|
+
const kind = source.provenance?.kind === "bundled" ? "bundled" : "linked";
|
|
40
|
+
lines.push(` ${out("bold", name)} ${kind} \xB7 ${source.root}`);
|
|
41
|
+
}
|
|
42
|
+
if (domainFilter || sourceFilter) {
|
|
43
|
+
lines.push(`Filter: ${[domainFilter && `domain=${domainFilter}`, sourceFilter && `source=${sourceFilter}`].filter(Boolean).join(" ")}`);
|
|
44
|
+
}
|
|
45
|
+
return lines;
|
|
46
|
+
};
|
|
24
47
|
if (command === "run") {
|
|
25
|
-
const
|
|
48
|
+
const available2 = await listRunnableRecipes(adapter, librarySources);
|
|
49
|
+
const recipes = select(available2, (recipe) => recipeDomain(recipe.name));
|
|
26
50
|
if (json) {
|
|
27
51
|
console.log(JSON.stringify({ schemaVersion: 1, command, action: "list", adapter, recipes }, null, 2));
|
|
28
52
|
return EXIT.ok;
|
|
29
53
|
}
|
|
30
|
-
const
|
|
54
|
+
const domainStyles = stylesFor(available2.map((recipe) => recipeDomain(recipe.name)));
|
|
31
55
|
console.log(out("bold", `runnable recipes (${adapter})`));
|
|
56
|
+
for (const line of libraryLines()) console.log(line);
|
|
32
57
|
console.log(out("comment", "Inspect: mm-harness run <recipe> --describe"));
|
|
58
|
+
if (recipes.length === 0) {
|
|
59
|
+
console.log("No runnable recipes match.");
|
|
60
|
+
console.log(`Available domains: ${[...domainStyles.keys()].join(", ") || "none"}`);
|
|
61
|
+
console.log(`Available sources: ${[...new Set(available2.map((recipe) => recipe.source))].join(", ") || "none"}`);
|
|
62
|
+
}
|
|
63
|
+
const groups = /* @__PURE__ */ new Map();
|
|
33
64
|
for (const recipe of recipes) {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
65
|
+
const group = groupKey(recipe, recipeDomain(recipe.name));
|
|
66
|
+
groups.set(group, [...groups.get(group) ?? [], recipe]);
|
|
67
|
+
}
|
|
68
|
+
for (const [group, entries] of groups) {
|
|
69
|
+
if (group) {
|
|
70
|
+
const label = sort === "library" ? `Library: ${group}` : group;
|
|
71
|
+
console.log(`
|
|
72
|
+
${out(sort === "domain" ? domainStyles.get(group) : "bold", label)} ${out("dim", `(${entries.length})`)}`);
|
|
73
|
+
}
|
|
74
|
+
for (const recipe of entries) {
|
|
75
|
+
const style = domainStyles.get(recipeDomain(recipe.name));
|
|
76
|
+
const shadowed = recipe.shadows.length > 0 ? ` shadows=${recipe.shadows.join(",")}` : "";
|
|
77
|
+
const variant = recipe.variant ? ` variant=${recipe.variant}` : " variant=all";
|
|
78
|
+
const summary = catalogDescription(recipe.description);
|
|
79
|
+
console.log(` ${out(style, recipe.name)} ${out("dim", `library=${recipe.source}${variant}${shadowed}`)}`);
|
|
80
|
+
if (summary) console.log(` ${summary}`);
|
|
81
|
+
}
|
|
40
82
|
}
|
|
41
83
|
return EXIT.ok;
|
|
42
84
|
}
|
|
@@ -47,9 +89,10 @@ async function handleListExecutables(command, options) {
|
|
|
47
89
|
);
|
|
48
90
|
const { getRecipeActionManifestActionNames } = await importRecipeProtocol();
|
|
49
91
|
const protocolNames = new Set(getRecipeActionManifestActionNames(manifest));
|
|
50
|
-
const
|
|
92
|
+
const available = describeManifestActions(manifest, actionSources).filter((entry) => protocolNames.has(entry.name)).sort((left, right) => left.name.localeCompare(right.name));
|
|
93
|
+
const described = select(available, actionDomain);
|
|
51
94
|
const shortCounts = /* @__PURE__ */ new Map();
|
|
52
|
-
for (const entry of
|
|
95
|
+
for (const entry of available) {
|
|
53
96
|
const short = finalSegment(entry.name);
|
|
54
97
|
shortCounts.set(short, (shortCounts.get(short) ?? 0) + 1);
|
|
55
98
|
}
|
|
@@ -73,9 +116,17 @@ async function handleListExecutables(command, options) {
|
|
|
73
116
|
console.log(JSON.stringify({ schemaVersion: 1, command, action: "list", adapter, actions }, null, 2));
|
|
74
117
|
return EXIT.ok;
|
|
75
118
|
}
|
|
119
|
+
const contextLines = libraryLines();
|
|
120
|
+
if (described.length === 0) {
|
|
121
|
+
contextLines.push(`Available domains: ${[...new Set(available.map(actionDomain))].sort().join(", ") || "none"}`);
|
|
122
|
+
contextLines.push(`Available sources: ${[...new Set(available.map((entry) => entry.source))].sort().join(", ") || "none"}`);
|
|
123
|
+
}
|
|
76
124
|
console.log(renderHumanActionCatalog(described, {
|
|
77
125
|
title: `invocable actions (${adapter})`,
|
|
78
126
|
guidance: "Use: mm-harness call <name> \xB7 short names shown first where unambiguous",
|
|
127
|
+
sort,
|
|
128
|
+
domainStyles: stylesFor(available.map(actionDomain)),
|
|
129
|
+
contextLines,
|
|
79
130
|
displayName: (entry) => {
|
|
80
131
|
const short = shortByName.get(entry.name);
|
|
81
132
|
return short ? `${short} (${entry.name})` : `${entry.name} (full name only \u2014 ambiguous short)`;
|
|
@@ -345,16 +345,21 @@ function summarizeDescribedAction(entry) {
|
|
|
345
345
|
const { schema: _schema, examples: _examples, ...summary } = entry;
|
|
346
346
|
return summary;
|
|
347
347
|
}
|
|
348
|
+
function actionDomain(entry) {
|
|
349
|
+
return entry.category.replaceAll("_", "-");
|
|
350
|
+
}
|
|
348
351
|
function renderHumanActionCatalog(actions, options) {
|
|
349
352
|
const out = (style, text) => color(style, text, { stream: process.stdout });
|
|
350
353
|
const groups = /* @__PURE__ */ new Map();
|
|
351
354
|
for (const entry of actions) {
|
|
352
|
-
const group = actionDomainGroup(entry);
|
|
355
|
+
const group = options.sort === "name" ? "" : options.sort === "library" ? `Library: ${entry.source}` : options.sort === "domain" ? actionDomain(entry) : actionDomainGroup(entry);
|
|
353
356
|
groups.set(group, [...groups.get(group) ?? [], entry]);
|
|
354
357
|
}
|
|
355
|
-
const lines = [out("bold", options.title), out("comment", options.guidance)];
|
|
358
|
+
const lines = [out("bold", options.title), ...options.contextLines ?? [], out("comment", options.guidance)];
|
|
359
|
+
if (actions.length === 0) lines.push("No callable actions match.");
|
|
356
360
|
for (const [group, entries] of groups) {
|
|
357
|
-
|
|
361
|
+
const groupStyle = options.sort === "domain" ? options.domainStyles?.get(group) ?? "accent" : group === "official" ? "label" : "accent";
|
|
362
|
+
if (group) lines.push("", `${out(groupStyle, group)} ${out("dim", `(${entries.length})`)}`);
|
|
358
363
|
for (const entry of entries) {
|
|
359
364
|
const fieldNames = humanFieldNames(entry);
|
|
360
365
|
const fields = fieldNames.length ? ` ${out("dim", `fields=${fieldNames.join(",")}`)}` : "";
|
|
@@ -362,7 +367,8 @@ function renderHumanActionCatalog(actions, options) {
|
|
|
362
367
|
const description = entry.description ? ` ${out("comment", `\u2014 ${entry.description}`)}` : "";
|
|
363
368
|
const source = ` ${out("dim", `[${entry.source}]`)}`;
|
|
364
369
|
const risk = entry.capabilities.length ? ` ${out("dim", `risk=${entry.capabilities.join(",")}`)}` : "";
|
|
365
|
-
|
|
370
|
+
const nameStyle = options.domainStyles?.get(actionDomain(entry)) ?? "cmd";
|
|
371
|
+
lines.push(` ${out(nameStyle, options.displayName?.(entry) ?? entry.name)}${source}${fields}${cases}${risk}${description}`);
|
|
366
372
|
}
|
|
367
373
|
}
|
|
368
374
|
return lines.join("\n");
|
|
@@ -585,6 +591,7 @@ function levenshtein(left, right) {
|
|
|
585
591
|
}
|
|
586
592
|
export {
|
|
587
593
|
ACTION_MATRIX_ADAPTERS,
|
|
594
|
+
actionDomain,
|
|
588
595
|
actionExampleCommand,
|
|
589
596
|
actionLibraryContextArgs,
|
|
590
597
|
describeManifestActions,
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
beginRunDiagnostics,
|
|
45
45
|
finishRunDiagnostics
|
|
46
46
|
} from "../run-diagnostics.js";
|
|
47
|
+
import { executedBrowser, recipeCdpPorts } from "./run-report.js";
|
|
47
48
|
import { EXIT } from "./shared.js";
|
|
48
49
|
import {
|
|
49
50
|
actionManifestPathOption,
|
|
@@ -155,7 +156,13 @@ async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManif
|
|
|
155
156
|
finalized.artifactManifestPath,
|
|
156
157
|
effectsState
|
|
157
158
|
);
|
|
158
|
-
|
|
159
|
+
const browser = executedBrowser(
|
|
160
|
+
adapter,
|
|
161
|
+
projectRoot,
|
|
162
|
+
finalized.artifactManifestPath,
|
|
163
|
+
recipeCdpPorts(runRequest)
|
|
164
|
+
);
|
|
165
|
+
return browser ? { ...finalized, browser } : finalized;
|
|
159
166
|
} finally {
|
|
160
167
|
restoreRuntimeEnvironment();
|
|
161
168
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { recipeRuntimeDir } from "../paths.js";
|
|
4
6
|
import { isRecord } from "./parse-args.js";
|
|
5
7
|
function writeRunReport(result) {
|
|
6
8
|
const summaryPath = String(result.summaryPath);
|
|
@@ -20,7 +22,7 @@ function writeRunReport(result) {
|
|
|
20
22
|
preview: lines.filter((line) => line.startsWith("- ")).slice(0, 6).map((line) => line.slice(2))
|
|
21
23
|
};
|
|
22
24
|
}
|
|
23
|
-
function indexProductProvenanceArtifact(artifactManifestPath, target, adapter) {
|
|
25
|
+
function indexProductProvenanceArtifact(artifactManifestPath, target, adapter, browser = null) {
|
|
24
26
|
const artifactsDir = path.dirname(artifactManifestPath);
|
|
25
27
|
let gitRef = "unknown";
|
|
26
28
|
let branch = "unknown";
|
|
@@ -53,7 +55,8 @@ function indexProductProvenanceArtifact(artifactManifestPath, target, adapter) {
|
|
|
53
55
|
repository: path.basename(path.resolve(target)),
|
|
54
56
|
git_ref: gitRef,
|
|
55
57
|
branch,
|
|
56
|
-
dirty
|
|
58
|
+
dirty,
|
|
59
|
+
...browser ? { browser } : {}
|
|
57
60
|
};
|
|
58
61
|
fs.writeFileSync(
|
|
59
62
|
path.join(artifactsDir, "product-provenance.json"),
|
|
@@ -77,7 +80,13 @@ function indexProductProvenanceArtifact(artifactManifestPath, target, adapter) {
|
|
|
77
80
|
type: "json",
|
|
78
81
|
label: "Product checkout provenance",
|
|
79
82
|
category: "system",
|
|
80
|
-
metadata: {
|
|
83
|
+
metadata: {
|
|
84
|
+
adapter,
|
|
85
|
+
git_ref: gitRef,
|
|
86
|
+
branch,
|
|
87
|
+
dirty,
|
|
88
|
+
...browser?.boundTo && browser.boundTo !== "none" ? { browser: browser.browser, browser_version: browser.version, browser_source: browser.source, extension_loading: browser.extensionLoading } : {}
|
|
89
|
+
}
|
|
81
90
|
}
|
|
82
91
|
];
|
|
83
92
|
fs.writeFileSync(
|
|
@@ -86,6 +95,87 @@ function indexProductProvenanceArtifact(artifactManifestPath, target, adapter) {
|
|
|
86
95
|
`
|
|
87
96
|
);
|
|
88
97
|
}
|
|
98
|
+
function readRecord(file) {
|
|
99
|
+
try {
|
|
100
|
+
const recorded = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
101
|
+
return isRecord(recorded) ? recorded : null;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const browserCdp = createRequire(import.meta.url)("../../adapters/shared/browser-cdp.cjs");
|
|
107
|
+
function verifyLaunch(record, cdpPort) {
|
|
108
|
+
const launch = isRecord(record.launch) ? record.launch : {};
|
|
109
|
+
const pid = Number(launch.pid);
|
|
110
|
+
const port = Number(launch.cdpPort);
|
|
111
|
+
if (!Number.isInteger(pid) || !Number.isInteger(port) || typeof launch.profile !== "string" || typeof launch.processStartedAt !== "string") {
|
|
112
|
+
return "the browser record has no process identity (pid, start time, profile, CDP port)";
|
|
113
|
+
}
|
|
114
|
+
if (cdpPort && port !== Number(cdpPort)) return `the browser record is for CDP port ${port}, not ${cdpPort}`;
|
|
115
|
+
try {
|
|
116
|
+
const owner = browserCdp.cdpOwner(port, launch.profile);
|
|
117
|
+
if (owner.pid !== pid || owner.startedAt !== launch.processStartedAt) {
|
|
118
|
+
return `CDP port ${port} is now held by another browser process than the recorded pid ${pid}`;
|
|
119
|
+
}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
return error instanceof Error ? error.message : String(error);
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
function resolveTemplatedPort(value, params, recipe) {
|
|
126
|
+
let resolved = value;
|
|
127
|
+
const template = /^\{\{\s*params\.([\w-]+)\s*\}\}$/u.exec(String(value).trim());
|
|
128
|
+
if (template) {
|
|
129
|
+
const name = template[1];
|
|
130
|
+
const schema = isRecord(recipe) && isRecord(recipe.paramsSchema) && isRecord(recipe.paramsSchema.properties) ? recipe.paramsSchema.properties[name] : void 0;
|
|
131
|
+
resolved = isRecord(params) && params[name] !== void 0 ? params[name] : isRecord(schema) ? schema.default : void 0;
|
|
132
|
+
}
|
|
133
|
+
const text = String(resolved ?? "").trim();
|
|
134
|
+
return /^\d+$/u.test(text) ? text : null;
|
|
135
|
+
}
|
|
136
|
+
function recipeCdpPorts(request) {
|
|
137
|
+
const ports = /* @__PURE__ */ new Set();
|
|
138
|
+
const envPort = request.env?.CDP_PORT || request.env?.RECIPE_CDP_PORT;
|
|
139
|
+
if (envPort) ports.add(String(envPort));
|
|
140
|
+
let recipe = request.recipeDocument;
|
|
141
|
+
if (recipe === void 0 && request.recipePath) {
|
|
142
|
+
try {
|
|
143
|
+
recipe = JSON.parse(fs.readFileSync(request.recipePath, "utf8"));
|
|
144
|
+
} catch {
|
|
145
|
+
recipe = void 0;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const workflow = isRecord(recipe) && isRecord(recipe.workflow) ? recipe.workflow : null;
|
|
149
|
+
const nodes = workflow && isRecord(workflow.nodes) ? Object.values(workflow.nodes) : [];
|
|
150
|
+
for (const node of nodes) {
|
|
151
|
+
if (!isRecord(node) || node.cdp_port === void 0 || node.cdp_port === null) continue;
|
|
152
|
+
const port = resolveTemplatedPort(node.cdp_port, request.params, recipe);
|
|
153
|
+
ports.add(port ?? `unresolved ${String(node.cdp_port)}`);
|
|
154
|
+
}
|
|
155
|
+
return [...ports];
|
|
156
|
+
}
|
|
157
|
+
function executedBrowser(adapter, target, artifactManifestPath, ports) {
|
|
158
|
+
if (adapter !== "extension") return null;
|
|
159
|
+
if (ports.length === 0) return { boundTo: "none", reason: "the run had no known CDP port" };
|
|
160
|
+
if (ports.length > 1) return { boundTo: "none", reason: `the run drove several CDP ports (${ports.join(", ")})` };
|
|
161
|
+
return launchedBrowser(target, path.dirname(artifactManifestPath), ports[0]);
|
|
162
|
+
}
|
|
163
|
+
function launchedBrowser(target, artifactsDir, cdpPort) {
|
|
164
|
+
const runtime = readRecord(path.join(artifactsDir, "extension-runtime", "runtime.json"));
|
|
165
|
+
const autolaunched = runtime && isRecord(runtime.browser) ? runtime.browser : null;
|
|
166
|
+
const candidates = [];
|
|
167
|
+
if (autolaunched) candidates.push([autolaunched, "run-autolaunch"]);
|
|
168
|
+
const slot = readRecord(path.join(path.resolve(target), recipeRuntimeDir(), "browser-resolution.json"));
|
|
169
|
+
if (slot) candidates.push([slot, "slot-runtime"]);
|
|
170
|
+
if (candidates.length === 0) return null;
|
|
171
|
+
const reasons = [];
|
|
172
|
+
for (const [record, boundTo] of candidates) {
|
|
173
|
+
const problem = verifyLaunch(record, cdpPort);
|
|
174
|
+
if (problem === null) return { ...record, boundTo };
|
|
175
|
+
reasons.push(`${boundTo}: ${problem}`);
|
|
176
|
+
}
|
|
177
|
+
return { boundTo: "none", reason: reasons.join("; ") };
|
|
178
|
+
}
|
|
89
179
|
function renderRunReport(summary, entries) {
|
|
90
180
|
const lines = [
|
|
91
181
|
"# MetaMask Recipe Run",
|
|
@@ -187,6 +277,9 @@ function indexRunReportArtifact(artifactManifestPath) {
|
|
|
187
277
|
`);
|
|
188
278
|
}
|
|
189
279
|
export {
|
|
280
|
+
executedBrowser,
|
|
190
281
|
indexProductProvenanceArtifact,
|
|
282
|
+
launchedBrowser,
|
|
283
|
+
recipeCdpPorts,
|
|
191
284
|
writeRunReport
|
|
192
285
|
};
|
package/dist/commands/run.js
CHANGED
|
@@ -147,6 +147,9 @@ async function handleRun(parsed) {
|
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
149
|
async function handleRunInner({ positional, options }, stream) {
|
|
150
|
+
if (!optionFlag(options, "list") && ["domain", "source", "sort"].some((name) => optionString(options, name) !== void 0)) {
|
|
151
|
+
throw usageError("--domain, --source, and --sort require run --list.");
|
|
152
|
+
}
|
|
150
153
|
if (optionFlag(options, "describe") && (optionFlag(options, "list") || optionFlag(options, "plan"))) {
|
|
151
154
|
const message = "--describe cannot be combined with --list or --plan.";
|
|
152
155
|
const userAction = "choose one: mm-harness run --list, mm-harness run <recipe> --describe, or mm-harness run <recipe> --plan";
|
|
@@ -373,7 +376,8 @@ async function handleRunInner({ positional, options }, stream) {
|
|
|
373
376
|
indexProductProvenanceArtifact(
|
|
374
377
|
result.artifactManifestPath,
|
|
375
378
|
target,
|
|
376
|
-
adapter
|
|
379
|
+
adapter,
|
|
380
|
+
result.browser ?? null
|
|
377
381
|
);
|
|
378
382
|
if (violation !== null) {
|
|
379
383
|
const userAction = violation.userAction ?? `inspect ${shellQuote(result.summaryPath)} and ${shellQuote(result.tracePath)}; fix the application or recipe failure before retrying`;
|
package/dist/doctor.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import { createRequire } from "node:module";
|
|
3
4
|
import path from "node:path";
|
|
@@ -167,7 +168,7 @@ function createDoctorReport(adapter, target, manifestValidation, actionManifestP
|
|
|
167
168
|
message: mode === "unsupported/no bridge" ? `No ${adapter} bridge is available for this checkout.` : `${adapter} compatibility mode: ${mode}.`
|
|
168
169
|
},
|
|
169
170
|
...adapter === "mobile" ? mobileToolDoctorChecks(mobilePlatform) : [],
|
|
170
|
-
...adapter === "extension" ? [extensionBrowserDoctorCheck(target)] : []
|
|
171
|
+
...adapter === "extension" ? [extensionBrowserDoctorCheck(target), extensionBrowserResolutionDoctorCheck(target)] : []
|
|
171
172
|
];
|
|
172
173
|
const requiredChecks = requiredDoctorCheckSummary(checks);
|
|
173
174
|
return {
|
|
@@ -214,6 +215,51 @@ function extensionBrowserDoctorCheck(target) {
|
|
|
214
215
|
userAction: "yarn playwright install chromium"
|
|
215
216
|
};
|
|
216
217
|
}
|
|
218
|
+
function extensionBrowserResolutionDoctorCheck(target) {
|
|
219
|
+
const resolver = path.join(runnerDir, "adapters/shared/browser-resolver.cjs");
|
|
220
|
+
const runtime = path.join(path.resolve(target), recipeRuntimeDir());
|
|
221
|
+
const record = path.join(runtime, "browser-resolution.json");
|
|
222
|
+
let profile = process.env.CHROME_USER_DATA_DIR || path.join(runtime, process.env.RECIPE_CHROME_PROFILE_NAME || "chrome-profile");
|
|
223
|
+
while (profile.length > 1 && profile.endsWith("/")) profile = profile.slice(0, -1);
|
|
224
|
+
const profileExists = fs.existsSync(path.join(profile, "Local State"));
|
|
225
|
+
const result = spawnSync(process.execPath, [
|
|
226
|
+
resolver,
|
|
227
|
+
"resolve",
|
|
228
|
+
"--target",
|
|
229
|
+
path.resolve(target),
|
|
230
|
+
"--launch-method",
|
|
231
|
+
"launch-services",
|
|
232
|
+
"--display",
|
|
233
|
+
"headful",
|
|
234
|
+
...profileExists && fs.existsSync(record) ? ["--recorded", record] : [],
|
|
235
|
+
"--json"
|
|
236
|
+
], {
|
|
237
|
+
cwd: path.resolve(target),
|
|
238
|
+
encoding: "utf8",
|
|
239
|
+
timeout: 135e3
|
|
240
|
+
});
|
|
241
|
+
if (result.status === 0) {
|
|
242
|
+
const resolution = JSON.parse(result.stdout);
|
|
243
|
+
const managed = resolution.managedChrome;
|
|
244
|
+
const managedNote = managed && !resolution.warning ? ` Google Chrome is managed by policy (${managed.forcedExtensions.length} forced extension${managed.forcedExtensions.length === 1 ? "" : "s"}: ${managed.forcedExtensions.map(({ id, name }) => name ? `${id} (${name})` : id).join(", ")}${managed.signinForced ? "; browser sign-in forced" : ""}); slots use Chrome for Testing.` : "";
|
|
245
|
+
const probe = resolution.probe ? `; Chrome for Testing probe (${resolution.probe.launchMethod}, ${resolution.probe.display}${resolution.probe.cached ? ", cached" : ""}${resolution.probe.transient ? `, retried after ${resolution.probe.expiresAt}` : ""}): ${resolution.probe.reason}` : resolution.recordedFrom ? `; kept from the slot profile record ${resolution.recordedFrom}` : "";
|
|
246
|
+
return {
|
|
247
|
+
id: "extension-browser",
|
|
248
|
+
status: "pass",
|
|
249
|
+
required: false,
|
|
250
|
+
message: `Launch browser: ${resolution.browser} ${resolution.version ?? "(unknown version)"} via ${resolution.source} (${resolution.mode}); extension loading: ${resolution.extensionLoading}${probe}.${managedNote}${resolution.warning ? ` Warning: ${resolution.warning}` : ""}`,
|
|
251
|
+
detail: resolution.bin
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
const message = (result.stderr || result.error?.message || "browser resolution failed").trim();
|
|
255
|
+
return {
|
|
256
|
+
id: "extension-browser",
|
|
257
|
+
status: "fail",
|
|
258
|
+
required: message.includes("BROWSER_UNLAUNCHABLE:"),
|
|
259
|
+
message,
|
|
260
|
+
userAction: "install Google Chrome, or set RECIPE_HARNESS_BROWSER / RECIPE_HARNESS_CHROME_BIN to a browser that starts"
|
|
261
|
+
};
|
|
262
|
+
}
|
|
217
263
|
function mobileToolDoctorChecks(platform) {
|
|
218
264
|
const specs = platform === "android" ? [{ tool: "adb", platform: "Android" }] : platform === "ios" ? [{ tool: "idb", platform: "iOS" }] : [
|
|
219
265
|
{ tool: "adb", platform: "Android" },
|
|
@@ -294,6 +340,7 @@ export {
|
|
|
294
340
|
compatibilityMode,
|
|
295
341
|
createDoctorReport,
|
|
296
342
|
extensionBrowserDoctorCheck,
|
|
343
|
+
extensionBrowserResolutionDoctorCheck,
|
|
297
344
|
fixtureFileSummary,
|
|
298
345
|
fixtureSummary,
|
|
299
346
|
mobileToolDoctorChecks,
|
package/dist/mm-harness-cli.js
CHANGED
|
@@ -298,6 +298,7 @@ Example:
|
|
|
298
298
|
Task:
|
|
299
299
|
--title <t> Task title (required)
|
|
300
300
|
--task-text <s> | --task-file <p> Task description, inline or from a file
|
|
301
|
+
--acceptance <criterion> Authored acceptance criterion (repeatable, preserved in order)
|
|
301
302
|
--ticket <key> Ticket key
|
|
302
303
|
--source-ref <url> Where the task came from
|
|
303
304
|
|
|
@@ -430,6 +431,9 @@ Example:
|
|
|
430
431
|
mm-harness actions.
|
|
431
432
|
|
|
432
433
|
--list List actions accepted by call for this adapter; no <action> needed
|
|
434
|
+
--domain <name> With --list: filter by action domain, e.g. perps or swap-bridge
|
|
435
|
+
--source <name> With --list: filter by origin, e.g. metamask, perps, or official
|
|
436
|
+
--sort <domain|name|library> With --list: grouping/order (default: domain; JSON: name)
|
|
433
437
|
--arg k=v Action field value (repeatable; equivalent to key=value shorthand)
|
|
434
438
|
--device <udid|serial|name> Mobile only: target this device (sets IOS_SIMULATOR / ADB_SERIAL and updates agentic-runtime.json). Without it, >1 connected mobile device fails fast and lists them. Do not shut down other devices to force selection.
|
|
435
439
|
--cdp-port <port> Extension only: bind the action to this live browser runtime.
|
|
@@ -464,6 +468,9 @@ Remote feature flags (mobile applies at runtime; extension pins at launch):
|
|
|
464
468
|
|
|
465
469
|
Discovery:
|
|
466
470
|
--list List complete recipes accepted by run for this adapter; no <recipe> needed
|
|
471
|
+
--domain <name> With --list: filter by recipe domain, e.g. perps or swap-bridge
|
|
472
|
+
--source <name> With --list: filter by library origin, e.g. metamask or perps
|
|
473
|
+
--sort <domain|name|library> With --list: grouping/order (default: domain; JSON: name)
|
|
467
474
|
--describe Show one recipe's parameters, composed actions/recipes, and runnable command
|
|
468
475
|
--plan Validate + print execution plan, touching nothing. Exit 5 if invalid.
|
|
469
476
|
--proof Preflight behavioral proof bindings before planning/execution; runtime evidence is still required.
|
package/docs/QA.md
CHANGED
|
@@ -94,6 +94,9 @@ mm-harness debug
|
|
|
94
94
|
`EXTENSION_PRODUCT_CONFIG_REQUIRED` before build or browser startup.
|
|
95
95
|
- [ ] Missing Chromium returns `EXTENSION_BROWSER_REQUIRED` without using system
|
|
96
96
|
Chrome.
|
|
97
|
+
- [ ] `doctor --adapter extension` names the launch browser, its version, why
|
|
98
|
+
it was chosen and how the extension loads; `RECIPE_HARNESS_BROWSER=cft`
|
|
99
|
+
restores Chrome for Testing, `=chrome` loads into Google Chrome over CDP.
|
|
97
100
|
- [ ] Launch uses the checkout-owned profile, opens one MetaMask surface, avoids
|
|
98
101
|
keychain prompts, and preserves keyboard focus by default.
|
|
99
102
|
- [ ] A second launch reuses the watcher/profile without duplicate tabs.
|