@deeeed/metamask-harness 0.75.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 (45) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/adapters/shared/reap-checkout-metros.sh +9 -0
  3. package/dist/adapters/core/overlay.js +34 -0
  4. package/dist/adapters/core/readiness.js +8 -0
  5. package/dist/adapters/core/surface.js +7 -0
  6. package/dist/adapters/extension/doctor-checks.js +135 -0
  7. package/dist/adapters/extension/fixtures.js +103 -0
  8. package/dist/adapters/extension/overlay.js +55 -0
  9. package/dist/adapters/extension/readiness.js +32 -0
  10. package/dist/adapters/extension/reload.js +40 -0
  11. package/dist/adapters/extension/surface.js +22 -1
  12. package/dist/adapters/harness-freshness.js +4 -2
  13. package/dist/adapters/mobile/devices.js +21 -0
  14. package/dist/adapters/mobile/doctor-checks.js +40 -0
  15. package/dist/adapters/mobile/fixtures.js +141 -0
  16. package/dist/adapters/mobile/overlay.js +27 -0
  17. package/dist/adapters/mobile/prepare-target.js +38 -0
  18. package/dist/adapters/mobile/readiness.js +39 -0
  19. package/dist/adapters/mobile/reload.js +15 -0
  20. package/dist/adapters/mobile/surface.js +28 -1
  21. package/dist/adapters/terminal/doctor-checks.js +22 -0
  22. package/dist/adapters/terminal/overlay.js +18 -0
  23. package/dist/adapters/terminal/readiness.js +11 -0
  24. package/dist/adapters/terminal/surface.js +15 -2
  25. package/dist/cli.js +3 -1
  26. package/dist/commands/debug.js +6 -25
  27. package/dist/commands/device-target.js +2 -1
  28. package/dist/commands/doctor.js +26 -137
  29. package/dist/commands/fixtures.js +13 -217
  30. package/dist/commands/logs.js +1 -1
  31. package/dist/commands/parse-args.js +9 -4
  32. package/dist/commands/prepare.js +23 -53
  33. package/dist/commands/provision.js +3 -7
  34. package/dist/commands/reload.js +8 -47
  35. package/dist/commands/runtime-decision.js +5 -27
  36. package/dist/commands/self-test.js +1 -3
  37. package/dist/commands/status.js +20 -28
  38. package/dist/commands/stop.js +1 -1
  39. package/dist/doctor.js +9 -119
  40. package/dist/harness.js +31 -100
  41. package/dist/mm-harness-cli.js +3 -2
  42. package/dist/paths.js +8 -2
  43. package/dist/runtime-context.js +12 -17
  44. package/docs/CONTRIBUTING.md +27 -17
  45. package/package.json +1 -1
@@ -10,7 +10,9 @@ import {
10
10
  import { color, stripAnsi } from "../cli-color.js";
11
11
  import { runnerProvenance } from "../doctor.js";
12
12
  import { detectAdapter } from "../harness.js";
13
- import { harnessExecutable, manifestPath, PREPARE_PROGRESS_ARTIFACT, recipeRuntimeDir } from "../paths.js";
13
+ import { adapterRegistry } from "../adapters/registry.js";
14
+ import { getAdapterSurface } from "../adapters/surface.js";
15
+ import { harnessExecutable, PREPARE_PROGRESS_ARTIFACT, recipeRuntimeDir } from "../paths.js";
14
16
  import { optionFlag, optionString, parseArgs, targetPath, usageError } from "./parse-args.js";
15
17
  import { EXIT } from "./shared.js";
16
18
  import { formatDuration } from "./status-watch.js";
@@ -52,8 +54,8 @@ const STEP_HINT = {
52
54
  launch: "can take minutes on Mobile: Metro bundle and app start",
53
55
  fixtures: "under a minute"
54
56
  };
55
- function stepChain(platform) {
56
- return platform === "core" ? ["doctor", "status", "verify"] : ["doctor", "status", "launch", "fixtures", "verify"];
57
+ function stepChain(surface) {
58
+ return surface.headless ? ["doctor", "status", "verify"] : ["doctor", "status", "launch", "fixtures", "verify"];
57
59
  }
58
60
  function lastRunDurations(recordPath) {
59
61
  let parsed;
@@ -225,36 +227,6 @@ function readDevices(artifactsDir) {
225
227
  const devices = parsed?.devices;
226
228
  return Array.isArray(devices) ? devices : [];
227
229
  }
228
- function readRuntimeContextTarget(target) {
229
- const file = path.join(target, recipeRuntimeDir(), "agentic-runtime.json");
230
- let parsed;
231
- try {
232
- parsed = JSON.parse(fs.readFileSync(file, "utf8"));
233
- } catch {
234
- return {};
235
- }
236
- const record = parsed ?? {};
237
- const str = (key) => typeof record[key] === "string" ? record[key] : void 0;
238
- return { simulator: str("simulator"), simulatorUdid: str("simulatorUdid"), adbSerial: str("adbSerial") };
239
- }
240
- function mobilePlatformFromConfiguredTarget(env, context) {
241
- const ios = Boolean(env.IOS_SIMULATOR || env.SIM_UDID || context.simulator || context.simulatorUdid);
242
- const android = Boolean(
243
- env.ADB_SERIAL || env.ANDROID_SERIAL || env.ANDROID_TARGET_DEVICE_NAME || env.ANDROID_DEVICE || context.adbSerial
244
- );
245
- if (ios === android) return void 0;
246
- return ios ? "ios" : "android";
247
- }
248
- function mobilePlatformFromDevices(devices) {
249
- const selected = devices.filter((device) => device?.selected === true);
250
- const candidates = selected.length > 0 ? selected : devices;
251
- const platforms = [
252
- ...new Set(
253
- candidates.map((device) => device?.platform).filter((value) => value === "ios" || value === "android")
254
- )
255
- ];
256
- return platforms.length === 1 ? platforms[0] : void 0;
257
- }
258
230
  function mobileTargetAmbiguityError() {
259
231
  const choices = [
260
232
  "1. iOS mm-harness prepare --platform mobile --mobile-platform ios",
@@ -300,8 +272,9 @@ function mobilePlatformOption(value) {
300
272
  }
301
273
  function resolvePlatform(requested, target) {
302
274
  const platform = requested ?? detectAdapter(target);
303
- if (platform === "mobile" || platform === "extension" || platform === "core") {
304
- return platform;
275
+ const surface = platform && adapterRegistry.has(platform) ? getAdapterSurface(platform) : void 0;
276
+ if (surface?.prepare) {
277
+ return surface;
305
278
  }
306
279
  throw usageError(
307
280
  `could not detect the platform of ${target}
@@ -328,21 +301,22 @@ async function handlePrepareLocked(argv) {
328
301
  const { options } = parseArgs(argv, "prepare");
329
302
  const json = optionFlag(options, "json");
330
303
  const target = targetPath(options);
331
- const platform = resolvePlatform(optionString(options, "platform"), target);
304
+ const surface = resolvePlatform(optionString(options, "platform"), target);
305
+ const platform = surface.adapter;
332
306
  const artifactsDir = path.resolve(
333
307
  optionString(options, "artifactsDir") ?? path.join(target, recipeRuntimeDir())
334
308
  );
335
309
  const executable = harnessExecutable();
336
310
  const targetArgs = ["--target", target];
337
311
  const clearMetro = optionFlag(options, "clearMetro");
338
- if (clearMetro && platform !== "mobile") {
312
+ if (clearMetro && !surface.prepare.clearMetro) {
339
313
  throw usageError(`--clear-metro is mobile only; this checkout is ${platform}
340
314
  Next: ${USAGE}`);
341
315
  }
342
316
  const remoteFlag = optionString(options, "remoteFlag");
343
317
  if (remoteFlag !== void 0) {
344
- if (platform === "core") {
345
- throw usageError(`--remote-flag needs an app runtime; this checkout is core
318
+ if (surface.headless) {
319
+ throw usageError(`--remote-flag needs an app runtime; this checkout is ${platform}
346
320
  Next: ${USAGE}`);
347
321
  }
348
322
  try {
@@ -357,7 +331,7 @@ async function handlePrepareLocked(argv) {
357
331
  let mobilePlatform = mobilePlatformOption(optionString(options, "mobilePlatform"));
358
332
  const recordPath = path.join(artifactsDir, READINESS_RECORD);
359
333
  const progressPath = path.join(artifactsDir, PREPARE_PROGRESS_ARTIFACT);
360
- const chain = stepChain(platform);
334
+ const chain = stepChain(surface);
361
335
  const view = json ? void 0 : createStepView(chain, stepHints(chain, lastRunDurations(recordPath)));
362
336
  view?.begin();
363
337
  const steps = [];
@@ -406,35 +380,33 @@ async function handlePrepareLocked(argv) {
406
380
  try {
407
381
  await run("doctor", ["doctor", "--fix", "--json", "--adapter", platform, ...targetArgs]);
408
382
  await run("status", ["status", "--json", ...targetArgs]);
409
- if (platform === "mobile" && !mobilePlatform && !failed()) {
410
- mobilePlatform = mobilePlatformFromConfiguredTarget(process.env, readRuntimeContextTarget(target)) ?? mobilePlatformFromDevices(readDevices(artifactsDir));
383
+ const { devicePlatform } = surface.prepare;
384
+ if (devicePlatform && !mobilePlatform && !failed()) {
385
+ mobilePlatform = devicePlatform(target, readDevices(artifactsDir));
411
386
  if (!mobilePlatform) return ambiguousMobileTarget(json);
412
387
  }
413
- if (platform === "core") {
414
- const reason = "core is headless: no app surface to launch or seed";
388
+ if (surface.headless) {
389
+ const reason = `${platform} is headless: no app surface to launch or seed`;
415
390
  addStep(skippedStep("launch", `${executable} launch`, reason));
416
391
  addStep(skippedStep("fixtures", `${executable} fixtures set`, reason));
417
- } else if (platform === "mobile") {
418
- const device = mobilePlatform ? [mobilePlatform] : [];
392
+ } else {
393
+ const device = devicePlatform && mobilePlatform ? [mobilePlatform] : [];
419
394
  await run("launch", ["launch", ...device, ...clearMetro ? ["--clear-metro"] : [], ...remoteFlagArgs, "--verify", "--json", ...targetArgs]);
420
395
  await run("fixtures", [
421
396
  "fixtures",
422
397
  "set",
423
- ...mobilePlatform ? ["--platform", mobilePlatform] : [],
398
+ ...device.flatMap((platformName) => ["--platform", platformName]),
424
399
  ...remoteFlagArgs,
425
400
  "--json",
426
401
  ...targetArgs
427
402
  ]);
428
- } else {
429
- await run("launch", ["launch", ...remoteFlagArgs, "--verify", "--json", ...targetArgs]);
430
- await run("fixtures", ["fixtures", "set", ...remoteFlagArgs, "--json", ...targetArgs]);
431
403
  }
432
404
  await run("verify", ["verify", "--json", "--adapter", platform, ...targetArgs]);
433
405
  } finally {
434
406
  clearInterval(heartbeat);
435
407
  view?.end();
436
408
  }
437
- const provenance = runnerProvenance(manifestPath(platform));
409
+ const provenance = runnerProvenance(surface.actions.manifestPath());
438
410
  const record = {
439
411
  schemaVersion: 1,
440
412
  harness: {
@@ -462,7 +434,5 @@ async function handlePrepareLocked(argv) {
462
434
  }
463
435
  export {
464
436
  handlePrepare,
465
- mobilePlatformFromConfiguredTarget,
466
- mobilePlatformFromDevices,
467
437
  mobileTargetAmbiguityError
468
438
  };
@@ -1,8 +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";
5
- import { ADAPTER_DETECT_NEXT, usageOut, writeInteractiveProgress } from "./shared.js";
4
+ import { ADAPTER_DETECT_NEXT, usageOut } from "./shared.js";
6
5
  import {
7
6
  applyWatcherPortOption,
8
7
  optionFlag,
@@ -27,10 +26,7 @@ async function handleProvision({ positional, options, rawArgv }) {
27
26
  const surface = getAdapterSurface(adapter);
28
27
  const rerunCommand = provisionRerunCommand(rawArgv, options, adapter, target);
29
28
  const platform = optionString(options, "platform") ?? optionString(options, "devicePlatform") ?? (positional[0] === "runway" ? positional[1] : positional[0]) ?? "ios";
30
- if (adapter === "mobile") {
31
- writeInteractiveProgress(json, `\u2192 provision mobile ${platform} \u2014 resolving simulator and Runway artifact`);
32
- if (!optionFlag(options, "resolveOnly")) clearMobileReleaseArtifactState(target);
33
- }
29
+ surface.runwayProvision.before?.(target, { json, platform, resolveOnly: optionFlag(options, "resolveOnly") });
34
30
  const result = await surface.runwayProvision.run(target, {
35
31
  json,
36
32
  platform,
@@ -50,7 +46,7 @@ async function handleProvision({ positional, options, rawArgv }) {
50
46
  });
51
47
  const provisionedSimulator = typeof result.simulator === "object" && result.simulator ? result.simulator : void 0;
52
48
  const provisionedDevice = typeof provisionedSimulator?.udid === "string" ? provisionedSimulator.udid : typeof provisionedSimulator?.name === "string" ? provisionedSimulator.name : void 0;
53
- const next = result.status === "pass" && adapter === "mobile" && result.resolveOnly !== true ? `mm-harness launch ${String(result.platform ?? platform)} --adapter mobile --target ${shellQuote(target)}` + (provisionedDevice ? ` --device ${shellQuoteArg(provisionedDevice)}` : "") : void 0;
49
+ const next = result.status === "pass" && surface.runwayProvision.launchNext && result.resolveOnly !== true ? `mm-harness launch ${String(result.platform ?? platform)} --adapter ${adapter} --target ${shellQuote(target)}` + (provisionedDevice ? ` --device ${shellQuoteArg(provisionedDevice)}` : "") : void 0;
54
50
  const output = next ? { ...result, next } : result;
55
51
  if (json) {
56
52
  console.log(JSON.stringify(output, null, 2));
@@ -1,13 +1,9 @@
1
- import path from "node:path";
2
- import { runnerDir } from "../paths.js";
3
1
  import { getAdapterSurface } from "../adapters/surface.js";
4
2
  import {
5
3
  ADAPTER_DETECT_NEXT,
6
- EXIT,
7
4
  flag,
8
5
  parseFlags,
9
6
  resolveAdapter,
10
- spawnScript,
11
7
  targetOf,
12
8
  usageOut
13
9
  } from "./shared.js";
@@ -25,55 +21,20 @@ async function handleReload(argv) {
25
21
  ADAPTER_DETECT_NEXT
26
22
  );
27
23
  }
28
- if (adapter === "core") {
24
+ const surface = getAdapterSurface(adapter);
25
+ if (surface.headless) {
29
26
  return usageOut(
30
27
  json,
31
28
  "reload",
32
- "core is headless; there is no app runtime to reload.",
33
- "mm-harness run <core-recipe>"
29
+ `${adapter} is headless; there is no app runtime to reload.`,
30
+ `mm-harness run <${adapter}-recipe>`
34
31
  );
35
32
  }
36
- getAdapterSurface(adapter).resolveSlotPorts(target);
37
- if (adapter === "extension") {
38
- if (!process.env.CDP_PORT) {
39
- return usageOut(
40
- json,
41
- "reload",
42
- "the Extension CDP port could not be resolved.",
43
- "mm-harness launch"
44
- );
45
- }
46
- const script2 = path.join(runnerDir, "adapters/extension/reattach.sh");
47
- const args2 = ["--target", target, "--cdp-port", process.env.CDP_PORT];
48
- if (process.env.WATCHER_PORT) {
49
- args2.push("--watcher-port", process.env.WATCHER_PORT);
50
- }
51
- const result2 = spawnScript(script2, args2, target, json);
52
- if (json) {
53
- console.log(
54
- JSON.stringify(
55
- {
56
- ok: result2.status === 0,
57
- adapter,
58
- method: "cdp-reattach",
59
- command: "reload",
60
- cdpPort: Number(process.env.CDP_PORT),
61
- ...result2.status === 0 ? {} : { error: result2.output.slice(-4e3) || "Extension reload failed." }
62
- },
63
- null,
64
- 2
65
- )
66
- );
67
- }
68
- return result2.status === 0 ? EXIT.ok : EXIT.runtime;
33
+ if (!surface.reload) {
34
+ return usageOut(json, "reload", `reload does not support the ${adapter} adapter.`, surface.hints.launch);
69
35
  }
70
- const script = path.join(runnerDir, "adapters/mobile/reload-app.mjs");
71
- const args = [];
72
- if (process.env.WATCHER_PORT) args.push("--port", process.env.WATCHER_PORT);
73
- if (json) args.push("--json");
74
- const result = spawnScript(process.execPath, [script, ...args], target, json);
75
- if (json) process.stdout.write(result.output);
76
- return result.status === 0 ? EXIT.ok : EXIT.runtime;
36
+ surface.resolveSlotPorts(target);
37
+ return surface.reload(target, json);
77
38
  }
78
39
  export {
79
40
  handleReload
@@ -1,32 +1,16 @@
1
- import { decideExtensionReadiness } from "../adapters/extension/runtime-decision.js";
2
- import { decideMobileReadiness } from "../adapters/mobile/runtime-decision.js";
1
+ import { getAdapterSurface } from "../adapters/surface.js";
3
2
  import {
4
3
  adapterOption,
5
4
  applyRuntimeDirOption,
6
5
  optionFlag,
7
- optionString,
8
- parsePort,
9
6
  targetPath
10
7
  } from "./parse-args.js";
11
8
  async function handleRuntimeDecision({ options }) {
12
9
  applyRuntimeDirOption(options);
13
10
  const adapter = adapterOption(options);
14
11
  const target = targetPath(options);
15
- if (adapter === "mobile") {
16
- const watcherPortRaw = optionString(options, "watcherPort") ?? process.env.WATCHER_PORT ?? process.env.METRO_PORT;
17
- const watcherPort = watcherPortRaw === void 0 ? void 0 : parsePort(watcherPortRaw, "runtime-decision --watcher-port must be a port.");
18
- const report2 = await decideMobileReadiness(target, {
19
- watcherPort,
20
- metroLog: optionString(options, "metroLog"),
21
- platform: optionString(options, "platform") ?? process.env.PLATFORM ?? process.env.RECIPE_HARNESS_PLATFORM,
22
- record: optionFlag(options, "record"),
23
- preflightMode: optionString(options, "preflightMode")
24
- });
25
- if (optionFlag(options, "json")) console.log(JSON.stringify(report2, null, 2));
26
- else console.log(`${report2.decision} ${report2.reasonCode} \u2014 ${report2.reasons[0] ?? ""}`);
27
- return 0;
28
- }
29
- if (adapter !== "extension") {
12
+ const decide = getAdapterSurface(adapter).runtimeDecision;
13
+ if (!decide) {
30
14
  const report2 = {
31
15
  schemaVersion: 1,
32
16
  adapter,
@@ -42,15 +26,9 @@ async function handleRuntimeDecision({ options }) {
42
26
  else console.log(`unknown adapter-unsupported \u2014 ${report2.reasons[0]}`);
43
27
  return 0;
44
28
  }
45
- const cdpPortRaw = optionString(options, "cdpPort") ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT;
46
- const cdpPort = cdpPortRaw === void 0 ? void 0 : parsePort(cdpPortRaw, "runtime-decision --cdp-port must be a port.");
47
- const report = await decideExtensionReadiness(target, {
48
- cdpPort,
49
- watchLog: optionString(options, "watchLog"),
50
- record: optionFlag(options, "record")
51
- });
29
+ const { report, line } = await decide(target, options);
52
30
  if (optionFlag(options, "json")) console.log(JSON.stringify(report, null, 2));
53
- else console.log(`${report.decision}${report.clean ? " (clean)" : ""} ${report.reasonCode} \u2014 ${report.reasons[0] ?? ""}`);
31
+ else console.log(line);
54
32
  return 0;
55
33
  }
56
34
  export {
@@ -25,9 +25,7 @@ async function runSelfTest(options) {
25
25
  for (const adapter of ["mobile", "extension"]) {
26
26
  const manifest = loadActionManifest(adapter);
27
27
  const manifestValidation = await validateManifest(manifest);
28
- const smokeRecipe = recipePath(
29
- adapter === "mobile" ? "mobile/runner/smoke.recipe.json" : "extension/runner/smoke.recipe.json"
30
- );
28
+ const smokeRecipe = recipePath(`${adapter}/runner/smoke.recipe.json`);
31
29
  const artifactsDir = path.join(root, adapter);
32
30
  const result = await runRecipe(adapter, smokeRecipe, artifactsDir, runnerDir, void 0, {
33
31
  skipExtensionRuntimePrepare: true
@@ -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,17 +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
7
  import { isPin } from "./harness-pin.js";
14
- import { harnessExecutable, manifestPath, readJson, recipeHarnessRoot, recipeRuntimeDir, runnerDir } from "./paths.js";
8
+ import { harnessExecutable, readJson, recipeHarnessRoot, recipeRuntimeDir, runnerDir } from "./paths.js";
15
9
  function requiredDoctorCheckSummary(checks) {
16
10
  const required = checks.filter((check) => check.required);
17
11
  const failed = required.filter((check) => check.status === "fail").map((check) => check.id);
@@ -42,21 +36,7 @@ function repoShape(target) {
42
36
  };
43
37
  }
44
38
  function compatibilityMode(adapter, target) {
45
- const shape = repoShape(target);
46
- if (adapter === "core") {
47
- return fs.existsSync(path.join(target, "packages/perps-controller/src/index.ts")) ? "headless controller (no bridge)" : "unsupported/no bridge";
48
- }
49
- if (adapter === "terminal") {
50
- return fs.existsSync(path.join(target, "src/features/perpetuals")) ? "slot browser over CDP (website; no product bridge)" : "unsupported/no bridge";
51
- }
52
- if (adapter === "mobile") {
53
- if (shape.injectedHarness && shape.agenticService) return "runner bridge with injected app bridge";
54
- if (shape.agenticService) return "runner bridge with app bridge";
55
- return "runner bridge available; app bridge not installed";
56
- }
57
- if (!shape.extensionProject) return "unsupported/no bridge";
58
- if (shape.extensionRuntime || shape.injectedHarness) return "bridge present";
59
- return "bridge injectable";
39
+ return getAdapterSurface(adapter).readiness.compatibilityMode(target, repoShape(target));
60
40
  }
61
41
  function readPackageInfo(target) {
62
42
  const packageJsonPath = path.join(target, "package.json");
@@ -97,7 +77,8 @@ function fixtureFileSummary(file, adapter, reportedPath = file) {
97
77
  );
98
78
  const flagKeys = Object.keys(remoteFeatureFlags);
99
79
  return {
100
- 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",
101
82
  path: reportedPath,
102
83
  accountCount: Array.isArray(data.accounts) ? data.accounts.length : 0,
103
84
  hasPassword,
@@ -155,7 +136,8 @@ function renderRuntimeContext(runtimeContext) {
155
136
  }
156
137
  return lines.join("\n");
157
138
  }
158
- 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;
159
141
  const mode = compatibilityMode(adapter, target);
160
142
  const manifestErrors = Number(manifestValidation.summary?.errors ?? 0);
161
143
  const checks = [
@@ -171,8 +153,7 @@ function createDoctorReport(adapter, target, manifestValidation, actionManifestP
171
153
  required: false,
172
154
  message: mode === "unsupported/no bridge" ? `No ${adapter} bridge is available for this checkout.` : `${adapter} compatibility mode: ${mode}.`
173
155
  },
174
- ...adapter === "mobile" ? mobileToolDoctorChecks(mobilePlatform) : [],
175
- ...adapter === "extension" ? [extensionBrowserDoctorCheck(target), extensionBrowserResolutionDoctorCheck(target)] : []
156
+ ...readiness.checks?.(target, platform) ?? []
176
157
  ];
177
158
  const requiredChecks = requiredDoctorCheckSummary(checks);
178
159
  return {
@@ -188,99 +169,11 @@ function createDoctorReport(adapter, target, manifestValidation, actionManifestP
188
169
  compatibilityMode: mode,
189
170
  shape: repoShape(target),
190
171
  fixture: fixtureSummary(target, adapter),
191
- environment: adapter === "mobile" ? { mobilePerps: mobilePerpsEnvironment(target) } : {},
172
+ environment: readiness.environment?.(target) ?? {},
192
173
  runtimeContext: runtimeContextSummary(target, adapter),
193
174
  manifestValidation: manifestValidation.summary
194
175
  };
195
176
  }
196
- function extensionBrowserDoctorCheck(target) {
197
- const requireFromTarget = createRequire(path.join(path.resolve(target), "package.json"));
198
- for (const packageName of ["@playwright/test", "playwright"]) {
199
- try {
200
- const playwright = requireFromTarget(packageName);
201
- const executable = playwright.chromium?.executablePath?.();
202
- if (executable && fs.existsSync(executable)) {
203
- return {
204
- id: "playwright-chromium",
205
- status: "pass",
206
- required: true,
207
- message: "Checkout-pinned Playwright Chromium is installed.",
208
- detail: executable
209
- };
210
- }
211
- } catch {
212
- }
213
- }
214
- return {
215
- id: "playwright-chromium",
216
- status: "fail",
217
- required: true,
218
- message: "Checkout-pinned Playwright Chromium is not installed.",
219
- userAction: "yarn playwright install chromium"
220
- };
221
- }
222
- function extensionBrowserResolutionDoctorCheck(target) {
223
- const resolver = path.join(runnerDir, "adapters/shared/browser-resolver.cjs");
224
- const runtime = path.join(path.resolve(target), recipeRuntimeDir());
225
- const record = path.join(runtime, "browser-resolution.json");
226
- let profile = process.env.CHROME_USER_DATA_DIR || path.join(runtime, process.env.RECIPE_CHROME_PROFILE_NAME || "chrome-profile");
227
- while (profile.length > 1 && profile.endsWith("/")) profile = profile.slice(0, -1);
228
- const profileExists = fs.existsSync(path.join(profile, "Local State"));
229
- const result = spawnSync(process.execPath, [
230
- resolver,
231
- "resolve",
232
- "--target",
233
- path.resolve(target),
234
- "--launch-method",
235
- "launch-services",
236
- "--display",
237
- "headful",
238
- ...profileExists && fs.existsSync(record) ? ["--recorded", record] : [],
239
- "--json"
240
- ], {
241
- cwd: path.resolve(target),
242
- encoding: "utf8",
243
- timeout: 135e3
244
- });
245
- if (result.status === 0) {
246
- const resolution = JSON.parse(result.stdout);
247
- const managed = resolution.managedChrome;
248
- 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.` : "";
249
- 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}` : "";
250
- return {
251
- id: "extension-browser",
252
- status: "pass",
253
- required: false,
254
- 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}` : ""}`,
255
- detail: resolution.bin
256
- };
257
- }
258
- const message = (result.stderr || result.error?.message || "browser resolution failed").trim();
259
- return {
260
- id: "extension-browser",
261
- status: "fail",
262
- required: message.includes("BROWSER_UNLAUNCHABLE:"),
263
- message,
264
- userAction: "install Google Chrome, or set RECIPE_HARNESS_BROWSER / RECIPE_HARNESS_CHROME_BIN to a browser that starts"
265
- };
266
- }
267
- function mobileToolDoctorChecks(platform) {
268
- const specs = platform === "android" ? [{ tool: "adb", platform: "Android" }] : platform === "ios" ? [{ tool: "idb", platform: "iOS" }] : [
269
- { tool: "adb", platform: "Android" },
270
- { tool: "idb", platform: "iOS" }
271
- ];
272
- return specs.map(({ tool, platform: platformName }) => {
273
- const resolved = resolveMobileToolPath(tool);
274
- return {
275
- id: `device-tool-${tool}`,
276
- status: resolved ? "pass" : "fail",
277
- required: platform !== void 0,
278
- message: resolved ? `${platformName} device tool ${tool} is ready.` : `${platformName} device tool ${tool} is unavailable.`,
279
- ...resolved ? { detail: resolved } : {},
280
- ...!resolved ? { userAction: mobileToolRecovery(tool) } : {}
281
- };
282
- });
283
- }
284
177
  function runnerInstallKind(runnerRoot, invokedPath, executablePath) {
285
178
  if (isPin(runnerRoot)) return "pinned-install";
286
179
  const normalizedRoot = path.normalize(runnerRoot);
@@ -344,11 +237,8 @@ function readJsonObject(file) {
344
237
  export {
345
238
  compatibilityMode,
346
239
  createDoctorReport,
347
- extensionBrowserDoctorCheck,
348
- extensionBrowserResolutionDoctorCheck,
349
240
  fixtureFileSummary,
350
241
  fixtureSummary,
351
- mobileToolDoctorChecks,
352
242
  renderRuntimeContext,
353
243
  repoShape,
354
244
  requiredDoctorCheckSummary,