@deeeed/metamask-harness 0.77.0 → 0.78.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 +8 -0
- package/README.md +8 -5
- package/adapters/extension/build-handoff.cjs +2 -2
- package/adapters/extension/ensure-browser.sh +10 -12
- package/adapters/extension/inject.mjs +10 -7
- package/adapters/extension/launch-browser.cjs +7 -10
- package/adapters/extension/live.sh +1 -2
- package/adapters/extension/reattach.sh +3 -3
- package/adapters/extension/sidepanel-toggle.sh +4 -4
- package/adapters/extension/wallet-fixture-state.cjs +2 -2
- package/adapters/manifest.json +0 -24
- package/adapters/shared/harness-source-fingerprint.mjs +16 -2
- package/adapters/terminal/launch.mjs +2 -2
- package/adapters/terminal/wallet-host.mjs +1 -1
- package/dist/adapters/extension/doctor-checks.js +2 -2
- package/dist/adapters/extension/launched-browser.js +1 -1
- package/dist/adapters/extension/runtime.js +4 -4
- package/dist/adapters/extension/validation-process-ownership.js +1 -1
- package/dist/commands/launch/extension.js +1 -1
- package/docs/cli-contract.md +6 -17
- package/library/actions/extension/platform/cdp.mjs +5 -7
- package/library/actions/terminal/platform/page.mjs +2 -2
- package/package.json +2 -1
- package/adapters/extension/lib/chrome-args.cjs +0 -365
- package/adapters/extension/lib/extension-id.cjs +0 -36
- package/adapters/extension/lib/macos-focus.cjs +0 -108
- package/adapters/extension/lib/playwright-cdp.cjs +0 -50
- package/adapters/extension/lib/validation-launch-supervisor.cjs +0 -292
- package/adapters/extension/lib/validation-process-ownership.cjs +0 -125
- package/adapters/shared/browser-cdp.cjs +0 -542
- package/adapters/shared/browser-resolver.cjs +0 -1007
|
@@ -1,1007 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// browser-resolver.cjs — choose the Chromium build that hosts the unpacked
|
|
3
|
-
// MetaMask extension, and load it over CDP when that build is branded Chrome.
|
|
4
|
-
//
|
|
5
|
-
// Shared by the Extension and Terminal adapters. Chrome for Testing (the
|
|
6
|
-
// Playwright build) honours --load-extension; branded Google Chrome 137+
|
|
7
|
-
// ignores it, so the extension is loaded with the browser-level CDP command
|
|
8
|
-
// Extensions.loadUnpacked (needs --enable-unsafe-extension-debugging); see
|
|
9
|
-
// browser-cdp.cjs.
|
|
10
|
-
//
|
|
11
|
-
// RECIPE_HARNESS_BROWSER=auto|cft|chrome|<path> (default auto):
|
|
12
|
-
// auto reuse the browser recorded for an existing slot profile; otherwise
|
|
13
|
-
// probe-launch Chrome for Testing the way the caller will launch it
|
|
14
|
-
// (cached per OS build, binary and launch mode) and use it when it
|
|
15
|
-
// starts and paints, else branded Chrome.
|
|
16
|
-
// cft the Playwright Chrome for Testing build, never probed (rollback).
|
|
17
|
-
// chrome branded Google Chrome.
|
|
18
|
-
// <path> that executable.
|
|
19
|
-
// RECIPE_HARNESS_CHROME_BIN, when set, wins and is used verbatim (no probe).
|
|
20
|
-
// RECIPE_HARNESS_CHROME_CANDIDATES (path-list) overrides where Google Chrome
|
|
21
|
-
// is looked for; RECIPE_HARNESS_BROWSER_CACHE overrides the probe cache file.
|
|
22
|
-
//
|
|
23
|
-
// CLI (stdout carries only the result; diagnostics go to stderr):
|
|
24
|
-
// browser-resolver.cjs resolve --target <checkout>
|
|
25
|
-
// [--launch-method spawn|launch-services] [--display headless|headful]
|
|
26
|
-
// [--recorded <browser-resolution.json>] [--out <file>]... [--json]
|
|
27
|
-
// prints the executable path (or the resolution JSON with --json).
|
|
28
|
-
// Exit 1 when no browser can be selected.
|
|
29
|
-
// browser-resolver.cjs load-unpacked --port <cdp> --path <dist> --profile <dir>
|
|
30
|
-
// [--expect-id <id>] [--open-url <url>] [--timeout-ms <ms>]
|
|
31
|
-
// prints {"id": ...}. Exit 1 on failure (the caller stops the browser).
|
|
32
|
-
'use strict';
|
|
33
|
-
|
|
34
|
-
const { execFileSync, spawn } = require('node:child_process');
|
|
35
|
-
const crypto = require('node:crypto');
|
|
36
|
-
const fs = require('node:fs');
|
|
37
|
-
const os = require('node:os');
|
|
38
|
-
const path = require('node:path');
|
|
39
|
-
const { createRequire } = require('node:module');
|
|
40
|
-
|
|
41
|
-
const cdp = require('./browser-cdp.cjs');
|
|
42
|
-
|
|
43
|
-
const SCHEMA_VERSION = 2;
|
|
44
|
-
const LOAD_EXTENSION = 'load-extension';
|
|
45
|
-
const CDP_LOAD_UNPACKED = 'cdp-load-unpacked';
|
|
46
|
-
const UNLAUNCHABLE_MARKER = 'BROWSER_UNLAUNCHABLE:';
|
|
47
|
-
// A probe that timed out (slow start, no frame under load) is not a property
|
|
48
|
-
// of the binary: it is remembered briefly so back-to-back launches do not pay
|
|
49
|
-
// the probe again, then retried.
|
|
50
|
-
const TRANSIENT_VERDICT_TTL_MS = 30 * 60 * 1000;
|
|
51
|
-
const PROBE_LOCK_STALE_MS = 3 * 60 * 1000;
|
|
52
|
-
const CHROME_FOR_TESTING = 'chrome-for-testing';
|
|
53
|
-
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
54
|
-
|
|
55
|
-
// Branded Chrome refuses --load-extension; these flags let the harness load
|
|
56
|
-
// the unpacked build over CDP and keep the isolated profile free of default-
|
|
57
|
-
// browser and sync prompts (callers already pass --no-first-run). No second
|
|
58
|
-
// --disable-features here: Chrome keeps only the last one on the command line.
|
|
59
|
-
const BRANDED_CHROME_ARGS = [
|
|
60
|
-
'--enable-unsafe-extension-debugging',
|
|
61
|
-
'--no-default-browser-check',
|
|
62
|
-
'--disable-sync',
|
|
63
|
-
];
|
|
64
|
-
|
|
65
|
-
function brandedChromeCandidates(env = process.env, platform = process.platform, home = os.homedir()) {
|
|
66
|
-
if (env.RECIPE_HARNESS_CHROME_CANDIDATES) return env.RECIPE_HARNESS_CHROME_CANDIDATES.split(path.delimiter).filter(Boolean);
|
|
67
|
-
if (platform === 'darwin') {
|
|
68
|
-
return [
|
|
69
|
-
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
70
|
-
path.join(home, 'Applications/Google Chrome.app/Contents/MacOS/Google Chrome'),
|
|
71
|
-
];
|
|
72
|
-
}
|
|
73
|
-
if (platform === 'linux') return ['/usr/bin/google-chrome-stable', '/usr/bin/google-chrome', '/opt/google/chrome/chrome'];
|
|
74
|
-
return [];
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function appBundleFor(executable) {
|
|
78
|
-
let current = path.resolve(executable);
|
|
79
|
-
while (current !== path.dirname(current)) {
|
|
80
|
-
if (current.endsWith('.app')) return current;
|
|
81
|
-
current = path.dirname(current);
|
|
82
|
-
}
|
|
83
|
-
return null;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function plistString(plist, key) {
|
|
87
|
-
const match = new RegExp(`<key>${key}</key>\\s*<string>([^<]*)</string>`, 'u').exec(plist);
|
|
88
|
-
return match ? match[1].trim() : null;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// ---- managed Google Chrome ----------------------------------------------------
|
|
92
|
-
|
|
93
|
-
// Google Chrome on a managed Mac reads policies from these plists; Chrome for
|
|
94
|
-
// Testing (another bundle id) does not. Force-installed extensions reach every
|
|
95
|
-
// profile, slot profiles included, and some open windows that take the front.
|
|
96
|
-
// RECIPE_HARNESS_MANAGED_CHROME_PLISTS (colon-separated, empty for none)
|
|
97
|
-
// replaces these paths; the shared test setups set it empty.
|
|
98
|
-
function managedChromePlists(env = process.env) {
|
|
99
|
-
if (env.RECIPE_HARNESS_MANAGED_CHROME_PLISTS !== undefined) {
|
|
100
|
-
return env.RECIPE_HARNESS_MANAGED_CHROME_PLISTS.split(':').filter(Boolean);
|
|
101
|
-
}
|
|
102
|
-
const user = env.USER || os.userInfo().username;
|
|
103
|
-
return [
|
|
104
|
-
'/Library/Managed Preferences/com.google.Chrome.plist',
|
|
105
|
-
path.join('/Library/Managed Preferences', user, 'com.google.Chrome.plist'),
|
|
106
|
-
];
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// One policy key, or undefined when absent or unreadable. `plutil -extract
|
|
110
|
-
// … json` only extracts arrays and dictionaries; a scalar (BrowserSignin: 2,
|
|
111
|
-
// ForceBrowserSignin: true) comes out with `raw`.
|
|
112
|
-
function readPolicyKey(file, key) {
|
|
113
|
-
const extract = (format) => execFileSync('plutil', ['-extract', key, format, '-o', '-', file], {
|
|
114
|
-
encoding: 'utf8',
|
|
115
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
116
|
-
timeout: 5000,
|
|
117
|
-
});
|
|
118
|
-
try {
|
|
119
|
-
return JSON.parse(extract('json'));
|
|
120
|
-
} catch {
|
|
121
|
-
// A scalar, or the key is absent: try it as a raw value.
|
|
122
|
-
}
|
|
123
|
-
try {
|
|
124
|
-
const raw = extract('raw').trim();
|
|
125
|
-
if (raw === 'true' || raw === 'false') return raw === 'true';
|
|
126
|
-
return /^-?\d+$/u.test(raw) ? Number(raw) : raw;
|
|
127
|
-
} catch {
|
|
128
|
-
return undefined;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// The name an installed copy of an extension declares, from any of the
|
|
133
|
-
// operator's own Chrome profiles; best effort, for doctor output only.
|
|
134
|
-
function installedExtensionName(id, home = os.homedir()) {
|
|
135
|
-
const root = path.join(home, 'Library/Application Support/Google/Chrome');
|
|
136
|
-
let profiles = [];
|
|
137
|
-
try {
|
|
138
|
-
profiles = fs.readdirSync(root);
|
|
139
|
-
} catch {
|
|
140
|
-
return null;
|
|
141
|
-
}
|
|
142
|
-
for (const profile of profiles) {
|
|
143
|
-
const dir = path.join(root, profile, 'Extensions', id);
|
|
144
|
-
let versions = [];
|
|
145
|
-
try {
|
|
146
|
-
versions = fs.readdirSync(dir);
|
|
147
|
-
} catch {
|
|
148
|
-
continue;
|
|
149
|
-
}
|
|
150
|
-
for (const version of versions) {
|
|
151
|
-
try {
|
|
152
|
-
const manifest = JSON.parse(fs.readFileSync(path.join(dir, version, 'manifest.json'), 'utf8'));
|
|
153
|
-
const message = /^__MSG_(.+)__$/u.exec(manifest.name ?? '');
|
|
154
|
-
if (!message) return manifest.name ?? null;
|
|
155
|
-
const locale = manifest.default_locale || 'en';
|
|
156
|
-
const messages = JSON.parse(fs.readFileSync(path.join(dir, version, '_locales', locale, 'messages.json'), 'utf8'));
|
|
157
|
-
const entry = Object.entries(messages).find(([key]) => key.toLowerCase() === message[1].toLowerCase());
|
|
158
|
-
return entry?.[1]?.message ?? null;
|
|
159
|
-
} catch {
|
|
160
|
-
// Unreadable manifest: try the next copy.
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
return null;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// { forcedExtensions: [{ id, name }], signinForced, sources } when Google
|
|
168
|
-
// Chrome is managed with force-installed extensions or forced sign-in;
|
|
169
|
-
// otherwise null. Only macOS managed preferences are read.
|
|
170
|
-
function readManagedChromePolicy({
|
|
171
|
-
platform = process.platform,
|
|
172
|
-
plists = managedChromePlists(),
|
|
173
|
-
exists = (file) => fs.existsSync(file),
|
|
174
|
-
readKey = readPolicyKey,
|
|
175
|
-
extensionName = installedExtensionName,
|
|
176
|
-
} = {}) {
|
|
177
|
-
if (platform !== 'darwin') return null;
|
|
178
|
-
const ids = new Set();
|
|
179
|
-
const sources = [];
|
|
180
|
-
let signinForced = false;
|
|
181
|
-
for (const file of plists) {
|
|
182
|
-
if (!exists(file)) continue;
|
|
183
|
-
sources.push(file);
|
|
184
|
-
const forcelist = readKey(file, 'ExtensionInstallForcelist');
|
|
185
|
-
if (Array.isArray(forcelist)) {
|
|
186
|
-
for (const entry of forcelist) {
|
|
187
|
-
const id = String(entry).split(';')[0].trim();
|
|
188
|
-
if (/^[a-p]{32}$/u.test(id)) ids.add(id);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
const settings = readKey(file, 'ExtensionSettings');
|
|
192
|
-
if (settings && typeof settings === 'object') {
|
|
193
|
-
for (const [id, setting] of Object.entries(settings)) {
|
|
194
|
-
if (/^[a-p]{32}$/u.test(id) && ['force_installed', 'normal_installed'].includes(setting?.installation_mode)) ids.add(id);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
if (readKey(file, 'BrowserSignin') === 2 || readKey(file, 'ForceBrowserSignin') === true) signinForced = true;
|
|
198
|
-
}
|
|
199
|
-
if (ids.size === 0 && !signinForced) return null;
|
|
200
|
-
return {
|
|
201
|
-
forcedExtensions: [...ids].sort().map((id) => ({ id, name: extensionName(id) })),
|
|
202
|
-
signinForced,
|
|
203
|
-
sources,
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function describeManagedChrome(managed) {
|
|
208
|
-
const list = managed.forcedExtensions.map(({ id, name }) => (name ? `${id} (${name})` : id)).join(', ');
|
|
209
|
-
return `Google Chrome is managed by policy (${managed.forcedExtensions.length} forced extension${managed.forcedExtensions.length === 1 ? '' : 's'}${list ? `: ${list}` : ''}${managed.signinForced ? '; browser sign-in forced' : ''})`;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function readBundleInfo(executable) {
|
|
213
|
-
const bundle = appBundleFor(executable);
|
|
214
|
-
if (!bundle) return null;
|
|
215
|
-
try {
|
|
216
|
-
const plist = fs.readFileSync(path.join(bundle, 'Contents/Info.plist'), 'utf8');
|
|
217
|
-
return {
|
|
218
|
-
bundleId: plistString(plist, 'CFBundleIdentifier'),
|
|
219
|
-
version: plistString(plist, 'CFBundleShortVersionString'),
|
|
220
|
-
};
|
|
221
|
-
} catch {
|
|
222
|
-
// Binary plist or unreadable bundle: fall back to --version below.
|
|
223
|
-
return null;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// Version without launching a browser window: the bundle plist on macOS,
|
|
228
|
-
// `--version` elsewhere (it does not start the browser process proper).
|
|
229
|
-
function browserVersion(executable) {
|
|
230
|
-
const fromBundle = readBundleInfo(executable)?.version;
|
|
231
|
-
if (fromBundle) return fromBundle;
|
|
232
|
-
try {
|
|
233
|
-
const output = execFileSync(executable, ['--version'], {
|
|
234
|
-
encoding: 'utf8',
|
|
235
|
-
timeout: 15000,
|
|
236
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
237
|
-
});
|
|
238
|
-
return /(\d+(?:\.\d+){1,3})/u.exec(output)?.[1] ?? null;
|
|
239
|
-
} catch {
|
|
240
|
-
return null;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// Branded Google Chrome (any channel) ignores --load-extension since 137.
|
|
245
|
-
// Chrome for Testing, Chromium, Brave and other builds still honour it.
|
|
246
|
-
function isBrandedChrome(executable) {
|
|
247
|
-
const bundleId = readBundleInfo(executable)?.bundleId;
|
|
248
|
-
if (bundleId) return /^com\.google\.Chrome(\.|$)/u.test(bundleId);
|
|
249
|
-
return /^google-chrome(-stable|-beta|-unstable)?$/u.test(path.basename(executable)) ||
|
|
250
|
-
executable === '/opt/google/chrome/chrome';
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
function browserKind(executable) {
|
|
254
|
-
const bundleId = readBundleInfo(executable)?.bundleId ?? '';
|
|
255
|
-
if (/^com\.google\.chrome\.for\.testing$/iu.test(bundleId) || /Google Chrome for Testing/u.test(executable)) {
|
|
256
|
-
return CHROME_FOR_TESTING;
|
|
257
|
-
}
|
|
258
|
-
if (isBrandedChrome(executable)) return 'google-chrome';
|
|
259
|
-
return 'other';
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function extensionLoadingFor(executable) {
|
|
263
|
-
return isBrandedChrome(executable) ? CDP_LOAD_UNPACKED : LOAD_EXTENSION;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
function describeBrowser(executable) {
|
|
267
|
-
return {
|
|
268
|
-
bin: executable,
|
|
269
|
-
browser: browserKind(executable),
|
|
270
|
-
version: browserVersion(executable),
|
|
271
|
-
extensionLoading: extensionLoadingFor(executable),
|
|
272
|
-
};
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// Extra launch arguments for the chosen loading method. Branded Chrome must
|
|
276
|
-
// not get --disable-extensions-except: it disables the CDP-loaded extension.
|
|
277
|
-
function extensionLaunchArgs(extensionDir, extensionLoading) {
|
|
278
|
-
if (extensionLoading === CDP_LOAD_UNPACKED) {
|
|
279
|
-
// --load-extension is ignored by branded Chrome but kept on the command
|
|
280
|
-
// line: runtime ownership checks match the loaded dist by this flag.
|
|
281
|
-
return [...BRANDED_CHROME_ARGS, `--load-extension=${extensionDir}`];
|
|
282
|
-
}
|
|
283
|
-
return [`--disable-extensions-except=${extensionDir}`, `--load-extension=${extensionDir}`];
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
function browserMode(env = process.env) {
|
|
287
|
-
const raw = String(env.RECIPE_HARNESS_BROWSER ?? '').trim();
|
|
288
|
-
if (raw === '' || raw === 'auto') return { mode: 'auto' };
|
|
289
|
-
if (raw === 'cft' || raw === 'chrome') return { mode: raw };
|
|
290
|
-
if (path.isAbsolute(raw)) return { mode: 'path', path: raw };
|
|
291
|
-
throw new Error(`RECIPE_HARNESS_BROWSER must be auto, cft, chrome, or an absolute executable path (got ${JSON.stringify(raw)}).`);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
function defaultCachePath(env = process.env) {
|
|
295
|
-
if (env.RECIPE_HARNESS_BROWSER_CACHE) return path.resolve(env.RECIPE_HARNESS_BROWSER_CACHE);
|
|
296
|
-
const base = env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
|
297
|
-
return path.join(base, 'mm-harness', 'browser-probe.json');
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// How the caller starts the browser decides whether Chrome for Testing
|
|
301
|
-
// survives on macOS 26+: as a direct child of a terminal process it can die
|
|
302
|
-
// with SIGBUS in ImageIO, while the same build started through Launch Services
|
|
303
|
-
// (`open -n -g -a`, the Extension launcher's path) runs. The probe therefore
|
|
304
|
-
// launches the way, and in the display mode, its caller will.
|
|
305
|
-
const SPAWN = 'spawn';
|
|
306
|
-
const LAUNCH_SERVICES = 'launch-services';
|
|
307
|
-
const HEADLESS = 'headless';
|
|
308
|
-
const HEADFUL = 'headful';
|
|
309
|
-
|
|
310
|
-
function effectiveLaunchMethod(executable, launchMethod, platform = process.platform) {
|
|
311
|
-
return launchMethod === LAUNCH_SERVICES && platform === 'darwin' && appBundleFor(executable) ? LAUNCH_SERVICES : SPAWN;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// One probe verdict per OS build + binary (path, version, size, mtime) +
|
|
315
|
-
// launch method + display mode. The cache file lives in this user's home, so
|
|
316
|
-
// it is per machine without keying on a hostname that can change.
|
|
317
|
-
function probeCacheKey(executable, version, launchMethod = SPAWN, display = HEADLESS) {
|
|
318
|
-
let stat = null;
|
|
319
|
-
try {
|
|
320
|
-
stat = fs.statSync(executable);
|
|
321
|
-
} catch {
|
|
322
|
-
// Missing binary: the key still differs from any installed one.
|
|
323
|
-
}
|
|
324
|
-
return crypto.createHash('sha256').update(JSON.stringify({
|
|
325
|
-
platform: process.platform,
|
|
326
|
-
arch: process.arch,
|
|
327
|
-
osRelease: os.release(),
|
|
328
|
-
executable: path.resolve(executable),
|
|
329
|
-
version,
|
|
330
|
-
launchMethod,
|
|
331
|
-
display,
|
|
332
|
-
size: stat?.size ?? null,
|
|
333
|
-
mtimeMs: stat ? Math.trunc(stat.mtimeMs) : null,
|
|
334
|
-
})).digest('hex');
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
function readProbeCache(cachePath) {
|
|
338
|
-
try {
|
|
339
|
-
const value = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
|
|
340
|
-
return value && value.schemaVersion === SCHEMA_VERSION && value.entries && typeof value.entries === 'object'
|
|
341
|
-
? value
|
|
342
|
-
: { schemaVersion: SCHEMA_VERSION, entries: {} };
|
|
343
|
-
} catch {
|
|
344
|
-
return { schemaVersion: SCHEMA_VERSION, entries: {} };
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
function writeProbeCache(cachePath, key, verdict) {
|
|
349
|
-
const cache = readProbeCache(cachePath);
|
|
350
|
-
cache.entries[key] = verdict;
|
|
351
|
-
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
352
|
-
const temporary = `${cachePath}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
353
|
-
fs.writeFileSync(temporary, `${JSON.stringify(cache, null, 2)}\n`, { mode: 0o600 });
|
|
354
|
-
fs.renameSync(temporary, cachePath);
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
function usableVerdict(verdict, nowMs) {
|
|
358
|
-
if (!verdict) return null;
|
|
359
|
-
if (verdict.transient && !(Date.parse(verdict.expiresAt) > nowMs)) return null;
|
|
360
|
-
return verdict;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
function pidAlive(pid) {
|
|
364
|
-
try {
|
|
365
|
-
process.kill(pid, 0);
|
|
366
|
-
return true;
|
|
367
|
-
} catch (error) {
|
|
368
|
-
return error.code === 'EPERM';
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
function readTicket(ticketPath) {
|
|
373
|
-
try {
|
|
374
|
-
const text = fs.readFileSync(ticketPath, 'utf8');
|
|
375
|
-
const { mtimeMs } = fs.statSync(ticketPath);
|
|
376
|
-
let holder = null;
|
|
377
|
-
try {
|
|
378
|
-
holder = JSON.parse(text);
|
|
379
|
-
} catch {
|
|
380
|
-
// Empty or half-written: judged by its age below.
|
|
381
|
-
}
|
|
382
|
-
return { mtimeMs, holder };
|
|
383
|
-
} catch (error) {
|
|
384
|
-
if (error.code === 'ENOENT') return null;
|
|
385
|
-
throw error;
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
// A ticket no longer holds the lock once it is released, its holder is dead,
|
|
390
|
-
// its pid now belongs to a process with another start time (read fresh with
|
|
391
|
-
// `ps` on every judgement, within `budgetMs`, with the same 2s slack as the CDP
|
|
392
|
-
// ownership check), or it is older than PROBE_LOCK_STALE_MS; an unreadable
|
|
393
|
-
// ticket is judged by its file age. A start time that cannot be read in time
|
|
394
|
-
// leaves the ticket held.
|
|
395
|
-
const { START_TIME_SLACK_MS } = cdp;
|
|
396
|
-
|
|
397
|
-
function ticketFree(dir, number, { nowMs = Date.now(), budgetMs = 30000, readStartTime = startTimeOf } = {}) {
|
|
398
|
-
if (number === 0 || fs.existsSync(path.join(dir, `${number}.released`))) return true;
|
|
399
|
-
const ticket = readTicket(path.join(dir, String(number)));
|
|
400
|
-
if (!ticket) return true;
|
|
401
|
-
const { holder } = ticket;
|
|
402
|
-
if (holder && Number.isInteger(holder.pid) && Number.isFinite(holder.at)) {
|
|
403
|
-
if (!pidAlive(holder.pid) || nowMs - holder.at > PROBE_LOCK_STALE_MS) return true;
|
|
404
|
-
if (typeof holder.started !== 'string' || holder.pid === process.pid || budgetMs <= 0) return false;
|
|
405
|
-
const started = readStartTime(holder.pid, budgetMs);
|
|
406
|
-
if (started === null) return false;
|
|
407
|
-
const drift = Math.abs(Date.parse(`${started} UTC`) - Date.parse(`${holder.started} UTC`));
|
|
408
|
-
return Number.isFinite(drift) && drift > START_TIME_SLACK_MS;
|
|
409
|
-
}
|
|
410
|
-
return nowMs - ticket.mtimeMs > PROBE_LOCK_STALE_MS;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// `ps` start time (UTC), or null when it cannot be read within `timeoutMs`.
|
|
414
|
-
function startTimeOf(pid, timeoutMs) {
|
|
415
|
-
try {
|
|
416
|
-
return cdp.processStartTime(pid, undefined, timeoutMs ? { timeoutMs } : {});
|
|
417
|
-
} catch {
|
|
418
|
-
return null;
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
function newestTicket(dir) {
|
|
423
|
-
let newest = 0;
|
|
424
|
-
for (const name of fs.readdirSync(dir)) {
|
|
425
|
-
if (/^\d+$/u.test(name)) newest = Math.max(newest, Number(name));
|
|
426
|
-
}
|
|
427
|
-
return newest;
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
// One probe per cache key at a time across processes: the first resolver
|
|
431
|
-
// probes and writes, the others wait and read its verdict. The lock is a
|
|
432
|
-
// directory of numbered tickets and the holder is the creator of the newest
|
|
433
|
-
// one. Ticket n+1 is created exclusively, only after ticket n was seen free,
|
|
434
|
-
// and no ticket is ever moved or removed, so a live holder is never displaced
|
|
435
|
-
// whatever the interleaving: of two resolvers that both judged ticket n free,
|
|
436
|
-
// one creates n+1 and the other waits on it. A holder older than
|
|
437
|
-
// PROBE_LOCK_STALE_MS counts as gone, so a probe that outlives it can overlap
|
|
438
|
-
// the next one. The directory grows by two small files per probe; a crashed
|
|
439
|
-
// holder's unreleased ticket stays, judged free once its holder is gone.
|
|
440
|
-
// `beforeClaim` and `readStartTime` are test seams.
|
|
441
|
-
async function withProbeLock(cachePath, key, fn, { waitMs = 150000, beforeClaim = null, readStartTime = startTimeOf } = {}) {
|
|
442
|
-
const dir = `${cachePath}.${key.slice(0, 16)}.locks`;
|
|
443
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
444
|
-
const deadline = Date.now() + waitMs;
|
|
445
|
-
const timedOut = () => new Error(`Timed out waiting for the browser probe lock ${dir}.`);
|
|
446
|
-
let mine = null;
|
|
447
|
-
let fd = null;
|
|
448
|
-
for (;;) {
|
|
449
|
-
const newest = newestTicket(dir);
|
|
450
|
-
if (ticketFree(dir, newest, { budgetMs: deadline - Date.now(), readStartTime })) {
|
|
451
|
-
if (beforeClaim) await beforeClaim(newest);
|
|
452
|
-
if (Date.now() > deadline) throw timedOut();
|
|
453
|
-
try {
|
|
454
|
-
fd = fs.openSync(path.join(dir, String(newest + 1)), 'wx', 0o600);
|
|
455
|
-
mine = newest + 1;
|
|
456
|
-
break;
|
|
457
|
-
} catch (error) {
|
|
458
|
-
if (error.code !== 'EEXIST') throw error;
|
|
459
|
-
continue;
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
if (Date.now() > deadline) throw timedOut();
|
|
463
|
-
await sleep(Math.max(1, Math.min(250, deadline - Date.now())));
|
|
464
|
-
}
|
|
465
|
-
// From here the ticket is ours: whatever happens, it is released.
|
|
466
|
-
try {
|
|
467
|
-
try {
|
|
468
|
-
fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now(), started: readStartTime(process.pid) }));
|
|
469
|
-
} finally {
|
|
470
|
-
fs.closeSync(fd);
|
|
471
|
-
}
|
|
472
|
-
return await fn();
|
|
473
|
-
} finally {
|
|
474
|
-
fs.writeFileSync(path.join(dir, `${mine}.released`), '', { mode: 0o600 });
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
// Probes in flight, so a CLI watchdog can stop their browsers before exiting.
|
|
479
|
-
const activeProbeProfiles = new Set();
|
|
480
|
-
|
|
481
|
-
function profilePids(profile) {
|
|
482
|
-
try {
|
|
483
|
-
return execFileSync('pgrep', ['-f', '--', `--user-data-dir=${profile}`], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
484
|
-
.split(/\s+/u)
|
|
485
|
-
.map((value) => Number.parseInt(value, 10))
|
|
486
|
-
.filter((pid) => Number.isInteger(pid) && pid > 0);
|
|
487
|
-
} catch {
|
|
488
|
-
return [];
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
function killProfile(profile) {
|
|
493
|
-
for (const pid of profilePids(profile)) {
|
|
494
|
-
try {
|
|
495
|
-
process.kill(pid, 'SIGKILL');
|
|
496
|
-
} catch {
|
|
497
|
-
// Already exited.
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
// `--version` succeeds even for a build that crashes at startup, so a browser
|
|
503
|
-
// is accepted only when a start answers on DevTools, paints a frame, and
|
|
504
|
-
// survives a settle without dying. The Chrome for Testing crash on macOS 26+
|
|
505
|
-
// lands 2-5s after DevTools opens, so the settle is several times that.
|
|
506
|
-
// Verdicts: ok, or died (both properties of the binary, cached), or transient
|
|
507
|
-
// (no DevTools / no frame in time: retried after TRANSIENT_VERDICT_TTL_MS).
|
|
508
|
-
// Worst case is about startupMs + 2 render attempts + settleMs (~80s).
|
|
509
|
-
async function probeLaunch(executable, {
|
|
510
|
-
launchMethod = SPAWN,
|
|
511
|
-
display = HEADLESS,
|
|
512
|
-
settleMs = 15000,
|
|
513
|
-
startupMs = 30000,
|
|
514
|
-
renderAttempts = 2,
|
|
515
|
-
} = {}) {
|
|
516
|
-
const method = effectiveLaunchMethod(executable, launchMethod);
|
|
517
|
-
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'mm-harness-browser-probe-'));
|
|
518
|
-
activeProbeProfiles.add(profile);
|
|
519
|
-
const startedAt = Date.now();
|
|
520
|
-
const verdict = (ok, reason, transient = false) => ({ ok, reason, transient, launchMethod: method, display, durationMs: Date.now() - startedAt });
|
|
521
|
-
let child = null;
|
|
522
|
-
let exited = null;
|
|
523
|
-
try {
|
|
524
|
-
const port = await freePort();
|
|
525
|
-
// A headful probe starts like the slot browsers (the launchers and the
|
|
526
|
-
// library autolaunch): in the background with no startup window (Chrome
|
|
527
|
-
// activates itself when it opens one); renderCheck opens a background
|
|
528
|
-
// window. Like the launchers, it keeps a window behind the operator's apps
|
|
529
|
-
// rendering.
|
|
530
|
-
const backgroundWindow = display === HEADFUL;
|
|
531
|
-
const args = [
|
|
532
|
-
...(display === HEADLESS ? ['--headless=new'] : ['--window-size=480,360']),
|
|
533
|
-
`--user-data-dir=${profile}`,
|
|
534
|
-
'--no-first-run',
|
|
535
|
-
'--no-default-browser-check',
|
|
536
|
-
'--use-mock-keychain',
|
|
537
|
-
'--disable-background-timer-throttling',
|
|
538
|
-
'--disable-backgrounding-occluded-windows',
|
|
539
|
-
'--disable-renderer-backgrounding',
|
|
540
|
-
'--remote-debugging-address=127.0.0.1',
|
|
541
|
-
`--remote-debugging-port=${port}`,
|
|
542
|
-
backgroundWindow ? '--no-startup-window' : 'about:blank',
|
|
543
|
-
];
|
|
544
|
-
if (method === LAUNCH_SERVICES) {
|
|
545
|
-
execFileSync('open', ['-g', '-n', '-a', appBundleFor(executable), '--args', ...args], { stdio: 'ignore', timeout: 15000 });
|
|
546
|
-
} else {
|
|
547
|
-
child = spawn(executable, args, { detached: true, stdio: 'ignore' });
|
|
548
|
-
child.on('exit', (code, signal) => { exited = signal ?? `code ${code}`; });
|
|
549
|
-
child.on('error', (error) => { exited = `spawn failed: ${error.message}`; });
|
|
550
|
-
}
|
|
551
|
-
const alive = () => (method === LAUNCH_SERVICES ? profilePids(profile).length > 0 : exited === null);
|
|
552
|
-
const died = (when) => verdict(false, `exited ${when}${exited ? ` (${exited})` : ''}`, String(exited).startsWith('spawn failed'));
|
|
553
|
-
let up = false;
|
|
554
|
-
while (!up && Date.now() - startedAt < startupMs) {
|
|
555
|
-
if (exited) return died('during startup');
|
|
556
|
-
up = await devtoolsAnswers(port);
|
|
557
|
-
if (!up) {
|
|
558
|
-
if (method === LAUNCH_SERVICES && Date.now() - startedAt > 5000 && !alive()) return died('during startup');
|
|
559
|
-
await sleep(250);
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
if (!up) return alive() ? verdict(false, `no DevTools endpoint within ${startupMs}ms`, true) : died('during startup');
|
|
563
|
-
let render = null;
|
|
564
|
-
for (let attempt = 1; attempt <= renderAttempts && !render?.ok; attempt += 1) {
|
|
565
|
-
render = await renderCheck(port);
|
|
566
|
-
if (!alive()) return died('after DevTools started');
|
|
567
|
-
}
|
|
568
|
-
if (!render.ok) return verdict(false, `${render.reason} (${renderAttempts} attempts)`, true);
|
|
569
|
-
const deadline = Date.now() + settleMs;
|
|
570
|
-
while (Date.now() < deadline) {
|
|
571
|
-
if (!alive()) return died('after DevTools started');
|
|
572
|
-
await sleep(250);
|
|
573
|
-
}
|
|
574
|
-
if (await devtoolsAnswers(port)) return verdict(true, 'started and rendered');
|
|
575
|
-
// Only a proven exit is a property of the binary.
|
|
576
|
-
return alive() ? verdict(false, 'DevTools stopped answering after the settle', true) : died('after DevTools started');
|
|
577
|
-
} catch (error) {
|
|
578
|
-
return verdict(false, `launch failed: ${error.message}`, true);
|
|
579
|
-
} finally {
|
|
580
|
-
try {
|
|
581
|
-
if (child?.pid) process.kill(-child.pid, 'SIGKILL');
|
|
582
|
-
} catch {
|
|
583
|
-
// Already exited.
|
|
584
|
-
}
|
|
585
|
-
killProfile(profile);
|
|
586
|
-
await sleep(300);
|
|
587
|
-
fs.rmSync(profile, { recursive: true, force: true });
|
|
588
|
-
activeProbeProfiles.delete(profile);
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
// A build can start and answer on DevTools yet never paint (seen with Chrome
|
|
593
|
-
// for Testing on macOS 26+, and under heavy load): rAF never fires and
|
|
594
|
-
// screenshots hang. Require one animation frame and one screenshot; every
|
|
595
|
-
// step has a deadline (about 15s per attempt).
|
|
596
|
-
async function renderCheck(port) {
|
|
597
|
-
let client = null;
|
|
598
|
-
try {
|
|
599
|
-
client = await cdp.connectBrowserCdp(port, { timeoutMs: 5000, commandTimeoutMs: 3000 });
|
|
600
|
-
const { targetInfos } = await client.send('Target.getTargets', {});
|
|
601
|
-
const page = targetInfos.find((target) => target.type === 'page');
|
|
602
|
-
const targetId = page ? page.targetId : (await cdp.openBackgroundWindow(client.send, 'about:blank', 3000)).targetId;
|
|
603
|
-
const { sessionId } = await client.send('Target.attachToTarget', { targetId, flatten: true });
|
|
604
|
-
await client.send('Runtime.evaluate', {
|
|
605
|
-
expression: 'new Promise((resolve) => requestAnimationFrame(() => resolve(true)))',
|
|
606
|
-
awaitPromise: true,
|
|
607
|
-
returnByValue: true,
|
|
608
|
-
}, sessionId, 6000).catch((error) => {
|
|
609
|
-
throw Object.assign(new Error('renders no frames (requestAnimationFrame never fired)'), { cause: error });
|
|
610
|
-
});
|
|
611
|
-
const shot = await client.send('Page.captureScreenshot', {}, sessionId, 6000).catch((error) => {
|
|
612
|
-
throw Object.assign(new Error('renders no frames (screenshot did not complete)'), { cause: error });
|
|
613
|
-
});
|
|
614
|
-
if (!shot?.data) return { ok: false, reason: 'renders no frames (empty screenshot)' };
|
|
615
|
-
return { ok: true };
|
|
616
|
-
} catch (error) {
|
|
617
|
-
return { ok: false, reason: error.message.startsWith('renders no frames') ? error.message : `render check failed: ${error.message}` };
|
|
618
|
-
} finally {
|
|
619
|
-
client?.close();
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
function freePort() {
|
|
624
|
-
return new Promise((resolve, reject) => {
|
|
625
|
-
const server = require('node:net').createServer();
|
|
626
|
-
server.once('error', reject);
|
|
627
|
-
server.listen(0, '127.0.0.1', () => {
|
|
628
|
-
const { port } = server.address();
|
|
629
|
-
server.close(() => resolve(port));
|
|
630
|
-
});
|
|
631
|
-
});
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
async function devtoolsAnswers(port) {
|
|
635
|
-
try {
|
|
636
|
-
return (await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(2000) })).ok;
|
|
637
|
-
} catch {
|
|
638
|
-
return false;
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
function playwrightChromiumExecutable(target) {
|
|
643
|
-
const requireFromTarget = createRequire(path.join(path.resolve(target), 'package.json'));
|
|
644
|
-
let chromium = null;
|
|
645
|
-
for (const name of ['@playwright/test', 'playwright']) {
|
|
646
|
-
try {
|
|
647
|
-
chromium = requireFromTarget(name).chromium;
|
|
648
|
-
if (chromium) break;
|
|
649
|
-
} catch {
|
|
650
|
-
// Optional Playwright package unavailable; try the next package name.
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
if (!chromium) {
|
|
654
|
-
return { error: '[recipe-harness] Playwright is not available from this checkout; install dependencies first, or set RECIPE_HARNESS_CHROME_BIN to an explicitly approved browser.' };
|
|
655
|
-
}
|
|
656
|
-
let executable = '';
|
|
657
|
-
try {
|
|
658
|
-
executable = chromium.executablePath();
|
|
659
|
-
} catch (error) {
|
|
660
|
-
return { error: `[recipe-harness] Could not resolve Playwright Chromium executable: ${error?.message ?? error}. Manual approval required before installing the Playwright Chromium browser cache (no package.json changes); ask the user before running yarn playwright install chromium.` };
|
|
661
|
-
}
|
|
662
|
-
if (!fs.existsSync(executable)) {
|
|
663
|
-
return {
|
|
664
|
-
error: `[recipe-harness] Playwright Chromium is not installed at ${executable}. Manual approval required before installing the Playwright Chromium browser cache (no package.json changes). Ask the user for approval; if they agree, run: cd ${shellQuote(path.resolve(target))} && yarn playwright install chromium\n` +
|
|
665
|
-
'[recipe-harness] To use a browser that is already installed, set RECIPE_HARNESS_CHROME_BIN=/path/to/chrome explicitly.',
|
|
666
|
-
};
|
|
667
|
-
}
|
|
668
|
-
return { executable };
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
function shellQuote(value) {
|
|
672
|
-
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
function firstExisting(candidates, exists) {
|
|
676
|
-
return candidates.find((candidate) => exists(candidate)) ?? null;
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
function resolutionError(message, resolution = {}) {
|
|
680
|
-
return Object.assign(new Error(message), { resolution });
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
function readRecorded(file) {
|
|
684
|
-
if (!file) return null;
|
|
685
|
-
try {
|
|
686
|
-
const recorded = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
687
|
-
return recorded && typeof recorded.bin === 'string' ? recorded : null;
|
|
688
|
-
} catch {
|
|
689
|
-
return null;
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
// Resolve the browser for one launch. Inputs are injectable for tests:
|
|
694
|
-
// cft() returns { executable } or { error }; probe(bin, options) returns a
|
|
695
|
-
// verdict. `recorded` names the browser-resolution.json of an existing slot
|
|
696
|
-
// profile: auto keeps that kind of browser (see below).
|
|
697
|
-
async function resolveBrowser({
|
|
698
|
-
env = process.env,
|
|
699
|
-
cft,
|
|
700
|
-
probe = probeLaunch,
|
|
701
|
-
cachePath = defaultCachePath(env),
|
|
702
|
-
chromeCandidates = brandedChromeCandidates(env),
|
|
703
|
-
exists = (file) => fs.existsSync(file),
|
|
704
|
-
describe = describeBrowser,
|
|
705
|
-
now = () => new Date(),
|
|
706
|
-
launchMethod = SPAWN,
|
|
707
|
-
display = HEADLESS,
|
|
708
|
-
recorded = null,
|
|
709
|
-
managedChrome = readManagedChromePolicy,
|
|
710
|
-
} = {}) {
|
|
711
|
-
// `selection` is the rule that picked the binary; a profile created by an
|
|
712
|
-
// explicit choice keeps that rule across later auto launches. A binary from
|
|
713
|
-
// the checkout's Playwright build is Chrome for Testing by its source, not by
|
|
714
|
-
// its path (Linux builds live under chrome-linux/).
|
|
715
|
-
const finish = (bin, source, extra = {}) => ({
|
|
716
|
-
schemaVersion: SCHEMA_VERSION,
|
|
717
|
-
...describe(bin),
|
|
718
|
-
source,
|
|
719
|
-
resolvedAt: now().toISOString(),
|
|
720
|
-
...extra,
|
|
721
|
-
});
|
|
722
|
-
|
|
723
|
-
if (env.RECIPE_HARNESS_CHROME_BIN) {
|
|
724
|
-
const bin = env.RECIPE_HARNESS_CHROME_BIN;
|
|
725
|
-
if (!exists(bin)) throw resolutionError(`[recipe-harness] RECIPE_HARNESS_CHROME_BIN is not an executable file: ${bin}`);
|
|
726
|
-
return finish(bin, 'RECIPE_HARNESS_CHROME_BIN', { mode: 'override', selection: 'override' });
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
const { mode, path: explicit } = browserMode(env);
|
|
730
|
-
if (mode === 'path') {
|
|
731
|
-
if (!exists(explicit)) throw resolutionError(`[recipe-harness] RECIPE_HARNESS_BROWSER is not an executable file: ${explicit}`);
|
|
732
|
-
return finish(explicit, 'RECIPE_HARNESS_BROWSER', { mode, selection: mode });
|
|
733
|
-
}
|
|
734
|
-
const chrome = () => firstExisting(chromeCandidates, exists);
|
|
735
|
-
if (mode === 'chrome') {
|
|
736
|
-
const bin = chrome();
|
|
737
|
-
if (!bin) throw resolutionError(`[recipe-harness] RECIPE_HARNESS_BROWSER=chrome but Google Chrome is not installed (looked in: ${chromeCandidates.join(', ')}).`);
|
|
738
|
-
return finish(bin, 'RECIPE_HARNESS_BROWSER', { mode, selection: mode });
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
if (mode === 'cft') {
|
|
742
|
-
const cftResult = cft();
|
|
743
|
-
if (cftResult.error) throw resolutionError(cftResult.error);
|
|
744
|
-
return finish(cftResult.executable, 'RECIPE_HARNESS_BROWSER', { mode, selection: mode, browser: CHROME_FOR_TESTING });
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
// auto. A slot profile keeps the kind of browser it was created with:
|
|
748
|
-
// - created on Google Chrome or an explicit browser: that binary, as long
|
|
749
|
-
// as it exists (never silently back to Chrome for Testing);
|
|
750
|
-
// - created on Chrome for Testing: the checkout's current Chrome for
|
|
751
|
-
// Testing (so a Playwright upgrade is followed), moving to Google Chrome
|
|
752
|
-
// only once that build is proven to die (a probe timeout keeps it).
|
|
753
|
-
// A fresh profile takes Chrome for Testing when it starts and paints.
|
|
754
|
-
const previous = readRecorded(recorded);
|
|
755
|
-
// Names for forced extensions the slot browser already reported (the
|
|
756
|
-
// operator's own Chrome profiles may not be readable).
|
|
757
|
-
const knownNames = new Map(['policyPinned', 'disabled'].flatMap((group) => previous?.launch?.otherExtensions?.[group] ?? []).map(({ id, name }) => [id, name]));
|
|
758
|
-
const managedPolicy = () => {
|
|
759
|
-
const policy = managedChrome();
|
|
760
|
-
if (!policy) return null;
|
|
761
|
-
return { ...policy, forcedExtensions: policy.forcedExtensions.map((entry) => ({ ...entry, name: entry.name ?? knownNames.get(entry.id) ?? null })) };
|
|
762
|
-
};
|
|
763
|
-
const recordedFrom = previous ? path.resolve(recorded) : null;
|
|
764
|
-
const recordedKind = previous ? previous.browser ?? describe(previous.bin).browser : null;
|
|
765
|
-
const recordedSelection = previous ? previous.selection ?? previous.mode ?? 'auto' : null;
|
|
766
|
-
// override/path: chosen by env; explicit: a launcher given --chrome-bin directly.
|
|
767
|
-
const followsCft = Boolean(previous) && recordedKind === CHROME_FOR_TESTING && !['override', 'path', 'explicit'].includes(recordedSelection);
|
|
768
|
-
if (previous && !followsCft) {
|
|
769
|
-
if (!exists(previous.bin)) {
|
|
770
|
-
throw resolutionError(
|
|
771
|
-
`[recipe-harness] This slot profile was created with ${recordedKind} at ${previous.bin}, which no longer exists. ` +
|
|
772
|
-
'Next: reinstall it, reset the profile (mm-harness fixtures set), or choose a browser with RECIPE_HARNESS_BROWSER.',
|
|
773
|
-
);
|
|
774
|
-
}
|
|
775
|
-
const managedNow = recordedKind === 'google-chrome' ? managedPolicy() : null;
|
|
776
|
-
return finish(previous.bin, 'auto-recorded', {
|
|
777
|
-
mode,
|
|
778
|
-
selection: recordedSelection,
|
|
779
|
-
...(recordedKind ? { browser: recordedKind } : {}),
|
|
780
|
-
...(managedNow ? {
|
|
781
|
-
managedChrome: managedNow,
|
|
782
|
-
warning: `${describeManagedChrome(managedNow)} and this slot profile was created on it, so forced extensions may open windows that take the front. ` +
|
|
783
|
-
'Next: reset the profile (mm-harness fixtures set) to move the slot to Chrome for Testing.',
|
|
784
|
-
} : {}),
|
|
785
|
-
recordedFrom,
|
|
786
|
-
recordedAt: previous.resolvedAt ?? null,
|
|
787
|
-
});
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
// An uninstalled Playwright build keeps today's approval-gated install
|
|
791
|
-
// guidance; only a build that is installed but cannot start falls back.
|
|
792
|
-
const cftResult = cft();
|
|
793
|
-
if (cftResult.error) throw resolutionError(cftResult.error);
|
|
794
|
-
const cftBin = cftResult.executable;
|
|
795
|
-
const version = browserVersion(cftBin);
|
|
796
|
-
const method = effectiveLaunchMethod(cftBin, launchMethod);
|
|
797
|
-
const key = probeCacheKey(cftBin, version, method, display);
|
|
798
|
-
const nowMs = () => now().getTime();
|
|
799
|
-
const lineage = previous ? { recordedFrom, recordedBin: previous.bin, recordedSelection } : {};
|
|
800
|
-
|
|
801
|
-
let cached = true;
|
|
802
|
-
let verdict = usableVerdict(readProbeCache(cachePath).entries[key], nowMs());
|
|
803
|
-
if (!verdict) {
|
|
804
|
-
verdict = await withProbeLock(cachePath, key, async () => {
|
|
805
|
-
const settled = usableVerdict(readProbeCache(cachePath).entries[key], nowMs());
|
|
806
|
-
if (settled) return settled;
|
|
807
|
-
cached = false;
|
|
808
|
-
const result = await probe(cftBin, { launchMethod: method, display });
|
|
809
|
-
const checkedAt = now();
|
|
810
|
-
const fresh = {
|
|
811
|
-
ok: result.ok,
|
|
812
|
-
reason: result.reason,
|
|
813
|
-
transient: Boolean(result.transient),
|
|
814
|
-
durationMs: result.durationMs,
|
|
815
|
-
bin: cftBin,
|
|
816
|
-
version,
|
|
817
|
-
launchMethod: method,
|
|
818
|
-
display,
|
|
819
|
-
checkedAt: checkedAt.toISOString(),
|
|
820
|
-
...(result.transient ? { expiresAt: new Date(checkedAt.getTime() + TRANSIENT_VERDICT_TTL_MS).toISOString() } : {}),
|
|
821
|
-
};
|
|
822
|
-
try {
|
|
823
|
-
writeProbeCache(cachePath, key, fresh);
|
|
824
|
-
} catch (error) {
|
|
825
|
-
process.stderr.write(`[browser] could not write probe cache ${cachePath}: ${error.message}\n`);
|
|
826
|
-
}
|
|
827
|
-
return fresh;
|
|
828
|
-
});
|
|
829
|
-
}
|
|
830
|
-
const probeRecord = {
|
|
831
|
-
bin: cftBin,
|
|
832
|
-
version,
|
|
833
|
-
launchMethod: method,
|
|
834
|
-
display,
|
|
835
|
-
ok: verdict.ok,
|
|
836
|
-
reason: verdict.reason,
|
|
837
|
-
transient: Boolean(verdict.transient),
|
|
838
|
-
checkedAt: verdict.checkedAt,
|
|
839
|
-
...(verdict.expiresAt ? { expiresAt: verdict.expiresAt } : {}),
|
|
840
|
-
cached,
|
|
841
|
-
cachePath,
|
|
842
|
-
};
|
|
843
|
-
// On a Mac whose Google Chrome is managed (forced extensions or sign-in),
|
|
844
|
-
// slots use Chrome for Testing, and never fall back to that managed Chrome.
|
|
845
|
-
const managed = managedPolicy();
|
|
846
|
-
const managedExtra = managed ? { reason: 'managed-chrome', managedChrome: managed } : {};
|
|
847
|
-
// A followed CfT profile keeps the rule it was created by (cft or auto).
|
|
848
|
-
const asCft = { mode, selection: previous ? recordedSelection : 'auto', browser: CHROME_FOR_TESTING, ...managedExtra };
|
|
849
|
-
if (verdict.ok) return finish(cftBin, previous ? 'auto-recorded' : 'auto', { ...asCft, probe: probeRecord, ...lineage });
|
|
850
|
-
if (previous && verdict.transient) {
|
|
851
|
-
return finish(cftBin, 'auto-recorded', {
|
|
852
|
-
...asCft,
|
|
853
|
-
probe: probeRecord,
|
|
854
|
-
...lineage,
|
|
855
|
-
warning: `Chrome for Testing probe did not complete (${verdict.reason}); the profile keeps Chrome for Testing until a probe proves it dies.`,
|
|
856
|
-
});
|
|
857
|
-
}
|
|
858
|
-
if (managed) {
|
|
859
|
-
throw resolutionError(
|
|
860
|
-
`${UNLAUNCHABLE_MARKER} ${describeManagedChrome(managed)}, so slots use Chrome for Testing, and Chrome for Testing ${version ?? ''} did not start and paint here (${verdict.reason}). ` +
|
|
861
|
-
'Next: either set RECIPE_HARNESS_BROWSER=chrome to use the managed Google Chrome and accept that its forced extensions may open windows that take the front, ' +
|
|
862
|
-
'or reduce the machine\'s load and retry (a probe that only timed out is retried after 30 minutes).',
|
|
863
|
-
{ probe: probeRecord, managedChrome: managed },
|
|
864
|
-
);
|
|
865
|
-
}
|
|
866
|
-
const fallback = chrome();
|
|
867
|
-
if (!fallback) {
|
|
868
|
-
throw resolutionError(
|
|
869
|
-
`${UNLAUNCHABLE_MARKER} Chrome for Testing ${version ?? ''} does not start on this machine (${verdict.reason}) and Google Chrome is not installed. ` +
|
|
870
|
-
'Next: install Google Chrome, or set RECIPE_HARNESS_CHROME_BIN to a browser that starts.',
|
|
871
|
-
{ probe: probeRecord },
|
|
872
|
-
);
|
|
873
|
-
}
|
|
874
|
-
return finish(fallback, 'auto', { mode, selection: 'auto', probe: probeRecord, ...lineage, fallbackReason: `Chrome for Testing probe failed: ${verdict.reason}` });
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
// ---- CLI -------------------------------------------------------------------
|
|
878
|
-
|
|
879
|
-
function parseCliArgs(argv) {
|
|
880
|
-
const args = { out: [] };
|
|
881
|
-
for (let index = 0; index < argv.length; index += 1) {
|
|
882
|
-
const flag = argv[index];
|
|
883
|
-
if (flag === '--json') {
|
|
884
|
-
args.json = true;
|
|
885
|
-
continue;
|
|
886
|
-
}
|
|
887
|
-
if (!flag.startsWith('--') || index + 1 >= argv.length) throw new Error(`Missing value for ${flag}`);
|
|
888
|
-
const key = flag.slice(2);
|
|
889
|
-
if (key === 'out') args.out.push(argv[index + 1]);
|
|
890
|
-
else args[key] = argv[index + 1];
|
|
891
|
-
index += 1;
|
|
892
|
-
}
|
|
893
|
-
return args;
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
function writeResolution(files, resolution) {
|
|
897
|
-
for (const file of files) {
|
|
898
|
-
fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
|
|
899
|
-
fs.writeFileSync(file, `${JSON.stringify(resolution, null, 2)}\n`);
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
// Outer bound for one CLI call. The probe stays well inside it; if anything
|
|
904
|
-
// still hangs, stop the probe browsers before giving up.
|
|
905
|
-
const CLI_DEADLINE_MS = 120000;
|
|
906
|
-
|
|
907
|
-
const USAGE = 'Usage: browser-resolver.cjs resolve --target <checkout> [--launch-method spawn|launch-services] [--display headless|headful] [--recorded <file>] [--out <file>] [--json] | load-unpacked --port <cdp> --path <dist> --profile <dir> [--expect-id <id>] [--open-url <url>] [--timeout-ms <ms>] | open-window --port <cdp> --url <url>';
|
|
908
|
-
|
|
909
|
-
async function main(argv) {
|
|
910
|
-
const [command, ...rest] = argv;
|
|
911
|
-
if (command === '--help' || command === '-h') {
|
|
912
|
-
process.stdout.write(`${USAGE}\n`);
|
|
913
|
-
return;
|
|
914
|
-
}
|
|
915
|
-
const args = parseCliArgs(rest);
|
|
916
|
-
const watchdog = setTimeout(() => {
|
|
917
|
-
for (const profile of activeProbeProfiles) {
|
|
918
|
-
killProfile(profile);
|
|
919
|
-
fs.rmSync(profile, { recursive: true, force: true });
|
|
920
|
-
}
|
|
921
|
-
process.stderr.write(`browser-resolver ${command} did not finish within ${CLI_DEADLINE_MS}ms.\n`);
|
|
922
|
-
process.exit(1);
|
|
923
|
-
}, CLI_DEADLINE_MS);
|
|
924
|
-
watchdog.unref();
|
|
925
|
-
if (command === 'resolve') {
|
|
926
|
-
const target = path.resolve(args.target || process.cwd());
|
|
927
|
-
const resolution = await resolveBrowser({
|
|
928
|
-
cft: () => playwrightChromiumExecutable(target),
|
|
929
|
-
launchMethod: args['launch-method'] === LAUNCH_SERVICES ? LAUNCH_SERVICES : SPAWN,
|
|
930
|
-
display: args.display === HEADFUL ? HEADFUL : HEADLESS,
|
|
931
|
-
recorded: args.recorded || null,
|
|
932
|
-
});
|
|
933
|
-
writeResolution(args.out, resolution);
|
|
934
|
-
process.stderr.write(`[browser] ${resolution.browser} ${resolution.version ?? '?'} via ${resolution.source}/${resolution.mode}; extension loading: ${resolution.extensionLoading}${resolution.fallbackReason ? ` (${resolution.fallbackReason})` : ''}\n`);
|
|
935
|
-
process.stdout.write(args.json ? `${JSON.stringify(resolution)}\n` : resolution.bin);
|
|
936
|
-
return;
|
|
937
|
-
}
|
|
938
|
-
if (command === 'load-unpacked') {
|
|
939
|
-
if (!args.port || !args.path || !args.profile) throw new Error('load-unpacked requires --port, --path and --profile');
|
|
940
|
-
const timeoutMs = Math.min(Number(args['timeout-ms'] || 60000), CLI_DEADLINE_MS - 15000);
|
|
941
|
-
const loaded = await cdp.loadUnpackedOverPort(Number(args.port), args.path, {
|
|
942
|
-
profile: args.profile,
|
|
943
|
-
...(args['expect-id'] ? { expectedId: args['expect-id'] } : {}),
|
|
944
|
-
url: args['open-url'] || null,
|
|
945
|
-
timeoutMs,
|
|
946
|
-
});
|
|
947
|
-
process.stdout.write(`${JSON.stringify(loaded)}\n`);
|
|
948
|
-
return;
|
|
949
|
-
}
|
|
950
|
-
if (command === 'open-window') {
|
|
951
|
-
if (!args.port || !args.url) throw new Error('open-window requires --port and --url');
|
|
952
|
-
const client = await cdp.connectBrowserCdp(Number(args.port), { timeoutMs: 15000 });
|
|
953
|
-
try {
|
|
954
|
-
const { targetId } = await cdp.openBackgroundWindow(client.send, args.url, 15000);
|
|
955
|
-
process.stdout.write(`${JSON.stringify({ targetId })}\n`);
|
|
956
|
-
} finally {
|
|
957
|
-
client.close();
|
|
958
|
-
}
|
|
959
|
-
return;
|
|
960
|
-
}
|
|
961
|
-
throw new Error(USAGE);
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
module.exports = {
|
|
965
|
-
BRANDED_CHROME_ARGS,
|
|
966
|
-
CDP_LOAD_UNPACKED,
|
|
967
|
-
HEADFUL,
|
|
968
|
-
HEADLESS,
|
|
969
|
-
LAUNCH_SERVICES,
|
|
970
|
-
LOAD_EXTENSION,
|
|
971
|
-
SPAWN,
|
|
972
|
-
TRANSIENT_VERDICT_TTL_MS,
|
|
973
|
-
UNLAUNCHABLE_MARKER,
|
|
974
|
-
assertCdpOwnedByProfile: cdp.assertCdpOwnedByProfile,
|
|
975
|
-
cdpListenerPids: cdp.cdpListenerPids,
|
|
976
|
-
cdpOwner: cdp.cdpOwner,
|
|
977
|
-
waitForCdpOwner: cdp.waitForCdpOwner,
|
|
978
|
-
brandedChromeCandidates,
|
|
979
|
-
browserMode,
|
|
980
|
-
browserVersion,
|
|
981
|
-
connectBrowserCdp: cdp.connectBrowserCdp,
|
|
982
|
-
defaultCachePath,
|
|
983
|
-
describeBrowser,
|
|
984
|
-
effectiveLaunchMethod,
|
|
985
|
-
expectedExtensionId: cdp.expectedExtensionId,
|
|
986
|
-
extensionLaunchArgs,
|
|
987
|
-
extensionLoadingFor,
|
|
988
|
-
isBrandedChrome,
|
|
989
|
-
loadUnpackedExtension: cdp.loadUnpackedExtension,
|
|
990
|
-
loadUnpackedOverPort: cdp.loadUnpackedOverPort,
|
|
991
|
-
playwrightChromiumExecutable,
|
|
992
|
-
probeCacheKey,
|
|
993
|
-
probeLaunch,
|
|
994
|
-
readProbeCache,
|
|
995
|
-
renderCheck,
|
|
996
|
-
resolveBrowser,
|
|
997
|
-
withProbeLock,
|
|
998
|
-
readManagedChromePolicy,
|
|
999
|
-
managedChromePlists,
|
|
1000
|
-
};
|
|
1001
|
-
|
|
1002
|
-
if (require.main === module) {
|
|
1003
|
-
main(process.argv.slice(2)).then(() => process.exit(0), (error) => {
|
|
1004
|
-
process.stderr.write(`${error.message}\n`);
|
|
1005
|
-
process.exit(1);
|
|
1006
|
-
});
|
|
1007
|
-
}
|