@deeeed/metamask-harness 0.17.5 → 0.19.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 (169) hide show
  1. package/CHANGELOG.md +1059 -0
  2. package/README.md +91 -140
  3. package/adapters/core/inject.sh +1 -6
  4. package/adapters/extension/console-tail.mjs +55 -24
  5. package/adapters/extension/ensure-browser.sh +6 -1
  6. package/adapters/extension/inject.mjs +7 -13
  7. package/adapters/extension/launch-browser.cjs +7 -1
  8. package/adapters/extension/launch-webpack.cjs +83 -0
  9. package/adapters/extension/launch.sh +8 -11
  10. package/adapters/extension/lib/chrome-args.cjs +8 -1
  11. package/adapters/extension/lib/macos-focus.cjs +32 -0
  12. package/adapters/extension/live.sh +12 -21
  13. package/adapters/extension/reattach.sh +1 -0
  14. package/adapters/extension/seed-fixture.sh +4 -12
  15. package/adapters/extension/sidepanel-toggle.sh +4 -1
  16. package/adapters/extension/stamp-runtime-title.cjs +58 -0
  17. package/adapters/extension/start-watch.sh +17 -4
  18. package/adapters/extension/stop-viewers.sh +1 -1
  19. package/adapters/extension/sync-webpack-dist.cjs +107 -0
  20. package/adapters/extension/verify.sh +2 -2
  21. package/adapters/manifest.json +53 -37
  22. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +16 -0
  23. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +1 -1
  24. package/adapters/mobile/bridge-runtime/lib/match-bridge-target.cjs +3 -1
  25. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +2 -3
  26. package/adapters/mobile/cleanup.sh +16 -176
  27. package/adapters/mobile/inject.sh +91 -671
  28. package/adapters/mobile/launch-console-forwarder.cjs +39 -0
  29. package/adapters/mobile/open-device.sh +45 -7
  30. package/adapters/mobile/start-console-forwarder.sh +70 -0
  31. package/adapters/mobile/start-metro.sh +0 -41
  32. package/adapters/mobile/verify.sh +34 -83
  33. package/adapters/mobile/wait-for-bridge.sh +22 -8
  34. package/adapters/shared/harness-source-fingerprint.mjs +14 -12
  35. package/adapters/shared/resolve-slot-ports-core.mjs +17 -6
  36. package/adapters/shared/resolve-slot-ports.sh +17 -5
  37. package/adapters/shared/update-check-worker.mjs +43 -0
  38. package/bin/mm-harness +21 -2
  39. package/dist/adapters/core/surface.js +4 -1
  40. package/dist/adapters/extension/console-capture.js +95 -0
  41. package/dist/adapters/extension/product-config.js +110 -0
  42. package/dist/adapters/extension/runtime-decision.js +20 -2
  43. package/dist/adapters/extension/surface.js +22 -3
  44. package/dist/adapters/harness-freshness.js +49 -0
  45. package/dist/adapters/mobile/perps-env.js +43 -3
  46. package/dist/adapters/mobile/prepare.js +39 -7
  47. package/dist/adapters/mobile/surface.js +5 -2
  48. package/dist/adapters/resolve-slot-ports.js +2 -2
  49. package/dist/adapters/slot-ports.js +13 -16
  50. package/dist/adapters.js +56 -16
  51. package/dist/checkout-lock.js +27 -2
  52. package/dist/cli-color.js +19 -0
  53. package/dist/cli-commands.js +1 -1
  54. package/dist/cli.js +7 -18
  55. package/dist/command-contract.js +462 -0
  56. package/dist/command-journal.js +263 -0
  57. package/dist/commands/call.js +252 -57
  58. package/dist/commands/check.js +10 -4
  59. package/dist/commands/completion-candidates.js +17 -22
  60. package/dist/commands/device-target.js +27 -12
  61. package/dist/commands/doctor.js +106 -25
  62. package/dist/commands/fixtures.js +142 -35
  63. package/dist/commands/last.js +60 -0
  64. package/dist/commands/launch/extension.js +40 -17
  65. package/dist/commands/launch/index.js +207 -63
  66. package/dist/commands/launch/mobile.js +2 -0
  67. package/dist/commands/list-executables.js +187 -29
  68. package/dist/commands/logs.js +8 -6
  69. package/dist/commands/manifest.js +286 -35
  70. package/dist/commands/parse-args.js +68 -4
  71. package/dist/commands/provision.js +10 -4
  72. package/dist/commands/run-engine.js +772 -264
  73. package/dist/commands/run-report.js +12 -3
  74. package/dist/commands/run.js +378 -85
  75. package/dist/commands/shared.js +76 -4
  76. package/dist/commands/status-probe.js +4 -1
  77. package/dist/commands/status.js +2 -1
  78. package/dist/commands/stop.js +7 -2
  79. package/dist/commands/update.js +16 -28
  80. package/dist/completions-cache.js +1 -1
  81. package/dist/doctor.js +57 -10
  82. package/dist/harness.js +47 -137
  83. package/dist/heal-bounds.js +7 -2
  84. package/dist/json-stream.js +57 -0
  85. package/dist/live-adapter-contract.js +138 -32
  86. package/dist/manifest.js +161 -1
  87. package/dist/mm-harness-cli.js +146 -73
  88. package/dist/paths.js +2 -5
  89. package/dist/recipe-security.js +178 -0
  90. package/dist/run-diagnostics.js +261 -0
  91. package/dist/run-recording.js +1 -1
  92. package/dist/runner.js +140 -9
  93. package/docs/CONTRIBUTING.md +136 -0
  94. package/docs/QA.md +184 -0
  95. package/docs/RECIPES.md +122 -0
  96. package/docs/SECURITY.md +88 -0
  97. package/library/README.md +7 -3
  98. package/library/actions/core/perps/_controller.mjs +43 -56
  99. package/library/actions/core/perps/assert_orders.mjs +6 -7
  100. package/library/actions/core/perps/assert_positions.mjs +6 -7
  101. package/library/actions/core/perps/close_orders.mjs +2 -0
  102. package/library/actions/core/perps/close_positions.mjs +2 -0
  103. package/library/actions/core/perps/ensure_orders.mjs +4 -2
  104. package/library/actions/core/perps/ensure_positions.mjs +4 -2
  105. package/library/actions/core/perps/place_order.mjs +7 -3
  106. package/library/actions/core/perps/read_account.mjs +2 -2
  107. package/library/actions/core/perps/read_orders.mjs +2 -1
  108. package/library/actions/core/perps/read_positions.mjs +2 -1
  109. package/library/actions/core/wallet/list_accounts.mjs +95 -0
  110. package/library/actions/extension/perps/assert_orders.mjs +2 -1
  111. package/library/actions/extension/perps/assert_positions.mjs +2 -1
  112. package/library/actions/extension/perps/perps.mjs +43 -14
  113. package/library/actions/extension/platform/cdp.mjs +1 -0
  114. package/library/actions/extension/wallet/list_accounts.mjs +41 -0
  115. package/library/actions/mobile/perps/assert_orders.mjs +2 -1
  116. package/library/actions/mobile/perps/assert_positions.mjs +2 -1
  117. package/library/actions/mobile/perps/perps.mjs +40 -12
  118. package/library/actions/mobile/wallet/list_accounts.mjs +37 -0
  119. package/library/library.json +1 -1
  120. package/library/manifests/core.action-manifest.json +1214 -396
  121. package/library/manifests/extension.action-manifest.json +1635 -728
  122. package/library/manifests/mobile.action-manifest.json +1740 -727
  123. package/library/recipes/app/lifecycle.android-smoke.mobile.recipe.json +63 -81
  124. package/library/recipes/perps/clean-market-testnet.core.recipe.json +44 -0
  125. package/library/recipes/perps/clean-market-testnet.recipe.json +49 -0
  126. package/library/recipes/perps/lifecycle.recipe.json +136 -180
  127. package/library/recipes/perps/order-lifecycle.core.recipe.json +71 -67
  128. package/library/recipes/perps/performance.background-resume.mobile.recipe.json +51 -67
  129. package/library/recipes/perps/performance.cold-start.mobile.recipe.json +51 -67
  130. package/library/recipes/perps/performance.mobile.recipe.json +37 -51
  131. package/library/recipes/perps/performance.warm-start.mobile.recipe.json +44 -59
  132. package/library/recipes/perps/read-markets.core.recipe.json +29 -31
  133. package/library/recipes/perps/smoke.core.recipe.json +29 -32
  134. package/library/recipes/perps/smoke.extension.recipe.json +41 -44
  135. package/library/recipes/perps/smoke.mobile.recipe.json +42 -44
  136. package/library/recipes/perps/trading-lifecycle.core.recipe.json +69 -65
  137. package/library/recipes/runner/action-validation.extension.recipe.json +312 -398
  138. package/library/recipes/runner/action-validation.mobile.recipe.json +316 -402
  139. package/library/recipes/runner/smoke.core.recipe.json +25 -0
  140. package/library/recipes/runner/smoke.extension.recipe.json +23 -24
  141. package/library/recipes/runner/smoke.mobile.recipe.json +23 -24
  142. package/library/recipes/wallet/smoke.extension.recipe.json +40 -0
  143. package/library/recipes/wallet/smoke.mobile.recipe.json +40 -0
  144. package/package.json +12 -5
  145. package/scripts/completions.sh +8 -11
  146. package/scripts/validate-human-outcomes.mjs +169 -0
  147. package/adapters/mobile/overlay/app/dev-tools/AgenticService/AgentStepHud.tsx.patch +0 -185
  148. package/adapters/mobile/overlay/app/dev-tools/AgenticService/AgenticService.ts.patch +0 -1662
  149. package/adapters/mobile/overlay/compat/README.md +0 -28
  150. package/adapters/mobile/overlay/compat/rn81-message-event-source.patch +0 -42
  151. package/adapters/shared/resolve-farmslot-ports-core.mjs +0 -3
  152. package/adapters/shared/resolve-farmslot-ports.mjs +0 -5
  153. package/adapters/shared/resolve-farmslot-ports.sh +0 -7
  154. package/dist/adapters/extension/harness-freshness.js +0 -39
  155. package/dist/commands/flows.js +0 -62
  156. package/docs/ADAPTER-SURFACE.md +0 -119
  157. package/docs/CHEATSHEET.md +0 -61
  158. package/docs/CLI-SPEC.md +0 -1098
  159. package/docs/CODE-MAP.md +0 -62
  160. package/docs/DEBUG-HANDOVER.md +0 -36
  161. package/docs/MENTAL-MODEL.md +0 -295
  162. package/docs/UX-PRINCIPLES.md +0 -64
  163. package/docs/architecture.md +0 -398
  164. package/docs/live-adapter-contract.md +0 -188
  165. package/docs/package-boundaries.md +0 -47
  166. package/docs/perps-flow-catalog.md +0 -235
  167. package/docs/recipe-libraries.md +0 -301
  168. package/docs/runtime-file-conventions.md +0 -36
  169. package/library/flows/perps.flows.json +0 -64
@@ -1,6 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { listConnectedDevices } from "../devices.js";
3
- import { optionString } from "./parse-args.js";
3
+ import { optionString, shellQuoteArg, targetPath } from "./parse-args.js";
4
4
  function normalizeDeviceName(value) {
5
5
  return value.replace(/_/gu, " ").trim();
6
6
  }
@@ -75,6 +75,12 @@ function configuredPinForPlatform(platform) {
75
75
  function oppositePlatform(platform) {
76
76
  return platform === "ios" ? "android" : "ios";
77
77
  }
78
+ function deviceProbe(options) {
79
+ return `mm-harness status --target ${shellQuoteArg(targetPath(options))} --all-devices --json`;
80
+ }
81
+ function deviceRecovery(options, rerun) {
82
+ return rerun ? `${deviceProbe(options)}; then retry: ${rerun}` : deviceProbe(options);
83
+ }
78
84
  function applyDeviceTargeting(command, adapter, options, opts) {
79
85
  const device = optionString(options, "device");
80
86
  const platformPreference = mobilePlatformPreference(options);
@@ -84,7 +90,8 @@ function applyDeviceTargeting(command, adapter, options, opts) {
84
90
  ok: false,
85
91
  code: "DEVICE_WRONG_ADAPTER",
86
92
  message: `--device is only supported on the mobile adapter (the ${adapter} adapter has no device to target).
87
- Drop --device, or run this ${command} inside a metamask-mobile checkout.`
93
+ Drop --device, or run this ${command} inside a metamask-mobile checkout.`,
94
+ userAction: `remove --device; ${adapter} has no device target`
88
95
  };
89
96
  }
90
97
  return { ok: true };
@@ -97,7 +104,8 @@ function applyDeviceTargeting(command, adapter, options, opts) {
97
104
  ok: false,
98
105
  code: "DEVICE_PLATFORM_CONFLICT",
99
106
  message: `--device ${device} resolves to ${matched.platform}, but --platform ${platformPreference} was requested.
100
- To use this device intentionally, drop --platform ${platformPreference} or choose a ${platformPreference} device.`
107
+ To use this device intentionally, drop --platform ${platformPreference} or choose a ${platformPreference} device.`,
108
+ userAction: deviceRecovery(options, opts.rerun)
101
109
  };
102
110
  }
103
111
  if (matched.platform === "android") setAndroidDeviceEnv(matched.id, matched.name);
@@ -126,7 +134,8 @@ function applyDeviceTargeting(command, adapter, options, opts) {
126
134
  ok: false,
127
135
  code: "DEVICE_NAME_AMBIGUOUS",
128
136
  message: `device name '${device}' is ambiguous; use the id:
129
- ` + byName.map((d) => ` - ${d.id}${d.name ? ` (${d.name})` : ""} [${d.state}] ${d.platform}`).join("\n")
137
+ ` + byName.map((d) => ` - ${d.id}${d.name ? ` (${d.name})` : ""} [${d.state}] ${d.platform}`).join("\n"),
138
+ userAction: deviceRecovery(options, opts.rerun)
130
139
  };
131
140
  }
132
141
  return {
@@ -134,7 +143,8 @@ function applyDeviceTargeting(command, adapter, options, opts) {
134
143
  code: "DEVICE_NOT_FOUND",
135
144
  message: `--device ${device} did not match any connected device.
136
145
  ` + (connected2.length > 0 ? ` Connected devices:
137
- ${formatConnectedDevices(connected2)}` : ` No devices are currently connected (adb devices / booted iOS simulators).`)
146
+ ${formatConnectedDevices(connected2)}` : ` No devices are currently connected (adb devices / booted iOS simulators).`),
147
+ userAction: deviceRecovery(options, opts.rerun)
138
148
  };
139
149
  }
140
150
  const connected = listConnectedDevices();
@@ -158,7 +168,8 @@ ${formatConnectedDevices(connected2)}` : ` No devices are currently connected (
158
168
  message: `--platform ${platformPreference} conflicts with the current slot-selected device.
159
169
  Selected devices:
160
170
  ${formatConnectedDevices(selectedTargetable2)}
161
- To use a different connected device intentionally, pass --device <id>.`
171
+ To use a different connected device intentionally, pass --device <id>.`,
172
+ userAction: deviceRecovery(options, opts.rerun)
162
173
  };
163
174
  }
164
175
  if (!configuredPin && oppositePin) {
@@ -167,7 +178,8 @@ ${formatConnectedDevices(selectedTargetable2)}
167
178
  code: "DEVICE_PLATFORM_CONFLICT",
168
179
  message: `--platform ${platformPreference} conflicts with the configured slot target.
169
180
  Configured ${oppositePlatform(platformPreference)} target: ${oppositePin}
170
- To use a different connected device intentionally, pass --device <id>.`
181
+ To use a different connected device intentionally, pass --device <id>.`,
182
+ userAction: deviceRecovery(options, opts.rerun)
171
183
  };
172
184
  }
173
185
  if (configuredPin) {
@@ -178,8 +190,8 @@ ${formatConnectedDevices(selectedTargetable2)}
178
190
  message: `--platform ${platformPreference} matches the configured slot target, but that target is not ready.
179
191
  Configured target: ${configuredPin}
180
192
  Connected devices:
181
- ${connected.length > 0 ? formatConnectedDevices(connected) : " - none"}
182
- Next: run mm-harness launch ${platformPreference}, or pass --device <id> to intentionally use another target.`
193
+ ${connected.length > 0 ? formatConnectedDevices(connected) : " - none"}`,
194
+ userAction: `mm-harness launch ${platformPreference} --target ${shellQuoteArg(targetPath(options))}`
183
195
  };
184
196
  }
185
197
  if (platformTargetable.length === 1) {
@@ -195,7 +207,8 @@ ${connected.length > 0 ? formatConnectedDevices(connected) : " - none"}
195
207
  code: "DEVICE_PLATFORM_NOT_FOUND",
196
208
  message: `--platform ${platformPreference} did not match any ready connected device.
197
209
  ` + (connected.length > 0 ? ` Connected devices:
198
- ${formatConnectedDevices(connected)}` : ` No devices are currently connected (adb devices / booted iOS simulators).`)
210
+ ${formatConnectedDevices(connected)}` : ` No devices are currently connected (adb devices / booted iOS simulators).`),
211
+ userAction: deviceRecovery(options, opts.rerun)
199
212
  };
200
213
  }
201
214
  return {
@@ -205,7 +218,8 @@ ${formatConnectedDevices(connected)}` : ` No devices are currently connected (a
205
218
  Connected devices:
206
219
  ${formatConnectedDevices(connected)}
207
220
  Add --device <id> to disambiguate, e.g.:${platformTargetable.map((d) => `
208
- --device ${d.id}`).join("")}`
221
+ --device ${d.id}`).join("")}`,
222
+ userAction: deviceRecovery(options, opts.rerun)
209
223
  };
210
224
  }
211
225
  const selectedTargetable = targetable.filter(deviceSelected);
@@ -224,7 +238,8 @@ ${formatConnectedDevices(connected)}
224
238
  Connected devices:
225
239
  ${formatConnectedDevices(connected)}
226
240
  ` + (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) => `
227
- --device ${d.id}`).join("")}`)
241
+ --device ${d.id}`).join("")}`),
242
+ userAction: deviceRecovery(options, opts.rerun)
228
243
  };
229
244
  }
230
245
  return { ok: true };
@@ -4,9 +4,10 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { color } from "../cli-color.js";
6
6
  import { createDoctorReport, fixtureSummary, renderRuntimeContext, requiredDoctorCheckSummary } from "../doctor.js";
7
- import { detectAdapter } from "../harness.js";
7
+ import { detectAdapter, resolveRuntimeContextPath } from "../harness.js";
8
8
  import { assertAdapter, runnerDir } from "../paths.js";
9
9
  import { getAdapterSurface } from "../adapters/surface.js";
10
+ import { extensionProductConfigBlock } from "../adapters/extension/product-config.js";
10
11
  import { ensureMobilePerpsEnvironment, mobilePerpsEnvironment } from "../adapters/mobile/perps-env.js";
11
12
  import { loadActionManifest, validateManifest } from "../manifest.js";
12
13
  import { ensureOverlay, newHealState, recipeRunning } from "../heal-bounds.js";
@@ -21,12 +22,13 @@ import {
21
22
  import { farmslotReadyIndicator } from "./farmslot-ready.js";
22
23
  import { ensureRuntimeContext } from "../runtime-context.js";
23
24
  import { acquireCheckoutLock } from "../checkout-lock.js";
24
- import { ADAPTER_DETECT_NEXT, EXIT, usageOut } from "./shared.js";
25
+ import { ADAPTER_DETECT_NEXT, checkoutBusyOut, EXIT, usageOut } from "./shared.js";
25
26
  import {
26
27
  actionManifestPathOption,
27
28
  applyRuntimeDirOption,
28
29
  optionFlag,
29
30
  optionString,
31
+ shellQuote,
30
32
  targetPath
31
33
  } from "./parse-args.js";
32
34
  function applyDoctorRuntimePorts(options) {
@@ -56,6 +58,14 @@ async function handleDoctor({ options }) {
56
58
  return usageOut(json, "doctor", `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
57
59
  }
58
60
  assertAdapter(adapter);
61
+ if (!fs.existsSync(target)) {
62
+ return usageOut(
63
+ json,
64
+ "doctor",
65
+ `target does not exist: ${target}`,
66
+ "pass --target <metamask-checkout> pointing to an existing checkout"
67
+ );
68
+ }
59
69
  if (printReady && json) {
60
70
  return usageOut(
61
71
  json,
@@ -72,12 +82,12 @@ async function handleDoctor({ options }) {
72
82
  surface.resolveSlotPorts(target);
73
83
  const dtResult = applyDeviceTargeting("doctor", adapter, options, { gate: false, rerun: "" });
74
84
  if ("code" in dtResult) {
75
- return usageOut(json, "doctor", dtResult.message, "mm-harness doctor --fix --adapter mobile --device <adb-serial|simulator-udid>");
85
+ return usageOut(json, "doctor", dtResult.message, dtResult.userAction);
76
86
  }
77
87
  applyDoctorRuntimePorts(options);
78
88
  const lock = acquireCheckoutLock(target, "doctor-fix");
79
89
  if ("message" in lock) {
80
- return usageOut(json, "doctor", lock.message, `wait for the current owner, or inspect ${lock.path} if its process has exited`);
90
+ return checkoutBusyOut(json, "doctor", lock.message, lock.path);
81
91
  }
82
92
  let fixed;
83
93
  let failed;
@@ -87,11 +97,36 @@ async function handleDoctor({ options }) {
87
97
  lock.release();
88
98
  }
89
99
  const result2 = createDoctorReport(adapter, target, manifestValidation, actionManifestPath);
90
- const nextActions = doctorFixNextActions(failed, result2.fixture);
100
+ let runtime2;
101
+ let runtimeProbeError2;
102
+ try {
103
+ runtime2 = await surface.runtimeStatus(target);
104
+ } catch (error2) {
105
+ runtimeProbeError2 = error2 instanceof Error ? error2.message : String(error2);
106
+ }
107
+ const ready = runtime2?.decision === "ready";
108
+ if (adapter === "extension" && runtime2?.reasonCode === "product-config-required") {
109
+ failed.push("extension-product-config");
110
+ }
111
+ const nextActions = doctorFixNextActions(failed, result2.fixture, adapter, target);
112
+ if (failed.length === 0 && !ready && runtime2?.nextAction) nextActions.push(runtime2.nextAction);
91
113
  const status = result2.status === "pass" && failed.length === 0 ? "pass" : "fail";
92
- if (json) console.log(JSON.stringify({ ...result2, status, fixed, failed, nextActions }, null, 2));
114
+ const retryDoctor = `mm-harness doctor --fix --adapter ${adapter} --target ${shellQuote(target)} --json`;
115
+ const fixUserAction = failed.includes("wallet-fixture") ? `choose one wallet fixture source from nextActions, run it, then retry: ${retryDoctor}` : nextActions[0] ?? retryDoctor;
116
+ const error = status === "fail" ? {
117
+ code: "DOCTOR_FIX_INCOMPLETE",
118
+ message: "doctor --fix could not make every required check pass",
119
+ userAction: fixUserAction
120
+ } : void 0;
121
+ if (json) console.log(JSON.stringify({ ...result2, status, fixed, failed, ready, runtime: runtime2, ...runtimeProbeError2 ? { runtimeProbeError: runtimeProbeError2 } : {}, nextActions, ...error ? { error } : {} }, null, 2));
93
122
  else {
94
- console.log(`${status} ${adapter} ${result2.compatibilityMode} manifest=${actionManifestPath} fixed=[${fixed.join(",")}] failed=[${failed.join(",")}]`);
123
+ console.log(`${status} ${adapter} ${result2.compatibilityMode} ready=${ready} manifest=${actionManifestPath} fixed=[${fixed.join(",")}] failed=[${failed.join(",")}]`);
124
+ if (runtime2) {
125
+ console.log(`runtime: decision=${runtime2.decision}${runtime2.reasonCode ? ` (${runtime2.reasonCode})` : ""}`);
126
+ for (const reason of runtime2.reasons) console.log(` ${reason}`);
127
+ } else if (runtimeProbeError2) {
128
+ console.log(`runtime probe failed: ${runtimeProbeError2}`);
129
+ }
95
130
  for (const action of nextActions) console.error(` Next: ${action}`);
96
131
  }
97
132
  return status === "pass" ? 0 : 1;
@@ -104,8 +139,9 @@ async function handleDoctor({ options }) {
104
139
  surface.resolveSlotPorts(target);
105
140
  const dtResult = applyDeviceTargeting("doctor", adapter, options, { gate: false, rerun: "" });
106
141
  if ("code" in dtResult) {
107
- if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "doctor", adapter, target, error: { code: dtResult.code, message: dtResult.message } }, null, 2));
108
- else console.error(`\u2717 doctor: ${dtResult.message}`);
142
+ if (json) console.log(JSON.stringify({ schemaVersion: 1, command: "doctor", adapter, target, error: { code: dtResult.code, message: dtResult.message, userAction: dtResult.userAction } }, null, 2));
143
+ else console.error(`\u2717 doctor: ${dtResult.message}
144
+ Next: ${dtResult.userAction}`);
109
145
  return EXIT.usage;
110
146
  }
111
147
  applyDoctorRuntimePorts(options);
@@ -123,7 +159,7 @@ async function handleDoctor({ options }) {
123
159
  requiredChecks
124
160
  };
125
161
  const orphanMetros = adapter === "mobile" ? detectOrphanMetros(target, process.env.WATCHER_PORT) : [];
126
- const capture = captureHelperHealth();
162
+ const capture = captureHealth(adapter);
127
163
  const deviceView = adapter === "mobile" ? mobileDeviceView(allDevices) : null;
128
164
  const liveView = deviceView ? await mobileDeviceLiveView(target, deviceView) : null;
129
165
  const devices = liveView?.devicesWithLive ?? deviceView?.devices ?? [];
@@ -142,11 +178,17 @@ async function handleDoctor({ options }) {
142
178
  printReady
143
179
  );
144
180
  }
145
- if (json) console.log(JSON.stringify({ ...doctorResult, ready: runtime?.decision === "ready", runtime, orphanMetros, capture, devices, additionalReachableDevices }, null, 2));
181
+ const doctorUserAction = `mm-harness doctor --fix --adapter ${adapter} --target ${shellQuote(target)} --json`;
182
+ const doctorError = doctorResult.status === "fail" ? { code: "DOCTOR_CHECKS_FAILED", message: "one or more required doctor checks failed", userAction: doctorUserAction } : void 0;
183
+ const next = doctorError?.userAction ?? (runtime?.decision === "ready" ? void 0 : runtime?.nextAction);
184
+ if (json) console.log(JSON.stringify({ ...doctorResult, ready: runtime?.decision === "ready", runtime, orphanMetros, capture, devices, additionalReachableDevices, ...next ? { next } : {}, ...doctorError ? { error: doctorError } : {} }, null, 2));
146
185
  else {
147
186
  const out = (style, text) => color(style, text, { stream: process.stdout });
148
187
  const stateStyle = (value, good) => value === good ? "ok" : "warn";
149
188
  console.log(`${out(doctorResult.status === "pass" ? "ok" : "err", doctorResult.status)} ${out("bold", adapter)} ${doctorResult.compatibilityMode} ${out("dim", `manifest=${actionManifestPath}`)}`);
189
+ console.log(
190
+ `${out("label", "harness:")} ${doctorResult.runner.packageName}@${doctorResult.runner.version} ${out("accent", doctorResult.runner.installKind)} ${out("dim", `executable=${doctorResult.runner.executablePath}`)}`
191
+ );
150
192
  if (runtime) {
151
193
  const provisionedDepsPending = runtime.reasonCode === "app-installed-deps-pending";
152
194
  const decisionStyle = provisionedDepsPending ? "info" : runtime.decision === "ready" ? "ok" : runtime.decision === "blocked" ? "err" : "warn";
@@ -173,18 +215,13 @@ async function handleDoctor({ options }) {
173
215
  );
174
216
  console.log(` ${out("dim", "Next: mm-harness stop # reaps every bundler this checkout leaked")}`);
175
217
  }
176
- if (capture) {
177
- console.log(`${out("label", "capture:")} ${out(capture.status === "pass" ? "ok" : "warn", capture.status)} ${out("dim", "(capture-helper: screenshots + --record video)")}`);
178
- if (capture.status !== "pass") {
179
- if (capture.failing.length > 0) console.log(` ${out("dim", `failing: ${capture.failing.join(", ")}`)}`);
180
- console.log(` ${out("dim", "Next: grant Screen Recording (System Settings \u2192 Privacy & Security \u2192 Screen Recording), or run: capture-helper doctor --open-permissions")}`);
181
- }
182
- }
218
+ if (capture) renderCaptureHealth(capture, out);
183
219
  if (adapter === "mobile") {
184
220
  if (deviceView) renderMobileDeviceList(deviceView, out);
185
221
  if (additionalReachableDevices.length > 0) renderAdditionalReachableDevices(additionalReachableDevices, out);
186
222
  if (liveView) renderMobileLiveBlock(deviceView?.devices ?? [], liveView.liveMap, out);
187
223
  }
224
+ if (next) console.log(` ${out("dim", `Next: ${next}`)}`);
188
225
  }
189
226
  return doctorResult.status === "pass" ? 0 : 1;
190
227
  }
@@ -207,15 +244,25 @@ function doctorRuntimeCheck(runtime, error, expectLive) {
207
244
  detail: runtime?.reasons.join(" | ") || "Runtime readiness probe returned no result."
208
245
  };
209
246
  }
210
- function doctorFixNextActions(failed, fixture) {
247
+ function doctorFixNextActions(failed, fixture, adapter, target) {
211
248
  const actions = [];
249
+ const executable = process.env.MM_HARNESS_EXECUTABLE ? shellQuote(process.env.MM_HARNESS_EXECUTABLE) : "mm-harness";
250
+ const scope = `--adapter ${adapter} --target ${shellQuote(target)}`;
212
251
  if (failed.includes("wallet-fixture")) {
213
252
  const force = fixture.status === "invalid" || fixture.status === "incomplete" ? " --force" : "";
214
- actions.push(`1) mm-harness fixtures init --from <path>${force} # existing team/test fixture`);
215
- actions.push(`2) mm-harness fixtures init --dev${force} # disposable public test wallet; never real funds`);
253
+ actions.push(`1) ${executable} fixtures init --from <path>${force} ${scope} # existing team/test fixture`);
254
+ actions.push(`2) ${executable} fixtures init --dev${force} ${scope} # disposable public test wallet; never real funds`);
255
+ }
256
+ if (failed.includes("runtime-context")) {
257
+ actions.push(
258
+ `inspect ${shellQuote(resolveRuntimeContextPath(target))}, then retry ${executable} doctor --fix ${scope}`
259
+ );
260
+ }
261
+ if (failed.includes("overlay")) actions.push(`${executable} install ${scope}`);
262
+ if (adapter === "extension" && failed.includes("extension-product-config")) {
263
+ const block = extensionProductConfigBlock(target);
264
+ if (block) actions.push(block.userAction);
216
265
  }
217
- if (failed.includes("runtime-context")) actions.push("inspect temp/recipe/runtime/agentic-runtime.json, then retry mm-harness doctor --fix");
218
- if (failed.includes("overlay")) actions.push("mm-harness install --target .");
219
266
  return actions;
220
267
  }
221
268
  function emitExpectLiveVerdict(adapter, target, runtime, verdict) {
@@ -223,7 +270,7 @@ function emitExpectLiveVerdict(adapter, target, runtime, verdict) {
223
270
  const detail = runtime ? `${runtime.decision}${runtime.reasonCode ? ` (${runtime.reasonCode})` : ""}` : "runtime probe unavailable";
224
271
  console.error(`${out("err", verdict)} ${out("bold", adapter)} ${detail} ${out("dim", target)}`);
225
272
  for (const reason of runtime?.reasons ?? []) console.error(` ${out("dim", reason)}`);
226
- console.error(` Next: ${getAdapterSurface(adapter).hints.relaunch}`);
273
+ console.error(` Next: ${runtime?.nextAction ?? getAdapterSurface(adapter).hints.runtimeProbeRecovery(target)}`);
227
274
  }
228
275
  function emitExpectLive(adapter, target, runtime, result, orphanMetros, capture, devices, additionalReachableDevices, json, printReady) {
229
276
  const live = runtime?.decision === "ready";
@@ -241,7 +288,9 @@ function emitExpectLive(adapter, target, runtime, result, orphanMetros, capture,
241
288
  return EXIT.runtime;
242
289
  }
243
290
  if (json) {
244
- console.log(JSON.stringify({ ...result, ready: live, runtime, orphanMetros, capture, devices, additionalReachableDevices }, null, 2));
291
+ const userAction = runtime?.nextAction ?? getAdapterSurface(adapter).hints.runtimeProbeRecovery(target);
292
+ const error = live ? void 0 : { code: "RUNTIME_NOT_LIVE", message: `${adapter} runtime is not live`, userAction };
293
+ console.log(JSON.stringify({ ...result, ready: live, runtime, orphanMetros, capture, devices, additionalReachableDevices, ...error ? { error } : {} }, null, 2));
245
294
  return live ? EXIT.ok : EXIT.runtime;
246
295
  }
247
296
  const out = (style, text) => color(style, text, { stream: process.stdout });
@@ -252,6 +301,25 @@ function emitExpectLive(adapter, target, runtime, result, orphanMetros, capture,
252
301
  emitExpectLiveVerdict(adapter, target, runtime, "not-live");
253
302
  return EXIT.runtime;
254
303
  }
304
+ function captureHealth(adapter) {
305
+ if (adapter === "core") return null;
306
+ const fallback = adapter === "extension" ? ["cdp"] : ["simctl", "adb"];
307
+ const helper = captureHelperHealth();
308
+ if (!helper) {
309
+ return {
310
+ status: "fallback",
311
+ failing: [],
312
+ screenshots: { available: true, providers: fallback },
313
+ recording: { available: false, provider: null }
314
+ };
315
+ }
316
+ return {
317
+ status: helper.status,
318
+ failing: helper.failing,
319
+ screenshots: { available: true, providers: ["capture-helper", ...fallback] },
320
+ recording: { available: helper.status === "pass", provider: helper.status === "pass" ? "capture-helper" : null }
321
+ };
322
+ }
255
323
  function captureHelperHealth() {
256
324
  if (process.platform !== "darwin") return null;
257
325
  const bin = process.env.CAPTURE_HELPER_PATH || "capture-helper";
@@ -264,6 +332,18 @@ function captureHelperHealth() {
264
332
  return null;
265
333
  }
266
334
  }
335
+ function renderCaptureHealth(capture, out) {
336
+ const video = capture.recording.available ? "capture-helper" : "unavailable";
337
+ console.log(
338
+ `${out("label", "capture:")} ${out(capture.status === "pass" ? "ok" : "warn", capture.status)} ${out("dim", `(screenshots: ${capture.screenshots.providers.join(" \u2192 ")}; video: ${video})`)}`
339
+ );
340
+ if (capture.status === "warn") {
341
+ if (capture.failing.length > 0) console.log(` ${out("dim", `failing: ${capture.failing.join(", ")}`)}`);
342
+ console.log(` ${out("dim", "Next: grant Screen Recording (System Settings \u2192 Privacy & Security \u2192 Screen Recording), or run: capture-helper doctor --open-permissions")}`);
343
+ } else if (capture.status === "fallback") {
344
+ console.log(` ${out("dim", "Next: screenshots work without capture-helper; install/configure capture-helper only when recipe video is required")}`);
345
+ }
346
+ }
267
347
  function detectOrphanMetros(target, livePort) {
268
348
  try {
269
349
  const script = path.join(runnerDir, "adapters/shared/reap-checkout-metros.sh");
@@ -381,6 +461,7 @@ function readTextSnapshot(file) {
381
461
  }
382
462
  }
383
463
  export {
464
+ doctorFixNextActions,
384
465
  ensureExtensionPerpsBuildFlag,
385
466
  handleDoctor
386
467
  };
@@ -5,25 +5,75 @@ import { runnerDir, walletFixturePath } from "../paths.js";
5
5
  import { getAdapterSurface } from "../adapters/surface.js";
6
6
  import { fixtureFileSummary, fixtureSummary } from "../doctor.js";
7
7
  import { acquireCheckoutLock } from "../checkout-lock.js";
8
+ import { colorHumanMessage } from "../cli-color.js";
8
9
  import { applyDeviceTargeting } from "./device-target.js";
9
- import { ADAPTER_DETECT_NEXT, EXIT, flag, parseFlags, resolveAdapter, scriptOverride, spawnScript, spawnScriptStreaming, str, targetOf, usageOut } from "./shared.js";
10
+ import { ADAPTER_DETECT_NEXT, checkoutBusyOut, EXIT, flag, parseFlags, resolveAdapter, scriptOverride, spawnScript, spawnScriptStreaming, str, targetOf, usageOut } from "./shared.js";
10
11
  const FIXTURES_BOOLEANS = /* @__PURE__ */ new Set(["dev", "force", "json"]);
11
- const MOBILE_FIXTURE_TIMEOUT_MS = 12e4;
12
- async function handleFixtures(argv, deps) {
13
- const { options } = parseFlags(argv, FIXTURES_BOOLEANS);
12
+ const FIXTURE_STREAM_TIMEOUT_MS = 12e4;
13
+ async function handleFixtures(argv) {
14
+ const { positional, options } = parseFlags(argv, FIXTURES_BOOLEANS);
15
+ if (!positional[0]) return fixturesStatus(options);
14
16
  const json = flag(options, "json");
15
17
  const target = targetOf(options);
16
- if (!fs.existsSync(target)) return handleFixturesLocked(argv, deps);
18
+ if (!fs.existsSync(target)) return handleFixturesLocked(argv);
17
19
  const lock = acquireCheckoutLock(target, "fixtures");
18
20
  if ("message" in lock) {
19
- return usageOut(json, "fixtures", lock.message, `wait for the current owner, or inspect ${lock.path} if its process has exited`);
21
+ return checkoutBusyOut(json, "fixtures", lock.message, lock.path);
20
22
  }
21
23
  try {
22
- return await handleFixturesLocked(argv, deps);
24
+ return await handleFixturesLocked(argv);
23
25
  } finally {
24
26
  lock.release();
25
27
  }
26
28
  }
29
+ function fixturesStatus(options) {
30
+ const json = flag(options, "json");
31
+ const statusOptions = /* @__PURE__ */ new Set(["json", "target", "adapter", "platform", "device"]);
32
+ const actionOption = Object.keys(options).find((key) => !statusOptions.has(key));
33
+ if (actionOption) {
34
+ return usageOut(
35
+ json,
36
+ "fixtures",
37
+ `--${actionOption.replace(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`)} requires a fixture action.`,
38
+ "mm-harness fixtures --help"
39
+ );
40
+ }
41
+ const target = targetOf(options);
42
+ const adapter = resolveAdapter(options, target);
43
+ if (!adapter) {
44
+ return usageOut(json, "fixtures", `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
45
+ }
46
+ const canonical = walletFixturePath(target);
47
+ const summary = fixtureSummary(target, adapter);
48
+ const fixture = { ...summary, path: canonical };
49
+ const ready = summary.status === "ready";
50
+ const nextCommand = ready ? "mm-harness fixtures set" : "mm-harness fixtures init --from <path> # or: mm-harness fixtures init --dev";
51
+ const operations = adapter === "extension" ? ["init", "sync", "set", "generate", "finalize"] : ["init", "sync", "set"];
52
+ if (json) {
53
+ console.log(JSON.stringify({
54
+ schemaVersion: 1,
55
+ command: "fixtures",
56
+ action: "status",
57
+ adapter,
58
+ target,
59
+ status: "pass",
60
+ fixture,
61
+ operations,
62
+ nextCommand,
63
+ exitCode: EXIT.ok
64
+ }, null, 2));
65
+ return EXIT.ok;
66
+ }
67
+ console.log(`wallet fixture (${adapter}): ${String(summary.status).toUpperCase()}`);
68
+ console.log(`path: ${canonical}`);
69
+ if (typeof summary.accountCount === "number") {
70
+ const password = summary.hasPassword === true ? "present" : "missing";
71
+ console.log(`accounts: ${summary.accountCount} \xB7 password: ${password}`);
72
+ }
73
+ console.log(`operations: ${operations.join(", ")}`);
74
+ console.log(`Next: ${nextCommand}`);
75
+ return EXIT.ok;
76
+ }
27
77
  function ensureFixturePublicationParent(target) {
28
78
  const repoRoot = fs.realpathSync(target);
29
79
  const parent = path.dirname(walletFixturePath(repoRoot));
@@ -49,13 +99,21 @@ function ensureFixturePublicationParent(target) {
49
99
  }
50
100
  return fs.realpathSync(parent) === parent ? null : `wallet fixture runtime path escapes the checkout: ${parent}`;
51
101
  }
52
- async function handleFixturesLocked(argv, deps) {
102
+ async function handleFixturesLocked(argv) {
53
103
  const { positional, options } = parseFlags(argv, FIXTURES_BOOLEANS);
54
104
  const json = flag(options, "json");
55
105
  const sub = positional[0];
56
106
  if (sub !== "init" && sub !== "sync" && sub !== "set" && sub !== "generate" && sub !== "finalize") {
57
107
  return usageOut(json, "fixtures", "fixtures requires a subcommand: mm-harness fixtures <init|sync|set|generate|finalize>", "mm-harness fixtures init --from <path> or mm-harness fixtures init --dev");
58
108
  }
109
+ if (sub !== "init" && (flag(options, "dev") || flag(options, "force") || str(options, "from"))) {
110
+ return usageOut(
111
+ json,
112
+ "fixtures",
113
+ "--dev, --from, and --force belong to fixtures init; they do not apply a fixture.",
114
+ "mm-harness fixtures init --dev && mm-harness fixtures set"
115
+ );
116
+ }
59
117
  const target = targetOf(options);
60
118
  const adapter = resolveAdapter(options, target);
61
119
  if (!adapter) {
@@ -65,7 +123,7 @@ async function handleFixturesLocked(argv, deps) {
65
123
  surface.resolveSlotPorts(target);
66
124
  const dtResult = applyDeviceTargeting("fixtures", adapter, options, { gate: false, rerun: "" });
67
125
  if ("code" in dtResult) {
68
- return usageOut(json, "fixtures", dtResult.message, "mm-harness fixtures set --adapter mobile --device <adb-serial|simulator-udid>");
126
+ return usageOut(json, "fixtures", dtResult.message, dtResult.userAction);
69
127
  }
70
128
  if (sub === "generate") return fixturesGenerate(adapter, target, options, json);
71
129
  if (sub === "finalize") return fixturesFinalize(adapter, target, options, json);
@@ -96,6 +154,17 @@ async function handleFixturesLocked(argv, deps) {
96
154
  return exitCode;
97
155
  }
98
156
  const fixturePath = path.resolve(str(options, "fixture") ?? process.env.RECIPE_WALLET_FIXTURE ?? canonicalFixture);
157
+ if (!fs.existsSync(fixturePath)) {
158
+ return usageOut(
159
+ json,
160
+ "fixtures",
161
+ `no wallet fixture at ${fixturePath}.`,
162
+ "mm-harness fixtures init --dev && mm-harness fixtures set"
163
+ );
164
+ }
165
+ if (adapter === "extension") {
166
+ return applyExtensionFixture(target, fixturePath, canonicalFixture, json);
167
+ }
99
168
  process.stderr.write(`\u2192 fixtures set ${adapter} \u2014 connecting bridge + applying wallet fixture (can take ~30s)\u2026
100
169
  `);
101
170
  let status;
@@ -105,8 +174,8 @@ async function handleFixturesLocked(argv, deps) {
105
174
  const previousAppRoot = process.env.APP_ROOT;
106
175
  process.env.APP_ROOT = target;
107
176
  try {
108
- const configuredTimeout = Number(process.env.MM_HARNESS_FIXTURES_SET_TIMEOUT_MS ?? MOBILE_FIXTURE_TIMEOUT_MS);
109
- const timeoutMs = Number.isFinite(configuredTimeout) && configuredTimeout > 0 ? configuredTimeout : MOBILE_FIXTURE_TIMEOUT_MS;
177
+ const configuredTimeout = Number(process.env.MM_HARNESS_FIXTURES_SET_TIMEOUT_MS ?? FIXTURE_STREAM_TIMEOUT_MS);
178
+ const timeoutMs = Number.isFinite(configuredTimeout) && configuredTimeout > 0 ? configuredTimeout : FIXTURE_STREAM_TIMEOUT_MS;
110
179
  mobileSetupResult = await spawnScriptStreaming(
111
180
  setupWalletSh,
112
181
  ["--fixture", fixturePath],
@@ -128,29 +197,8 @@ async function handleFixturesLocked(argv, deps) {
128
197
  if (!json && status === "fail") {
129
198
  console.error(` Next: ${retryHint}`);
130
199
  }
131
- } else {
132
- if (!fs.existsSync(fixturePath)) {
133
- return usageOut(
134
- json,
135
- "fixtures",
136
- `no wallet fixture at ${fixturePath}.`,
137
- "create it (or pass --fixture <path>), then re-run: mm-harness fixtures set"
138
- );
139
- }
140
- const previousFixtureEnv = process.env.RECIPE_WALLET_FIXTURE;
141
- process.env.RECIPE_WALLET_FIXTURE = fixturePath;
142
- try {
143
- const result = await deps.runOneNode("extension", "metamask.wallet.setup", {}, target, str(options, "actionManifest"));
144
- status = result.status;
145
- } finally {
146
- if (previousFixtureEnv === void 0) delete process.env.RECIPE_WALLET_FIXTURE;
147
- else process.env.RECIPE_WALLET_FIXTURE = previousFixtureEnv;
148
- }
149
- if (!json && status === "fail") {
150
- console.error(` Next: ${retryHint}`);
151
- }
152
200
  }
153
- const teaching = `Wallet fixture applied. Want different accounts? Edit: ${canonicalFixture}`;
201
+ const teaching = status === "pass" ? `Wallet fixture applied. Want different accounts? Edit: ${canonicalFixture}` : "Wallet fixture was not applied.";
154
202
  if (json) {
155
203
  console.log(
156
204
  JSON.stringify(
@@ -174,11 +222,68 @@ async function handleFixturesLocked(argv, deps) {
174
222
  2
175
223
  )
176
224
  );
177
- } else {
225
+ } else if (status === "pass") {
178
226
  console.error(teaching);
179
227
  }
180
228
  return status === "pass" ? EXIT.ok : EXIT.runtime;
181
229
  }
230
+ async function applyExtensionFixture(target, fixturePath, canonicalFixture, json) {
231
+ const distManifest = path.join(target, "dist/chrome/manifest.json");
232
+ const userAction = `mm-harness launch --build --verify --target ${JSON.stringify(target)}`;
233
+ if (!fs.existsSync(distManifest)) {
234
+ const message2 = `Extension fixture needs an existing dist/chrome build; no manifest was found at ${distManifest}.`;
235
+ if (json) {
236
+ console.log(JSON.stringify({
237
+ schemaVersion: 1,
238
+ command: "fixtures",
239
+ action: "set",
240
+ adapter: "extension",
241
+ fixture: fixturePath,
242
+ canonicalFixture,
243
+ status: "fail",
244
+ exitCode: EXIT.runtime,
245
+ error: { code: "EXTENSION_FIXTURE_BUILD_REQUIRED", message: message2, userAction }
246
+ }, null, 2));
247
+ } else {
248
+ console.error(`\u2717 mm-harness fixtures set: ${message2}
249
+ Next: ${userAction}`);
250
+ }
251
+ return EXIT.runtime;
252
+ }
253
+ process.stderr.write(`${colorHumanMessage("\u2192 fixtures set extension \u2014 resetting and seeding the slot-owned profile from the existing build\u2026")}
254
+ `);
255
+ const liveScript = path.join(runnerDir, "adapters/extension/live.sh");
256
+ const args = ["--target", target, "--launch-existing-dist"];
257
+ if (process.env.CDP_PORT) args.push("--cdp-port", process.env.CDP_PORT);
258
+ const result = await spawnScriptStreaming(liveScript, args, target, {
259
+ env: { RECIPE_WALLET_FIXTURE: fixturePath },
260
+ timeoutMs: FIXTURE_STREAM_TIMEOUT_MS
261
+ });
262
+ const status = result.status === 0 ? "pass" : "fail";
263
+ const exitCode = status === "pass" ? EXIT.ok : EXIT.runtime;
264
+ const message = status === "pass" ? `Wallet fixture applied and validated. Want different accounts? Edit: ${canonicalFixture}` : "Extension wallet fixture could not be applied and validated.";
265
+ if (json) {
266
+ console.log(JSON.stringify({
267
+ schemaVersion: 1,
268
+ command: "fixtures",
269
+ action: "set",
270
+ adapter: "extension",
271
+ fixture: fixturePath,
272
+ canonicalFixture,
273
+ status,
274
+ exitCode,
275
+ profileReset: status === "pass",
276
+ message,
277
+ error: status === "fail" ? { code: "EXTENSION_FIXTURE_APPLY_FAILED", message, userAction } : null
278
+ }, null, 2));
279
+ } else if (status === "pass") {
280
+ console.error(message);
281
+ } else {
282
+ console.error(`\u2717 mm-harness fixtures set: ${message}
283
+ Next: ${userAction}`);
284
+ }
285
+ return exitCode;
286
+ }
182
287
  const EXT_FIXTURE_LEAF = "adapters/extension/wallet-fixture-state.cjs";
183
288
  function emitLeafMissing(json, action, fields, leaf) {
184
289
  const message = `leaf could not start: ${path.basename(leaf)} (ENOENT).`;
@@ -298,7 +403,9 @@ async function fixturesFinalize(adapter, target, options, json) {
298
403
  if (outPath) leafArgs.push("--out", outPath);
299
404
  process.stderr.write(`\u2192 fixtures finalize extension \u2014 seeding account labels via CDP on port ${cdpPort} (can take ~30s)\u2026
300
405
  `);
301
- const result = await spawnScriptStreaming(process.execPath, leafArgs, target);
406
+ const result = await spawnScriptStreaming(process.execPath, leafArgs, target, {
407
+ timeoutMs: FIXTURE_STREAM_TIMEOUT_MS
408
+ });
302
409
  const status = result.status === 0 ? "pass" : "fail";
303
410
  const exitCode = result.status === 0 ? EXIT.ok : EXIT.runtime;
304
411
  const retry = `mm-harness fixtures finalize --fixture ${fixturePath} --state ${statePath} --cdp-port ${cdpPort} --extension-dir ${extensionDirPath}`;