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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.33.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 60fcdc4: Launches without `channel` or `executablePath` start Chrome for Testing (the one Playwright or Puppeteer downloaded, else the newest build in its download cache; Selenium tries the Playwright and Puppeteer caches, then Selenium Manager) instead of the installed Google Chrome, which is used only on request and never as a silent fallback (#179). A Chrome for Testing launch gets the new `chrome-for-testing` restriction preset by default: `no-chrome-for-testing-infobar` (`--test-type=gpu`) and `mock-keychain` (`--use-mock-keychain`); `defaultRestrictions` changes it. New exports: `isChromeForTesting`, `defaultRestrictionsFor`, `CHROME_FOR_TESTING_RESTRICTIONS`.
8
+
9
+ Trace checkpoint screenshots and default screenshots of pages without a fixed viewport use CDP `Page.captureScreenshot` without a clip, so a headful window resized during a capture keeps its real size (#180).
10
+
3
11
  ## 0.32.1
4
12
 
5
13
  ### Patch Changes
package/README.md CHANGED
@@ -390,8 +390,8 @@ see [trace and session hardening](https://github.com/link-foundation/browser-com
390
390
  ```javascript
391
391
  const { browser, page, close } = await launchBrowser({
392
392
  engine: 'playwright', // 'playwright' or 'puppeteer'
393
- launch: 'real', // 'real' (start the installed browser, attach) or 'engine'
394
- channel: 'chrome', // or executablePath; defaults to the installed Chrome
393
+ launch: 'real', // 'real' (start the browser itself, attach) or 'engine'
394
+ channel: 'chrome', // or executablePath; default: Chrome for Testing
395
395
  headless: false, // Run in headless mode
396
396
  userDataDir: undefined, // Keep a profile; default is a fresh temporary one
397
397
  restrictions: [], // Opt-in restrictions such as 'no-extensions', 'no-sync'
@@ -410,6 +410,47 @@ browser and deletes a temporary profile. The result also carries `launch`,
410
410
  `userDataDir`, `temporaryProfile` and the exact `args` the browser was started
411
411
  with.
412
412
 
413
+ #### Chrome for Testing by default
414
+
415
+ Without `channel` or `executablePath`, a launch starts Chrome for Testing - the
416
+ one Playwright or Puppeteer downloaded, or else another build in its download
417
+ cache (`ms-playwright`, moved by `PLAYWRIGHT_BROWSERS_PATH`; `~/.cache/puppeteer`,
418
+ moved by `PUPPETEER_CACHE_DIR`), newest first; Selenium tries both caches, then
419
+ Selenium Manager's - never your own Google Chrome. Chrome for Testing is an app of
420
+ its own (`com.google.chrome.for.testing` on macOS), so the Dock icon, new tabs
421
+ and links opened from other apps keep going to your Chrome. Without it the
422
+ launch fails and names the install command (`npx playwright install chromium`,
423
+ `npx puppeteer browsers install chrome`) and `channel: 'chrome'`, which starts
424
+ the installed Google Chrome instead. It is never a silent fallback.
425
+
426
+ A Chrome for Testing launch gets the `chrome-for-testing` restriction preset
427
+ before your own `restrictions`: `no-chrome-for-testing-infobar`
428
+ (`--test-type=gpu`, the only value that hides the "Chrome for Testing is only
429
+ for automated testing" bar) and `mock-keychain` (`--use-mock-keychain`, no
430
+ keychain prompt at each start). Other browsers get nothing. `defaultRestrictions`
431
+ replaces that choice (`[]` opts out) and the result reports what was applied:
432
+
433
+ ```javascript
434
+ import { isChromeForTesting, launchBrowser } from 'browser-commander';
435
+
436
+ const { defaultRestrictions, close } = await launchBrowser({});
437
+ console.log(defaultRestrictions); // ['no-chrome-for-testing-infobar', 'mock-keychain']
438
+ await close();
439
+
440
+ // Keep the infobar, or start Google Chrome on purpose:
441
+ await launchBrowser({ defaultRestrictions: [] });
442
+ await launchBrowser({ channel: 'chrome' });
443
+ isChromeForTesting(
444
+ '/home/me/.cache/ms-playwright/chromium-1248/chrome-linux64/chrome'
445
+ ); // true
446
+ ```
447
+
448
+ `launchRealBrowser()` and `launchWebDriver()` take the same
449
+ `defaultRestrictions`. With the mock keychain, Chrome for Testing cannot decrypt
450
+ cookies another Chrome wrote into a copied profile; move logins with
451
+ `readBrowserCookies()` or `storageState`, which set cookies through the
452
+ browser's cookie API instead.
453
+
413
454
  The `args` option allows passing custom Chrome arguments, which is useful for headless server environments (Docker, CI/CD) that require flags like `--no-sandbox`.
414
455
 
415
456
  The `storageState` option accepts a Playwright-compatible JSON path or object.
@@ -439,11 +480,13 @@ Playwright accepts `timeout` and Puppeteer accepts `protocolTimeout`.
439
480
 
440
481
  ### launchRealBrowser(options)
441
482
 
442
- Start an installed browser and connect through `connectBrowser()`. Use
483
+ Start a browser and connect through `connectBrowser()`. Without `channel` or
484
+ `executablePath` it starts Chrome for Testing, as `launchBrowser()` does; use
443
485
  `channel` (`chrome`, `chrome-beta`, `chrome-dev`, `chrome-canary`, `msedge`,
444
486
  `msedge-beta`, `msedge-dev`, `msedge-canary`, `brave`, or `chromium`) or an
445
- explicit `executablePath`. Without `userDataDir` it creates a fresh temporary
446
- profile that `close()` deletes; it rejects known default browser-profile paths,
487
+ explicit `executablePath` for an installed browser. Without `userDataDir` it
488
+ creates a fresh temporary profile that `close()` deletes; it rejects known
489
+ default browser-profile paths,
447
490
  protects its remote-debugging switches, reserves a loopback port (or uses
448
491
  `remoteDebuggingPort`, never `0`), and returns the spawned `browserProcess`,
449
492
  `cdpEndpoint`, `remoteDebuggingPort`, `executablePath`, `userDataDir`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-commander",
3
- "version": "0.32.1",
3
+ "version": "0.33.0",
4
4
  "description": "Universal browser automation library that supports Playwright, Puppeteer, and Selenium with a unified API",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -0,0 +1,198 @@
1
+ import { readdirSync, statSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * Finding Chrome for Testing in the download caches (issue #179).
7
+ *
8
+ * The engine's own `executablePath()` names one exact build; when that build
9
+ * is not installed, a launch still starts another Chrome for Testing the
10
+ * Playwright, Puppeteer or Selenium Manager caches hold, newest version
11
+ * first. Mirrors `CacheLocations` in `rust/src/browser/chrome_for_testing.rs`.
12
+ */
13
+
14
+ /** The caches each engine finds Chrome for Testing in, in order. */
15
+ export const ENGINE_CACHES = Object.freeze({
16
+ playwright: ['playwright'],
17
+ puppeteer: ['puppeteer'],
18
+ selenium: ['playwright', 'puppeteer', 'selenium'],
19
+ });
20
+
21
+ // name: archive and Selenium cache platform; puppeteer: Puppeteer's prefix.
22
+ const PLATFORMS = {
23
+ 'linux-x64': { name: 'linux64', puppeteer: 'linux' },
24
+ 'darwin-x64': { name: 'mac-x64', puppeteer: 'mac' },
25
+ 'darwin-arm64': { name: 'mac-arm64', puppeteer: 'mac_arm' },
26
+ 'win32-x64': { name: 'win64', puppeteer: 'win64' },
27
+ 'win32-ia32': { name: 'win32', puppeteer: 'win32' },
28
+ };
29
+
30
+ /**
31
+ * Where the caches are on this machine, or `undefined` where Chrome for
32
+ * Testing is not built for the platform.
33
+ *
34
+ * @param {Object} [machine]
35
+ * @param {string} [machine.platform] - `process.platform`
36
+ * @param {string} [machine.arch] - `process.arch`
37
+ * @param {string} [machine.home] - The home directory
38
+ * @param {Object} [machine.env] - The environment variables
39
+ */
40
+ export function cacheLocations({
41
+ platform = process.platform,
42
+ arch = process.arch,
43
+ home = os.homedir(),
44
+ env = process.env,
45
+ } = {}) {
46
+ const known = PLATFORMS[platformKey(platform, arch)];
47
+ return known ? { platform, ...known, home, env } : undefined;
48
+ }
49
+
50
+ function platformKey(platform, arch) {
51
+ if (platform === 'darwin') {
52
+ return arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64';
53
+ }
54
+ if (platform === 'win32') {
55
+ return arch === 'ia32' ? 'win32-ia32' : 'win32-x64';
56
+ }
57
+ return `${platform}-${arch}`;
58
+ }
59
+
60
+ function variable(locations, name) {
61
+ return locations.env[name] || undefined;
62
+ }
63
+
64
+ function homePath(locations, ...relative) {
65
+ return locations.home ? path.join(locations.home, ...relative) : undefined;
66
+ }
67
+
68
+ const ROOTS = {
69
+ playwright(locations) {
70
+ const root = locations.env.PLAYWRIGHT_BROWSERS_PATH;
71
+ if (root) {
72
+ // `0` keeps the browsers inside node_modules, where only the
73
+ // Playwright package itself knows the path.
74
+ return root === '0' ? undefined : root;
75
+ }
76
+ if (locations.platform === 'win32') {
77
+ const local = variable(locations, 'LOCALAPPDATA');
78
+ return local && path.join(local, 'ms-playwright');
79
+ }
80
+ if (locations.platform === 'darwin') {
81
+ return homePath(locations, 'Library', 'Caches', 'ms-playwright');
82
+ }
83
+ const cache = variable(locations, 'XDG_CACHE_HOME');
84
+ return cache
85
+ ? path.join(cache, 'ms-playwright')
86
+ : homePath(locations, '.cache', 'ms-playwright');
87
+ },
88
+ puppeteer: (locations) =>
89
+ variable(locations, 'PUPPETEER_CACHE_DIR') ??
90
+ homePath(locations, '.cache', 'puppeteer'),
91
+ selenium: (locations) =>
92
+ variable(locations, 'SE_CACHE_PATH') ??
93
+ homePath(locations, '.cache', 'selenium'),
94
+ };
95
+
96
+ /** The binary inside an unpacked Chrome for Testing directory. */
97
+ function binary(locations) {
98
+ if (locations.platform === 'darwin') {
99
+ return path.join(
100
+ 'Google Chrome for Testing.app',
101
+ 'Contents',
102
+ 'MacOS',
103
+ 'Google Chrome for Testing'
104
+ );
105
+ }
106
+ return locations.platform === 'win32' ? 'chrome.exe' : 'chrome';
107
+ }
108
+
109
+ /** A dotted version (`131.0.6778.204`) or revision (`1248`) as numbers. */
110
+ function versionKey(text) {
111
+ return text !== undefined && /^\d+(\.\d+)*$/u.test(text)
112
+ ? text.split('.').map(Number)
113
+ : undefined;
114
+ }
115
+
116
+ function compareVersions(left, right) {
117
+ for (let index = 0; index < Math.max(left.length, right.length); index++) {
118
+ const difference = (left[index] ?? -1) - (right[index] ?? -1);
119
+ if (difference !== 0) {
120
+ return difference;
121
+ }
122
+ }
123
+ return 0;
124
+ }
125
+
126
+ function isDirectory(file) {
127
+ try {
128
+ return statSync(file).isDirectory();
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+
134
+ /** The directories in `root` whose name `key` parses, newest first. */
135
+ function newestFirst(root, key) {
136
+ let names;
137
+ try {
138
+ names = readdirSync(root);
139
+ } catch {
140
+ return [];
141
+ }
142
+ return names
143
+ .map((name) => ({ version: key(name), directory: path.join(root, name) }))
144
+ .filter(({ version, directory }) => version && isDirectory(directory))
145
+ .sort((left, right) => compareVersions(right.version, left.version))
146
+ .map(({ directory }) => directory);
147
+ }
148
+
149
+ const withPrefix = (prefix) => (name) =>
150
+ name.startsWith(prefix) ? versionKey(name.slice(prefix.length)) : undefined;
151
+
152
+ /**
153
+ * The Chrome for Testing binaries `cache` may hold, newest version first.
154
+ * The paths are candidates: the caller checks they exist.
155
+ *
156
+ * @param {'playwright'|'puppeteer'|'selenium'} cache
157
+ * @param {Object} locations - From {@link cacheLocations}
158
+ * @returns {string[]}
159
+ */
160
+ export function chromeForTestingCandidates(cache, locations) {
161
+ const root = ROOTS[cache](locations);
162
+ if (!root) {
163
+ return [];
164
+ }
165
+ const archive = `chrome-${locations.name}`;
166
+ const file = binary(locations);
167
+ if (cache === 'selenium') {
168
+ return newestFirst(
169
+ path.join(root, 'chrome', locations.name),
170
+ versionKey
171
+ ).map((directory) => path.join(directory, file));
172
+ }
173
+ const directories =
174
+ cache === 'playwright'
175
+ ? newestFirst(root, withPrefix('chromium-'))
176
+ : newestFirst(
177
+ path.join(root, 'chrome'),
178
+ withPrefix(`${locations.puppeteer}-`)
179
+ );
180
+ return directories.map((directory) => path.join(directory, archive, file));
181
+ }
182
+
183
+ /**
184
+ * The Chrome for Testing candidates in the caches `engine` uses, in order.
185
+ *
186
+ * @param {string} engine
187
+ * @param {Object} [machine] - See {@link cacheLocations}
188
+ * @returns {string[]}
189
+ */
190
+ export function cachedChromeForTesting(engine, machine) {
191
+ const locations = cacheLocations(machine);
192
+ if (!locations) {
193
+ return [];
194
+ }
195
+ return (ENGINE_CACHES[engine] ?? []).flatMap((cache) =>
196
+ chromeForTestingCandidates(cache, locations)
197
+ );
198
+ }
@@ -0,0 +1,203 @@
1
+ import { constants } from 'node:fs';
2
+ import { access } from 'node:fs/promises';
3
+ import { cachedChromeForTesting } from './chrome-for-testing-cache.js';
4
+ import { LAUNCH_RESTRICTION_PRESETS } from './restrictions.js';
5
+ import { resolveSystemBrowserExecutable } from './system-browser.js';
6
+
7
+ /**
8
+ * Which browser binary an automation launch starts (issue #179).
9
+ *
10
+ * Without `channel` or `executablePath` a launch starts Chrome for Testing,
11
+ * the browser Playwright and Puppeteer download. It is an application of its
12
+ * own (`com.google.chrome.for.testing` on macOS), so the user's Google Chrome
13
+ * - its Dock icon, its new tabs, the links other apps open - never goes to the
14
+ * automation window. The installed Google Chrome is used only on request.
15
+ */
16
+
17
+ /** Load the browser launcher of each engine that downloads a browser. */
18
+ export const ENGINE_BROWSERS = Object.freeze({
19
+ playwright: async () => (await import('playwright')).chromium,
20
+ puppeteer: async () => (await import('puppeteer')).default,
21
+ });
22
+
23
+ // Selenium has no browser of its own; it starts the one Playwright or
24
+ // Puppeteer downloaded, then the one Selenium Manager keeps.
25
+ const BUNDLED_BROWSER_ENGINES = {
26
+ playwright: ['playwright'],
27
+ puppeteer: ['puppeteer'],
28
+ selenium: ['playwright', 'puppeteer'],
29
+ };
30
+
31
+ const PLAYWRIGHT_INSTALL = 'npx playwright install chromium';
32
+ const PUPPETEER_INSTALL = 'npx puppeteer browsers install chrome';
33
+ const INSTALL_COMMANDS = {
34
+ playwright: [PLAYWRIGHT_INSTALL],
35
+ puppeteer: [PUPPETEER_INSTALL],
36
+ selenium: [
37
+ PLAYWRIGHT_INSTALL,
38
+ PUPPETEER_INSTALL,
39
+ 'selenium-manager --browser chrome --force-browser-download',
40
+ ],
41
+ };
42
+
43
+ // Chrome for Testing archives unpack to chrome-<platform>; the macOS app is
44
+ // "Google Chrome for Testing.app". Playwright's older own Chromium builds
45
+ // (chrome-linux, chrome-win, Chromium.app) are not Chrome for Testing.
46
+ const CHROME_FOR_TESTING_DIRECTORIES = new Set([
47
+ 'chrome-linux64',
48
+ 'chrome-win64',
49
+ 'chrome-win32',
50
+ 'chrome-mac-x64',
51
+ 'chrome-mac-arm64',
52
+ ]);
53
+ const SELENIUM_CACHE_PLATFORMS = new Set([
54
+ 'linux64',
55
+ 'win64',
56
+ 'win32',
57
+ 'mac-x64',
58
+ 'mac-arm64',
59
+ ]);
60
+
61
+ /** The restrictions every Chrome for Testing launch gets by default. */
62
+ export const CHROME_FOR_TESTING_RESTRICTIONS =
63
+ LAUNCH_RESTRICTION_PRESETS['chrome-for-testing'];
64
+
65
+ /**
66
+ * Whether an executable is a Chrome for Testing build, judged by its install
67
+ * path: Playwright, Puppeteer, `@puppeteer/browsers` and Selenium Manager all
68
+ * keep it under a Chrome for Testing archive directory.
69
+ *
70
+ * @param {string} [executablePath]
71
+ * @returns {boolean}
72
+ */
73
+ export function isChromeForTesting(executablePath) {
74
+ if (typeof executablePath !== 'string') {
75
+ return false;
76
+ }
77
+ const segments = executablePath.toLowerCase().split(/[\\/]+/u);
78
+ if (
79
+ segments.some(
80
+ (segment) =>
81
+ segment.includes('chrome for testing') ||
82
+ CHROME_FOR_TESTING_DIRECTORIES.has(segment)
83
+ )
84
+ ) {
85
+ return true;
86
+ }
87
+ // Selenium Manager: ~/.cache/selenium/chrome/<platform>/<version>/chrome
88
+ const selenium = segments.lastIndexOf('selenium');
89
+ return (
90
+ selenium >= 0 &&
91
+ segments[selenium + 1] === 'chrome' &&
92
+ SELENIUM_CACHE_PLATFORMS.has(segments[selenium + 2])
93
+ );
94
+ }
95
+
96
+ /**
97
+ * The restrictions a launch of `executablePath` gets unless the caller sets
98
+ * `defaultRestrictions`: the `chrome-for-testing` preset for Chrome for
99
+ * Testing, nothing for any other browser.
100
+ *
101
+ * @param {string} executablePath
102
+ * @param {string[]} [requested] - The caller's `defaultRestrictions`
103
+ * @returns {string[]}
104
+ */
105
+ export function defaultRestrictionsFor(executablePath, requested) {
106
+ if (requested !== undefined) {
107
+ return requested;
108
+ }
109
+ return isChromeForTesting(executablePath)
110
+ ? [...CHROME_FOR_TESTING_RESTRICTIONS]
111
+ : [];
112
+ }
113
+
114
+ async function isExecutable(file) {
115
+ try {
116
+ await access(file, constants.X_OK);
117
+ return true;
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * The Chrome for Testing Selenium Manager keeps in `~/.cache/selenium`,
125
+ * downloaded when missing. `--force-browser-download` stops it from picking
126
+ * the installed Google Chrome instead.
127
+ *
128
+ * @returns {Promise<string|undefined>}
129
+ */
130
+ export async function seleniumManagerChromeForTesting() {
131
+ // Imported here: webdriver.js imports this module.
132
+ const { seleniumManagerPath } = await import('./webdriver.js');
133
+ const { runCommand } = await import('../utilities/subprocess.js');
134
+ const { stdout, code } = await runCommand(
135
+ seleniumManagerPath(),
136
+ ['--browser', 'chrome', '--force-browser-download', '--output', 'json'],
137
+ { check: false }
138
+ );
139
+ return code === 0 ? JSON.parse(stdout)?.result?.browser_path : undefined;
140
+ }
141
+
142
+ async function existingExecutable(find) {
143
+ const executable = await Promise.resolve()
144
+ .then(find)
145
+ .catch(() => undefined);
146
+ return executable && (await isExecutable(executable))
147
+ ? executable
148
+ : undefined;
149
+ }
150
+
151
+ /**
152
+ * Pick the browser binary for a launch.
153
+ *
154
+ * An explicit `executablePath` or `channel` is honoured as given. Without
155
+ * either, the engine's downloaded Chrome for Testing is started, then any
156
+ * other build in its download cache, newest first; Selenium uses Playwright's,
157
+ * then Puppeteer's, then the cached ones, then Selenium Manager's. The
158
+ * installed Google Chrome is never a silent fallback: without Chrome for
159
+ * Testing the launch fails and says how to install it or how to ask for
160
+ * Google Chrome.
161
+ */
162
+ export async function resolveLaunchExecutable(
163
+ { engine, channel, executablePath },
164
+ {
165
+ resolveSystem = resolveSystemBrowserExecutable,
166
+ loadEngineModule,
167
+ seleniumManagerBrowser = seleniumManagerChromeForTesting,
168
+ cachedBrowsers = cachedChromeForTesting,
169
+ } = {}
170
+ ) {
171
+ if (executablePath !== undefined || channel !== undefined) {
172
+ return await resolveSystem({
173
+ channel: channel ?? 'chrome',
174
+ executablePath,
175
+ });
176
+ }
177
+ const engines = BUNDLED_BROWSER_ENGINES[engine] ?? [];
178
+ const loaders = loadEngineModule
179
+ ? [loadEngineModule]
180
+ : engines.map((name) => ENGINE_BROWSERS[name]);
181
+ const finders = loaders.map(
182
+ (load) => async () => (await load()).executablePath()
183
+ );
184
+ const cached = await Promise.resolve()
185
+ .then(() => cachedBrowsers(engine))
186
+ .catch(() => []);
187
+ finders.push(...cached.map((executable) => () => executable));
188
+ if (engine === 'selenium') {
189
+ finders.push(seleniumManagerBrowser);
190
+ }
191
+ for (const find of finders) {
192
+ const executable = await existingExecutable(find);
193
+ if (executable) {
194
+ return executable;
195
+ }
196
+ }
197
+ const install = (INSTALL_COMMANDS[engine] ?? [])
198
+ .map((command) => `\`${command}\``)
199
+ .join(' or ');
200
+ throw new Error(
201
+ `Chrome for Testing is not installed for ${engine}: run ${install}, or pass channel: 'chrome' to start the installed Google Chrome`
202
+ );
203
+ }
@@ -32,9 +32,14 @@
32
32
  },
33
33
  {
34
34
  "id": "mock-keychain",
35
- "description": "Use a mock macOS Keychain. Real-profile data cannot be decrypted or migrated.",
35
+ "description": "Use a mock macOS Keychain. Real-profile data cannot be decrypted or migrated. Added by default for Chrome for Testing, which otherwise asks for its keychain entry at each start.",
36
36
  "args": ["--use-mock-keychain"]
37
37
  },
38
+ {
39
+ "id": "no-chrome-for-testing-infobar",
40
+ "description": "Hide the \"Chrome for Testing is only for automated testing\" bar; Chromium skips it only for --test-type=gpu, which also skips the unsupported-flag and other startup infobars. Added by default for Chrome for Testing.",
41
+ "args": ["--test-type=gpu"]
42
+ },
38
43
  {
39
44
  "id": "no-extensions",
40
45
  "description": "Disable extensions, component extensions with background pages and default apps.",
@@ -167,6 +172,7 @@
167
172
  "no-google-services",
168
173
  "no-translate"
169
174
  ],
175
+ "chrome-for-testing": ["no-chrome-for-testing-infobar", "mock-keychain"],
170
176
  "quiet-ui": [
171
177
  "no-first-run",
172
178
  "no-default-browser-check",