browser-commander 0.19.0 → 0.21.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 (155) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/README.md +216 -32
  3. package/bin/browser-commander.js +16 -0
  4. package/eslint.config.js +10 -0
  5. package/examples/react-test-app/src/App.jsx +24 -23
  6. package/extension/background.js +14 -0
  7. package/extension/manifest.json +12 -0
  8. package/extension/relay-handler.js +353 -0
  9. package/package.json +25 -6
  10. package/scripts/run-tests.mjs +4 -2
  11. package/src/browser/attach/differences.js +131 -0
  12. package/src/browser/attach/extension-relay.js +402 -0
  13. package/src/browser/attach/index.js +81 -0
  14. package/src/browser/attach/snapshot-launch.js +146 -0
  15. package/src/browser/attach/snapshot.js +374 -0
  16. package/src/browser/attach/websocket.js +615 -0
  17. package/src/browser/browser-cookie-cache.js +12 -17
  18. package/src/browser/browser-cookie-credentials.js +6 -9
  19. package/src/browser/browser-cookies.js +5 -2
  20. package/src/browser/cdp-session.js +94 -0
  21. package/src/browser/connector.js +42 -4
  22. package/src/browser/debugging-port.js +155 -0
  23. package/src/browser/launch-options.js +63 -52
  24. package/src/browser/launch-restrictions.json +147 -0
  25. package/src/browser/launcher.js +329 -133
  26. package/src/browser/media.js +5 -0
  27. package/src/browser/migration/bookmarks.js +73 -0
  28. package/src/browser/migration/chromium-crypto.js +89 -0
  29. package/src/browser/migration/cookies.js +155 -0
  30. package/src/browser/migration/extensions.js +187 -0
  31. package/src/browser/migration/firefox-bookmarks.js +144 -0
  32. package/src/browser/migration/firefox-der.js +84 -0
  33. package/src/browser/migration/firefox-nss.js +281 -0
  34. package/src/browser/migration/firefox.js +334 -0
  35. package/src/browser/migration/fs-utils.js +51 -0
  36. package/src/browser/migration/history.js +103 -0
  37. package/src/browser/migration/index.js +307 -0
  38. package/src/browser/migration/os-crypt-keys.js +106 -0
  39. package/src/browser/migration/passwords.js +171 -0
  40. package/src/browser/migration/preferences.js +132 -0
  41. package/src/browser/migration/sqlite-snapshot.js +138 -0
  42. package/src/browser/open-in-user-browser.js +112 -0
  43. package/src/browser/parity.js +439 -0
  44. package/src/browser/profile-directory.js +102 -0
  45. package/src/browser/real-browser.js +513 -306
  46. package/src/browser/restrictions.js +148 -0
  47. package/src/browser/system-browser.js +232 -0
  48. package/src/browser/webdriver.js +572 -0
  49. package/src/cli/args.js +253 -0
  50. package/src/cli/commands.js +332 -0
  51. package/src/cli/dispatcher.js +99 -0
  52. package/src/cli/handle-methods.js +136 -0
  53. package/src/cli/handles.js +273 -0
  54. package/src/cli/high-level-methods.js +310 -0
  55. package/src/cli/main.js +67 -0
  56. package/src/cli/modules.js +100 -0
  57. package/src/cli/rpc-error.js +77 -0
  58. package/src/cli/script.js +77 -0
  59. package/src/cli/serve.js +144 -0
  60. package/src/cli/sessions.js +195 -0
  61. package/src/core/constants.js +7 -1
  62. package/src/core/engine-adapter.js +107 -1
  63. package/src/core/engine-detection.js +18 -3
  64. package/src/core/navigation-safety.js +4 -0
  65. package/src/core/webdriver-conversions.js +347 -0
  66. package/src/core/webdriver-events.js +229 -0
  67. package/src/core/webdriver-page.js +716 -0
  68. package/src/downloads/attach.js +6 -0
  69. package/src/downloads/manager.js +9 -0
  70. package/src/downloads/sources.js +71 -0
  71. package/src/downloads/staging.js +21 -9
  72. package/src/elements/selectors.js +3 -2
  73. package/src/elements/visibility.js +4 -0
  74. package/src/exports.js +61 -2
  75. package/src/factory.js +19 -4
  76. package/src/fingerprint/apply.js +6 -0
  77. package/src/fingerprint/automation-parity.js +7 -8
  78. package/src/fingerprint/limitations.js +1 -1
  79. package/src/fingerprint/limitations.json +45 -2
  80. package/src/parity/harness.js +303 -0
  81. package/src/parity/probe.js +838 -0
  82. package/src/parity/version-page.js +144 -0
  83. package/src/traces/bundle.js +10 -2
  84. package/src/traces/recorder.js +16 -3
  85. package/src/utilities/subprocess.js +199 -0
  86. package/tests/e2e/api-coverage.e2e.test.js +174 -0
  87. package/tests/e2e/cli.e2e.test.js +160 -0
  88. package/tests/e2e/downloads.e2e.test.js +79 -2
  89. package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
  90. package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
  91. package/tests/e2e/measure-parity.e2e.test.js +118 -0
  92. package/tests/e2e/playwright.e2e.test.js +25 -17
  93. package/tests/e2e/puppeteer.e2e.test.js +17 -23
  94. package/tests/e2e/webdriver.e2e.test.js +239 -0
  95. package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
  96. package/tests/helpers/cli-fakes.js +214 -0
  97. package/tests/helpers/declared-api.js +80 -0
  98. package/tests/helpers/e2e-browser.js +40 -14
  99. package/tests/helpers/fake-chrome.js +124 -0
  100. package/tests/helpers/migration-fixtures.js +242 -0
  101. package/tests/helpers/temp-directory.js +24 -0
  102. package/tests/helpers/trace-fixtures.js +18 -0
  103. package/tests/helpers/webdriver-mocks.js +183 -0
  104. package/tests/unit/browser/attach/extension-relay.test.js +360 -0
  105. package/tests/unit/browser/attach/snapshot-launch.test.js +312 -0
  106. package/tests/unit/browser/attach/snapshot.test.js +390 -0
  107. package/tests/unit/browser/attach/websocket.test.js +240 -0
  108. package/tests/unit/browser/browser-cookies.test.js +2 -32
  109. package/tests/unit/browser/cdp-session.test.js +162 -0
  110. package/tests/unit/browser/connector.test.js +23 -0
  111. package/tests/unit/browser/launch-building-blocks.test.js +238 -0
  112. package/tests/unit/browser/launch-options.test.js +41 -22
  113. package/tests/unit/browser/launcher.test.js +281 -102
  114. package/tests/unit/browser/media.test.js +13 -1
  115. package/tests/unit/browser/migration/bookmarks.test.js +69 -0
  116. package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
  117. package/tests/unit/browser/migration/cookies.test.js +94 -0
  118. package/tests/unit/browser/migration/extensions.test.js +104 -0
  119. package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
  120. package/tests/unit/browser/migration/firefox.test.js +139 -0
  121. package/tests/unit/browser/migration/history.test.js +48 -0
  122. package/tests/unit/browser/migration/index.test.js +117 -0
  123. package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
  124. package/tests/unit/browser/migration/passwords.test.js +144 -0
  125. package/tests/unit/browser/migration/preferences.test.js +77 -0
  126. package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
  127. package/tests/unit/browser/open-in-user-browser.test.js +123 -0
  128. package/tests/unit/browser/parity.test.js +281 -0
  129. package/tests/unit/browser/real-browser.test.js +458 -33
  130. package/tests/unit/browser/webdriver.test.js +473 -0
  131. package/tests/unit/cli/bin.test.js +69 -0
  132. package/tests/unit/cli/declared-api.test.js +49 -0
  133. package/tests/unit/cli/dispatcher.test.js +429 -0
  134. package/tests/unit/cli/handles.test.js +170 -0
  135. package/tests/unit/cli/main.test.js +409 -0
  136. package/tests/unit/cli/serve.test.js +167 -0
  137. package/tests/unit/core/webdriver-conversions.test.js +235 -0
  138. package/tests/unit/core/webdriver-page.test.js +534 -0
  139. package/tests/unit/downloads/manager.test.js +82 -9
  140. package/tests/unit/downloads/staging.test.js +24 -2
  141. package/tests/unit/fingerprint/automation-parity.test.js +17 -4
  142. package/tests/unit/fingerprint/limitations.test.js +1 -1
  143. package/tests/unit/parity/harness.test.js +138 -0
  144. package/tests/unit/scripts/ci-timeout-budgets.test.js +4 -0
  145. package/tests/unit/scripts/ci-workflow-policy.test.js +8 -0
  146. package/tests/unit/scripts/feature-parity.test.js +221 -0
  147. package/tests/unit/scripts/npm-release-helpers.test.js +3 -2
  148. package/tests/unit/scripts/pre-commit-config.test.js +2 -1
  149. package/tests/unit/traces/bundle.test.js +24 -3
  150. package/tests/unit/traces/links.test.js +7 -0
  151. package/tests/unit/traces/reader.test.js +11 -4
  152. package/tests/unit/traces/recorder.test.js +4 -0
  153. package/tests/unit/traces/viewer.test.js +4 -0
  154. package/tests/unit/utilities/command-stream-only.test.js +28 -0
  155. package/tests/unit/utilities/subprocess.test.js +80 -0
@@ -92,7 +92,8 @@ function readChromiumRows(database, domainFilter) {
92
92
  );
93
93
  }
94
94
 
95
- function mapFirefoxRows(rows) {
95
+ /** Map `moz_cookies` rows to cookies; profile migration reuses this too. */
96
+ export function mapFirefoxCookieRows(rows) {
96
97
  return rows.map((row) => ({
97
98
  name: row.name,
98
99
  value: row.value,
@@ -306,7 +307,9 @@ export async function readBrowserCookiesWithDependencies(
306
307
  let cookies;
307
308
  try {
308
309
  if (browser === 'firefox') {
309
- cookies = mapFirefoxRows(readFirefoxRows(database, options.domainFilter));
310
+ cookies = mapFirefoxCookieRows(
311
+ readFirefoxRows(database, options.domainFilter)
312
+ );
310
313
  } else {
311
314
  const databaseVersion = readDatabaseVersion(database);
312
315
  const rows = readChromiumRows(database, options.domainFilter);
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Raw Chrome DevTools Protocol sessions with one surface for both engines
3
+ * (issue #104).
4
+ *
5
+ * Rust has chromiumoxide and raw CDP; this module gives JavaScript the same
6
+ * `cdpSession` API: `send(method, params)`, `on(event, fn)`, `off(event, fn)`
7
+ * and `detach()`, whether the page belongs to Playwright or Puppeteer.
8
+ */
9
+ import { detectEngine } from '../core/engine-detection.js';
10
+ import { createCdpSession as openEngineCdpSession } from '../fingerprint/apply.js';
11
+
12
+ /**
13
+ * Wrap an engine CDP session in the uniform surface.
14
+ *
15
+ * @param {Object} session - Playwright or Puppeteer `CDPSession`
16
+ * @param {'playwright'|'puppeteer'} engine
17
+ * @returns {CdpSession}
18
+ */
19
+ export function wrapCdpSession(session, engine) {
20
+ let detached;
21
+ const wrapper = {
22
+ engine,
23
+ /** The engine's own `CDPSession`, for anything not covered here. */
24
+ session,
25
+ /** Send a CDP command and resolve with its result. */
26
+ send: (method, params = {}) => session.send(method, params),
27
+ /** Listen to a CDP event, such as `Network.requestWillBeSent`. */
28
+ on(event, listener) {
29
+ session.on(event, listener);
30
+ return wrapper;
31
+ },
32
+ /** Listen to one occurrence of a CDP event. */
33
+ once(event, listener) {
34
+ session.once(event, listener);
35
+ return wrapper;
36
+ },
37
+ /** Stop listening to a CDP event. */
38
+ off(event, listener) {
39
+ session.off(event, listener);
40
+ return wrapper;
41
+ },
42
+ /** Detach the session; calling it again is a no-op. */
43
+ detach() {
44
+ detached ??= Promise.resolve(session.detach());
45
+ return detached;
46
+ },
47
+ };
48
+ return wrapper;
49
+ }
50
+
51
+ function isLegacyOptions(value) {
52
+ return (
53
+ value !== null &&
54
+ typeof value === 'object' &&
55
+ 'page' in value &&
56
+ typeof value.goto !== 'function'
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Open a raw CDP session for a page.
62
+ *
63
+ * Playwright opens it with `page.context().newCDPSession(page)`, Puppeteer
64
+ * with `page.createCDPSession()`. Both are returned with the same methods.
65
+ * The older `createCdpSession({browser, page, engine})` form is accepted too.
66
+ *
67
+ * @param {Object} page - Playwright or Puppeteer page
68
+ * @param {Object} [options]
69
+ * @param {'playwright'|'puppeteer'} [options.engine] - Detected from the page when omitted
70
+ * @param {Object} [options.browser] - Playwright context to open the session on
71
+ * @returns {Promise<CdpSession>}
72
+ *
73
+ * @typedef {Object} CdpSession
74
+ * @property {string} engine
75
+ * @property {Object} session
76
+ * @property {function(string, Object=): Promise<Object>} send
77
+ * @property {function(string, Function): CdpSession} on
78
+ * @property {function(string, Function): CdpSession} once
79
+ * @property {function(string, Function): CdpSession} off
80
+ * @property {function(): Promise<void>} detach
81
+ */
82
+ export async function createCdpSession(page, options = {}) {
83
+ const request = isLegacyOptions(page) ? page : { ...options, page };
84
+ if (!request.page) {
85
+ throw new Error('createCdpSession requires a page');
86
+ }
87
+ const engine = request.engine ?? detectEngine(request.page);
88
+ const session = await openEngineCdpSession({
89
+ browser: request.browser,
90
+ page: request.page,
91
+ engine,
92
+ });
93
+ return wrapCdpSession(session, engine);
94
+ }
@@ -5,12 +5,21 @@ import {
5
5
  restorePuppeteerStorageState,
6
6
  } from './storage-state.js';
7
7
 
8
- function validateConnectionOptions({ engine, cdpEndpoint, wsEndpoint }) {
8
+ /**
9
+ * Throw unless `engine` is one Browser Commander drives.
10
+ *
11
+ * @param {string} engine
12
+ */
13
+ export function assertSupportedEngine(engine) {
9
14
  if (!['playwright', 'puppeteer'].includes(engine)) {
10
15
  throw new Error(
11
16
  `Invalid engine: ${engine}. Expected 'playwright' or 'puppeteer'`
12
17
  );
13
18
  }
19
+ }
20
+
21
+ function validateConnectionOptions({ engine, cdpEndpoint, wsEndpoint }) {
22
+ assertSupportedEngine(engine);
14
23
 
15
24
  if (Boolean(cdpEndpoint) === Boolean(wsEndpoint)) {
16
25
  throw new Error(
@@ -66,6 +75,33 @@ async function prepareStorageState({ storageState, seedCookies }) {
66
75
  };
67
76
  }
68
77
 
78
+ /**
79
+ * Pick the tab that is on screen.
80
+ *
81
+ * A browser attached after it started can already have several tabs - a fresh
82
+ * Chrome profile opens "What's new" next to the New Tab page - and neither
83
+ * engine lists them in a stable order. Driving a background tab would make
84
+ * `document.hidden` true where a person's first navigation would see a visible
85
+ * page, so the visible tab wins and the first tab is the fallback.
86
+ *
87
+ * @param {Object[]} pages - Engine page handles
88
+ * @returns {Promise<Object|undefined>}
89
+ */
90
+ export async function pickForegroundPage(pages) {
91
+ for (const page of pages) {
92
+ if (typeof page?.evaluate !== 'function') {
93
+ continue;
94
+ }
95
+ const state = await page
96
+ .evaluate(() => document.visibilityState)
97
+ .catch(() => null);
98
+ if (state === 'visible') {
99
+ return page;
100
+ }
101
+ }
102
+ return pages[0];
103
+ }
104
+
69
105
  async function connectPlaywright({ options, loadPlaywright, storageState }) {
70
106
  const { chromium } = await loadPlaywright();
71
107
  const endpoint = options.cdpEndpoint ?? options.wsEndpoint;
@@ -77,7 +113,8 @@ async function connectPlaywright({ options, loadPlaywright, storageState }) {
77
113
  if (!context) {
78
114
  throw new Error('Connected Playwright browser has no default context');
79
115
  }
80
- const page = context.pages()[0] ?? (await context.newPage());
116
+ const page =
117
+ (await pickForegroundPage(context.pages())) ?? (await context.newPage());
81
118
 
82
119
  await restorePlaywrightStorageState({ context, storageState });
83
120
  return { browser, page };
@@ -89,8 +126,9 @@ async function connectPuppeteer({ options, loadPuppeteer, storageState }) {
89
126
  const browser = await puppeteer.connect(
90
127
  buildPuppeteerConnectOptions(options)
91
128
  );
92
- const pages = await browser.pages();
93
- const page = pages[0] ?? (await browser.newPage());
129
+ const page =
130
+ (await pickForegroundPage(await browser.pages())) ??
131
+ (await browser.newPage());
94
132
 
95
133
  await restorePuppeteerStorageState({ page, storageState });
96
134
  return { browser, page };
@@ -0,0 +1,155 @@
1
+ import net from 'node:net';
2
+
3
+ /**
4
+ * A fixed, non-zero `--remote-debugging-port` is the only CDP transport that
5
+ * leaves `navigator.webdriver` false without the unsupported
6
+ * `--disable-blink-features=AutomationControlled` switch: Chromium's
7
+ * `content/child/runtime_features.cc` treats `--remote-debugging-pipe` and
8
+ * `--remote-debugging-port=0` as automation, but a specific port as a human
9
+ * attaching a debugger (issue #101).
10
+ *
11
+ * The port is reserved by binding `127.0.0.1:0`, reading the port the kernel
12
+ * picked and closing the socket. Another process can take the port between
13
+ * that close and Chrome's bind, so the launcher confirms ownership and retries.
14
+ *
15
+ * Chrome only writes `DevToolsActivePort` into the profile for port 0 (see
16
+ * `chrome/browser/devtools/remote_debugging_server.cc`), so for a fixed port
17
+ * ownership is confirmed from the line Chromium prints to stderr when its
18
+ * DevTools server starts:
19
+ *
20
+ * DevTools listening on ws://127.0.0.1:<port>/devtools/browser/<id>
21
+ *
22
+ * When the loopback port is taken Chromium logs `bind() failed: Address
23
+ * already in use` and falls back to `[::1]:<port>` (measured with Chrome 153,
24
+ * experiments/issue-105/port-race.mjs); when both fail it logs `Cannot start
25
+ * http server for devtools`. Either outcome is reported as a race.
26
+ */
27
+
28
+ export const LOOPBACK_HOST = '127.0.0.1';
29
+
30
+ /** Thrown when the reserved port was taken before the browser could bind it. */
31
+ export class PortRaceError extends Error {
32
+ constructor(port, detail) {
33
+ super(
34
+ `Remote debugging port ${port} was taken by another process before the browser bound it${detail ? ` (${detail})` : ''}`
35
+ );
36
+ this.name = 'PortRaceError';
37
+ this.port = port;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Reserve a free loopback TCP port for `--remote-debugging-port`.
43
+ *
44
+ * @param {Object} [options]
45
+ * @param {string} [options.host='127.0.0.1'] - Interface to bind while reserving
46
+ * @returns {Promise<number>} A port that was free a moment ago
47
+ */
48
+ export async function reserveLoopbackPort({ host = LOOPBACK_HOST } = {}) {
49
+ return await new Promise((resolve, reject) => {
50
+ const server = net.createServer();
51
+ server.unref();
52
+ server.once('error', reject);
53
+ server.listen({ port: 0, host, exclusive: true }, () => {
54
+ const { port } = server.address();
55
+ server.close((error) => (error ? reject(error) : resolve(port)));
56
+ });
57
+ });
58
+ }
59
+
60
+ /** Validate a caller-supplied debugging port; zero is refused on purpose. */
61
+ export function assertFixedDebuggingPort(port) {
62
+ if (port === 0) {
63
+ throw new RangeError(
64
+ 'remoteDebuggingPort 0 makes Chrome enable AutomationControlled (navigator.webdriver === true); omit it so a free fixed port is reserved'
65
+ );
66
+ }
67
+ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
68
+ throw new RangeError(
69
+ 'remoteDebuggingPort must be an integer from 1 to 65535'
70
+ );
71
+ }
72
+ return port;
73
+ }
74
+
75
+ const LISTENING_PATTERN =
76
+ /DevTools listening on (ws:\/\/(\[[^\]]+\]|[^:/\s]+):(\d+)\/devtools\/browser\/[^\s]+)/;
77
+ // A bare `bind() failed` can come from unrelated sockets (media router, mDNS),
78
+ // so only DevTools' own give-up message counts as a failure on its own.
79
+ const BIND_FAILURE_PATTERN = /Cannot start http server for devtools/i;
80
+
81
+ /**
82
+ * Parse Chromium's DevTools startup output.
83
+ *
84
+ * @param {string} text - Browser stderr collected so far
85
+ * @returns {{listening: {url: string, host: string, port: number}|null, bindFailed: boolean}}
86
+ */
87
+ export function parseDevToolsOutput(text) {
88
+ const match = LISTENING_PATTERN.exec(text);
89
+ return {
90
+ listening: match
91
+ ? {
92
+ url: match[1],
93
+ host: match[2].replace(/^\[|\]$/g, ''),
94
+ port: Number(match[3]),
95
+ }
96
+ : null,
97
+ bindFailed: BIND_FAILURE_PATTERN.test(text),
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Collect a browser's stderr so the launcher can confirm which process owns
103
+ * the debugging port. The stream keeps being drained after startup so a
104
+ * chatty browser never blocks on a full pipe.
105
+ *
106
+ * @param {Object|null|undefined} stream - Browser stderr
107
+ * @param {Object} [options]
108
+ * @param {boolean} [options.forward=false] - Also copy the output to process.stderr
109
+ * @returns {{available: boolean, state: function(): Object}}
110
+ */
111
+ export function watchDevToolsOutput(stream, { forward = false } = {}) {
112
+ if (!stream || typeof stream.on !== 'function') {
113
+ return {
114
+ available: false,
115
+ state: () => ({ listening: null, bindFailed: false }),
116
+ };
117
+ }
118
+ let text = '';
119
+ let settled = null;
120
+ stream.on('data', (chunk) => {
121
+ if (forward) {
122
+ process.stderr.write(chunk);
123
+ }
124
+ if (settled) {
125
+ return;
126
+ }
127
+ // Only the startup lines matter; cap the buffer so it cannot grow forever.
128
+ text = `${text}${chunk}`.slice(-65_536);
129
+ const parsed = parseDevToolsOutput(text);
130
+ if (parsed.listening) {
131
+ settled = parsed;
132
+ }
133
+ });
134
+ return {
135
+ available: true,
136
+ state: () => settled ?? parseDevToolsOutput(text),
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Decide whether the DevTools output proves that `port` belongs to our
142
+ * browser, proves a race, or is not conclusive yet.
143
+ *
144
+ * @returns {'owned'|'race'|'pending'}
145
+ */
146
+ export function classifyDevToolsOwnership(output, port) {
147
+ const { listening, bindFailed } = output;
148
+ if (listening) {
149
+ if (listening.port === port && listening.host === LOOPBACK_HOST) {
150
+ return 'owned';
151
+ }
152
+ return 'race';
153
+ }
154
+ return bindFailed ? 'race' : 'pending';
155
+ }
@@ -1,24 +1,34 @@
1
- import { CHROME_ARGS } from '../core/constants.js';
1
+ import {
2
+ assertStringArray,
3
+ mergeFeatureSwitches,
4
+ resolveRestrictions,
5
+ } from './restrictions.js';
2
6
  import {
3
7
  applyAutomationParityArgs,
4
8
  parityIgnoredDefaultArgs,
5
9
  } from '../fingerprint/automation-parity.js';
6
10
 
7
- function stringArray(value, name) {
8
- if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
9
- throw new TypeError(`${name} must be an array of strings`);
10
- }
11
- return value;
12
- }
13
-
14
- /** Resolve Browser Commander defaults, compatibility args, and extra args. */
11
+ /**
12
+ * Resolve the switches an engine-launched browser gets on top of the engine's
13
+ * own: opt-in restrictions, then custom args (issue #103). Browser Commander
14
+ * adds nothing by default; `CHROME_ARGS` is kept only as the
15
+ * `legacy-defaults` restriction preset.
16
+ *
17
+ * @param {Object} [options]
18
+ * @param {string[]} [options.args]
19
+ * @param {string[]} [options.extraArgs]
20
+ * @param {boolean|string[]} [options.ignoreDefaultArgs] - Engine defaults to omit
21
+ * @param {string[]} [options.restrictions] - Names from launch-restrictions.json
22
+ * @returns {{args: string[], ignoreDefaultArgs: boolean|string[]}}
23
+ */
15
24
  export function resolveChromeArgs({
16
25
  args = [],
17
26
  extraArgs = [],
18
27
  ignoreDefaultArgs = [],
28
+ restrictions = [],
19
29
  } = {}) {
20
- stringArray(args, 'args');
21
- stringArray(extraArgs, 'extraArgs');
30
+ assertStringArray(args, 'args');
31
+ assertStringArray(extraArgs, 'extraArgs');
22
32
  if (
23
33
  typeof ignoreDefaultArgs !== 'boolean' &&
24
34
  !Array.isArray(ignoreDefaultArgs)
@@ -26,23 +36,16 @@ export function resolveChromeArgs({
26
36
  throw new TypeError('ignoreDefaultArgs must be a boolean or string array');
27
37
  }
28
38
  if (Array.isArray(ignoreDefaultArgs)) {
29
- stringArray(ignoreDefaultArgs, 'ignoreDefaultArgs');
39
+ assertStringArray(ignoreDefaultArgs, 'ignoreDefaultArgs');
30
40
  }
31
41
 
32
- const normalizedIgnoreDefaultArgs =
33
- ignoreDefaultArgs === false ? [] : ignoreDefaultArgs;
34
- const ignored = new Set(
35
- normalizedIgnoreDefaultArgs === true
36
- ? CHROME_ARGS
37
- : normalizedIgnoreDefaultArgs
38
- );
39
42
  return {
40
- args: [
41
- ...CHROME_ARGS.filter((argument) => !ignored.has(argument)),
43
+ args: mergeFeatureSwitches([
44
+ ...resolveRestrictions(restrictions).args,
42
45
  ...args,
43
46
  ...extraArgs,
44
- ],
45
- ignoreDefaultArgs: normalizedIgnoreDefaultArgs,
47
+ ]),
48
+ ignoreDefaultArgs: ignoreDefaultArgs === false ? [] : ignoreDefaultArgs,
46
49
  };
47
50
  }
48
51
 
@@ -56,6 +59,31 @@ function setBrowserSelectionOptions(options, { channel, executablePath }) {
56
59
  return options;
57
60
  }
58
61
 
62
+ /**
63
+ * The engine default switches to drop: the caller's list plus, with
64
+ * automation parity, the ones that turn AutomationControlled on.
65
+ *
66
+ * @param {'playwright'|'puppeteer'} engine
67
+ * @returns {true|string[]}
68
+ */
69
+ function resolveEngineIgnoreDefaultArgs(
70
+ engine,
71
+ { headless, ignoreDefaultArgs, automationParity, always = [] }
72
+ ) {
73
+ if (ignoreDefaultArgs === true) {
74
+ return true;
75
+ }
76
+ return [
77
+ ...new Set([
78
+ ...always,
79
+ ...(automationParity
80
+ ? parityIgnoredDefaultArgs(engine, { headless })
81
+ : []),
82
+ ...(ignoreDefaultArgs === false ? [] : ignoreDefaultArgs),
83
+ ]),
84
+ ];
85
+ }
86
+
59
87
  export function buildPlaywrightLaunchOptions({
60
88
  headless,
61
89
  slowMo,
@@ -66,20 +94,12 @@ export function buildPlaywrightLaunchOptions({
66
94
  ignoreDefaultArgs = [],
67
95
  automationParity = true,
68
96
  }) {
69
- const normalizedIgnoreDefaultArgs =
70
- ignoreDefaultArgs === false ? [] : ignoreDefaultArgs;
71
- const engineIgnoreDefaultArgs =
72
- normalizedIgnoreDefaultArgs === true
73
- ? true
74
- : [
75
- ...new Set([
76
- '--enable-automation',
77
- ...(automationParity
78
- ? parityIgnoredDefaultArgs('playwright', { headless })
79
- : []),
80
- ...normalizedIgnoreDefaultArgs,
81
- ]),
82
- ];
97
+ const engineIgnoreDefaultArgs = resolveEngineIgnoreDefaultArgs('playwright', {
98
+ headless,
99
+ ignoreDefaultArgs,
100
+ automationParity,
101
+ always: ['--enable-automation'],
102
+ });
83
103
  const options = {
84
104
  headless,
85
105
  slowMo,
@@ -105,24 +125,15 @@ export function buildPuppeteerLaunchOptions({
105
125
  ignoreDefaultArgs = [],
106
126
  automationParity = true,
107
127
  }) {
108
- const normalizedIgnoreDefaultArgs =
109
- ignoreDefaultArgs === false ? [] : ignoreDefaultArgs;
110
- const baseArgs = ['--start-maximized', ...chromeArgs];
111
- const engineIgnoreDefaultArgs =
112
- normalizedIgnoreDefaultArgs === true
113
- ? true
114
- : [
115
- ...new Set([
116
- ...(automationParity
117
- ? parityIgnoredDefaultArgs('puppeteer', { headless })
118
- : []),
119
- ...normalizedIgnoreDefaultArgs,
120
- ]),
121
- ];
128
+ const engineIgnoreDefaultArgs = resolveEngineIgnoreDefaultArgs('puppeteer', {
129
+ headless,
130
+ ignoreDefaultArgs,
131
+ automationParity,
132
+ });
122
133
  const options = {
123
134
  headless,
124
135
  defaultViewport: null,
125
- args: automationParity ? applyAutomationParityArgs(baseArgs) : baseArgs,
136
+ args: automationParity ? applyAutomationParityArgs(chromeArgs) : chromeArgs,
126
137
  userDataDir,
127
138
  };
128
139
  if (engineIgnoreDefaultArgs === true || engineIgnoreDefaultArgs.length > 0) {
@@ -0,0 +1,147 @@
1
+ {
2
+ "restrictions": [
3
+ {
4
+ "id": "no-first-run",
5
+ "description": "Skip first-run UI with the switch instead of the First Run sentinel file Browser Commander already writes.",
6
+ "args": ["--no-first-run"]
7
+ },
8
+ {
9
+ "id": "no-default-browser-check",
10
+ "description": "Hide the \"Chrome isn't your default browser\" infobar that a hand-started Chrome shows.",
11
+ "args": ["--no-default-browser-check"]
12
+ },
13
+ {
14
+ "id": "no-crash-restore",
15
+ "description": "Hide the restore-pages bubble after an unclean exit.",
16
+ "args": [
17
+ "--disable-session-crashed-bubble",
18
+ "--hide-crash-restore-bubble",
19
+ "--disable-crash-restore"
20
+ ]
21
+ },
22
+ {
23
+ "id": "no-infobars",
24
+ "description": "Legacy switch; current Chrome ignores it and still shows the unsupported-flag infobar.",
25
+ "args": ["--disable-infobars"]
26
+ },
27
+ {
28
+ "id": "basic-password-store",
29
+ "description": "Store passwords and cookie keys unencrypted instead of in libsecret/KWallet. Real-profile data cannot be decrypted or migrated.",
30
+ "args": ["--password-store=basic"]
31
+ },
32
+ {
33
+ "id": "mock-keychain",
34
+ "description": "Use a mock macOS Keychain. Real-profile data cannot be decrypted or migrated.",
35
+ "args": ["--use-mock-keychain"]
36
+ },
37
+ {
38
+ "id": "no-extensions",
39
+ "description": "Disable extensions, component extensions with background pages and default apps.",
40
+ "args": [
41
+ "--disable-extensions",
42
+ "--disable-component-extensions-with-background-pages",
43
+ "--disable-default-apps"
44
+ ]
45
+ },
46
+ {
47
+ "id": "no-sync",
48
+ "description": "Disable Chrome sync.",
49
+ "args": ["--disable-sync"]
50
+ },
51
+ {
52
+ "id": "no-google-services",
53
+ "description": "Override the built-in Google API keys for the browser process only; disables Chrome sign-in, sync, Safe Browsing and other Google services. The host process environment is never changed.",
54
+ "env": {
55
+ "GOOGLE_API_KEY": "no",
56
+ "GOOGLE_DEFAULT_CLIENT_ID": "no",
57
+ "GOOGLE_DEFAULT_CLIENT_SECRET": "no"
58
+ }
59
+ },
60
+ {
61
+ "id": "no-translate",
62
+ "description": "Disable the Translate feature and its bubble.",
63
+ "disableFeatures": ["Translate"]
64
+ },
65
+ {
66
+ "id": "no-component-update",
67
+ "description": "Disable component updates (Widevine, CRLSets, Safe Browsing lists).",
68
+ "args": ["--disable-component-update"]
69
+ },
70
+ {
71
+ "id": "no-background-networking",
72
+ "description": "Disable background networking such as variations and update checks.",
73
+ "args": ["--disable-background-networking"]
74
+ },
75
+ {
76
+ "id": "no-popup-blocking",
77
+ "description": "Never block popups; pages can observe this.",
78
+ "args": ["--disable-popup-blocking"]
79
+ },
80
+ {
81
+ "id": "no-phishing-detection",
82
+ "description": "Disable client-side phishing detection.",
83
+ "args": ["--disable-client-side-phishing-detection"]
84
+ },
85
+ {
86
+ "id": "no-https-upgrades",
87
+ "description": "Disable automatic HTTPS upgrades.",
88
+ "disableFeatures": ["HttpsUpgrades"]
89
+ },
90
+ {
91
+ "id": "no-media-router",
92
+ "description": "Disable Cast/Media Router and global media controls.",
93
+ "disableFeatures": [
94
+ "MediaRouter",
95
+ "DialMediaRouteProvider",
96
+ "GlobalMediaControls"
97
+ ]
98
+ },
99
+ {
100
+ "id": "no-lens",
101
+ "description": "Disable the Lens overlay.",
102
+ "disableFeatures": ["LensOverlay"]
103
+ },
104
+ {
105
+ "id": "no-background-throttling",
106
+ "description": "Keep hidden tabs at full speed; pages can observe this.",
107
+ "args": [
108
+ "--disable-background-timer-throttling",
109
+ "--disable-renderer-backgrounding",
110
+ "--disable-backgrounding-occluded-windows"
111
+ ]
112
+ },
113
+ {
114
+ "id": "no-back-forward-cache",
115
+ "description": "Disable the back/forward cache; pageshow.persisted is always false.",
116
+ "args": ["--disable-back-forward-cache"]
117
+ },
118
+ {
119
+ "id": "srgb-color-profile",
120
+ "description": "Force the sRGB colour profile; changes rendered colours.",
121
+ "args": ["--force-color-profile=srgb"]
122
+ },
123
+ {
124
+ "id": "start-maximized",
125
+ "description": "Start with a maximized window.",
126
+ "args": ["--start-maximized"]
127
+ }
128
+ ],
129
+ "presets": {
130
+ "legacy-defaults": [
131
+ "no-crash-restore",
132
+ "no-infobars",
133
+ "basic-password-store",
134
+ "no-first-run",
135
+ "no-default-browser-check"
136
+ ],
137
+ "legacy-launch-browser": [
138
+ "no-crash-restore",
139
+ "no-infobars",
140
+ "basic-password-store",
141
+ "no-first-run",
142
+ "no-default-browser-check",
143
+ "no-google-services",
144
+ "no-translate"
145
+ ]
146
+ }
147
+ }