@deeeed/metamask-harness 0.35.0 → 0.37.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 (73) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +9 -1
  3. package/adapters/extension/artifact-runtime-state.cjs +128 -0
  4. package/adapters/extension/check-infura-readiness.cjs +102 -0
  5. package/adapters/extension/inject.mjs +2 -0
  6. package/adapters/extension/launch-browser.cjs +22 -0
  7. package/adapters/extension/live.sh +103 -18
  8. package/adapters/extension/readiness.mjs +77 -36
  9. package/adapters/extension/snapshot-dist.sh +88 -3
  10. package/adapters/extension/start-watch.sh +15 -0
  11. package/adapters/extension/verify.sh +6 -2
  12. package/adapters/extension/wallet-fixture-state.cjs +3 -1
  13. package/adapters/manifest.json +25 -1
  14. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +73 -42
  15. package/adapters/mobile/reset-app-data.sh +154 -0
  16. package/adapters/shared/log-tui.mjs +1 -1
  17. package/dist/adapters/extension/artifact-integrity.js +38 -0
  18. package/dist/adapters/extension/extension-id.js +23 -4
  19. package/dist/adapters/extension/product-config.js +29 -1
  20. package/dist/adapters/extension/release-artifact.js +386 -0
  21. package/dist/adapters/extension/runtime-decision.js +161 -20
  22. package/dist/adapters/extension/runtime.js +127 -0
  23. package/dist/adapters/mobile/release-artifact-state.js +124 -0
  24. package/dist/adapters/mobile/release-artifact.js +295 -0
  25. package/dist/adapters.js +29 -4
  26. package/dist/cli-commands.js +1 -1
  27. package/dist/cli.js +2 -2
  28. package/dist/command-contract.js +17 -1
  29. package/dist/commands/call.js +2 -1
  30. package/dist/commands/device-target.js +5 -0
  31. package/dist/commands/fixtures.js +106 -31
  32. package/dist/commands/launch/extension.js +92 -7
  33. package/dist/commands/launch/index.js +13 -0
  34. package/dist/commands/launch/mobile.js +2 -0
  35. package/dist/commands/provision.js +2 -0
  36. package/dist/commands/run-engine.js +89 -5
  37. package/dist/commands/run.js +3 -1
  38. package/dist/commands/runtime-launch.js +178 -10
  39. package/dist/heal-bounds.js +1 -1
  40. package/dist/live-adapter-contract.js +3 -1
  41. package/dist/metamask-action-validation.js +47 -1
  42. package/dist/mm-harness-cli.js +37 -4
  43. package/dist/recipe-security.js +3 -0
  44. package/dist/run-diagnostics.js +1 -1
  45. package/docs/RECIPES.md +29 -0
  46. package/docs/RELEASE-QA-CAPABILITY-MAP.md +150 -0
  47. package/library/actions/extension/perps/perps.mjs +2 -0
  48. package/library/actions/extension/perps/read_snapshot.mjs +470 -0
  49. package/library/actions/extension/platform/cdp.mjs +6 -3
  50. package/library/actions/extension/wallet/import.mjs +201 -0
  51. package/library/actions/extension/wallet/reset.mjs +98 -0
  52. package/library/actions/extension/wallet/secret-input.mjs +98 -0
  53. package/library/actions/extension/wallet/state.mjs +1 -0
  54. package/library/actions/mobile/analytics/consent-settings.mjs +112 -0
  55. package/library/actions/mobile/analytics/set_consent.mjs +4 -112
  56. package/library/actions/mobile/platform/bridge.mjs +8 -0
  57. package/library/actions/mobile/platform/observe-ui.mjs +84 -2
  58. package/library/actions/mobile/ui/native-navigation.mjs +225 -0
  59. package/library/actions/mobile/ui/navigate.mjs +7 -0
  60. package/library/actions/mobile/wallet/import.mjs +328 -0
  61. package/library/actions/mobile/wallet/native-ui.mjs +493 -0
  62. package/library/actions/mobile/wallet/read_state.mjs +16 -0
  63. package/library/actions/mobile/wallet/reset-helper.mjs +99 -0
  64. package/library/actions/mobile/wallet/reset.mjs +20 -0
  65. package/library/actions/shared/ui/locators.mjs +7 -0
  66. package/library/actions/shared/wallet/import-source.mjs +101 -0
  67. package/library/manifests/extension.action-manifest.json +228 -0
  68. package/library/manifests/mobile.action-manifest.json +124 -0
  69. package/library/recipes/extension/runner/action-validation.recipe.json +12 -1
  70. package/library/recipes/wallet/import.recipe.json +102 -0
  71. package/library/recipes/wallet/reset-import.recipe.json +107 -0
  72. package/package.json +1 -1
  73. package/scripts/completions.sh +2 -2
@@ -239,6 +239,22 @@ const PUBLIC_COMMAND_CONTRACTS = {
239
239
  }),
240
240
  positionals: [{ label: "platform", choices: ["ios", "android"] }]
241
241
  },
242
+ "runtime-launch": {
243
+ options: options(HELP, JSON, TARGET, ADAPTER, DEVICE, MOBILE_PLATFORM, {
244
+ "--cdp-port": value(),
245
+ "--chrome-user-data-dir": value(),
246
+ "--artifacts-dir": value(),
247
+ "--start-watch": bool(),
248
+ "--build-lavamoat": bool(),
249
+ "--remote-flag": value(),
250
+ "--artifact-file": value(),
251
+ "--artifact-version": value(),
252
+ "--artifact-build-id": value(),
253
+ "--artifact-package-id": value(),
254
+ "--artifact-sha256": value(),
255
+ "--artifact-cache": value()
256
+ })
257
+ },
242
258
  logs: {
243
259
  options: options(HELP, JSON, TARGET, ADAPTER, ADAPTER_OR_MOBILE_PLATFORM, {
244
260
  "--full": bool(),
@@ -270,7 +286,7 @@ const PUBLIC_COMMAND_CONTRACTS = {
270
286
  "--extension-id-file": value(),
271
287
  "--action-manifest": value()
272
288
  }),
273
- positionals: [{ label: "action", choices: ["init", "sync", "set", "generate", "finalize"] }],
289
+ positionals: [{ label: "action", choices: ["init", "sync", "set", "reset", "generate", "finalize"] }],
274
290
  minimumPositionals: 0
275
291
  }
276
292
  };
@@ -40,6 +40,7 @@ import {
40
40
  synthesizeOneNodeRecipe,
41
41
  validateRecipeAdapterAware
42
42
  } from "./run-engine.js";
43
+ import { readMobileReleaseArtifactState } from "../adapters/mobile/release-artifact-state.js";
43
44
  import { acquireCheckoutLock } from "../checkout-lock.js";
44
45
  import { formatRunDiagnosticsForHuman, readRunDiagnosticsDocument } from "../run-diagnostics.js";
45
46
  import { recipeTrustFailure } from "../recipe-security.js";
@@ -267,7 +268,7 @@ async function handleCall(argv) {
267
268
  }
268
269
  try {
269
270
  const prepared = await prepareHeal(adapter, target, options, json, {
270
- skipMobileSourceFreshness: adapter === "mobile" && resolvedAction === "app.lifecycle"
271
+ skipMobileSourceFreshness: adapter === "mobile" && (resolvedAction === "app.lifecycle" || readMobileReleaseArtifactState(target) !== null)
271
272
  });
272
273
  if (typeof prepared === "number") return prepared;
273
274
  const { state, heal } = prepared;
@@ -256,6 +256,11 @@ ${formatConnectedDevices(connected)}
256
256
  userAction: deviceRecovery(options, opts.rerun)
257
257
  };
258
258
  }
259
+ if (targetable.length === 1) {
260
+ const selected = targetable[0];
261
+ if (selected.platform === "android") setAndroidDeviceEnv(selected.id, selected.name);
262
+ else setIosDeviceEnv(selected.id, selected.name);
263
+ }
259
264
  return { ok: true };
260
265
  }
261
266
  function scopedDevices(devices, allDevices = false) {
@@ -48,7 +48,7 @@ function fixturesStatus(options) {
48
48
  const fixture = { ...summary, path: canonical };
49
49
  const ready = summary.status === "ready";
50
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"];
51
+ const operations = adapter === "extension" ? ["init", "sync", "set", "reset", "generate", "finalize"] : adapter === "mobile" ? ["init", "sync", "set", "reset"] : ["init", "sync", "set"];
52
52
  if (json) {
53
53
  console.log(JSON.stringify({
54
54
  schemaVersion: 1,
@@ -103,8 +103,8 @@ async function handleFixturesLocked(argv) {
103
103
  const { positional, options } = parseFlags(argv, FIXTURES_BOOLEANS);
104
104
  const json = flag(options, "json");
105
105
  const sub = positional[0];
106
- if (sub !== "init" && sub !== "sync" && sub !== "set" && sub !== "generate" && sub !== "finalize") {
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");
106
+ if (sub !== "init" && sub !== "sync" && sub !== "set" && sub !== "reset" && sub !== "generate" && sub !== "finalize") {
107
+ return usageOut(json, "fixtures", "fixtures requires a subcommand: mm-harness fixtures <init|sync|set|reset|generate|finalize>", "mm-harness fixtures init --from <path> or mm-harness fixtures init --dev");
108
108
  }
109
109
  if (sub !== "init" && (flag(options, "dev") || flag(options, "force") || str(options, "from"))) {
110
110
  return usageOut(
@@ -121,7 +121,7 @@ async function handleFixturesLocked(argv) {
121
121
  }
122
122
  const surface = getAdapterSurface(adapter);
123
123
  surface.resolveSlotPorts(target);
124
- const dtResult = applyDeviceTargeting("fixtures", adapter, options, { gate: false, rerun: "" });
124
+ const dtResult = applyDeviceTargeting("fixtures", adapter, options, { gate: sub === "reset", rerun: "" });
125
125
  if ("code" in dtResult) {
126
126
  return usageOut(json, "fixtures", dtResult.message, dtResult.userAction);
127
127
  }
@@ -129,6 +129,9 @@ async function handleFixturesLocked(argv) {
129
129
  if (sub === "finalize") return fixturesFinalize(adapter, target, options, json);
130
130
  const canonicalFixture = walletFixturePath(target);
131
131
  if (sub === "init") return fixturesInit(adapter, target, options, json);
132
+ if (sub === "reset" && surface.headless) {
133
+ return usageOut(json, "fixtures", "fixtures reset requires a Mobile or Extension client.", "use fixtures set for the core adapter");
134
+ }
132
135
  if (surface.headless && sub === "set") {
133
136
  const fixture = fixtureSummary(target, adapter);
134
137
  if (fixture.status !== "ready") {
@@ -139,7 +142,7 @@ async function handleFixturesLocked(argv) {
139
142
  else console.error(message);
140
143
  return EXIT.ok;
141
144
  }
142
- const retryHint = `${surface.hints.relaunch} # relaunch, then retry: mm-harness fixtures set`;
145
+ const retryHint = `${surface.hints.relaunch} # relaunch, then retry: mm-harness fixtures ${sub}`;
143
146
  if (sub === "sync") {
144
147
  const exitCode = fixturesSync(adapter, target, json);
145
148
  if (json) {
@@ -162,34 +165,105 @@ async function handleFixturesLocked(argv) {
162
165
  "mm-harness fixtures init --dev && mm-harness fixtures set"
163
166
  );
164
167
  }
168
+ if (sub === "reset") {
169
+ const selectedFixture = fixtureFileSummary(fixturePath, adapter, fixturePath);
170
+ if (selectedFixture.status !== "ready") {
171
+ return usageOut(
172
+ json,
173
+ "fixtures",
174
+ `wallet fixture is ${String(selectedFixture.status)} at ${fixturePath}; reset was not started.`,
175
+ `mm-harness fixtures init --from <path> --target ${target}`
176
+ );
177
+ }
178
+ }
165
179
  if (adapter === "extension") {
166
- return applyExtensionFixture(target, fixturePath, canonicalFixture, json);
180
+ return applyExtensionFixture(target, fixturePath, canonicalFixture, json, sub);
167
181
  }
168
- process.stderr.write(`\u2192 fixtures set ${adapter} \u2014 connecting bridge + applying wallet fixture (can take ~30s)\u2026
182
+ const reset = sub === "reset";
183
+ process.stderr.write(`\u2192 fixtures ${sub} ${adapter} \u2014 ${reset ? "resetting selected app data, relaunching, then applying" : "connecting bridge + applying"} wallet fixture (can take ~30s)\u2026
169
184
  `);
170
185
  let status;
171
186
  let mobileSetupResult;
187
+ let resetError;
172
188
  if (adapter === "mobile") {
173
189
  const setupWalletSh = path.join(runnerDir, "adapters/mobile/bridge-runtime/setup-wallet.sh");
174
190
  const previousAppRoot = process.env.APP_ROOT;
175
191
  process.env.APP_ROOT = target;
176
192
  try {
193
+ if (reset) {
194
+ const platform = process.env.PLATFORM === "android" || process.env.ADB_SERIAL || process.env.ANDROID_SERIAL ? "android" : "ios";
195
+ const port = process.env.WATCHER_PORT ?? process.env.METRO_PORT ?? "8081";
196
+ const resetAppData = path.join(runnerDir, "adapters/mobile/reset-app-data.sh");
197
+ const resetArgs = ["--platform", platform, "--target", target];
198
+ if (platform === "ios") {
199
+ resetArgs.push("--simulator", process.env.SIM_UDID || process.env.IOS_SIMULATOR || "booted");
200
+ } else if (process.env.ADB_SERIAL || process.env.ANDROID_SERIAL) {
201
+ resetArgs.push("--adb-serial", process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || "");
202
+ }
203
+ const resetResult = await spawnScriptStreaming(resetAppData, resetArgs, target, {
204
+ timeoutMs: FIXTURE_STREAM_TIMEOUT_MS
205
+ });
206
+ if (resetResult.status !== 0) {
207
+ resetError = new Error("Mobile app data reset failed.");
208
+ } else {
209
+ const prewarmBundle = path.join(runnerDir, "adapters/mobile/prewarm-bundle.sh");
210
+ const prewarmResult = await spawnScriptStreaming(
211
+ prewarmBundle,
212
+ ["--platform", platform, "--target", target, "--port", port],
213
+ target,
214
+ {
215
+ timeoutMs: FIXTURE_STREAM_TIMEOUT_MS
216
+ }
217
+ );
218
+ if (prewarmResult.status !== 0) {
219
+ resetError = new Error("Mobile bundle prewarm failed after app data reset.");
220
+ }
221
+ }
222
+ if (!resetError) {
223
+ const openDevice = path.join(runnerDir, "adapters/mobile/open-device.sh");
224
+ const openArgs = ["--platform", platform, "--target", target, "--port", port, "--preflight-mode", "fast", "--restart"];
225
+ if (platform === "ios") {
226
+ openArgs.push("--simulator", process.env.SIM_UDID || process.env.IOS_SIMULATOR || "booted");
227
+ } else if (process.env.ADB_SERIAL || process.env.ANDROID_SERIAL) {
228
+ openArgs.push("--adb-serial", process.env.ADB_SERIAL || process.env.ANDROID_SERIAL || "");
229
+ }
230
+ const openResult = await spawnScriptStreaming(openDevice, openArgs, target, {
231
+ timeoutMs: FIXTURE_STREAM_TIMEOUT_MS
232
+ });
233
+ if (openResult.status !== 0) {
234
+ resetError = new Error("Mobile app relaunch after data reset failed.");
235
+ } else {
236
+ const waitForBridge = path.join(runnerDir, "adapters/mobile/wait-for-bridge.sh");
237
+ const waitResult = await spawnScriptStreaming(
238
+ waitForBridge,
239
+ ["--target", target, "--port", port, "--platform", platform],
240
+ target,
241
+ { timeoutMs: FIXTURE_STREAM_TIMEOUT_MS }
242
+ );
243
+ if (waitResult.status !== 0) {
244
+ resetError = new Error("Mobile bridge did not return after app data reset.");
245
+ }
246
+ }
247
+ }
248
+ }
177
249
  const configuredTimeout = Number(process.env.MM_HARNESS_FIXTURES_SET_TIMEOUT_MS ?? FIXTURE_STREAM_TIMEOUT_MS);
178
250
  const timeoutMs = Number.isFinite(configuredTimeout) && configuredTimeout > 0 ? configuredTimeout : FIXTURE_STREAM_TIMEOUT_MS;
179
- mobileSetupResult = await spawnScriptStreaming(
180
- setupWalletSh,
181
- ["--fixture", fixturePath],
182
- target,
183
- {
184
- env: {
185
- APP_ROOT: target,
186
- WATCHER_PORT: process.env.WATCHER_PORT ?? "8081",
187
- METRO_PORT: process.env.METRO_PORT ?? process.env.WATCHER_PORT ?? "8081"
188
- },
189
- timeoutMs
190
- }
191
- );
192
- status = mobileSetupResult.status === 0 ? "pass" : "fail";
251
+ if (!resetError) {
252
+ mobileSetupResult = await spawnScriptStreaming(
253
+ setupWalletSh,
254
+ ["--fixture", fixturePath],
255
+ target,
256
+ {
257
+ env: {
258
+ APP_ROOT: target,
259
+ WATCHER_PORT: process.env.WATCHER_PORT ?? "8081",
260
+ METRO_PORT: process.env.METRO_PORT ?? process.env.WATCHER_PORT ?? "8081"
261
+ },
262
+ timeoutMs
263
+ }
264
+ );
265
+ }
266
+ status = !resetError && mobileSetupResult?.status === 0 ? "pass" : "fail";
193
267
  } finally {
194
268
  if (previousAppRoot === void 0) delete process.env.APP_ROOT;
195
269
  else process.env.APP_ROOT = previousAppRoot;
@@ -205,16 +279,17 @@ async function handleFixturesLocked(argv) {
205
279
  {
206
280
  schemaVersion: 1,
207
281
  command: "fixtures",
208
- action: "set",
282
+ action: sub,
209
283
  adapter,
210
284
  fixture: fixturePath,
211
285
  canonicalFixture,
212
286
  status,
213
287
  exitCode: status === "pass" ? EXIT.ok : EXIT.runtime,
288
+ walletReset: reset && status === "pass",
214
289
  message: teaching,
215
290
  error: status === "fail" ? {
216
- code: mobileSetupResult?.timedOut ? "SETUP_WALLET_TIMEOUT" : "SETUP_WALLET_FAILED",
217
- message: mobileSetupResult?.timedOut ? `wallet fixture setup exceeded its ${String(mobileSetupResult.timeoutMs)}ms bound on Metro port ${process.env.WATCHER_PORT ?? "8081"}` : `wallet fixture setup failed on ${process.env.WATCHER_PORT ?? "8081"}`,
291
+ code: resetError ? "MOBILE_WALLET_RESET_FAILED" : mobileSetupResult?.timedOut ? "SETUP_WALLET_TIMEOUT" : "SETUP_WALLET_FAILED",
292
+ message: resetError ? `wallet reset failed: ${resetError instanceof Error ? resetError.message : String(resetError)}` : mobileSetupResult?.timedOut ? `wallet fixture setup exceeded its ${String(mobileSetupResult.timeoutMs)}ms bound on Metro port ${process.env.WATCHER_PORT ?? "8081"}` : `wallet fixture setup failed on ${process.env.WATCHER_PORT ?? "8081"}`,
218
293
  userAction: retryHint
219
294
  } : null
220
295
  },
@@ -227,7 +302,7 @@ async function handleFixturesLocked(argv) {
227
302
  }
228
303
  return status === "pass" ? EXIT.ok : EXIT.runtime;
229
304
  }
230
- async function applyExtensionFixture(target, fixturePath, canonicalFixture, json) {
305
+ async function applyExtensionFixture(target, fixturePath, canonicalFixture, json, action) {
231
306
  const distManifest = path.join(target, "dist/chrome/manifest.json");
232
307
  const userAction = `mm-harness launch --build --verify --target ${JSON.stringify(target)}`;
233
308
  if (!fs.existsSync(distManifest)) {
@@ -236,7 +311,7 @@ async function applyExtensionFixture(target, fixturePath, canonicalFixture, json
236
311
  console.log(JSON.stringify({
237
312
  schemaVersion: 1,
238
313
  command: "fixtures",
239
- action: "set",
314
+ action,
240
315
  adapter: "extension",
241
316
  fixture: fixturePath,
242
317
  canonicalFixture,
@@ -245,15 +320,15 @@ async function applyExtensionFixture(target, fixturePath, canonicalFixture, json
245
320
  error: { code: "EXTENSION_FIXTURE_BUILD_REQUIRED", message: message2, userAction }
246
321
  }, null, 2));
247
322
  } else {
248
- console.error(`\u2717 mm-harness fixtures set: ${message2}
323
+ console.error(`\u2717 mm-harness fixtures ${action}: ${message2}
249
324
  Next: ${userAction}`);
250
325
  }
251
326
  return EXIT.runtime;
252
327
  }
253
- process.stderr.write(`${colorHumanMessage("\u2192 fixtures set extension \u2014 resetting and seeding the slot-owned profile from the existing build\u2026")}
328
+ process.stderr.write(`${colorHumanMessage(`\u2192 fixtures ${action} extension \u2014 resetting and seeding the slot-owned profile from the existing build\u2026`)}
254
329
  `);
255
330
  const liveScript = path.join(runnerDir, "adapters/extension/live.sh");
256
- const args = ["--target", target, "--launch-existing-dist"];
331
+ const args = ["--target", target, "--launch-existing-dist", "--launch-only"];
257
332
  if (process.env.CDP_PORT) args.push("--cdp-port", process.env.CDP_PORT);
258
333
  const result = await spawnScriptStreaming(liveScript, args, target, {
259
334
  env: { RECIPE_WALLET_FIXTURE: fixturePath },
@@ -266,7 +341,7 @@ async function applyExtensionFixture(target, fixturePath, canonicalFixture, json
266
341
  console.log(JSON.stringify({
267
342
  schemaVersion: 1,
268
343
  command: "fixtures",
269
- action: "set",
344
+ action,
270
345
  adapter: "extension",
271
346
  fixture: fixturePath,
272
347
  canonicalFixture,
@@ -279,7 +354,7 @@ async function applyExtensionFixture(target, fixturePath, canonicalFixture, json
279
354
  } else if (status === "pass") {
280
355
  console.error(message);
281
356
  } else {
282
- console.error(`\u2717 mm-harness fixtures set: ${message}
357
+ console.error(`\u2717 mm-harness fixtures ${action}: ${message}
283
358
  Next: ${userAction}`);
284
359
  }
285
360
  return exitCode;
@@ -15,11 +15,20 @@ import {
15
15
  runnerDir
16
16
  } from "../../paths.js";
17
17
  import { extensionIdFromKey } from "../../adapters/extension/extension-id.js";
18
- import { extensionProductConfigBlock } from "../../adapters/extension/product-config.js";
19
- import { isExtensionDistStale } from "../../adapters/extension/runtime-decision.js";
18
+ import {
19
+ EXTENSION_PRODUCT_CONFIG_FINGERPRINT_FILENAME,
20
+ extensionCompiledScriptsMatchProductConfig,
21
+ extensionProductConfigBlock,
22
+ extensionProductConfigFingerprint
23
+ } from "../../adapters/extension/product-config.js";
24
+ import {
25
+ isExtensionDistStale,
26
+ releaseArtifactState,
27
+ runtimeDistCheck
28
+ } from "../../adapters/extension/runtime-decision.js";
20
29
  import { checkExtensionRuntimeHealth } from "../../adapters/extension/runtime.js";
21
30
  import { ensureHarnessFresh } from "../../adapters/harness-freshness.js";
22
- import { stopExtensionWatcher } from "../../adapters/slot-ports.js";
31
+ import { isExtensionWatcherLive, stopExtensionWatcher } from "../../adapters/slot-ports.js";
23
32
  import { EXIT, spawnScriptStreaming } from "../shared.js";
24
33
  const { CdpSession } = await importRecipeHarnessRuntimeCdp();
25
34
  const { RUNTIME_IDENTITY_FILENAME, RUNTIME_NONCE_PREFIX } = createRequire(import.meta.url)(
@@ -50,7 +59,18 @@ async function launchExtension(target, tier, wantWatch, displayMode = "fullscree
50
59
  if (process.env.CHROME_USER_DATA_DIR) {
51
60
  process.env.CHROME_USER_DATA_DIR = path.resolve(process.env.CHROME_USER_DATA_DIR);
52
61
  }
53
- const reusable = !wantWatch && tier !== "build" && await extensionRuntimeReusable(target);
62
+ const artifact = releaseArtifactState(target);
63
+ const artifactSnapshot = runtimeDistCheck(target, artifact);
64
+ const artifactLaunchable = !wantWatch && tier !== "build" && artifact.status === "valid" && artifactSnapshot.status === "fresh";
65
+ const reusable = !wantWatch && tier !== "build" && await extensionRuntimeReusable(target, artifact);
66
+ if (artifact.status !== "none" && !reusable && !artifactLaunchable) {
67
+ const detail = artifact.status === "invalid" ? `identity is invalid: ${artifact.reason ?? "unknown reason"}` : "runtime is not healthy and reusable";
68
+ return {
69
+ status: EXIT.infra,
70
+ output: `RELEASE_ARTIFACT_NOT_REUSABLE: The loaded release artifact ${detail}.
71
+ Next: rerun runtime-launch with the same artifact source.`
72
+ };
73
+ }
54
74
  if (!reusable) {
55
75
  const block = extensionProductConfigBlock(target);
56
76
  if (block) {
@@ -75,12 +95,23 @@ Next: ${block.userAction}`
75
95
  if (reusable) {
76
96
  return extensionReattach(target, displayMode);
77
97
  }
78
- return extensionRebuild(target);
98
+ if (artifactLaunchable) {
99
+ return extensionLaunchReleaseArtifact(target, artifact);
100
+ }
101
+ return extensionLaunchDevelopment(target);
79
102
  }
80
- async function extensionRuntimeReusable(target) {
103
+ async function extensionRuntimeReusable(target, artifact = releaseArtifactState(target)) {
81
104
  const cdpPort = process.env.CDP_PORT;
82
105
  if (!cdpPort) return false;
83
- if (isExtensionDistStale(target)) return false;
106
+ const runtimeSnapshot = runtimeDistCheck(target, artifact);
107
+ const releaseArtifact = runtimeSnapshot.source === "release-artifact";
108
+ if (releaseArtifact) {
109
+ if (runtimeSnapshot.status !== "fresh") return false;
110
+ } else {
111
+ if (isExtensionDistStale(target)) return false;
112
+ if (isExtensionWatcherLive(target) && !harnessWatcherMatchesProductConfig(target)) return false;
113
+ if (!extensionCompiledScriptsMatchProductConfig(target, expectedRuntimeDist(target))) return false;
114
+ }
84
115
  for (let attempt = 0; attempt < 3; attempt += 1) {
85
116
  const reachable = await cdpVersionReachable(cdpPort);
86
117
  const nonceOwned = reachable && await cdpOwnedByRuntimeNonce(cdpPort, target);
@@ -325,6 +356,59 @@ async function extensionReattach(target, displayMode = "fullscreen") {
325
356
  console.error(colorHumanMessage(`\u2192 extension quick reattach \u2014 reload in place \xB7 CDP :${process.env.CDP_PORT ?? "default"} (no rebuild, no relaunch)`));
326
357
  return spawnScriptStreaming(reattachScript, reattachArgs, target);
327
358
  }
359
+ async function extensionLaunchDevelopment(target) {
360
+ const liveScript = recipeHarnessPath(target, "extension", "scripts", "live.sh");
361
+ const liveArgs = ["--target", target];
362
+ const existingBuild = isExtensionWatcherLive(target) && harnessWatcherMatchesProductConfig(target) && extensionCompiledScriptsMatchProductConfig(target, path.join(target, "dist/chrome")) && fs.existsSync(path.join(target, "dist/chrome/manifest.json")) && !isExtensionDistStale(target);
363
+ liveArgs.push(existingBuild ? "--launch-existing-dist" : "--start-watch");
364
+ if (process.env.CDP_PORT) liveArgs.push("--cdp-port", process.env.CDP_PORT);
365
+ if (process.env.EXTENSION_START_URL) liveArgs.push("--start-url", process.env.EXTENSION_START_URL);
366
+ console.error(colorHumanMessage(
367
+ existingBuild ? `\u2192 extension quick launch \u2014 existing yarn start build \xB7 CDP :${process.env.CDP_PORT ?? "default"} (output streams below)` : `\u2192 extension quick launch \u2014 yarn start development build \xB7 CDP :${process.env.CDP_PORT ?? "default"} (output streams below)`
368
+ ));
369
+ return spawnScriptStreaming(liveScript, liveArgs, target);
370
+ }
371
+ async function extensionLaunchReleaseArtifact(target, artifact) {
372
+ if (!artifact.sourceDir || !artifact.provenancePath) {
373
+ return {
374
+ status: EXIT.infra,
375
+ output: "RELEASE_ARTIFACT_NOT_REUSABLE: The loaded release artifact identity is incomplete.\nNext: rerun runtime-launch with the same artifact source."
376
+ };
377
+ }
378
+ const liveScript = recipeHarnessPath(target, "extension", "scripts", "live.sh");
379
+ const liveArgs = [
380
+ "--target",
381
+ target,
382
+ "--external-artifact",
383
+ "--dist-dir",
384
+ artifact.sourceDir,
385
+ "--artifact-provenance",
386
+ artifact.provenancePath,
387
+ "--preserve-profile"
388
+ ];
389
+ if (process.env.CDP_PORT) liveArgs.push("--cdp-port", process.env.CDP_PORT);
390
+ if (process.env.EXTENSION_START_URL) liveArgs.push("--start-url", process.env.EXTENSION_START_URL);
391
+ console.error(colorHumanMessage(
392
+ `\u2192 extension release relaunch \u2014 verified artifact ${artifact.manifestVersion ?? artifact.expectedVersion ?? ""} \xB7 CDP :${process.env.CDP_PORT ?? "default"}`
393
+ ));
394
+ return spawnScriptStreaming(liveScript, liveArgs, target);
395
+ }
396
+ function harnessWatcherMatchesProductConfig(target) {
397
+ const runtimeDir = path.join(target, recipeRuntimeDir());
398
+ const harnessPidFile = path.join(runtimeDir, "recipe-harness-webpack.pid");
399
+ if (!fs.existsSync(harnessPidFile)) return true;
400
+ const expected = extensionProductConfigFingerprint(target);
401
+ if (!expected) return true;
402
+ try {
403
+ const actual = fs.readFileSync(
404
+ path.join(runtimeDir, EXTENSION_PRODUCT_CONFIG_FINGERPRINT_FILENAME),
405
+ "utf8"
406
+ ).trim();
407
+ return actual === expected;
408
+ } catch {
409
+ return false;
410
+ }
411
+ }
328
412
  async function extensionRebuild(target) {
329
413
  const runtimeDirRel = recipeRuntimeDir();
330
414
  const runtimeAbs = path.join(target, runtimeDirRel);
@@ -348,6 +432,7 @@ async function extensionRebuild(target) {
348
432
  }
349
433
  export {
350
434
  extensionDepsBlock,
435
+ extensionLaunchDevelopment,
351
436
  extensionReattach,
352
437
  extensionRebuild,
353
438
  extensionRuntimeReusable,
@@ -259,6 +259,16 @@ async function handleLaunchLocked(argv, stream) {
259
259
  exitCode: EXIT.infra
260
260
  });
261
261
  }
262
+ if (adapter === "extension" && extensionEvmRpcUnreachable(attempt.output)) {
263
+ return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
264
+ code: "EVM_RPC_UNREACHABLE",
265
+ message: "EVM RPC readiness failed; Infura configured does not mean RPC reachable.",
266
+ recoverable: false,
267
+ userAction: `Check the effective INFURA_PROJECT_ID from the environment, .metamaskprodrc, or ${shellQuote(path.join(target, ".metamaskrc"))}, then rerun: mm-harness launch --verify --target ${shellQuote(target)}`,
268
+ exitCode: EXIT.runtime,
269
+ originalError: attempt.output.trim() || void 0
270
+ });
271
+ }
262
272
  if (heal === "off") {
263
273
  return launchFail(jsonOutput, stream, adapter, mobileTarget, tier, state, target, {
264
274
  code: "LAUNCH_FAILED",
@@ -374,6 +384,9 @@ function extensionBrowserMissing(output) {
374
384
  function extensionProductConfigMissing(output) {
375
385
  return /EXTENSION_PRODUCT_CONFIG_REQUIRED:/u.test(output);
376
386
  }
387
+ function extensionEvmRpcUnreachable(output) {
388
+ return /EVM_RPC_UNREACHABLE|EVM RPC readiness probe failed|Infura configured ≠ RPC reachable/u.test(output);
389
+ }
377
390
  function mobileProvisioningBlocked(output) {
378
391
  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);
379
392
  }
@@ -4,8 +4,10 @@ import {
4
4
  recordMobileSourceBaseline
5
5
  } from "../../adapters/mobile/source-freshness.js";
6
6
  import { ensureHarnessFresh } from "../../adapters/harness-freshness.js";
7
+ import { clearMobileReleaseArtifactState } from "../../adapters/mobile/release-artifact-state.js";
7
8
  import { EXIT } from "../shared.js";
8
9
  async function launchMobile(target, mobileTarget, tier, json, restartApp = false, clearMetro = false) {
10
+ clearMobileReleaseArtifactState(target);
9
11
  await ensureHarnessFresh(target, "mobile");
10
12
  const platform = mobileTarget ?? "ios";
11
13
  const watcherPort = process.env.WATCHER_PORT ? parseInt(process.env.WATCHER_PORT, 10) : void 0;
@@ -1,6 +1,7 @@
1
1
  import { detectAdapter } from "../harness.js";
2
2
  import { assertAdapter } from "../paths.js";
3
3
  import { getAdapterSurface } from "../adapters/surface.js";
4
+ import { clearMobileReleaseArtifactState } from "../adapters/mobile/release-artifact-state.js";
4
5
  import { ADAPTER_DETECT_NEXT, usageOut, writeInteractiveProgress } from "./shared.js";
5
6
  import {
6
7
  applyWatcherPortOption,
@@ -28,6 +29,7 @@ async function handleProvision({ positional, options, rawArgv }) {
28
29
  const platform = optionString(options, "platform") ?? optionString(options, "devicePlatform") ?? (positional[0] === "runway" ? positional[1] : positional[0]) ?? "ios";
29
30
  if (adapter === "mobile") {
30
31
  writeInteractiveProgress(json, `\u2192 provision mobile ${platform} \u2014 resolving simulator and Runway artifact`);
32
+ if (!optionFlag(options, "resolveOnly")) clearMobileReleaseArtifactState(target);
31
33
  }
32
34
  const result = await surface.runwayProvision.run(target, {
33
35
  json,