@deeeed/metamask-harness 0.75.0 → 0.77.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 (49) hide show
  1. package/CHANGELOG.md +28 -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/call.js +3 -7
  27. package/dist/commands/debug.js +6 -25
  28. package/dist/commands/device-target.js +2 -1
  29. package/dist/commands/doctor.js +26 -137
  30. package/dist/commands/fixtures.js +13 -217
  31. package/dist/commands/list-executables.js +12 -22
  32. package/dist/commands/logs.js +1 -1
  33. package/dist/commands/manifest.js +11 -133
  34. package/dist/commands/parse-args.js +9 -4
  35. package/dist/commands/prepare.js +23 -53
  36. package/dist/commands/provision.js +3 -7
  37. package/dist/commands/reload.js +8 -47
  38. package/dist/commands/run.js +2 -5
  39. package/dist/commands/runtime-decision.js +5 -27
  40. package/dist/commands/self-test.js +1 -3
  41. package/dist/commands/status.js +20 -28
  42. package/dist/commands/stop.js +1 -1
  43. package/dist/doctor.js +9 -119
  44. package/dist/harness.js +31 -100
  45. package/dist/mm-harness-cli.js +3 -2
  46. package/dist/paths.js +8 -2
  47. package/dist/runtime-context.js +12 -17
  48. package/docs/CONTRIBUTING.md +27 -17
  49. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.77.0 - 2026-10-04
6
+
7
+ ### Changed
8
+
9
+ - The action catalog runs on `@farmslot/recipe-cli` 0.1.2: `actions` (lookup, search, related actions, categories, `--matrix`), `call` name lookup and `call --list` short names, and the missing-capability refusals in `call` and `run` use its catalog helpers instead of mm-harness's own copies. mm-harness still resolves the action manifest with its own policy, and flags and output are unchanged.
10
+
11
+ ## 0.76.0 - 2026-10-04
12
+
13
+ ### Changed
14
+
15
+ - The lifecycle and readiness commands reach platform behavior through the adapter surface instead of branching on the adapter name. New `AdapterSurface` members:
16
+ - `harness`: the install/cleanup leaves, verify dispatch and environment, and the restart hint;
17
+ - `runtimeContext`: forbidden fields, local port bases and the resources `agentic-runtime.json` records;
18
+ - `readiness`: doctor's compatibility mode, platform checks, live checks, environment, orphaned dev servers, capture providers, `--fix` repairs, runtime block, pinned feature flags, the status runtime probe, and connected devices;
19
+ - `fixtures` (operations, apply), `prepare`, `debug`, `reload` and `runtimeDecision`;
20
+ - plus `devServer.afterStop` and `runwayProvision.before`/`launchNext`.
21
+ - `harness.ts`, `prepare`, `runtime-context`, `status`, both doctor modules, `fixtures`, `debug`, `reload`, `runtime-decision`, `provision`, `stop`, `logs`, `self-test`, `device-target` and the CLI front door (`--adapter`/`--platform` validation now asks the registry) go from 90 adapter-name branches to 0; `src/` goes from 101 to 10 (one of the 101 was a comment, reworded).
22
+ - Platform code moves to `src/adapters/<adapter>/` (`overlay.ts`, `readiness.ts`, `doctor-checks.ts`, `fixtures.ts`, `reload.ts`, Mobile `devices.ts` and `prepare-target.ts`).
23
+ - The test-only `example` adapter now also installs, verifies, cleans up, records its runtime context, and is reached by doctor, fixtures, reload, runtime-decision, stop, provision and debug.
24
+ - Flags, output and the CLI goldens are unchanged for Mobile, Extension and Core. Four Terminal paths that fell into another platform's branch now behave as Terminal:
25
+ - `doctor` reports `cdp` as the screenshot provider instead of `simctl`/`adb`;
26
+ - `logs --window` names the tail window `browser-<port>` instead of `webpack-<port>`;
27
+ - `reload` and `fixtures set`/`reset` report that Terminal has nothing to reload or apply, instead of running the Mobile reload script or failing with a status-less envelope.
28
+
29
+ ### Fixed
30
+
31
+ - `doctor` finds leaked Metro bundlers by running `reap-checkout-metros.sh detect <checkout> [port]` with an argv instead of a `bash -c` string, so the checkout path and port are never shell text (code scanning alerts 41, 42 and 131).
32
+
5
33
  ## 0.75.0 - 2026-10-04
6
34
 
7
35
  ### Added
@@ -6,6 +6,8 @@
6
6
  #
7
7
  # reap_checkout_metros <checkout-abs-path> [keep-pid]
8
8
  # detect_checkout_metros <checkout-abs-path> [live-port]
9
+ # Sourced for the functions; run directly, `detect <checkout-abs-path> [live-port]`
10
+ # prints the orphan pids (doctor calls it this way, with an argv and no shell string).
9
11
  # reap kills every bundler for the checkout (stop wants a clean slate), sparing
10
12
  # keep-pid. detect lists orphans for doctor and EXCLUDES the bundler serving
11
13
  # live-port (the currently-running valid Metro is not a leak).
@@ -73,3 +75,10 @@ detect_checkout_metros() {
73
75
  $(_checkout_metro_pids "$repo")
74
76
  LIST
75
77
  }
78
+
79
+ if [ "${BASH_SOURCE[0]}" = "$0" ]; then
80
+ case "${1:-}" in
81
+ detect) shift; detect_checkout_metros "$@" ;;
82
+ *) printf 'usage: %s detect <checkout-abs-path> [live-port]\n' "$0" >&2; exit 2 ;;
83
+ esac
84
+ fi
@@ -0,0 +1,34 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { recipeHarnessPath } from "../../paths.js";
4
+ function isExecutable(file) {
5
+ try {
6
+ const stat = fs.statSync(file);
7
+ return stat.isFile() && (stat.mode & 73) !== 0;
8
+ } catch {
9
+ return false;
10
+ }
11
+ }
12
+ const coreHarness = {
13
+ install: { entry: "adapters/core/inject.sh", fallback: "scripts/inject-core-harness.sh" },
14
+ cleanup: { entry: "adapters/core/cleanup.sh", fallback: "scripts/cleanup-core-harness.sh", fromInstalledRunner: true },
15
+ verify(target) {
16
+ const delegate = path.join(recipeHarnessPath(target, "core"), "runner", "bin", "mm-harness");
17
+ if (!isExecutable(delegate)) {
18
+ return {
19
+ error: `core recipe harness not installed: missing delegate ${delegate}.
20
+ Next: mm-harness install --platform core --target ${target}`
21
+ };
22
+ }
23
+ return { command: delegate, prefixArgs: ["doctor", "--adapter", "core"] };
24
+ }
25
+ };
26
+ const coreRuntimeContext = {
27
+ forbiddenFields: ["cdpPort", "watcherPort", "metroPort", "devServerPort", "simulator", "simulatorUdid", "adbSerial", "extensionId"]
28
+ };
29
+ const corePrepare = {};
30
+ export {
31
+ coreHarness,
32
+ corePrepare,
33
+ coreRuntimeContext
34
+ };
@@ -0,0 +1,8 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ const coreReadiness = {
4
+ compatibilityMode: (target) => fs.existsSync(path.join(target, "packages/perps-controller/src/index.ts")) ? "headless controller (no bridge)" : "unsupported/no bridge"
5
+ };
6
+ export {
7
+ coreReadiness
8
+ };
@@ -3,6 +3,8 @@ import { depsCheck } from "@farmslot/recipe-harness/runtime/deps-readiness";
3
3
  import { shellQuote } from "../../commands/parse-args.js";
4
4
  import { adapterRegistry } from "../registry.js";
5
5
  import { coreActions } from "./actions.js";
6
+ import { coreHarness, corePrepare, coreRuntimeContext } from "./overlay.js";
7
+ import { coreReadiness } from "./readiness.js";
6
8
  import { coreRun } from "./run.js";
7
9
  const coreSurface = {
8
10
  adapter: "core",
@@ -59,6 +61,11 @@ const coreSurface = {
59
61
  },
60
62
  launch: async ({ usage }) => usage("core is headless; there is nothing to launch.", "mm-harness verify"),
61
63
  actions: coreActions,
64
+ harness: coreHarness,
65
+ runtimeContext: coreRuntimeContext,
66
+ readiness: coreReadiness,
67
+ fixtures: { operations: ["init", "sync", "set"] },
68
+ prepare: corePrepare,
62
69
  run: coreRun
63
70
  };
64
71
  adapterRegistry.register(coreSurface);
@@ -0,0 +1,135 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import fs from "node:fs";
4
+ import { createRequire } from "node:module";
5
+ import path from "node:path";
6
+ import { recipeRuntimeDir, runnerDir } from "../../paths.js";
7
+ function extensionBrowserDoctorCheck(target) {
8
+ const requireFromTarget = createRequire(path.join(path.resolve(target), "package.json"));
9
+ for (const packageName of ["@playwright/test", "playwright"]) {
10
+ try {
11
+ const playwright = requireFromTarget(packageName);
12
+ const executable = playwright.chromium?.executablePath?.();
13
+ if (executable && fs.existsSync(executable)) {
14
+ return {
15
+ id: "playwright-chromium",
16
+ status: "pass",
17
+ required: true,
18
+ message: "Checkout-pinned Playwright Chromium is installed.",
19
+ detail: executable
20
+ };
21
+ }
22
+ } catch {
23
+ }
24
+ }
25
+ return {
26
+ id: "playwright-chromium",
27
+ status: "fail",
28
+ required: true,
29
+ message: "Checkout-pinned Playwright Chromium is not installed.",
30
+ userAction: "yarn playwright install chromium"
31
+ };
32
+ }
33
+ function extensionBrowserResolutionDoctorCheck(target) {
34
+ const resolver = path.join(runnerDir, "adapters/shared/browser-resolver.cjs");
35
+ const runtime = path.join(path.resolve(target), recipeRuntimeDir());
36
+ const record = path.join(runtime, "browser-resolution.json");
37
+ let profile = process.env.CHROME_USER_DATA_DIR || path.join(runtime, process.env.RECIPE_CHROME_PROFILE_NAME || "chrome-profile");
38
+ while (profile.length > 1 && profile.endsWith("/")) profile = profile.slice(0, -1);
39
+ const profileExists = fs.existsSync(path.join(profile, "Local State"));
40
+ const result = spawnSync(process.execPath, [
41
+ resolver,
42
+ "resolve",
43
+ "--target",
44
+ path.resolve(target),
45
+ "--launch-method",
46
+ "launch-services",
47
+ "--display",
48
+ "headful",
49
+ ...profileExists && fs.existsSync(record) ? ["--recorded", record] : [],
50
+ "--json"
51
+ ], {
52
+ cwd: path.resolve(target),
53
+ encoding: "utf8",
54
+ timeout: 135e3
55
+ });
56
+ if (result.status === 0) {
57
+ const resolution = JSON.parse(result.stdout);
58
+ const managed = resolution.managedChrome;
59
+ 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.` : "";
60
+ 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}` : "";
61
+ return {
62
+ id: "extension-browser",
63
+ status: "pass",
64
+ required: false,
65
+ 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}` : ""}`,
66
+ detail: resolution.bin
67
+ };
68
+ }
69
+ const message = (result.stderr || result.error?.message || "browser resolution failed").trim();
70
+ return {
71
+ id: "extension-browser",
72
+ status: "fail",
73
+ required: message.includes("BROWSER_UNLAUNCHABLE:"),
74
+ message,
75
+ userAction: "install Google Chrome, or set RECIPE_HARNESS_BROWSER / RECIPE_HARNESS_CHROME_BIN to a browser that starts"
76
+ };
77
+ }
78
+ function ensureExtensionPerpsBuildFlag(target) {
79
+ const configPath = path.join(target, ".metamaskrc");
80
+ const templatePath = path.join(target, ".metamaskrc.dist");
81
+ const config = readTextSnapshot(configPath);
82
+ const template = config.kind === "file" ? null : readTextSnapshot(templatePath);
83
+ const source = config.kind === "file" ? config : template?.kind === "file" ? template : config.kind === "symlink" ? { kind: "file", text: "", mode: 438 } : null;
84
+ if (!source) return false;
85
+ const before = source.text;
86
+ if (/^\s*PERPS_ENABLED\s*=\s*(['"]?)true\1\s*$/mu.test(before)) return false;
87
+ const line = "PERPS_ENABLED='true'";
88
+ const after = /^\s*PERPS_ENABLED\s*=.*$/mu.test(before) ? before.replace(/^\s*PERPS_ENABLED\s*=.*$/mu, line) : `${before.replace(/\s*$/u, "")}
89
+ ${line}
90
+ `;
91
+ const temporary = `${configPath}.${process.pid}.${randomUUID()}.tmp`;
92
+ let descriptor = null;
93
+ try {
94
+ const mode = config.kind === "file" ? config.mode : 438;
95
+ descriptor = fs.openSync(
96
+ temporary,
97
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL,
98
+ mode
99
+ );
100
+ fs.writeFileSync(descriptor, after);
101
+ if (config.kind === "file") fs.fchmodSync(descriptor, config.mode);
102
+ fs.closeSync(descriptor);
103
+ descriptor = null;
104
+ fs.renameSync(temporary, configPath);
105
+ } finally {
106
+ if (descriptor !== null) fs.closeSync(descriptor);
107
+ fs.rmSync(temporary, { force: true });
108
+ }
109
+ return true;
110
+ }
111
+ function readTextSnapshot(file) {
112
+ let descriptor;
113
+ try {
114
+ descriptor = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
115
+ } catch (error) {
116
+ const code = error.code;
117
+ if (code === "ENOENT") return { kind: "missing" };
118
+ if (code === "ELOOP") return { kind: "symlink" };
119
+ throw error;
120
+ }
121
+ try {
122
+ return {
123
+ kind: "file",
124
+ text: fs.readFileSync(descriptor, "utf8"),
125
+ mode: fs.fstatSync(descriptor).mode & 511
126
+ };
127
+ } finally {
128
+ fs.closeSync(descriptor);
129
+ }
130
+ }
131
+ export {
132
+ ensureExtensionPerpsBuildFlag,
133
+ extensionBrowserDoctorCheck,
134
+ extensionBrowserResolutionDoctorCheck
135
+ };
@@ -0,0 +1,103 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { colorHumanMessage } from "../../cli-color.js";
4
+ import { FIXTURE_STREAM_TIMEOUT_MS } from "../../commands/fixtures.js";
5
+ import { EXIT, spawnScriptStreaming } from "../../commands/shared.js";
6
+ import { sameFeatureFlags } from "../../feature-flags.js";
7
+ import { runnerDir } from "../../paths.js";
8
+ import { readExtensionPinnedFlags } from "./feature-flags.js";
9
+ async function applyExtensionFixture({
10
+ target,
11
+ fixturePath,
12
+ canonicalFixture,
13
+ json,
14
+ sub: action,
15
+ fixtureFlags
16
+ }) {
17
+ const distManifest = path.join(target, "dist/chrome/manifest.json");
18
+ const userAction = `mm-harness launch --build --verify --target ${JSON.stringify(target)}`;
19
+ if (!fs.existsSync(distManifest)) {
20
+ const message2 = `Extension fixture needs an existing dist/chrome build; no manifest was found at ${distManifest}.`;
21
+ if (json) {
22
+ console.log(JSON.stringify({
23
+ schemaVersion: 1,
24
+ command: "fixtures",
25
+ action,
26
+ adapter: "extension",
27
+ fixture: fixturePath,
28
+ canonicalFixture,
29
+ status: "fail",
30
+ exitCode: EXIT.runtime,
31
+ error: { code: "EXTENSION_FIXTURE_BUILD_REQUIRED", message: message2, userAction }
32
+ }, null, 2));
33
+ } else {
34
+ console.error(`\u2717 mm-harness fixtures ${action}: ${message2}
35
+ Next: ${userAction}`);
36
+ }
37
+ return EXIT.runtime;
38
+ }
39
+ process.stderr.write(`${colorHumanMessage(`\u2192 fixtures ${action} extension \u2014 resetting and seeding the slot-owned profile from the existing build\u2026`)}
40
+ `);
41
+ const liveScript = path.join(runnerDir, "adapters/extension/live.sh");
42
+ const args = ["--target", target, "--launch-existing-dist", "--launch-only", "--reset-profile"];
43
+ const flagKeys = Object.keys(fixtureFlags);
44
+ if (flagKeys.length > 0) args.push("--remote-flags-json", JSON.stringify(fixtureFlags));
45
+ if (process.env.CDP_PORT) args.push("--cdp-port", process.env.CDP_PORT);
46
+ const result = await spawnScriptStreaming(liveScript, args, target, {
47
+ env: { RECIPE_WALLET_FIXTURE: fixturePath },
48
+ timeoutMs: FIXTURE_STREAM_TIMEOUT_MS
49
+ });
50
+ const pinned = readExtensionPinnedFlags(target);
51
+ const pinnedFlags = pinned.pinned ?? {};
52
+ const flagMismatch = result.status === 0 && !pinned.error && (pinned.pinned === null || !sameFeatureFlags(pinnedFlags, fixtureFlags)) ? [.../* @__PURE__ */ new Set([...flagKeys, ...Object.keys(pinnedFlags)])].filter(
53
+ (key) => JSON.stringify(pinnedFlags[key]) !== JSON.stringify(fixtureFlags[key])
54
+ ) : [];
55
+ if (result.status === 0 && (pinned.error || flagMismatch.length > 0)) {
56
+ const detail = pinned.error ? `the launched snapshot manifest could not be read (${pinned.sourcePath ?? "unknown path"}): ${pinned.error}` : `snapshot pin disagrees with the fixture for: ${flagMismatch.join(", ")}`;
57
+ const flagNext = `mm-harness launch --adapter extension --build --target ${JSON.stringify(target)}`;
58
+ if (json) {
59
+ console.log(JSON.stringify({
60
+ schemaVersion: 1,
61
+ command: "fixtures",
62
+ action,
63
+ adapter: "extension",
64
+ fixture: fixturePath,
65
+ canonicalFixture,
66
+ status: "fail",
67
+ exitCode: EXIT.runtime,
68
+ error: { code: "EXTENSION_FIXTURE_FLAGS_NOT_APPLIED", message: detail, userAction: flagNext }
69
+ }, null, 2));
70
+ } else {
71
+ console.error(`\u2717 mm-harness fixtures ${action}: ${detail}
72
+ Next: ${flagNext}`);
73
+ }
74
+ return EXIT.runtime;
75
+ }
76
+ const status = result.status === 0 ? "pass" : "fail";
77
+ const exitCode = status === "pass" ? EXIT.ok : EXIT.runtime;
78
+ const message = status === "pass" ? `Wallet fixture applied and validated. Want different accounts? Edit: ${canonicalFixture}` : "Extension wallet fixture could not be applied and validated.";
79
+ if (json) {
80
+ console.log(JSON.stringify({
81
+ schemaVersion: 1,
82
+ command: "fixtures",
83
+ action,
84
+ adapter: "extension",
85
+ fixture: fixturePath,
86
+ canonicalFixture,
87
+ status,
88
+ exitCode,
89
+ profileReset: status === "pass",
90
+ message,
91
+ error: status === "fail" ? { code: "EXTENSION_FIXTURE_APPLY_FAILED", message, userAction } : null
92
+ }, null, 2));
93
+ } else if (status === "pass") {
94
+ console.error(message);
95
+ } else {
96
+ console.error(`\u2717 mm-harness fixtures ${action}: ${message}
97
+ Next: ${userAction}`);
98
+ }
99
+ return exitCode;
100
+ }
101
+ export {
102
+ applyExtensionFixture
103
+ };
@@ -0,0 +1,55 @@
1
+ import fs from "node:fs";
2
+ import { readRuntimeContextField, resolveRuntimeContextPath } from "../../harness.js";
3
+ import { resolveExtensionSlotPorts } from "../slot-ports.js";
4
+ function hasArg(args, needle) {
5
+ return args.some((arg) => arg === needle || arg.startsWith(`${needle}=`));
6
+ }
7
+ function applyExtensionRuntimeEnv(target, args) {
8
+ const contextPath = resolveRuntimeContextPath(target);
9
+ const contextExists = fs.existsSync(contextPath);
10
+ if (contextExists) {
11
+ process.env.RECIPE_RUNTIME_CONTEXT = contextPath;
12
+ if (!process.env.RECIPE_SLOT_ID) {
13
+ const slotId = readRuntimeContextField(contextPath, "slotId");
14
+ if (slotId) process.env.RECIPE_SLOT_ID = slotId;
15
+ }
16
+ if (!process.env.RECIPE_HARNESS_EXTENSION_ID) {
17
+ const extensionId = readRuntimeContextField(contextPath, "extensionId");
18
+ if (extensionId) process.env.RECIPE_HARNESS_EXTENSION_ID = extensionId;
19
+ }
20
+ } else {
21
+ delete process.env.RECIPE_RUNTIME_CONTEXT;
22
+ }
23
+ resolveExtensionSlotPorts(target);
24
+ let result = [...args];
25
+ if (!hasArg(result, "--cdp-port")) {
26
+ const cdpPort = process.env.RECIPE_CDP_PORT ?? process.env.CDP_PORT;
27
+ if (cdpPort) {
28
+ process.env.RECIPE_CDP_PORT = cdpPort;
29
+ process.env.CDP_PORT = cdpPort;
30
+ result = [...result, "--cdp-port", cdpPort];
31
+ }
32
+ }
33
+ return result;
34
+ }
35
+ const extensionHarness = {
36
+ install: { entry: "adapters/extension/inject.mjs", fallback: "scripts/inject-extension-harness.mjs", node: true },
37
+ cleanup: { entry: "adapters/extension/cleanup.mjs", fallback: "scripts/cleanup-extension-harness.mjs", node: true, fromInstalledRunner: true },
38
+ // Runner-owned typed code, not a shipped leaf: no installed overlay copy can shadow it.
39
+ verify: () => ({ inProcess: async () => (await import("./verify.js")).runExtensionVerify }),
40
+ verifyArgs: applyExtensionRuntimeEnv,
41
+ restart: "mm-harness launch"
42
+ };
43
+ const extensionRuntimeContext = {
44
+ forbiddenFields: ["metroPort", "simulator", "simulatorUdid", "adbSerial"],
45
+ localPorts: { cdp: 2e4, watcher: 3e4 },
46
+ resources: ({ existing, defaults, envPort }) => ({
47
+ cdpPort: envPort("RECIPE_CDP_PORT", "CDP_PORT") ?? existing.cdpPort ?? defaults.cdpPort
48
+ })
49
+ };
50
+ const extensionPrepare = {};
51
+ export {
52
+ extensionHarness,
53
+ extensionPrepare,
54
+ extensionRuntimeContext
55
+ };
@@ -0,0 +1,32 @@
1
+ import { optionFlag, optionString, parsePort } from "../../commands/parse-args.js";
2
+ import { ensureExtensionPerpsBuildFlag, extensionBrowserDoctorCheck, extensionBrowserResolutionDoctorCheck } from "./doctor-checks.js";
3
+ import { readExtensionPinnedFlags } from "./feature-flags.js";
4
+ import { extensionProductConfigBlock } from "./product-config.js";
5
+ const extensionReadiness = {
6
+ compatibilityMode(_target, shape) {
7
+ if (!shape.extensionProject) return "unsupported/no bridge";
8
+ if (shape.extensionRuntime || shape.injectedHarness) return "bridge present";
9
+ return "bridge injectable";
10
+ },
11
+ checks: (target) => [extensionBrowserDoctorCheck(target), extensionBrowserResolutionDoctorCheck(target)],
12
+ captureProviders: ["cdp"],
13
+ fixes: [{ id: "extension-perps-build", apply: ensureExtensionPerpsBuildFlag }],
14
+ runtimeBlock: (target, runtime) => runtime?.reasonCode === "product-config-required" ? { id: "extension-product-config", userAction: extensionProductConfigBlock(target)?.userAction } : void 0,
15
+ pinnedFlags: readExtensionPinnedFlags,
16
+ statusRuntime: true
17
+ };
18
+ async function extensionRuntimeDecision(target, options) {
19
+ const cdpPortRaw = optionString(options, "cdpPort") ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT;
20
+ const cdpPort = cdpPortRaw === void 0 ? void 0 : parsePort(cdpPortRaw, "runtime-decision --cdp-port must be a port.");
21
+ const { decideExtensionReadiness } = await import("./runtime-decision.js");
22
+ const report = await decideExtensionReadiness(target, {
23
+ cdpPort,
24
+ watchLog: optionString(options, "watchLog"),
25
+ record: optionFlag(options, "record")
26
+ });
27
+ return { report, line: `${report.decision}${report.clean ? " (clean)" : ""} ${report.reasonCode} \u2014 ${report.reasons[0] ?? ""}` };
28
+ }
29
+ export {
30
+ extensionReadiness,
31
+ extensionRuntimeDecision
32
+ };
@@ -0,0 +1,40 @@
1
+ import path from "node:path";
2
+ import { EXIT, spawnScript, usageOut } from "../../commands/shared.js";
3
+ import { runnerDir } from "../../paths.js";
4
+ function reloadExtension(target, json) {
5
+ const adapter = "extension";
6
+ if (!process.env.CDP_PORT) {
7
+ return usageOut(
8
+ json,
9
+ "reload",
10
+ "the Extension CDP port could not be resolved.",
11
+ "mm-harness launch"
12
+ );
13
+ }
14
+ const script = path.join(runnerDir, "adapters/extension/reattach.sh");
15
+ const args = ["--target", target, "--cdp-port", process.env.CDP_PORT];
16
+ if (process.env.WATCHER_PORT) {
17
+ args.push("--watcher-port", process.env.WATCHER_PORT);
18
+ }
19
+ const result = spawnScript(script, args, target, json);
20
+ if (json) {
21
+ console.log(
22
+ JSON.stringify(
23
+ {
24
+ ok: result.status === 0,
25
+ adapter,
26
+ method: "cdp-reattach",
27
+ command: "reload",
28
+ cdpPort: Number(process.env.CDP_PORT),
29
+ ...result.status === 0 ? {} : { error: result.output.slice(-4e3) || "Extension reload failed." }
30
+ },
31
+ null,
32
+ 2
33
+ )
34
+ );
35
+ }
36
+ return result.status === 0 ? EXIT.ok : EXIT.runtime;
37
+ }
38
+ export {
39
+ reloadExtension
40
+ };
@@ -11,6 +11,8 @@ import { decideExtensionReadiness } from "./runtime-decision.js";
11
11
  import { extensionProductConfigBlock } from "./product-config.js";
12
12
  import { adapterRegistry } from "../registry.js";
13
13
  import { extensionActions } from "./actions.js";
14
+ import { extensionHarness, extensionPrepare, extensionRuntimeContext } from "./overlay.js";
15
+ import { extensionReadiness, extensionRuntimeDecision } from "./readiness.js";
14
16
  import { extensionDiagnostics, extensionObservation, extensionRecording, extensionRun } from "./run.js";
15
17
  function watcherStatus(buildLog) {
16
18
  if (buildLog === "ok") return "up";
@@ -84,7 +86,8 @@ const extensionSurface = {
84
86
  spawnSync("bash", [stopViewers, "--target", target], { stdio: "ignore", timeout: 5e3 });
85
87
  const summary = stopped.signalled > 0 || stopped.removedMarkers > 0 ? `stopped Extension runtime (${stopped.signalled} process${stopped.signalled === 1 ? "" : "es"}, ${stopped.removedMarkers} stale marker${stopped.removedMarkers === 1 ? "" : "s"}) for ${target}` : `Extension runtime not running for ${target} \u2014 nothing to stop`;
86
88
  return { kind: "stopped", status: 0, summary, signalled: stopped.signalled };
87
- }
89
+ },
90
+ afterStop: (target) => `mm-harness launch --target ${shellQuote(target)}`
88
91
  },
89
92
  logSources(target) {
90
93
  return [
@@ -110,6 +113,24 @@ const extensionSurface = {
110
113
  return runDeviceLaunch(context, extensionLaunchProfile);
111
114
  },
112
115
  actions: extensionActions,
116
+ harness: extensionHarness,
117
+ runtimeContext: extensionRuntimeContext,
118
+ readiness: extensionReadiness,
119
+ fixtures: {
120
+ operations: ["init", "sync", "set", "reset", "generate", "finalize"],
121
+ apply: async (context) => (await import("./fixtures.js")).applyExtensionFixture(context)
122
+ },
123
+ prepare: extensionPrepare,
124
+ debug: {
125
+ refuse: ({ devMenu }) => devMenu ? { message: "--dev-menu is mobile-only (RN developer menu). Use --worker on the extension.", userAction: "mm-harness debug --worker" } : void 0,
126
+ args: ({ worker }) => [
127
+ ...process.env.CDP_PORT ? ["--cdp-port", process.env.CDP_PORT] : [],
128
+ "--target",
129
+ worker ? "worker" : "page"
130
+ ]
131
+ },
132
+ reload: async (target, json) => (await import("./reload.js")).reloadExtension(target, json),
133
+ runtimeDecision: extensionRuntimeDecision,
113
134
  run: extensionRun,
114
135
  recording: extensionRecording,
115
136
  diagnostics: extensionDiagnostics,
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { recipeHarnessPath, runnerDir } from "../paths.js";
6
+ import { getAdapterSurface } from "./surface.js";
6
7
  async function ensureHarnessFresh(target, adapter) {
7
8
  if (!fs.existsSync(path.join(target, "package.json")) || !hasCheckoutHead(target)) return;
8
9
  let current;
@@ -17,9 +18,10 @@ async function ensureHarnessFresh(target, adapter) {
17
18
  const installed = readInstalledFingerprint(manifestPath);
18
19
  if (installed === current) return;
19
20
  const reason = installed === null ? "harness not installed" : "runner changed";
20
- const injector = path.join(runnerDir, `adapters/${adapter}/inject.${adapter === "extension" ? "mjs" : "sh"}`);
21
+ const install = getAdapterSurface(adapter).harness.install;
22
+ const injector = path.resolve(runnerDir, install.entry);
21
23
  try {
22
- execFileSync(adapter === "extension" ? process.execPath : "bash", [injector, "--target", target], {
24
+ execFileSync(install.node ? process.execPath : "bash", [injector, "--target", target], {
23
25
  stdio: ["ignore", "ignore", "inherit"]
24
26
  });
25
27
  console.error(`\u2192 refreshed ${adapter} harness overlay (${reason})`);
@@ -0,0 +1,21 @@
1
+ import {
2
+ mobileDeviceLiveView,
3
+ mobileDeviceView,
4
+ nextForLive,
5
+ renderAdditionalReachableDevices,
6
+ renderAndroidPortHints,
7
+ renderMobileDeviceList,
8
+ renderMobileLiveBlock
9
+ } from "../../commands/mobile-device-view.js";
10
+ const mobileDevices = {
11
+ view: mobileDeviceView,
12
+ live: mobileDeviceLiveView,
13
+ renderList: renderMobileDeviceList,
14
+ renderAdditional: renderAdditionalReachableDevices,
15
+ renderLive: renderMobileLiveBlock,
16
+ renderPortHints: renderAndroidPortHints,
17
+ nextForLive
18
+ };
19
+ export {
20
+ mobileDevices
21
+ };
@@ -0,0 +1,40 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import path from "node:path";
3
+ import {
4
+ mobileToolRecovery,
5
+ resolveMobileToolPath
6
+ } from "../../../library/actions/mobile/platform/tool-paths.mjs";
7
+ import { runnerDir } from "../../paths.js";
8
+ function mobileToolDoctorChecks(platform) {
9
+ const specs = platform === "android" ? [{ tool: "adb", platform: "Android" }] : platform === "ios" ? [{ tool: "idb", platform: "iOS" }] : [
10
+ { tool: "adb", platform: "Android" },
11
+ { tool: "idb", platform: "iOS" }
12
+ ];
13
+ return specs.map(({ tool, platform: platformName }) => {
14
+ const resolved = resolveMobileToolPath(tool);
15
+ return {
16
+ id: `device-tool-${tool}`,
17
+ status: resolved ? "pass" : "fail",
18
+ required: platform !== void 0,
19
+ message: resolved ? `${platformName} device tool ${tool} is ready.` : `${platformName} device tool ${tool} is unavailable.`,
20
+ ...resolved ? { detail: resolved } : {},
21
+ ...!resolved ? { userAction: mobileToolRecovery(tool) } : {}
22
+ };
23
+ });
24
+ }
25
+ function detectOrphanMetros(target, livePort) {
26
+ try {
27
+ const script = path.join(runnerDir, "adapters/shared/reap-checkout-metros.sh");
28
+ const out = execFileSync("bash", [script, "detect", target, livePort ?? ""], {
29
+ encoding: "utf8",
30
+ timeout: 5e3
31
+ });
32
+ return out.split("\n").map((line) => line.trim()).filter((line) => /^\d+$/u.test(line));
33
+ } catch {
34
+ return [];
35
+ }
36
+ }
37
+ export {
38
+ detectOrphanMetros,
39
+ mobileToolDoctorChecks
40
+ };