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.
- package/CHANGELOG.md +156 -0
- package/README.md +216 -32
- package/bin/browser-commander.js +16 -0
- package/eslint.config.js +10 -0
- package/examples/react-test-app/src/App.jsx +24 -23
- package/extension/background.js +14 -0
- package/extension/manifest.json +12 -0
- package/extension/relay-handler.js +353 -0
- package/package.json +25 -6
- package/scripts/run-tests.mjs +4 -2
- package/src/browser/attach/differences.js +131 -0
- package/src/browser/attach/extension-relay.js +402 -0
- package/src/browser/attach/index.js +81 -0
- package/src/browser/attach/snapshot-launch.js +146 -0
- package/src/browser/attach/snapshot.js +374 -0
- package/src/browser/attach/websocket.js +615 -0
- package/src/browser/browser-cookie-cache.js +12 -17
- package/src/browser/browser-cookie-credentials.js +6 -9
- package/src/browser/browser-cookies.js +5 -2
- package/src/browser/cdp-session.js +94 -0
- package/src/browser/connector.js +42 -4
- package/src/browser/debugging-port.js +155 -0
- package/src/browser/launch-options.js +63 -52
- package/src/browser/launch-restrictions.json +147 -0
- package/src/browser/launcher.js +329 -133
- package/src/browser/media.js +5 -0
- package/src/browser/migration/bookmarks.js +73 -0
- package/src/browser/migration/chromium-crypto.js +89 -0
- package/src/browser/migration/cookies.js +155 -0
- package/src/browser/migration/extensions.js +187 -0
- package/src/browser/migration/firefox-bookmarks.js +144 -0
- package/src/browser/migration/firefox-der.js +84 -0
- package/src/browser/migration/firefox-nss.js +281 -0
- package/src/browser/migration/firefox.js +334 -0
- package/src/browser/migration/fs-utils.js +51 -0
- package/src/browser/migration/history.js +103 -0
- package/src/browser/migration/index.js +307 -0
- package/src/browser/migration/os-crypt-keys.js +106 -0
- package/src/browser/migration/passwords.js +171 -0
- package/src/browser/migration/preferences.js +132 -0
- package/src/browser/migration/sqlite-snapshot.js +138 -0
- package/src/browser/open-in-user-browser.js +112 -0
- package/src/browser/parity.js +439 -0
- package/src/browser/profile-directory.js +102 -0
- package/src/browser/real-browser.js +513 -306
- package/src/browser/restrictions.js +148 -0
- package/src/browser/system-browser.js +232 -0
- package/src/browser/webdriver.js +572 -0
- package/src/cli/args.js +253 -0
- package/src/cli/commands.js +332 -0
- package/src/cli/dispatcher.js +99 -0
- package/src/cli/handle-methods.js +136 -0
- package/src/cli/handles.js +273 -0
- package/src/cli/high-level-methods.js +310 -0
- package/src/cli/main.js +67 -0
- package/src/cli/modules.js +100 -0
- package/src/cli/rpc-error.js +77 -0
- package/src/cli/script.js +77 -0
- package/src/cli/serve.js +144 -0
- package/src/cli/sessions.js +195 -0
- package/src/core/constants.js +7 -1
- package/src/core/engine-adapter.js +107 -1
- package/src/core/engine-detection.js +18 -3
- package/src/core/navigation-safety.js +4 -0
- package/src/core/webdriver-conversions.js +347 -0
- package/src/core/webdriver-events.js +229 -0
- package/src/core/webdriver-page.js +716 -0
- package/src/downloads/attach.js +6 -0
- package/src/downloads/manager.js +9 -0
- package/src/downloads/sources.js +71 -0
- package/src/downloads/staging.js +21 -9
- package/src/elements/selectors.js +3 -2
- package/src/elements/visibility.js +4 -0
- package/src/exports.js +61 -2
- package/src/factory.js +19 -4
- package/src/fingerprint/apply.js +6 -0
- package/src/fingerprint/automation-parity.js +7 -8
- package/src/fingerprint/limitations.js +1 -1
- package/src/fingerprint/limitations.json +45 -2
- package/src/parity/harness.js +303 -0
- package/src/parity/probe.js +838 -0
- package/src/parity/version-page.js +144 -0
- package/src/traces/bundle.js +10 -2
- package/src/traces/recorder.js +16 -3
- package/src/utilities/subprocess.js +199 -0
- package/tests/e2e/api-coverage.e2e.test.js +174 -0
- package/tests/e2e/cli.e2e.test.js +160 -0
- package/tests/e2e/downloads.e2e.test.js +79 -2
- package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
- package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
- package/tests/e2e/measure-parity.e2e.test.js +118 -0
- package/tests/e2e/playwright.e2e.test.js +25 -17
- package/tests/e2e/puppeteer.e2e.test.js +17 -23
- package/tests/e2e/webdriver.e2e.test.js +239 -0
- package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
- package/tests/helpers/cli-fakes.js +214 -0
- package/tests/helpers/declared-api.js +80 -0
- package/tests/helpers/e2e-browser.js +40 -14
- package/tests/helpers/fake-chrome.js +124 -0
- package/tests/helpers/migration-fixtures.js +242 -0
- package/tests/helpers/temp-directory.js +24 -0
- package/tests/helpers/trace-fixtures.js +18 -0
- package/tests/helpers/webdriver-mocks.js +183 -0
- package/tests/unit/browser/attach/extension-relay.test.js +360 -0
- package/tests/unit/browser/attach/snapshot-launch.test.js +312 -0
- package/tests/unit/browser/attach/snapshot.test.js +390 -0
- package/tests/unit/browser/attach/websocket.test.js +240 -0
- package/tests/unit/browser/browser-cookies.test.js +2 -32
- package/tests/unit/browser/cdp-session.test.js +162 -0
- package/tests/unit/browser/connector.test.js +23 -0
- package/tests/unit/browser/launch-building-blocks.test.js +238 -0
- package/tests/unit/browser/launch-options.test.js +41 -22
- package/tests/unit/browser/launcher.test.js +281 -102
- package/tests/unit/browser/media.test.js +13 -1
- package/tests/unit/browser/migration/bookmarks.test.js +69 -0
- package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
- package/tests/unit/browser/migration/cookies.test.js +94 -0
- package/tests/unit/browser/migration/extensions.test.js +104 -0
- package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
- package/tests/unit/browser/migration/firefox.test.js +139 -0
- package/tests/unit/browser/migration/history.test.js +48 -0
- package/tests/unit/browser/migration/index.test.js +117 -0
- package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
- package/tests/unit/browser/migration/passwords.test.js +144 -0
- package/tests/unit/browser/migration/preferences.test.js +77 -0
- package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
- package/tests/unit/browser/open-in-user-browser.test.js +123 -0
- package/tests/unit/browser/parity.test.js +281 -0
- package/tests/unit/browser/real-browser.test.js +458 -33
- package/tests/unit/browser/webdriver.test.js +473 -0
- package/tests/unit/cli/bin.test.js +69 -0
- package/tests/unit/cli/declared-api.test.js +49 -0
- package/tests/unit/cli/dispatcher.test.js +429 -0
- package/tests/unit/cli/handles.test.js +170 -0
- package/tests/unit/cli/main.test.js +409 -0
- package/tests/unit/cli/serve.test.js +167 -0
- package/tests/unit/core/webdriver-conversions.test.js +235 -0
- package/tests/unit/core/webdriver-page.test.js +534 -0
- package/tests/unit/downloads/manager.test.js +82 -9
- package/tests/unit/downloads/staging.test.js +24 -2
- package/tests/unit/fingerprint/automation-parity.test.js +17 -4
- package/tests/unit/fingerprint/limitations.test.js +1 -1
- package/tests/unit/parity/harness.test.js +138 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +4 -0
- package/tests/unit/scripts/ci-workflow-policy.test.js +8 -0
- package/tests/unit/scripts/feature-parity.test.js +221 -0
- package/tests/unit/scripts/npm-release-helpers.test.js +3 -2
- package/tests/unit/scripts/pre-commit-config.test.js +2 -1
- package/tests/unit/traces/bundle.test.js +24 -3
- package/tests/unit/traces/links.test.js +7 -0
- package/tests/unit/traces/reader.test.js +11 -4
- package/tests/unit/traces/recorder.test.js +4 -0
- package/tests/unit/traces/viewer.test.js +4 -0
- package/tests/unit/utilities/command-stream-only.test.js +28 -0
- package/tests/unit/utilities/subprocess.test.js +80 -0
|
@@ -92,7 +92,8 @@ function readChromiumRows(database, domainFilter) {
|
|
|
92
92
|
);
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
|
|
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 =
|
|
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
|
+
}
|
package/src/browser/connector.js
CHANGED
|
@@ -5,12 +5,21 @@ import {
|
|
|
5
5
|
restorePuppeteerStorageState,
|
|
6
6
|
} from './storage-state.js';
|
|
7
7
|
|
|
8
|
-
|
|
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 =
|
|
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
|
|
93
|
-
|
|
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 {
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
43
|
+
args: mergeFeatureSwitches([
|
|
44
|
+
...resolveRestrictions(restrictions).args,
|
|
42
45
|
...args,
|
|
43
46
|
...extraArgs,
|
|
44
|
-
],
|
|
45
|
-
ignoreDefaultArgs:
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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(
|
|
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
|
+
}
|