@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.
- package/CHANGELOG.md +28 -0
- package/adapters/shared/reap-checkout-metros.sh +9 -0
- package/dist/adapters/core/overlay.js +34 -0
- package/dist/adapters/core/readiness.js +8 -0
- package/dist/adapters/core/surface.js +7 -0
- package/dist/adapters/extension/doctor-checks.js +135 -0
- package/dist/adapters/extension/fixtures.js +103 -0
- package/dist/adapters/extension/overlay.js +55 -0
- package/dist/adapters/extension/readiness.js +32 -0
- package/dist/adapters/extension/reload.js +40 -0
- package/dist/adapters/extension/surface.js +22 -1
- package/dist/adapters/harness-freshness.js +4 -2
- package/dist/adapters/mobile/devices.js +21 -0
- package/dist/adapters/mobile/doctor-checks.js +40 -0
- package/dist/adapters/mobile/fixtures.js +141 -0
- package/dist/adapters/mobile/overlay.js +27 -0
- package/dist/adapters/mobile/prepare-target.js +38 -0
- package/dist/adapters/mobile/readiness.js +39 -0
- package/dist/adapters/mobile/reload.js +15 -0
- package/dist/adapters/mobile/surface.js +28 -1
- package/dist/adapters/terminal/doctor-checks.js +22 -0
- package/dist/adapters/terminal/overlay.js +18 -0
- package/dist/adapters/terminal/readiness.js +11 -0
- package/dist/adapters/terminal/surface.js +15 -2
- package/dist/cli.js +3 -1
- package/dist/commands/call.js +3 -7
- package/dist/commands/debug.js +6 -25
- package/dist/commands/device-target.js +2 -1
- package/dist/commands/doctor.js +26 -137
- package/dist/commands/fixtures.js +13 -217
- package/dist/commands/list-executables.js +12 -22
- package/dist/commands/logs.js +1 -1
- package/dist/commands/manifest.js +11 -133
- package/dist/commands/parse-args.js +9 -4
- package/dist/commands/prepare.js +23 -53
- package/dist/commands/provision.js +3 -7
- package/dist/commands/reload.js +8 -47
- package/dist/commands/run.js +2 -5
- package/dist/commands/runtime-decision.js +5 -27
- package/dist/commands/self-test.js +1 -3
- package/dist/commands/status.js +20 -28
- package/dist/commands/stop.js +1 -1
- package/dist/doctor.js +9 -119
- package/dist/harness.js +31 -100
- package/dist/mm-harness-cli.js +3 -2
- package/dist/paths.js +8 -2
- package/dist/runtime-context.js +12 -17
- package/docs/CONTRIBUTING.md +27 -17
- 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
|
|
21
|
+
const install = getAdapterSurface(adapter).harness.install;
|
|
22
|
+
const injector = path.resolve(runnerDir, install.entry);
|
|
21
23
|
try {
|
|
22
|
-
execFileSync(
|
|
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
|
+
};
|