browser-commander 0.20.0 → 0.21.1
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/.jscpd.json +2 -1
- package/.prettierignore +1 -0
- package/CHANGELOG.md +159 -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 +21 -4
- 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 +26 -19
- 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 +205 -0
- package/src/browser/real-browser.js +545 -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 +300 -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 +199 -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/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 +3 -2
- package/src/traces/observers.js +14 -5
- package/src/traces/recorder.js +3 -0
- package/src/traces/viewer.js +16 -8
- 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/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 +26 -17
- package/tests/e2e/puppeteer.e2e.test.js +18 -23
- package/tests/e2e/webdriver.e2e.test.js +239 -0
- package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.html +2 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.png +1 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.state.json +54 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +2 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.png +1 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.state.json +60 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +2 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.png +1 -0
- package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.state.json +60 -0
- package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +18 -0
- package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +77 -0
- package/tests/fixtures/traces/conformance/expected/bundle/mutations/0001.ndjson +2 -0
- package/tests/fixtures/traces/conformance/expected/bundle/mutations/0002.ndjson +1 -0
- package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +309 -0
- package/tests/fixtures/traces/conformance/expected/redaction.json +86 -0
- package/tests/fixtures/traces/conformance/expected/trace.lino +27 -0
- package/tests/fixtures/traces/conformance/scenario.json +249 -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/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 +321 -0
- package/tests/unit/browser/attach/snapshot.test.js +409 -0
- package/tests/unit/browser/attach/websocket.test.js +240 -0
- package/tests/unit/browser/browser-cookie-lock.test.js +102 -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 +361 -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/storage-state.test.js +2 -0
- 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 +437 -0
- package/tests/unit/cli/serve.test.js +167 -0
- package/tests/unit/core/navigation-manager.test.js +11 -1
- package/tests/unit/core/webdriver-conversions.test.js +235 -0
- package/tests/unit/core/webdriver-page.test.js +534 -0
- 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/playwright-protocol-coverage.test.js +231 -0
- package/tests/unit/puppeteer-bindings-coverage.test.js +214 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +27 -0
- package/tests/unit/scripts/feature-parity.test.js +327 -0
- package/tests/unit/traces/conformance.test.js +92 -0
- package/tests/unit/traces/reader.test.js +1 -0
- package/tests/unit/traces/recorder.test.js +1 -0
- package/tests/unit/traces/viewer.test.js +78 -0
- package/tests/unit/utilities/command-stream-only.test.js +28 -0
- package/tests/unit/utilities/subprocess.test.js +80 -0
package/src/cli/serve.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `browser-commander serve --stdio`: JSON-RPC 2.0 over stdin/stdout, one
|
|
3
|
+
* message per line (issue #104). Requests may be pipelined; each response
|
|
4
|
+
* carries its request id, and server notifications (`events.emit`) have none.
|
|
5
|
+
*/
|
|
6
|
+
import { once } from 'node:events';
|
|
7
|
+
import readline from 'node:readline';
|
|
8
|
+
|
|
9
|
+
import { createDispatcher } from './dispatcher.js';
|
|
10
|
+
import { RPC_ERROR, toRpcError } from './rpc-error.js';
|
|
11
|
+
|
|
12
|
+
/** How long pending requests may run after stdin closes. */
|
|
13
|
+
export const DEFAULT_DRAIN_TIMEOUT_MS = 5_000;
|
|
14
|
+
|
|
15
|
+
function errorResponse(id, code, message) {
|
|
16
|
+
return { jsonrpc: '2.0', id, error: { code, message } };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isValidId(id) {
|
|
20
|
+
return id === null || typeof id === 'string' || typeof id === 'number';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isRequest(message) {
|
|
24
|
+
return (
|
|
25
|
+
message !== null &&
|
|
26
|
+
typeof message === 'object' &&
|
|
27
|
+
!Array.isArray(message) &&
|
|
28
|
+
message.jsonrpc === '2.0' &&
|
|
29
|
+
typeof message.method === 'string' &&
|
|
30
|
+
(!('id' in message) || isValidId(message.id))
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build the handler for one JSON-RPC message or batch.
|
|
36
|
+
*
|
|
37
|
+
* @param {{dispatch: Function}} dispatcher
|
|
38
|
+
* @returns {function(unknown): Promise<Object|Array<Object>|null>} The response, or null for notifications
|
|
39
|
+
*/
|
|
40
|
+
export function createMessageHandler(dispatcher) {
|
|
41
|
+
async function handleOne(message) {
|
|
42
|
+
if (!isRequest(message)) {
|
|
43
|
+
const id = isValidId(message?.id) ? message.id : null;
|
|
44
|
+
return errorResponse(id, RPC_ERROR.INVALID_REQUEST, 'Invalid request');
|
|
45
|
+
}
|
|
46
|
+
const notification = !('id' in message);
|
|
47
|
+
try {
|
|
48
|
+
const result = await dispatcher.dispatch(
|
|
49
|
+
message.method,
|
|
50
|
+
message.params ?? {}
|
|
51
|
+
);
|
|
52
|
+
return notification
|
|
53
|
+
? null
|
|
54
|
+
: { jsonrpc: '2.0', id: message.id, result: result ?? null };
|
|
55
|
+
} catch (error) {
|
|
56
|
+
return notification
|
|
57
|
+
? null
|
|
58
|
+
: { jsonrpc: '2.0', id: message.id, error: toRpcError(error) };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return async function handleMessage(message) {
|
|
63
|
+
if (!Array.isArray(message)) {
|
|
64
|
+
return await handleOne(message);
|
|
65
|
+
}
|
|
66
|
+
if (message.length === 0) {
|
|
67
|
+
return errorResponse(null, RPC_ERROR.INVALID_REQUEST, 'Empty batch');
|
|
68
|
+
}
|
|
69
|
+
const responses = (await Promise.all(message.map(handleOne))).filter(
|
|
70
|
+
Boolean
|
|
71
|
+
);
|
|
72
|
+
return responses.length > 0 ? responses : null;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Parse one line and answer it.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} line
|
|
80
|
+
* @param {Function} handleMessage - From {@link createMessageHandler}
|
|
81
|
+
* @returns {Promise<Object|Object[]|null>}
|
|
82
|
+
*/
|
|
83
|
+
export async function handleLine(line, handleMessage) {
|
|
84
|
+
if (line.trim() === '') {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
let message;
|
|
88
|
+
try {
|
|
89
|
+
message = JSON.parse(line);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return errorResponse(
|
|
92
|
+
null,
|
|
93
|
+
RPC_ERROR.PARSE_ERROR,
|
|
94
|
+
`Parse error: ${error.message}`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
return await handleMessage(message);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function delay(ms) {
|
|
101
|
+
return new Promise((resolve) => {
|
|
102
|
+
setTimeout(resolve, ms).unref?.();
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Serve JSON-RPC until `input` ends, then close every session.
|
|
108
|
+
*
|
|
109
|
+
* @param {Object} options
|
|
110
|
+
* @param {Object} options.input - Usually process.stdin
|
|
111
|
+
* @param {function(string): void} options.write - Writes one line to stdout
|
|
112
|
+
* @param {Object} [options.dependencies] - Dispatcher dependencies (tests)
|
|
113
|
+
* @param {number} [options.drainTimeoutMs] - Grace for in-flight requests
|
|
114
|
+
* @returns {Promise<void>}
|
|
115
|
+
*/
|
|
116
|
+
export async function serveStdio({
|
|
117
|
+
input,
|
|
118
|
+
write,
|
|
119
|
+
dependencies,
|
|
120
|
+
drainTimeoutMs = DEFAULT_DRAIN_TIMEOUT_MS,
|
|
121
|
+
}) {
|
|
122
|
+
const send = (message) => write(`${JSON.stringify(message)}\n`);
|
|
123
|
+
const dispatcher = createDispatcher({
|
|
124
|
+
dependencies,
|
|
125
|
+
notify: (method, params) => send({ jsonrpc: '2.0', method, params }),
|
|
126
|
+
});
|
|
127
|
+
const handleMessage = createMessageHandler(dispatcher);
|
|
128
|
+
const pending = new Set();
|
|
129
|
+
const lines = readline.createInterface({ input, crlfDelay: Infinity });
|
|
130
|
+
lines.on('line', (line) => {
|
|
131
|
+
const task = handleLine(line, handleMessage)
|
|
132
|
+
.then((response) => {
|
|
133
|
+
if (response) {
|
|
134
|
+
send(response);
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
.catch(() => {});
|
|
138
|
+
pending.add(task);
|
|
139
|
+
task.finally(() => pending.delete(task));
|
|
140
|
+
});
|
|
141
|
+
await once(lines, 'close');
|
|
142
|
+
await Promise.race([Promise.allSettled([...pending]), delay(drainTimeoutMs)]);
|
|
143
|
+
await dispatcher.close();
|
|
144
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser sessions owned by one `serve --stdio` or `run` dispatcher
|
|
3
|
+
* (issue #104). A session is a launched or attached browser with the engine
|
|
4
|
+
* objects behind it: `browser`, `context` and the `page` commands act on.
|
|
5
|
+
*/
|
|
6
|
+
import { invalidParams } from './rpc-error.js';
|
|
7
|
+
|
|
8
|
+
/** `--browser` names mapped to installed-browser channels. */
|
|
9
|
+
export const BROWSER_CHANNELS = Object.freeze({
|
|
10
|
+
chrome: 'chrome',
|
|
11
|
+
edge: 'msedge',
|
|
12
|
+
msedge: 'msedge',
|
|
13
|
+
brave: 'brave',
|
|
14
|
+
chromium: 'chromium',
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const ENGINES = Object.freeze(['playwright', 'puppeteer']);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Launch options that are forwarded to `launchBrowser()` as they are, so the
|
|
21
|
+
* bridge can express everything the old Rust bridge's `launch` operation did.
|
|
22
|
+
*/
|
|
23
|
+
const LAUNCH_PASSTHROUGH = Object.freeze([
|
|
24
|
+
'args',
|
|
25
|
+
'extraArgs',
|
|
26
|
+
'ignoreDefaultArgs',
|
|
27
|
+
'slowMo',
|
|
28
|
+
'colorScheme',
|
|
29
|
+
'channel',
|
|
30
|
+
'storageState',
|
|
31
|
+
'fingerprint',
|
|
32
|
+
'remoteDebuggingPort',
|
|
33
|
+
'automationParity',
|
|
34
|
+
'env',
|
|
35
|
+
'startupTimeout',
|
|
36
|
+
'attach',
|
|
37
|
+
'preferences',
|
|
38
|
+
'localState',
|
|
39
|
+
'defaultBrowserCheck',
|
|
40
|
+
'firstRun',
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
/** Validate an engine name, defaulting to Playwright. */
|
|
44
|
+
export function resolveEngine(engine = 'playwright') {
|
|
45
|
+
if (!ENGINES.includes(engine)) {
|
|
46
|
+
throw invalidParams(
|
|
47
|
+
`Unsupported engine "${engine}"; expected ${ENGINES.join(' or ')}`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return engine;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolveChannel(browser) {
|
|
54
|
+
if (browser === undefined) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
const channel = BROWSER_CHANNELS[browser];
|
|
58
|
+
if (!channel) {
|
|
59
|
+
throw invalidParams(
|
|
60
|
+
`Unsupported browser "${browser}"; expected chrome, edge, brave or chromium`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return channel;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function assertStringList(value, name) {
|
|
67
|
+
if (
|
|
68
|
+
value !== undefined &&
|
|
69
|
+
(!Array.isArray(value) || value.some((item) => typeof item !== 'string'))
|
|
70
|
+
) {
|
|
71
|
+
throw invalidParams(`${name} must be an array of strings`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Translate `session.launch` params into `launchBrowser()` options.
|
|
77
|
+
*
|
|
78
|
+
* @param {Object} params - JSON-RPC params
|
|
79
|
+
* @returns {Object} launchBrowser options
|
|
80
|
+
*/
|
|
81
|
+
export function buildLaunchOptions(params = {}) {
|
|
82
|
+
assertStringList(params.restrictions, 'restrictions');
|
|
83
|
+
assertStringList(params.args, 'args');
|
|
84
|
+
const options = {
|
|
85
|
+
engine: resolveEngine(params.engine),
|
|
86
|
+
headless: params.headless === true,
|
|
87
|
+
};
|
|
88
|
+
const channel = resolveChannel(params.browser);
|
|
89
|
+
const defined = {
|
|
90
|
+
launch: params.launch,
|
|
91
|
+
executablePath: params.executablePath,
|
|
92
|
+
userDataDir: params.userDataDir,
|
|
93
|
+
restrictions: params.restrictions,
|
|
94
|
+
channel,
|
|
95
|
+
};
|
|
96
|
+
for (const name of LAUNCH_PASSTHROUGH) {
|
|
97
|
+
defined[name] ??= params[name];
|
|
98
|
+
}
|
|
99
|
+
for (const [key, value] of Object.entries(defined)) {
|
|
100
|
+
if (value !== undefined) {
|
|
101
|
+
options[key] = value;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return options;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Build a session from a `launchBrowser()` result.
|
|
109
|
+
*
|
|
110
|
+
* Playwright's real launch returns the default context as `browser` and the
|
|
111
|
+
* CDP-connected `Browser` as `connectedBrowser`; the engine launch returns a
|
|
112
|
+
* persistent context whose `browser()` is null. Puppeteer returns a Browser.
|
|
113
|
+
*/
|
|
114
|
+
export function sessionFromLaunch(engine, launched) {
|
|
115
|
+
const playwright = engine === 'playwright';
|
|
116
|
+
const handle = launched.browser;
|
|
117
|
+
return {
|
|
118
|
+
engine,
|
|
119
|
+
browser: playwright
|
|
120
|
+
? (launched.connectedBrowser ?? handle?.browser?.() ?? null)
|
|
121
|
+
: handle,
|
|
122
|
+
context: playwright ? handle : (handle?.defaultBrowserContext?.() ?? null),
|
|
123
|
+
page: launched.page,
|
|
124
|
+
args: launched.args ?? [],
|
|
125
|
+
close: () => launched.close(),
|
|
126
|
+
connected: false,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Build a session from a `connectBrowser()` result. Closing it only
|
|
132
|
+
* disconnects: the browser keeps running for the next command.
|
|
133
|
+
*/
|
|
134
|
+
export function sessionFromConnect(engine, connected) {
|
|
135
|
+
const { browser, page } = connected;
|
|
136
|
+
const playwright = engine === 'playwright';
|
|
137
|
+
return {
|
|
138
|
+
engine,
|
|
139
|
+
browser,
|
|
140
|
+
context: playwright
|
|
141
|
+
? (page?.context?.() ?? null)
|
|
142
|
+
: (browser?.defaultBrowserContext?.() ?? null),
|
|
143
|
+
page,
|
|
144
|
+
// Playwright's close() on a connectOverCDP browser disconnects;
|
|
145
|
+
// Puppeteer's close() would quit the browser, disconnect() does not.
|
|
146
|
+
close: async () => {
|
|
147
|
+
await connected.downloads?.dispose?.();
|
|
148
|
+
await (playwright ? browser.close() : browser.disconnect());
|
|
149
|
+
},
|
|
150
|
+
connected: true,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Sessions with deterministic ids `s1`, `s2`, … */
|
|
155
|
+
export class SessionTable {
|
|
156
|
+
constructor() {
|
|
157
|
+
this.nextId = 1;
|
|
158
|
+
this.sessions = new Map();
|
|
159
|
+
this.latest = null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Add a session and return its id. */
|
|
163
|
+
add(session) {
|
|
164
|
+
const id = `s${this.nextId++}`;
|
|
165
|
+
this.sessions.set(id, session);
|
|
166
|
+
this.latest = id;
|
|
167
|
+
return id;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The session behind an id (or the latest one when omitted). */
|
|
171
|
+
get(id) {
|
|
172
|
+
const resolved = id ?? this.latest;
|
|
173
|
+
const session = resolved ? this.sessions.get(resolved) : undefined;
|
|
174
|
+
if (!session) {
|
|
175
|
+
throw invalidParams(
|
|
176
|
+
id ? `Unknown session: ${id}` : 'No session; call session.launch first'
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return session;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Close one session and forget it. */
|
|
183
|
+
async close(id) {
|
|
184
|
+
const resolved = id ?? this.latest;
|
|
185
|
+
const session = this.get(resolved);
|
|
186
|
+
this.sessions.delete(resolved);
|
|
187
|
+
if (this.latest === resolved) {
|
|
188
|
+
this.latest = [...this.sessions.keys()].at(-1) ?? null;
|
|
189
|
+
}
|
|
190
|
+
await session.trace?.stop?.().catch(() => {});
|
|
191
|
+
await session.close();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Close every session, ignoring individual failures. */
|
|
195
|
+
async closeAll() {
|
|
196
|
+
const ids = [...this.sessions.keys()];
|
|
197
|
+
await Promise.all(ids.map((id) => this.close(id).catch(() => {})));
|
|
198
|
+
}
|
|
199
|
+
}
|
package/src/core/constants.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The switches Browser Commander added to every launch before issue #103.
|
|
3
|
+
*
|
|
4
|
+
* They are no longer added by default: a launch starts Chrome with only
|
|
5
|
+
* `--user-data-dir` and `--remote-debugging-port`. The list is kept for
|
|
6
|
+
* callers that relied on it and equals the `legacy-defaults` restriction
|
|
7
|
+
* preset, so `launchBrowser({ restrictions: ['legacy-defaults'] })` restores
|
|
8
|
+
* the old command line.
|
|
3
9
|
*/
|
|
4
10
|
export const CHROME_ARGS = [
|
|
5
11
|
'--disable-session-crashed-bubble',
|
|
@@ -606,9 +606,115 @@ export function createEngineAdapter(page, engine) {
|
|
|
606
606
|
return new PlaywrightAdapter(page);
|
|
607
607
|
} else if (engine === 'puppeteer') {
|
|
608
608
|
return new PuppeteerAdapter(page);
|
|
609
|
+
} else if (engine === 'selenium') {
|
|
610
|
+
return new SeleniumAdapter(page);
|
|
609
611
|
} else {
|
|
610
612
|
throw new Error(
|
|
611
|
-
`Unsupported engine: ${engine}. Expected 'playwright' or '
|
|
613
|
+
`Unsupported engine: ${engine}. Expected 'playwright', 'puppeteer' or 'selenium'`
|
|
612
614
|
);
|
|
613
615
|
}
|
|
614
616
|
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Selenium (WebDriver) adapter implementation (issue #104).
|
|
620
|
+
*
|
|
621
|
+
* The page is a `WebDriverPage` facade, which already speaks the Puppeteer
|
|
622
|
+
* page API, so everything not listed here is the Puppeteer implementation.
|
|
623
|
+
* The overrides use WebDriver's native element commands where they exist:
|
|
624
|
+
* Element Click, Element Send Keys and Element Clear run the browser's own
|
|
625
|
+
* interactability checks and produce trusted input events.
|
|
626
|
+
*/
|
|
627
|
+
export class SeleniumAdapter extends PuppeteerAdapter {
|
|
628
|
+
getEngineName() {
|
|
629
|
+
return 'selenium';
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async click(locatorOrElement, options = {}) {
|
|
633
|
+
if (options.force) {
|
|
634
|
+
// Element Click refuses an obscured element; force means "click where
|
|
635
|
+
// it is anyway", which is a pointer action at the element's centre.
|
|
636
|
+
await this.page.evaluate(
|
|
637
|
+
(el) => el.scrollIntoView({ block: 'center', inline: 'center' }),
|
|
638
|
+
locatorOrElement
|
|
639
|
+
);
|
|
640
|
+
await this.page.driver
|
|
641
|
+
.actions({ async: true })
|
|
642
|
+
.move({ origin: locatorOrElement })
|
|
643
|
+
.click()
|
|
644
|
+
.perform();
|
|
645
|
+
} else {
|
|
646
|
+
await locatorOrElement.click();
|
|
647
|
+
}
|
|
648
|
+
// A click may navigate; page.url() should say where it went.
|
|
649
|
+
await this.page.syncUrl();
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async type(locatorOrElement, text) {
|
|
653
|
+
await locatorOrElement.sendKeys(text);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async fill(locatorOrElement, text) {
|
|
657
|
+
await locatorOrElement.clear();
|
|
658
|
+
await locatorOrElement.sendKeys(text);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async focus(locatorOrElement) {
|
|
662
|
+
await this.page.evaluate((el) => el.focus(), locatorOrElement);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// ============================================================================
|
|
666
|
+
// WebDriver and BiDi access
|
|
667
|
+
// ============================================================================
|
|
668
|
+
|
|
669
|
+
/** @returns {Object} The selenium-webdriver WebDriver */
|
|
670
|
+
getDriver() {
|
|
671
|
+
return this.page.driver;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** @returns {Promise<Object|null>} The BiDi connection, null for classic */
|
|
675
|
+
getBidi() {
|
|
676
|
+
return this.page.bidi();
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Subscribe to a raw BiDi event, e.g. 'log.entryAdded'.
|
|
681
|
+
* @returns {Promise<{unsubscribe: Function}>}
|
|
682
|
+
*/
|
|
683
|
+
onBidiEvent(method, handler) {
|
|
684
|
+
return this.page.subscribeBidi(method, handler);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Receive console messages (BiDi `log.entryAdded`).
|
|
689
|
+
* @param {Function} handler - Receives a Puppeteer-shaped ConsoleMessage
|
|
690
|
+
* @returns {Promise<Function>} Resolves once subscribed, to an unsubscribe
|
|
691
|
+
*/
|
|
692
|
+
onConsoleMessage(handler) {
|
|
693
|
+
return this._listen('console', handler);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Receive top-level navigations (BiDi browsingContext events).
|
|
698
|
+
* @param {Function} handler - Receives the new URL
|
|
699
|
+
* @returns {Promise<Function>} Resolves once subscribed, to an unsubscribe
|
|
700
|
+
*/
|
|
701
|
+
onNavigation(handler) {
|
|
702
|
+
return this._listen('framenavigated', (frame) => handler(frame.url()));
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
async _listen(eventName, handler) {
|
|
706
|
+
await this.page.requireBidi(`page "${eventName}" events`);
|
|
707
|
+
this.page.on(eventName, handler);
|
|
708
|
+
await this.page.eventsReady();
|
|
709
|
+
return () => this.page.off(eventName, handler);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Navigate with BiDi `browsingContext.navigate`.
|
|
714
|
+
* @param {string} url
|
|
715
|
+
* @param {Object} [options] - {wait: 'none'|'interactive'|'complete'}
|
|
716
|
+
*/
|
|
717
|
+
navigate(url, options = {}) {
|
|
718
|
+
return this.page.navigateBidi(url, options);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
@@ -2,10 +2,25 @@ import { isVerboseEnabled } from './logger.js';
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Detect which browser automation engine is being used
|
|
5
|
-
* @param {Object} pageOrContext - Page or context object from Playwright or
|
|
6
|
-
*
|
|
5
|
+
* @param {Object} pageOrContext - Page or context object from Playwright or
|
|
6
|
+
* Puppeteer, a selenium-webdriver WebDriver, or a WebDriverPage over one
|
|
7
|
+
* @returns {string} - 'playwright', 'puppeteer' or 'selenium'
|
|
7
8
|
*/
|
|
8
9
|
export function detectEngine(pageOrContext) {
|
|
10
|
+
// Selenium first: the WebDriverPage facade has $eval/$$eval like Puppeteer,
|
|
11
|
+
// and a bare WebDriver has none of the page methods the checks below use.
|
|
12
|
+
if (
|
|
13
|
+
pageOrContext.isWebDriverPage === true ||
|
|
14
|
+
(typeof pageOrContext.findElements === 'function' &&
|
|
15
|
+
typeof pageOrContext.executeScript === 'function' &&
|
|
16
|
+
typeof pageOrContext.getCurrentUrl === 'function')
|
|
17
|
+
) {
|
|
18
|
+
if (isVerboseEnabled()) {
|
|
19
|
+
console.log('🔍 [ENGINE DETECTION] Detected: selenium');
|
|
20
|
+
}
|
|
21
|
+
return 'selenium';
|
|
22
|
+
}
|
|
23
|
+
|
|
9
24
|
const hasEval = !!pageOrContext.$eval;
|
|
10
25
|
const hasEvalAll = !!pageOrContext.$$eval;
|
|
11
26
|
const locatorType = typeof pageOrContext.locator;
|
|
@@ -44,6 +59,6 @@ export function detectEngine(pageOrContext) {
|
|
|
44
59
|
console.log('🔍 [ENGINE DETECTION] Could not detect engine!');
|
|
45
60
|
}
|
|
46
61
|
throw new Error(
|
|
47
|
-
'Unknown browser automation engine. Expected Playwright or Puppeteer page
|
|
62
|
+
'Unknown browser automation engine. Expected a Playwright or Puppeteer page, or a selenium-webdriver WebDriver.'
|
|
48
63
|
);
|
|
49
64
|
}
|
|
@@ -27,6 +27,10 @@ export function isNavigationError(error) {
|
|
|
27
27
|
'Frame was detached',
|
|
28
28
|
'context was destroyed',
|
|
29
29
|
'Page crashed',
|
|
30
|
+
// WebDriver (selenium engine): an element or window from the previous
|
|
31
|
+
// document after a navigation.
|
|
32
|
+
'stale element reference',
|
|
33
|
+
'no such window',
|
|
30
34
|
];
|
|
31
35
|
|
|
32
36
|
return navigationErrorPatterns.some((pattern) =>
|