@deeeed/metamask-harness 0.17.5 → 0.18.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 (132) hide show
  1. package/CHANGELOG.md +872 -0
  2. package/README.md +94 -140
  3. package/adapters/extension/console-tail.mjs +55 -24
  4. package/adapters/extension/ensure-browser.sh +6 -1
  5. package/adapters/extension/inject.mjs +6 -9
  6. package/adapters/extension/launch-browser.cjs +7 -1
  7. package/adapters/extension/launch-webpack.cjs +83 -0
  8. package/adapters/extension/launch.sh +8 -11
  9. package/adapters/extension/lib/chrome-args.cjs +8 -1
  10. package/adapters/extension/lib/macos-focus.cjs +32 -0
  11. package/adapters/extension/live.sh +12 -21
  12. package/adapters/extension/reattach.sh +1 -0
  13. package/adapters/extension/seed-fixture.sh +4 -12
  14. package/adapters/extension/sidepanel-toggle.sh +4 -1
  15. package/adapters/extension/stamp-runtime-title.cjs +58 -0
  16. package/adapters/extension/start-watch.sh +17 -4
  17. package/adapters/extension/stop-viewers.sh +1 -1
  18. package/adapters/extension/sync-webpack-dist.cjs +107 -0
  19. package/adapters/extension/verify.sh +1 -1
  20. package/adapters/manifest.json +53 -37
  21. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +16 -0
  22. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +1 -1
  23. package/adapters/mobile/bridge-runtime/lib/match-bridge-target.cjs +3 -1
  24. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +2 -3
  25. package/adapters/mobile/cleanup.sh +16 -176
  26. package/adapters/mobile/inject.sh +90 -671
  27. package/adapters/mobile/launch-console-forwarder.cjs +39 -0
  28. package/adapters/mobile/open-device.sh +45 -7
  29. package/adapters/mobile/start-console-forwarder.sh +70 -0
  30. package/adapters/mobile/start-metro.sh +0 -41
  31. package/adapters/mobile/verify.sh +19 -64
  32. package/adapters/mobile/wait-for-bridge.sh +22 -8
  33. package/adapters/shared/resolve-slot-ports-core.mjs +17 -6
  34. package/adapters/shared/resolve-slot-ports.sh +17 -5
  35. package/adapters/shared/update-check-worker.mjs +43 -0
  36. package/bin/mm-harness +21 -2
  37. package/dist/adapters/core/surface.js +4 -1
  38. package/dist/adapters/extension/console-capture.js +95 -0
  39. package/dist/adapters/extension/product-config.js +110 -0
  40. package/dist/adapters/extension/runtime-decision.js +20 -2
  41. package/dist/adapters/extension/surface.js +22 -3
  42. package/dist/adapters/mobile/perps-env.js +43 -3
  43. package/dist/adapters/mobile/prepare.js +39 -7
  44. package/dist/adapters/mobile/surface.js +5 -2
  45. package/dist/adapters/resolve-slot-ports.js +2 -2
  46. package/dist/adapters/slot-ports.js +13 -16
  47. package/dist/adapters.js +56 -16
  48. package/dist/checkout-lock.js +27 -2
  49. package/dist/cli-color.js +19 -0
  50. package/dist/cli-commands.js +1 -1
  51. package/dist/cli.js +7 -14
  52. package/dist/command-contract.js +451 -0
  53. package/dist/command-journal.js +225 -0
  54. package/dist/commands/call.js +170 -50
  55. package/dist/commands/check.js +9 -3
  56. package/dist/commands/completion-candidates.js +20 -13
  57. package/dist/commands/device-target.js +27 -12
  58. package/dist/commands/doctor.js +106 -25
  59. package/dist/commands/fixtures.js +92 -34
  60. package/dist/commands/flows.js +39 -10
  61. package/dist/commands/last.js +52 -0
  62. package/dist/commands/launch/extension.js +38 -15
  63. package/dist/commands/launch/index.js +207 -63
  64. package/dist/commands/list-executables.js +151 -29
  65. package/dist/commands/logs.js +8 -6
  66. package/dist/commands/manifest.js +270 -35
  67. package/dist/commands/parse-args.js +13 -1
  68. package/dist/commands/provision.js +10 -3
  69. package/dist/commands/run-engine.js +435 -105
  70. package/dist/commands/run-report.js +12 -3
  71. package/dist/commands/run.js +355 -50
  72. package/dist/commands/shared.js +75 -3
  73. package/dist/commands/status-probe.js +4 -1
  74. package/dist/commands/status.js +2 -1
  75. package/dist/commands/stop.js +7 -2
  76. package/dist/commands/update.js +16 -28
  77. package/dist/completions-cache.js +1 -1
  78. package/dist/doctor.js +57 -10
  79. package/dist/harness.js +47 -137
  80. package/dist/heal-bounds.js +2 -2
  81. package/dist/json-stream.js +57 -0
  82. package/dist/live-adapter-contract.js +138 -32
  83. package/dist/manifest.js +161 -1
  84. package/dist/mm-harness-cli.js +135 -54
  85. package/dist/paths.js +2 -5
  86. package/dist/recipe-security.js +178 -0
  87. package/dist/run-diagnostics.js +261 -0
  88. package/dist/runner.js +117 -8
  89. package/docs/CONTRIBUTING.md +137 -0
  90. package/docs/QA.md +185 -0
  91. package/docs/RECIPES.md +161 -0
  92. package/docs/SECURITY.md +88 -0
  93. package/library/README.md +4 -0
  94. package/library/actions/core/perps/_controller.mjs +10 -55
  95. package/library/actions/core/perps/read_account.mjs +2 -2
  96. package/library/actions/core/perps/read_orders.mjs +2 -1
  97. package/library/actions/core/perps/read_positions.mjs +2 -1
  98. package/library/actions/core/wallet/list_accounts.mjs +95 -0
  99. package/library/actions/extension/platform/cdp.mjs +1 -0
  100. package/library/actions/extension/wallet/list_accounts.mjs +41 -0
  101. package/library/actions/mobile/wallet/list_accounts.mjs +37 -0
  102. package/library/manifests/core.action-manifest.json +68 -7
  103. package/library/manifests/extension.action-manifest.json +53 -0
  104. package/library/manifests/mobile.action-manifest.json +81 -3
  105. package/library/recipes/runner/action-validation.extension.recipe.json +8 -1
  106. package/library/recipes/runner/action-validation.mobile.recipe.json +8 -1
  107. package/library/recipes/runner/smoke.core.recipe.json +27 -0
  108. package/library/recipes/wallet/smoke.extension.recipe.json +42 -0
  109. package/library/recipes/wallet/smoke.mobile.recipe.json +42 -0
  110. package/package.json +12 -5
  111. package/scripts/completions.sh +7 -7
  112. package/scripts/validate-human-outcomes.mjs +169 -0
  113. package/adapters/mobile/overlay/app/dev-tools/AgenticService/AgentStepHud.tsx.patch +0 -185
  114. package/adapters/mobile/overlay/app/dev-tools/AgenticService/AgenticService.ts.patch +0 -1662
  115. package/adapters/mobile/overlay/compat/README.md +0 -28
  116. package/adapters/mobile/overlay/compat/rn81-message-event-source.patch +0 -42
  117. package/adapters/shared/resolve-farmslot-ports-core.mjs +0 -3
  118. package/adapters/shared/resolve-farmslot-ports.mjs +0 -5
  119. package/adapters/shared/resolve-farmslot-ports.sh +0 -7
  120. package/docs/ADAPTER-SURFACE.md +0 -119
  121. package/docs/CHEATSHEET.md +0 -61
  122. package/docs/CLI-SPEC.md +0 -1098
  123. package/docs/CODE-MAP.md +0 -62
  124. package/docs/DEBUG-HANDOVER.md +0 -36
  125. package/docs/MENTAL-MODEL.md +0 -295
  126. package/docs/UX-PRINCIPLES.md +0 -64
  127. package/docs/architecture.md +0 -398
  128. package/docs/live-adapter-contract.md +0 -188
  129. package/docs/package-boundaries.md +0 -47
  130. package/docs/perps-flow-catalog.md +0 -235
  131. package/docs/recipe-libraries.md +0 -301
  132. package/docs/runtime-file-conventions.md +0 -36
@@ -3,13 +3,15 @@ import http from "node:http";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { depsCheck } from "@farmslot/recipe-harness/runtime/deps-readiness";
6
+ import { colorHumanMessage } from "../../cli-color.js";
6
7
  import { recipeHarnessPath, recipeRuntimeDir, runnerDir } from "../../paths.js";
7
8
  import { extensionIdFromKey } from "../../adapters/extension/extension-id.js";
9
+ import { extensionProductConfigBlock } from "../../adapters/extension/product-config.js";
8
10
  import { isExtensionDistStale } from "../../adapters/extension/runtime-decision.js";
9
11
  import { checkExtensionRuntimeHealth } from "../../adapters/extension/runtime.js";
10
12
  import { ensureExtensionHarnessFresh } from "../../adapters/extension/harness-freshness.js";
11
13
  import { stopExtensionWatcher } from "../../adapters/slot-ports.js";
12
- import { spawnScriptStreaming } from "../shared.js";
14
+ import { EXIT, spawnScriptStreaming } from "../shared.js";
13
15
  function extensionDepsBlock(target) {
14
16
  if (!fs.existsSync(path.join(target, "package.json"))) return null;
15
17
  const deps = depsCheck(target);
@@ -30,18 +32,32 @@ function installExtensionDeps(target) {
30
32
  return spawnScriptStreaming(installScript, ["--target", target], target);
31
33
  }
32
34
  async function launchExtension(target, tier, wantWatch, displayMode = "fullscreen") {
35
+ if (process.env.CHROME_USER_DATA_DIR) {
36
+ process.env.CHROME_USER_DATA_DIR = path.resolve(process.env.CHROME_USER_DATA_DIR);
37
+ }
38
+ const reusable = !wantWatch && tier !== "build" && await extensionRuntimeReusable(target);
39
+ if (!reusable) {
40
+ const block = extensionProductConfigBlock(target);
41
+ if (block) {
42
+ return {
43
+ status: EXIT.infra,
44
+ output: `EXTENSION_PRODUCT_CONFIG_REQUIRED: ${block.message}
45
+ Next: ${block.userAction}`
46
+ };
47
+ }
48
+ }
33
49
  await ensureExtensionHarnessFresh(target);
34
50
  if (wantWatch) {
35
51
  const startWatchSh = path.join(runnerDir, "adapters/extension/start-watch.sh");
36
52
  const watchArgs = ["--target", target];
37
53
  if (process.env.WATCHER_PORT) watchArgs.push("--watcher-port", process.env.WATCHER_PORT);
38
- console.error(`\u2192 extension watch \u2014 webpack :${process.env.WATCHER_PORT ?? "default"} (output streams below)`);
54
+ console.error(colorHumanMessage(`\u2192 extension watch \u2014 webpack :${process.env.WATCHER_PORT ?? "default"} (output streams below)`));
39
55
  return spawnScriptStreaming(startWatchSh, watchArgs, target);
40
56
  }
41
57
  if (tier === "build") {
42
58
  return extensionRebuild(target);
43
59
  }
44
- if (await extensionRuntimeReusable(target)) {
60
+ if (reusable) {
45
61
  return extensionReattach(target, displayMode);
46
62
  }
47
63
  return extensionRebuild(target);
@@ -49,12 +65,16 @@ async function launchExtension(target, tier, wantWatch, displayMode = "fullscree
49
65
  async function extensionRuntimeReusable(target) {
50
66
  const cdpPort = process.env.CDP_PORT;
51
67
  if (!cdpPort) return false;
52
- if (!cdpOwnedByExpectedRuntime(cdpPort, target)) return false;
53
- if (!await cdpVersionReachable(cdpPort)) return false;
54
- if (!await cdpHasExpectedExtensionTarget(cdpPort, target)) return false;
55
- if (!await cdpRuntimeHealthy(cdpPort, target)) return false;
56
68
  if (isExtensionDistStale(target)) return false;
57
- return true;
69
+ for (let attempt = 0; attempt < 3; attempt += 1) {
70
+ const reachable = await cdpVersionReachable(cdpPort);
71
+ const owned = reachable && cdpOwnedByExpectedRuntime(cdpPort, target);
72
+ const targetPresent = owned && await cdpHasExpectedExtensionTarget(cdpPort, target);
73
+ const healthy = targetPresent && await cdpRuntimeHealthy(cdpPort, target);
74
+ if (healthy) return true;
75
+ if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 250));
76
+ }
77
+ return false;
58
78
  }
59
79
  function expectedChromeProfile(target) {
60
80
  const profileName = process.env.RECIPE_CHROME_PROFILE_NAME || "chrome-profile";
@@ -170,12 +190,15 @@ function cdpHasExpectedExtensionTarget(port, target) {
170
190
  });
171
191
  }
172
192
  async function cdpRuntimeHealthy(port, target) {
173
- try {
174
- const report = await checkExtensionRuntimeHealth(target, Number(port), { pageMode: "home-or-sidepanel" });
175
- return report.status === "PASS";
176
- } catch {
177
- return false;
193
+ for (let attempt = 0; attempt < 3; attempt += 1) {
194
+ try {
195
+ const report = await checkExtensionRuntimeHealth(target, Number(port), { pageMode: "home-or-sidepanel" });
196
+ if (report.status === "PASS") return true;
197
+ } catch {
198
+ }
199
+ if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 250));
178
200
  }
201
+ return false;
179
202
  }
180
203
  async function extensionReattach(target, displayMode = "fullscreen") {
181
204
  const reattachScript = recipeHarnessPath(target, "extension", "scripts", "reattach.sh");
@@ -183,7 +206,7 @@ async function extensionReattach(target, displayMode = "fullscreen") {
183
206
  if (process.env.CDP_PORT) reattachArgs.push("--cdp-port", process.env.CDP_PORT);
184
207
  if (process.env.WATCHER_PORT) reattachArgs.push("--watcher-port", process.env.WATCHER_PORT);
185
208
  if (process.env.EXTENSION_START_URL) reattachArgs.push("--start-url", process.env.EXTENSION_START_URL);
186
- console.error(`\u2192 extension quick reattach \u2014 reload in place \xB7 CDP :${process.env.CDP_PORT ?? "default"} (no rebuild, no relaunch)`);
209
+ console.error(colorHumanMessage(`\u2192 extension quick reattach \u2014 reload in place \xB7 CDP :${process.env.CDP_PORT ?? "default"} (no rebuild, no relaunch)`));
187
210
  return spawnScriptStreaming(reattachScript, reattachArgs, target);
188
211
  }
189
212
  async function extensionRebuild(target) {
@@ -197,7 +220,7 @@ async function extensionRebuild(target) {
197
220
  const liveArgs = ["--target", target, "--start-watch"];
198
221
  if (process.env.CDP_PORT) liveArgs.push("--cdp-port", process.env.CDP_PORT);
199
222
  if (process.env.EXTENSION_START_URL) liveArgs.push("--start-url", process.env.EXTENSION_START_URL);
200
- console.error(`\u2192 extension quick relaunch \u2014 webpack :${process.env.WATCHER_PORT ?? "default"} \xB7 CDP :${process.env.CDP_PORT ?? "default"} (output streams below)`);
223
+ console.error(colorHumanMessage(`\u2192 extension quick relaunch \u2014 webpack :${process.env.WATCHER_PORT ?? "default"} \xB7 CDP :${process.env.CDP_PORT ?? "default"} (output streams below)`));
201
224
  const result = await spawnScriptStreaming(liveScript, liveArgs, target);
202
225
  if (result.output) {
203
226
  try {
@@ -1,12 +1,13 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import { color } from "../../cli-color.js";
4
+ import { color, colorHumanMessage } from "../../cli-color.js";
5
5
  import { handleHarness } from "../../harness.js";
6
6
  import { recipeRuntimePath, runnerDir } from "../../paths.js";
7
7
  import { getAdapterSurface } from "../../adapters/surface.js";
8
8
  import {
9
9
  ADAPTER_DETECT_NEXT,
10
+ checkoutBusyOut,
10
11
  EXIT,
11
12
  flag,
12
13
  parseFlags,
@@ -14,7 +15,8 @@ import {
14
15
  spawnScript,
15
16
  str,
16
17
  targetOf,
17
- usageOut
18
+ usageOut,
19
+ writeInteractiveProgress
18
20
  } from "../shared.js";
19
21
  import {
20
22
  RECOVERY_CODE,
@@ -32,6 +34,9 @@ import {
32
34
  import { launchMobile } from "./mobile.js";
33
35
  import { applyDeviceTargeting } from "../device-target.js";
34
36
  import { acquireCheckoutLock } from "../../checkout-lock.js";
37
+ import { JsonStreamWriter } from "../../json-stream.js";
38
+ import { ensureExtensionConsoleCapture } from "../../adapters/extension/console-capture.js";
39
+ import { extensionProductConfigBlock } from "../../adapters/extension/product-config.js";
35
40
  const LAUNCH_BOOLEANS = /* @__PURE__ */ new Set([
36
41
  "build",
37
42
  "watch",
@@ -39,30 +44,58 @@ const LAUNCH_BOOLEANS = /* @__PURE__ */ new Set([
39
44
  "sidepanel",
40
45
  "fullscreen",
41
46
  "runway",
42
- "json"
47
+ "json",
48
+ "jsonStream"
43
49
  ]);
44
50
  const DEFAULT_EXTENSION_DAPP_URL = "https://metamask.github.io/test-dapp/";
45
51
  async function handleLaunch(argv) {
46
52
  const { options } = parseFlags(argv, LAUNCH_BOOLEANS);
47
53
  const json = flag(options, "json");
54
+ const stream = new JsonStreamWriter("launch", flag(options, "jsonStream"));
55
+ const jsonOutput = json && !stream.enabled;
56
+ const restoreStdout = stream.isolateStdout();
48
57
  const target = targetOf(options);
49
- if (!fs.existsSync(target)) return handleLaunchLocked(argv);
50
- const lock = acquireCheckoutLock(target, "launch");
51
- if ("message" in lock) {
52
- return usageOut(json, "launch", lock.message, `wait for the current owner, or inspect ${lock.path} if its process has exited`);
53
- }
54
58
  try {
55
- return await handleLaunchLocked(argv);
59
+ let exitCode;
60
+ if (!fs.existsSync(target)) {
61
+ exitCode = await handleLaunchLocked(argv, stream);
62
+ } else {
63
+ const lock = acquireCheckoutLock(target, "launch");
64
+ if ("message" in lock) {
65
+ const userAction = `wait for the current owner, or inspect ${lock.path} if its process has exited`;
66
+ stream.error({ code: "SANDBOX_BUSY", message: lock.message, userAction });
67
+ exitCode = checkoutBusyOut(jsonOutput, "launch", lock.message, lock.path);
68
+ } else {
69
+ try {
70
+ exitCode = await handleLaunchLocked(argv, stream);
71
+ } finally {
72
+ lock.release();
73
+ }
74
+ }
75
+ }
76
+ stream.complete(exitCode === EXIT.ok ? "pass" : "fail", exitCode);
77
+ return exitCode;
78
+ } catch (error) {
79
+ const exitCode = error !== null && typeof error === "object" && "exitCode" in error && typeof error.exitCode === "number" ? error.exitCode : EXIT.runtime;
80
+ stream.error({
81
+ code: exitCode === EXIT.usage ? "CLI_USAGE_ERROR" : "LAUNCH_FAILED",
82
+ message: error instanceof Error ? error.message : String(error),
83
+ userAction: `mm-harness doctor --target ${shellQuote(target)} --json`
84
+ });
85
+ stream.complete("fail", exitCode);
86
+ throw error;
56
87
  } finally {
57
- lock.release();
88
+ restoreStdout();
58
89
  }
59
90
  }
60
- async function handleLaunchLocked(argv) {
91
+ async function handleLaunchLocked(argv, stream) {
61
92
  const { positional, options } = parseFlags(argv, LAUNCH_BOOLEANS);
62
93
  const json = flag(options, "json");
94
+ const jsonOutput = json && !stream.enabled;
95
+ const machine = json || stream.enabled;
63
96
  const target = targetOf(options);
64
97
  const heal = parseHeal(options, "auto");
65
- if (typeof heal !== "string") return usageOut(json, "launch", heal.error, "use --heal off|infra-only|auto");
98
+ if (typeof heal !== "string") return launchUsage(jsonOutput, stream, heal.error, "use --heal off|infra-only|auto");
66
99
  const posToken = positional[0];
67
100
  const mobileTargetToken = posToken === "ios" || posToken === "android" ? posToken : void 0;
68
101
  const platformFlag = str(options, "platform");
@@ -71,15 +104,16 @@ async function handleLaunchLocked(argv) {
71
104
  const adapterHint = mobileTarget ? "mobile" : void 0;
72
105
  const adapter = resolveAdapter(options, target, adapterHint);
73
106
  if (!adapter) {
74
- return usageOut(json, "launch", `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
107
+ return launchUsage(jsonOutput, stream, `could not detect the MetaMask repo type for ${target}`, ADAPTER_DETECT_NEXT);
75
108
  }
109
+ stream.phase("resolve", { target, adapter, platform: mobileTarget ?? null });
76
110
  if (adapter === "core") {
77
- return usageOut(json, "launch", "core is headless; there is nothing to launch.", "mm-harness verify");
111
+ return launchUsage(jsonOutput, stream, "core is headless; there is nothing to launch.", "mm-harness verify");
78
112
  }
79
113
  if (adapter === "mobile" && !mobileTarget) {
80
- return usageOut(
81
- json,
82
- "launch",
114
+ return launchUsage(
115
+ jsonOutput,
116
+ stream,
83
117
  "target is required for mobile.",
84
118
  "mm-harness launch ios or mm-harness launch android"
85
119
  );
@@ -89,15 +123,15 @@ async function handleLaunchLocked(argv) {
89
123
  const wantWatch = flag(options, "watch");
90
124
  const wantRunway = flag(options, "runway");
91
125
  if (wantRunway && adapter !== "mobile") {
92
- return usageOut(json, "launch", "runway is mobile-only.", "drop --runway for the extension");
126
+ return launchUsage(jsonOutput, stream, "runway is mobile-only.", "drop --runway for the extension");
93
127
  }
94
128
  const displayMode = flag(options, "sidepanel") && !flag(options, "fullscreen") ? "sidepanel" : "fullscreen";
95
129
  for (const portFlag of ["cdpPort", "watcherPort"]) {
96
130
  const value = str(options, portFlag);
97
131
  if (value !== void 0 && !/^\d+$/u.test(value)) {
98
- return usageOut(
99
- json,
100
- "launch",
132
+ return launchUsage(
133
+ jsonOutput,
134
+ stream,
101
135
  `--${portFlag === "cdpPort" ? "cdp-port" : "watcher-port"} must be numeric (got: ${value}).`,
102
136
  `pass a numeric port, e.g. --${portFlag === "cdpPort" ? "cdp-port 6663" : "watcher-port 8081"}`
103
137
  );
@@ -105,7 +139,7 @@ async function handleLaunchLocked(argv) {
105
139
  }
106
140
  const envResult = applyLaunchEnvOverrides(options, adapter, mobileTarget, target);
107
141
  if (envResult && "code" in envResult) {
108
- return usageOut(json, "launch", envResult.message, "mm-harness launch android --device <adb-serial|device-name>");
142
+ return launchUsage(jsonOutput, stream, envResult.message, envResult.userAction);
109
143
  }
110
144
  const tier = wantBuild ? "build" : "quick";
111
145
  if (adapter === "extension") {
@@ -114,44 +148,65 @@ async function handleLaunchLocked(argv) {
114
148
  process.env.EXTENSION_START_URL = requestedUrl || DEFAULT_EXTENSION_DAPP_URL;
115
149
  }
116
150
  }
117
- if (!json && adapter === "extension") {
151
+ if (!machine && adapter === "extension") {
118
152
  const modeNote = displayMode === "sidepanel" ? `sidepanel \xB7 dapp ${process.env.EXTENSION_START_URL ?? DEFAULT_EXTENSION_DAPP_URL}` : "fullscreen";
119
153
  const workNote = wantWatch ? "watch only" : tier === "build" ? "clean build" : "quick reuse probe";
120
- console.error(
154
+ console.error(colorHumanMessage(
121
155
  `\u2192 extension launch \u2014 ${modeNote} \xB7 CDP :${process.env.CDP_PORT ?? "default"} \xB7 ${workNote}`
156
+ ));
157
+ }
158
+ if (adapter === "mobile") {
159
+ const device = str(options, "device") ?? (mobileTarget === "android" ? process.env.ADB_SERIAL ?? process.env.ANDROID_SERIAL : process.env.IOS_SIMULATOR ?? process.env.SIM_UDID) ?? "configured device";
160
+ const watcherPort = str(options, "watcherPort") ?? process.env.WATCHER_PORT ?? "auto";
161
+ writeInteractiveProgress(
162
+ machine,
163
+ `\u2192 mobile launch \u2014 ${mobileTarget} \xB7 ${tier === "build" ? "native build" : "quick readiness"} \xB7 ${device} \xB7 Metro ${watcherPort === "auto" ? "auto" : `:${watcherPort}`}`
122
164
  );
123
165
  }
124
166
  const state = newHealState();
125
167
  if (tier === "quick" && nativeInputsChanged(target, adapter)) {
126
- return usageOut(
127
- json,
128
- "launch",
168
+ return launchUsage(
169
+ jsonOutput,
170
+ stream,
129
171
  "native build inputs changed since last build.",
130
172
  `mm-harness launch ${adapter === "mobile" ? `${mobileTarget} ` : ""}--build`
131
173
  );
132
174
  }
133
- const ensured = await ensureOverlay(adapter, target, heal, state, json);
175
+ stream.phase("install");
176
+ const ensured = await ensureOverlay(adapter, target, heal, state, machine);
134
177
  if (!ensured.ok) {
135
- return launchFail(json, adapter, mobileTarget, tier, state, {
178
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
136
179
  code: "OVERLAY_INSTALL_FAILED",
137
180
  message: ensured.error ?? "overlay install failed",
138
181
  recoverable: false,
139
182
  exitCode: EXIT.infra
140
183
  });
141
184
  }
185
+ stream.phase("healthcheck");
142
186
  if (adapter === "extension") {
143
187
  const willBuild = wantWatch || tier === "build" || !await extensionRuntimeReusable(target);
144
188
  if (willBuild) {
189
+ const productConfigBlock = extensionProductConfigBlock(target);
190
+ if (productConfigBlock) {
191
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
192
+ code: "EXTENSION_PRODUCT_CONFIG_REQUIRED",
193
+ message: productConfigBlock.message,
194
+ recoverable: false,
195
+ userAction: productConfigBlock.userAction,
196
+ exitCode: EXIT.infra
197
+ });
198
+ }
145
199
  const depsBlock = extensionDepsBlock(target);
146
200
  if (depsBlock) {
147
201
  if (heal === "off") {
148
- return usageOut(json, "launch", depsBlock.message, depsBlock.userAction);
202
+ return launchUsage(jsonOutput, stream, depsBlock.message, depsBlock.userAction);
149
203
  }
150
204
  const recoveryCode2 = "deps.installed";
151
205
  state.attemptedRecoveries.push(recoveryCode2);
206
+ stream.phase("recover");
152
207
  const repaired = await installExtensionDeps(target);
153
208
  if (repaired.status !== 0 || extensionDepsBlock(target)) {
154
- return launchFail(json, adapter, mobileTarget, tier, state, {
209
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
155
210
  code: "DEPENDENCY_INSTALL_FAILED",
156
211
  message: depsBlock.message,
157
212
  recoverable: false,
@@ -165,12 +220,32 @@ async function handleLaunchLocked(argv) {
165
220
  }
166
221
  }
167
222
  }
168
- let attempt = await executeComposition(adapter, mobileTarget, tier, wantWatch, target, json, displayMode);
223
+ stream.phase("launch");
224
+ let attempt = await executeComposition(adapter, mobileTarget, tier, wantWatch, target, machine, displayMode);
169
225
  if (attempt.status === 0) {
170
- return await finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
226
+ return await finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
227
+ }
228
+ if (adapter === "extension" && extensionProductConfigMissing(attempt.output)) {
229
+ const block = extensionProductConfigBlock(target);
230
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
231
+ code: "EXTENSION_PRODUCT_CONFIG_REQUIRED",
232
+ message: block?.message ?? "Extension product configuration was not ready when the build started.",
233
+ recoverable: false,
234
+ userAction: block?.userAction ?? `mm-harness launch --adapter extension --target ${shellQuote(target)}`,
235
+ exitCode: EXIT.infra
236
+ });
237
+ }
238
+ if (adapter === "extension" && extensionBrowserMissing(attempt.output)) {
239
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
240
+ code: "EXTENSION_BROWSER_REQUIRED",
241
+ message: "the checkout Playwright browser is not installed; system Chrome is not a compatible unpacked-extension fallback. Installing the browser cache requires user approval.",
242
+ recoverable: false,
243
+ userAction: `ask the user for approval, then run: cd ${shellQuote(target)} && yarn playwright install chromium`,
244
+ exitCode: EXIT.infra
245
+ });
171
246
  }
172
247
  if (heal === "off") {
173
- return launchFail(json, adapter, mobileTarget, tier, state, {
248
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
174
249
  code: "LAUNCH_FAILED",
175
250
  message: `launch ${adapter}${mobileTarget ? ` ${mobileTarget}` : ""} failed (healing off)`,
176
251
  recoverable: false,
@@ -180,12 +255,13 @@ async function handleLaunchLocked(argv) {
180
255
  if (adapter === "extension" && tier === "quick" && extensionQuickReattachFailed(attempt.output)) {
181
256
  const recoveryCode2 = "chrome.relaunched";
182
257
  state.attemptedRecoveries.push(recoveryCode2);
183
- const rebuildAttempt = await executeComposition(adapter, mobileTarget, "build", wantWatch, target, json, displayMode);
258
+ stream.phase("recover");
259
+ const rebuildAttempt = await executeComposition(adapter, mobileTarget, "build", wantWatch, target, machine, displayMode);
184
260
  if (rebuildAttempt.status === 0) {
185
261
  state.recovered.push(recoveryCode2);
186
- return await finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
262
+ return await finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
187
263
  }
188
- return launchFail(json, adapter, mobileTarget, tier, state, {
264
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
189
265
  code: "EXTENSION_RELAUNCH_FAILED",
190
266
  message: "quick reattach failed, and clean relaunch failed too.",
191
267
  recoverable: false,
@@ -194,7 +270,7 @@ async function handleLaunchLocked(argv) {
194
270
  });
195
271
  }
196
272
  if (adapter === "mobile" && mobileProvisioningBlocked(attempt.output)) {
197
- return launchFail(json, adapter, mobileTarget, tier, state, {
273
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
198
274
  code: "MOBILE_PROVISION_REQUIRED",
199
275
  message: `mobile ${mobileTarget ?? "runtime"} is not provisioned for this slot.`,
200
276
  recoverable: false,
@@ -205,7 +281,7 @@ async function handleLaunchLocked(argv) {
205
281
  }
206
282
  const bound = checkHealBounds(target, attempt.output, state);
207
283
  if (bound !== null) {
208
- return launchFail(json, adapter, mobileTarget, tier, state, {
284
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
209
285
  code: bound.code,
210
286
  message: bound.message,
211
287
  recoverable: false,
@@ -216,12 +292,13 @@ async function handleLaunchLocked(argv) {
216
292
  }
217
293
  const recoveryCode = RECOVERY_CODE[adapter];
218
294
  state.attemptedRecoveries.push(recoveryCode);
219
- attempt = await executeComposition(adapter, mobileTarget, tier, wantWatch, target, json, displayMode);
295
+ stream.phase("recover");
296
+ attempt = await executeComposition(adapter, mobileTarget, tier, wantWatch, target, machine, displayMode);
220
297
  if (attempt.status === 0) {
221
298
  state.recovered.push(recoveryCode);
222
- return await finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
299
+ return await finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify);
223
300
  }
224
- return launchFail(json, adapter, mobileTarget, tier, state, {
301
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
225
302
  code: "SAME_RECOVERY_TWICE",
226
303
  message: `launch ${adapter}${mobileTarget ? ` ${mobileTarget}` : ""} failed again after ${recoveryCode} recovery \u2014 refusing to loop.`,
227
304
  recoverable: false,
@@ -235,6 +312,12 @@ function extensionQuickReattachFailed(output) {
235
312
  function extensionRuntimeBlocked(output) {
236
313
  return /ERR_BLOCKED_BY_CLIENT|has been blocked by Chromium/u.test(output);
237
314
  }
315
+ function extensionBrowserMissing(output) {
316
+ return /Playwright Chromium is not installed|No compatible Chromium binary selected/u.test(output);
317
+ }
318
+ function extensionProductConfigMissing(output) {
319
+ return /EXTENSION_PRODUCT_CONFIG_REQUIRED:/u.test(output);
320
+ }
238
321
  function mobileProvisioningBlocked(output) {
239
322
  return /open-device: configured iOS simulator '.+' does not exist|open-device: no MetaMask bundle found|fast mode requires an installed (?:iOS dev client|Android dev client)/u.test(output);
240
323
  }
@@ -274,14 +357,16 @@ function applyLaunchEnvOverrides(options, adapter, mobileTarget, target) {
274
357
  return {
275
358
  ok: false,
276
359
  code: "DEVICE_WRONG_PLATFORM",
277
- message: `--device ${device} did not resolve to an Android device for launch android.`
360
+ message: `--device ${device} did not resolve to an Android device for launch android.`,
361
+ userAction: `mm-harness status --target ${shellQuote(target)} --all-devices --json`
278
362
  };
279
363
  }
280
364
  if (mobileTarget === "ios" && device && !process.env.IOS_SIMULATOR) {
281
365
  return {
282
366
  ok: false,
283
367
  code: "DEVICE_WRONG_PLATFORM",
284
- message: `--device ${device} did not resolve to an iOS simulator for launch ios.`
368
+ message: `--device ${device} did not resolve to an iOS simulator for launch ios.`,
369
+ userAction: `mm-harness status --target ${shellQuote(target)} --all-devices --json`
285
370
  };
286
371
  }
287
372
  }
@@ -327,26 +412,29 @@ async function executeComposition(adapter, mobileTarget, tier, wantWatch, target
327
412
  }
328
413
  return launchExtension(target, tier, wantWatch, displayMode);
329
414
  }
330
- async function finishLaunch(json, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify, sidepanelRecoveryAllowed = true) {
415
+ async function finishLaunch(jsonOutput, machine, stream, adapter, mobileTarget, tier, displayMode, target, state, wantWatch, wantVerify, sidepanelRecoveryAllowed = true) {
331
416
  if (adapter === "extension" && displayMode === "sidepanel") {
332
417
  const sidepanelSh = path.join(runnerDir, "adapters/extension/sidepanel-toggle.sh");
333
418
  const sidepanelArgs = ["open"];
334
419
  if (process.env.CDP_PORT) sidepanelArgs.push("--cdp-port", process.env.CDP_PORT);
335
- if (!json) {
420
+ if (!machine) {
336
421
  console.error(
337
422
  `[sidepanel] opening panel beside ${process.env.EXTENSION_START_URL ?? DEFAULT_EXTENSION_DAPP_URL}`
338
423
  );
339
424
  }
340
- const sidepanel = spawnScript(sidepanelSh, sidepanelArgs, target, json, { REPO: target });
425
+ const sidepanel = spawnScript(sidepanelSh, sidepanelArgs, target, machine, { REPO: target });
341
426
  if (sidepanel.status !== 0) {
342
427
  const recoveryCode = "chrome.relaunched";
343
428
  if (sidepanelRecoveryAllowed && tier === "quick" && extensionRuntimeBlocked(sidepanel.output) && !state.attemptedRecoveries.includes(recoveryCode)) {
344
429
  state.attemptedRecoveries.push(recoveryCode);
345
- const rebuildAttempt = await executeComposition(adapter, mobileTarget, "build", wantWatch, target, json, displayMode);
430
+ stream.phase("recover");
431
+ const rebuildAttempt = await executeComposition(adapter, mobileTarget, "build", wantWatch, target, machine, displayMode);
346
432
  if (rebuildAttempt.status === 0) {
347
433
  state.recovered.push(recoveryCode);
348
434
  return await finishLaunch(
349
- json,
435
+ jsonOutput,
436
+ machine,
437
+ stream,
350
438
  adapter,
351
439
  mobileTarget,
352
440
  tier,
@@ -358,7 +446,7 @@ async function finishLaunch(json, adapter, mobileTarget, tier, displayMode, targ
358
446
  false
359
447
  );
360
448
  }
361
- return launchFail(json, adapter, mobileTarget, tier, state, {
449
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
362
450
  code: "EXTENSION_RELAUNCH_FAILED",
363
451
  message: "sidepanel open found a blocked extension runtime, and clean relaunch failed too.",
364
452
  recoverable: false,
@@ -366,7 +454,7 @@ async function finishLaunch(json, adapter, mobileTarget, tier, displayMode, targ
366
454
  originalError: rebuildAttempt.output.trim() || void 0
367
455
  });
368
456
  }
369
- return launchFail(json, adapter, mobileTarget, tier, state, {
457
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
370
458
  code: "SIDEPANEL_OPEN_FAILED",
371
459
  message: "app launched but opening the side panel failed.",
372
460
  recoverable: false,
@@ -376,16 +464,48 @@ async function finishLaunch(json, adapter, mobileTarget, tier, displayMode, targ
376
464
  });
377
465
  }
378
466
  }
467
+ if (adapter === "extension") {
468
+ try {
469
+ await ensureExtensionConsoleCapture(target);
470
+ } catch (error) {
471
+ if (!machine) {
472
+ console.error(`WARN: Extension console capture unavailable: ${error instanceof Error ? error.message : String(error)}`);
473
+ }
474
+ }
475
+ }
379
476
  if (wantVerify) {
477
+ stream.phase("verify");
380
478
  const verifyArgs = ["verify", "--adapter", adapter, "--target", target];
381
479
  if (adapter === "mobile" && mobileTarget) verifyArgs.push("--platform", mobileTarget);
382
- if (json) verifyArgs.push("--json");
383
- return handleHarness(verifyArgs);
480
+ if (machine) verifyArgs.push("--json");
481
+ const exitCode = await handleHarness(verifyArgs);
482
+ if (stream.enabled) {
483
+ for (const mutation of state.mutations) stream.mutation(mutation);
484
+ for (const recovery of state.recovered) stream.recovery(recovery);
485
+ stream.complete(exitCode === EXIT.ok ? "pass" : "fail", exitCode, {
486
+ adapter,
487
+ platform: mobileTarget ?? (adapter === "extension" ? displayMode : null),
488
+ tier,
489
+ recovered: state.recovered,
490
+ mutations: state.mutations
491
+ });
492
+ }
493
+ return exitCode;
384
494
  }
385
- return launchPass(json, adapter, mobileTarget, tier, displayMode, state);
495
+ return launchPass(jsonOutput, stream, adapter, mobileTarget, tier, displayMode, state);
386
496
  }
387
- function launchPass(json, adapter, mobileTarget, tier, displayMode, state) {
388
- if (json) {
497
+ function launchPass(json, stream, adapter, mobileTarget, tier, displayMode, state) {
498
+ if (stream.enabled) {
499
+ for (const mutation of state.mutations) stream.mutation(mutation);
500
+ for (const recovery of state.recovered) stream.recovery(recovery);
501
+ stream.complete("pass", EXIT.ok, {
502
+ adapter,
503
+ platform: mobileTarget ?? (adapter === "extension" ? displayMode : null),
504
+ tier,
505
+ recovered: state.recovered,
506
+ mutations: state.mutations
507
+ });
508
+ } else if (json) {
389
509
  console.log(
390
510
  JSON.stringify(
391
511
  {
@@ -423,8 +543,32 @@ function launchDeviceLabel(mobileTarget) {
423
543
  }
424
544
  return process.env.IOS_SIMULATOR || process.env.ADB_SERIAL || "booted device";
425
545
  }
426
- function launchFail(json, adapter, mobileTarget, tier, state, failure) {
427
- if (json) {
546
+ function launchUsage(json, stream, message, userAction) {
547
+ stream.error({ code: "USAGE", message, userAction });
548
+ return usageOut(json, "launch", message, userAction);
549
+ }
550
+ function launchFail(json, stream, adapter, mobileTarget, tier, state, target, failure) {
551
+ const userAction = failure.userAction ?? `mm-harness doctor --fix --adapter ${adapter} --target ${shellQuote(target)} --json`;
552
+ if (stream.enabled) {
553
+ for (const mutation of state.mutations) stream.mutation(mutation);
554
+ for (const recovery of state.recovered) stream.recovery(recovery);
555
+ stream.error({
556
+ code: failure.code,
557
+ message: failure.message,
558
+ retryable: failure.recoverable,
559
+ userAction,
560
+ originalError: failure.originalError ?? null
561
+ });
562
+ stream.complete("fail", failure.exitCode, {
563
+ adapter,
564
+ platform: mobileTarget ?? null,
565
+ tier,
566
+ recovered: state.recovered,
567
+ mutations: state.mutations,
568
+ recoverable: failure.recoverable,
569
+ attemptedRecoveries: state.attemptedRecoveries
570
+ });
571
+ } else if (json) {
428
572
  console.log(
429
573
  JSON.stringify(
430
574
  {
@@ -444,7 +588,7 @@ function launchFail(json, adapter, mobileTarget, tier, state, failure) {
444
588
  code: failure.code,
445
589
  message: failure.message,
446
590
  retryable: failure.recoverable,
447
- userAction: failure.userAction ?? null,
591
+ userAction,
448
592
  originalError: failure.originalError ?? null
449
593
  }
450
594
  },
@@ -453,13 +597,13 @@ function launchFail(json, adapter, mobileTarget, tier, state, failure) {
453
597
  )
454
598
  );
455
599
  } else {
456
- console.error(
600
+ console.error(colorHumanMessage(
457
601
  `\u2717 launch ${adapter}${mobileTarget ? ` ${mobileTarget}` : ""} failed
458
602
  ${failure.message}` + (failure.originalError ? `
459
603
  --- original failure ---
460
- ${failure.originalError}` : "") + (failure.userAction ? `
461
- Next: ${failure.userAction}` : "")
462
- );
604
+ ${failure.originalError}` : "") + `
605
+ Next: ${userAction}`
606
+ ));
463
607
  }
464
608
  return failure.exitCode;
465
609
  }