@deeeed/metamask-harness 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +26 -5
  2. package/adapters/core/cleanup.sh +0 -0
  3. package/adapters/core/inject.sh +0 -0
  4. package/adapters/extension/cleanup.mjs +0 -0
  5. package/adapters/extension/ensure-browser.sh +0 -0
  6. package/adapters/extension/inject.mjs +0 -0
  7. package/adapters/extension/launch-browser.cjs +0 -0
  8. package/adapters/extension/launch.sh +0 -0
  9. package/adapters/extension/live.sh +0 -0
  10. package/adapters/extension/readiness.mjs +0 -0
  11. package/adapters/extension/reattach.sh +0 -0
  12. package/adapters/extension/refresh-build.sh +0 -0
  13. package/adapters/extension/seed-fixture.sh +0 -0
  14. package/adapters/extension/sidepanel-toggle.sh +0 -0
  15. package/adapters/extension/snapshot-dist.sh +0 -0
  16. package/adapters/extension/start-watch.sh +0 -0
  17. package/adapters/extension/verify.sh +4 -4
  18. package/adapters/extension/wallet-fixture-state.cjs +0 -0
  19. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +16 -0
  20. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +2 -4
  21. package/adapters/mobile/bridge-runtime/lib/bridge-errors.cjs +106 -0
  22. package/adapters/mobile/bridge-runtime/lib/console-format.cjs +53 -0
  23. package/adapters/mobile/bridge-runtime/lib/match-bridge-target.cjs +82 -0
  24. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +17 -13
  25. package/adapters/mobile/bridge-runtime/lib/ws-client.cjs +10 -5
  26. package/adapters/mobile/bridge-runtime/setup-wallet.sh +0 -0
  27. package/adapters/mobile/cleanup.sh +0 -0
  28. package/adapters/mobile/inject.sh +0 -0
  29. package/adapters/mobile/lib/metro-listener.sh +0 -0
  30. package/adapters/mobile/lib/tmux-viewer.sh +0 -0
  31. package/adapters/mobile/open-device.sh +0 -0
  32. package/adapters/mobile/prewarm-bundle.sh +0 -0
  33. package/adapters/mobile/start-metro.sh +22 -1
  34. package/adapters/mobile/stop-metro.sh +0 -0
  35. package/adapters/mobile/verify.sh +50 -13
  36. package/adapters/mobile/wait-for-bridge.sh +41 -23
  37. package/adapters/mobile/yarn-setup.sh +0 -0
  38. package/adapters/shared/activate-repo-node.sh +0 -0
  39. package/adapters/shared/activate-repo-ruby.sh +0 -0
  40. package/adapters/shared/cli-ux.sh +0 -0
  41. package/adapters/shared/ensure-runner-deps.sh +0 -0
  42. package/adapters/shared/harness-path.sh +0 -0
  43. package/adapters/shared/hash-helpers.sh +0 -0
  44. package/adapters/shared/json-field.sh +0 -0
  45. package/adapters/shared/log-tui.mjs +7 -3
  46. package/adapters/shared/open-log-window.sh +0 -0
  47. package/adapters/shared/reap-checkout-metros.sh +0 -0
  48. package/adapters/shared/resolve-farmslot-ports.mjs +0 -0
  49. package/adapters/shared/resolve-farmslot-ports.sh +0 -0
  50. package/adapters/shared/resolve-slot-ports.mjs +0 -0
  51. package/adapters/shared/resolve-slot-ports.sh +0 -0
  52. package/adapters/shared/sync-wallet-fixture.sh +0 -0
  53. package/adapters/shared/tmux-session.sh +0 -0
  54. package/dist/adapters/mobile/prepare.js +23 -2
  55. package/dist/adapters/mobile/runtime-decision.js +2 -1
  56. package/dist/adapters.js +33 -10
  57. package/dist/cli-commands.js +5 -4
  58. package/dist/commands/call.js +138 -3
  59. package/dist/commands/device-target.js +10 -3
  60. package/dist/commands/fixtures.js +2 -1
  61. package/dist/commands/manifest.js +2 -0
  62. package/dist/commands/run-engine.js +71 -4
  63. package/dist/commands/run.js +2 -0
  64. package/dist/commands/self-test.js +1 -1
  65. package/dist/commands/status.js +19 -4
  66. package/dist/mm-harness-cli.js +19 -5
  67. package/dist/recipe-files.js +14 -0
  68. package/docs/architecture.md +2 -2
  69. package/docs/live-adapter-contract.md +1 -1
  70. package/docs/recipe-libraries.md +21 -18
  71. package/library/actions/mobile/platform/bridge.mjs +35 -4
  72. package/library/recipes/{app-lifecycle-android-smoke.mobile.recipe.json → app/lifecycle.android-smoke.mobile.recipe.json} +1 -1
  73. package/library/recipes/{perps-performance.mobile.recipe.json → perps/performance.mobile.recipe.json} +1 -1
  74. package/library/recipes/perps/smoke.core.recipe.json +39 -0
  75. package/library/recipes/perps/smoke.extension.recipe.json +51 -0
  76. package/library/recipes/perps/smoke.mobile.recipe.json +51 -0
  77. package/library/recipes/{action-validation.extension.recipe.json → runner/action-validation.extension.recipe.json} +7 -7
  78. package/library/recipes/{action-validation.mobile.recipe.json → runner/action-validation.mobile.recipe.json} +7 -7
  79. package/package.json +2 -2
  80. package/scripts/completions.sh +0 -0
  81. package/scripts/install-completions.sh +0 -0
  82. /package/library/recipes/{perps-lifecycle.recipe.json → perps/lifecycle.recipe.json} +0 -0
  83. /package/library/recipes/{order-lifecycle.core.recipe.json → perps/order-lifecycle.core.recipe.json} +0 -0
  84. /package/library/recipes/{perps-performance-background-resume.mobile.recipe.json → perps/performance.background-resume.mobile.recipe.json} +0 -0
  85. /package/library/recipes/{perps-performance-cold-start.mobile.recipe.json → perps/performance.cold-start.mobile.recipe.json} +0 -0
  86. /package/library/recipes/{perps-performance-warm-start.mobile.recipe.json → perps/performance.warm-start.mobile.recipe.json} +0 -0
  87. /package/library/recipes/{read-markets.core.recipe.json → perps/read-markets.core.recipe.json} +0 -0
  88. /package/library/recipes/{trading-lifecycle.core.recipe.json → perps/trading-lifecycle.core.recipe.json} +0 -0
  89. /package/library/recipes/{smoke.extension.recipe.json → runner/smoke.extension.recipe.json} +0 -0
  90. /package/library/recipes/{smoke.mobile.recipe.json → runner/smoke.mobile.recipe.json} +0 -0
@@ -6,14 +6,17 @@ import { loadActionManifest } from "../manifest.js";
6
6
  import { importRecipeProtocol } from "../paths.js";
7
7
  import { recipeRunning } from "../heal-bounds.js";
8
8
  import { EXIT } from "./shared.js";
9
+ import { describeManifestActions, fuzzyResolveActions } from "./manifest.js";
9
10
  import {
10
11
  parseArgs,
12
+ isRecord,
11
13
  optionFlag,
12
14
  optionString,
13
15
  resolveAdapter,
14
16
  runtimeOptionsFromCli,
15
17
  usageError
16
18
  } from "./parse-args.js";
19
+ import { getAdapterSurface } from "../adapters/surface.js";
17
20
  import { handleListExecutables } from "./list-executables.js";
18
21
  import { applyDeviceTargeting } from "./device-target.js";
19
22
  import {
@@ -31,7 +34,7 @@ async function handleCall(argv) {
31
34
  return handleListExecutables("call", options2);
32
35
  }
33
36
  if (argv.length > 0 && argv[0].startsWith("--")) {
34
- const message = "call requires <action> first: mm-harness call <action> [--arg k=v ...] [flags]";
37
+ const message = "call requires <action> first: mm-harness call <action> [key=value ...] [--arg k=v ...] [flags]";
35
38
  console.error(message);
36
39
  return EXIT.usage;
37
40
  }
@@ -58,6 +61,7 @@ async function handleCall(argv) {
58
61
  return EXIT.usage;
59
62
  }
60
63
  const { adapter, target } = resolveAdapter(options);
64
+ getAdapterSurface(adapter).resolveSlotPorts(target);
61
65
  const dtResult = applyDeviceTargeting("call", adapter, options, { gate: true, rerun: "" });
62
66
  if ("code" in dtResult) {
63
67
  if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "call", adapter, error: { code: dtResult.code, message: dtResult.message } }, null, 2));
@@ -125,6 +129,7 @@ async function handleCall(argv) {
125
129
  state
126
130
  );
127
131
  if (violation !== null) return emitHealViolation(json, "call", result, violation, state, adapter);
132
+ const callOutput = readCallOutput(result.tracePath);
128
133
  if (json) {
129
134
  console.log(
130
135
  JSON.stringify(
@@ -139,6 +144,7 @@ async function handleCall(argv) {
139
144
  summaryPath: result.summaryPath,
140
145
  tracePath: result.tracePath,
141
146
  artifactManifestPath: result.artifactManifestPath,
147
+ ...callOutput !== void 0 ? { output: callOutput } : {},
142
148
  recovered: state.recovered,
143
149
  mutations: state.mutations,
144
150
  exitCode: result.status === "pass" ? EXIT.ok : EXIT.runtime
@@ -148,11 +154,105 @@ async function handleCall(argv) {
148
154
  )
149
155
  );
150
156
  } else {
151
- console.log(`call ${resolvedAction}: ${result.status}
157
+ const rendered = callOutput !== void 0 ? `
158
+ Result:
159
+ ${formatCallOutput(callOutput)}` : "";
160
+ console.log(`call ${resolvedAction}: ${result.status}${rendered}
152
161
  Artifacts: ${result.artifactManifestPath}`);
153
162
  }
154
163
  return result.status === "pass" ? EXIT.ok : EXIT.runtime;
155
164
  }
165
+ function readCallOutput(tracePath) {
166
+ try {
167
+ const trace = JSON.parse(fs.readFileSync(tracePath, "utf8"));
168
+ const entries = Array.isArray(trace) ? trace : isRecord(trace) && Array.isArray(trace.entries) ? trace.entries : [];
169
+ const entry = entries.find((item) => isRecord(item) && item.nodeId === "call");
170
+ return isRecord(entry) && Object.prototype.hasOwnProperty.call(entry, "output") ? entry.output : void 0;
171
+ } catch {
172
+ return void 0;
173
+ }
174
+ }
175
+ function formatCallOutput(value) {
176
+ if (typeof value === "string") return value;
177
+ if (typeof value === "number" || typeof value === "boolean" || value === null) return String(value);
178
+ return JSON.stringify(value, null, 2);
179
+ }
180
+ async function handleCallHelp(argv, genericHelp) {
181
+ const { action: shortName, rest } = parseCallArgs(argv.filter((arg) => arg !== "--help" && arg !== "-h"));
182
+ const { options } = parseArgs(rest, "call");
183
+ let adapter;
184
+ let manifest;
185
+ try {
186
+ ({ adapter } = resolveAdapter(options));
187
+ manifest = loadActionManifest(adapter, optionString(options, "actionManifest"));
188
+ } catch {
189
+ process.stdout.write(`${genericHelp}
190
+ `);
191
+ return EXIT.ok;
192
+ }
193
+ const matches = shortName ? fuzzyResolveActions(describeManifestActions(manifest), shortName) : [];
194
+ if (matches.length === 0) {
195
+ process.stdout.write(`${genericHelp}
196
+ `);
197
+ if (shortName) {
198
+ process.stdout.write(
199
+ `
200
+ No action matches "${shortName}" for the ${adapter} adapter.
201
+ Next: mm-harness actions --adapter ${adapter} # list the action vocabulary
202
+ `
203
+ );
204
+ }
205
+ return EXIT.ok;
206
+ }
207
+ for (const entry of matches) process.stdout.write(renderCallActionHelp(entry));
208
+ process.stdout.write(`${genericHelp}
209
+ `);
210
+ return EXIT.ok;
211
+ }
212
+ function renderCallActionHelp(entry) {
213
+ const short = entry.name.split(".").pop() ?? entry.name;
214
+ const schema = isRecord(entry.schema) ? entry.schema : {};
215
+ const properties = isRecord(schema.properties) ? schema.properties : {};
216
+ const required = new Set(
217
+ Array.isArray(schema.required) ? schema.required.filter((r) => typeof r === "string") : []
218
+ );
219
+ const lines = [`mm-harness call ${entry.name} [key=value ...] [--arg k=v ...] [flags]`, ""];
220
+ if (entry.description) lines.push(` ${entry.description}`, "");
221
+ const names = Object.keys(properties).sort();
222
+ if (names.length === 0) {
223
+ lines.push(" Fields: (none)");
224
+ } else {
225
+ lines.push(" Fields (pass as <name>=<value> or --arg <name>=<value>):");
226
+ const width = Math.max(...names.map((name) => name.length));
227
+ for (const name of names) {
228
+ const prop = isRecord(properties[name]) ? properties[name] : {};
229
+ const type = typeof prop.type === "string" ? prop.type : "any";
230
+ const req = required.has(name) ? " (required)" : "";
231
+ const desc = typeof prop.description === "string" ? ` \u2014 ${prop.description}` : "";
232
+ const enumVals = Array.isArray(prop.enum) ? ` [one of: ${prop.enum.join(", ")}]` : "";
233
+ lines.push(` ${name.padEnd(width)} ${type}${req}${desc}${enumVals}`);
234
+ }
235
+ }
236
+ const examples = renderCallExamples(short, entry.examples);
237
+ if (examples.length > 0) {
238
+ lines.push("", " Examples:");
239
+ for (const example of examples) lines.push(` ${example}`);
240
+ }
241
+ return `${lines.join("\n")}
242
+
243
+ `;
244
+ }
245
+ function renderCallExamples(short, examples) {
246
+ if (!Array.isArray(examples)) return [];
247
+ const out = [];
248
+ for (const example of examples.slice(0, 2)) {
249
+ const node = isRecord(example) && isRecord(example.node) ? example.node : void 0;
250
+ if (!node) continue;
251
+ const tokens = Object.entries(node).filter(([key]) => key !== "action" && key !== "intent").map(([key, value]) => `${key}=${typeof value === "string" ? value : JSON.stringify(value)}`);
252
+ out.push(`mm-harness call ${short} ${tokens.join(" ")}`.trim());
253
+ }
254
+ return out;
255
+ }
156
256
  function parseCallArgs(argv) {
157
257
  const args = {};
158
258
  const rest = [];
@@ -171,10 +271,44 @@ function parseCallArgs(argv) {
171
271
  action = arg;
172
272
  continue;
173
273
  }
274
+ if (action !== void 0 && !isForwardedOptionValue(argv, i) && isArgPair(arg)) {
275
+ const eq = arg.indexOf("=");
276
+ args[arg.slice(0, eq)] = arg.slice(eq + 1);
277
+ continue;
278
+ }
174
279
  rest.push(arg);
175
280
  }
176
281
  return { action, args, rest };
177
282
  }
283
+ function isArgPair(value) {
284
+ const eq = value.indexOf("=");
285
+ return eq > 0;
286
+ }
287
+ function isForwardedOptionValue(argv, index) {
288
+ if (index === 0) return false;
289
+ const previous = argv[index - 1];
290
+ if (!previous.startsWith("--") || previous.includes("=")) return false;
291
+ return VALUE_TAKING_CALL_FLAGS.has(normalizeFlagName(previous));
292
+ }
293
+ const VALUE_TAKING_CALL_FLAGS = /* @__PURE__ */ new Set([
294
+ "action-manifest",
295
+ "adapter",
296
+ "artifacts-dir",
297
+ "cdp-port",
298
+ "device",
299
+ "heal",
300
+ "library",
301
+ "metro-port",
302
+ "platform",
303
+ "project-root",
304
+ "slot",
305
+ "target",
306
+ "validation-runtime-dir",
307
+ "watcher-port"
308
+ ]);
309
+ function normalizeFlagName(flag) {
310
+ return flag.replace(/^--/u, "");
311
+ }
178
312
  function resolveActionName(shortName, names) {
179
313
  if (names.includes(shortName)) return { status: "ok", resolved: shortName, candidates: [shortName] };
180
314
  const finalSegment = (name) => name.split(".").pop() ?? name;
@@ -191,5 +325,6 @@ function pickCallExampleAction(names) {
191
325
  return names[0] ?? "command";
192
326
  }
193
327
  export {
194
- handleCall
328
+ handleCall,
329
+ handleCallHelp
195
330
  };
@@ -111,9 +111,16 @@ function applyDeviceTargeting(command, adapter, options, opts) {
111
111
  ${formatConnectedDevices(connected2)}` : ` No devices are currently connected (adb devices / booted iOS simulators).`)
112
112
  };
113
113
  }
114
- if (!opts.gate) return { ok: true };
115
114
  const connected = listConnectedDevices();
116
115
  const targetable = connected.filter(isTargetable);
116
+ const selectedTargetable = targetable.filter(deviceSelected);
117
+ if (selectedTargetable.length === 1) {
118
+ const selected = selectedTargetable[0];
119
+ if (selected.platform === "android") setAndroidDeviceEnv(selected.id, selected.name);
120
+ else setIosDeviceEnv(selected.id, selected.name);
121
+ return { ok: true };
122
+ }
123
+ if (!opts.gate) return { ok: true };
117
124
  if (targetable.length > 1) {
118
125
  return {
119
126
  ok: false,
@@ -121,8 +128,8 @@ ${formatConnectedDevices(connected2)}` : ` No devices are currently connected (
121
128
  message: `${targetable.length} mobile devices available \u2014 ${command} needs exactly one target.
122
129
  Connected devices:
123
130
  ${formatConnectedDevices(connected)}
124
- Add --device <id> to disambiguate, e.g.:${targetable.map((d) => `
125
- --device ${d.id}`).join("")}`
131
+ ` + (selectedTargetable.length > 1 ? ` The current slot context selects more than one target; fix the slot device pins or add --device <id>.` : ` Add --device <id> to disambiguate, e.g.:${targetable.map((d) => `
132
+ --device ${d.id}`).join("")}`)
126
133
  };
127
134
  }
128
135
  return { ok: true };
@@ -39,13 +39,14 @@ async function handleFixtures(argv, deps) {
39
39
  if (!adapter) {
40
40
  return usageOut(json, "fixtures", `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
41
41
  }
42
+ const surface = getAdapterSurface(adapter);
43
+ surface.resolveSlotPorts(target);
42
44
  const dtResult = applyDeviceTargeting("fixtures", adapter, options, { gate: false, rerun: "" });
43
45
  if ("code" in dtResult) {
44
46
  return usageOut(json, "fixtures", dtResult.message, "mm-harness fixtures set --adapter mobile --device <adb-serial|simulator-udid>");
45
47
  }
46
48
  if (sub === "generate") return fixturesGenerate(adapter, target, options, json);
47
49
  if (sub === "finalize") return fixturesFinalize(adapter, target, options, json);
48
- const surface = getAdapterSurface(adapter);
49
50
  if (surface.headless) return usageOut(json, "fixtures", "core is headless; it has no wallet fixture.", surface.hints.launch);
50
51
  const canonicalFixture = walletFixturePath(target);
51
52
  const retryHint = `${surface.hints.relaunch} # relaunch, then retry: mm-harness fixtures set`;
@@ -87,6 +87,8 @@ function describeManifestAction(name, kind, metadata) {
87
87
  };
88
88
  }
89
89
  export {
90
+ describeManifestActions,
91
+ fuzzyResolveActions,
90
92
  handleActions,
91
93
  handleManifest
92
94
  };
@@ -18,6 +18,7 @@ import {
18
18
  runnerDir
19
19
  } from "../paths.js";
20
20
  import { captureHelperSupportsRecordSessionSnapshots } from "../recording-target.js";
21
+ import { listRecipeFiles } from "../recipe-files.js";
21
22
  import { startRecipeRecording, stopRecipeRecording } from "../run-recording.js";
22
23
  import { EXIT } from "./shared.js";
23
24
  import {
@@ -68,7 +69,15 @@ async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManif
68
69
  recordVideo: useFramedExtensionRecording ? false : recordVideo,
69
70
  ...runtimeOptions.librarySources ? { librarySources: runtimeOptions.librarySources } : {}
70
71
  };
71
- const result = await runner.run(runRequest);
72
+ let result;
73
+ try {
74
+ result = await runner.run(runRequest);
75
+ } finally {
76
+ if (adapter === "mobile") {
77
+ const { hideMobileHudOnTeardown } = await import("../adapters.js");
78
+ await hideMobileHudOnTeardown(projectRoot, recipeRunEnv(adapter, runtimeOptions));
79
+ }
80
+ }
72
81
  await stopRecipeRecording(recording, result);
73
82
  return result;
74
83
  } finally {
@@ -144,7 +153,7 @@ function resolveRunRecipeArg(recipeArg, adapter, librarySources) {
144
153
  const direct = path.resolve(recipeArg);
145
154
  if (isRecipeFile(direct)) return { recipeFile: direct };
146
155
  if (!recipeArg.includes("/") && !recipeArg.includes(path.sep)) {
147
- const candidates = [`${recipeArg}.${adapter}.recipe.json`, `${recipeArg}.recipe.json`, recipeArg];
156
+ const candidates = recipeNameCandidates(recipeArg, adapter);
148
157
  if (librarySources && librarySources.length > 0) {
149
158
  for (const source of librarySources) {
150
159
  for (const candidate of candidates) {
@@ -168,17 +177,75 @@ function resolveRunRecipeArg(recipeArg, adapter, librarySources) {
168
177
  function libraryRecipeNames(adapter) {
169
178
  let entries;
170
179
  try {
171
- entries = fs.readdirSync(recipePath(""));
180
+ entries = listRecipeFiles(recipePath(""));
172
181
  } catch {
173
182
  return [];
174
183
  }
175
184
  const scopes = ["mobile", "extension", "core"];
176
- const names = entries.filter((f) => f.endsWith(".recipe.json")).map((f) => f.slice(0, -".recipe.json".length)).filter((n) => {
185
+ const names = entries.filter((f) => f.endsWith(".recipe.json")).map((f) => f.slice(0, -".recipe.json".length).replace(/[\\/]/gu, ".")).filter((n) => {
177
186
  const scope = n.split(".").pop() ?? "";
178
187
  return !scopes.includes(scope) || scope === adapter;
179
188
  }).map((n) => n.endsWith(`.${adapter}`) ? n.slice(0, -(adapter.length + 1)) : n);
180
189
  return [...new Set(names)].sort();
181
190
  }
191
+ function recipeNameCandidates(recipeArg, adapter) {
192
+ const names = [recipeArg, ...LEGACY_RECIPE_ALIASES[recipeArg] ?? []];
193
+ const candidates = [];
194
+ for (const name of names) {
195
+ candidates.push(...singleRecipeNameCandidates(name, adapter));
196
+ }
197
+ return [...new Set(candidates)];
198
+ }
199
+ function singleRecipeNameCandidates(name, adapter) {
200
+ if (name.endsWith(".recipe.json")) {
201
+ return uniqueStrings([domainPathForRecipeFile(name), name]);
202
+ }
203
+ const bases = uniqueStrings([domainPathForRecipeName(name), name]);
204
+ const candidates = [];
205
+ for (const base of bases) {
206
+ candidates.push(`${base}.${adapter}.recipe.json`, `${base}.recipe.json`, base);
207
+ }
208
+ return candidates;
209
+ }
210
+ function domainPathForRecipeFile(name) {
211
+ const suffix = ".recipe.json";
212
+ const base = name.slice(0, -suffix.length);
213
+ return `${domainPathForRecipeName(base)}${suffix}`;
214
+ }
215
+ function domainPathForRecipeName(name) {
216
+ if (name.includes("/") || name.includes(path.sep)) return name;
217
+ const dot = name.indexOf(".");
218
+ return dot > 0 ? `${name.slice(0, dot)}/${name.slice(dot + 1)}` : name;
219
+ }
220
+ function uniqueStrings(values) {
221
+ return [...new Set(values)];
222
+ }
223
+ const LEGACY_RECIPE_ALIASES = {
224
+ "action-validation": ["runner.action-validation"],
225
+ "action-validation.extension.recipe.json": ["runner.action-validation.extension.recipe.json"],
226
+ "action-validation.mobile.recipe.json": ["runner.action-validation.mobile.recipe.json"],
227
+ "app-lifecycle-android-smoke": ["app.lifecycle.android-smoke"],
228
+ "app-lifecycle-android-smoke.mobile.recipe.json": ["app.lifecycle.android-smoke.mobile.recipe.json"],
229
+ "order-lifecycle": ["perps.order-lifecycle"],
230
+ "order-lifecycle.core.recipe.json": ["perps.order-lifecycle.core.recipe.json"],
231
+ "perps-lifecycle": ["perps.lifecycle"],
232
+ "perps-lifecycle.recipe.json": ["perps.lifecycle.recipe.json"],
233
+ "perps-performance": ["perps.performance"],
234
+ "perps-performance-background-resume": ["perps.performance.background-resume"],
235
+ "perps-performance-background-resume.mobile.recipe.json": ["perps.performance.background-resume.mobile.recipe.json"],
236
+ "perps-performance-cold-start": ["perps.performance.cold-start"],
237
+ "perps-performance-cold-start.mobile.recipe.json": ["perps.performance.cold-start.mobile.recipe.json"],
238
+ "perps-performance-warm-start": ["perps.performance.warm-start"],
239
+ "perps-performance-warm-start.mobile.recipe.json": ["perps.performance.warm-start.mobile.recipe.json"],
240
+ "perps-performance.mobile.recipe.json": ["perps.performance.mobile.recipe.json"],
241
+ "read-markets": ["perps.read-markets"],
242
+ "read-markets.core.recipe.json": ["perps.read-markets.core.recipe.json"],
243
+ "smoke": ["runner.smoke"],
244
+ "smoke.extension.recipe.json": ["runner.smoke.extension.recipe.json"],
245
+ "smoke.mobile.recipe.json": ["runner.smoke.mobile.recipe.json"],
246
+ "trading-lifecycle": ["perps.trading-lifecycle"],
247
+ "trading-lifecycle.core.recipe.json": ["perps.trading-lifecycle.core.recipe.json"]
248
+ };
182
249
  async function validateRunRecipeStatic(recipeArg, adapter, options) {
183
250
  const librarySources = await resolveMetaMaskLibrarySources(optionString(options, "library"));
184
251
  const resolved = resolveRunRecipeArg(recipeArg, adapter, librarySources);
@@ -20,6 +20,7 @@ import {
20
20
  } from "./run-engine.js";
21
21
  import { handleListExecutables } from "./list-executables.js";
22
22
  import { applyDeviceTargeting } from "./device-target.js";
23
+ import { getAdapterSurface } from "../adapters/surface.js";
23
24
  async function handleRun({ positional, options }) {
24
25
  if (optionFlag(options, "list")) return handleListExecutables("run", options);
25
26
  const targetRecipe = positional[0];
@@ -27,6 +28,7 @@ async function handleRun({ positional, options }) {
27
28
  if (optionFlag(options, "plan")) return handleRunPlan(targetRecipe, options);
28
29
  const { adapter, target } = resolveAdapter(options);
29
30
  const json = optionFlag(options, "json");
31
+ getAdapterSurface(adapter).resolveSlotPorts(target);
30
32
  const dtResult = applyDeviceTargeting("run", adapter, options, { gate: true, rerun: "" });
31
33
  if ("code" in dtResult) {
32
34
  return emitRunUsageError(json, adapter, targetRecipe, dtResult.code, dtResult.message);
@@ -26,7 +26,7 @@ async function runSelfTest(options) {
26
26
  const manifest = loadActionManifest(adapter);
27
27
  const manifestValidation = await validateManifest(manifest);
28
28
  const smokeRecipe = recipePath(
29
- adapter === "mobile" ? "smoke.mobile.recipe.json" : "smoke.extension.recipe.json"
29
+ adapter === "mobile" ? "runner/smoke.mobile.recipe.json" : "runner/smoke.extension.recipe.json"
30
30
  );
31
31
  const artifactsDir = path.join(root, adapter);
32
32
  const result = await runRecipe(adapter, smokeRecipe, artifactsDir, runnerDir, void 0, {
@@ -16,8 +16,10 @@ async function handleStatus({ options }) {
16
16
  return usageOut(json, "status", `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
17
17
  }
18
18
  assertAdapter(adapter);
19
- const devices = adapter === "mobile" ? listConnectedDevices().map((device) => ({ ...device, selected: deviceSelected(device) })) : [];
20
- const next = getAdapterSurface(adapter).hints.relaunch;
19
+ const surface = getAdapterSurface(adapter);
20
+ surface.resolveSlotPorts(target);
21
+ const next = surface.hints.relaunch;
22
+ const devices = adapter === "mobile" ? slotScopedDevices(listConnectedDevices()) : [];
21
23
  if (json) {
22
24
  const doProbe = !fast && adapter === "mobile" && devices.length > 0;
23
25
  if (doProbe) {
@@ -25,7 +27,7 @@ async function handleStatus({ options }) {
25
27
  const devicesWithLive = devices.map((d) => mergeDeviceLive(d, liveMap.get(d.id)));
26
28
  console.log(
27
29
  JSON.stringify(
28
- { schemaVersion: 1, command: "status", adapter, target, devices: devicesWithLive, next },
30
+ { schemaVersion: 1, command: "status", adapter, target, devices: devicesWithLive, next: nextForLive(next, liveMap) },
29
31
  null,
30
32
  2
31
33
  )
@@ -40,13 +42,26 @@ async function handleStatus({ options }) {
40
42
  const out = (style, text) => color(style, text, { stream: process.stdout });
41
43
  console.log(`${out("label", "status")} ${out("bold", adapter)} ${out("dim", target)}`);
42
44
  if (adapter === "mobile") renderDeviceList(devices, out);
43
- console.log(`${out("label", "Next:")} ${out("cmd", next)}`);
44
45
  if (!fast && adapter === "mobile" && devices.length > 0) {
45
46
  const liveMap = await probeMobileLiveState(target, devices);
46
47
  renderLiveBlock(devices, liveMap, out);
48
+ console.log(`${out("label", "Next:")} ${out("cmd", nextForLive(next, liveMap))}`);
49
+ } else {
50
+ console.log(`${out("label", "Next:")} ${out("cmd", next)}`);
47
51
  }
48
52
  return EXIT.ok;
49
53
  }
54
+ function nextForLive(fallback, liveMap) {
55
+ for (const live of liveMap.values()) {
56
+ if (live.liveState === void 0) return "mm-harness logs";
57
+ }
58
+ return fallback;
59
+ }
60
+ function slotScopedDevices(devices) {
61
+ const withSelection = devices.map((device) => ({ ...device, selected: deviceSelected(device) }));
62
+ const selected = withSelection.filter((device) => device.selected);
63
+ return selected.length > 0 ? selected : withSelection;
64
+ }
50
65
  function mergeDeviceLive(device, live) {
51
66
  if (!live) return device;
52
67
  return {
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
5
5
  import { Command } from "commander";
6
6
  import { color } from "./cli-color.js";
7
7
  import { handleUpdate, maybeNudge } from "./commands/update.js";
8
+ import { handleCallHelp } from "./commands/call.js";
8
9
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
9
10
  globalThis.__MM_HARNESS_WRAPPER__ = true;
10
11
  const { main: recipeMain } = await import("./cli.js");
@@ -89,15 +90,16 @@ Example:
89
90
  name: "call",
90
91
  summary: "Run one action in isolation as a one-node recipe through the real engine path (fuzzy short names; --arg k=v; same trace/evidence as run).",
91
92
  example: "mm-harness call ensure_unlocked",
92
- helpText: `mm-harness call <action> [--arg k=v ...] [flags]
93
+ helpText: `mm-harness call <action> [key=value ...] [--arg k=v ...] [flags]
93
94
 
94
95
  Run one action in isolation as a one-node recipe through the real engine path.
95
96
  Fuzzy short-name: 'ensure_unlocked' resolves to 'metamask.wallet.ensure_unlocked'
96
- if unique; ambiguous = exit 2. Actions differ per adapter \u2014 list this checkout's
97
- with: mm-harness actions.
97
+ if unique; ambiguous = exit 2. Pass action fields as key=value shorthand or
98
+ with --arg k=v. Actions differ per adapter \u2014 list this checkout's with:
99
+ mm-harness actions.
98
100
 
99
101
  --list List everything invocable for the adapter (actions + flows); no <action> needed
100
- --arg k=v Action field value (repeatable)
102
+ --arg k=v Action field value (repeatable; equivalent to key=value shorthand)
101
103
  --device <udid|serial|name> Mobile only: target this device (env: IOS_SIMULATOR / ADB_SERIAL). Without it, >1 connected mobile device fails fast and lists them.
102
104
  --adapter <mobile|extension|core> Target adapter (auto-detected inside a checkout)
103
105
  --target <path> Checkout path (default: cwd)
@@ -109,7 +111,8 @@ Example:
109
111
 
110
112
  Example (real actions; run mm-harness actions for this checkout's full set):
111
113
  mm-harness call ensure_unlocked --adapter extension # a wallet action (extension/mobile)
112
- mm-harness call command --arg cmd="echo hi" --adapter core # the universal action (all adapters)`
114
+ mm-harness call navigate page=perps --adapter mobile
115
+ mm-harness call command cmd="echo hi" --adapter core # the universal action (all adapters)`
113
116
  },
114
117
  {
115
118
  name: "flows",
@@ -614,6 +617,13 @@ function hasPassthroughHelp(argv) {
614
617
  if (divider === -1) return false;
615
618
  return argv.slice(divider + 1).some((arg) => arg === "-h" || arg === "--help");
616
619
  }
620
+ function isCallActionHelp(argv) {
621
+ if (argv[0] !== "call") return false;
622
+ if (!argv[1] || argv[1].startsWith("-")) return false;
623
+ const divider = argv.indexOf("--");
624
+ const scope = divider === -1 ? argv : argv.slice(0, divider);
625
+ return scope.includes("--help") || scope.includes("-h");
626
+ }
617
627
  if (rawArgv.length === 0) {
618
628
  process.stdout.write(groupedHelp());
619
629
  process.exit(0);
@@ -621,4 +631,8 @@ if (rawArgv.length === 0) {
621
631
  if (hasPassthroughHelp(rawArgv)) {
622
632
  process.exit(await delegate(rawArgv));
623
633
  }
634
+ if (isCallActionHelp(rawArgv)) {
635
+ const callHelp = REAL.find((command) => command.name === "call")?.helpText ?? "";
636
+ process.exit(await handleCallHelp(rawArgv.slice(1), callHelp));
637
+ }
624
638
  await program.parseAsync(process.argv);
@@ -0,0 +1,14 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ function listRecipeFiles(root, base = "") {
4
+ const out = [];
5
+ for (const entry of fs.readdirSync(path.join(root, base), { withFileTypes: true })) {
6
+ const rel = path.join(base, entry.name);
7
+ if (entry.isDirectory()) out.push(...listRecipeFiles(root, rel));
8
+ else if (entry.isFile() && entry.name.endsWith(".recipe.json")) out.push(rel);
9
+ }
10
+ return out;
11
+ }
12
+ export {
13
+ listRecipeFiles
14
+ };
@@ -216,7 +216,7 @@ describes **how an agent should work**, it belongs in skills.
216
216
  | `adapters/extension/` | Runner-owned Extension launch/live/watch/windows/wallet-state/readiness helpers (orchestration) and verify (recipe) copied into installed harnesses. |
217
217
  | `adapters/shared/path-defaults.json` | Single source for default `recipeHarnessRoot` and `recipeRuntimeDir`. |
218
218
  | `adapters/shared/harness-path.sh`, `src/paths.ts` | Shell and TypeScript accessors for those defaults plus validation. |
219
- | `library/recipes/` | Reusable smoke/action-validation recipes only. Task-specific proof recipes stay task-local. |
219
+ | `library/recipes/` | Reusable smoke/runner.action-validation recipes only. Task-specific proof recipes stay task-local. |
220
220
  | `library/library.json`, `library/flows/` | Canonical recipe library: minimal, stable flows recipes can `call`. Personal/team libraries shadow it by default — see [Recipe Libraries](recipe-libraries.md). |
221
221
  | `docs/` | Runner architecture, contracts, and operational conventions. |
222
222
 
@@ -377,7 +377,7 @@ TypeScript or a focused `.mjs` adapter and keep shell as the launcher.
377
377
  5. Use official `ui.*` actions and screenshot claims for visible acceptance
378
378
  criteria; do not add task-specific `metamask.*` actions for exact copy,
379
379
  styling, ticket IDs, or one-off selectors.
380
- 6. Update smoke/action-validation recipes only when the capability is reusable.
380
+ 6. Update smoke/runner.action-validation recipes only when the capability is reusable.
381
381
 
382
382
  ## Standalone use first; farm later
383
383
 
@@ -85,7 +85,7 @@ domain capability useful across many tasks, such as `metamask.perps.start_state`
85
85
  `metamask.perps.place_order`, `metamask.perps.close_positions`, or
86
86
  `metamask.perps.assert_positions`.
87
87
 
88
- `ui.scroll` is part of the current executable contract and action-validation must
88
+ `ui.scroll` is part of the current executable contract and runner.action-validation must
89
89
  prove both normal scrolling and `scroll_into_view` before screenshot capture.
90
90
  `ui.gesture` is intentionally not advertised yet; drag/swipe proof must wait
91
91
  until the shared runtime and this runner expose and validate that action on both
@@ -101,19 +101,20 @@ each user-visible step takes. The runner has no benchmark verb: timings are just
101
101
  the per-node `duration`s in a passing run's `trace.json`, so a flow is comparable
102
102
  across runs only when you pin the run (same device, healing off) and keep the node
103
103
  graph stable. The repo ships one canonical example,
104
- `library/recipes/perps-performance.mobile.recipe.json` — unlock → open the Perps
104
+ `library/recipes/perps/performance.mobile.recipe.json` — unlock → open the Perps
105
105
  market list → read live state → open a market detail. This walkthrough copies it
106
106
  into a library of your own and retargets it to your journey. A peer engineer can
107
- follow it verbatim; the same steps run as the `perps-performance-recipe` contract
107
+ follow it verbatim; the same steps run as the `perps.performance-recipe` contract
108
108
  test.
109
109
 
110
110
  ### 1. Scaffold a personal library
111
111
 
112
112
  A library is a directory with a `library.json` marker. Keep reusable `flows/`
113
- (referenced via `call`) beside a `recipes/` folder for the full flows you run:
113
+ (referenced via `call`) beside a domain-grouped `recipes/` folder for the full
114
+ flows you run:
114
115
 
115
116
  ```bash
116
- mkdir -p ~/my-recipes/flows ~/my-recipes/recipes
117
+ mkdir -p ~/my-recipes/flows ~/my-recipes/recipes/my-perps
117
118
  cat > ~/my-recipes/library.json <<'JSON'
118
119
  { "kind": "recipe-library", "schema_version": 1, "name": "mydev", "owner": "mydev" }
119
120
  JSON
@@ -130,8 +131,8 @@ it works headlessly (CI, a scratch checkout) and so the path is unambiguous.
130
131
  Copy it out of your runner checkout's `library/recipes/`:
131
132
 
132
133
  ```bash
133
- cp library/recipes/perps-performance.mobile.recipe.json \
134
- ~/my-recipes/recipes/my-perps-performance.mobile.recipe.json
134
+ cp library/recipes/perps/performance.mobile.recipe.json \
135
+ ~/my-recipes/recipes/my-perps/performance.mobile.recipe.json
135
136
  ```
136
137
 
137
138
  ### 3. Edit the nodes to your journey
@@ -211,33 +212,35 @@ field on flow catalog entries — not yet in the protocol.
211
212
 
212
213
  ### 4. Run it and read the timings
213
214
 
214
- Validate statically **by name** first — `run` probes each library source's `recipes/`
215
- directory in precedence order (personal → team → canonical), so `my-perps-performance`
216
- resolves from `~/my-recipes/recipes/` without you spelling out the path:
215
+ Validate statically **by name** first — `run` probes each library source's
216
+ `recipes/` directory in precedence order (personal → team → canonical), so
217
+ `my-perps.performance` resolves from
218
+ `~/my-recipes/recipes/my-perps/performance.mobile.recipe.json` without you
219
+ spelling out the path:
217
220
 
218
221
  ```bash
219
222
  # Static validation by NAME — resolves from the personal library via --library.
220
- mm-harness run my-perps-performance \
223
+ mm-harness run my-perps.performance \
221
224
  --library mydev=~/my-recipes --plan --adapter mobile
222
225
 
223
226
  # Zero-flag personal-library: when ~/my-recipes is placed at
224
227
  # $FARMSLOT_HOME/recipe-library (default ~/.farmslot/recipe-library), the runner
225
228
  # discovers it automatically and run-by-name works without --library:
226
- mm-harness run my-perps-performance --plan --adapter mobile
229
+ mm-harness run my-perps.performance --plan --adapter mobile
227
230
 
228
231
  # Pinned live run — same device, healing OFF, so durations are comparable.
229
- mm-harness run my-perps-performance \
232
+ mm-harness run my-perps.performance \
230
233
  --library mydev=~/my-recipes \
231
234
  --adapter mobile --device <serial> --heal off --artifacts-dir artifacts
232
235
 
233
236
  # Canonical start-state variants — same installed app, no rebuild.
234
- mm-harness run app-lifecycle-android-smoke \
237
+ mm-harness run app.lifecycle.android-smoke \
235
238
  --adapter mobile --device <serial> --heal off --artifacts-dir artifacts/lifecycle-smoke
236
- mm-harness run perps-performance-warm-start \
239
+ mm-harness run perps.performance.warm-start \
237
240
  --adapter mobile --device <serial> --heal off --artifacts-dir artifacts/warm
238
- mm-harness run perps-performance-background-resume \
241
+ mm-harness run perps.performance.background-resume \
239
242
  --adapter mobile --device <serial> --heal off --artifacts-dir artifacts/background
240
- mm-harness run perps-performance-cold-start \
243
+ mm-harness run perps.performance.cold-start \
241
244
  --adapter mobile --device <serial> --heal off --artifacts-dir artifacts/cold
242
245
 
243
246
  # Per-node durations to diff across runs (trace.json is an array of entries, or
@@ -249,7 +252,7 @@ node -e 'const t=require("./artifacts/trace.json"); \
249
252
  A miss with `--library` names the sources that were searched, so you can tell at a
250
253
  glance whether a typo or a missing library entry caused the failure.
251
254
 
252
- Run `app-lifecycle-android-smoke` first when validating a new Android slot; it
255
+ Run `app.lifecycle.android-smoke` first when validating a new Android slot; it
253
256
  isolates lifecycle control from wallet setup and Perps navigation. The start-state
254
257
  variants use the standard outer `app.lifecycle` action. Every
255
258
  variant begins with `app.status` so `trace.json` records an idempotent start
@@ -294,5 +297,5 @@ mm-harness run my-perps-with-segment --plan --adapter mobile \
294
297
  ```
295
298
 
296
299
  That resolution — unresolved without the source, `pass` with it — is what the
297
- `perps-performance-recipe` contract test asserts, alongside the canonical recipe
300
+ `perps.performance-recipe` contract test asserts, alongside the canonical recipe
298
301
  resolving by name and the personal mobile copy planning by path and basename.