browser-commander 0.20.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 +145 -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 +19 -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 +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/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/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 +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/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/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/feature-parity.test.js +221 -0
- package/tests/unit/traces/reader.test.js +1 -0
- package/tests/unit/traces/recorder.test.js +1 -0
- package/tests/unit/utilities/command-stream-only.test.js +28 -0
- package/tests/unit/utilities/subprocess.test.js +80 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read chrome://version of a browser started with the reference command line.
|
|
3
|
+
*
|
|
4
|
+
* The browser rewrites its own command line: headless mode appends
|
|
5
|
+
* `--ozone-platform=headless`, `--use-angle=swiftshader-webgl`,
|
|
6
|
+
* `--no-first-run` and more, and a Linux build may append
|
|
7
|
+
* `--ozone-platform=x11`. chrome://version shows the rewritten line, so the
|
|
8
|
+
* automated browser's line cannot be compared with the argv a person typed.
|
|
9
|
+
* It is compared with what the same binary shows for that argv instead.
|
|
10
|
+
*
|
|
11
|
+
* A page cannot read chrome://version, so this capture needs a debugger. It
|
|
12
|
+
* uses a fixed `--remote-debugging-port` - which issue #101 measured to change
|
|
13
|
+
* nothing a page observes - and speaks raw CDP over the global WebSocket, so
|
|
14
|
+
* no automation engine is involved.
|
|
15
|
+
*/
|
|
16
|
+
import { startProcess } from '../utilities/subprocess.js';
|
|
17
|
+
import { reserveLoopbackPort } from '../browser/debugging-port.js';
|
|
18
|
+
import {
|
|
19
|
+
createTemporaryUserDataDir,
|
|
20
|
+
removeUserDataDir,
|
|
21
|
+
} from '../browser/profile-directory.js';
|
|
22
|
+
|
|
23
|
+
/** Evaluated in chrome://version; returns null until the page has rendered. */
|
|
24
|
+
const READ_VERSION_PAGE = `(() => {
|
|
25
|
+
const text = (id) => (document.getElementById(id)?.textContent ?? '').trim();
|
|
26
|
+
if (!text('command_line')) return null;
|
|
27
|
+
return {
|
|
28
|
+
commandLine: text('command_line'),
|
|
29
|
+
version: text('version'),
|
|
30
|
+
executablePath: text('executable_path'),
|
|
31
|
+
};
|
|
32
|
+
})()`;
|
|
33
|
+
|
|
34
|
+
/** Send CDP commands over one page WebSocket. */
|
|
35
|
+
function openCdpSocket(url) {
|
|
36
|
+
if (typeof globalThis.WebSocket !== 'function') {
|
|
37
|
+
throw new Error('Reading chrome://version needs Node.js 22.4 or newer');
|
|
38
|
+
}
|
|
39
|
+
const socket = new globalThis.WebSocket(url);
|
|
40
|
+
const pending = new Map();
|
|
41
|
+
let nextId = 1;
|
|
42
|
+
socket.addEventListener('message', (event) => {
|
|
43
|
+
const message = JSON.parse(String(event.data));
|
|
44
|
+
const waiter = pending.get(message.id);
|
|
45
|
+
if (typeof waiter === 'function') {
|
|
46
|
+
pending.delete(message.id);
|
|
47
|
+
waiter(message);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
const opened = new Promise((resolve, reject) => {
|
|
51
|
+
socket.addEventListener('open', resolve, { once: true });
|
|
52
|
+
socket.addEventListener('error', reject, { once: true });
|
|
53
|
+
});
|
|
54
|
+
return {
|
|
55
|
+
async send(method, params = {}) {
|
|
56
|
+
await opened;
|
|
57
|
+
const id = nextId++;
|
|
58
|
+
const reply = new Promise((resolve) => pending.set(id, resolve));
|
|
59
|
+
socket.send(JSON.stringify({ id, method, params }));
|
|
60
|
+
const message = await reply;
|
|
61
|
+
if (message.error) {
|
|
62
|
+
throw new Error(`${method}: ${message.error.message}`);
|
|
63
|
+
}
|
|
64
|
+
return message.result;
|
|
65
|
+
},
|
|
66
|
+
close() {
|
|
67
|
+
socket.close();
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function openVersionTarget(port, deadline) {
|
|
73
|
+
while (Date.now() < deadline) {
|
|
74
|
+
try {
|
|
75
|
+
const response = await fetch(
|
|
76
|
+
`http://127.0.0.1:${port}/json/new?chrome://version`,
|
|
77
|
+
{ method: 'PUT' }
|
|
78
|
+
);
|
|
79
|
+
if (response.ok) {
|
|
80
|
+
return await response.json();
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// The DevTools HTTP server is not up yet.
|
|
84
|
+
}
|
|
85
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
86
|
+
}
|
|
87
|
+
throw new Error(`no DevTools endpoint on port ${port}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function evaluateUntilReady(cdp, deadline) {
|
|
91
|
+
while (Date.now() < deadline) {
|
|
92
|
+
const { result } = await cdp.send('Runtime.evaluate', {
|
|
93
|
+
expression: READ_VERSION_PAGE,
|
|
94
|
+
returnByValue: true,
|
|
95
|
+
});
|
|
96
|
+
if (result?.value) {
|
|
97
|
+
return result.value;
|
|
98
|
+
}
|
|
99
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
100
|
+
}
|
|
101
|
+
throw new Error('chrome://version did not render');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Start `executablePath` with the reference command line plus a fixed
|
|
106
|
+
* debugging port and read chrome://version.
|
|
107
|
+
*
|
|
108
|
+
* @param {Object} options
|
|
109
|
+
* @param {string} options.executablePath
|
|
110
|
+
* @param {boolean} [options.headless=false]
|
|
111
|
+
* @param {number} [options.timeout=30000]
|
|
112
|
+
* @returns {Promise<{commandLine: string, version: string, executablePath: string}>}
|
|
113
|
+
*/
|
|
114
|
+
export async function readReferenceVersionPage({
|
|
115
|
+
executablePath,
|
|
116
|
+
headless = false,
|
|
117
|
+
timeout = 30_000,
|
|
118
|
+
start = startProcess,
|
|
119
|
+
}) {
|
|
120
|
+
const userDataDir = await createTemporaryUserDataDir();
|
|
121
|
+
const port = await reserveLoopbackPort();
|
|
122
|
+
const child = start(
|
|
123
|
+
executablePath,
|
|
124
|
+
[
|
|
125
|
+
`--user-data-dir=${userDataDir}`,
|
|
126
|
+
`--remote-debugging-port=${port}`,
|
|
127
|
+
...(headless ? ['--headless=new'] : []),
|
|
128
|
+
'about:blank',
|
|
129
|
+
],
|
|
130
|
+
{ killGrace: 3000 }
|
|
131
|
+
);
|
|
132
|
+
let cdp;
|
|
133
|
+
try {
|
|
134
|
+
const deadline = Date.now() + timeout;
|
|
135
|
+
const target = await openVersionTarget(port, deadline);
|
|
136
|
+
cdp = openCdpSocket(target.webSocketDebuggerUrl);
|
|
137
|
+
return await evaluateUntilReady(cdp, deadline);
|
|
138
|
+
} finally {
|
|
139
|
+
cdp?.close();
|
|
140
|
+
child.kill('SIGTERM');
|
|
141
|
+
await child.exited;
|
|
142
|
+
await removeUserDataDir(userDataDir);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { ProcessRunner } from 'command-stream/process-runner';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Every subprocess Browser Commander starts - browsers, WebDriver servers, the
|
|
5
|
+
* engine bridge, credential tools (`security`, `secret-tool`, `kwallet-query`,
|
|
6
|
+
* `powershell.exe`, `icacls`, `whoami`) and the user's own browser opener -
|
|
7
|
+
* goes through command-stream (issue #104). Its `exec` mode runs the file
|
|
8
|
+
* directly with exact argv boundaries, no shell in between, and gives one
|
|
9
|
+
* consistent surface for streaming output, cancellation and exit codes. The
|
|
10
|
+
* Rust crate uses the `command-stream` crate and the Python package a
|
|
11
|
+
* `subprocess` wrapper with the same two functions.
|
|
12
|
+
*
|
|
13
|
+
* The lightweight `command-stream/process-runner` entry point is used so the
|
|
14
|
+
* optional PTY and terminal-rendering modules are never loaded.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
function runnerFor(file, args, { env, cwd, stdin = 'ignore', killGrace }) {
|
|
18
|
+
return new ProcessRunner(
|
|
19
|
+
{ mode: 'exec', file, args },
|
|
20
|
+
{
|
|
21
|
+
mirror: false,
|
|
22
|
+
capture: true,
|
|
23
|
+
stdin,
|
|
24
|
+
...(env ? { env } : {}),
|
|
25
|
+
...(cwd ? { cwd } : {}),
|
|
26
|
+
...(killGrace === undefined ? {} : { killGrace }),
|
|
27
|
+
}
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Error raised when a command exits with a non-zero status. */
|
|
32
|
+
export class CommandError extends Error {
|
|
33
|
+
constructor(file, args, result) {
|
|
34
|
+
const stderr = String(result.stderr ?? '').trim();
|
|
35
|
+
super(
|
|
36
|
+
`${file} exited with code ${result.code}${stderr ? `: ${stderr}` : ''}`
|
|
37
|
+
);
|
|
38
|
+
this.name = 'CommandError';
|
|
39
|
+
this.file = file;
|
|
40
|
+
this.args = args;
|
|
41
|
+
this.code = result.code;
|
|
42
|
+
this.exitCode = result.code;
|
|
43
|
+
this.stdout = String(result.stdout ?? '');
|
|
44
|
+
this.stderr = String(result.stderr ?? '');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Run a command to completion and return its output.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} file - Executable to run (resolved through PATH)
|
|
52
|
+
* @param {string[]} [args] - Exact arguments
|
|
53
|
+
* @param {Object} [options]
|
|
54
|
+
* @param {Object<string,string>} [options.env] - Environment for the child only
|
|
55
|
+
* @param {string} [options.cwd] - Working directory
|
|
56
|
+
* @param {string|Buffer} [options.input] - Data written to the child's stdin
|
|
57
|
+
* @param {boolean} [options.check=true] - Throw {@link CommandError} on a non-zero exit
|
|
58
|
+
* @returns {Promise<{stdout: string, stderr: string, code: number}>}
|
|
59
|
+
*/
|
|
60
|
+
export async function runCommand(file, args = [], options = {}) {
|
|
61
|
+
const { env, cwd, input, check = true } = options;
|
|
62
|
+
const runner = runnerFor(file, args, {
|
|
63
|
+
env,
|
|
64
|
+
cwd,
|
|
65
|
+
stdin: input === undefined ? 'ignore' : input,
|
|
66
|
+
});
|
|
67
|
+
let result;
|
|
68
|
+
try {
|
|
69
|
+
result = await runner;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
// A missing executable fails the spawn itself; report it like execFile.
|
|
72
|
+
if (error && error.code === undefined) {
|
|
73
|
+
error.code = 'ENOENT';
|
|
74
|
+
}
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
const normalized = {
|
|
78
|
+
stdout: String(result.stdout ?? ''),
|
|
79
|
+
stderr: String(result.stderr ?? ''),
|
|
80
|
+
code: result.code,
|
|
81
|
+
};
|
|
82
|
+
if (check && normalized.code !== 0) {
|
|
83
|
+
throw new CommandError(file, args, normalized);
|
|
84
|
+
}
|
|
85
|
+
return normalized;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Start a long-running process, such as a browser or a driver server.
|
|
90
|
+
*
|
|
91
|
+
* The returned handle has the part of the `ChildProcess` surface the launchers
|
|
92
|
+
* rely on (`pid`, `exitCode`, `kill()`, `stderr`/`stdout` `data` events and an
|
|
93
|
+
* `exit` event), plus an `exited` promise.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} file - Executable path
|
|
96
|
+
* @param {string[]} args - Exact arguments
|
|
97
|
+
* @param {Object} [options]
|
|
98
|
+
* @param {Object<string,string>} [options.env] - Environment for the child only
|
|
99
|
+
* @param {boolean} [options.forwardOutput=false] - Mirror the child's output to this process
|
|
100
|
+
* @param {number} [options.killGrace=2000] - Milliseconds between SIGTERM and SIGKILL
|
|
101
|
+
* @returns {ManagedProcess}
|
|
102
|
+
*/
|
|
103
|
+
export function startProcess(file, args = [], options = {}) {
|
|
104
|
+
const { env, cwd, forwardOutput = false, killGrace = 2000 } = options;
|
|
105
|
+
const runner = runnerFor(file, args, { env, cwd, killGrace });
|
|
106
|
+
return new ManagedProcess(runner, { forwardOutput });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
class OutputChannel {
|
|
110
|
+
constructor() {
|
|
111
|
+
this.listeners = new Set();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
on(event, listener) {
|
|
115
|
+
if (event === 'data') {
|
|
116
|
+
this.listeners.add(listener);
|
|
117
|
+
}
|
|
118
|
+
return this;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
off(event, listener) {
|
|
122
|
+
this.listeners.delete(listener);
|
|
123
|
+
return this;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
emit(chunk) {
|
|
127
|
+
for (const listener of this.listeners) {
|
|
128
|
+
listener(chunk);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Handle for a process started by {@link startProcess}. */
|
|
134
|
+
export class ManagedProcess {
|
|
135
|
+
constructor(runner, { forwardOutput }) {
|
|
136
|
+
this.runner = runner;
|
|
137
|
+
this.exitCode = null;
|
|
138
|
+
this.signalCode = null;
|
|
139
|
+
this.stdout = new OutputChannel();
|
|
140
|
+
this.stderr = new OutputChannel();
|
|
141
|
+
this.exitListeners = new Set();
|
|
142
|
+
runner.on('stdout', (chunk) => {
|
|
143
|
+
if (forwardOutput) {
|
|
144
|
+
process.stdout.write(chunk);
|
|
145
|
+
}
|
|
146
|
+
this.stdout.emit(chunk);
|
|
147
|
+
});
|
|
148
|
+
runner.on('stderr', (chunk) => {
|
|
149
|
+
if (forwardOutput) {
|
|
150
|
+
process.stderr.write(chunk);
|
|
151
|
+
}
|
|
152
|
+
this.stderr.emit(chunk);
|
|
153
|
+
});
|
|
154
|
+
runner.start();
|
|
155
|
+
this.exited = Promise.resolve(runner)
|
|
156
|
+
.catch((error) => ({ code: error?.code ?? 1 }))
|
|
157
|
+
.then((result) => {
|
|
158
|
+
this.exitCode = typeof result.code === 'number' ? result.code : 1;
|
|
159
|
+
for (const listener of this.exitListeners) {
|
|
160
|
+
listener(this.exitCode);
|
|
161
|
+
}
|
|
162
|
+
return this.exitCode;
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Operating system process id, once spawned. */
|
|
167
|
+
get pid() {
|
|
168
|
+
return this.runner.pid;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
on(event, listener) {
|
|
172
|
+
if (event === 'exit') {
|
|
173
|
+
if (this.exitCode !== null) {
|
|
174
|
+
listener(this.exitCode);
|
|
175
|
+
} else {
|
|
176
|
+
this.exitListeners.add(listener);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return this;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
once(event, listener) {
|
|
183
|
+
return this.on(event, listener);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Stop the process: the signal first, then SIGKILL after the grace period.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} [signal='SIGTERM']
|
|
190
|
+
* @returns {boolean} Whether a running process was signalled
|
|
191
|
+
*/
|
|
192
|
+
kill(signal) {
|
|
193
|
+
if (this.exitCode !== null) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
this.runner.kill(signal);
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API coverage of the generic handle methods (issue #104).
|
|
3
|
+
*
|
|
4
|
+
* `serve --stdio` promises that any public Playwright or Puppeteer method can
|
|
5
|
+
* be called through `handle.call`, so the Rust and Python ports need no
|
|
6
|
+
* binding per method. This suite walks the real engine objects through the
|
|
7
|
+
* same dispatcher `serve` uses and asserts that `handle.describe` lists every
|
|
8
|
+
* method the engine declares for that type:
|
|
9
|
+
*
|
|
10
|
+
* - Puppeteer: `puppeteer-core/lib/types.d.ts`.
|
|
11
|
+
* - Playwright: `playwright-core/types/types.d.ts`. Playwright's wire
|
|
12
|
+
* protocol description (`protocol.yml`) is not shipped in the npm
|
|
13
|
+
* package, so the published client API declarations are the reference.
|
|
14
|
+
*
|
|
15
|
+
* When an engine upgrade adds a method, the declarations grow and this test
|
|
16
|
+
* shows whether the bridge reaches it.
|
|
17
|
+
*
|
|
18
|
+
* Run with: npm run test:e2e:api-coverage
|
|
19
|
+
*/
|
|
20
|
+
import { after, before, describe, it } from 'node:test';
|
|
21
|
+
import assert from 'node:assert';
|
|
22
|
+
|
|
23
|
+
import { createDispatcher } from '../../src/cli/dispatcher.js';
|
|
24
|
+
import {
|
|
25
|
+
playwrightDeclarations,
|
|
26
|
+
puppeteerDeclarations,
|
|
27
|
+
} from '../helpers/declared-api.js';
|
|
28
|
+
import { CHROME_LAUNCH_OPTIONS } from '../helpers/e2e-browser.js';
|
|
29
|
+
import { sendHtml, startFixtureHost } from '../helpers/fixture-server.js';
|
|
30
|
+
|
|
31
|
+
const E2E = { skip: !process.env.RUN_E2E, timeout: 180_000 };
|
|
32
|
+
|
|
33
|
+
const PAGE = `<!doctype html><title>API coverage</title>
|
|
34
|
+
<p id="text">coverage</p><iframe srcdoc="<p>inner</p>"></iframe>`;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* How to reach one declared type from the session roots. Each step is
|
|
38
|
+
* `['call', method, args]` or `['get', property]`, run with handle.call or
|
|
39
|
+
* handle.get on the previous handle; `from` names the starting handle.
|
|
40
|
+
*/
|
|
41
|
+
/** Page-level types both engines expose the same way, under their own names. */
|
|
42
|
+
function sharedPaths({ response, request }) {
|
|
43
|
+
return {
|
|
44
|
+
Browser: { from: 'browser', steps: [] },
|
|
45
|
+
BrowserContext: { from: 'context', steps: [] },
|
|
46
|
+
Page: { from: 'page', steps: [] },
|
|
47
|
+
[response]: { from: 'page', steps: [['call', 'goto', ['$url']]] },
|
|
48
|
+
[request]: {
|
|
49
|
+
from: 'page',
|
|
50
|
+
steps: [
|
|
51
|
+
['call', 'goto', ['$url']],
|
|
52
|
+
['call', 'request', []],
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
Frame: { from: 'page', steps: [['call', 'mainFrame', []]] },
|
|
56
|
+
ElementHandle: { from: 'page', steps: [['call', '$', ['#text']]] },
|
|
57
|
+
JSHandle: {
|
|
58
|
+
from: 'page',
|
|
59
|
+
steps: [['call', 'evaluateHandle', [{ $function: '() => window' }]]],
|
|
60
|
+
},
|
|
61
|
+
Locator: { from: 'page', steps: [['call', 'locator', ['#text']]] },
|
|
62
|
+
Keyboard: { from: 'page', steps: [['get', 'keyboard']] },
|
|
63
|
+
Mouse: { from: 'page', steps: [['get', 'mouse']] },
|
|
64
|
+
Touchscreen: { from: 'page', steps: [['get', 'touchscreen']] },
|
|
65
|
+
Coverage: { from: 'page', steps: [['get', 'coverage']] },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const PLAYWRIGHT_PATHS = {
|
|
70
|
+
...sharedPaths({ response: 'Response', request: 'Request' }),
|
|
71
|
+
FrameLocator: { from: 'page', steps: [['call', 'frameLocator', ['iframe']]] },
|
|
72
|
+
Clock: { from: 'page', steps: [['get', 'clock']] },
|
|
73
|
+
APIRequestContext: { from: 'page', steps: [['get', 'request']] },
|
|
74
|
+
Tracing: { from: 'context', steps: [['get', 'tracing']] },
|
|
75
|
+
CDPSession: {
|
|
76
|
+
from: 'context',
|
|
77
|
+
steps: [['call', 'newCDPSession', ['$page']]],
|
|
78
|
+
},
|
|
79
|
+
BrowserType: { from: 'engine', steps: [['get', 'chromium']] },
|
|
80
|
+
APIRequest: { from: 'engine', steps: [['get', 'request']] },
|
|
81
|
+
Selectors: { from: 'engine', steps: [['get', 'selectors']] },
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const PUPPETEER_PATHS = {
|
|
85
|
+
...sharedPaths({ response: 'HTTPResponse', request: 'HTTPRequest' }),
|
|
86
|
+
PuppeteerNode: { from: 'engine', steps: [] },
|
|
87
|
+
Accessibility: { from: 'page', steps: [['get', 'accessibility']] },
|
|
88
|
+
Tracing: { from: 'page', steps: [['get', 'tracing']] },
|
|
89
|
+
Target: { from: 'page', steps: [['call', 'target', []]] },
|
|
90
|
+
CDPSession: { from: 'page', steps: [['call', 'createCDPSession', []]] },
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const ENGINES = {
|
|
94
|
+
playwright: { paths: PLAYWRIGHT_PATHS, declarations: playwrightDeclarations },
|
|
95
|
+
puppeteer: { paths: PUPPETEER_PATHS, declarations: puppeteerDeclarations },
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** Follow one path from the session roots to a handle. */
|
|
99
|
+
async function reach(dispatch, roots, { from, steps }, url) {
|
|
100
|
+
const substitute = (arg) =>
|
|
101
|
+
arg === '$url' ? url : arg === '$page' ? roots.page : arg;
|
|
102
|
+
let handle = roots[from];
|
|
103
|
+
for (const [kind, name, args = []] of steps) {
|
|
104
|
+
handle =
|
|
105
|
+
kind === 'get'
|
|
106
|
+
? await dispatch('handle.get', { handle, property: name })
|
|
107
|
+
: await dispatch('handle.call', {
|
|
108
|
+
handle,
|
|
109
|
+
method: name,
|
|
110
|
+
args: args.map(substitute),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return handle;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const [engine, { paths, declarations }] of Object.entries(ENGINES)) {
|
|
117
|
+
describe(`E2E - handle.describe covers the ${engine} API (issue #104)`, () => {
|
|
118
|
+
let server;
|
|
119
|
+
let dispatcher;
|
|
120
|
+
let roots;
|
|
121
|
+
|
|
122
|
+
before(async () => {
|
|
123
|
+
if (!process.env.RUN_E2E) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
server = await startFixtureHost((_path, _req, res) =>
|
|
127
|
+
sendHtml(res, PAGE)
|
|
128
|
+
);
|
|
129
|
+
dispatcher = createDispatcher();
|
|
130
|
+
const { session } = await dispatcher.dispatch('session.launch', {
|
|
131
|
+
engine,
|
|
132
|
+
headless: true,
|
|
133
|
+
...CHROME_LAUNCH_OPTIONS,
|
|
134
|
+
});
|
|
135
|
+
roots = {
|
|
136
|
+
...(await dispatcher.dispatch('handle.root', {
|
|
137
|
+
name: `session:${session}`,
|
|
138
|
+
})),
|
|
139
|
+
engine: await dispatcher.dispatch('handle.root', { name: engine }),
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
after(async () => {
|
|
144
|
+
await dispatcher?.close();
|
|
145
|
+
await server?.close();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('every mapped type is still declared by the engine', E2E, () => {
|
|
149
|
+
const declared = declarations();
|
|
150
|
+
const missing = Object.keys(paths).filter((type) => !declared.has(type));
|
|
151
|
+
assert.deepEqual(missing, []);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
for (const type of Object.keys(paths)) {
|
|
155
|
+
it(`${type}: every declared method is reachable`, E2E, async () => {
|
|
156
|
+
const { dispatch } = dispatcher;
|
|
157
|
+
const handle = await reach(
|
|
158
|
+
dispatch,
|
|
159
|
+
roots,
|
|
160
|
+
paths[type],
|
|
161
|
+
server.baseUrl
|
|
162
|
+
);
|
|
163
|
+
assert.ok(handle?.$handle, `${type} did not resolve to a handle`);
|
|
164
|
+
|
|
165
|
+
const described = await dispatch('handle.describe', { handle });
|
|
166
|
+
const available = new Set(described.methods);
|
|
167
|
+
const missing = [...declarations().get(type)].filter(
|
|
168
|
+
(method) => !available.has(method)
|
|
169
|
+
);
|
|
170
|
+
assert.deepEqual(missing, [], `${type} (${described.type})`);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real-browser tests of the `browser-commander` CLI (issue #104).
|
|
3
|
+
*
|
|
4
|
+
* The binary runs as a separate process through command-stream, the way a
|
|
5
|
+
* shell script, a Rust program or a Python program would use it:
|
|
6
|
+
*
|
|
7
|
+
* - `run tests/cli-contract/basic.json` must print, after normalization,
|
|
8
|
+
* exactly `tests/cli-contract/basic.expected.json`. The Rust and Python
|
|
9
|
+
* CLIs are checked against the same file, so all three agree.
|
|
10
|
+
* - `launch --keep-open` leaves a browser running that later `goto` and
|
|
11
|
+
* `eval` commands reach with `--cdp-endpoint`.
|
|
12
|
+
*
|
|
13
|
+
* Run with: npm run test:e2e:cli
|
|
14
|
+
*/
|
|
15
|
+
import { describe, it } from 'node:test';
|
|
16
|
+
import assert from 'node:assert';
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
import { ProcessRunner } from 'command-stream/process-runner';
|
|
21
|
+
|
|
22
|
+
import { normalizeRunOutput } from '../../../tests/cli-contract/normalize.mjs';
|
|
23
|
+
import { runCommand } from '../../src/utilities/subprocess.js';
|
|
24
|
+
import { SANDBOX_ARGS } from '../helpers/e2e-browser.js';
|
|
25
|
+
import { repoPath } from '../helpers/repo.js';
|
|
26
|
+
|
|
27
|
+
const BIN = fileURLToPath(
|
|
28
|
+
new URL('../../bin/browser-commander.js', import.meta.url)
|
|
29
|
+
);
|
|
30
|
+
const ENGINES = ['playwright', 'puppeteer'];
|
|
31
|
+
const E2E = { skip: !process.env.RUN_E2E, timeout: 180_000 };
|
|
32
|
+
|
|
33
|
+
/** Options that pick the test browser: CHROME_PATH and sandbox arguments. */
|
|
34
|
+
function browserOptions(engine) {
|
|
35
|
+
return [
|
|
36
|
+
'--engine',
|
|
37
|
+
engine,
|
|
38
|
+
...(process.env.CHROME_PATH
|
|
39
|
+
? ['--executable-path', process.env.CHROME_PATH]
|
|
40
|
+
: []),
|
|
41
|
+
// "--arg=VALUE": a value that starts with "--" cannot follow a space.
|
|
42
|
+
...SANDBOX_ARGS.map((arg) => `--arg=${arg}`),
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Run the CLI to completion and parse its one JSON document. */
|
|
47
|
+
async function browserCommander(args) {
|
|
48
|
+
const { code, stdout, stderr } = await runCommand(
|
|
49
|
+
process.execPath,
|
|
50
|
+
[BIN, ...args],
|
|
51
|
+
{ check: false }
|
|
52
|
+
);
|
|
53
|
+
let document;
|
|
54
|
+
try {
|
|
55
|
+
document = JSON.parse(stdout);
|
|
56
|
+
} catch {
|
|
57
|
+
throw new Error(`No JSON from ${args.join(' ')}: ${stdout}\n${stderr}`);
|
|
58
|
+
}
|
|
59
|
+
return { code, document };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Start `launch --keep-open` with stdin kept open, and resolve with its
|
|
64
|
+
* document once it has been printed.
|
|
65
|
+
*/
|
|
66
|
+
function startKeptOpenBrowser(engine) {
|
|
67
|
+
const runner = new ProcessRunner(
|
|
68
|
+
{
|
|
69
|
+
mode: 'exec',
|
|
70
|
+
file: process.execPath,
|
|
71
|
+
args: [
|
|
72
|
+
BIN,
|
|
73
|
+
'launch',
|
|
74
|
+
'--keep-open',
|
|
75
|
+
'--headless',
|
|
76
|
+
...browserOptions(engine),
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
{ mirror: false, capture: true, stdin: 'pipe' }
|
|
80
|
+
);
|
|
81
|
+
let output = '';
|
|
82
|
+
const printed = new Promise((resolve, reject) => {
|
|
83
|
+
runner.on('stdout', (chunk) => {
|
|
84
|
+
output += chunk.toString();
|
|
85
|
+
if (output.includes('\n')) {
|
|
86
|
+
resolve(JSON.parse(output.split('\n')[0]));
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
Promise.resolve(runner).then(
|
|
90
|
+
(result) =>
|
|
91
|
+
reject(new Error(`launch exited early: ${output}${result.stderr}`)),
|
|
92
|
+
reject
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
runner.start();
|
|
96
|
+
return { runner, printed };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for (const engine of ENGINES) {
|
|
100
|
+
describe(`E2E - browser-commander CLI with ${engine} (issue #104)`, () => {
|
|
101
|
+
it(
|
|
102
|
+
'run basic.json matches the cross-language expected output',
|
|
103
|
+
E2E,
|
|
104
|
+
async () => {
|
|
105
|
+
const { code, document } = await browserCommander([
|
|
106
|
+
'run',
|
|
107
|
+
repoPath('tests', 'cli-contract', 'basic.json'),
|
|
108
|
+
...browserOptions(engine),
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
const expected = JSON.parse(
|
|
112
|
+
readFileSync(
|
|
113
|
+
repoPath('tests', 'cli-contract', 'basic.expected.json'),
|
|
114
|
+
'utf8'
|
|
115
|
+
)
|
|
116
|
+
);
|
|
117
|
+
assert.deepEqual(normalizeRunOutput(document), expected);
|
|
118
|
+
assert.equal(code, 0);
|
|
119
|
+
}
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
it(
|
|
123
|
+
'launch --keep-open serves goto and eval over --cdp-endpoint',
|
|
124
|
+
E2E,
|
|
125
|
+
async () => {
|
|
126
|
+
const { runner, printed } = startKeptOpenBrowser(engine);
|
|
127
|
+
try {
|
|
128
|
+
const launched = await printed;
|
|
129
|
+
assert.match(launched.cdpEndpoint, /^http:\/\/127\.0\.0\.1:\d+$/u);
|
|
130
|
+
const attach = [
|
|
131
|
+
'--cdp-endpoint',
|
|
132
|
+
launched.cdpEndpoint,
|
|
133
|
+
'--engine',
|
|
134
|
+
engine,
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
const url = `data:text/html,${encodeURIComponent(
|
|
138
|
+
'<title>Kept open</title><p id="x">42</p>'
|
|
139
|
+
)}`;
|
|
140
|
+
const goto = await browserCommander(['goto', url, ...attach]);
|
|
141
|
+
assert.equal(goto.code, 0);
|
|
142
|
+
assert.deepEqual(goto.document, { url, title: 'Kept open' });
|
|
143
|
+
|
|
144
|
+
// The second command attaches again and sees the page the first
|
|
145
|
+
// one left behind: the browser outlived both commands.
|
|
146
|
+
const evaluated = await browserCommander([
|
|
147
|
+
'eval',
|
|
148
|
+
"document.title + ':' + document.querySelector('#x').textContent",
|
|
149
|
+
...attach,
|
|
150
|
+
]);
|
|
151
|
+
assert.equal(evaluated.code, 0);
|
|
152
|
+
assert.deepEqual(evaluated.document, { value: 'Kept open:42' });
|
|
153
|
+
} finally {
|
|
154
|
+
runner.kill('SIGTERM');
|
|
155
|
+
await Promise.resolve(runner).catch(() => {});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
}
|