@deeeed/metamask-harness 0.4.0 → 0.5.1
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 +33 -0
- package/adapters/extension/inject.mjs +2 -0
- package/adapters/extension/refresh-build.sh +2 -2
- package/adapters/extension/start-watch.sh +2 -2
- package/adapters/manifest.json +19 -3
- package/adapters/mobile/start-metro.sh +1 -1
- package/adapters/mobile/verify.sh +1 -1
- package/adapters/shared/activate-repo-node.sh +1 -1
- package/adapters/shared/cli-ux.sh +24 -23
- package/adapters/shared/log-tui.mjs +4 -4
- package/adapters/shared/open-debug.mjs +1 -1
- package/adapters/shared/resolve-farmslot-ports-core.mjs +196 -0
- package/adapters/shared/resolve-farmslot-ports.mjs +20 -0
- package/adapters/shared/resolve-farmslot-ports.sh +19 -126
- package/dist/adapters/core/surface.js +53 -0
- package/dist/adapters/extension/ensure-ready.js +109 -0
- package/dist/adapters/extension/extension-id.js +62 -0
- package/dist/adapters/extension/runtime-decision.js +305 -0
- package/dist/adapters/extension/runtime.js +324 -0
- package/dist/adapters/extension/surface.js +69 -0
- package/dist/adapters/mobile/deps-markers.js +22 -0
- package/dist/adapters/mobile/prepare.js +146 -0
- package/dist/adapters/mobile/provision.js +465 -0
- package/dist/adapters/mobile/runtime-decision.js +317 -0
- package/dist/adapters/mobile/surface.js +54 -0
- package/dist/adapters/resolve-farmslot-ports.js +22 -0
- package/dist/adapters/slot-ports.js +140 -0
- package/dist/adapters/surface.js +14 -0
- package/dist/adapters.js +485 -0
- package/dist/cli-color.js +79 -0
- package/dist/cli-commands.js +224 -0
- package/dist/cli-version.js +111 -0
- package/dist/cli.js +1571 -0
- package/dist/commands/debug.js +56 -0
- package/dist/commands/fixtures.js +153 -0
- package/dist/commands/launch.js +325 -0
- package/dist/commands/logs.js +73 -0
- package/dist/commands/shared.js +157 -0
- package/dist/commands/update.js +243 -0
- package/dist/completions-cache.js +53 -0
- package/dist/doctor.js +169 -0
- package/dist/harness.js +627 -0
- package/dist/heal-bounds.js +120 -0
- package/dist/index.js +25 -0
- package/dist/leaf-invoke.js +19 -0
- package/dist/live-adapter-contract.js +240 -0
- package/dist/manifest.js +37 -0
- package/dist/mm-harness-cli.js +521 -0
- package/dist/paths.js +179 -0
- package/dist/progress.js +94 -0
- package/dist/recording-target.js +133 -0
- package/dist/run-recording.js +271 -0
- package/dist/runner.js +88 -0
- package/dist/types.js +0 -0
- package/docs/CLI-SPEC.md +26 -3
- package/library/actions/core/perps/_controller.mjs +1 -1
- package/library/actions/extension/platform/cdp.mjs +1 -1
- package/library/actions/extension/wallet/ensure_unlocked.mjs +1 -1
- package/library/actions/harness-exports.mjs +27 -0
- package/library/actions/mobile/wallet/ensure_unlocked.mjs +1 -1
- package/library/actions/mobile/wallet/setup.mjs +1 -1
- package/package.json +5 -1
- package/src/adapters/core/surface.ts +15 -0
- package/src/adapters/extension/surface.ts +20 -3
- package/src/adapters/mobile/provision.ts +594 -0
- package/src/adapters/mobile/runtime-decision.ts +8 -1
- package/src/adapters/mobile/surface.ts +16 -4
- package/src/adapters/resolve-farmslot-ports.ts +13 -0
- package/src/adapters/slot-ports.ts +18 -25
- package/src/adapters/surface.ts +35 -0
- package/src/cli-commands.ts +1 -1
- package/src/cli.ts +149 -6
- package/src/harness.ts +140 -3
- package/src/mm-harness-cli.ts +52 -5
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
depsCheck,
|
|
7
|
+
INSTALL_MARKERS,
|
|
8
|
+
recordDepsBaseline
|
|
9
|
+
} from "@farmslot/recipe-harness/runtime/deps-readiness";
|
|
10
|
+
import { recipeHarnessPath, recipeWatchLogCandidates } from "../../paths.js";
|
|
11
|
+
const WEBPACK_DIRECT_INPUTS = ["package.json", "yarn.lock", ".yarnrc.yml", ".tool-versions"];
|
|
12
|
+
const WEBPACK_RECURSIVE_INPUTS = ["development/webpack"];
|
|
13
|
+
const WEBPACK_CACHE_DIR = "node_modules/.cache/webpack";
|
|
14
|
+
const WATCH_LOG_CANDIDATES = recipeWatchLogCandidates();
|
|
15
|
+
function git(target, args) {
|
|
16
|
+
try {
|
|
17
|
+
return execFileSync("git", ["-C", target, ...args], {
|
|
18
|
+
encoding: "utf8",
|
|
19
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
20
|
+
}).trim();
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function gitRaw(target, args) {
|
|
26
|
+
try {
|
|
27
|
+
return execFileSync("git", ["-C", target, ...args], {
|
|
28
|
+
encoding: "utf8",
|
|
29
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
30
|
+
});
|
|
31
|
+
} catch {
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function addFile(hash, target, rel) {
|
|
36
|
+
const abs = path.join(target, rel);
|
|
37
|
+
const stat = fs.statSync(abs);
|
|
38
|
+
hash.update(rel);
|
|
39
|
+
hash.update(String(stat.size));
|
|
40
|
+
hash.update(fs.readFileSync(abs));
|
|
41
|
+
}
|
|
42
|
+
function walk(hash, target, relDir) {
|
|
43
|
+
const absDir = path.join(target, relDir);
|
|
44
|
+
if (!fs.existsSync(absDir)) return;
|
|
45
|
+
for (const name of fs.readdirSync(absDir).sort()) {
|
|
46
|
+
const rel = path.join(relDir, name);
|
|
47
|
+
const stat = fs.statSync(path.join(target, rel));
|
|
48
|
+
if (stat.isDirectory()) walk(hash, target, rel);
|
|
49
|
+
else if (stat.isFile() && /\.(c?m?[jt]sx?|json)$/u.test(rel)) addFile(hash, target, rel);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function webpackFingerprint(target) {
|
|
53
|
+
const hash = crypto.createHash("sha256");
|
|
54
|
+
for (const rel of WEBPACK_DIRECT_INPUTS) {
|
|
55
|
+
if (fs.existsSync(path.join(target, rel))) addFile(hash, target, rel);
|
|
56
|
+
}
|
|
57
|
+
for (const dir of WEBPACK_RECURSIVE_INPUTS) walk(hash, target, dir);
|
|
58
|
+
return { gitHead: git(target, ["rev-parse", "HEAD"]) ?? "unknown", fingerprint: hash.digest("hex") };
|
|
59
|
+
}
|
|
60
|
+
function stateDir(target) {
|
|
61
|
+
return recipeHarnessPath(target, "extension", "decision-state");
|
|
62
|
+
}
|
|
63
|
+
function readBaseline(target, name) {
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(fs.readFileSync(path.join(stateDir(target), name), "utf8"));
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function writeBaseline(target, name, value) {
|
|
71
|
+
const dir = stateDir(target);
|
|
72
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
73
|
+
fs.writeFileSync(path.join(dir, name), `${JSON.stringify(value, null, 2)}
|
|
74
|
+
`);
|
|
75
|
+
}
|
|
76
|
+
function newestMtime(target, rels) {
|
|
77
|
+
let newest = 0;
|
|
78
|
+
for (const rel of rels) {
|
|
79
|
+
const abs = path.join(target, rel);
|
|
80
|
+
if (fs.existsSync(abs)) newest = Math.max(newest, fs.statSync(abs).mtimeMs);
|
|
81
|
+
}
|
|
82
|
+
return newest;
|
|
83
|
+
}
|
|
84
|
+
function webpackCacheCheck(target) {
|
|
85
|
+
const cachePresent = fs.existsSync(path.join(target, WEBPACK_CACHE_DIR));
|
|
86
|
+
if (!cachePresent) return { cachePresent: false, status: "no-cache", hasBaseline: false };
|
|
87
|
+
const baseline = readBaseline(target, "webpack-cache-state.json");
|
|
88
|
+
if (!baseline) {
|
|
89
|
+
const cacheMtime = fs.statSync(path.join(target, WEBPACK_CACHE_DIR)).mtimeMs;
|
|
90
|
+
const poisoned = newestMtime(target, ["yarn.lock", ...INSTALL_MARKERS]) > cacheMtime;
|
|
91
|
+
return { cachePresent: true, status: poisoned ? "stale" : "current", hasBaseline: false };
|
|
92
|
+
}
|
|
93
|
+
const current = webpackFingerprint(target);
|
|
94
|
+
const status = baseline.fingerprint === current.fingerprint && baseline.gitHead === current.gitHead ? "current" : "stale";
|
|
95
|
+
return { cachePresent: true, status, hasBaseline: true };
|
|
96
|
+
}
|
|
97
|
+
function buildLogCheck(target, watchLog) {
|
|
98
|
+
const candidates = (watchLog ? [path.isAbsolute(watchLog) ? watchLog : path.join(target, watchLog)] : WATCH_LOG_CANDIDATES.map((rel) => path.join(target, rel))).filter((file) => fs.existsSync(file));
|
|
99
|
+
if (!candidates.length) return { status: "no-watch" };
|
|
100
|
+
const newestFirst = [...candidates].sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
|
101
|
+
const lines = fs.readFileSync(newestFirst[0], "utf8").split("\n");
|
|
102
|
+
const ERR = /Module build failed|^ERROR in |compiled with [1-9]\d* error/u;
|
|
103
|
+
const OK = /compiled successfully|compiled with \d+ warning|MetaMask .* compiled|Bundle end: service worker|Bundle end:.*app-init/iu;
|
|
104
|
+
let lastErr = -1;
|
|
105
|
+
let lastOk = -1;
|
|
106
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
107
|
+
if (ERR.test(lines[i])) lastErr = i;
|
|
108
|
+
if (OK.test(lines[i])) lastOk = i;
|
|
109
|
+
}
|
|
110
|
+
if (lastErr > lastOk) {
|
|
111
|
+
const excerpt = lines.slice(lastErr, lastErr + 3).join(" | ").slice(0, 400);
|
|
112
|
+
const staleCache = /ENOENT/u.test(excerpt) && /node_modules/u.test(excerpt);
|
|
113
|
+
return { status: "errors", reason: staleCache ? "stale-cache" : "build-error", excerpt };
|
|
114
|
+
}
|
|
115
|
+
if (lastOk >= 0) return { status: "ok" };
|
|
116
|
+
return { status: "building" };
|
|
117
|
+
}
|
|
118
|
+
function uncommittedSource(target) {
|
|
119
|
+
const dirs = ["ui", "app", "shared", "development"].filter((d) => fs.existsSync(path.join(target, d)));
|
|
120
|
+
if (!dirs.length) return [];
|
|
121
|
+
const SRC = /\.(ts|tsx|js|jsx|cjs|mjs|json|scss|css)$/iu;
|
|
122
|
+
const records = gitRaw(target, ["-c", "core.quotePath=false", "status", "--porcelain", "-z", "--", ...dirs]).split("\0").filter(Boolean);
|
|
123
|
+
const dirty = [];
|
|
124
|
+
for (const record of records) {
|
|
125
|
+
const file = /^.. /u.test(record) ? record.slice(3) : record;
|
|
126
|
+
if (SRC.test(file)) dirty.push(file);
|
|
127
|
+
}
|
|
128
|
+
return dirty;
|
|
129
|
+
}
|
|
130
|
+
function latestFileMtime(target, files) {
|
|
131
|
+
let latest = 0;
|
|
132
|
+
for (const file of files) {
|
|
133
|
+
const absolute = path.join(target, file);
|
|
134
|
+
if (!fs.existsSync(absolute)) continue;
|
|
135
|
+
const stat = fs.statSync(absolute);
|
|
136
|
+
if (stat.isFile()) latest = Math.max(latest, stat.mtimeMs);
|
|
137
|
+
}
|
|
138
|
+
return latest;
|
|
139
|
+
}
|
|
140
|
+
function latestDistMtime(target) {
|
|
141
|
+
const distDir = path.join(target, "dist/chrome");
|
|
142
|
+
let latest = 0;
|
|
143
|
+
function walk2(dir) {
|
|
144
|
+
if (!fs.existsSync(dir)) return;
|
|
145
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
146
|
+
const absolute = path.join(dir, entry);
|
|
147
|
+
const stat = fs.statSync(absolute);
|
|
148
|
+
if (stat.isDirectory()) walk2(absolute);
|
|
149
|
+
else if (stat.isFile()) latest = Math.max(latest, stat.mtimeMs);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
walk2(distDir);
|
|
153
|
+
return latest;
|
|
154
|
+
}
|
|
155
|
+
function distCheck(target) {
|
|
156
|
+
const manifestPath = path.join(target, "dist/chrome/manifest.json");
|
|
157
|
+
if (!fs.existsSync(manifestPath)) return { status: "no-build" };
|
|
158
|
+
let manifest;
|
|
159
|
+
try {
|
|
160
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
161
|
+
} catch {
|
|
162
|
+
return { status: "no-build" };
|
|
163
|
+
}
|
|
164
|
+
const description = typeof manifest.description === "string" ? manifest.description : "";
|
|
165
|
+
const match = /from git id:\s*([0-9a-f]{7,40})/iu.exec(description);
|
|
166
|
+
const head = (git(target, ["rev-parse", "HEAD"]) ?? "").toLowerCase();
|
|
167
|
+
const headShort = head ? head.slice(0, 8) : void 0;
|
|
168
|
+
if (!match) return { status: "unknown", head: headShort };
|
|
169
|
+
const distGitId = match[1].toLowerCase();
|
|
170
|
+
const distShort = distGitId.slice(0, 8);
|
|
171
|
+
if (!head) return { status: "unknown", distGitId: distShort };
|
|
172
|
+
if (!head.startsWith(distGitId)) {
|
|
173
|
+
return { status: "stale", reason: "commit-mismatch", distGitId: distShort, head: headShort };
|
|
174
|
+
}
|
|
175
|
+
const dirty = uncommittedSource(target);
|
|
176
|
+
if (dirty.length) {
|
|
177
|
+
const dirtySinceBuild = latestFileMtime(target, dirty) > latestDistMtime(target);
|
|
178
|
+
if (dirtySinceBuild) {
|
|
179
|
+
return { status: "stale", reason: "uncommitted-source", distGitId: distShort, head: headShort, modified: dirty.slice(0, 10) };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { status: "fresh", distGitId: distShort, head: headShort };
|
|
183
|
+
}
|
|
184
|
+
async function cdpCheck(target, cdpPort) {
|
|
185
|
+
if (!cdpPort) return { status: "skipped" };
|
|
186
|
+
try {
|
|
187
|
+
const { checkExtensionRuntimeHealth } = await import("./runtime.js");
|
|
188
|
+
const report = await checkExtensionRuntimeHealth(target, cdpPort);
|
|
189
|
+
return report.status === "PASS" ? { status: "pass" } : { status: "fail", findings: report.findings };
|
|
190
|
+
} catch (error) {
|
|
191
|
+
return { status: "fail", findings: [error instanceof Error ? error.message : String(error)] };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function recordReadinessBaseline(target) {
|
|
195
|
+
recordDepsBaseline(target);
|
|
196
|
+
const cache = webpackFingerprint(target);
|
|
197
|
+
writeBaseline(target, "webpack-cache-state.json", cache);
|
|
198
|
+
}
|
|
199
|
+
async function decideExtensionReadiness(target, options = {}) {
|
|
200
|
+
const resolved = path.resolve(target);
|
|
201
|
+
if (options.record) recordReadinessBaseline(resolved);
|
|
202
|
+
const deps = depsCheck(resolved);
|
|
203
|
+
const webpackCache = webpackCacheCheck(resolved);
|
|
204
|
+
const buildLog = buildLogCheck(resolved, options.watchLog);
|
|
205
|
+
const dist = distCheck(resolved);
|
|
206
|
+
const cdp = await cdpCheck(resolved, options.cdpPort);
|
|
207
|
+
const checks = { deps, webpackCache, buildLog, dist, cdp };
|
|
208
|
+
const install = [{ id: "yarn-install", argv: ["yarn", "install", "--immutable"], cwd: resolved }];
|
|
209
|
+
const relaunch = [{ id: "relaunch-browser" }];
|
|
210
|
+
const cacheStale = buildLog.reason === "stale-cache";
|
|
211
|
+
const rules = [
|
|
212
|
+
{
|
|
213
|
+
when: deps.status === "missing",
|
|
214
|
+
decision: "install",
|
|
215
|
+
reasonCode: "deps-missing",
|
|
216
|
+
reasons: ["Dependencies are not installed (no yarn install-state markers)."],
|
|
217
|
+
actions: install
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
when: deps.status === "stale",
|
|
221
|
+
decision: "install",
|
|
222
|
+
reasonCode: "deps-stale",
|
|
223
|
+
reasons: ["package.json/yarn.lock changed since the recorded install."],
|
|
224
|
+
actions: install
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
when: buildLog.status === "errors",
|
|
228
|
+
decision: "build",
|
|
229
|
+
clean: cacheStale,
|
|
230
|
+
reasonCode: cacheStale ? "webpack-cache-stale" : "build-errors",
|
|
231
|
+
reasons: [
|
|
232
|
+
cacheStale ? "Webpack is failing on a poisoned cache (ENOENT on a deduped module)." : "Webpack build has errors; fix the source/build before validating.",
|
|
233
|
+
...buildLog.excerpt ? [buildLog.excerpt] : []
|
|
234
|
+
],
|
|
235
|
+
actions: buildAction(resolved, cacheStale)
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
when: webpackCache.status === "stale",
|
|
239
|
+
decision: "build",
|
|
240
|
+
clean: true,
|
|
241
|
+
reasonCode: "webpack-cache-stale",
|
|
242
|
+
reasons: ["Webpack build inputs changed since the cache was recorded; clear cache and rebuild."],
|
|
243
|
+
actions: buildAction(resolved, true)
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
when: dist.status === "no-build",
|
|
247
|
+
decision: "build",
|
|
248
|
+
reasonCode: "dist-missing",
|
|
249
|
+
reasons: ["No dist/chrome build present."],
|
|
250
|
+
actions: buildAction(resolved, false)
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
when: dist.status === "stale",
|
|
254
|
+
decision: "build",
|
|
255
|
+
reasonCode: "dist-stale",
|
|
256
|
+
reasons: [dist.reason === "uncommitted-source" ? `Uncommitted source since the build (${dist.modified?.length ?? 0} file(s)); rebuild.` : `dist git id ${dist.distGitId} != HEAD ${dist.head}; rebuild.`],
|
|
257
|
+
actions: buildAction(resolved, false)
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
when: cdp.status === "pass",
|
|
261
|
+
decision: "ready",
|
|
262
|
+
reasonCode: "healthy",
|
|
263
|
+
reasons: ["Build is fresh and the extension runtime is healthy over CDP."],
|
|
264
|
+
actions: []
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
when: cdp.status === "fail",
|
|
268
|
+
decision: "relaunch",
|
|
269
|
+
reasonCode: "runtime-unhealthy",
|
|
270
|
+
reasons: [
|
|
271
|
+
"Build is fresh but the live extension is unhealthy; relaunch the browser.",
|
|
272
|
+
...cdp.findings?.slice(0, 3) ?? []
|
|
273
|
+
],
|
|
274
|
+
actions: relaunch
|
|
275
|
+
}
|
|
276
|
+
];
|
|
277
|
+
const fallback = {
|
|
278
|
+
decision: "relaunch",
|
|
279
|
+
reasonCode: "cdp-unknown",
|
|
280
|
+
reasons: ["Build is fresh; browser liveness unverified (pass --cdp-port to confirm `ready`)."],
|
|
281
|
+
actions: relaunch
|
|
282
|
+
};
|
|
283
|
+
const core = rules.find((rule) => rule.when) ?? fallback;
|
|
284
|
+
return {
|
|
285
|
+
schemaVersion: 1,
|
|
286
|
+
adapter: "extension",
|
|
287
|
+
target: resolved,
|
|
288
|
+
decision: core.decision,
|
|
289
|
+
clean: core.clean ?? false,
|
|
290
|
+
reasonCode: core.reasonCode,
|
|
291
|
+
reasons: core.reasons,
|
|
292
|
+
checks,
|
|
293
|
+
actions: core.actions
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
function buildAction(target, clean) {
|
|
297
|
+
const actions = [];
|
|
298
|
+
if (clean) actions.push({ id: "clear-webpack-cache", paths: [WEBPACK_CACHE_DIR] });
|
|
299
|
+
actions.push({ id: "start-webpack-watch", argv: ["yarn", "start"], cwd: target });
|
|
300
|
+
return actions;
|
|
301
|
+
}
|
|
302
|
+
export {
|
|
303
|
+
decideExtensionReadiness,
|
|
304
|
+
recordReadinessBaseline
|
|
305
|
+
};
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { importRecipeHarnessRuntimeBrowserExtension, importRecipeHarnessRuntimeCdp, recipeRuntimeDir, resolveLocalProtocolRoot, resolveRequiredLocalProtocolRoot, runnerDir } from "../../paths.js";
|
|
5
|
+
const { CdpSession, jsonGet, sleep } = await importRecipeHarnessRuntimeCdp();
|
|
6
|
+
const { extensionIdFromTarget } = await importRecipeHarnessRuntimeBrowserExtension();
|
|
7
|
+
async function prepareExtensionRuntime(options) {
|
|
8
|
+
const projectRoot = path.resolve(options.projectRoot);
|
|
9
|
+
const slot = resolveExtensionSlot(projectRoot, options.slot);
|
|
10
|
+
const cdpPort = resolveCdpPort(options.cdpPort, slot);
|
|
11
|
+
let launch = null;
|
|
12
|
+
if (options.launchExistingDist) {
|
|
13
|
+
if (!slot) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
`Cannot launch host-managed Extension runtime for ${projectRoot}: no configured slot maps to this repo. Pass --slot <slot-id> or add the checkout to pool/*.json.`
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
launch = await launchHostValidationBrowser({
|
|
19
|
+
projectRoot,
|
|
20
|
+
cdpPort,
|
|
21
|
+
slotId: slot.id,
|
|
22
|
+
validationRuntimeDir: options.validationRuntimeDir ?? `${recipeRuntimeDir()}/validation-${cdpPort}`
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
const health = await assertHealthyExtensionRuntime({
|
|
26
|
+
projectRoot,
|
|
27
|
+
cdpPort,
|
|
28
|
+
timeoutMs: options.healthTimeoutMs
|
|
29
|
+
});
|
|
30
|
+
return { launch, health };
|
|
31
|
+
}
|
|
32
|
+
async function assertHealthyExtensionRuntime(options) {
|
|
33
|
+
const timeoutMs = options.timeoutMs ?? 3e4;
|
|
34
|
+
const deadline = Date.now() + timeoutMs;
|
|
35
|
+
let lastReport = null;
|
|
36
|
+
while (Date.now() <= deadline) {
|
|
37
|
+
lastReport = await checkExtensionRuntimeHealth(options.projectRoot, options.cdpPort);
|
|
38
|
+
if (lastReport.status === "PASS") return lastReport;
|
|
39
|
+
await sleep(500);
|
|
40
|
+
}
|
|
41
|
+
const report = lastReport ?? failReport(options.cdpPort, ["CDP runtime was not probed."], {});
|
|
42
|
+
throw new Error(formatHealthFailure(report, options.projectRoot));
|
|
43
|
+
}
|
|
44
|
+
async function checkExtensionRuntimeHealth(projectRoot, cdpPort) {
|
|
45
|
+
const findings = [];
|
|
46
|
+
let targets = [];
|
|
47
|
+
try {
|
|
48
|
+
await jsonGet(`http://127.0.0.1:${cdpPort}/json/version`);
|
|
49
|
+
const rawTargets = await jsonGet(`http://127.0.0.1:${cdpPort}/json/list`);
|
|
50
|
+
targets = Array.isArray(rawTargets) ? rawTargets : [];
|
|
51
|
+
} catch (error) {
|
|
52
|
+
return failReport(cdpPort, [`CDP is not reachable on port ${cdpPort}: ${messageOf(error)}`], {});
|
|
53
|
+
}
|
|
54
|
+
const extensionTargets = targets.filter(
|
|
55
|
+
(target2) => target2.type === "page" && String(target2.url ?? "").startsWith("chrome-extension://") && String(target2.url ?? "").includes("/home.html") && Boolean(target2.webSocketDebuggerUrl)
|
|
56
|
+
);
|
|
57
|
+
if (extensionTargets.length !== 1) {
|
|
58
|
+
findings.push(`Expected exactly one MetaMask extension home page target, found ${extensionTargets.length}.`);
|
|
59
|
+
}
|
|
60
|
+
const target = extensionTargets[0];
|
|
61
|
+
if (!target?.webSocketDebuggerUrl) {
|
|
62
|
+
return failReport(cdpPort, findings, {
|
|
63
|
+
targetUrls: targets.map((entry) => entry.url).filter(Boolean)
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
if (String(target.url ?? "").startsWith("chrome-error://")) {
|
|
67
|
+
findings.push(`Extension target resolved to chrome-error page: ${target.url}`);
|
|
68
|
+
}
|
|
69
|
+
const session = await CdpSession.connect(target.webSocketDebuggerUrl);
|
|
70
|
+
try {
|
|
71
|
+
await session.call("Runtime.enable");
|
|
72
|
+
await session.call("Page.enable");
|
|
73
|
+
const runtime = await evaluateHealth(session);
|
|
74
|
+
if (runtime.href && !String(runtime.href).startsWith("chrome-extension://")) {
|
|
75
|
+
findings.push(`Extension page href is not an extension URL: ${runtime.href}`);
|
|
76
|
+
}
|
|
77
|
+
if (runtime.backgroundUnresponsive === true) {
|
|
78
|
+
findings.push("Extension UI reports background connection unresponsive.");
|
|
79
|
+
}
|
|
80
|
+
if (runtime.hasSubmitRequest !== true) {
|
|
81
|
+
findings.push("stateHooks.submitRequestToBackground is unavailable.");
|
|
82
|
+
}
|
|
83
|
+
if (runtime.hasStore !== true) {
|
|
84
|
+
findings.push("stateHooks.store is unavailable.");
|
|
85
|
+
}
|
|
86
|
+
if (runtime.hasPerpsStreamManager !== true) {
|
|
87
|
+
findings.push("stateHooks.getPerpsStreamManager is unavailable.");
|
|
88
|
+
}
|
|
89
|
+
if (runtime.backgroundProbeOk !== true) {
|
|
90
|
+
findings.push(`Perps background read probe failed: ${runtime.backgroundProbeError ?? "unknown error"}.`);
|
|
91
|
+
}
|
|
92
|
+
const extensionId = safeExtensionId(target);
|
|
93
|
+
return {
|
|
94
|
+
status: findings.length === 0 ? "PASS" : "FAIL",
|
|
95
|
+
cdpPort,
|
|
96
|
+
targetUrl: target.url,
|
|
97
|
+
extensionId,
|
|
98
|
+
extensionPageTargets: extensionTargets.length,
|
|
99
|
+
findings,
|
|
100
|
+
details: {
|
|
101
|
+
projectRoot,
|
|
102
|
+
targetUrls: targets.map((entry) => entry.url).filter(Boolean),
|
|
103
|
+
runtime
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
} finally {
|
|
107
|
+
session.close();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function failReport(cdpPort, findings, details) {
|
|
111
|
+
return {
|
|
112
|
+
status: "FAIL",
|
|
113
|
+
cdpPort,
|
|
114
|
+
extensionPageTargets: 0,
|
|
115
|
+
findings,
|
|
116
|
+
details
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
async function evaluateHealth(session) {
|
|
120
|
+
const result = await session.call("Runtime.evaluate", {
|
|
121
|
+
expression: `(() => {
|
|
122
|
+
const hooks = globalThis.stateHooks || {};
|
|
123
|
+
const bodyText = document.body?.innerText || '';
|
|
124
|
+
const storeState = hooks.store?.getState?.() || {};
|
|
125
|
+
const manager = hooks.getPerpsStreamManager?.();
|
|
126
|
+
const accountCache = manager?.account?.cache;
|
|
127
|
+
return Promise.race([
|
|
128
|
+
(async () => {
|
|
129
|
+
let backgroundProbeOk = false;
|
|
130
|
+
let backgroundProbeError = null;
|
|
131
|
+
if (typeof hooks.submitRequestToBackground === 'function') {
|
|
132
|
+
try {
|
|
133
|
+
const accountState = await hooks.submitRequestToBackground('perpsGetAccountState', []);
|
|
134
|
+
backgroundProbeOk = Boolean(accountState && typeof accountState === 'object');
|
|
135
|
+
if (!backgroundProbeOk) backgroundProbeError = 'perpsGetAccountState returned an empty result';
|
|
136
|
+
} catch (error) {
|
|
137
|
+
backgroundProbeError = String(error?.message || error);
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
backgroundProbeError = 'submitRequestToBackground is not a function';
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
href: location.href,
|
|
144
|
+
title: document.title,
|
|
145
|
+
hookKeys: Object.keys(hooks),
|
|
146
|
+
hasSubmitRequest: typeof hooks.submitRequestToBackground === 'function',
|
|
147
|
+
hasStore: Boolean(hooks.store),
|
|
148
|
+
hasPerpsStreamManager: typeof hooks.getPerpsStreamManager === 'function',
|
|
149
|
+
backgroundUnresponsive: bodyText.includes('Background connection unresponsive') || bodyText.includes('MetaMask had trouble starting'),
|
|
150
|
+
activeProvider: storeState.metamask?.activeProvider || null,
|
|
151
|
+
isTestnet: Boolean(storeState.metamask?.isTestnet),
|
|
152
|
+
perpsManagerInitialized: Boolean(manager?.isInitialized?.()),
|
|
153
|
+
positionsCacheIsArray: Array.isArray(manager?.positions?.cache),
|
|
154
|
+
ordersCacheIsArray: Array.isArray(manager?.orders?.cache),
|
|
155
|
+
accountCachePresent: Boolean(accountCache && typeof accountCache === 'object'),
|
|
156
|
+
backgroundProbeOk,
|
|
157
|
+
backgroundProbeError,
|
|
158
|
+
};
|
|
159
|
+
})(),
|
|
160
|
+
new Promise((resolve) => setTimeout(() => resolve({
|
|
161
|
+
href: location.href,
|
|
162
|
+
title: document.title,
|
|
163
|
+
hookKeys: Object.keys(hooks),
|
|
164
|
+
hasSubmitRequest: typeof hooks.submitRequestToBackground === 'function',
|
|
165
|
+
hasStore: Boolean(hooks.store),
|
|
166
|
+
hasPerpsStreamManager: typeof hooks.getPerpsStreamManager === 'function',
|
|
167
|
+
backgroundUnresponsive: bodyText.includes('Background connection unresponsive') || bodyText.includes('MetaMask had trouble starting'),
|
|
168
|
+
backgroundProbeOk: false,
|
|
169
|
+
backgroundProbeError: 'perpsGetAccountState timed out after 5000ms',
|
|
170
|
+
}), 5000)),
|
|
171
|
+
]);
|
|
172
|
+
})()`,
|
|
173
|
+
awaitPromise: true,
|
|
174
|
+
returnByValue: true
|
|
175
|
+
});
|
|
176
|
+
if (result.exceptionDetails) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
result.exceptionDetails.exception?.description ?? result.exceptionDetails.text ?? "Extension runtime health evaluation failed."
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
return result.result?.value ?? {};
|
|
182
|
+
}
|
|
183
|
+
function formatHealthFailure(report, projectRoot) {
|
|
184
|
+
return [
|
|
185
|
+
`Extension runtime health check failed for ${projectRoot} on CDP port ${report.cdpPort}.`,
|
|
186
|
+
...report.findings.map((finding) => `- ${finding}`),
|
|
187
|
+
"Use the host-managed validation launcher, for example:",
|
|
188
|
+
` mm-harness run <recipe.json> --adapter extension --target ${projectRoot} --slot <slot-id> --cdp-port ${report.cdpPort} --launch-existing-dist --artifacts-dir <artifacts-dir>`,
|
|
189
|
+
`Health details: ${JSON.stringify(report.details)}`
|
|
190
|
+
].join("\n");
|
|
191
|
+
}
|
|
192
|
+
function resolveCdpPort(rawPort, slot) {
|
|
193
|
+
const raw = rawPort ?? process.env.CDP_PORT ?? process.env.RECIPE_CDP_PORT ?? slot?.cdpPort;
|
|
194
|
+
const port = Number(raw);
|
|
195
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
196
|
+
throw new Error("Extension runtime requires --cdp-port, CDP_PORT, RECIPE_CDP_PORT, or a configured slot with resources.browser.cdp_port.");
|
|
197
|
+
}
|
|
198
|
+
return port;
|
|
199
|
+
}
|
|
200
|
+
function resolveExtensionSlot(projectRoot, requestedSlot) {
|
|
201
|
+
const protocolRoot = resolveLocalProtocolRoot();
|
|
202
|
+
if (!protocolRoot) {
|
|
203
|
+
if (requestedSlot) throw new Error("Extension slot lookup requires a local host checkout when --slot is provided.");
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
const poolsDir = path.join(protocolRoot, "pool");
|
|
207
|
+
if (!fs.existsSync(poolsDir)) return null;
|
|
208
|
+
for (const file of fs.readdirSync(poolsDir)) {
|
|
209
|
+
if (!file.endsWith(".json") || file.includes(".bak")) continue;
|
|
210
|
+
const pool = JSON.parse(fs.readFileSync(path.join(poolsDir, file), "utf8"));
|
|
211
|
+
for (const rawSlot of pool.slots ?? []) {
|
|
212
|
+
if (!rawSlot || typeof rawSlot !== "object") continue;
|
|
213
|
+
const slot = rawSlot;
|
|
214
|
+
if (requestedSlot && slot.id !== requestedSlot) continue;
|
|
215
|
+
if (!requestedSlot) {
|
|
216
|
+
const repo = String(slot.repo ?? "");
|
|
217
|
+
if (!path.isAbsolute(repo) || path.resolve(repo) !== projectRoot) continue;
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
id: String(slot.id),
|
|
221
|
+
repo: path.resolve(String(slot.repo)),
|
|
222
|
+
cdpPort: Number(slot.resources?.browser?.cdp_port || slot.resources?.browser?.cdpPort || void 0) || void 0
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
async function launchHostValidationBrowser(options) {
|
|
229
|
+
const protocolRoot = resolveRequiredLocalProtocolRoot("Extension validation browser launch");
|
|
230
|
+
const script = path.join(protocolRoot, "projects/metamask-extension-farm/setup/launch-validation-browser.sh");
|
|
231
|
+
if (!fs.existsSync(script)) {
|
|
232
|
+
throw new Error(`Extension validation launcher not found: ${script}`);
|
|
233
|
+
}
|
|
234
|
+
await killCdpPort(options.cdpPort);
|
|
235
|
+
const result = await runProcess("bash", [
|
|
236
|
+
script,
|
|
237
|
+
options.slotId,
|
|
238
|
+
"--cdp-port",
|
|
239
|
+
String(options.cdpPort),
|
|
240
|
+
"--runtime-dir",
|
|
241
|
+
options.validationRuntimeDir,
|
|
242
|
+
"--no-landing"
|
|
243
|
+
], {
|
|
244
|
+
cwd: protocolRoot,
|
|
245
|
+
env: process.env,
|
|
246
|
+
timeoutMs: 18e4
|
|
247
|
+
});
|
|
248
|
+
if (result.timedOut) {
|
|
249
|
+
throw new Error(`Extension validation launcher timed out after 180000ms.
|
|
250
|
+
${result.stdout}
|
|
251
|
+
${result.stderr}`);
|
|
252
|
+
}
|
|
253
|
+
if (result.exitCode !== 0) {
|
|
254
|
+
throw new Error(`Extension validation launcher failed with exit ${result.exitCode}.
|
|
255
|
+
${result.stdout}
|
|
256
|
+
${result.stderr}`);
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
launched: true,
|
|
260
|
+
slotId: options.slotId,
|
|
261
|
+
cdpPort: options.cdpPort,
|
|
262
|
+
runtimeDir: options.validationRuntimeDir,
|
|
263
|
+
stdout: result.stdout
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function runProcess(command, args, options) {
|
|
267
|
+
return new Promise((resolve, reject) => {
|
|
268
|
+
const child = spawn(command, args, { cwd: options.cwd, env: options.env, stdio: ["ignore", "pipe", "pipe"] });
|
|
269
|
+
let stdout = "";
|
|
270
|
+
let stderr = "";
|
|
271
|
+
let settled = false;
|
|
272
|
+
const timeout = options.timeoutMs ? setTimeout(() => {
|
|
273
|
+
if (settled) return;
|
|
274
|
+
settled = true;
|
|
275
|
+
child.kill("SIGTERM");
|
|
276
|
+
setTimeout(() => {
|
|
277
|
+
if (!child.killed) child.kill("SIGKILL");
|
|
278
|
+
}, 1e3);
|
|
279
|
+
resolve({ exitCode: null, stdout, stderr, timedOut: true });
|
|
280
|
+
}, options.timeoutMs) : void 0;
|
|
281
|
+
child.stdout.on("data", (chunk) => {
|
|
282
|
+
stdout += chunk;
|
|
283
|
+
});
|
|
284
|
+
child.stderr.on("data", (chunk) => {
|
|
285
|
+
stderr += chunk;
|
|
286
|
+
});
|
|
287
|
+
child.on("error", (error) => {
|
|
288
|
+
if (settled) return;
|
|
289
|
+
settled = true;
|
|
290
|
+
if (timeout) clearTimeout(timeout);
|
|
291
|
+
reject(error);
|
|
292
|
+
});
|
|
293
|
+
child.on("close", (exitCode) => {
|
|
294
|
+
if (settled) return;
|
|
295
|
+
settled = true;
|
|
296
|
+
if (timeout) clearTimeout(timeout);
|
|
297
|
+
resolve({ exitCode, stdout, stderr, timedOut: false });
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
async function killCdpPort(cdpPort) {
|
|
302
|
+
if (process.platform !== "darwin" && process.platform !== "linux") return;
|
|
303
|
+
await runProcess("bash", ["-lc", `lsof -ti tcp:${cdpPort} -sTCP:LISTEN | xargs -r kill -TERM || true; sleep 1; lsof -ti tcp:${cdpPort} -sTCP:LISTEN | xargs -r kill -KILL || true`], {
|
|
304
|
+
cwd: runnerDir,
|
|
305
|
+
env: process.env,
|
|
306
|
+
timeoutMs: 1e4
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
function safeExtensionId(target) {
|
|
310
|
+
try {
|
|
311
|
+
return extensionIdFromTarget(target);
|
|
312
|
+
} catch (_error) {
|
|
313
|
+
return void 0;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
function messageOf(error) {
|
|
317
|
+
return error instanceof Error ? error.message : String(error);
|
|
318
|
+
}
|
|
319
|
+
export {
|
|
320
|
+
assertHealthyExtensionRuntime,
|
|
321
|
+
checkExtensionRuntimeHealth,
|
|
322
|
+
formatHealthFailure,
|
|
323
|
+
prepareExtensionRuntime
|
|
324
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { recipeRuntimePath } from "../../paths.js";
|
|
4
|
+
import { resolveExtensionSlotPorts, stopExtensionWatcher } from "../slot-ports.js";
|
|
5
|
+
import { decideExtensionReadiness } from "./runtime-decision.js";
|
|
6
|
+
function watcherStatus(buildLog) {
|
|
7
|
+
if (buildLog === "ok") return "up";
|
|
8
|
+
if (buildLog === "no-watch") return "down";
|
|
9
|
+
return buildLog;
|
|
10
|
+
}
|
|
11
|
+
const extensionSurface = {
|
|
12
|
+
adapter: "extension",
|
|
13
|
+
headless: false,
|
|
14
|
+
resolveSlotPorts(target) {
|
|
15
|
+
resolveExtensionSlotPorts(target);
|
|
16
|
+
},
|
|
17
|
+
async runtimeStatus(target) {
|
|
18
|
+
const cdpPort = process.env.CDP_PORT ? parseInt(process.env.CDP_PORT, 10) : void 0;
|
|
19
|
+
const report = await decideExtensionReadiness(target, { cdpPort });
|
|
20
|
+
return {
|
|
21
|
+
decision: report.decision,
|
|
22
|
+
reasonCode: report.reasonCode,
|
|
23
|
+
reasons: report.reasons,
|
|
24
|
+
deps: report.checks.deps.status,
|
|
25
|
+
devServer: { label: "webpack", status: watcherStatus(report.checks.buildLog.status) }
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
runwayProvision: {
|
|
29
|
+
async run(target, options) {
|
|
30
|
+
return {
|
|
31
|
+
schemaVersion: 1,
|
|
32
|
+
command: "provision",
|
|
33
|
+
adapter: "extension",
|
|
34
|
+
target: path.resolve(target),
|
|
35
|
+
status: "fail",
|
|
36
|
+
exitCode: 2,
|
|
37
|
+
error: {
|
|
38
|
+
code: "UNSUPPORTED_ADAPTER",
|
|
39
|
+
message: "extension uses a browser runtime; Runway mobile provisioning is not supported.",
|
|
40
|
+
userAction: options.rerunCommand || "mm-harness provision --adapter extension"
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
devServer: {
|
|
46
|
+
describe: () => "webpack watcher",
|
|
47
|
+
stop(target) {
|
|
48
|
+
const signalled = stopExtensionWatcher(target);
|
|
49
|
+
const port = process.env.WATCHER_PORT ?? "default";
|
|
50
|
+
spawnSync("tmux", ["kill-window", "-t", `webpack-${port}`], { stdio: "ignore", timeout: 2e3 });
|
|
51
|
+
const summary = signalled > 0 ? `stopped webpack watcher (${signalled} process${signalled === 1 ? "" : "es"}) for ${target}` : `webpack watcher not running for ${target} \u2014 nothing to stop`;
|
|
52
|
+
return { kind: "stopped", status: 0, summary, signalled };
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
logSources(target) {
|
|
56
|
+
return [
|
|
57
|
+
{ label: "webpack", path: recipeRuntimePath(target, "webpack.log") },
|
|
58
|
+
{ label: "watcher", path: recipeRuntimePath(target, "recipe-harness-webpack.log") },
|
|
59
|
+
{ label: "rebuild", path: recipeRuntimePath(target, "rebuild.log") }
|
|
60
|
+
];
|
|
61
|
+
},
|
|
62
|
+
hints: {
|
|
63
|
+
launch: "mm-harness launch",
|
|
64
|
+
relaunch: "mm-harness launch --build"
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
export {
|
|
68
|
+
extensionSurface
|
|
69
|
+
};
|