browser-commander 0.32.1 → 0.33.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 +48 -5
- package/package.json +1 -1
- package/src/browser/chrome-for-testing-cache.js +198 -0
- package/src/browser/launch-executable.js +203 -0
- package/src/browser/launch-restrictions.json +7 -1
- package/src/browser/launcher.js +78 -65
- package/src/browser/parity.js +6 -1
- package/src/browser/real-browser.js +29 -9
- package/src/browser/webdriver.js +22 -3
- package/src/capture/screenshot.js +16 -1
- package/src/capture/viewport.js +2 -1
- package/src/exports.js +4 -0
- package/src/traces/recorder.js +18 -2
- package/tests/e2e/startup-readiness.e2e.test.js +2 -0
- package/tests/unit/browser/chrome-for-testing-cache.test.js +139 -0
- package/tests/unit/browser/launch-executable.test.js +93 -0
- package/tests/unit/browser/launcher.test.js +235 -31
- package/tests/unit/browser/parity.test.js +19 -0
- package/tests/unit/browser/real-browser.test.js +52 -0
- package/tests/unit/browser/webdriver.test.js +39 -0
- package/tests/unit/traces/issue-162-defaults.test.js +1 -1
- package/tests/unit/traces/issue-166-policy.test.js +1 -1
- package/tests/unit/traces/issue-180-screenshots.test.js +173 -0
package/src/browser/launcher.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { access } from 'node:fs/promises';
|
|
2
1
|
import { launchFailure } from './launch-diagnostics.js';
|
|
3
2
|
import {
|
|
4
3
|
installSessionPersistence,
|
|
@@ -20,9 +19,13 @@ import {
|
|
|
20
19
|
configureUserDataDir,
|
|
21
20
|
removeUserDataDir,
|
|
22
21
|
} from './profile-directory.js';
|
|
22
|
+
import {
|
|
23
|
+
ENGINE_BROWSERS,
|
|
24
|
+
defaultRestrictionsFor,
|
|
25
|
+
resolveLaunchExecutable,
|
|
26
|
+
} from './launch-executable.js';
|
|
23
27
|
import { launchRealBrowser } from './real-browser.js';
|
|
24
28
|
import { resolveRestrictions } from './restrictions.js';
|
|
25
|
-
import { resolveSystemBrowserExecutable } from './system-browser.js';
|
|
26
29
|
import { applyFingerprint } from '../fingerprint/apply.js';
|
|
27
30
|
import { attachDownloads } from '../downloads/attach.js';
|
|
28
31
|
import { launchWebDriver } from './webdriver.js';
|
|
@@ -36,14 +39,11 @@ import {
|
|
|
36
39
|
} from './storage-state.js';
|
|
37
40
|
import { interruptionMode } from '../utilities/subprocess.js';
|
|
38
41
|
|
|
42
|
+
export { resolveLaunchExecutable };
|
|
43
|
+
|
|
39
44
|
/** How `launchBrowser` starts the browser (issue #103). */
|
|
40
45
|
export const LAUNCH_MODES = Object.freeze(['real', 'engine']);
|
|
41
46
|
|
|
42
|
-
const loadEngine = {
|
|
43
|
-
playwright: async () => (await import('playwright')).chromium,
|
|
44
|
-
puppeteer: async () => (await import('puppeteer')).default,
|
|
45
|
-
};
|
|
46
|
-
|
|
47
47
|
function validateLaunchMode({ engine, launch, attach }) {
|
|
48
48
|
assertSupportedEngine(engine);
|
|
49
49
|
if (!LAUNCH_MODES.includes(launch)) {
|
|
@@ -58,48 +58,6 @@ function validateLaunchMode({ engine, launch, attach }) {
|
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
async function isExecutable(file) {
|
|
62
|
-
try {
|
|
63
|
-
await access(file);
|
|
64
|
-
return true;
|
|
65
|
-
} catch {
|
|
66
|
-
return false;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Pick the browser binary for a real launch.
|
|
72
|
-
*
|
|
73
|
-
* An explicit `executablePath` or `channel` is honoured as given. Without
|
|
74
|
-
* either, the installed Google Chrome is preferred - it is the browser a
|
|
75
|
-
* person would start - and the engine's own downloaded Chromium is the
|
|
76
|
-
* fallback, so a machine with only `npx playwright install` still works. The
|
|
77
|
-
* binary is spawned by Browser Commander either way, with the same clean
|
|
78
|
-
* command line.
|
|
79
|
-
*/
|
|
80
|
-
export async function resolveLaunchExecutable(
|
|
81
|
-
{ engine, channel, executablePath },
|
|
82
|
-
{ resolveSystem = resolveSystemBrowserExecutable, loadEngineModule } = {}
|
|
83
|
-
) {
|
|
84
|
-
if (executablePath !== undefined || channel !== undefined) {
|
|
85
|
-
return await resolveSystem({
|
|
86
|
-
channel: channel ?? 'chrome',
|
|
87
|
-
executablePath,
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
try {
|
|
91
|
-
return await resolveSystem({ channel: 'chrome' });
|
|
92
|
-
} catch (error) {
|
|
93
|
-
const bundled = await (loadEngineModule ?? loadEngine[engine])()
|
|
94
|
-
.then((module) => module.executablePath())
|
|
95
|
-
.catch(() => undefined);
|
|
96
|
-
if (bundled && (await isExecutable(bundled))) {
|
|
97
|
-
return bundled;
|
|
98
|
-
}
|
|
99
|
-
throw error;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
61
|
async function launchReal(options, dependencies) {
|
|
104
62
|
const {
|
|
105
63
|
engine,
|
|
@@ -144,14 +102,47 @@ async function launchReal(options, dependencies) {
|
|
|
144
102
|
return { browser, page, close, connectedBrowser, ...meta };
|
|
145
103
|
}
|
|
146
104
|
|
|
105
|
+
// The Chrome for Testing an engine launch starts on its own, or the one a
|
|
106
|
+
// Selenium launch is pointed at; undefined for any installed browser.
|
|
107
|
+
async function engineExecutable(
|
|
108
|
+
{ engine, browser = 'chrome', channel, executablePath },
|
|
109
|
+
engineModule,
|
|
110
|
+
dependencies
|
|
111
|
+
) {
|
|
112
|
+
if (executablePath !== undefined || channel !== undefined) {
|
|
113
|
+
return executablePath;
|
|
114
|
+
}
|
|
115
|
+
if (engine === 'selenium') {
|
|
116
|
+
// Without Chrome for Testing this fails rather than let the driver pick
|
|
117
|
+
// the installed Google Chrome (issue #179).
|
|
118
|
+
return browser === 'chrome'
|
|
119
|
+
? await resolveLaunchExecutable({ engine }, dependencies)
|
|
120
|
+
: undefined;
|
|
121
|
+
}
|
|
122
|
+
return await Promise.resolve()
|
|
123
|
+
.then(() => engineModule.executablePath?.())
|
|
124
|
+
.catch(() => undefined);
|
|
125
|
+
}
|
|
126
|
+
|
|
147
127
|
async function launchWithEngine(options, dependencies) {
|
|
148
128
|
if (options.engine === 'selenium') {
|
|
129
|
+
// launchWebDriver adds the defaults of the browser it resolves.
|
|
130
|
+
const executablePath = await engineExecutable(options, null, dependencies);
|
|
149
131
|
const launched = await (dependencies.launchWebDriver ?? launchWebDriver)({
|
|
150
132
|
...options,
|
|
133
|
+
...(executablePath ? { executablePath } : {}),
|
|
151
134
|
args: [...options.args, ...options.extraArgs],
|
|
152
135
|
});
|
|
153
136
|
return { ...launched, browser: launched.driver };
|
|
154
137
|
}
|
|
138
|
+
const engineModule = await (
|
|
139
|
+
dependencies.loadEngineModule ?? ENGINE_BROWSERS[options.engine]
|
|
140
|
+
)();
|
|
141
|
+
const defaultRestrictions = defaultRestrictionsFor(
|
|
142
|
+
await engineExecutable(options, engineModule, dependencies),
|
|
143
|
+
options.defaultRestrictions
|
|
144
|
+
);
|
|
145
|
+
const restrictions = [...defaultRestrictions, ...options.restrictions];
|
|
155
146
|
const {
|
|
156
147
|
engine,
|
|
157
148
|
userDataDir: requestedUserDataDir,
|
|
@@ -160,7 +151,6 @@ async function launchWithEngine(options, dependencies) {
|
|
|
160
151
|
args,
|
|
161
152
|
extraArgs,
|
|
162
153
|
ignoreDefaultArgs,
|
|
163
|
-
restrictions,
|
|
164
154
|
env,
|
|
165
155
|
colorScheme,
|
|
166
156
|
channel,
|
|
@@ -195,9 +185,6 @@ async function launchWithEngine(options, dependencies) {
|
|
|
195
185
|
},
|
|
196
186
|
localState: options.localState,
|
|
197
187
|
});
|
|
198
|
-
const engineModule = await (
|
|
199
|
-
dependencies.loadEngineModule ?? loadEngine[engine]
|
|
200
|
-
)();
|
|
201
188
|
const selection = { channel, executablePath };
|
|
202
189
|
|
|
203
190
|
let browser;
|
|
@@ -263,6 +250,7 @@ async function launchWithEngine(options, dependencies) {
|
|
|
263
250
|
userDataDir,
|
|
264
251
|
temporaryProfile,
|
|
265
252
|
args: chrome.args,
|
|
253
|
+
defaultRestrictions,
|
|
266
254
|
};
|
|
267
255
|
}
|
|
268
256
|
|
|
@@ -285,13 +273,20 @@ async function unfocusAddressBar(page, verbose, settleMs = 500) {
|
|
|
285
273
|
/**
|
|
286
274
|
* Launch a browser.
|
|
287
275
|
*
|
|
288
|
-
* By default (`launch: 'real'`) Browser Commander starts
|
|
289
|
-
*
|
|
276
|
+
* By default (`launch: 'real'`) Browser Commander starts Chrome itself,
|
|
277
|
+
* exactly like a person who wants to attach a debugger would:
|
|
290
278
|
* `--user-data-dir=<fresh temporary profile> --remote-debugging-port=<reserved
|
|
291
279
|
* port>` and nothing else, then attaches the engine over CDP (issues #101 and
|
|
292
280
|
* #103). The profile is deleted on close. Every restriction the library used
|
|
293
281
|
* to add silently is an explicit opt-in through `restrictions`.
|
|
294
282
|
*
|
|
283
|
+
* The browser is the engine's Chrome for Testing, an application of its own,
|
|
284
|
+
* so the user's Google Chrome is never taken over; `channel: 'chrome'` asks
|
|
285
|
+
* for the installed Google Chrome. Chrome for Testing gets the
|
|
286
|
+
* `chrome-for-testing` restrictions (`--test-type=gpu`, the only switch that
|
|
287
|
+
* hides its "only for automated testing" bar, and `--use-mock-keychain`)
|
|
288
|
+
* unless `defaultRestrictions` says otherwise (issue #179).
|
|
289
|
+
*
|
|
295
290
|
* `launch: 'engine'` keeps the Playwright/Puppeteer-launched browser for CI
|
|
296
291
|
* and headless use; the switches those engines add are listed in
|
|
297
292
|
* limitations.json (`engine-launch-switches`).
|
|
@@ -304,12 +299,13 @@ async function unfocusAddressBar(page, verbose, settleMs = 500) {
|
|
|
304
299
|
* @param {number} [options.slowMo=0] - Slow down operations by ms
|
|
305
300
|
* @param {boolean} [options.verbose=false] - Enable verbose logging
|
|
306
301
|
* @param {string[]} [options.restrictions] - Opt-in restrictions from launch-restrictions.json, such as 'no-extensions' or the 'legacy-defaults' preset
|
|
302
|
+
* @param {string[]} [options.defaultRestrictions] - Restrictions that replace the executable's defaults (the 'chrome-for-testing' preset for Chrome for Testing, none otherwise); [] turns them off
|
|
307
303
|
* @param {string[]} [options.args] - Additional Chrome arguments
|
|
308
304
|
* @param {string[]} [options.extraArgs] - Additional Chrome arguments appended after args
|
|
309
305
|
* @param {boolean|string[]} [options.ignoreDefaultArgs] - Engine default switches to omit, or true for all ('engine' launch only)
|
|
310
306
|
* @param {Object<string,string>} [options.env] - Extra environment for the browser process only
|
|
311
307
|
* @param {string|null} [options.colorScheme] - Emulate color scheme: 'light', 'dark', 'no-preference', or null to reset
|
|
312
|
-
* @param {string} [options.channel] - Installed browser channel, such as 'chrome', 'chrome-beta', 'msedge', 'brave' or 'chromium'
|
|
308
|
+
* @param {string} [options.channel] - Installed browser channel, such as 'chrome', 'chrome-beta', 'msedge', 'brave' or 'chromium'; without it (and without executablePath) the engine's Chrome for Testing is started
|
|
313
309
|
* @param {string} [options.executablePath] - Explicit path to a Chrome or Chromium executable
|
|
314
310
|
* @param {number} [options.remoteDebuggingPort] - Fixed CDP port for the real launch; a free one is reserved when omitted
|
|
315
311
|
* @param {string|Object} [options.storageState] - Playwright-compatible storage state path or object
|
|
@@ -317,7 +313,7 @@ async function unfocusAddressBar(page, verbose, settleMs = 500) {
|
|
|
317
313
|
* @param {boolean} [options.automationParity=true] - Keep navigator.webdriver false where a launch switch would turn it on (headless or engine launches)
|
|
318
314
|
* @param {boolean|Object} [options.downloads] - Manage downloads: true for defaults, or {directory, persist, conflict}. The directory may be an absolute path, 'user-downloads' or 'temporary'.
|
|
319
315
|
* @param {boolean|Object} [options.debug=false] - Opt-in recording: true, {output}, or {directory, retain, maxDurationMs, limits} for managed storage
|
|
320
|
-
* @returns {Promise<{browser: Object, page: Object, downloads: (Object|null), close: function(): Promise<void>, launch: string, userDataDir: string, temporaryProfile: boolean, args: Array<string>}>} Browser (a BrowserContext for Playwright), page, download manager and launch metadata
|
|
316
|
+
* @returns {Promise<{browser: Object, page: Object, downloads: (Object|null), close: function(): Promise<void>, launch: string, userDataDir: string, temporaryProfile: boolean, args: Array<string>, defaultRestrictions: Array<string>}>} Browser (a BrowserContext for Playwright), page, download manager and launch metadata
|
|
321
317
|
*/
|
|
322
318
|
export async function launchBrowser(options = {}) {
|
|
323
319
|
return await launchBrowserWithDependencies(options);
|
|
@@ -368,6 +364,23 @@ export async function launchBrowserWithDependencies(
|
|
|
368
364
|
}
|
|
369
365
|
}
|
|
370
366
|
|
|
367
|
+
function validateSeleniumLaunch({
|
|
368
|
+
launch,
|
|
369
|
+
browser,
|
|
370
|
+
colorScheme,
|
|
371
|
+
fingerprint,
|
|
372
|
+
downloads,
|
|
373
|
+
}) {
|
|
374
|
+
if (colorScheme !== undefined || fingerprint !== undefined || downloads) {
|
|
375
|
+
throw new Error(
|
|
376
|
+
'selenium does not support CDP emulation, fingerprint overrides or managed downloads (webdriver-no-cdp-emulation)'
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
if (launch === 'real' && browser === 'firefox') {
|
|
380
|
+
throw new Error("selenium Firefox requires launch: 'engine'");
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
371
384
|
async function launchNonSafariBrowser(options, dependencies) {
|
|
372
385
|
const {
|
|
373
386
|
engine = 'playwright',
|
|
@@ -388,16 +401,16 @@ async function launchNonSafariBrowser(options, dependencies) {
|
|
|
388
401
|
} = options;
|
|
389
402
|
validateLaunchMode({ engine, launch, attach: rest.attach });
|
|
390
403
|
if (engine === 'selenium') {
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}
|
|
404
|
+
validateSeleniumLaunch({
|
|
405
|
+
launch,
|
|
406
|
+
colorScheme,
|
|
407
|
+
fingerprint,
|
|
408
|
+
downloads,
|
|
409
|
+
...rest,
|
|
410
|
+
});
|
|
399
411
|
}
|
|
400
412
|
// Validate arguments before anything is started or written to disk.
|
|
413
|
+
resolveRestrictions(rest.defaultRestrictions ?? []);
|
|
401
414
|
resolveChromeArgs({
|
|
402
415
|
args,
|
|
403
416
|
extraArgs,
|
package/src/browser/parity.js
CHANGED
|
@@ -317,7 +317,12 @@ function normalizeWhitespace(text) {
|
|
|
317
317
|
function requestedArgsFor(session, options) {
|
|
318
318
|
const requested = [...(options.args ?? []), ...(options.extraArgs ?? [])];
|
|
319
319
|
const launchedArgs = session.args ?? [];
|
|
320
|
-
|
|
320
|
+
// Chrome for Testing's default restrictions are requested too (issue #179).
|
|
321
|
+
const restrictions = [
|
|
322
|
+
...(session.defaultRestrictions ?? []),
|
|
323
|
+
...(options.restrictions ?? []),
|
|
324
|
+
];
|
|
325
|
+
if (restrictions.length > 0) {
|
|
321
326
|
requested.push(
|
|
322
327
|
...launchedArgs.filter(
|
|
323
328
|
(arg) =>
|
|
@@ -48,6 +48,10 @@ import {
|
|
|
48
48
|
knownDefaultUserDataDirs,
|
|
49
49
|
resolveSystemBrowserExecutable,
|
|
50
50
|
} from './system-browser.js';
|
|
51
|
+
import {
|
|
52
|
+
defaultRestrictionsFor,
|
|
53
|
+
resolveLaunchExecutable,
|
|
54
|
+
} from './launch-executable.js';
|
|
51
55
|
|
|
52
56
|
/**
|
|
53
57
|
* Switches the launcher owns. Letting a caller pass them would break the
|
|
@@ -375,22 +379,25 @@ function driverInterruption(engine, interruption) {
|
|
|
375
379
|
}
|
|
376
380
|
|
|
377
381
|
/**
|
|
378
|
-
* Spawn a
|
|
379
|
-
*
|
|
382
|
+
* Spawn a Chrome-family browser with a dedicated profile, wait for its
|
|
383
|
+
* loopback CDP endpoint, and attach through {@link connectBrowser}.
|
|
380
384
|
*
|
|
381
385
|
* The command line is `--user-data-dir=<dir> --remote-debugging-port=<port>`
|
|
382
386
|
* and nothing else unless asked for, so the session is as close to a
|
|
383
387
|
* hand-started browser as CDP allows and `navigator.webdriver` is false.
|
|
388
|
+
* Without `channel`, `executablePath` or `attach` the engine's Chrome for
|
|
389
|
+
* Testing is started, with the `chrome-for-testing` restrictions (issue #179).
|
|
384
390
|
*
|
|
385
391
|
* @param {Object} options - Launch and connection options
|
|
386
392
|
* @param {'playwright'|'puppeteer'} [options.engine='playwright'] - Automation engine
|
|
387
|
-
* @param {string} [options.channel
|
|
393
|
+
* @param {string} [options.channel] - Installed Chrome-family channel, such as 'chrome' for the installed Google Chrome; it also names the browser a snapshot or migration belongs to (default 'chrome')
|
|
388
394
|
* @param {string} [options.executablePath] - Explicit installed browser executable
|
|
389
395
|
* @param {string} [options.userDataDir] - Persistent dedicated profile. When omitted a fresh temporary profile is created and deleted on close.
|
|
390
396
|
* @param {number} [options.remoteDebuggingPort] - Fixed loopback CDP port. When omitted a free port is reserved (and re-reserved on a port race). Zero is refused because it sets navigator.webdriver.
|
|
391
397
|
* @param {number} [options.portAttempts=3] - Launch attempts when a reserved port is lost to a race
|
|
392
398
|
* @param {boolean} [options.headless=false] - Run the installed browser headlessly
|
|
393
399
|
* @param {string[]} [options.restrictions] - Opt-in restrictions from launch-restrictions.json, such as 'no-extensions'
|
|
400
|
+
* @param {string[]} [options.defaultRestrictions] - Restrictions that replace the executable's defaults: the 'chrome-for-testing' preset for Chrome for Testing, none for any other browser. Pass [] to start Chrome for Testing without them.
|
|
394
401
|
* @param {string[]} [options.args] - Additional browser arguments
|
|
395
402
|
* @param {string[]} [options.extraArgs] - Additional browser arguments appended after args
|
|
396
403
|
* @param {boolean} [options.defaultBrowserCheck=false] - Allow the disposable browser to ask to become the default
|
|
@@ -404,7 +411,7 @@ function driverInterruption(engine, interruption) {
|
|
|
404
411
|
* @param {{browser: string, profile: (string|undefined), userDataDir: (string|undefined), include: (Array<string>|undefined), domains: (Array<string>|undefined)}} [options.migrateFrom] - Migrate a real browser profile into the dedicated profile before launch; on-disk data is written before the browser starts and cookies are seeded over CDP after connecting
|
|
405
412
|
* @param {{mode: 'snapshot', browser: (string|undefined), profile: (string|undefined), userDataDir: (string|undefined)}} [options.attach] - Launch on a read-only snapshot of a real profile (issue #102, addendum): `snapshotUserDataDir()` copies it into a temporary user data directory, which is deleted on close. `browser` defaults to the channel's browser and `profile` to Default. Mutually exclusive with `migrateFrom` and `userDataDir`.
|
|
406
413
|
* @param {boolean} [options.verbose=false] - Show browser and connection logs
|
|
407
|
-
* @returns {Promise<{browser: Object, page: Object, downloads: (Object|null), close: function(): Promise<void>, browserProcess: Object, cdpEndpoint: string, remoteDebuggingPort: number, executablePath: string, userDataDir: string, temporaryProfile: boolean, args: Array<string>, migration: (Object|undefined), attach: ({mode: 'snapshot', snapshot: Object, differences: Array<{aspect: string, description: string}>}|undefined)}>} Connected handles and process metadata (with a `migration` report when `migrateFrom` was given and an `attach` entry when `attach` was given)
|
|
414
|
+
* @returns {Promise<{browser: Object, page: Object, downloads: (Object|null), close: function(): Promise<void>, browserProcess: Object, cdpEndpoint: string, remoteDebuggingPort: number, executablePath: string, userDataDir: string, temporaryProfile: boolean, args: Array<string>, defaultRestrictions: Array<string>, migration: (Object|undefined), attach: ({mode: 'snapshot', snapshot: Object, differences: Array<{aspect: string, description: string}>}|undefined)}>} Connected handles and process metadata (with a `migration` report when `migrateFrom` was given and an `attach` entry when `attach` was given)
|
|
408
415
|
*/
|
|
409
416
|
export async function launchAndConnectRealBrowser(options = {}) {
|
|
410
417
|
return await launchAndConnectRealBrowserWithDependencies(options);
|
|
@@ -662,6 +669,7 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
662
669
|
portAttempts = 3,
|
|
663
670
|
headless = false,
|
|
664
671
|
restrictions = [],
|
|
672
|
+
defaultRestrictions: requestedDefaultRestrictions,
|
|
665
673
|
args = [],
|
|
666
674
|
extraArgs = [],
|
|
667
675
|
ignoreDefaultArgs: _ignoredLegacyDefaults,
|
|
@@ -699,14 +707,17 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
699
707
|
attach,
|
|
700
708
|
migrateFrom,
|
|
701
709
|
});
|
|
710
|
+
resolveRestrictions(requestedDefaultRestrictions ?? []);
|
|
702
711
|
|
|
712
|
+
// Without a channel, executablePath or attach the engine's Chrome for
|
|
713
|
+
// Testing is started, never the user's Google Chrome (issue #179).
|
|
703
714
|
const resolveExecutable =
|
|
704
|
-
dependencies.resolveExecutable ??
|
|
705
|
-
|
|
715
|
+
dependencies.resolveExecutable ??
|
|
716
|
+
((request) => resolveLaunchExecutable({ engine, ...request }));
|
|
706
717
|
const executablePath = await Promise.resolve()
|
|
707
718
|
.then(() =>
|
|
708
719
|
resolveExecutable({
|
|
709
|
-
channel:
|
|
720
|
+
channel: attach?.browser ?? options.channel,
|
|
710
721
|
executablePath: requestedExecutablePath,
|
|
711
722
|
})
|
|
712
723
|
)
|
|
@@ -718,6 +729,14 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
718
729
|
executablePath: requestedExecutablePath,
|
|
719
730
|
});
|
|
720
731
|
});
|
|
732
|
+
// Chrome for Testing gets its infobar hidden and a mock keychain unless the
|
|
733
|
+
// caller sets defaultRestrictions; the snapshot keystore warning, the
|
|
734
|
+
// environment and the profile preferences all see them.
|
|
735
|
+
const defaultRestrictions = defaultRestrictionsFor(
|
|
736
|
+
executablePath,
|
|
737
|
+
requestedDefaultRestrictions
|
|
738
|
+
);
|
|
739
|
+
argOptions.restrictions = [...defaultRestrictions, ...restrictions];
|
|
721
740
|
|
|
722
741
|
const temporaryProfile = !requestedUserDataDir;
|
|
723
742
|
const { userDataDir, snapshotLaunch } = await prepareProfile({
|
|
@@ -728,7 +747,7 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
728
747
|
snapshot: dependencies.snapshotUserDataDir,
|
|
729
748
|
firstRun,
|
|
730
749
|
});
|
|
731
|
-
const childEnv = browserEnvironment(restrictions, env);
|
|
750
|
+
const childEnv = browserEnvironment(argOptions.restrictions, env);
|
|
732
751
|
|
|
733
752
|
const { migration, migratedCookies } = await runPreLaunchMigration({
|
|
734
753
|
migrateFrom,
|
|
@@ -745,7 +764,7 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
745
764
|
configureUserDataDir(userDataDir, {
|
|
746
765
|
defaultBrowserCheck,
|
|
747
766
|
preferences: {
|
|
748
|
-
...resolveRestrictions(restrictions).preferences,
|
|
767
|
+
...resolveRestrictions(argOptions.restrictions).preferences,
|
|
749
768
|
...preferences,
|
|
750
769
|
},
|
|
751
770
|
localState,
|
|
@@ -814,6 +833,7 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
814
833
|
userDataDir,
|
|
815
834
|
temporaryProfile,
|
|
816
835
|
args: launched.browserArgs,
|
|
836
|
+
defaultRestrictions,
|
|
817
837
|
...(migration ? { migration } : {}),
|
|
818
838
|
...(snapshotLaunch ? { attach: snapshotLaunch.attach } : {}),
|
|
819
839
|
},
|
package/src/browser/webdriver.js
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
} from './profile-directory.js';
|
|
43
43
|
import { buildRealBrowserArgs } from './real-browser.js';
|
|
44
44
|
import { resolveRestrictions } from './restrictions.js';
|
|
45
|
+
import { defaultRestrictionsFor } from './launch-executable.js';
|
|
45
46
|
import { findBrowserSource } from './browser-sources.js';
|
|
46
47
|
import {
|
|
47
48
|
isSafariChannel,
|
|
@@ -480,13 +481,14 @@ async function teardown({
|
|
|
480
481
|
* @param {boolean} [options.bidi=false] - Enable WebDriver BiDi (events, preload scripts, full-page screenshots)
|
|
481
482
|
* @param {string[]} [options.args] - Extra browser arguments
|
|
482
483
|
* @param {string[]} [options.restrictions] - Chrome launch restrictions (launch-restrictions.json)
|
|
484
|
+
* @param {string[]} [options.defaultRestrictions] - Restrictions that replace the browser's defaults (the 'chrome-for-testing' preset for Chrome for Testing, none otherwise); [] turns them off
|
|
483
485
|
* @param {boolean} [options.automationParity=true] - Keep navigator.webdriver false in headless Chrome
|
|
484
486
|
* @param {Object<string,string>} [options.env] - Extra environment for the driver and browser
|
|
485
487
|
* @param {number} [options.startupTimeout=30000]
|
|
486
488
|
* @param {boolean} [options.verbose=false] - Mirror the driver server's output
|
|
487
489
|
* @param {'managed'|'caller'} [options.interruption='managed'] - Who handles Ctrl+C for the driver server and the browser under it; see `startProcess`
|
|
488
490
|
* @param {Object} [dependencies] - Injected selenium modules, runCommand, startProcess, fetch, reservePort, environment, platform (tests)
|
|
489
|
-
* @returns {Promise<{driver: Object, page: Object, close: function(): Promise<void>, serverUrl: string, driverProcess: Object, driverPath: string, driverSource: string, browser: string, userDataDir: string, temporaryProfile: boolean, args: Array<string>, bidi: boolean}>}
|
|
491
|
+
* @returns {Promise<{driver: Object, page: Object, close: function(): Promise<void>, serverUrl: string, driverProcess: Object, driverPath: string, driverSource: string, browser: string, userDataDir: string, temporaryProfile: boolean, args: Array<string>, bidi: boolean, defaultRestrictions: Array<string>}>}
|
|
490
492
|
*/
|
|
491
493
|
export async function launchWebDriver(options = {}, dependencies = {}) {
|
|
492
494
|
if (!isSafariChannel(options.browser ?? 'chrome')) {
|
|
@@ -550,6 +552,17 @@ function withDriverContext(error, { serverUrl, exitCode, output }) {
|
|
|
550
552
|
);
|
|
551
553
|
}
|
|
552
554
|
|
|
555
|
+
// Chrome for Testing - the engines' download or Selenium Manager's - gets its
|
|
556
|
+
// default restrictions like any other launch of it (issue #179).
|
|
557
|
+
function launchDefaultRestrictions(
|
|
558
|
+
{ browser = 'chrome', debuggerAddress, defaultRestrictions },
|
|
559
|
+
browserPath
|
|
560
|
+
) {
|
|
561
|
+
return browser === 'chrome' && !debuggerAddress
|
|
562
|
+
? defaultRestrictionsFor(browserPath, defaultRestrictions)
|
|
563
|
+
: [];
|
|
564
|
+
}
|
|
565
|
+
|
|
553
566
|
async function launchWebDriverOnce(options, dependencies) {
|
|
554
567
|
const {
|
|
555
568
|
browser = 'chrome',
|
|
@@ -593,12 +606,17 @@ async function launchWebDriverOnce(options, dependencies) {
|
|
|
593
606
|
executablePath,
|
|
594
607
|
});
|
|
595
608
|
|
|
609
|
+
const defaultRestrictions = launchDefaultRestrictions(
|
|
610
|
+
options,
|
|
611
|
+
resolved.browserPath
|
|
612
|
+
);
|
|
613
|
+
const launchRestrictions = [...defaultRestrictions, ...restrictions];
|
|
596
614
|
const temporaryProfile = !safari && !debuggerAddress && !requestedUserDataDir;
|
|
597
615
|
const userDataDir =
|
|
598
616
|
safari || debuggerAddress
|
|
599
617
|
? undefined
|
|
600
618
|
: await resolveProfile(browser, requestedUserDataDir);
|
|
601
|
-
const childEnv = driverEnvironment(
|
|
619
|
+
const childEnv = driverEnvironment(launchRestrictions, env);
|
|
602
620
|
|
|
603
621
|
let driverProcess;
|
|
604
622
|
let driver;
|
|
@@ -636,7 +654,7 @@ async function launchWebDriverOnce(options, dependencies) {
|
|
|
636
654
|
remoteDebuggingPort:
|
|
637
655
|
browser === 'chrome' ? await reservePort() : undefined,
|
|
638
656
|
headless,
|
|
639
|
-
restrictions,
|
|
657
|
+
restrictions: launchRestrictions,
|
|
640
658
|
disableFeatures,
|
|
641
659
|
args,
|
|
642
660
|
automationParity,
|
|
@@ -675,6 +693,7 @@ async function launchWebDriverOnce(options, dependencies) {
|
|
|
675
693
|
temporaryProfile,
|
|
676
694
|
args: built.args,
|
|
677
695
|
bidi,
|
|
696
|
+
defaultRestrictions,
|
|
678
697
|
};
|
|
679
698
|
} catch (error) {
|
|
680
699
|
const exitCode = driverProcess?.exitCode;
|
|
@@ -10,6 +10,7 @@ import { captureStableViewport, validateStableViewport } from './viewport.js';
|
|
|
10
10
|
* @property {string} [path]
|
|
11
11
|
* @property {boolean} [fullPage]
|
|
12
12
|
* @property {boolean} [stableViewport] Capture only the existing view without visual styling.
|
|
13
|
+
* Default captures already do so without a fixed viewport; false keeps the engine capture.
|
|
13
14
|
* @property {string} [selector]
|
|
14
15
|
* @property {Object} [element]
|
|
15
16
|
* @property {{x:number,y:number,width:number,height:number}} [clip]
|
|
@@ -61,7 +62,7 @@ export async function screenshot(options = {}) {
|
|
|
61
62
|
if (options.waitForFonts) {
|
|
62
63
|
await page.evaluate(() => document.fonts.ready.then(() => true));
|
|
63
64
|
}
|
|
64
|
-
if (options
|
|
65
|
+
if (capturesView(options, engine, css)) {
|
|
65
66
|
if (engine === 'playwright') {
|
|
66
67
|
native.scale = scale;
|
|
67
68
|
native.caret = 'initial';
|
|
@@ -123,6 +124,20 @@ export async function screenshot(options = {}) {
|
|
|
123
124
|
}
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
/**
|
|
128
|
+
* A default capture of a page without a fixed viewport takes the view as it
|
|
129
|
+
* is: a clipped engine capture can leave a resized window stuck (issue #180).
|
|
130
|
+
*/
|
|
131
|
+
function capturesView(options, engine, css) {
|
|
132
|
+
const { selector, element, clip, fullPage, omitBackground } = options;
|
|
133
|
+
return (
|
|
134
|
+
options.stableViewport ??
|
|
135
|
+
(['playwright', 'puppeteer'].includes(engine) &&
|
|
136
|
+
![selector, element, clip, fullPage, omitBackground, css].some(Boolean) &&
|
|
137
|
+
(options.scale ?? 'device') === 'device')
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
126
141
|
async function finishScreenshot(
|
|
127
142
|
bytes,
|
|
128
143
|
{ page, engine, scale, format, quality, output }
|
package/src/capture/viewport.js
CHANGED
|
@@ -28,7 +28,8 @@ export async function captureStableViewport(page, engine, native) {
|
|
|
28
28
|
}
|
|
29
29
|
let session;
|
|
30
30
|
if (
|
|
31
|
-
(engine === 'playwright' &&
|
|
31
|
+
(engine === 'playwright' &&
|
|
32
|
+
typeof page.context?.()?.newCDPSession === 'function') ||
|
|
32
33
|
(engine === 'puppeteer' &&
|
|
33
34
|
(typeof page.createCDPSession === 'function' ||
|
|
34
35
|
typeof page.target === 'function'))
|
package/src/exports.js
CHANGED
|
@@ -104,6 +104,10 @@ export {
|
|
|
104
104
|
LAUNCH_MODES,
|
|
105
105
|
resolveLaunchExecutable,
|
|
106
106
|
} from './browser/launcher.js';
|
|
107
|
+
export {
|
|
108
|
+
CHROME_FOR_TESTING_RESTRICTIONS,
|
|
109
|
+
isChromeForTesting,
|
|
110
|
+
} from './browser/launch-executable.js';
|
|
107
111
|
export {
|
|
108
112
|
LAUNCH_RESTRICTIONS,
|
|
109
113
|
LAUNCH_RESTRICTION_PRESETS,
|
package/src/traces/recorder.js
CHANGED
|
@@ -21,6 +21,7 @@ import { startTraceDrain } from './drain-timer.js';
|
|
|
21
21
|
import { attachRecorderObservers } from './observers.js';
|
|
22
22
|
import { attachNetwork, validateNetworkOptions } from './network.js';
|
|
23
23
|
import { startRecording } from '../capture/recording.js';
|
|
24
|
+
import { captureStableViewport } from '../capture/viewport.js';
|
|
24
25
|
import { normalizePrivacyOptions, redactValue } from './redaction.js';
|
|
25
26
|
import path from 'node:path';
|
|
26
27
|
import {
|
|
@@ -47,6 +48,17 @@ const DEFAULT_DOM_OPTIONS = Object.freeze({
|
|
|
47
48
|
openShadowRoots: true,
|
|
48
49
|
});
|
|
49
50
|
|
|
51
|
+
/** The CDP-capable engine a bare page belongs to, or null for any other page. */
|
|
52
|
+
function pageEngine(page) {
|
|
53
|
+
if (typeof page.context === 'function') {
|
|
54
|
+
return 'playwright';
|
|
55
|
+
}
|
|
56
|
+
return typeof page.createCDPSession === 'function' ||
|
|
57
|
+
typeof page.target === 'function'
|
|
58
|
+
? 'puppeteer'
|
|
59
|
+
: null;
|
|
60
|
+
}
|
|
61
|
+
|
|
50
62
|
function normalizeMode(mode) {
|
|
51
63
|
const values = Object.values(TRACE_MODE);
|
|
52
64
|
if (!values.includes(mode)) {
|
|
@@ -275,12 +287,16 @@ export async function startTrace(options = {}) {
|
|
|
275
287
|
return null;
|
|
276
288
|
}
|
|
277
289
|
try {
|
|
290
|
+
// A clipped engine capture can leave a resized headful window drawn at
|
|
291
|
+
// its old size (issue #180); without a fixed viewport, capture the view.
|
|
278
292
|
const shot = await withDeadline(
|
|
279
|
-
page
|
|
293
|
+
captureStableViewport(page, engine ?? pageEngine(page), {
|
|
294
|
+
type: 'png',
|
|
295
|
+
}),
|
|
280
296
|
captureTimeoutMs,
|
|
281
297
|
'trace screenshot'
|
|
282
298
|
);
|
|
283
|
-
return
|
|
299
|
+
return shot.bytes;
|
|
284
300
|
} catch (error) {
|
|
285
301
|
await bundle.drop({
|
|
286
302
|
reason: TRACE_DROP_REASON.CAPTURE_FAILED,
|
|
@@ -44,6 +44,7 @@ it(
|
|
|
44
44
|
async () => {
|
|
45
45
|
const sibling = await launchBrowser({
|
|
46
46
|
debug: false,
|
|
47
|
+
channel: 'chrome',
|
|
47
48
|
headless: true,
|
|
48
49
|
...CHROME_LAUNCH_OPTIONS,
|
|
49
50
|
});
|
|
@@ -58,6 +59,7 @@ it(
|
|
|
58
59
|
launchAndConnectRealBrowserWithDependencies(
|
|
59
60
|
{
|
|
60
61
|
debug: false,
|
|
62
|
+
channel: 'chrome',
|
|
61
63
|
headless: true,
|
|
62
64
|
persistSessionCookies: suppliedProfile,
|
|
63
65
|
...(suppliedProfile ? { userDataDir: root } : {}),
|