@deeeed/metamask-harness 0.69.0 → 0.70.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 +10 -0
- package/README.md +97 -0
- package/adapters/extension/ensure-browser.sh +85 -15
- package/adapters/extension/inject.mjs +2 -0
- package/adapters/extension/launch-browser.cjs +140 -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 +528 -0
- package/adapters/shared/browser-resolver.cjs +996 -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 +87 -46
- 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 ? ` 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,26 @@ function autolaunchEnabled(input) {
|
|
|
368
369
|
process.env.METAMASK_RECIPE_EXTENSION_LAUNCH_EXISTING_DIST === '1';
|
|
369
370
|
}
|
|
370
371
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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'));
|
|
374
376
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
}
|
|
377
|
+
// Same browser choice as the live launcher (RECIPE_HARNESS_BROWSER /
|
|
378
|
+
// RECIPE_HARNESS_CHROME_BIN); branded Chrome loads the dist over CDP. This
|
|
379
|
+
// launcher spawns a headful browser directly, so the probe does too, and a
|
|
380
|
+
// reused profile keeps its recorded browser.
|
|
381
|
+
async function resolveChromeBinary(projectRoot, recorded) {
|
|
382
|
+
try {
|
|
383
|
+
return await browserResolver.resolveBrowser({
|
|
384
|
+
cft: () => browserResolver.playwrightChromiumExecutable(projectRoot),
|
|
385
|
+
launchMethod: browserResolver.SPAWN,
|
|
386
|
+
display: browserResolver.HEADFUL,
|
|
387
|
+
recorded,
|
|
388
|
+
});
|
|
389
|
+
} catch (error) {
|
|
390
|
+
throw new Error(`Extension autolaunch could not select a browser: ${error.message}`);
|
|
387
391
|
}
|
|
388
|
-
throw new Error('Extension autolaunch requires RECIPE_HARNESS_CHROME_BIN or Playwright installed in the target checkout.');
|
|
389
392
|
}
|
|
390
393
|
|
|
391
394
|
async function execNodeScript(script, args, options) {
|
|
@@ -446,7 +449,7 @@ async function findWalletFixture(projectRoot) {
|
|
|
446
449
|
return null;
|
|
447
450
|
}
|
|
448
451
|
|
|
449
|
-
async function launchExistingDistRuntime(input, port) {
|
|
452
|
+
export async function launchExistingDistRuntime(input, port) {
|
|
450
453
|
const projectRoot = input.context.projectRoot;
|
|
451
454
|
const artifactsDir = input.context.artifactsDir;
|
|
452
455
|
const distDir = path.resolve(projectRoot, input.node?.dist_dir || process.env.METAMASK_RECIPE_EXTENSION_DIST_DIR || 'dist/chrome');
|
|
@@ -476,12 +479,23 @@ async function launchExistingDistRuntime(input, port) {
|
|
|
476
479
|
fixtureSeeded = true;
|
|
477
480
|
}
|
|
478
481
|
|
|
479
|
-
|
|
482
|
+
// Never start a second browser on a port something else holds: whatever
|
|
483
|
+
// answers there afterwards would not be ours to load or seed.
|
|
484
|
+
const holders = browserResolver.cdpListenerPids(port);
|
|
485
|
+
if (holders.length > 0) {
|
|
486
|
+
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.`);
|
|
487
|
+
}
|
|
488
|
+
// Next to the profile, so a reused profile (METAMASK_RECIPE_EXTENSION_PROFILE_DIR)
|
|
489
|
+
// finds the browser it was created with.
|
|
490
|
+
const browserRecord = `${profileDir}.browser.json`;
|
|
491
|
+
const profileExisted = await canAccess(path.join(profileDir, 'Local State'));
|
|
492
|
+
const browser = await resolveChromeBinary(projectRoot, profileExisted ? browserRecord : null);
|
|
493
|
+
const loadsOverCdp = browser.extensionLoading === browserResolver.CDP_LOAD_UNPACKED;
|
|
480
494
|
const stdoutFd = openSync(path.join(logsDir, 'chrome.log'), 'a');
|
|
481
495
|
const stderrFd = openSync(path.join(logsDir, 'chrome.log'), 'a');
|
|
482
496
|
let child;
|
|
483
497
|
try {
|
|
484
|
-
child = spawn(
|
|
498
|
+
child = spawn(browser.bin, [
|
|
485
499
|
`--user-data-dir=${profileDir}`,
|
|
486
500
|
...(process.platform === 'darwin' ? ['--use-mock-keychain'] : []),
|
|
487
501
|
'--remote-debugging-address=127.0.0.1',
|
|
@@ -493,8 +507,7 @@ async function launchExistingDistRuntime(input, port) {
|
|
|
493
507
|
'--disable-extensions-file-access-check',
|
|
494
508
|
'--disable-extensions-content-verification',
|
|
495
509
|
'--disable-features=ExtensionContentVerification,DisableLoadExtensionCommandLineSwitch',
|
|
496
|
-
|
|
497
|
-
`--load-extension=${runtimeDist}`,
|
|
510
|
+
...browserResolver.extensionLaunchArgs(runtimeDist, browser.extensionLoading),
|
|
498
511
|
'chrome://extensions/',
|
|
499
512
|
], {
|
|
500
513
|
cwd: projectRoot,
|
|
@@ -516,32 +529,58 @@ async function launchExistingDistRuntime(input, port) {
|
|
|
516
529
|
}
|
|
517
530
|
child.unref();
|
|
518
531
|
await writeFile(path.join(logsDir, 'chrome.pid'), `${child.pid}\n`);
|
|
519
|
-
await retryJsonGet(`http://127.0.0.1:${port}/json/version`, 45000);
|
|
520
|
-
|
|
521
532
|
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
|
-
|
|
533
|
+
let launchedBrowser = null;
|
|
534
|
+
try {
|
|
535
|
+
await retryJsonGet(`http://127.0.0.1:${port}/json/version`, 45000);
|
|
536
|
+
const owner = browserResolver.waitForCdpOwner(port, profileDir);
|
|
537
|
+
const cdpLoad = loadsOverCdp ? await browserResolver.loadUnpackedOverPort(port, runtimeDist, { profile: profileDir }) : null;
|
|
538
|
+
launchedBrowser = {
|
|
539
|
+
...browser,
|
|
540
|
+
launch: {
|
|
541
|
+
pid: owner.pid,
|
|
542
|
+
cdpPort: port,
|
|
543
|
+
startedAt: new Date().toISOString(),
|
|
544
|
+
profile: owner.profile,
|
|
545
|
+
processStartedAt: owner.startedAt,
|
|
546
|
+
...(cdpLoad ? { extensionId: cdpLoad.id, otherExtensions: cdpLoad.otherExtensions } : {}),
|
|
547
|
+
},
|
|
548
|
+
};
|
|
549
|
+
await writeFile(browserRecord, `${JSON.stringify(launchedBrowser, null, 2)}\n`);
|
|
550
|
+
if (fixtureSeeded) {
|
|
551
|
+
fixtureValidation = path.join(logsDir, 'fixture-account-parity.json');
|
|
552
|
+
await execNodeScript(
|
|
553
|
+
fixtureScript,
|
|
554
|
+
[
|
|
555
|
+
'seed-cdp',
|
|
556
|
+
'--target',
|
|
557
|
+
projectRoot,
|
|
558
|
+
'--fixture',
|
|
559
|
+
fixture,
|
|
560
|
+
'--state',
|
|
561
|
+
fixtureState,
|
|
562
|
+
'--cdp-port',
|
|
563
|
+
String(port),
|
|
564
|
+
'--extension-dir',
|
|
565
|
+
runtimeDist,
|
|
566
|
+
'--extension-id-file',
|
|
567
|
+
extensionIdFile,
|
|
568
|
+
'--out',
|
|
569
|
+
fixtureValidation,
|
|
570
|
+
],
|
|
571
|
+
{ cwd: projectRoot },
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
} catch (error) {
|
|
575
|
+
// The launch failed after the browser started: stop it before reporting,
|
|
576
|
+
// so no detached browser outlives this action. A failed stop is reported
|
|
577
|
+
// with the original failure, never swallowed.
|
|
578
|
+
try {
|
|
579
|
+
stopProfileProcessesSync(profileDir, { extraPids: [child.pid] });
|
|
580
|
+
} catch (stopError) {
|
|
581
|
+
throw new Error(`${error.message}\nStopping the autolaunched browser (pid ${child.pid}, profile ${profileDir}) also failed: ${stopError.message}`, { cause: error });
|
|
582
|
+
}
|
|
583
|
+
throw error;
|
|
545
584
|
}
|
|
546
585
|
|
|
547
586
|
await writeFile(path.join(runtimeRoot, 'runtime.json'), `${JSON.stringify({
|
|
@@ -553,6 +592,8 @@ async function launchExistingDistRuntime(input, port) {
|
|
|
553
592
|
profileDir,
|
|
554
593
|
fixtureSeeded,
|
|
555
594
|
fixtureValidation,
|
|
595
|
+
browser: launchedBrowser,
|
|
596
|
+
browserRecord,
|
|
556
597
|
launchedAt: new Date().toISOString(),
|
|
557
598
|
}, null, 2)}\n`);
|
|
558
599
|
return { launched: true, pid: child.pid, runtimeRoot, fixtureSeeded };
|
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
|
})()
|