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
|
@@ -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
|
+
}
|
package/src/traces/bundle.js
CHANGED
|
@@ -58,6 +58,7 @@ export async function openTraceBundle(options = {}) {
|
|
|
58
58
|
|
|
59
59
|
const eventsPath = path.join(root, TRACE_FILES.EVENTS);
|
|
60
60
|
const eventsHandle = await fs.open(eventsPath, 'a', TRACE_FILE_MODE);
|
|
61
|
+
let closeEventsPromise;
|
|
61
62
|
|
|
62
63
|
let written = 0;
|
|
63
64
|
let sequence = 0;
|
|
@@ -69,6 +70,12 @@ export async function openTraceBundle(options = {}) {
|
|
|
69
70
|
const counts = { checkpoints: 0, events: 0, mutationBatches: 0 };
|
|
70
71
|
const problems = [];
|
|
71
72
|
|
|
73
|
+
/** Close the timeline exactly once, after every queued append. */
|
|
74
|
+
function closeEvents() {
|
|
75
|
+
closeEventsPromise ??= appendQueue.then(() => eventsHandle.close());
|
|
76
|
+
return closeEventsPromise;
|
|
77
|
+
}
|
|
78
|
+
|
|
72
79
|
const fits = (bytes) =>
|
|
73
80
|
bytes <= maxResourceBytes && written + bytes <= maxBundleBytes;
|
|
74
81
|
|
|
@@ -308,6 +315,8 @@ export async function openTraceBundle(options = {}) {
|
|
|
308
315
|
writeMutations,
|
|
309
316
|
writeArtifact,
|
|
310
317
|
drop,
|
|
318
|
+
/** Close an unfinished bundle without manufacturing a manifest. */
|
|
319
|
+
abort: closeEvents,
|
|
311
320
|
/**
|
|
312
321
|
* Write the manifest and close the timeline.
|
|
313
322
|
*
|
|
@@ -325,8 +334,7 @@ export async function openTraceBundle(options = {}) {
|
|
|
325
334
|
: manifest.outcome,
|
|
326
335
|
};
|
|
327
336
|
// Anything a listener reported on its way out is still queued.
|
|
328
|
-
await
|
|
329
|
-
await eventsHandle.close();
|
|
337
|
+
await closeEvents();
|
|
330
338
|
await fs.writeFile(
|
|
331
339
|
path.join(root, TRACE_FILES.MANIFEST),
|
|
332
340
|
`${JSON.stringify(finished, null, 2)}\n`,
|
package/src/traces/recorder.js
CHANGED
|
@@ -74,6 +74,18 @@ function normalizeEvents(events) {
|
|
|
74
74
|
return [...new Set(events)];
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Reject a malformed side export before the bundle opens any file handles.
|
|
79
|
+
*
|
|
80
|
+
* @param {Object|null} links - Optional Links Notation export options
|
|
81
|
+
* @returns {void}
|
|
82
|
+
*/
|
|
83
|
+
function validateLinksOptions(links) {
|
|
84
|
+
if (links && (typeof links.output !== 'string' || links.output === '')) {
|
|
85
|
+
throw new Error('trace links require an output path');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
77
89
|
/**
|
|
78
90
|
* Start recording a session.
|
|
79
91
|
*
|
|
@@ -111,6 +123,10 @@ export async function startTrace(options = {}) {
|
|
|
111
123
|
}
|
|
112
124
|
const eventSources = normalizeEvents(events);
|
|
113
125
|
const privacyOptions = normalizePrivacyOptions(privacy);
|
|
126
|
+
// Validate the optional side export before opening the authoritative bundle.
|
|
127
|
+
// Otherwise a rejected start (for example `links: {}`) strands the bundle's
|
|
128
|
+
// events handle because no running trace is returned for the caller to stop.
|
|
129
|
+
validateLinksOptions(links);
|
|
114
130
|
const engine = commander?.engine ?? null;
|
|
115
131
|
const identity = createTraceIdentity({ page });
|
|
116
132
|
|
|
@@ -139,9 +155,6 @@ export async function startTrace(options = {}) {
|
|
|
139
155
|
const startedAt = new Date(now()).toISOString();
|
|
140
156
|
|
|
141
157
|
if (links) {
|
|
142
|
-
if (typeof links.output !== 'string' || links.output === '') {
|
|
143
|
-
throw new Error('trace links require an output path');
|
|
144
|
-
}
|
|
145
158
|
linksSink = await openTraceLinks({
|
|
146
159
|
output: links.output,
|
|
147
160
|
include: links.include,
|
|
@@ -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
|
+
}
|