@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.
Files changed (31) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +8 -5
  3. package/adapters/extension/build-handoff.cjs +2 -2
  4. package/adapters/extension/ensure-browser.sh +10 -12
  5. package/adapters/extension/inject.mjs +10 -7
  6. package/adapters/extension/launch-browser.cjs +7 -10
  7. package/adapters/extension/live.sh +1 -2
  8. package/adapters/extension/reattach.sh +3 -3
  9. package/adapters/extension/sidepanel-toggle.sh +4 -4
  10. package/adapters/extension/wallet-fixture-state.cjs +2 -2
  11. package/adapters/manifest.json +0 -24
  12. package/adapters/shared/harness-source-fingerprint.mjs +16 -2
  13. package/adapters/terminal/launch.mjs +2 -2
  14. package/adapters/terminal/wallet-host.mjs +1 -1
  15. package/dist/adapters/extension/doctor-checks.js +2 -2
  16. package/dist/adapters/extension/launched-browser.js +1 -1
  17. package/dist/adapters/extension/runtime.js +4 -4
  18. package/dist/adapters/extension/validation-process-ownership.js +1 -1
  19. package/dist/commands/launch/extension.js +1 -1
  20. package/docs/cli-contract.md +6 -17
  21. package/library/actions/extension/platform/cdp.mjs +5 -7
  22. package/library/actions/terminal/platform/page.mjs +2 -2
  23. package/package.json +2 -1
  24. package/adapters/extension/lib/chrome-args.cjs +0 -365
  25. package/adapters/extension/lib/extension-id.cjs +0 -36
  26. package/adapters/extension/lib/macos-focus.cjs +0 -108
  27. package/adapters/extension/lib/playwright-cdp.cjs +0 -50
  28. package/adapters/extension/lib/validation-launch-supervisor.cjs +0 -292
  29. package/adapters/extension/lib/validation-process-ownership.cjs +0 -125
  30. package/adapters/shared/browser-cdp.cjs +0 -542
  31. 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
- }