@deeeed/metamask-harness 0.11.0 → 0.13.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 (65) hide show
  1. package/CHANGELOG.md +24 -0
  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 +0 -0
  18. package/adapters/extension/wallet-fixture-state.cjs +0 -0
  19. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +63 -0
  20. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +341 -0
  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 +48 -42
  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 +4 -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 +37 -0
  34. package/adapters/mobile/stop-metro.sh +21 -0
  35. package/adapters/mobile/verify.sh +0 -0
  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/open-debug.mjs +5 -0
  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 +39 -9
  57. package/dist/commands/call.js +80 -1
  58. package/dist/commands/manifest.js +2 -0
  59. package/dist/commands/run-engine.js +9 -1
  60. package/dist/mm-harness-cli.js +12 -0
  61. package/dist/runner.js +2 -2
  62. package/library/actions/mobile/platform/bridge.mjs +35 -4
  63. package/package.json +2 -2
  64. package/scripts/completions.sh +0 -0
  65. package/scripts/install-completions.sh +0 -0
package/dist/adapters.js CHANGED
@@ -2,7 +2,7 @@ import http from "node:http";
2
2
  import { compatibilityMode, fixtureSummary, repoShape } from "./doctor.js";
3
3
  import { runLiveAdapterScript } from "./live-adapter-contract.js";
4
4
  import { withExtensionPage } from "../library/actions/extension/platform/cdp.mjs";
5
- import { bridgeCommand, evalSync, selectBridgeStatusEntry, simulatorScreenshot } from "../library/actions/mobile/platform/bridge.mjs";
5
+ import { bridgeCommand, evalSync, MOBILE_BRIDGE_ERROR_CODES, selectBridgeStatusEntry, simulatorScreenshot } from "../library/actions/mobile/platform/bridge.mjs";
6
6
  function sleep(ms) {
7
7
  return new Promise((resolve) => setTimeout(resolve, ms));
8
8
  }
@@ -331,9 +331,23 @@ async function handleMobileHud(payload, context) {
331
331
  return mobileHudSkippedOrThrow(error, { nodeId: hud.nodeId, status: hud.status });
332
332
  }
333
333
  }
334
+ async function hideMobileHudOnTeardown(projectRoot, env = {}) {
335
+ if (process.env.METAMASK_RECIPE_AUTO_HUD === "0" || process.env.METAMASK_RECIPE_AUTO_HUD === "false") return;
336
+ const input = {
337
+ node: { bridge_timeout_ms: 8e3, cdp_timeout_ms: 5e3 },
338
+ context: { nodeId: "teardown", projectRoot, artifactsDir: projectRoot, env }
339
+ };
340
+ try {
341
+ await bridgeCommand(input, ["hide-step"]);
342
+ } catch {
343
+ }
344
+ }
334
345
  function mobileHudSkippedOrThrow(error, extra = {}) {
335
346
  const reason = traceText(error);
336
- if (isMobileHudLifecycleSkip(reason)) {
347
+ const code = isRecord(error) && typeof error.code === "string" ? error.code : void 0;
348
+ if (isMobileHudLifecycleSkip(reason, code)) {
349
+ process.stderr.write(`app.hud skipped (bridge target down or transitioning): ${reason}
350
+ `);
337
351
  return {
338
352
  hud: false,
339
353
  skipped: true,
@@ -344,16 +358,30 @@ function mobileHudSkippedOrThrow(error, extra = {}) {
344
358
  }
345
359
  throw new Error(`app.hud bridge command failed outside a lifecycle/no-target transition: ${reason}`);
346
360
  }
347
- function isMobileHudLifecycleSkip(reason) {
361
+ const HUD_LIFECYCLE_SKIP_CODES = /* @__PURE__ */ new Set([
362
+ MOBILE_BRIDGE_ERROR_CODES.NO_TARGET,
363
+ MOBILE_BRIDGE_ERROR_CODES.CDP_TIMEOUT,
364
+ MOBILE_BRIDGE_ERROR_CODES.WS_CLOSED,
365
+ MOBILE_BRIDGE_ERROR_CODES.METRO_UNREACHABLE
366
+ ]);
367
+ function isMobileHudLifecycleSkip(reason, code) {
368
+ if (code && HUD_LIFECYCLE_SKIP_CODES.has(code)) return true;
369
+ const haystack = reason.toLowerCase();
348
370
  return [
349
371
  "no responding bridge target",
350
- "Pinned android device",
372
+ "pinned android device",
351
373
  "bridge probe failed",
352
- "ECONNREFUSED",
374
+ "econnrefused",
353
375
  "timed out",
354
- "Timed out",
355
- "No React Native bridge target"
356
- ].some((needle) => reason.includes(needle));
376
+ "no react native bridge target",
377
+ // Metro down mid-run: HUD cannot paint, so skip rather than abort (the real
378
+ // action fails on its own). Matches the METRO_UNREACHABLE code branch above.
379
+ "cannot reach metro",
380
+ // Dev-client foreground/reload kills the Hermes page mid-flight: pending
381
+ // CDP calls surface as a message timeout or a closed socket.
382
+ "cdp message timeout",
383
+ "websocket closed"
384
+ ].some((needle) => haystack.includes(needle));
357
385
  }
358
386
  function animatedFlag(payload) {
359
387
  return payload.animated === true ? "--animated" : "--no-animated";
@@ -504,5 +532,7 @@ function targetProbeUrl(platform, targetPort) {
504
532
  }
505
533
  export {
506
534
  createMetaMaskAdapters,
507
- createMetaMaskUiTransport
535
+ createMetaMaskUiTransport,
536
+ hideMobileHudOnTeardown,
537
+ isMobileHudLifecycleSkip
508
538
  };
@@ -6,8 +6,10 @@ 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,
@@ -153,6 +155,82 @@ Artifacts: ${result.artifactManifestPath}`);
153
155
  }
154
156
  return result.status === "pass" ? EXIT.ok : EXIT.runtime;
155
157
  }
158
+ async function handleCallHelp(argv, genericHelp) {
159
+ const { action: shortName, rest } = parseCallArgs(argv.filter((arg) => arg !== "--help" && arg !== "-h"));
160
+ const { options } = parseArgs(rest, "call");
161
+ let adapter;
162
+ let manifest;
163
+ try {
164
+ ({ adapter } = resolveAdapter(options));
165
+ manifest = loadActionManifest(adapter, optionString(options, "actionManifest"));
166
+ } catch {
167
+ process.stdout.write(`${genericHelp}
168
+ `);
169
+ return EXIT.ok;
170
+ }
171
+ const matches = shortName ? fuzzyResolveActions(describeManifestActions(manifest), shortName) : [];
172
+ if (matches.length === 0) {
173
+ process.stdout.write(`${genericHelp}
174
+ `);
175
+ if (shortName) {
176
+ process.stdout.write(
177
+ `
178
+ No action matches "${shortName}" for the ${adapter} adapter.
179
+ Next: mm-harness actions --adapter ${adapter} # list the action vocabulary
180
+ `
181
+ );
182
+ }
183
+ return EXIT.ok;
184
+ }
185
+ for (const entry of matches) process.stdout.write(renderCallActionHelp(entry));
186
+ process.stdout.write(`${genericHelp}
187
+ `);
188
+ return EXIT.ok;
189
+ }
190
+ function renderCallActionHelp(entry) {
191
+ const short = entry.name.split(".").pop() ?? entry.name;
192
+ const schema = isRecord(entry.schema) ? entry.schema : {};
193
+ const properties = isRecord(schema.properties) ? schema.properties : {};
194
+ const required = new Set(
195
+ Array.isArray(schema.required) ? schema.required.filter((r) => typeof r === "string") : []
196
+ );
197
+ const lines = [`mm-harness call ${entry.name} [--arg k=v ...] [flags]`, ""];
198
+ if (entry.description) lines.push(` ${entry.description}`, "");
199
+ const names = Object.keys(properties).sort();
200
+ if (names.length === 0) {
201
+ lines.push(" Fields: (none)");
202
+ } else {
203
+ lines.push(" Fields (pass with --arg <name>=<value>):");
204
+ const width = Math.max(...names.map((name) => name.length));
205
+ for (const name of names) {
206
+ const prop = isRecord(properties[name]) ? properties[name] : {};
207
+ const type = typeof prop.type === "string" ? prop.type : "any";
208
+ const req = required.has(name) ? " (required)" : "";
209
+ const desc = typeof prop.description === "string" ? ` \u2014 ${prop.description}` : "";
210
+ const enumVals = Array.isArray(prop.enum) ? ` [one of: ${prop.enum.join(", ")}]` : "";
211
+ lines.push(` ${name.padEnd(width)} ${type}${req}${desc}${enumVals}`);
212
+ }
213
+ }
214
+ const examples = renderCallExamples(short, entry.examples);
215
+ if (examples.length > 0) {
216
+ lines.push("", " Examples:");
217
+ for (const example of examples) lines.push(` ${example}`);
218
+ }
219
+ return `${lines.join("\n")}
220
+
221
+ `;
222
+ }
223
+ function renderCallExamples(short, examples) {
224
+ if (!Array.isArray(examples)) return [];
225
+ const out = [];
226
+ for (const example of examples.slice(0, 2)) {
227
+ const node = isRecord(example) && isRecord(example.node) ? example.node : void 0;
228
+ if (!node) continue;
229
+ const tokens = Object.entries(node).filter(([key]) => key !== "action" && key !== "intent").map(([key, value]) => `--arg ${key}=${typeof value === "string" ? value : JSON.stringify(value)}`);
230
+ out.push(`mm-harness call ${short} ${tokens.join(" ")}`.trim());
231
+ }
232
+ return out;
233
+ }
156
234
  function parseCallArgs(argv) {
157
235
  const args = {};
158
236
  const rest = [];
@@ -191,5 +269,6 @@ function pickCallExampleAction(names) {
191
269
  return names[0] ?? "command";
192
270
  }
193
271
  export {
194
- handleCall
272
+ handleCall,
273
+ handleCallHelp
195
274
  };
@@ -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
  };
@@ -68,7 +68,15 @@ async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManif
68
68
  recordVideo: useFramedExtensionRecording ? false : recordVideo,
69
69
  ...runtimeOptions.librarySources ? { librarySources: runtimeOptions.librarySources } : {}
70
70
  };
71
- const result = await runner.run(runRequest);
71
+ let result;
72
+ try {
73
+ result = await runner.run(runRequest);
74
+ } finally {
75
+ if (adapter === "mobile") {
76
+ const { hideMobileHudOnTeardown } = await import("../adapters.js");
77
+ await hideMobileHudOnTeardown(projectRoot, recipeRunEnv(adapter, runtimeOptions));
78
+ }
79
+ }
72
80
  await stopRecipeRecording(recording, result);
73
81
  return result;
74
82
  } finally {
@@ -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");
@@ -614,6 +615,13 @@ function hasPassthroughHelp(argv) {
614
615
  if (divider === -1) return false;
615
616
  return argv.slice(divider + 1).some((arg) => arg === "-h" || arg === "--help");
616
617
  }
618
+ function isCallActionHelp(argv) {
619
+ if (argv[0] !== "call") return false;
620
+ if (!argv[1] || argv[1].startsWith("-")) return false;
621
+ const divider = argv.indexOf("--");
622
+ const scope = divider === -1 ? argv : argv.slice(0, divider);
623
+ return scope.includes("--help") || scope.includes("-h");
624
+ }
617
625
  if (rawArgv.length === 0) {
618
626
  process.stdout.write(groupedHelp());
619
627
  process.exit(0);
@@ -621,4 +629,8 @@ if (rawArgv.length === 0) {
621
629
  if (hasPassthroughHelp(rawArgv)) {
622
630
  process.exit(await delegate(rawArgv));
623
631
  }
632
+ if (isCallActionHelp(rawArgv)) {
633
+ const callHelp = REAL.find((command) => command.name === "call")?.helpText ?? "";
634
+ process.exit(await handleCallHelp(rawArgv.slice(1), callHelp));
635
+ }
624
636
  await program.parseAsync(process.argv);
package/dist/runner.js CHANGED
@@ -40,10 +40,10 @@ async function createMetaMaskRunner(adapter, actionManifest, options = {}) {
40
40
  })
41
41
  });
42
42
  const existing = new Set([...core, ...ui].map((entry) => entry.action));
43
- const lifecycle = adapter === "mobile" ? createAppLifecycleAdapters({
43
+ const lifecycle = createAppLifecycleAdapters({
44
44
  actions,
45
45
  targetProvider: { resolveTarget: resolveMetaMaskMobileLifecycleTarget }
46
- }) : [];
46
+ });
47
47
  const custom = createMetaMaskAdapters(adapter).filter(
48
48
  (entry) => declaredActions.has(entry.action) && !existing.has(entry.action) && !lifecycle.some((lifecycleEntry) => lifecycleEntry.action === entry.action)
49
49
  );
@@ -3,9 +3,35 @@ import { execFile, spawn } from 'node:child_process';
3
3
  import { promisify } from 'node:util';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import bridgeErrors from '../../../../adapters/mobile/bridge-runtime/lib/bridge-errors.cjs';
7
+
8
+ const {
9
+ BRIDGE_ERROR_CODES,
10
+ ERROR_CODE_BY_EXIT_CODE,
11
+ classifyBridgeErrorMessage,
12
+ coded,
13
+ parseErrorMarker,
14
+ } = bridgeErrors;
15
+
16
+ // Re-exported so the TS adapter classifies on the same code constants without a
17
+ // second import path into the cjs bridge-runtime.
18
+ export const MOBILE_BRIDGE_ERROR_CODES = BRIDGE_ERROR_CODES;
6
19
 
7
20
  const execFileAsync = promisify(execFile);
8
21
 
22
+ // Recover the typed code a cdp-bridge child reported: the stderr marker is
23
+ // authoritative (it carries the class the bridge classified at the source), the
24
+ // coded exit status is the fallback for output-less failures, and a raw message
25
+ // needle covers a bridge that predates the codes.
26
+ function bridgeChildErrorCode(exitCode, output) {
27
+ return (
28
+ parseErrorMarker(output) ||
29
+ ERROR_CODE_BY_EXIT_CODE[exitCode] ||
30
+ classifyBridgeErrorMessage(output) ||
31
+ null
32
+ );
33
+ }
34
+
9
35
  function sleep(ms) {
10
36
  return new Promise((resolve) => setTimeout(resolve, ms));
11
37
  }
@@ -169,12 +195,12 @@ export function selectBridgeStatusEntry(status, input) {
169
195
  }
170
196
  // Pinned android but no android entry answered — failing fast beats letting the
171
197
  // caller read the iOS entry and report misleading wallet state.
172
- throw new Error(
198
+ throw coded(new Error(
173
199
  `Pinned android device ${adbSerial} has no responding bridge target.\n` +
174
200
  ` Bridge status entries:\n` +
175
201
  entries.map((entry) => ` - ${entry.deviceName ?? '?'} [${entry.platform || 'unknown'}]`).join('\n') +
176
202
  `\n Next: mm-harness launch android # bring the app back to the foreground`,
177
- );
203
+ ), BRIDGE_ERROR_CODES.NO_TARGET);
178
204
  }
179
205
  if (iosSimulator) {
180
206
  const sim = entries.find((entry) => entry.deviceName === iosSimulator);
@@ -239,11 +265,16 @@ export async function bridgeCommand(input, args) {
239
265
  });
240
266
  if (result.timedOut) {
241
267
  const command = ['node', path.relative(input.context.projectRoot, script), ...redactBridgeArgs(args)].join(' ');
242
- throw new Error(`Mobile CDP bridge command timed out after ${result.timeoutMs}ms: ${command}`);
268
+ throw coded(
269
+ new Error(`Mobile CDP bridge command timed out after ${result.timeoutMs}ms: ${command}`),
270
+ BRIDGE_ERROR_CODES.CDP_TIMEOUT,
271
+ );
243
272
  }
244
273
  if (result.exitCode !== 0) {
245
274
  const command = ['node', path.relative(input.context.projectRoot, script), ...redactBridgeArgs(args)].join(' ');
246
- throw new Error(`Mobile CDP bridge command failed: ${command}\n${redactBridgeOutput(result.stderr || result.stdout, sensitiveBridgeArgs(args))}`);
275
+ const code = bridgeChildErrorCode(result.exitCode, result.stderr || result.stdout);
276
+ const error = new Error(`Mobile CDP bridge command failed: ${command}\n${redactBridgeOutput(result.stderr || result.stdout, sensitiveBridgeArgs(args))}`);
277
+ throw code ? coded(error, code) : error;
247
278
  }
248
279
  // For transient commands, stdout of '' or 'undefined' means the state is not yet
249
280
  // settled (e.g. mid-navigation). Return null so callers like waitForRoute can poll
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deeeed/metamask-harness",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "mm-harness": "bin/mm-harness"
@@ -69,4 +69,4 @@
69
69
  "url": "https://github.com/MetaMask/experimental-metamask-harness/issues"
70
70
  },
71
71
  "homepage": "https://github.com/MetaMask/experimental-metamask-harness#readme"
72
- }
72
+ }
File without changes
File without changes