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
|
@@ -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
|
@@ -32,7 +32,7 @@ export const TRACE_DIRECTORY_MODE = 0o700;
|
|
|
32
32
|
/**
|
|
33
33
|
* Open a bundle for writing.
|
|
34
34
|
*
|
|
35
|
-
* @param {Object} options - `{output, limits, strict, now, onEvent}`
|
|
35
|
+
* @param {Object} options - `{output, limits, strict, now, monotonic, onEvent}`
|
|
36
36
|
* @returns {Promise<Object>} The bundle writer
|
|
37
37
|
*/
|
|
38
38
|
export async function openTraceBundle(options = {}) {
|
|
@@ -41,6 +41,7 @@ export async function openTraceBundle(options = {}) {
|
|
|
41
41
|
strict = false,
|
|
42
42
|
limits = {},
|
|
43
43
|
now = () => Date.now(),
|
|
44
|
+
monotonic = () => performance.now(),
|
|
44
45
|
onEvent = null,
|
|
45
46
|
} = options;
|
|
46
47
|
|
|
@@ -119,7 +120,7 @@ export async function openTraceBundle(options = {}) {
|
|
|
119
120
|
const record = {
|
|
120
121
|
sequence: ++sequence,
|
|
121
122
|
at: new Date(now()).toISOString(),
|
|
122
|
-
monotonicMs: Math.round(
|
|
123
|
+
monotonicMs: Math.round(monotonic()),
|
|
123
124
|
...event,
|
|
124
125
|
};
|
|
125
126
|
const line = `${JSON.stringify(record)}\n`;
|
package/src/traces/observers.js
CHANGED
|
@@ -51,7 +51,7 @@ export function interactionTarget(argument) {
|
|
|
51
51
|
/**
|
|
52
52
|
* Subscribe to everything a running trace listens to.
|
|
53
53
|
*
|
|
54
|
-
* @param {Object} options - `{commander, page, eventSources, record, note, identity}`
|
|
54
|
+
* @param {Object} options - `{commander, page, eventSources, record, note, identity, now}`
|
|
55
55
|
* @param {Object} [options.commander] - The commander being traced, when there
|
|
56
56
|
* is one; interactions and managed downloads are reached through it
|
|
57
57
|
* @param {Object} options.page - The page being traced
|
|
@@ -61,10 +61,19 @@ export function interactionTarget(argument) {
|
|
|
61
61
|
* @param {Object} [options.identity] - Trace identity, see `identity.js`; the
|
|
62
62
|
* navigation observer is what moves it on to the next navigation, and the
|
|
63
63
|
* interaction observer is what names an action (issue #93)
|
|
64
|
+
* @param {Function} [options.now] - Wall clock in milliseconds, for durations
|
|
64
65
|
* @returns {Function[]} One detach function per attached observer
|
|
65
66
|
*/
|
|
66
67
|
export function attachTimelineObservers(options) {
|
|
67
|
-
const {
|
|
68
|
+
const {
|
|
69
|
+
commander,
|
|
70
|
+
page,
|
|
71
|
+
eventSources,
|
|
72
|
+
record,
|
|
73
|
+
note,
|
|
74
|
+
identity,
|
|
75
|
+
now = () => Date.now(),
|
|
76
|
+
} = options;
|
|
68
77
|
const detachers = [];
|
|
69
78
|
|
|
70
79
|
function subscribe(source, attach) {
|
|
@@ -234,7 +243,7 @@ export function attachTimelineObservers(options) {
|
|
|
234
243
|
}
|
|
235
244
|
originals.set(name, original);
|
|
236
245
|
commander[name] = async (...args) => {
|
|
237
|
-
const startedMs =
|
|
246
|
+
const startedMs = now();
|
|
238
247
|
const target = interactionTarget(args[0]);
|
|
239
248
|
// Named before the call, so everything the action causes can be traced
|
|
240
249
|
// back to it even when the action itself ends in an exception.
|
|
@@ -245,7 +254,7 @@ export function attachTimelineObservers(options) {
|
|
|
245
254
|
actionId,
|
|
246
255
|
action: name,
|
|
247
256
|
target,
|
|
248
|
-
durationMs:
|
|
257
|
+
durationMs: now() - startedMs,
|
|
249
258
|
ok: true,
|
|
250
259
|
});
|
|
251
260
|
return result;
|
|
@@ -254,7 +263,7 @@ export function attachTimelineObservers(options) {
|
|
|
254
263
|
actionId,
|
|
255
264
|
action: name,
|
|
256
265
|
target,
|
|
257
|
-
durationMs:
|
|
266
|
+
durationMs: now() - startedMs,
|
|
258
267
|
ok: false,
|
|
259
268
|
error: error.message,
|
|
260
269
|
});
|
package/src/traces/recorder.js
CHANGED
|
@@ -110,6 +110,7 @@ export async function startTrace(options = {}) {
|
|
|
110
110
|
commanderVersion = null,
|
|
111
111
|
log = commander?.log,
|
|
112
112
|
now = () => Date.now(),
|
|
113
|
+
monotonic,
|
|
113
114
|
} = options;
|
|
114
115
|
|
|
115
116
|
if (!page) {
|
|
@@ -150,6 +151,7 @@ export async function startTrace(options = {}) {
|
|
|
150
151
|
limits,
|
|
151
152
|
strict,
|
|
152
153
|
now,
|
|
154
|
+
monotonic,
|
|
153
155
|
onEvent: (event) => linksSink?.event(event),
|
|
154
156
|
});
|
|
155
157
|
const startedAt = new Date(now()).toISOString();
|
|
@@ -323,6 +325,7 @@ export async function startTrace(options = {}) {
|
|
|
323
325
|
record,
|
|
324
326
|
note,
|
|
325
327
|
identity,
|
|
328
|
+
now,
|
|
326
329
|
});
|
|
327
330
|
|
|
328
331
|
// Registered before the first record, so a navigation that starts in the same
|
package/src/traces/viewer.js
CHANGED
|
@@ -36,7 +36,7 @@ function embed(data) {
|
|
|
36
36
|
);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
const VIEWER_STYLE = `
|
|
39
|
+
export const VIEWER_STYLE = `
|
|
40
40
|
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
|
|
41
41
|
body { margin: 0; display: grid; grid-template-columns: 22rem 1fr;
|
|
42
42
|
grid-template-rows: auto 1fr; height: 100vh; }
|
|
@@ -62,7 +62,7 @@ const VIEWER_STYLE = `
|
|
|
62
62
|
button { font: inherit; }
|
|
63
63
|
`;
|
|
64
64
|
|
|
65
|
-
const VIEWER_SCRIPT = String.raw`
|
|
65
|
+
export const VIEWER_SCRIPT = String.raw`
|
|
66
66
|
const trace = JSON.parse(document.getElementById('trace-data').textContent);
|
|
67
67
|
const timeline = document.getElementById('timeline');
|
|
68
68
|
const frame = document.getElementById('stage');
|
|
@@ -72,6 +72,13 @@ const stepLabel = document.getElementById('step');
|
|
|
72
72
|
let selectedCheckpoint = null;
|
|
73
73
|
let mutationStep = 0;
|
|
74
74
|
|
|
75
|
+
// Everything in the trace is what the recorded page made it, and the policy
|
|
76
|
+
// allows inline handlers for this script, so recorded text is escaped before
|
|
77
|
+
// it is put into the viewer's markup.
|
|
78
|
+
function escapeHtml(value) {
|
|
79
|
+
return String(value).replace(/[&<>"']/g, (c) => '&#' + c.charCodeAt(0) + ';');
|
|
80
|
+
}
|
|
81
|
+
|
|
75
82
|
function checkpointFor(event) {
|
|
76
83
|
if (event.kind === 'checkpoint') return event.index;
|
|
77
84
|
let latest = null;
|
|
@@ -241,15 +248,16 @@ function showCheckpoint(index) {
|
|
|
241
248
|
const state = trace.state[index];
|
|
242
249
|
const previous = trace.state[index - 1];
|
|
243
250
|
details.innerHTML = state
|
|
244
|
-
? '<table><tr><th>url</th><td>' + state.url + '</td></tr>' +
|
|
245
|
-
'<tr><th>title</th><td>' + (state.title || '') + '</td></tr>' +
|
|
251
|
+
? '<table><tr><th>url</th><td>' + escapeHtml(state.url) + '</td></tr>' +
|
|
252
|
+
'<tr><th>title</th><td>' + escapeHtml(state.title || '') + '</td></tr>' +
|
|
246
253
|
'<tr><th>controls</th><td>' + (state.controls || []).length + '</td></tr></table>'
|
|
247
254
|
: '<p>no state captured</p>';
|
|
248
255
|
const changes = trace.diffs[index] || [];
|
|
249
256
|
diffPanel.innerHTML = previous && changes.length
|
|
250
257
|
? '<table><tr><th>control</th><th>before</th><th>after</th></tr>' +
|
|
251
|
-
changes.map((c) => '<tr><td>' + c.path + '</td><td>' +
|
|
252
|
-
|
|
258
|
+
changes.map((c) => '<tr><td>' + escapeHtml(c.path) + '</td><td>' +
|
|
259
|
+
escapeHtml(c.before ?? '') + '</td><td>' + escapeHtml(c.after ?? '') +
|
|
260
|
+
'</td></tr>').join('') + '</table>'
|
|
253
261
|
: '<p>no control changes against the previous checkpoint</p>';
|
|
254
262
|
stepLabel.textContent = (trace.mutations[index] || []).length + ' mutation batches';
|
|
255
263
|
}
|
|
@@ -261,8 +269,8 @@ function select(event, element) {
|
|
|
261
269
|
if (index != null && index !== selectedCheckpoint) showCheckpoint(index);
|
|
262
270
|
if (event.kind !== 'checkpoint') {
|
|
263
271
|
details.innerHTML = '<table>' + Object.entries(event)
|
|
264
|
-
.map(([k, v]) => '<tr><th>' + k + '</th><td>' + String(
|
|
265
|
-
typeof v === 'object' ? JSON.stringify(v) : v).slice(0, 400) + '</td></tr>')
|
|
272
|
+
.map(([k, v]) => '<tr><th>' + escapeHtml(k) + '</th><td>' + escapeHtml(String(
|
|
273
|
+
typeof v === 'object' ? JSON.stringify(v) : v).slice(0, 400)) + '</td></tr>')
|
|
266
274
|
.join('') + '</table>';
|
|
267
275
|
}
|
|
268
276
|
}
|
|
@@ -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
|
+
}
|