@deeeed/metamask-harness 0.74.0 → 0.75.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 (60) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/README.md +1 -1
  3. package/adapters/shared/ensure-runner-deps.sh +15 -3
  4. package/dist/action-values.js +15 -0
  5. package/dist/adapters/core/actions.js +20 -0
  6. package/dist/adapters/core/live-script-env.js +36 -0
  7. package/dist/adapters/core/run.js +54 -0
  8. package/dist/adapters/core/surface.js +5 -1
  9. package/dist/adapters/extension/action-inputs.js +206 -0
  10. package/dist/adapters/extension/actions.js +51 -0
  11. package/dist/adapters/extension/console-capture.js +13 -1
  12. package/dist/adapters/extension/launch.js +315 -0
  13. package/dist/adapters/extension/launched-browser.js +51 -0
  14. package/dist/adapters/extension/run.js +105 -0
  15. package/dist/adapters/extension/surface.js +9 -2
  16. package/dist/adapters/mobile/actions.js +54 -0
  17. package/dist/adapters/mobile/launch.js +192 -0
  18. package/dist/adapters/mobile/lifecycle.js +300 -0
  19. package/dist/adapters/mobile/run.js +156 -0
  20. package/dist/adapters/mobile/runtime-identity.js +34 -0
  21. package/dist/adapters/mobile/surface.js +11 -2
  22. package/dist/adapters/mobile/ui.js +701 -0
  23. package/dist/adapters/performance/cdp-trace.js +24 -6
  24. package/dist/adapters/performance/device-platform.js +10 -0
  25. package/dist/adapters/registry.js +1 -1
  26. package/dist/adapters/terminal/actions.js +43 -0
  27. package/dist/adapters/terminal/console-capture.js +14 -1
  28. package/dist/adapters/terminal/run.js +30 -0
  29. package/dist/adapters/terminal/surface.js +6 -1
  30. package/dist/adapters.js +32 -824
  31. package/dist/cli-commands.js +1 -0
  32. package/dist/command-contract.js +3 -0
  33. package/dist/commands/call.js +2 -5
  34. package/dist/commands/launch/device.js +2 -6
  35. package/dist/commands/launch/extension.js +3 -303
  36. package/dist/commands/launch/index.js +1 -1
  37. package/dist/commands/launch/mobile.js +2 -190
  38. package/dist/commands/launch/quote.js +7 -0
  39. package/dist/commands/pin.js +226 -0
  40. package/dist/commands/run-engine.js +38 -138
  41. package/dist/commands/run-report.js +4 -48
  42. package/dist/commands/run.js +6 -48
  43. package/dist/doctor.js +3 -1
  44. package/dist/execution-provenance.js +3 -2
  45. package/dist/harness-pin.js +10 -0
  46. package/dist/heal-bounds.js +3 -2
  47. package/dist/live-adapter-contract.js +8 -34
  48. package/dist/manifest.js +3 -5
  49. package/dist/metamask-action-validation.js +5 -196
  50. package/dist/mm-harness-cli.js +27 -2
  51. package/dist/network-observation.js +10 -3
  52. package/dist/performance-observation.js +15 -22
  53. package/dist/pr-body/render.js +3 -1
  54. package/dist/recording-target.js +4 -39
  55. package/dist/run-diagnostics.js +21 -60
  56. package/dist/run-recording.js +5 -4
  57. package/dist/runner.js +20 -368
  58. package/docs/CONTRIBUTING.md +19 -7
  59. package/package.json +2 -1
  60. package/scripts/completions.sh +2 -1
@@ -0,0 +1,226 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs";
4
+ import { createRequire } from "node:module";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { isPin, PIN_MARKER } from "../harness-pin.js";
8
+ import { runnerDir } from "../paths.js";
9
+ import { EXIT } from "./shared.js";
10
+ const PACKAGE_NAME = "@deeeed/metamask-harness";
11
+ const STALE_STAGING_MS = 30 * 60 * 1e3;
12
+ const RUNNER_STATE = /* @__PURE__ */ new Set([".omc", ".omx"]);
13
+ function pinRoot(env = process.env, home = os.homedir()) {
14
+ return path.resolve(env.MM_HARNESS_PIN_ROOT || path.join(home, ".local/share/mm-harness/pins"));
15
+ }
16
+ function readManifest(root) {
17
+ return JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
18
+ }
19
+ function contentDigest(root) {
20
+ const hash = crypto.createHash("sha256");
21
+ const walk = (directory, relative) => {
22
+ hash.update(`d\0${relative}\0`);
23
+ const entries = fs.readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
24
+ for (const entry of entries) {
25
+ if (RUNNER_STATE.has(entry.name) || !relative && entry.name === PIN_MARKER) continue;
26
+ const absolute = path.join(directory, entry.name);
27
+ const child = relative ? `${relative}/${entry.name}` : entry.name;
28
+ if (entry.isDirectory()) {
29
+ walk(absolute, child);
30
+ } else if (entry.isSymbolicLink()) {
31
+ hash.update(`l\0${child}\0${fs.readlinkSync(absolute)}\0`);
32
+ } else if (entry.isFile()) {
33
+ const fd = fs.openSync(absolute, "r");
34
+ try {
35
+ const opened = fs.fstatSync(fd);
36
+ hash.update(`f\0${child}\0${opened.mode & 73 ? "x" : "-"}\0${opened.size}\0`);
37
+ hash.update(fs.readFileSync(fd));
38
+ hash.update("\0");
39
+ } finally {
40
+ fs.closeSync(fd);
41
+ }
42
+ }
43
+ }
44
+ };
45
+ walk(root, "");
46
+ return hash.digest("hex");
47
+ }
48
+ function relinkIntoCopy(copy, sourceAliases) {
49
+ const walk = (directory) => {
50
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
51
+ const absolute = path.join(directory, entry.name);
52
+ if (entry.isDirectory()) {
53
+ walk(absolute);
54
+ continue;
55
+ }
56
+ if (!entry.isSymbolicLink()) continue;
57
+ const raw = fs.readlinkSync(absolute);
58
+ if (!path.isAbsolute(raw)) continue;
59
+ const targets = [path.resolve(raw)];
60
+ try {
61
+ targets.push(fs.realpathSync(raw));
62
+ } catch {
63
+ }
64
+ let match;
65
+ for (const target of targets) {
66
+ const alias = sourceAliases.find((prefix) => target === prefix || target.startsWith(`${prefix}${path.sep}`));
67
+ if (alias) {
68
+ match = { alias, target };
69
+ break;
70
+ }
71
+ }
72
+ if (!match) continue;
73
+ fs.unlinkSync(absolute);
74
+ fs.symlinkSync(path.relative(directory, path.join(copy, path.relative(match.alias, match.target))) || ".", absolute);
75
+ }
76
+ };
77
+ walk(copy);
78
+ }
79
+ function assertSelfContained(root) {
80
+ const manifest = readManifest(root);
81
+ const lookup = createRequire(path.join(root, "package.json")).resolve.paths("") ?? [];
82
+ const realRoot = fs.realpathSync(root);
83
+ const inside = (candidate) => candidate === realRoot || candidate.startsWith(`${realRoot}${path.sep}`);
84
+ for (const dependency of Object.keys(manifest.dependencies ?? {})) {
85
+ const found = lookup.map((directory) => path.join(directory, dependency)).find((candidate) => fs.existsSync(path.join(candidate, "package.json")));
86
+ if (!found) throw new Error(`dependency ${dependency} is not installed for ${root}`);
87
+ const resolved = fs.realpathSync(found);
88
+ if (!inside(resolved)) {
89
+ throw new Error(`dependency ${dependency} resolves outside the install (${resolved}); only a self-contained install (npm i -g) can be pinned`);
90
+ }
91
+ }
92
+ const walk = (directory) => {
93
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
94
+ const absolute = path.join(directory, entry.name);
95
+ if (entry.isDirectory()) walk(absolute);
96
+ else if (entry.isSymbolicLink() && path.isAbsolute(fs.readlinkSync(absolute))) {
97
+ let resolved;
98
+ try {
99
+ resolved = fs.realpathSync(absolute);
100
+ } catch {
101
+ continue;
102
+ }
103
+ if (!inside(resolved)) throw new Error(`link ${path.relative(root, absolute)} leads outside the install (${resolved})`);
104
+ }
105
+ }
106
+ };
107
+ walk(root);
108
+ }
109
+ function readPinMarker(pin) {
110
+ try {
111
+ return JSON.parse(fs.readFileSync(path.join(pin, PIN_MARKER), "utf8"));
112
+ } catch (error) {
113
+ if (error.code === "ENOENT") return void 0;
114
+ return {};
115
+ }
116
+ }
117
+ function assertPinUnchanged(pin, marker) {
118
+ if (typeof marker.contentSha256 !== "string" || marker.contentSha256 !== contentDigest(pin)) {
119
+ throw new Error(`pin ${pin} changed after it was created; remove it, then re-run: mm-harness pin`);
120
+ }
121
+ }
122
+ function sweepStaleStaging(store, now = Date.now()) {
123
+ let names;
124
+ try {
125
+ names = fs.readdirSync(store);
126
+ } catch {
127
+ return;
128
+ }
129
+ for (const name of names) {
130
+ const created = /^\.staging-.+-(\d{13})-[^-]+$/u.exec(name)?.[1];
131
+ if (created && now - Number(created) > STALE_STAGING_MS) {
132
+ fs.rmSync(path.join(store, name), { recursive: true, force: true });
133
+ }
134
+ }
135
+ }
136
+ function pinHarness(options = {}) {
137
+ const sourceAlias = path.resolve(options.sourceRoot ?? runnerDir);
138
+ const source = fs.realpathSync(sourceAlias);
139
+ const store = options.root ?? pinRoot();
140
+ const manifest = readManifest(source);
141
+ if (manifest.name !== PACKAGE_NAME || typeof manifest.version !== "string" || !manifest.version) {
142
+ throw new Error(`not an ${PACKAGE_NAME} package: ${source}`);
143
+ }
144
+ const version = manifest.version;
145
+ const ownExecutable = path.join(source, "bin/mm-harness");
146
+ if (isPin(source)) {
147
+ return { version, source, executable: ownExecutable, pinned: true, created: false, reason: "already-pinned" };
148
+ }
149
+ if (!source.includes(`${path.sep}node_modules${path.sep}`)) {
150
+ return { version, source, executable: ownExecutable, pinned: false, created: false, reason: "source-checkout" };
151
+ }
152
+ const key = `${version}-${contentDigest(source).slice(0, 12)}`;
153
+ const target = path.join(store, key);
154
+ const executable = path.join(target, "bin/mm-harness");
155
+ const existing = readPinMarker(target);
156
+ if (existing) {
157
+ assertPinUnchanged(target, existing);
158
+ return { version, source, executable, pinned: true, created: false };
159
+ }
160
+ assertSelfContained(source);
161
+ fs.mkdirSync(store, { recursive: true });
162
+ sweepStaleStaging(store);
163
+ const staging = fs.mkdtempSync(path.join(store, `.staging-${key}-${Date.now()}-`));
164
+ const copy = path.join(staging, "package");
165
+ try {
166
+ fs.cpSync(source, copy, {
167
+ recursive: true,
168
+ preserveTimestamps: true,
169
+ verbatimSymlinks: true,
170
+ mode: fs.constants.COPYFILE_FICLONE,
171
+ filter: (from) => !RUNNER_STATE.has(path.basename(from))
172
+ });
173
+ relinkIntoCopy(copy, [.../* @__PURE__ */ new Set([source, sourceAlias])]);
174
+ assertSelfContained(copy);
175
+ const check = spawnSync(path.join(copy, "bin/mm-harness"), ["--version"], {
176
+ encoding: "utf8",
177
+ env: { ...process.env, MM_HARNESS_BIN: "", MM_HARNESS_NO_UPDATE_CHECK: "1" },
178
+ timeout: 6e4
179
+ });
180
+ if (check.status !== 0 || check.stdout.trim() !== version) {
181
+ throw new Error(`the copied harness did not run (exit ${check.status}): ${(check.stderr || check.stdout || "").trim()}`);
182
+ }
183
+ const marker = { schemaVersion: 1, key, version, source, contentSha256: contentDigest(copy), createdAt: (/* @__PURE__ */ new Date()).toISOString() };
184
+ fs.writeFileSync(path.join(copy, PIN_MARKER), `${JSON.stringify(marker, null, 2)}
185
+ `);
186
+ try {
187
+ fs.renameSync(copy, target);
188
+ } catch (error) {
189
+ if (!isPin(target)) throw error;
190
+ return { version, source, executable, pinned: true, created: false };
191
+ }
192
+ return { version, source, executable, pinned: true, created: true };
193
+ } finally {
194
+ fs.rmSync(staging, { recursive: true, force: true });
195
+ }
196
+ }
197
+ function handlePin(argv) {
198
+ const json = argv.includes("--json");
199
+ try {
200
+ const result = pinHarness();
201
+ if (json) {
202
+ console.log(JSON.stringify({ schemaVersion: 1, command: "pin", status: "pass", ...result }, null, 2));
203
+ } else {
204
+ console.log(result.executable);
205
+ if (result.reason === "source-checkout") {
206
+ console.error("mm-harness pin: a source checkout is not copied; record it by its working-tree hash.");
207
+ }
208
+ }
209
+ return EXIT.ok;
210
+ } catch (error) {
211
+ const message = error instanceof Error ? error.message : String(error);
212
+ const userAction = "install the harness globally (npm i -g @deeeed/metamask-harness), then re-run: mm-harness pin";
213
+ if (json) {
214
+ console.log(JSON.stringify({ schemaVersion: 1, command: "pin", status: "fail", error: { code: "PIN_FAILED", message, userAction } }, null, 2));
215
+ } else {
216
+ console.error(`\u2717 mm-harness pin: ${message}
217
+ Next: ${userAction}`);
218
+ }
219
+ return EXIT.runtime;
220
+ }
221
+ }
222
+ export {
223
+ handlePin,
224
+ pinHarness,
225
+ pinRoot
226
+ };
@@ -14,9 +14,6 @@ import {
14
14
  import { resolveActionManifest, validateManifest } from "../manifest.js";
15
15
  import { getAdapterSurface } from "../adapters/surface.js";
16
16
  import { extensionProductConfigBlock } from "../adapters/extension/product-config.js";
17
- import {
18
- requireCurrentMobileReleaseArtifactState
19
- } from "../adapters/mobile/release-artifact-state.js";
20
17
  import {
21
18
  importRecipeHarness,
22
19
  importRecipeProtocol,
@@ -121,13 +118,8 @@ async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManif
121
118
  );
122
119
  }
123
120
  } finally {
124
- if (adapter === "mobile") {
125
- const { hideMobileHudOnTeardown } = await import("../adapters.js");
126
- await hideMobileHudOnTeardown(
127
- projectRoot,
128
- recipeRunEnv(adapter, runtimeOptions)
129
- );
130
- }
121
+ const teardown = getAdapterSurface(adapter).run?.teardown;
122
+ if (teardown) await teardown(projectRoot, recipeRunEnv(adapter, runtimeOptions));
131
123
  }
132
124
  let recordingError;
133
125
  if (executionError === void 0 && result) {
@@ -240,11 +232,12 @@ async function resolveRecipeExecution(adapter, recipe, artifactsDir, projectRoot
240
232
  projectRoot,
241
233
  rootRecipe: recipeDocument
242
234
  });
243
- const suppressHudForOpaqueMobile = adapter === "mobile" && process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME === "1";
235
+ const surface = getAdapterSurface(adapter);
236
+ const suppressAutoHud = surface.run?.autoHud?.() === false;
244
237
  const runnerOptions = {
245
238
  quietStdout: runtimeOptions.stdoutIsMachineContract === true,
246
239
  suppressLibraryResolutionLogs: runtimeOptions.suppressLibraryResolutionLogs,
247
- autoHud: suppressHudForOpaqueMobile ? false : trust.source && trust.source.trust !== "trusted" ? false : runtimeOptions.autoHud,
240
+ autoHud: suppressAutoHud ? false : trust.source && trust.source.trust !== "trusted" ? false : runtimeOptions.autoHud,
248
241
  onActionEvent: runtimeOptions.onActionEvent,
249
242
  actionSources,
250
243
  // A direct engineer invocation is the explicit trust boundary for a
@@ -256,7 +249,7 @@ async function resolveRecipeExecution(adapter, recipe, artifactsDir, projectRoot
256
249
  ...runnerOptions,
257
250
  trustedMutation
258
251
  });
259
- const useFramedExtensionRecording = adapter === "extension" && recordVideo === "full-run" && trust.source?.trust !== "untrusted" && trust.source?.trust !== "unknown" && captureHelperSupportsRecordSessionSnapshots(projectRoot);
252
+ const useFramedExtensionRecording = surface.recording?.framed !== void 0 && recordVideo === "full-run" && trust.source?.trust !== "untrusted" && trust.source?.trust !== "unknown" && captureHelperSupportsRecordSessionSnapshots(projectRoot);
260
253
  const runRequest = {
261
254
  ...recipeDocument !== void 0 ? { recipeDocument } : absoluteRecipePath ? { recipePath: absoluteRecipePath } : { recipeDocument: recipe },
262
255
  artifactsDir: absoluteArtifactsDir,
@@ -447,31 +440,22 @@ function executionLibrarySources(sources, invocationTrust) {
447
440
  };
448
441
  });
449
442
  }
443
+ const RUN_ENV_KEYS = [
444
+ "CDP_PORT",
445
+ "RECIPE_CDP_PORT",
446
+ "WATCHER_PORT",
447
+ "METRO_PORT",
448
+ "METAMASK_RECIPE_EXTENSION_AUTOLAUNCH"
449
+ ];
450
450
  function activateRecipeRuntimeEnvironment(adapter, projectRoot, runtimeOptions) {
451
- const previousCdpPort = process.env.CDP_PORT;
452
- const previousRecipeCdpPort = process.env.RECIPE_CDP_PORT;
453
- const previousWatcherPort = process.env.WATCHER_PORT;
454
- const previousMetroPort = process.env.METRO_PORT;
455
- const previousExtensionAutolaunch = process.env.METAMASK_RECIPE_EXTENSION_AUTOLAUNCH;
456
- const previousMobileOpaqueRuntime = process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME;
457
- const previousAndroidPackageId = process.env.ANDROID_PACKAGE_ID;
458
- const previousAdbSerial = process.env.ADB_SERIAL;
459
- const previousAndroidSerial = process.env.ANDROID_SERIAL;
460
- const explicitMobileDeviceEnv = adapter === "mobile" && process.env.MM_HARNESS_EXPLICIT_PLATFORM ? Object.fromEntries(
461
- [
462
- "PLATFORM",
463
- "MM_HARNESS_EXPLICIT_PLATFORM",
464
- "IOS_SIMULATOR",
465
- "SIM_UDID",
466
- "ADB_SERIAL",
467
- "ANDROID_SERIAL",
468
- "ANDROID_DEVICE",
469
- "ANDROID_TARGET_DEVICE_NAME"
470
- ].map((key) => [key, process.env[key]])
471
- ) : void 0;
472
- getAdapterSurface(adapter).resolveSlotPorts(projectRoot);
473
- if (explicitMobileDeviceEnv) {
474
- for (const [key, value] of Object.entries(explicitMobileDeviceEnv)) {
451
+ const surface = getAdapterSurface(adapter);
452
+ const previousEnv = Object.fromEntries(
453
+ [...RUN_ENV_KEYS, ...surface.run?.envKeys ?? []].map((key) => [key, process.env[key]])
454
+ );
455
+ const pinnedEnv = surface.run?.pinnedEnv?.();
456
+ surface.resolveSlotPorts(projectRoot);
457
+ if (pinnedEnv) {
458
+ for (const [key, value] of Object.entries(pinnedEnv)) {
475
459
  restoreEnv(key, value);
476
460
  }
477
461
  }
@@ -486,37 +470,9 @@ function activateRecipeRuntimeEnvironment(adapter, projectRoot, runtimeOptions)
486
470
  if (runtimeOptions.launchExistingDist) {
487
471
  process.env.METAMASK_RECIPE_EXTENSION_AUTOLAUNCH = "1";
488
472
  }
489
- if (adapter === "mobile") {
490
- const state = requireCurrentMobileReleaseArtifactState(projectRoot);
491
- if (state) {
492
- const selected = process.env.ADB_SERIAL ?? process.env.ANDROID_SERIAL;
493
- if (selected && selected !== state.deviceSerial) {
494
- throw new Error(
495
- `The Mobile release artifact is installed on ${state.deviceSerial}, not ${selected}. Next: rerun runtime-launch for ${selected}.`
496
- );
497
- }
498
- process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME = "1";
499
- process.env.ANDROID_PACKAGE_ID = state.packageId;
500
- process.env.ADB_SERIAL = state.deviceSerial;
501
- process.env.ANDROID_SERIAL = state.deviceSerial;
502
- }
503
- }
473
+ surface.run?.activateEnv?.(projectRoot);
504
474
  return () => {
505
- restoreEnv("CDP_PORT", previousCdpPort);
506
- restoreEnv("RECIPE_CDP_PORT", previousRecipeCdpPort);
507
- restoreEnv("WATCHER_PORT", previousWatcherPort);
508
- restoreEnv("METRO_PORT", previousMetroPort);
509
- restoreEnv(
510
- "METAMASK_RECIPE_EXTENSION_AUTOLAUNCH",
511
- previousExtensionAutolaunch
512
- );
513
- restoreEnv(
514
- "METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME",
515
- previousMobileOpaqueRuntime
516
- );
517
- restoreEnv("ANDROID_PACKAGE_ID", previousAndroidPackageId);
518
- restoreEnv("ADB_SERIAL", previousAdbSerial);
519
- restoreEnv("ANDROID_SERIAL", previousAndroidSerial);
475
+ for (const [key, value] of Object.entries(previousEnv)) restoreEnv(key, value);
520
476
  };
521
477
  }
522
478
  function actionSourceMap(sources) {
@@ -539,42 +495,11 @@ function recipeRunEnv(adapter, runtimeOptions = {}) {
539
495
  PLATFORM: process.env.PLATFORM,
540
496
  MM_HARNESS_EXPLICIT_PLATFORM: process.env.MM_HARNESS_EXPLICIT_PLATFORM
541
497
  };
542
- if (adapter !== "mobile") return base;
543
- return {
544
- ...base,
545
- WATCHER_PORT: process.env.WATCHER_PORT ?? base.CDP_PORT ?? base.RECIPE_CDP_PORT,
546
- METRO_PORT: process.env.METRO_PORT ?? process.env.WATCHER_PORT ?? base.CDP_PORT ?? base.RECIPE_CDP_PORT,
547
- IOS_SIMULATOR: process.env.IOS_SIMULATOR,
548
- SIM_UDID: process.env.SIM_UDID,
549
- ANDROID_DEVICE: process.env.ANDROID_DEVICE,
550
- ANDROID_TARGET_DEVICE_NAME: process.env.ANDROID_TARGET_DEVICE_NAME,
551
- ADB_SERIAL: process.env.ADB_SERIAL,
552
- ANDROID_SERIAL: process.env.ANDROID_SERIAL,
553
- ANDROID_PACKAGE_ID: process.env.ANDROID_PACKAGE_ID,
554
- METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME: process.env.METAMASK_RECIPE_MOBILE_OPAQUE_RUNTIME
555
- };
498
+ const childEnv = getAdapterSurface(adapter).run?.childEnv;
499
+ return childEnv ? childEnv(base) : base;
556
500
  }
557
501
  async function prepareRuntimeIfNeeded(adapter, projectRoot, runtimeOptions) {
558
- if (adapter !== "extension" || runtimeOptions.skipExtensionRuntimePrepare === true)
559
- return;
560
- const { releaseArtifactState, runtimeDistCheck } = await import("../adapters/extension/runtime-decision.js");
561
- const artifact = releaseArtifactState(projectRoot);
562
- const runtimeSnapshot = runtimeDistCheck(projectRoot, artifact);
563
- if (artifact.status !== "none") {
564
- if (artifact.status === "valid" && runtimeSnapshot.status === "fresh") return;
565
- throw new Error(
566
- "The loaded release artifact identity is invalid or stale. Next: rerun runtime-launch with the same artifact source."
567
- );
568
- }
569
- const { prepareExtensionRuntime } = await import("../adapters/extension/runtime.js");
570
- await prepareExtensionRuntime({
571
- projectRoot,
572
- cdpPort: runtimeOptions.cdpPort,
573
- slot: runtimeOptions.slot,
574
- launchExistingDist: runtimeOptions.launchExistingDist === true,
575
- validationRuntimeDir: runtimeOptions.validationRuntimeDir,
576
- pageMode: "home-or-sidepanel"
577
- });
502
+ await getAdapterSurface(adapter).run?.prepareRuntime?.(projectRoot, runtimeOptions);
578
503
  }
579
504
  function restoreEnv(key, value) {
580
505
  if (value === void 0) delete process.env[key];
@@ -1051,7 +976,15 @@ async function prepareHeal(adapter, target, options, json, prepareOptions = {})
1051
976
  return EXIT.usage;
1052
977
  }
1053
978
  const state = newHealState();
1054
- const mobileArtifact = adapter === "mobile" ? requireCurrentMobileReleaseArtifactState(target) : null;
979
+ const runtimeCheck = getAdapterSurface(adapter).run?.runtimeCheck?.({
980
+ target,
981
+ options,
982
+ heal,
983
+ state,
984
+ json,
985
+ appRestartAuthored: prepareOptions.appRestartAuthored,
986
+ onRecovery: (code) => prepareOptions.onPhase?.("recover", { code })
987
+ });
1055
988
  prepareOptions.onPhase?.("install");
1056
989
  const ensured = await ensureOverlay(
1057
990
  adapter,
@@ -1089,41 +1022,8 @@ async function prepareHeal(adapter, target, options, json, prepareOptions = {})
1089
1022
  return EXIT.infra;
1090
1023
  }
1091
1024
  prepareOptions.onPhase?.("healthcheck");
1092
- if (adapter === "extension") {
1093
- const previousCdpPort = process.env.CDP_PORT;
1094
- const previousRecipeCdpPort = process.env.RECIPE_CDP_PORT;
1095
- const previousWatcherPort = process.env.WATCHER_PORT;
1096
- const explicitCdpPort = optionString(options, "cdpPort");
1097
- const explicitWatcherPort = optionString(options, "watcherPort") ?? optionString(options, "metroPort");
1098
- if (explicitCdpPort) {
1099
- process.env.CDP_PORT = explicitCdpPort;
1100
- process.env.RECIPE_CDP_PORT = explicitCdpPort;
1101
- }
1102
- if (explicitWatcherPort) process.env.WATCHER_PORT = explicitWatcherPort;
1103
- try {
1104
- const current = await ensureExtensionProofRuntime(
1105
- target,
1106
- heal,
1107
- state,
1108
- json,
1109
- {
1110
- onRecovery: (code) => prepareOptions.onPhase?.("recover", { code })
1111
- }
1112
- );
1113
- if (current !== null) return current;
1114
- } finally {
1115
- restoreEnv("CDP_PORT", previousCdpPort);
1116
- restoreEnv("RECIPE_CDP_PORT", previousRecipeCdpPort);
1117
- restoreEnv("WATCHER_PORT", previousWatcherPort);
1118
- }
1119
- }
1120
- if (adapter === "mobile" && mobileArtifact === null && prepareOptions.skipMobileSourceFreshness !== true) {
1121
- const current = await ensureMobileProofRuntime(
1122
- target,
1123
- heal,
1124
- state,
1125
- json
1126
- );
1025
+ if (runtimeCheck) {
1026
+ const current = await runtimeCheck();
1127
1027
  if (current !== null) return current;
1128
1028
  }
1129
1029
  return { state, heal };
@@ -1372,7 +1272,7 @@ async function executeWithHealBounds(exec, target, state) {
1372
1272
  };
1373
1273
  }
1374
1274
  function emitHealViolation(json, command, result, violation, state, adapter) {
1375
- const userAction = adapter === "core" && violation.code === "WALLET_STATE_REQUIRED" ? "for signing, configure a reserved wallet-fixture.json account and pass its non-empty name; an explicit 0x address is supported only by read-only actions" : violation.userAction ?? `inspect ${shellQuoteArg(result.summaryPath)} and ${shellQuoteArg(result.tracePath)}; fix the application or recipe failure before retrying`;
1275
+ const userAction = (adapter ? getAdapterSurface(adapter).run?.violationUserAction?.(violation) : void 0) ?? violation.userAction ?? `inspect ${shellQuoteArg(result.summaryPath)} and ${shellQuoteArg(result.tracePath)}; fix the application or recipe failure before retrying`;
1376
1276
  if (json) {
1377
1277
  console.log(
1378
1278
  JSON.stringify(
@@ -1,8 +1,7 @@
1
1
  import fs from "node:fs";
2
- import { createRequire } from "node:module";
3
2
  import path from "node:path";
4
3
  import { execFileSync } from "node:child_process";
5
- import { recipeRuntimeDir } from "../paths.js";
4
+ import { getAdapterSurface } from "../adapters/surface.js";
6
5
  import { isRecord } from "./parse-args.js";
7
6
  function writeRunReport(result) {
8
7
  const summaryPath = String(result.summaryPath);
@@ -95,33 +94,6 @@ function indexProductProvenanceArtifact(artifactManifestPath, target, adapter, b
95
94
  `
96
95
  );
97
96
  }
98
- function readRecord(file) {
99
- try {
100
- const recorded = JSON.parse(fs.readFileSync(file, "utf8"));
101
- return isRecord(recorded) ? recorded : null;
102
- } catch {
103
- return null;
104
- }
105
- }
106
- const browserCdp = createRequire(import.meta.url)("../../adapters/shared/browser-cdp.cjs");
107
- function verifyLaunch(record, cdpPort) {
108
- const launch = isRecord(record.launch) ? record.launch : {};
109
- const pid = Number(launch.pid);
110
- const port = Number(launch.cdpPort);
111
- if (!Number.isInteger(pid) || !Number.isInteger(port) || typeof launch.profile !== "string" || typeof launch.processStartedAt !== "string") {
112
- return "the browser record has no process identity (pid, start time, profile, CDP port)";
113
- }
114
- if (cdpPort && port !== Number(cdpPort)) return `the browser record is for CDP port ${port}, not ${cdpPort}`;
115
- try {
116
- const owner = browserCdp.cdpOwner(port, launch.profile);
117
- if (owner.pid !== pid || owner.startedAt !== launch.processStartedAt) {
118
- return `CDP port ${port} is now held by another browser process than the recorded pid ${pid}`;
119
- }
120
- } catch (error) {
121
- return error instanceof Error ? error.message : String(error);
122
- }
123
- return null;
124
- }
125
97
  function resolveTemplatedPort(value, params, recipe) {
126
98
  let resolved = value;
127
99
  const template = /^\{\{\s*params\.([\w-]+)\s*\}\}$/u.exec(String(value).trim());
@@ -155,26 +127,11 @@ function recipeCdpPorts(request) {
155
127
  return [...ports];
156
128
  }
157
129
  function executedBrowser(adapter, target, artifactManifestPath, ports) {
158
- if (adapter !== "extension") return null;
130
+ const launched = getAdapterSurface(adapter).run?.launchedBrowser;
131
+ if (!launched) return null;
159
132
  if (ports.length === 0) return { boundTo: "none", reason: "the run had no known CDP port" };
160
133
  if (ports.length > 1) return { boundTo: "none", reason: `the run drove several CDP ports (${ports.join(", ")})` };
161
- return launchedBrowser(target, path.dirname(artifactManifestPath), ports[0]);
162
- }
163
- function launchedBrowser(target, artifactsDir, cdpPort) {
164
- const runtime = readRecord(path.join(artifactsDir, "extension-runtime", "runtime.json"));
165
- const autolaunched = runtime && isRecord(runtime.browser) ? runtime.browser : null;
166
- const candidates = [];
167
- if (autolaunched) candidates.push([autolaunched, "run-autolaunch"]);
168
- const slot = readRecord(path.join(path.resolve(target), recipeRuntimeDir(), "browser-resolution.json"));
169
- if (slot) candidates.push([slot, "slot-runtime"]);
170
- if (candidates.length === 0) return null;
171
- const reasons = [];
172
- for (const [record, boundTo] of candidates) {
173
- const problem = verifyLaunch(record, cdpPort);
174
- if (problem === null) return { ...record, boundTo };
175
- reasons.push(`${boundTo}: ${problem}`);
176
- }
177
- return { boundTo: "none", reason: reasons.join("; ") };
134
+ return launched(target, path.dirname(artifactManifestPath), ports[0]);
178
135
  }
179
136
  function renderRunReport(summary, entries) {
180
137
  const lines = [
@@ -279,7 +236,6 @@ function indexRunReportArtifact(artifactManifestPath) {
279
236
  export {
280
237
  executedBrowser,
281
238
  indexProductProvenanceArtifact,
282
- launchedBrowser,
283
239
  recipeCdpPorts,
284
240
  writeRunReport
285
241
  };
@@ -28,8 +28,6 @@ import {
28
28
  import { handleDescribeRecipe, handleListExecutables } from "./list-executables.js";
29
29
  import { applyDeviceTargeting } from "./device-target.js";
30
30
  import { getAdapterSurface } from "../adapters/surface.js";
31
- import { coreDependencyBlock } from "./core-readiness.js";
32
- import { readMobileReleaseArtifactState } from "../adapters/mobile/release-artifact-state.js";
33
31
  import {
34
32
  indexProductProvenanceArtifact,
35
33
  writeRunReport
@@ -244,7 +242,10 @@ async function handleRunInner({ positional, options }, stream) {
244
242
  actionLibraryContextArgs(validated.librarySources)
245
243
  );
246
244
  }
247
- const depsBlock = adapter === "core" && await recipeUsesCoreController(validated.recipe, validated.librarySources) ? coreDependencyBlock(target) : null;
245
+ const depsBlock = await getAdapterSurface(adapter).run?.dependencyBlock?.(target, {
246
+ recipe: validated.recipe,
247
+ librarySources: validated.librarySources
248
+ }) ?? null;
248
249
  if (depsBlock) {
249
250
  return emitRunUsageError(jsonOutput, stream, adapter, validated.recipeFile, depsBlock.code, depsBlock.message, depsBlock.userAction);
250
251
  }
@@ -295,8 +296,7 @@ async function handleRunInner({ positional, options }, stream) {
295
296
  }
296
297
  try {
297
298
  const prepared = await prepareHeal(adapter, target, options, machine, {
298
- onPhase: (phase, fields) => stream.phase(phase, fields),
299
- skipMobileSourceFreshness: adapter === "mobile" && readMobileReleaseArtifactState(target) !== null
299
+ onPhase: (phase, fields) => stream.phase(phase, fields)
300
300
  });
301
301
  if (typeof prepared === "number") {
302
302
  stream.error({
@@ -511,48 +511,6 @@ function formatPreviewLine(line, out) {
511
511
  const rest = match[2];
512
512
  return `${out(status === "PASS" ? "ok" : "err", status)} ${rest}`;
513
513
  }
514
- async function recipeUsesCoreController(recipe, librarySources) {
515
- if (objectUsesCoreController(recipe)) return true;
516
- const refs = collectCallRefs(recipe);
517
- if (refs.length === 0) return false;
518
- const sources = librarySources ?? [];
519
- if (sources.length === 0) return false;
520
- const { loadRecipeLibraries } = await import("@farmslot/recipe-harness");
521
- const recipes = (await loadRecipeLibraries(sources, { adapter: "core" })).recipes;
522
- const visited = /* @__PURE__ */ new Set();
523
- const visit = (ref) => {
524
- if (visited.has(ref)) return false;
525
- visited.add(ref);
526
- const dependency = recipes.get(ref);
527
- if (!dependency) return false;
528
- if (objectUsesCoreController(dependency.document)) return true;
529
- return collectCallRefs(dependency.document).some(visit);
530
- };
531
- return refs.some(visit);
532
- }
533
- function objectUsesCoreController(value) {
534
- if (Array.isArray(value)) return value.some(objectUsesCoreController);
535
- if (!isRecord(value)) return false;
536
- if (actionUsesCoreController(value.action)) return true;
537
- return Object.values(value).some(objectUsesCoreController);
538
- }
539
- function collectCallRefs(value) {
540
- const refs = [];
541
- const visit = (node) => {
542
- if (Array.isArray(node)) {
543
- for (const item of node) visit(item);
544
- return;
545
- }
546
- if (!isRecord(node)) return;
547
- if (node.action === "call" && typeof node.ref === "string" && node.ref) refs.push(node.ref);
548
- for (const child of Object.values(node)) visit(child);
549
- };
550
- visit(value);
551
- return [...new Set(refs)];
552
- }
553
- function actionUsesCoreController(action) {
554
- return typeof action === "string" && action.startsWith("metamask.perps.");
555
- }
556
514
  async function handleRunPlan(recipeArg, params, options, stream) {
557
515
  const json = optionFlag(options, "json");
558
516
  const jsonOutput = json && !stream.enabled;
@@ -635,7 +593,7 @@ async function handleRunPlan(recipeArg, params, options, stream) {
635
593
  status: "planned",
636
594
  detail: "would auto-ensure the runtime overlay if missing (install phase)"
637
595
  },
638
- ...adapter === "core" ? [] : [
596
+ ...getAdapterSurface(adapter).headless ? [] : [
639
597
  {
640
598
  step: "launch.app",
641
599
  confidence: "conditional",
package/dist/doctor.js CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  } from "../library/actions/mobile/platform/tool-paths.mjs";
11
11
  import { mobilePerpsEnvironment } from "./adapters/mobile/perps-env.js";
12
12
  import { readRuntimeContextField, resolveRuntimeContextPath } from "./harness.js";
13
+ import { isPin } from "./harness-pin.js";
13
14
  import { harnessExecutable, manifestPath, readJson, recipeHarnessRoot, recipeRuntimeDir, runnerDir } from "./paths.js";
14
15
  function requiredDoctorCheckSummary(checks) {
15
16
  const required = checks.filter((check) => check.required);
@@ -281,6 +282,7 @@ function mobileToolDoctorChecks(platform) {
281
282
  });
282
283
  }
283
284
  function runnerInstallKind(runnerRoot, invokedPath, executablePath) {
285
+ if (isPin(runnerRoot)) return "pinned-install";
284
286
  const normalizedRoot = path.normalize(runnerRoot);
285
287
  const nodeModulesSegment = `${path.sep}node_modules${path.sep}`;
286
288
  const globalNodeModulesSegment = `${path.sep}lib${path.sep}node_modules${path.sep}`;
@@ -315,7 +317,7 @@ function runnerProvenance(actionManifestPath) {
315
317
  const executablePath = harnessExecutable();
316
318
  const invokedPath = path.resolve(process.env.MM_HARNESS_INVOKED_AS ?? executablePath);
317
319
  const installKind = runnerInstallKind(runnerDir, invokedPath, executablePath);
318
- const packageSource = runnerDir.includes(`${path.sep}node_modules${path.sep}`) ? "node_modules" : "source-checkout";
320
+ const packageSource = isPin(runnerDir) ? "pin" : runnerDir.includes(`${path.sep}node_modules${path.sep}`) ? "node_modules" : "source-checkout";
319
321
  return {
320
322
  name: "@metamask/recipe-runner",
321
323
  packageName,