@deeeed/metamask-harness 0.69.0 → 0.70.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 +20 -0
- package/README.md +104 -0
- package/adapters/extension/ensure-browser.sh +85 -15
- package/adapters/extension/inject.mjs +2 -0
- package/adapters/extension/launch-browser.cjs +152 -18
- package/adapters/extension/lib/macos-focus.cjs +67 -195
- package/adapters/extension/live.sh +29 -48
- package/adapters/manifest.json +20 -4
- package/adapters/shared/browser-cdp.cjs +542 -0
- package/adapters/shared/browser-resolver.cjs +1007 -0
- package/adapters/shared/open-debug.mjs +6 -1
- package/dist/commands/run-engine.js +8 -1
- package/dist/commands/run-report.js +96 -3
- package/dist/commands/run.js +2 -1
- package/dist/doctor.js +48 -1
- package/docs/QA.md +3 -0
- package/library/actions/extension/platform/cdp.mjs +121 -55
- package/package.json +1 -1
- package/scripts/site-contrast.mjs +16 -3
|
@@ -475,8 +475,13 @@ async function openBrokeredMobileDebugger(port, shouldOpen) {
|
|
|
475
475
|
function pickExtensionTarget(targets, targetKind) {
|
|
476
476
|
const extensionTargets = targets.filter((target) => String(target.url || '').startsWith('chrome-extension://'));
|
|
477
477
|
if (targetKind === 'worker') {
|
|
478
|
+
// Branded Chrome runs component extensions with their own service workers;
|
|
479
|
+
// prefer the worker of the extension whose wallet page is open.
|
|
480
|
+
const walletPage = extensionTargets.find((target) => /\/(home|sidepanel)\.html/u.test(String(target.url || '')));
|
|
481
|
+
const walletId = process.env.RECIPE_HARNESS_EXTENSION_ID || (walletPage ? String(walletPage.url).split('/')[2] : '');
|
|
478
482
|
return (
|
|
479
|
-
extensionTargets.find((target) => target.type === 'service_worker')
|
|
483
|
+
(walletId && extensionTargets.find((target) => target.type === 'service_worker' && String(target.url).startsWith(`chrome-extension://${walletId}/`)))
|
|
484
|
+
|| extensionTargets.find((target) => target.type === 'service_worker')
|
|
480
485
|
|| targets.find((target) => target.type === 'service_worker' && String(target.url || '').startsWith('chrome-extension://'))
|
|
481
486
|
|| null
|
|
482
487
|
);
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
beginRunDiagnostics,
|
|
45
45
|
finishRunDiagnostics
|
|
46
46
|
} from "../run-diagnostics.js";
|
|
47
|
+
import { executedBrowser, recipeCdpPorts } from "./run-report.js";
|
|
47
48
|
import { EXIT } from "./shared.js";
|
|
48
49
|
import {
|
|
49
50
|
actionManifestPathOption,
|
|
@@ -155,7 +156,13 @@ async function runRecipe(adapter, recipe, artifactsDir, projectRoot, actionManif
|
|
|
155
156
|
finalized.artifactManifestPath,
|
|
156
157
|
effectsState
|
|
157
158
|
);
|
|
158
|
-
|
|
159
|
+
const browser = executedBrowser(
|
|
160
|
+
adapter,
|
|
161
|
+
projectRoot,
|
|
162
|
+
finalized.artifactManifestPath,
|
|
163
|
+
recipeCdpPorts(runRequest)
|
|
164
|
+
);
|
|
165
|
+
return browser ? { ...finalized, browser } : finalized;
|
|
159
166
|
} finally {
|
|
160
167
|
restoreRuntimeEnvironment();
|
|
161
168
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { recipeRuntimeDir } from "../paths.js";
|
|
4
6
|
import { isRecord } from "./parse-args.js";
|
|
5
7
|
function writeRunReport(result) {
|
|
6
8
|
const summaryPath = String(result.summaryPath);
|
|
@@ -20,7 +22,7 @@ function writeRunReport(result) {
|
|
|
20
22
|
preview: lines.filter((line) => line.startsWith("- ")).slice(0, 6).map((line) => line.slice(2))
|
|
21
23
|
};
|
|
22
24
|
}
|
|
23
|
-
function indexProductProvenanceArtifact(artifactManifestPath, target, adapter) {
|
|
25
|
+
function indexProductProvenanceArtifact(artifactManifestPath, target, adapter, browser = null) {
|
|
24
26
|
const artifactsDir = path.dirname(artifactManifestPath);
|
|
25
27
|
let gitRef = "unknown";
|
|
26
28
|
let branch = "unknown";
|
|
@@ -53,7 +55,8 @@ function indexProductProvenanceArtifact(artifactManifestPath, target, adapter) {
|
|
|
53
55
|
repository: path.basename(path.resolve(target)),
|
|
54
56
|
git_ref: gitRef,
|
|
55
57
|
branch,
|
|
56
|
-
dirty
|
|
58
|
+
dirty,
|
|
59
|
+
...browser ? { browser } : {}
|
|
57
60
|
};
|
|
58
61
|
fs.writeFileSync(
|
|
59
62
|
path.join(artifactsDir, "product-provenance.json"),
|
|
@@ -77,7 +80,13 @@ function indexProductProvenanceArtifact(artifactManifestPath, target, adapter) {
|
|
|
77
80
|
type: "json",
|
|
78
81
|
label: "Product checkout provenance",
|
|
79
82
|
category: "system",
|
|
80
|
-
metadata: {
|
|
83
|
+
metadata: {
|
|
84
|
+
adapter,
|
|
85
|
+
git_ref: gitRef,
|
|
86
|
+
branch,
|
|
87
|
+
dirty,
|
|
88
|
+
...browser?.boundTo && browser.boundTo !== "none" ? { browser: browser.browser, browser_version: browser.version, browser_source: browser.source, extension_loading: browser.extensionLoading } : {}
|
|
89
|
+
}
|
|
81
90
|
}
|
|
82
91
|
];
|
|
83
92
|
fs.writeFileSync(
|
|
@@ -86,6 +95,87 @@ function indexProductProvenanceArtifact(artifactManifestPath, target, adapter) {
|
|
|
86
95
|
`
|
|
87
96
|
);
|
|
88
97
|
}
|
|
98
|
+
function readRecord(file) {
|
|
99
|
+
try {
|
|
100
|
+
const recorded = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
101
|
+
return isRecord(recorded) ? recorded : null;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const browserCdp = createRequire(import.meta.url)("../../adapters/shared/browser-cdp.cjs");
|
|
107
|
+
function verifyLaunch(record, cdpPort) {
|
|
108
|
+
const launch = isRecord(record.launch) ? record.launch : {};
|
|
109
|
+
const pid = Number(launch.pid);
|
|
110
|
+
const port = Number(launch.cdpPort);
|
|
111
|
+
if (!Number.isInteger(pid) || !Number.isInteger(port) || typeof launch.profile !== "string" || typeof launch.processStartedAt !== "string") {
|
|
112
|
+
return "the browser record has no process identity (pid, start time, profile, CDP port)";
|
|
113
|
+
}
|
|
114
|
+
if (cdpPort && port !== Number(cdpPort)) return `the browser record is for CDP port ${port}, not ${cdpPort}`;
|
|
115
|
+
try {
|
|
116
|
+
const owner = browserCdp.cdpOwner(port, launch.profile);
|
|
117
|
+
if (owner.pid !== pid || owner.startedAt !== launch.processStartedAt) {
|
|
118
|
+
return `CDP port ${port} is now held by another browser process than the recorded pid ${pid}`;
|
|
119
|
+
}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
return error instanceof Error ? error.message : String(error);
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
function resolveTemplatedPort(value, params, recipe) {
|
|
126
|
+
let resolved = value;
|
|
127
|
+
const template = /^\{\{\s*params\.([\w-]+)\s*\}\}$/u.exec(String(value).trim());
|
|
128
|
+
if (template) {
|
|
129
|
+
const name = template[1];
|
|
130
|
+
const schema = isRecord(recipe) && isRecord(recipe.paramsSchema) && isRecord(recipe.paramsSchema.properties) ? recipe.paramsSchema.properties[name] : void 0;
|
|
131
|
+
resolved = isRecord(params) && params[name] !== void 0 ? params[name] : isRecord(schema) ? schema.default : void 0;
|
|
132
|
+
}
|
|
133
|
+
const text = String(resolved ?? "").trim();
|
|
134
|
+
return /^\d+$/u.test(text) ? text : null;
|
|
135
|
+
}
|
|
136
|
+
function recipeCdpPorts(request) {
|
|
137
|
+
const ports = /* @__PURE__ */ new Set();
|
|
138
|
+
const envPort = request.env?.CDP_PORT || request.env?.RECIPE_CDP_PORT;
|
|
139
|
+
if (envPort) ports.add(String(envPort));
|
|
140
|
+
let recipe = request.recipeDocument;
|
|
141
|
+
if (recipe === void 0 && request.recipePath) {
|
|
142
|
+
try {
|
|
143
|
+
recipe = JSON.parse(fs.readFileSync(request.recipePath, "utf8"));
|
|
144
|
+
} catch {
|
|
145
|
+
recipe = void 0;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const workflow = isRecord(recipe) && isRecord(recipe.workflow) ? recipe.workflow : null;
|
|
149
|
+
const nodes = workflow && isRecord(workflow.nodes) ? Object.values(workflow.nodes) : [];
|
|
150
|
+
for (const node of nodes) {
|
|
151
|
+
if (!isRecord(node) || node.cdp_port === void 0 || node.cdp_port === null) continue;
|
|
152
|
+
const port = resolveTemplatedPort(node.cdp_port, request.params, recipe);
|
|
153
|
+
ports.add(port ?? `unresolved ${String(node.cdp_port)}`);
|
|
154
|
+
}
|
|
155
|
+
return [...ports];
|
|
156
|
+
}
|
|
157
|
+
function executedBrowser(adapter, target, artifactManifestPath, ports) {
|
|
158
|
+
if (adapter !== "extension") return null;
|
|
159
|
+
if (ports.length === 0) return { boundTo: "none", reason: "the run had no known CDP port" };
|
|
160
|
+
if (ports.length > 1) return { boundTo: "none", reason: `the run drove several CDP ports (${ports.join(", ")})` };
|
|
161
|
+
return launchedBrowser(target, path.dirname(artifactManifestPath), ports[0]);
|
|
162
|
+
}
|
|
163
|
+
function launchedBrowser(target, artifactsDir, cdpPort) {
|
|
164
|
+
const runtime = readRecord(path.join(artifactsDir, "extension-runtime", "runtime.json"));
|
|
165
|
+
const autolaunched = runtime && isRecord(runtime.browser) ? runtime.browser : null;
|
|
166
|
+
const candidates = [];
|
|
167
|
+
if (autolaunched) candidates.push([autolaunched, "run-autolaunch"]);
|
|
168
|
+
const slot = readRecord(path.join(path.resolve(target), recipeRuntimeDir(), "browser-resolution.json"));
|
|
169
|
+
if (slot) candidates.push([slot, "slot-runtime"]);
|
|
170
|
+
if (candidates.length === 0) return null;
|
|
171
|
+
const reasons = [];
|
|
172
|
+
for (const [record, boundTo] of candidates) {
|
|
173
|
+
const problem = verifyLaunch(record, cdpPort);
|
|
174
|
+
if (problem === null) return { ...record, boundTo };
|
|
175
|
+
reasons.push(`${boundTo}: ${problem}`);
|
|
176
|
+
}
|
|
177
|
+
return { boundTo: "none", reason: reasons.join("; ") };
|
|
178
|
+
}
|
|
89
179
|
function renderRunReport(summary, entries) {
|
|
90
180
|
const lines = [
|
|
91
181
|
"# MetaMask Recipe Run",
|
|
@@ -187,6 +277,9 @@ function indexRunReportArtifact(artifactManifestPath) {
|
|
|
187
277
|
`);
|
|
188
278
|
}
|
|
189
279
|
export {
|
|
280
|
+
executedBrowser,
|
|
190
281
|
indexProductProvenanceArtifact,
|
|
282
|
+
launchedBrowser,
|
|
283
|
+
recipeCdpPorts,
|
|
191
284
|
writeRunReport
|
|
192
285
|
};
|
package/dist/commands/run.js
CHANGED
|
@@ -376,7 +376,8 @@ async function handleRunInner({ positional, options }, stream) {
|
|
|
376
376
|
indexProductProvenanceArtifact(
|
|
377
377
|
result.artifactManifestPath,
|
|
378
378
|
target,
|
|
379
|
-
adapter
|
|
379
|
+
adapter,
|
|
380
|
+
result.browser ?? null
|
|
380
381
|
);
|
|
381
382
|
if (violation !== null) {
|
|
382
383
|
const userAction = violation.userAction ?? `inspect ${shellQuote(result.summaryPath)} and ${shellQuote(result.tracePath)}; fix the application or recipe failure before retrying`;
|
package/dist/doctor.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import { createRequire } from "node:module";
|
|
3
4
|
import path from "node:path";
|
|
@@ -167,7 +168,7 @@ function createDoctorReport(adapter, target, manifestValidation, actionManifestP
|
|
|
167
168
|
message: mode === "unsupported/no bridge" ? `No ${adapter} bridge is available for this checkout.` : `${adapter} compatibility mode: ${mode}.`
|
|
168
169
|
},
|
|
169
170
|
...adapter === "mobile" ? mobileToolDoctorChecks(mobilePlatform) : [],
|
|
170
|
-
...adapter === "extension" ? [extensionBrowserDoctorCheck(target)] : []
|
|
171
|
+
...adapter === "extension" ? [extensionBrowserDoctorCheck(target), extensionBrowserResolutionDoctorCheck(target)] : []
|
|
171
172
|
];
|
|
172
173
|
const requiredChecks = requiredDoctorCheckSummary(checks);
|
|
173
174
|
return {
|
|
@@ -214,6 +215,51 @@ function extensionBrowserDoctorCheck(target) {
|
|
|
214
215
|
userAction: "yarn playwright install chromium"
|
|
215
216
|
};
|
|
216
217
|
}
|
|
218
|
+
function extensionBrowserResolutionDoctorCheck(target) {
|
|
219
|
+
const resolver = path.join(runnerDir, "adapters/shared/browser-resolver.cjs");
|
|
220
|
+
const runtime = path.join(path.resolve(target), recipeRuntimeDir());
|
|
221
|
+
const record = path.join(runtime, "browser-resolution.json");
|
|
222
|
+
let profile = process.env.CHROME_USER_DATA_DIR || path.join(runtime, process.env.RECIPE_CHROME_PROFILE_NAME || "chrome-profile");
|
|
223
|
+
while (profile.length > 1 && profile.endsWith("/")) profile = profile.slice(0, -1);
|
|
224
|
+
const profileExists = fs.existsSync(path.join(profile, "Local State"));
|
|
225
|
+
const result = spawnSync(process.execPath, [
|
|
226
|
+
resolver,
|
|
227
|
+
"resolve",
|
|
228
|
+
"--target",
|
|
229
|
+
path.resolve(target),
|
|
230
|
+
"--launch-method",
|
|
231
|
+
"launch-services",
|
|
232
|
+
"--display",
|
|
233
|
+
"headful",
|
|
234
|
+
...profileExists && fs.existsSync(record) ? ["--recorded", record] : [],
|
|
235
|
+
"--json"
|
|
236
|
+
], {
|
|
237
|
+
cwd: path.resolve(target),
|
|
238
|
+
encoding: "utf8",
|
|
239
|
+
timeout: 135e3
|
|
240
|
+
});
|
|
241
|
+
if (result.status === 0) {
|
|
242
|
+
const resolution = JSON.parse(result.stdout);
|
|
243
|
+
const managed = resolution.managedChrome;
|
|
244
|
+
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.` : "";
|
|
245
|
+
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}` : "";
|
|
246
|
+
return {
|
|
247
|
+
id: "extension-browser",
|
|
248
|
+
status: "pass",
|
|
249
|
+
required: false,
|
|
250
|
+
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}` : ""}`,
|
|
251
|
+
detail: resolution.bin
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
const message = (result.stderr || result.error?.message || "browser resolution failed").trim();
|
|
255
|
+
return {
|
|
256
|
+
id: "extension-browser",
|
|
257
|
+
status: "fail",
|
|
258
|
+
required: message.includes("BROWSER_UNLAUNCHABLE:"),
|
|
259
|
+
message,
|
|
260
|
+
userAction: "install Google Chrome, or set RECIPE_HARNESS_BROWSER / RECIPE_HARNESS_CHROME_BIN to a browser that starts"
|
|
261
|
+
};
|
|
262
|
+
}
|
|
217
263
|
function mobileToolDoctorChecks(platform) {
|
|
218
264
|
const specs = platform === "android" ? [{ tool: "adb", platform: "Android" }] : platform === "ios" ? [{ tool: "idb", platform: "iOS" }] : [
|
|
219
265
|
{ tool: "adb", platform: "Android" },
|
|
@@ -294,6 +340,7 @@ export {
|
|
|
294
340
|
compatibilityMode,
|
|
295
341
|
createDoctorReport,
|
|
296
342
|
extensionBrowserDoctorCheck,
|
|
343
|
+
extensionBrowserResolutionDoctorCheck,
|
|
297
344
|
fixtureFileSummary,
|
|
298
345
|
fixtureSummary,
|
|
299
346
|
mobileToolDoctorChecks,
|
package/docs/QA.md
CHANGED
|
@@ -94,6 +94,9 @@ mm-harness debug
|
|
|
94
94
|
`EXTENSION_PRODUCT_CONFIG_REQUIRED` before build or browser startup.
|
|
95
95
|
- [ ] Missing Chromium returns `EXTENSION_BROWSER_REQUIRED` without using system
|
|
96
96
|
Chrome.
|
|
97
|
+
- [ ] `doctor --adapter extension` names the launch browser, its version, why
|
|
98
|
+
it was chosen and how the extension loads; `RECIPE_HARNESS_BROWSER=cft`
|
|
99
|
+
restores Chrome for Testing, `=chrome` loads into Google Chrome over CDP.
|
|
97
100
|
- [ ] Launch uses the checkout-owned profile, opens one MetaMask surface, avoids
|
|
98
101
|
keychain prompts, and preserves keyboard focus by default.
|
|
99
102
|
- [ ] A second launch reuses the watcher/profile without duplicate tabs.
|
|
@@ -19,6 +19,7 @@ import { closeSync, openSync } from 'node:fs';
|
|
|
19
19
|
import { spawn } from 'node:child_process';
|
|
20
20
|
import { createRequire } from 'node:module';
|
|
21
21
|
import path from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
22
23
|
|
|
23
24
|
import {
|
|
24
25
|
importRecipeHarnessRuntimeBrowserExtension,
|
|
@@ -368,24 +369,46 @@ function autolaunchEnabled(input) {
|
|
|
368
369
|
process.env.METAMASK_RECIPE_EXTENSION_LAUNCH_EXISTING_DIST === '1';
|
|
369
370
|
}
|
|
370
371
|
|
|
371
|
-
|
|
372
|
-
|
|
372
|
+
const runnerRequire = createRequire(import.meta.url);
|
|
373
|
+
const runnerRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..');
|
|
374
|
+
const browserResolver = runnerRequire(path.join(runnerRoot, 'adapters/shared/browser-resolver.cjs'));
|
|
375
|
+
const { stopProfileProcessesSync } = runnerRequire(path.join(runnerRoot, 'adapters/extension/lib/validation-process-ownership.cjs'));
|
|
376
|
+
const browserCdp = runnerRequire(path.join(runnerRoot, 'adapters/shared/browser-cdp.cjs'));
|
|
377
|
+
const macosFocus = runnerRequire(path.join(runnerRoot, 'adapters/extension/lib/macos-focus.cjs'));
|
|
378
|
+
|
|
379
|
+
// On macOS a browser that opens a window at startup activates itself, so the
|
|
380
|
+
// autolaunched browser starts without one and gets its pages over CDP in the
|
|
381
|
+
// background (as the extension launcher does).
|
|
382
|
+
const startsWithoutWindow = () => process.platform === 'darwin';
|
|
383
|
+
|
|
384
|
+
// One bounded browser-level CDP operation (the shared helpers in
|
|
385
|
+
// browser-cdp.cjs never take the operator's focus).
|
|
386
|
+
async function withBrowserCdp(port, fn) {
|
|
387
|
+
const deadline = Date.now() + 15000;
|
|
388
|
+
const remaining = () => Math.max(1, deadline - Date.now());
|
|
389
|
+
const client = await browserCdp.connectBrowserCdp(port, { timeoutMs: remaining() });
|
|
390
|
+
try {
|
|
391
|
+
return await fn(client.send, remaining);
|
|
392
|
+
} finally {
|
|
393
|
+
client.close();
|
|
394
|
+
}
|
|
373
395
|
}
|
|
374
396
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
}
|
|
397
|
+
// Same browser choice as the live launcher (RECIPE_HARNESS_BROWSER /
|
|
398
|
+
// RECIPE_HARNESS_CHROME_BIN); branded Chrome loads the dist over CDP. This
|
|
399
|
+
// launcher spawns a headful browser directly, so the probe does too, and a
|
|
400
|
+
// reused profile keeps its recorded browser.
|
|
401
|
+
async function resolveChromeBinary(projectRoot, recorded) {
|
|
402
|
+
try {
|
|
403
|
+
return await browserResolver.resolveBrowser({
|
|
404
|
+
cft: () => browserResolver.playwrightChromiumExecutable(projectRoot),
|
|
405
|
+
launchMethod: browserResolver.SPAWN,
|
|
406
|
+
display: browserResolver.HEADFUL,
|
|
407
|
+
recorded,
|
|
408
|
+
});
|
|
409
|
+
} catch (error) {
|
|
410
|
+
throw new Error(`Extension autolaunch could not select a browser: ${error.message}`);
|
|
387
411
|
}
|
|
388
|
-
throw new Error('Extension autolaunch requires RECIPE_HARNESS_CHROME_BIN or Playwright installed in the target checkout.');
|
|
389
412
|
}
|
|
390
413
|
|
|
391
414
|
async function execNodeScript(script, args, options) {
|
|
@@ -446,7 +469,7 @@ async function findWalletFixture(projectRoot) {
|
|
|
446
469
|
return null;
|
|
447
470
|
}
|
|
448
471
|
|
|
449
|
-
async function launchExistingDistRuntime(input, port) {
|
|
472
|
+
export async function launchExistingDistRuntime(input, port) {
|
|
450
473
|
const projectRoot = input.context.projectRoot;
|
|
451
474
|
const artifactsDir = input.context.artifactsDir;
|
|
452
475
|
const distDir = path.resolve(projectRoot, input.node?.dist_dir || process.env.METAMASK_RECIPE_EXTENSION_DIST_DIR || 'dist/chrome');
|
|
@@ -476,12 +499,24 @@ async function launchExistingDistRuntime(input, port) {
|
|
|
476
499
|
fixtureSeeded = true;
|
|
477
500
|
}
|
|
478
501
|
|
|
479
|
-
|
|
502
|
+
// Never start a second browser on a port something else holds: whatever
|
|
503
|
+
// answers there afterwards would not be ours to load or seed.
|
|
504
|
+
const holders = browserResolver.cdpListenerPids(port);
|
|
505
|
+
if (holders.length > 0) {
|
|
506
|
+
throw new Error(`Extension autolaunch refused: CDP port ${port} is already held by pid ${holders.join(', ')}. Next: stop that browser or choose a free --cdp-port.`);
|
|
507
|
+
}
|
|
508
|
+
// Next to the profile, so a reused profile (METAMASK_RECIPE_EXTENSION_PROFILE_DIR)
|
|
509
|
+
// finds the browser it was created with.
|
|
510
|
+
const browserRecord = `${profileDir}.browser.json`;
|
|
511
|
+
const profileExisted = await canAccess(path.join(profileDir, 'Local State'));
|
|
512
|
+
const browser = await resolveChromeBinary(projectRoot, profileExisted ? browserRecord : null);
|
|
513
|
+
const loadsOverCdp = browser.extensionLoading === browserResolver.CDP_LOAD_UNPACKED;
|
|
514
|
+
const previousFrontmost = macosFocus.captureMacFrontmost();
|
|
480
515
|
const stdoutFd = openSync(path.join(logsDir, 'chrome.log'), 'a');
|
|
481
516
|
const stderrFd = openSync(path.join(logsDir, 'chrome.log'), 'a');
|
|
482
517
|
let child;
|
|
483
518
|
try {
|
|
484
|
-
child = spawn(
|
|
519
|
+
child = spawn(browser.bin, [
|
|
485
520
|
`--user-data-dir=${profileDir}`,
|
|
486
521
|
...(process.platform === 'darwin' ? ['--use-mock-keychain'] : []),
|
|
487
522
|
'--remote-debugging-address=127.0.0.1',
|
|
@@ -493,9 +528,8 @@ async function launchExistingDistRuntime(input, port) {
|
|
|
493
528
|
'--disable-extensions-file-access-check',
|
|
494
529
|
'--disable-extensions-content-verification',
|
|
495
530
|
'--disable-features=ExtensionContentVerification,DisableLoadExtensionCommandLineSwitch',
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
'chrome://extensions/',
|
|
531
|
+
...browserResolver.extensionLaunchArgs(runtimeDist, browser.extensionLoading),
|
|
532
|
+
startsWithoutWindow() ? '--no-startup-window' : 'chrome://extensions/',
|
|
499
533
|
], {
|
|
500
534
|
cwd: projectRoot,
|
|
501
535
|
detached: true,
|
|
@@ -516,33 +550,64 @@ async function launchExistingDistRuntime(input, port) {
|
|
|
516
550
|
}
|
|
517
551
|
child.unref();
|
|
518
552
|
await writeFile(path.join(logsDir, 'chrome.pid'), `${child.pid}\n`);
|
|
519
|
-
await retryJsonGet(`http://127.0.0.1:${port}/json/version`, 45000);
|
|
520
|
-
|
|
521
553
|
let fixtureValidation = null;
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
await
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
554
|
+
let launchedBrowser = null;
|
|
555
|
+
try {
|
|
556
|
+
await retryJsonGet(`http://127.0.0.1:${port}/json/version`, 45000);
|
|
557
|
+
const owner = browserResolver.waitForCdpOwner(port, profileDir);
|
|
558
|
+
// The CDP load opens a background window itself; otherwise open one here.
|
|
559
|
+
// Without it the browser has no window, so a failure fails the launch.
|
|
560
|
+
const cdpLoad = loadsOverCdp ? await browserResolver.loadUnpackedOverPort(port, runtimeDist, { profile: profileDir }) : null;
|
|
561
|
+
if (!loadsOverCdp && startsWithoutWindow()) await withBrowserCdp(port, browserCdp.ensureBackgroundWindow);
|
|
562
|
+
launchedBrowser = {
|
|
563
|
+
...browser,
|
|
564
|
+
launch: {
|
|
565
|
+
pid: owner.pid,
|
|
566
|
+
cdpPort: port,
|
|
567
|
+
startedAt: new Date().toISOString(),
|
|
568
|
+
profile: owner.profile,
|
|
569
|
+
processStartedAt: owner.startedAt,
|
|
570
|
+
...(cdpLoad ? { extensionId: cdpLoad.id, otherExtensions: cdpLoad.otherExtensions } : {}),
|
|
571
|
+
},
|
|
572
|
+
};
|
|
573
|
+
await writeFile(browserRecord, `${JSON.stringify(launchedBrowser, null, 2)}\n`);
|
|
574
|
+
if (fixtureSeeded) {
|
|
575
|
+
fixtureValidation = path.join(logsDir, 'fixture-account-parity.json');
|
|
576
|
+
await execNodeScript(
|
|
577
|
+
fixtureScript,
|
|
578
|
+
[
|
|
579
|
+
'seed-cdp',
|
|
580
|
+
'--target',
|
|
581
|
+
projectRoot,
|
|
582
|
+
'--fixture',
|
|
583
|
+
fixture,
|
|
584
|
+
'--state',
|
|
585
|
+
fixtureState,
|
|
586
|
+
'--cdp-port',
|
|
587
|
+
String(port),
|
|
588
|
+
'--extension-dir',
|
|
589
|
+
runtimeDist,
|
|
590
|
+
'--extension-id-file',
|
|
591
|
+
extensionIdFile,
|
|
592
|
+
'--out',
|
|
593
|
+
fixtureValidation,
|
|
594
|
+
],
|
|
595
|
+
{ cwd: projectRoot },
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
} catch (error) {
|
|
599
|
+
// The launch failed after the browser started: stop it before reporting,
|
|
600
|
+
// so no detached browser outlives this action. A failed stop is reported
|
|
601
|
+
// with the original failure, never swallowed.
|
|
602
|
+
try {
|
|
603
|
+
stopProfileProcessesSync(profileDir, { extraPids: [child.pid] });
|
|
604
|
+
} catch (stopError) {
|
|
605
|
+
throw new Error(`${error.message}\nStopping the autolaunched browser (pid ${child.pid}, profile ${profileDir}) also failed: ${stopError.message}`, { cause: error });
|
|
606
|
+
}
|
|
607
|
+
throw error;
|
|
545
608
|
}
|
|
609
|
+
// One check, at most one restore, only if this launch's browser holds the front.
|
|
610
|
+
macosFocus.restoreFrontmostIfOurs(previousFrontmost, [launchedBrowser.launch.pid]);
|
|
546
611
|
|
|
547
612
|
await writeFile(path.join(runtimeRoot, 'runtime.json'), `${JSON.stringify({
|
|
548
613
|
port,
|
|
@@ -553,6 +618,8 @@ async function launchExistingDistRuntime(input, port) {
|
|
|
553
618
|
profileDir,
|
|
554
619
|
fixtureSeeded,
|
|
555
620
|
fixtureValidation,
|
|
621
|
+
browser: launchedBrowser,
|
|
622
|
+
browserRecord,
|
|
556
623
|
launchedAt: new Date().toISOString(),
|
|
557
624
|
}, null, 2)}\n`);
|
|
558
625
|
return { launched: true, pid: child.pid, runtimeRoot, fixtureSeeded };
|
|
@@ -577,17 +644,16 @@ function extensionIdFromAnyTarget(targets) {
|
|
|
577
644
|
return null;
|
|
578
645
|
}
|
|
579
646
|
|
|
580
|
-
|
|
647
|
+
// The wallet home goes into the launch's blank tab (so it is the window's
|
|
648
|
+
// selected page, and what a native screenshot of the window captures), or a
|
|
649
|
+
// background window when there is no blank tab; never a foreground one.
|
|
650
|
+
export async function openExtensionHomePage(port, extensionId) {
|
|
581
651
|
const url = `chrome-extension://${extensionId}/home.html`;
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
}
|
|
587
|
-
if (!response.ok) {
|
|
588
|
-
throw new Error(`Failed to open extension home page ${url}: HTTP ${response.status}`);
|
|
652
|
+
try {
|
|
653
|
+
return await withBrowserCdp(port, (send, remaining) => browserCdp.openUrl(send, url, remaining));
|
|
654
|
+
} catch (error) {
|
|
655
|
+
throw new Error(`Failed to open extension home page ${url}: ${error.message}`);
|
|
589
656
|
}
|
|
590
|
-
return response.json();
|
|
591
657
|
}
|
|
592
658
|
|
|
593
659
|
async function ensureExtensionTarget(input, port) {
|
package/package.json
CHANGED
|
@@ -449,8 +449,12 @@ async function checkBehavior(browserEndpoint, port, pages) {
|
|
|
449
449
|
return checked;
|
|
450
450
|
})()
|
|
451
451
|
`);
|
|
452
|
-
if (!
|
|
453
|
-
throw new Error('
|
|
452
|
+
if (!['fix-bug/extension', 'fix-bug/mobile', 'qa/default'].every(id => previews.includes(id))) {
|
|
453
|
+
throw new Error('Runtime workflow previews must be available');
|
|
454
|
+
}
|
|
455
|
+
const reviewSource = await page.evaluate("document.querySelector('[data-static-review-source]')?.href");
|
|
456
|
+
if (!reviewSource?.endsWith('/domains/perps/skills/perps-review-pr/skill.md')) {
|
|
457
|
+
throw new Error('Static review must link to its owning skill');
|
|
454
458
|
}
|
|
455
459
|
const progress = await page.evaluate(String.raw`
|
|
456
460
|
(() => {
|
|
@@ -514,7 +518,16 @@ async function checkBehavior(browserEndpoint, port, pages) {
|
|
|
514
518
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
515
519
|
}
|
|
516
520
|
}
|
|
517
|
-
if (!response?.ok)
|
|
521
|
+
if (!response?.ok) {
|
|
522
|
+
failures.push(url.pathname + ' (' + (response?.status ?? 'network') + ')');
|
|
523
|
+
} else if (url.hash) {
|
|
524
|
+
const target = url.pathname === location.pathname
|
|
525
|
+
? document
|
|
526
|
+
: new DOMParser().parseFromString(await response.text(), 'text/html');
|
|
527
|
+
if (!target.getElementById(decodeURIComponent(url.hash.slice(1)))) {
|
|
528
|
+
failures.push(url.pathname + url.hash + ' (missing anchor)');
|
|
529
|
+
}
|
|
530
|
+
}
|
|
518
531
|
}
|
|
519
532
|
return failures;
|
|
520
533
|
})()
|