browser-commander 0.32.0 → 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.
@@ -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 the installed Chrome
289
- * itself, exactly like a person who wants to attach a debugger would:
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
- if (colorScheme !== undefined || fingerprint !== undefined || downloads) {
392
- throw new Error(
393
- 'selenium does not support CDP emulation, fingerprint overrides or managed downloads (webdriver-no-cdp-emulation)'
394
- );
395
- }
396
- if (launch === 'real' && rest.browser === 'firefox') {
397
- throw new Error("selenium Firefox requires launch: 'engine'");
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,
@@ -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
- if ((options.restrictions ?? []).length > 0) {
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) =>
@@ -141,8 +141,15 @@ export async function configureUserDataDir(
141
141
  ) {
142
142
  throw new TypeError('profileDirectory must be one profile directory name');
143
143
  }
144
+ // Chrome 155 opens a "Continue where you left off" session restore infobar
145
+ // in a new profile until it has been shown 3 times.
144
146
  const profilePreferences = deepMerge(
145
- { browser: { check_default_browser: false } },
147
+ {
148
+ browser: {
149
+ check_default_browser: false,
150
+ session_restore_infobar_times_shown: 5,
151
+ },
152
+ },
146
153
  preferences
147
154
  );
148
155
  assertObject(profilePreferences.browser, 'preferences.browser');
@@ -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 genuine installed Chrome-family browser with a dedicated profile,
379
- * wait for its loopback CDP endpoint, and attach through {@link connectBrowser}.
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='chrome'] - Installed Chrome-family 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 ?? resolveSystemBrowserExecutable;
705
- const launchChannel = attach?.browser ?? channel;
715
+ dependencies.resolveExecutable ??
716
+ ((request) => resolveLaunchExecutable({ engine, ...request }));
706
717
  const executablePath = await Promise.resolve()
707
718
  .then(() =>
708
719
  resolveExecutable({
709
- channel: launchChannel,
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
  },
@@ -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(restrictions, env);
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.stableViewport) {
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 }
@@ -28,7 +28,8 @@ export async function captureStableViewport(page, engine, native) {
28
28
  }
29
29
  let session;
30
30
  if (
31
- (engine === 'playwright' && typeof page.context === 'function') ||
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,
@@ -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.screenshot({ type: 'png' }),
293
+ captureStableViewport(page, engine ?? pageEngine(page), {
294
+ type: 'png',
295
+ }),
280
296
  captureTimeoutMs,
281
297
  'trace screenshot'
282
298
  );
283
- return Buffer.isBuffer(shot) ? shot : Buffer.from(shot);
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 } : {}),