@deeeed/metamask-harness 0.74.0 → 0.76.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 (96) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +1 -1
  3. package/adapters/shared/ensure-runner-deps.sh +15 -3
  4. package/adapters/shared/reap-checkout-metros.sh +9 -0
  5. package/dist/action-values.js +15 -0
  6. package/dist/adapters/core/actions.js +20 -0
  7. package/dist/adapters/core/live-script-env.js +36 -0
  8. package/dist/adapters/core/overlay.js +34 -0
  9. package/dist/adapters/core/readiness.js +8 -0
  10. package/dist/adapters/core/run.js +54 -0
  11. package/dist/adapters/core/surface.js +12 -1
  12. package/dist/adapters/extension/action-inputs.js +206 -0
  13. package/dist/adapters/extension/actions.js +51 -0
  14. package/dist/adapters/extension/console-capture.js +13 -1
  15. package/dist/adapters/extension/doctor-checks.js +135 -0
  16. package/dist/adapters/extension/fixtures.js +103 -0
  17. package/dist/adapters/extension/launch.js +315 -0
  18. package/dist/adapters/extension/launched-browser.js +51 -0
  19. package/dist/adapters/extension/overlay.js +55 -0
  20. package/dist/adapters/extension/readiness.js +32 -0
  21. package/dist/adapters/extension/reload.js +40 -0
  22. package/dist/adapters/extension/run.js +105 -0
  23. package/dist/adapters/extension/surface.js +31 -3
  24. package/dist/adapters/harness-freshness.js +4 -2
  25. package/dist/adapters/mobile/actions.js +54 -0
  26. package/dist/adapters/mobile/devices.js +21 -0
  27. package/dist/adapters/mobile/doctor-checks.js +40 -0
  28. package/dist/adapters/mobile/fixtures.js +141 -0
  29. package/dist/adapters/mobile/launch.js +192 -0
  30. package/dist/adapters/mobile/lifecycle.js +300 -0
  31. package/dist/adapters/mobile/overlay.js +27 -0
  32. package/dist/adapters/mobile/prepare-target.js +38 -0
  33. package/dist/adapters/mobile/readiness.js +39 -0
  34. package/dist/adapters/mobile/reload.js +15 -0
  35. package/dist/adapters/mobile/run.js +156 -0
  36. package/dist/adapters/mobile/runtime-identity.js +34 -0
  37. package/dist/adapters/mobile/surface.js +39 -3
  38. package/dist/adapters/mobile/ui.js +701 -0
  39. package/dist/adapters/performance/cdp-trace.js +24 -6
  40. package/dist/adapters/performance/device-platform.js +10 -0
  41. package/dist/adapters/registry.js +1 -1
  42. package/dist/adapters/terminal/actions.js +43 -0
  43. package/dist/adapters/terminal/console-capture.js +14 -1
  44. package/dist/adapters/terminal/doctor-checks.js +22 -0
  45. package/dist/adapters/terminal/overlay.js +18 -0
  46. package/dist/adapters/terminal/readiness.js +11 -0
  47. package/dist/adapters/terminal/run.js +30 -0
  48. package/dist/adapters/terminal/surface.js +21 -3
  49. package/dist/adapters.js +32 -824
  50. package/dist/cli-commands.js +1 -0
  51. package/dist/cli.js +3 -1
  52. package/dist/command-contract.js +3 -0
  53. package/dist/commands/call.js +2 -5
  54. package/dist/commands/debug.js +6 -25
  55. package/dist/commands/device-target.js +2 -1
  56. package/dist/commands/doctor.js +26 -137
  57. package/dist/commands/fixtures.js +13 -217
  58. package/dist/commands/launch/device.js +2 -6
  59. package/dist/commands/launch/extension.js +3 -303
  60. package/dist/commands/launch/index.js +1 -1
  61. package/dist/commands/launch/mobile.js +2 -190
  62. package/dist/commands/launch/quote.js +7 -0
  63. package/dist/commands/logs.js +1 -1
  64. package/dist/commands/parse-args.js +9 -4
  65. package/dist/commands/pin.js +226 -0
  66. package/dist/commands/prepare.js +23 -53
  67. package/dist/commands/provision.js +3 -7
  68. package/dist/commands/reload.js +8 -47
  69. package/dist/commands/run-engine.js +38 -138
  70. package/dist/commands/run-report.js +4 -48
  71. package/dist/commands/run.js +6 -48
  72. package/dist/commands/runtime-decision.js +5 -27
  73. package/dist/commands/self-test.js +1 -3
  74. package/dist/commands/status.js +20 -28
  75. package/dist/commands/stop.js +1 -1
  76. package/dist/doctor.js +12 -120
  77. package/dist/execution-provenance.js +3 -2
  78. package/dist/harness-pin.js +10 -0
  79. package/dist/harness.js +31 -100
  80. package/dist/heal-bounds.js +3 -2
  81. package/dist/live-adapter-contract.js +8 -34
  82. package/dist/manifest.js +3 -5
  83. package/dist/metamask-action-validation.js +5 -196
  84. package/dist/mm-harness-cli.js +30 -4
  85. package/dist/network-observation.js +10 -3
  86. package/dist/paths.js +8 -2
  87. package/dist/performance-observation.js +15 -22
  88. package/dist/pr-body/render.js +3 -1
  89. package/dist/recording-target.js +4 -39
  90. package/dist/run-diagnostics.js +21 -60
  91. package/dist/run-recording.js +5 -4
  92. package/dist/runner.js +20 -368
  93. package/dist/runtime-context.js +12 -17
  94. package/docs/CONTRIBUTING.md +29 -7
  95. package/package.json +2 -1
  96. package/scripts/completions.sh +2 -1
@@ -1,6 +1,5 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { readExtensionPinnedFlags } from "../adapters/extension/feature-flags.js";
4
3
  import { featureFlagReport } from "../feature-flags.js";
5
4
  import { color } from "../cli-color.js";
6
5
  import { collectCheckoutView, renderCheckoutView } from "../checkout-view.js";
@@ -10,15 +9,6 @@ import { getAdapterSurface } from "../adapters/surface.js";
10
9
  import {
11
10
  applyDeviceTargeting
12
11
  } from "./device-target.js";
13
- import {
14
- mobileDeviceView,
15
- mobileDeviceLiveView,
16
- nextForLive,
17
- renderAndroidPortHints,
18
- renderMobileDeviceList,
19
- renderAdditionalReachableDevices,
20
- renderMobileLiveBlock
21
- } from "./mobile-device-view.js";
22
12
  import { ADAPTER_DETECT_NEXT, EXIT, usageOut } from "./shared.js";
23
13
  import { handleStatusTaskView, renderAcceptanceLines } from "./status-watch.js";
24
14
  import { collectTaskView, findTaskDir } from "../task-view.js";
@@ -45,23 +35,25 @@ async function handleStatus({ options }) {
45
35
  const view = collectCheckoutView(target, adapter);
46
36
  const surface = getAdapterSurface(adapter);
47
37
  surface.resolveSlotPorts(target);
48
- if (adapter === "mobile") {
38
+ const { readiness } = surface;
39
+ const deviceSource = await readiness.devices?.();
40
+ if (deviceSource) {
49
41
  const dtResult = applyDeviceTargeting("status", adapter, options, { gate: false, rerun: "" });
50
42
  if ("code" in dtResult) {
51
43
  return usageOut(json, "status", dtResult.message, dtResult.userAction);
52
44
  }
53
45
  }
54
46
  const next = surface.hints.relaunch;
55
- const extensionFlags = adapter === "extension" ? readExtensionPinnedFlags(target) : null;
56
- const staticFeatureFlags = extensionFlags ? featureFlagReport(extensionFlags.overrides, extensionFlags) : featureFlagReport(null);
57
- const deviceView = adapter === "mobile" ? mobileDeviceView(allDevices) : null;
47
+ const pinnedFlags = readiness.pinnedFlags?.(target) ?? null;
48
+ const staticFeatureFlags = pinnedFlags ? featureFlagReport(pinnedFlags.overrides, pinnedFlags) : featureFlagReport(null);
49
+ const deviceView = deviceSource ? deviceSource.view(allDevices) : null;
58
50
  const devices = deviceView?.devices ?? [];
59
51
  const discovery = deviceView?.deviceDiscoveryErrors.length ? { deviceDiscoveryErrors: deviceView.deviceDiscoveryErrors } : {};
60
52
  const discoveryNext = discovery.deviceDiscoveryErrors?.[0]?.userAction;
61
53
  if (json) {
62
- const doProbe = !fast && adapter === "mobile" && deviceView !== null && deviceView.allConnectedDevices.length > 0;
54
+ const doProbe = !fast && deviceSource && deviceView !== null && deviceView.allConnectedDevices.length > 0;
63
55
  if (doProbe) {
64
- const liveView = await mobileDeviceLiveView(target, deviceView);
56
+ const liveView = await deviceSource.live(target, deviceView);
65
57
  console.log(
66
58
  JSON.stringify(
67
59
  {
@@ -75,14 +67,14 @@ async function handleStatus({ options }) {
75
67
  ...discovery,
76
68
  ...liveView.additionalReachableDevices.length > 0 ? { additionalReachableDevices: liveView.additionalReachableDevices } : {},
77
69
  ...liveView.androidPortHints.length > 0 ? { androidPortHints: liveView.androidPortHints } : {},
78
- next: discoveryNext ?? nextForLive(next, liveView.liveMap)
70
+ next: discoveryNext ?? deviceSource.nextForLive(next, liveView.liveMap)
79
71
  },
80
72
  null,
81
73
  2
82
74
  )
83
75
  );
84
76
  } else {
85
- const runtime = !fast && adapter === "extension" ? await surface.runtimeStatus(target) : void 0;
77
+ const runtime = !fast && readiness.statusRuntime ? await surface.runtimeStatus(target) : void 0;
86
78
  console.log(
87
79
  JSON.stringify(
88
80
  {
@@ -113,21 +105,21 @@ async function handleStatus({ options }) {
113
105
  console.log(renderAcceptanceLines(taskView).join("\n"));
114
106
  }
115
107
  }
116
- if (adapter !== "mobile") renderFeatureFlagLine(staticFeatureFlags, out);
117
- if (deviceView) renderMobileDeviceList(deviceView, out);
118
- if (!fast && adapter === "mobile" && deviceView !== null && deviceView.allConnectedDevices.length > 0) {
119
- const liveView = await mobileDeviceLiveView(target, deviceView);
120
- if (liveView.additionalReachableDevices.length > 0) renderAdditionalReachableDevices(liveView.additionalReachableDevices, out);
108
+ if (!deviceSource) renderFeatureFlagLine(staticFeatureFlags, out);
109
+ if (deviceSource && deviceView) deviceSource.renderList(deviceView, out);
110
+ if (!fast && deviceSource && deviceView !== null && deviceView.allConnectedDevices.length > 0) {
111
+ const liveView = await deviceSource.live(target, deviceView);
112
+ if (liveView.additionalReachableDevices.length > 0) deviceSource.renderAdditional(liveView.additionalReachableDevices, out);
121
113
  renderFeatureFlagLine(featureFlagReport(liveView.featureFlagOverrides), out);
122
- renderMobileLiveBlock(devices, liveView.liveMap, out);
123
- renderAndroidPortHints(liveView.androidPortHints, out);
124
- console.log(`${out("label", "Next:")} ${out("cmd", discoveryNext ?? nextForLive(next, liveView.liveMap))}`);
125
- } else if (!fast && adapter === "extension") {
114
+ deviceSource.renderLive(devices, liveView.liveMap, out);
115
+ deviceSource.renderPortHints(liveView.androidPortHints, out);
116
+ console.log(`${out("label", "Next:")} ${out("cmd", discoveryNext ?? deviceSource.nextForLive(next, liveView.liveMap))}`);
117
+ } else if (!fast && readiness.statusRuntime) {
126
118
  const runtime = await surface.runtimeStatus(target);
127
119
  renderRuntimeStatus(runtime, out);
128
120
  console.log(`${out("label", "Next:")} ${out("cmd", nextForRuntime(surface.hints.launch, surface.hints.relaunch, runtime))}`);
129
121
  } else {
130
- if (adapter === "mobile") renderFeatureFlagLine(featureFlagReport(null), out);
122
+ if (deviceSource) renderFeatureFlagLine(featureFlagReport(null), out);
131
123
  console.log(`${out("label", "Next:")} ${out("cmd", discoveryNext ?? next)}`);
132
124
  }
133
125
  return EXIT.ok;
@@ -27,7 +27,7 @@ async function handleStop(argv) {
27
27
  const collector = await stopProjectCollector(target);
28
28
  const signalled = (stop.signalled ?? 0) + (collector.stopped ? 1 : 0);
29
29
  const summary = collector.stopped ? `${stop.summary}; stopped analytics collector ${collector.pid}` : stop.summary;
30
- const next = adapter === "extension" ? `${surface.hints.launch} --target ${shellQuote(target)}` : void 0;
30
+ const next = surface.devServer.afterStop?.(target);
31
31
  const userAction = `mm-harness status --target ${shellQuote(target)} --json`;
32
32
  if (json) {
33
33
  console.log(
package/dist/doctor.js CHANGED
@@ -1,16 +1,11 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import fs from "node:fs";
3
- import { createRequire } from "node:module";
4
2
  import path from "node:path";
5
3
  import { color } from "./cli-color.js";
6
4
  import { normalizeFeatureFlagRecord } from "./feature-flags.js";
7
- import {
8
- mobileToolRecovery,
9
- resolveMobileToolPath
10
- } from "../library/actions/mobile/platform/tool-paths.mjs";
11
- import { mobilePerpsEnvironment } from "./adapters/mobile/perps-env.js";
5
+ import { getAdapterSurface } from "./adapters/surface.js";
12
6
  import { readRuntimeContextField, resolveRuntimeContextPath } from "./harness.js";
13
- import { harnessExecutable, manifestPath, readJson, recipeHarnessRoot, recipeRuntimeDir, runnerDir } from "./paths.js";
7
+ import { isPin } from "./harness-pin.js";
8
+ import { harnessExecutable, readJson, recipeHarnessRoot, recipeRuntimeDir, runnerDir } from "./paths.js";
14
9
  function requiredDoctorCheckSummary(checks) {
15
10
  const required = checks.filter((check) => check.required);
16
11
  const failed = required.filter((check) => check.status === "fail").map((check) => check.id);
@@ -41,21 +36,7 @@ function repoShape(target) {
41
36
  };
42
37
  }
43
38
  function compatibilityMode(adapter, target) {
44
- const shape = repoShape(target);
45
- if (adapter === "core") {
46
- return fs.existsSync(path.join(target, "packages/perps-controller/src/index.ts")) ? "headless controller (no bridge)" : "unsupported/no bridge";
47
- }
48
- if (adapter === "terminal") {
49
- return fs.existsSync(path.join(target, "src/features/perpetuals")) ? "slot browser over CDP (website; no product bridge)" : "unsupported/no bridge";
50
- }
51
- if (adapter === "mobile") {
52
- if (shape.injectedHarness && shape.agenticService) return "runner bridge with injected app bridge";
53
- if (shape.agenticService) return "runner bridge with app bridge";
54
- return "runner bridge available; app bridge not installed";
55
- }
56
- if (!shape.extensionProject) return "unsupported/no bridge";
57
- if (shape.extensionRuntime || shape.injectedHarness) return "bridge present";
58
- return "bridge injectable";
39
+ return getAdapterSurface(adapter).readiness.compatibilityMode(target, repoShape(target));
59
40
  }
60
41
  function readPackageInfo(target) {
61
42
  const packageJsonPath = path.join(target, "package.json");
@@ -96,7 +77,8 @@ function fixtureFileSummary(file, adapter, reportedPath = file) {
96
77
  );
97
78
  const flagKeys = Object.keys(remoteFeatureFlags);
98
79
  return {
99
- status: hasAccounts && (adapter === "core" || hasPassword) ? "ready" : "incomplete",
80
+ // A headless platform signs with the accounts alone; nothing unlocks a UI.
81
+ status: hasAccounts && (adapter !== void 0 && getAdapterSurface(adapter).headless || hasPassword) ? "ready" : "incomplete",
100
82
  path: reportedPath,
101
83
  accountCount: Array.isArray(data.accounts) ? data.accounts.length : 0,
102
84
  hasPassword,
@@ -154,7 +136,8 @@ function renderRuntimeContext(runtimeContext) {
154
136
  }
155
137
  return lines.join("\n");
156
138
  }
157
- function createDoctorReport(adapter, target, manifestValidation, actionManifestPath = manifestPath(adapter), mobilePlatform) {
139
+ function createDoctorReport(adapter, target, manifestValidation, actionManifestPath = getAdapterSurface(adapter).actions.manifestPath(), platform) {
140
+ const readiness = getAdapterSurface(adapter).readiness;
158
141
  const mode = compatibilityMode(adapter, target);
159
142
  const manifestErrors = Number(manifestValidation.summary?.errors ?? 0);
160
143
  const checks = [
@@ -170,8 +153,7 @@ function createDoctorReport(adapter, target, manifestValidation, actionManifestP
170
153
  required: false,
171
154
  message: mode === "unsupported/no bridge" ? `No ${adapter} bridge is available for this checkout.` : `${adapter} compatibility mode: ${mode}.`
172
155
  },
173
- ...adapter === "mobile" ? mobileToolDoctorChecks(mobilePlatform) : [],
174
- ...adapter === "extension" ? [extensionBrowserDoctorCheck(target), extensionBrowserResolutionDoctorCheck(target)] : []
156
+ ...readiness.checks?.(target, platform) ?? []
175
157
  ];
176
158
  const requiredChecks = requiredDoctorCheckSummary(checks);
177
159
  return {
@@ -187,100 +169,13 @@ function createDoctorReport(adapter, target, manifestValidation, actionManifestP
187
169
  compatibilityMode: mode,
188
170
  shape: repoShape(target),
189
171
  fixture: fixtureSummary(target, adapter),
190
- environment: adapter === "mobile" ? { mobilePerps: mobilePerpsEnvironment(target) } : {},
172
+ environment: readiness.environment?.(target) ?? {},
191
173
  runtimeContext: runtimeContextSummary(target, adapter),
192
174
  manifestValidation: manifestValidation.summary
193
175
  };
194
176
  }
195
- function extensionBrowserDoctorCheck(target) {
196
- const requireFromTarget = createRequire(path.join(path.resolve(target), "package.json"));
197
- for (const packageName of ["@playwright/test", "playwright"]) {
198
- try {
199
- const playwright = requireFromTarget(packageName);
200
- const executable = playwright.chromium?.executablePath?.();
201
- if (executable && fs.existsSync(executable)) {
202
- return {
203
- id: "playwright-chromium",
204
- status: "pass",
205
- required: true,
206
- message: "Checkout-pinned Playwright Chromium is installed.",
207
- detail: executable
208
- };
209
- }
210
- } catch {
211
- }
212
- }
213
- return {
214
- id: "playwright-chromium",
215
- status: "fail",
216
- required: true,
217
- message: "Checkout-pinned Playwright Chromium is not installed.",
218
- userAction: "yarn playwright install chromium"
219
- };
220
- }
221
- function extensionBrowserResolutionDoctorCheck(target) {
222
- const resolver = path.join(runnerDir, "adapters/shared/browser-resolver.cjs");
223
- const runtime = path.join(path.resolve(target), recipeRuntimeDir());
224
- const record = path.join(runtime, "browser-resolution.json");
225
- let profile = process.env.CHROME_USER_DATA_DIR || path.join(runtime, process.env.RECIPE_CHROME_PROFILE_NAME || "chrome-profile");
226
- while (profile.length > 1 && profile.endsWith("/")) profile = profile.slice(0, -1);
227
- const profileExists = fs.existsSync(path.join(profile, "Local State"));
228
- const result = spawnSync(process.execPath, [
229
- resolver,
230
- "resolve",
231
- "--target",
232
- path.resolve(target),
233
- "--launch-method",
234
- "launch-services",
235
- "--display",
236
- "headful",
237
- ...profileExists && fs.existsSync(record) ? ["--recorded", record] : [],
238
- "--json"
239
- ], {
240
- cwd: path.resolve(target),
241
- encoding: "utf8",
242
- timeout: 135e3
243
- });
244
- if (result.status === 0) {
245
- const resolution = JSON.parse(result.stdout);
246
- const managed = resolution.managedChrome;
247
- const managedNote = managed && !resolution.warning?.includes("managed by policy") ? ` Google Chrome is managed by policy (${managed.forcedExtensions.length} forced extension${managed.forcedExtensions.length === 1 ? "" : "s"}: ${managed.forcedExtensions.map(({ id, name }) => name ? `${id} (${name})` : id).join(", ")}${managed.signinForced ? "; browser sign-in forced" : ""}); slots use Chrome for Testing.` : "";
248
- const probe = resolution.probe ? `; Chrome for Testing probe (${resolution.probe.launchMethod}, ${resolution.probe.display}${resolution.probe.cached ? ", cached" : ""}${resolution.probe.transient ? `, retried after ${resolution.probe.expiresAt}` : ""}): ${resolution.probe.reason}` : resolution.recordedFrom ? `; kept from the slot profile record ${resolution.recordedFrom}` : "";
249
- return {
250
- id: "extension-browser",
251
- status: "pass",
252
- required: false,
253
- message: `Launch browser: ${resolution.browser} ${resolution.version ?? "(unknown version)"} via ${resolution.source} (${resolution.mode}); extension loading: ${resolution.extensionLoading}${probe}.${managedNote}${resolution.warning ? ` Warning: ${resolution.warning}` : ""}`,
254
- detail: resolution.bin
255
- };
256
- }
257
- const message = (result.stderr || result.error?.message || "browser resolution failed").trim();
258
- return {
259
- id: "extension-browser",
260
- status: "fail",
261
- required: message.includes("BROWSER_UNLAUNCHABLE:"),
262
- message,
263
- userAction: "install Google Chrome, or set RECIPE_HARNESS_BROWSER / RECIPE_HARNESS_CHROME_BIN to a browser that starts"
264
- };
265
- }
266
- function mobileToolDoctorChecks(platform) {
267
- const specs = platform === "android" ? [{ tool: "adb", platform: "Android" }] : platform === "ios" ? [{ tool: "idb", platform: "iOS" }] : [
268
- { tool: "adb", platform: "Android" },
269
- { tool: "idb", platform: "iOS" }
270
- ];
271
- return specs.map(({ tool, platform: platformName }) => {
272
- const resolved = resolveMobileToolPath(tool);
273
- return {
274
- id: `device-tool-${tool}`,
275
- status: resolved ? "pass" : "fail",
276
- required: platform !== void 0,
277
- message: resolved ? `${platformName} device tool ${tool} is ready.` : `${platformName} device tool ${tool} is unavailable.`,
278
- ...resolved ? { detail: resolved } : {},
279
- ...!resolved ? { userAction: mobileToolRecovery(tool) } : {}
280
- };
281
- });
282
- }
283
177
  function runnerInstallKind(runnerRoot, invokedPath, executablePath) {
178
+ if (isPin(runnerRoot)) return "pinned-install";
284
179
  const normalizedRoot = path.normalize(runnerRoot);
285
180
  const nodeModulesSegment = `${path.sep}node_modules${path.sep}`;
286
181
  const globalNodeModulesSegment = `${path.sep}lib${path.sep}node_modules${path.sep}`;
@@ -315,7 +210,7 @@ function runnerProvenance(actionManifestPath) {
315
210
  const executablePath = harnessExecutable();
316
211
  const invokedPath = path.resolve(process.env.MM_HARNESS_INVOKED_AS ?? executablePath);
317
212
  const installKind = runnerInstallKind(runnerDir, invokedPath, executablePath);
318
- const packageSource = runnerDir.includes(`${path.sep}node_modules${path.sep}`) ? "node_modules" : "source-checkout";
213
+ const packageSource = isPin(runnerDir) ? "pin" : runnerDir.includes(`${path.sep}node_modules${path.sep}`) ? "node_modules" : "source-checkout";
319
214
  return {
320
215
  name: "@metamask/recipe-runner",
321
216
  packageName,
@@ -342,11 +237,8 @@ function readJsonObject(file) {
342
237
  export {
343
238
  compatibilityMode,
344
239
  createDoctorReport,
345
- extensionBrowserDoctorCheck,
346
- extensionBrowserResolutionDoctorCheck,
347
240
  fixtureFileSummary,
348
241
  fixtureSummary,
349
- mobileToolDoctorChecks,
350
242
  renderRuntimeContext,
351
243
  repoShape,
352
244
  requiredDoctorCheckSummary,
@@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import fs, { constants as fsConstants } from "node:fs";
4
4
  import path from "node:path";
5
- import { mobileSourceFingerprint } from "./adapters/mobile/source-freshness.js";
5
+ import { getAdapterSurface } from "./adapters/surface.js";
6
6
  import { indexArtifactManifest } from "./artifact-files.js";
7
7
  import { importRecipeProtocol, runnerDir } from "./paths.js";
8
8
  const gitContexts = /* @__PURE__ */ new Map();
@@ -156,10 +156,11 @@ function sourceSnapshot(root, adapter, excludedRoots = [], includedRoots = []) {
156
156
  ...pathspecs
157
157
  ]);
158
158
  const head = gitBuffer(git.topLevel, ["rev-parse", "HEAD"]);
159
+ const platformFingerprint = adapter ? getAdapterSurface(adapter).sourceFingerprint : void 0;
159
160
  return {
160
161
  head: head.toString("utf8").trim() || null,
161
162
  status,
162
- sourceFingerprint: adapter === "mobile" && path.resolve(root) === git.topLevel ? mobileSourceFingerprint(root) : nestedGitSourceFingerprint(root, git, pathspecs, excludedRoots, includedRoots, head)
163
+ sourceFingerprint: platformFingerprint && path.resolve(root) === git.topLevel ? platformFingerprint(root) : nestedGitSourceFingerprint(root, git, pathspecs, excludedRoots, includedRoots, head)
163
164
  };
164
165
  }
165
166
  function nestedGitSourceFingerprint(root, git, pathspecs, excludedRoots, includedRoots, head) {
@@ -0,0 +1,10 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ const PIN_MARKER = ".mm-harness-pin.json";
4
+ function isPin(root) {
5
+ return fs.existsSync(path.join(root, PIN_MARKER));
6
+ }
7
+ export {
8
+ PIN_MARKER,
9
+ isPin
10
+ };
package/dist/harness.js CHANGED
@@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process";
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { shellLeafMissing, missingShellLeafMessage } from "./leaf-invoke.js";
6
- import { resolveExtensionSlotPorts } from "./adapters/slot-ports.js";
6
+ import { adapterRegistry } from "./adapters/registry.js";
7
7
  import { recipeHarnessPath, recipeRuntimeDir, runnerDir } from "./paths.js";
8
8
  import { colorHumanMessage } from "./cli-color.js";
9
9
  const HARNESS_ACTIONS = ["install", "verify", "cleanup"];
@@ -37,15 +37,15 @@ Examples:
37
37
 
38
38
  core supports install, verify, and cleanup (headless; no live).`);
39
39
  }
40
- function failureHint(adapter, action) {
41
- if (adapter === "core") {
40
+ function failureHint(surface, action) {
41
+ const restart = surface.harness.restart;
42
+ if (!restart) {
42
43
  if (action === "verify") {
43
- return `Install the core harness first: mm-harness install --platform core`;
44
+ return `Install the ${surface.adapter} harness first: mm-harness install --platform ${surface.adapter}`;
44
45
  }
45
46
  return "Read the error above for the specific cause, then re-run this command.";
46
47
  }
47
- const helper = adapter === "mobile" ? "mm-harness launch ios # or launch android" : "mm-harness launch";
48
- return `Read the error above for the specific cause. To (re)start the runtime, run: ${helper}`;
48
+ return `Read the error above for the specific cause. To (re)start the runtime, run: ${restart}`;
49
49
  }
50
50
  function successNext(adapter, action, target) {
51
51
  const base = `--adapter ${adapter} --target ${shellQuote(target)}`;
@@ -67,7 +67,7 @@ function shellQuote(value) {
67
67
  return /^[A-Za-z0-9_./:=@+-]+$/u.test(value) ? value : `'${value.replace(/'/gu, `'\\''`)}'`;
68
68
  }
69
69
  function isAdapter(value) {
70
- return value === "mobile" || value === "extension" || value === "core" || value === "terminal";
70
+ return value !== void 0 && adapterRegistry.has(value);
71
71
  }
72
72
  function parseHarnessArgs(args) {
73
73
  const forward = [];
@@ -170,37 +170,9 @@ function readRuntimeContextField(contextPath, field) {
170
170
  if (node === void 0 || node === null || node === "") return void 0;
171
171
  return String(node);
172
172
  }
173
- function applyExtensionRuntimeEnv(target, args) {
174
- const contextPath = resolveRuntimeContextPath(target);
175
- const contextExists = fs.existsSync(contextPath);
176
- if (contextExists) {
177
- process.env.RECIPE_RUNTIME_CONTEXT = contextPath;
178
- if (!process.env.RECIPE_SLOT_ID) {
179
- const slotId = readRuntimeContextField(contextPath, "slotId");
180
- if (slotId) process.env.RECIPE_SLOT_ID = slotId;
181
- }
182
- if (!process.env.RECIPE_HARNESS_EXTENSION_ID) {
183
- const extensionId = readRuntimeContextField(contextPath, "extensionId");
184
- if (extensionId) process.env.RECIPE_HARNESS_EXTENSION_ID = extensionId;
185
- }
186
- } else {
187
- delete process.env.RECIPE_RUNTIME_CONTEXT;
188
- }
189
- resolveExtensionSlotPorts(target);
190
- let result = [...args];
191
- if (!hasArg(result, "--cdp-port")) {
192
- const cdpPort = process.env.RECIPE_CDP_PORT ?? process.env.CDP_PORT;
193
- if (cdpPort) {
194
- process.env.RECIPE_CDP_PORT = cdpPort;
195
- process.env.CDP_PORT = cdpPort;
196
- result = [...result, "--cdp-port", cdpPort];
197
- }
198
- }
199
- return result;
200
- }
201
173
  function resolveEntry(base, candidates, mode) {
202
174
  for (const candidate of candidates) {
203
- const full = path.join(base, candidate);
175
+ const full = path.resolve(base, candidate);
204
176
  try {
205
177
  const stat = fs.statSync(full);
206
178
  if (!stat.isFile()) continue;
@@ -208,7 +180,7 @@ function resolveEntry(base, candidates, mode) {
208
180
  } catch {
209
181
  }
210
182
  }
211
- return path.join(base, candidates[0]);
183
+ return path.resolve(base, candidates[0]);
212
184
  }
213
185
  function installedRunnerSource(target, adapter) {
214
186
  const pointer = path.join(recipeHarnessPath(target, adapter), "runner", ".runner-source");
@@ -217,56 +189,12 @@ function installedRunnerSource(target, adapter) {
217
189
  if (!value || !fs.existsSync(value)) return void 0;
218
190
  return value;
219
191
  }
220
- function isExecutable(file) {
221
- try {
222
- const stat = fs.statSync(file);
223
- return stat.isFile() && (stat.mode & 73) !== 0;
224
- } catch {
225
- return false;
226
- }
227
- }
228
- const INJECT_CANDIDATES = {
229
- mobile: { entry: "adapters/mobile/inject.sh", fallback: "scripts/inject-mobile-harness.sh" },
230
- extension: { entry: "adapters/extension/inject.mjs", fallback: "scripts/inject-extension-harness.mjs" },
231
- core: { entry: "adapters/core/inject.sh", fallback: "scripts/inject-core-harness.sh" },
232
- terminal: { entry: "adapters/terminal/inject.mjs", fallback: "adapters/terminal/inject.mjs" }
233
- };
234
- const CLEANUP_CANDIDATES = {
235
- mobile: { entry: "adapters/mobile/cleanup.sh", fallback: "scripts/cleanup-mobile-harness.sh" },
236
- extension: { entry: "adapters/extension/cleanup.mjs", fallback: "scripts/cleanup-extension-harness.mjs" },
237
- core: { entry: "adapters/core/cleanup.sh", fallback: "scripts/cleanup-core-harness.sh" },
238
- terminal: { entry: "adapters/terminal/cleanup.mjs", fallback: "adapters/terminal/cleanup.mjs" }
239
- };
240
- function resolveHarnessDispatch(adapter, action, target) {
241
- if (action === "install") {
242
- const { entry, fallback } = INJECT_CANDIDATES[adapter];
243
- if (adapter === "extension" || adapter === "terminal") {
244
- return { command: process.execPath, prefixArgs: [resolveEntry(runnerDir, [entry, fallback], "file")] };
245
- }
246
- return { command: resolveEntry(runnerDir, [entry, fallback], "file"), prefixArgs: [] };
247
- }
248
- if (action === "cleanup") {
249
- const { entry, fallback } = CLEANUP_CANDIDATES[adapter];
250
- const base = adapter === "terminal" ? runnerDir : installedRunnerSource(target, adapter) ?? runnerDir;
251
- if (adapter === "extension" || adapter === "terminal") {
252
- return { command: process.execPath, prefixArgs: [resolveEntry(base, [entry, fallback], "file")] };
253
- }
254
- return { command: resolveEntry(base, [entry, fallback], "file"), prefixArgs: [] };
255
- }
256
- if (adapter === "terminal" && action === "verify") {
257
- return { command: process.execPath, prefixArgs: [path.join(runnerDir, "adapters", "terminal", "verify.mjs")] };
258
- }
259
- if (adapter === "core" && action === "verify") {
260
- const delegate = path.join(recipeHarnessPath(target, "core"), "runner", "bin", "mm-harness");
261
- if (!isExecutable(delegate)) {
262
- return {
263
- error: `core recipe harness not installed: missing delegate ${delegate}.
264
- Next: mm-harness install --platform core --target ${target}`
265
- };
266
- }
267
- return { command: delegate, prefixArgs: ["doctor", "--adapter", "core"] };
268
- }
269
- return { inProcess: adapter === "mobile" ? "mobile-verify" : "extension-verify" };
192
+ function resolveHarnessDispatch(surface, action, target) {
193
+ if (action === "verify") return surface.harness.verify(target);
194
+ const leaf = action === "install" ? surface.harness.install : surface.harness.cleanup;
195
+ const base = action === "cleanup" && surface.harness.cleanup.fromInstalledRunner ? installedRunnerSource(target, surface.adapter) ?? runnerDir : runnerDir;
196
+ const entry = resolveEntry(base, [leaf.entry, leaf.fallback], "file");
197
+ return leaf.node ? { command: process.execPath, prefixArgs: [entry] } : { command: entry, prefixArgs: [] };
270
198
  }
271
199
  async function handleHarness(argv) {
272
200
  const action = argv[0];
@@ -280,6 +208,7 @@ async function handleHarness(argv) {
280
208
  return 2;
281
209
  }
282
210
  const harnessAction = action;
211
+ const { getAdapterSurface } = await import("./adapters/surface.js");
283
212
  const { adapter: parsedAdapter, json, quiet, forward } = parseHarnessArgs(argv.slice(1));
284
213
  const rawTarget = argValue(forward, "--target");
285
214
  const target = path.resolve(rawTarget ?? process.cwd());
@@ -306,10 +235,11 @@ async function handleHarness(argv) {
306
235
  if (harnessAction === "install" && hasArg(forward, "--runway")) {
307
236
  return handleRunwayInstall(adapter, target, forward, json);
308
237
  }
309
- if (adapter === "extension" && harnessAction === "verify") {
310
- forwardArgs = applyExtensionRuntimeEnv(target, forwardArgs);
238
+ const surface = getAdapterSurface(adapter);
239
+ if (harnessAction === "verify" && surface.harness.verifyArgs) {
240
+ forwardArgs = surface.harness.verifyArgs(target, forwardArgs);
311
241
  }
312
- const dispatch = resolveHarnessDispatch(adapter, harnessAction, target);
242
+ const dispatch = resolveHarnessDispatch(surface, harnessAction, target);
313
243
  if ("error" in dispatch) {
314
244
  if (quiet) {
315
245
  return 1;
@@ -335,7 +265,7 @@ async function handleHarness(argv) {
335
265
  }
336
266
  const start = Date.now();
337
267
  if ("inProcess" in dispatch) {
338
- const runVerify = dispatch.inProcess === "mobile-verify" ? (await import("./adapters/mobile/verify.js")).runMobileVerify : (await import("./adapters/extension/verify.js")).runExtensionVerify;
268
+ const runVerify = await dispatch.inProcess();
339
269
  let exitCode;
340
270
  try {
341
271
  exitCode = await runVerify(forwardArgs, {
@@ -355,7 +285,7 @@ async function handleHarness(argv) {
355
285
  `);
356
286
  exitCode = 1;
357
287
  }
358
- return reportHarnessOutcome(harnessAction, adapter, target, exitCode, autoDetected, json, quiet, start);
288
+ return reportHarnessOutcome(harnessAction, surface, target, exitCode, autoDetected, json, quiet, start);
359
289
  }
360
290
  if (shellLeafMissing(dispatch.command)) {
361
291
  const message = missingShellLeafMessage(dispatch.command);
@@ -366,7 +296,7 @@ async function handleHarness(argv) {
366
296
  harnessSummary(harnessAction, adapter, target, "fail", 1, autoDetected, {
367
297
  code: "HARNESS_SPAWN_FAILED",
368
298
  message,
369
- userAction: failureHint(adapter, harnessAction)
299
+ userAction: failureHint(surface, harnessAction)
370
300
  })
371
301
  );
372
302
  } else {
@@ -387,19 +317,20 @@ async function handleHarness(argv) {
387
317
  harnessSummary(harnessAction, adapter, target, "fail", 1, autoDetected, {
388
318
  code: "HARNESS_SPAWN_FAILED",
389
319
  message: `${harnessAction} ${adapter} could not start: ${result.error.message}`,
390
- userAction: failureHint(adapter, harnessAction)
320
+ userAction: failureHint(surface, harnessAction)
391
321
  })
392
322
  );
393
323
  } else {
394
324
  console.error(`\u2717 ${harnessAction} ${adapter} could not start: ${result.error.message}
395
- ${failureHint(adapter, harnessAction)}`);
325
+ ${failureHint(surface, harnessAction)}`);
396
326
  }
397
327
  return 1;
398
328
  }
399
- return reportHarnessOutcome(harnessAction, adapter, target, result.status ?? 1, autoDetected, json, quiet, start);
329
+ return reportHarnessOutcome(harnessAction, surface, target, result.status ?? 1, autoDetected, json, quiet, start);
400
330
  }
401
- function reportHarnessOutcome(action, adapter, target, exitCode, autoDetected, json, quiet, start) {
331
+ function reportHarnessOutcome(action, surface, target, exitCode, autoDetected, json, quiet, start) {
402
332
  const seconds = ((Date.now() - start) / 1e3).toFixed(1);
333
+ const adapter = surface.adapter;
403
334
  if (quiet) {
404
335
  return exitCode;
405
336
  } else if (json) {
@@ -411,7 +342,7 @@ function reportHarnessOutcome(action, adapter, target, exitCode, autoDetected, j
411
342
  exitCode === 0 ? "pass" : "fail",
412
343
  exitCode,
413
344
  autoDetected,
414
- exitCode === 0 ? void 0 : { code: "HARNESS_FAILED", message: `${action} ${adapter} failed (exit ${exitCode})`, userAction: failureHint(adapter, action) }
345
+ exitCode === 0 ? void 0 : { code: "HARNESS_FAILED", message: `${action} ${adapter} failed (exit ${exitCode})`, userAction: failureHint(surface, action) }
415
346
  )
416
347
  );
417
348
  } else if (exitCode === 0) {
@@ -419,7 +350,7 @@ function reportHarnessOutcome(action, adapter, target, exitCode, autoDetected, j
419
350
  Next: ${successNext(adapter, action, target)}`));
420
351
  } else {
421
352
  console.error(colorHumanMessage(`\u2717 ${action} ${adapter} failed (exit ${exitCode}, ${seconds}s)
422
- ${failureHint(adapter, action)}`));
353
+ ${failureHint(surface, action)}`));
423
354
  }
424
355
  return exitCode;
425
356
  }
@@ -448,7 +379,7 @@ async function handleRunwayInstall(adapter, target, forward, json) {
448
379
  });
449
380
  const provisionedSimulator = typeof result.simulator === "object" && result.simulator ? result.simulator : void 0;
450
381
  const provisionedDevice = typeof provisionedSimulator?.udid === "string" ? provisionedSimulator.udid : typeof provisionedSimulator?.name === "string" ? provisionedSimulator.name : void 0;
451
- const next = result.status === "pass" && adapter === "mobile" && result.resolveOnly !== true ? `mm-harness launch ${String(result.platform ?? "ios")} --adapter mobile --target ${shellQuote(target)}` + (provisionedDevice ? ` --device ${shellQuote(provisionedDevice)}` : "") : void 0;
382
+ const next = result.status === "pass" && surface.runwayProvision.launchNext && result.resolveOnly !== true ? `mm-harness launch ${String(result.platform ?? "ios")} --adapter ${adapter} --target ${shellQuote(target)}` + (provisionedDevice ? ` --device ${shellQuote(provisionedDevice)}` : "") : void 0;
452
383
  const output = next ? { ...result, next } : result;
453
384
  if (json) {
454
385
  console.log(JSON.stringify(output, null, 2));
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import { getAdapterSurface } from "./adapters/surface.js";
4
5
  import { handleHarness } from "./harness.js";
5
6
  import { recipeHarnessPath, recipeRuntimePath } from "./paths.js";
6
7
  import { EXIT } from "./commands/shared.js";
@@ -28,7 +29,7 @@ function overlayPresent(target, adapter) {
28
29
  }
29
30
  }
30
31
  function overlayDelegateValid(target, adapter) {
31
- if (adapter === "core") return true;
32
+ if (getAdapterSurface(adapter).headless) return true;
32
33
  const pointer = path.join(recipeHarnessPath(target, adapter), "runner", ".runner-source");
33
34
  if (!fs.existsSync(pointer)) return true;
34
35
  const runnerPath = fs.readFileSync(pointer, "utf8").trim();
@@ -36,7 +37,7 @@ function overlayDelegateValid(target, adapter) {
36
37
  return fs.existsSync(path.join(runnerPath, "bin", "mm-harness"));
37
38
  }
38
39
  async function ensureOverlay(adapter, target, heal, state, json) {
39
- if (adapter === "core") return { ok: true };
40
+ if (getAdapterSurface(adapter).headless) return { ok: true };
40
41
  if (overlayPresent(target, adapter) && overlayDelegateValid(target, adapter)) return { ok: true };
41
42
  if (heal === "off") return { ok: true };
42
43
  const installBin = process.env.MM_HARNESS_INSTALL_BIN;