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,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* High-level JSON-RPC methods (docs/cli-and-bridge.md, "High-level methods").
|
|
3
|
+
* They mirror the CLI commands and return plain JSON, so the same command
|
|
4
|
+
* script gives the same results in JS, Rust and Python.
|
|
5
|
+
*
|
|
6
|
+
* Every method is `(state, params) => result`, where `state` is the
|
|
7
|
+
* dispatcher state built by `createDispatcher()`.
|
|
8
|
+
*/
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { invalidParams, requireString } from './rpc-error.js';
|
|
12
|
+
import {
|
|
13
|
+
buildLaunchOptions,
|
|
14
|
+
resolveEngine,
|
|
15
|
+
sessionFromConnect,
|
|
16
|
+
sessionFromLaunch,
|
|
17
|
+
} from './sessions.js';
|
|
18
|
+
|
|
19
|
+
function sessionOf(state, params) {
|
|
20
|
+
return state.sessions.get(params.session);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function launchSession(state, params) {
|
|
24
|
+
const options = buildLaunchOptions(params);
|
|
25
|
+
const launched = await state.dependencies.launchBrowser(options);
|
|
26
|
+
const session = state.sessions.add(
|
|
27
|
+
sessionFromLaunch(options.engine, launched)
|
|
28
|
+
);
|
|
29
|
+
return {
|
|
30
|
+
session,
|
|
31
|
+
cdpEndpoint: launched.cdpEndpoint ?? null,
|
|
32
|
+
remoteDebuggingPort: launched.remoteDebuggingPort ?? null,
|
|
33
|
+
userDataDir: launched.userDataDir ?? null,
|
|
34
|
+
temporaryProfile: launched.temporaryProfile ?? false,
|
|
35
|
+
...(launched.attach ? { attach: launched.attach } : {}),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function connectSession(state, params) {
|
|
40
|
+
const cdpEndpoint = requireString(params, 'cdpEndpoint');
|
|
41
|
+
const engine = resolveEngine(params.engine);
|
|
42
|
+
const connected = await state.dependencies.connectBrowser({
|
|
43
|
+
engine,
|
|
44
|
+
cdpEndpoint,
|
|
45
|
+
});
|
|
46
|
+
const session = state.sessions.add(sessionFromConnect(engine, connected));
|
|
47
|
+
return { session, cdpEndpoint };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function closeSession(state, params) {
|
|
51
|
+
await state.sessions.close(params.session);
|
|
52
|
+
return { closed: true };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function goto(state, params) {
|
|
56
|
+
const { page } = sessionOf(state, params);
|
|
57
|
+
await page.goto(requireString(params, 'url'));
|
|
58
|
+
return { url: page.url(), title: await page.title() };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function click(state, params) {
|
|
62
|
+
const selector = requireString(params, 'selector');
|
|
63
|
+
await sessionOf(state, params).page.click(selector);
|
|
64
|
+
return { clicked: selector };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function fill(state, params) {
|
|
68
|
+
const selector = requireString(params, 'selector');
|
|
69
|
+
const value = params.value === undefined ? '' : String(params.value);
|
|
70
|
+
const { engine, page } = sessionOf(state, params);
|
|
71
|
+
if (engine === 'playwright') {
|
|
72
|
+
await page.fill(selector, value);
|
|
73
|
+
} else {
|
|
74
|
+
await page.locator(selector).fill(value);
|
|
75
|
+
}
|
|
76
|
+
return { filled: selector, value };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function evaluate(state, params) {
|
|
80
|
+
const expression = requireString(params, 'expression');
|
|
81
|
+
const value = await sessionOf(state, params).page.evaluate(expression);
|
|
82
|
+
return { value: value === undefined ? null : value };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Write to `path` when given, otherwise return the bytes inline. */
|
|
86
|
+
async function capture(params, write) {
|
|
87
|
+
if (params.path !== undefined) {
|
|
88
|
+
const target = path.resolve(requireString(params, 'path'));
|
|
89
|
+
const bytes = await write({ path: target });
|
|
90
|
+
return { path: target, bytes: bytes.length };
|
|
91
|
+
}
|
|
92
|
+
const bytes = await write({});
|
|
93
|
+
return { data: { $binary: Buffer.from(bytes).toString('base64') } };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function screenshot(state, params) {
|
|
97
|
+
const { page } = sessionOf(state, params);
|
|
98
|
+
return capture(params, (options) =>
|
|
99
|
+
page.screenshot({ ...options, fullPage: params.fullPage === true })
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function pdf(state, params) {
|
|
104
|
+
const { page } = sessionOf(state, params);
|
|
105
|
+
return capture(params, (options) => page.pdf(options));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function traceStart(state, params) {
|
|
109
|
+
const session = sessionOf(state, params);
|
|
110
|
+
if (session.trace) {
|
|
111
|
+
throw invalidParams('A trace is already recording in this session');
|
|
112
|
+
}
|
|
113
|
+
const output = path.resolve(requireString(params, 'out'));
|
|
114
|
+
session.trace = await state.dependencies.startTrace({
|
|
115
|
+
page: session.page,
|
|
116
|
+
output,
|
|
117
|
+
});
|
|
118
|
+
return { trace: session.trace.path ?? output };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function traceStop(state, params) {
|
|
122
|
+
const session = sessionOf(state, params);
|
|
123
|
+
if (!session.trace) {
|
|
124
|
+
throw invalidParams('No trace is recording in this session');
|
|
125
|
+
}
|
|
126
|
+
const { trace } = session;
|
|
127
|
+
session.trace = null;
|
|
128
|
+
await trace.stop();
|
|
129
|
+
return { trace: trace.path };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function cookieKey(cookie) {
|
|
133
|
+
return `${cookie.domain}\t${cookie.path}\t${cookie.name}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function readCookies(state, params) {
|
|
137
|
+
const from = requireString(params, 'from');
|
|
138
|
+
const domains = params.domains ?? [];
|
|
139
|
+
if (!Array.isArray(domains)) {
|
|
140
|
+
throw invalidParams('domains must be an array of strings');
|
|
141
|
+
}
|
|
142
|
+
const filters = domains.length > 0 ? domains : [undefined];
|
|
143
|
+
const unique = new Map();
|
|
144
|
+
for (const domainFilter of filters) {
|
|
145
|
+
const cookies = await state.dependencies.readBrowserCookies({
|
|
146
|
+
browser: from,
|
|
147
|
+
profile: params.profile,
|
|
148
|
+
domainFilter,
|
|
149
|
+
ignoreDecryptionErrors: true,
|
|
150
|
+
});
|
|
151
|
+
for (const cookie of cookies) {
|
|
152
|
+
unique.set(cookieKey(cookie), cookie);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return [...unique.values()];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function cookieAdder({ engine, context, page }) {
|
|
159
|
+
return engine === 'playwright'
|
|
160
|
+
? (cookies) => context.addCookies(cookies)
|
|
161
|
+
: (cookies) => page.setCookie(...cookies);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Import cookies from an installed browser profile into the session. A batch
|
|
166
|
+
* that the engine rejects is retried one cookie at a time, so one malformed
|
|
167
|
+
* cookie is reported in `skipped` instead of failing the whole import.
|
|
168
|
+
*/
|
|
169
|
+
async function importCookies(state, params) {
|
|
170
|
+
const session = sessionOf(state, params);
|
|
171
|
+
const cookies = await readCookies(state, params);
|
|
172
|
+
const add = cookieAdder(session);
|
|
173
|
+
if (cookies.length === 0) {
|
|
174
|
+
return { imported: 0, skipped: [] };
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
await add(cookies);
|
|
178
|
+
return { imported: cookies.length, skipped: [] };
|
|
179
|
+
} catch {
|
|
180
|
+
const skipped = [];
|
|
181
|
+
for (const cookie of cookies) {
|
|
182
|
+
await add([cookie]).catch((error) =>
|
|
183
|
+
skipped.push({
|
|
184
|
+
item: `${cookie.domain} ${cookie.name}`,
|
|
185
|
+
reason: error.message,
|
|
186
|
+
})
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
return { imported: cookies.length - skipped.length, skipped };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function migrateProfile(state, params) {
|
|
194
|
+
return state.dependencies.migrateProfile({
|
|
195
|
+
from: params.from,
|
|
196
|
+
profile: params.profile,
|
|
197
|
+
to: params.to,
|
|
198
|
+
include: params.include,
|
|
199
|
+
domains: params.domains,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function snapshotProfile(state, params) {
|
|
204
|
+
return state.dependencies.snapshotUserDataDir({
|
|
205
|
+
browser: requireString(params, 'from'),
|
|
206
|
+
profile: params.profile,
|
|
207
|
+
userDataDir: params.userDataDir,
|
|
208
|
+
to: params.to,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function relayOf(state) {
|
|
213
|
+
if (!state.relay) {
|
|
214
|
+
throw invalidParams('No extension is attached; call attach first');
|
|
215
|
+
}
|
|
216
|
+
return state.relay;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Start the extension relay and wait for the companion extension. The relay
|
|
221
|
+
* stays open until `attach.close` or the end of the connection.
|
|
222
|
+
*/
|
|
223
|
+
async function attach(state, params) {
|
|
224
|
+
if (params.mode !== 'extension') {
|
|
225
|
+
throw invalidParams(
|
|
226
|
+
`attach supports mode "extension", got ${JSON.stringify(params.mode)}; use session.launch with attach for a snapshot and open for the user's browser`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
if (state.relay) {
|
|
230
|
+
throw invalidParams('An extension is already attached; call attach.close');
|
|
231
|
+
}
|
|
232
|
+
const relay = await state.dependencies.attachUserBrowser({
|
|
233
|
+
mode: 'extension',
|
|
234
|
+
port: params.port,
|
|
235
|
+
host: params.host,
|
|
236
|
+
timeoutMs: params.timeoutMs,
|
|
237
|
+
allowedExtensionIds: params.allowedExtensionIds,
|
|
238
|
+
});
|
|
239
|
+
state.relay = relay;
|
|
240
|
+
return {
|
|
241
|
+
mode: relay.mode,
|
|
242
|
+
extension: relay.extension,
|
|
243
|
+
tabs: await relay.tabs(),
|
|
244
|
+
differences: relay.differences,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function attachTabs(state) {
|
|
249
|
+
return await relayOf(state).tabs();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Send one CDP command to a tab of the attached browser. */
|
|
253
|
+
async function attachSend(state, params) {
|
|
254
|
+
const relay = relayOf(state);
|
|
255
|
+
if (!Number.isInteger(params.tabId)) {
|
|
256
|
+
throw invalidParams('tabId must be an integer');
|
|
257
|
+
}
|
|
258
|
+
const session = await relay.session(params.tabId);
|
|
259
|
+
return await session.send(
|
|
260
|
+
requireString(params, 'method'),
|
|
261
|
+
params.params ?? {}
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function attachClose(state) {
|
|
266
|
+
const { relay } = state;
|
|
267
|
+
state.relay = null;
|
|
268
|
+
await relay?.close();
|
|
269
|
+
return { closed: Boolean(relay) };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function open(state, params) {
|
|
273
|
+
const url = requireString(params, 'url');
|
|
274
|
+
const result = await state.dependencies.openInUserBrowser(url);
|
|
275
|
+
return result && typeof result === 'object' ? result : { opened: url };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** `measureParity()` takes launch options: engine, channel, executablePath… */
|
|
279
|
+
function doctor(state, params) {
|
|
280
|
+
return state.dependencies.measureParity(buildLaunchOptions(params));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function version(state) {
|
|
284
|
+
return state.dependencies.packageInfo();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Method name to implementation. */
|
|
288
|
+
export const HIGH_LEVEL_METHODS = Object.freeze({
|
|
289
|
+
'session.launch': launchSession,
|
|
290
|
+
'session.connect': connectSession,
|
|
291
|
+
'session.close': closeSession,
|
|
292
|
+
'page.goto': goto,
|
|
293
|
+
'page.click': click,
|
|
294
|
+
'page.fill': fill,
|
|
295
|
+
'page.eval': evaluate,
|
|
296
|
+
'page.screenshot': screenshot,
|
|
297
|
+
'page.pdf': pdf,
|
|
298
|
+
'trace.start': traceStart,
|
|
299
|
+
'trace.stop': traceStop,
|
|
300
|
+
'cookies.import': importCookies,
|
|
301
|
+
'profile.migrate': migrateProfile,
|
|
302
|
+
'profile.snapshot': snapshotProfile,
|
|
303
|
+
attach,
|
|
304
|
+
'attach.tabs': attachTabs,
|
|
305
|
+
'attach.send': attachSend,
|
|
306
|
+
'attach.close': attachClose,
|
|
307
|
+
open,
|
|
308
|
+
doctor,
|
|
309
|
+
version,
|
|
310
|
+
});
|
package/src/cli/main.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entry point of the `browser-commander` CLI (issue #104).
|
|
3
|
+
*
|
|
4
|
+
* Every command writes exactly one JSON document to stdout. Exit codes: 0
|
|
5
|
+
* success, 1 error, 2 `doctor` found an unlisted difference, 64 usage error.
|
|
6
|
+
*/
|
|
7
|
+
import { parseCommandLine, UsageError } from './args.js';
|
|
8
|
+
import { COMMAND_HANDLERS } from './commands.js';
|
|
9
|
+
import { describeError, RPC_ERROR, RpcError } from './rpc-error.js';
|
|
10
|
+
|
|
11
|
+
/** Process exit codes. */
|
|
12
|
+
export const EXIT_CODES = Object.freeze({
|
|
13
|
+
OK: 0,
|
|
14
|
+
ERROR: 1,
|
|
15
|
+
UNLISTED_DIFFERENCE: 2,
|
|
16
|
+
USAGE: 64,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function isUsageError(error) {
|
|
20
|
+
return (
|
|
21
|
+
error instanceof UsageError ||
|
|
22
|
+
(error instanceof RpcError && error.code === RPC_ERROR.INVALID_PARAMS)
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Run the CLI.
|
|
28
|
+
*
|
|
29
|
+
* @param {string[]} argv - Arguments after the program name
|
|
30
|
+
* @param {Object} [io]
|
|
31
|
+
* @param {{write: function(string): unknown}} [io.stdout=process.stdout]
|
|
32
|
+
* @param {Object} [io.stdin=process.stdin]
|
|
33
|
+
* @param {Object} [io.signals=process] - Emits SIGINT/SIGTERM
|
|
34
|
+
* @param {Object} [io.dependencies] - Dispatcher dependencies (tests)
|
|
35
|
+
* @returns {Promise<number>} The exit code
|
|
36
|
+
*/
|
|
37
|
+
export async function runCli(argv, io = {}) {
|
|
38
|
+
const stdout = io.stdout ?? process.stdout;
|
|
39
|
+
const write = (text) => stdout.write(text);
|
|
40
|
+
const print = (document) => write(`${JSON.stringify(document)}\n`);
|
|
41
|
+
const context = {
|
|
42
|
+
write,
|
|
43
|
+
print,
|
|
44
|
+
stdin: io.stdin ?? process.stdin,
|
|
45
|
+
signals: io.signals ?? process,
|
|
46
|
+
dependencies: io.dependencies,
|
|
47
|
+
};
|
|
48
|
+
try {
|
|
49
|
+
const parsed = parseCommandLine(argv);
|
|
50
|
+
const { document, exitCode } = await COMMAND_HANDLERS[parsed.command](
|
|
51
|
+
parsed,
|
|
52
|
+
context
|
|
53
|
+
);
|
|
54
|
+
if (document !== null) {
|
|
55
|
+
print(document);
|
|
56
|
+
}
|
|
57
|
+
return exitCode;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
const usage = isUsageError(error);
|
|
60
|
+
print({
|
|
61
|
+
error: usage
|
|
62
|
+
? { name: 'UsageError', message: error.message }
|
|
63
|
+
: describeError(error),
|
|
64
|
+
});
|
|
65
|
+
return usage ? EXIT_CODES.USAGE : EXIT_CODES.ERROR;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The library functions the CLI dispatcher calls, loaded on first use.
|
|
3
|
+
*
|
|
4
|
+
* Loading lazily keeps `browser-commander version` fast and lets commands
|
|
5
|
+
* whose module is not installed (an optional engine, or a feature shipped by
|
|
6
|
+
* a later release) fail with a JSON error instead of breaking every command.
|
|
7
|
+
* Tests replace any entry through the dispatcher's `dependencies` object.
|
|
8
|
+
*/
|
|
9
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
10
|
+
|
|
11
|
+
const PACKAGE_JSON = new URL('../../package.json', import.meta.url);
|
|
12
|
+
|
|
13
|
+
/** Import a module, turning "not installed" into a readable error. */
|
|
14
|
+
export async function importOptional(specifier, feature) {
|
|
15
|
+
try {
|
|
16
|
+
return await import(specifier);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (
|
|
19
|
+
error?.code === 'ERR_MODULE_NOT_FOUND' ||
|
|
20
|
+
error?.code === 'MODULE_NOT_FOUND'
|
|
21
|
+
) {
|
|
22
|
+
throw new Error(`${feature} is not available: ${error.message}`, {
|
|
23
|
+
cause: error,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Read the package name and version for `version`. */
|
|
31
|
+
export async function readPackageInfo() {
|
|
32
|
+
const { name, version } = JSON.parse(await readFile(PACKAGE_JSON, 'utf8'));
|
|
33
|
+
return { name, version, language: 'js' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Render a trace bundle's static viewer, into the bundle by default or to
|
|
38
|
+
* `output`.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} bundlePath
|
|
41
|
+
* @param {string} [output]
|
|
42
|
+
* @returns {Promise<string>} The written file
|
|
43
|
+
*/
|
|
44
|
+
export async function writeTraceViewer(bundlePath, output) {
|
|
45
|
+
const viewer = await import('../traces/viewer.js');
|
|
46
|
+
if (!output) {
|
|
47
|
+
return await viewer.writeTraceViewer(bundlePath);
|
|
48
|
+
}
|
|
49
|
+
const { readTrace } = await import('../traces/reader.js');
|
|
50
|
+
const document = await viewer.renderTraceViewer(await readTrace(bundlePath));
|
|
51
|
+
await writeFile(output, document);
|
|
52
|
+
return output;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Default implementations; every entry is a zero-coupling async function. */
|
|
56
|
+
export const DEFAULT_DEPENDENCIES = Object.freeze({
|
|
57
|
+
packageInfo: readPackageInfo,
|
|
58
|
+
launchBrowser: async (options) =>
|
|
59
|
+
(await import('../browser/launcher.js')).launchBrowser(options),
|
|
60
|
+
connectBrowser: async (options) =>
|
|
61
|
+
(await import('../browser/connector.js')).connectBrowser(options),
|
|
62
|
+
startTrace: async (options) =>
|
|
63
|
+
(await import('../traces/recorder.js')).startTrace(options),
|
|
64
|
+
writeTraceViewer,
|
|
65
|
+
readBrowserCookies: async (options) =>
|
|
66
|
+
(await import('../browser/browser-cookies.js')).readBrowserCookies(options),
|
|
67
|
+
loadEngine: (name) => importOptional(name, `The ${name} engine`),
|
|
68
|
+
openInUserBrowser: async (url) =>
|
|
69
|
+
(
|
|
70
|
+
await importOptional(
|
|
71
|
+
'../browser/open-in-user-browser.js',
|
|
72
|
+
'open (openInUserBrowser)'
|
|
73
|
+
)
|
|
74
|
+
).openInUserBrowser(url),
|
|
75
|
+
migrateProfile: async (options) =>
|
|
76
|
+
(
|
|
77
|
+
await importOptional(
|
|
78
|
+
'../browser/migration/index.js',
|
|
79
|
+
'profile migrate (migrateProfile)'
|
|
80
|
+
)
|
|
81
|
+
).migrateProfile(options),
|
|
82
|
+
snapshotUserDataDir: async (options) =>
|
|
83
|
+
(
|
|
84
|
+
await importOptional(
|
|
85
|
+
'../browser/attach/snapshot.js',
|
|
86
|
+
'profile snapshot (snapshotUserDataDir)'
|
|
87
|
+
)
|
|
88
|
+
).snapshotUserDataDir(options),
|
|
89
|
+
attachUserBrowser: async (options) =>
|
|
90
|
+
(
|
|
91
|
+
await importOptional(
|
|
92
|
+
'../browser/attach/index.js',
|
|
93
|
+
'attach (attachUserBrowser)'
|
|
94
|
+
)
|
|
95
|
+
).attachUserBrowser(options),
|
|
96
|
+
measureParity: async (options) =>
|
|
97
|
+
(
|
|
98
|
+
await importOptional('../browser/parity.js', 'doctor (measureParity)')
|
|
99
|
+
).measureParity(options),
|
|
100
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON-RPC 2.0 error codes used by `serve --stdio` and `run` (issue #104).
|
|
3
|
+
* See docs/cli-and-bridge.md, "Errors".
|
|
4
|
+
*/
|
|
5
|
+
export const RPC_ERROR = Object.freeze({
|
|
6
|
+
PARSE_ERROR: -32700,
|
|
7
|
+
INVALID_REQUEST: -32600,
|
|
8
|
+
METHOD_NOT_FOUND: -32601,
|
|
9
|
+
INVALID_PARAMS: -32602,
|
|
10
|
+
ENGINE_ERROR: -32000,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/** An error that carries its JSON-RPC code. */
|
|
14
|
+
export class RpcError extends Error {
|
|
15
|
+
/**
|
|
16
|
+
* @param {number} code - One of {@link RPC_ERROR}
|
|
17
|
+
* @param {string} message - Human-readable message
|
|
18
|
+
* @param {Object} [data] - Optional structured detail
|
|
19
|
+
*/
|
|
20
|
+
constructor(code, message, data) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = 'RpcError';
|
|
23
|
+
this.code = code;
|
|
24
|
+
this.data = data;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Shorthand for an invalid-params error. */
|
|
29
|
+
export function invalidParams(message) {
|
|
30
|
+
return new RpcError(RPC_ERROR.INVALID_PARAMS, message);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A required non-empty string param, or an invalid-params error. */
|
|
34
|
+
export function requireString(params, name) {
|
|
35
|
+
const value = params[name];
|
|
36
|
+
if (typeof value !== 'string' || value === '') {
|
|
37
|
+
throw invalidParams(`${name} must be a non-empty string`);
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `{name, message}` of any thrown value. */
|
|
43
|
+
export function describeError(error) {
|
|
44
|
+
if (error instanceof Error) {
|
|
45
|
+
return { name: error.name, message: error.message };
|
|
46
|
+
}
|
|
47
|
+
return { name: 'Error', message: String(error) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The JSON-RPC `error` member for a thrown value: an {@link RpcError} keeps its
|
|
52
|
+
* code, anything else is an engine error with name, message and stack.
|
|
53
|
+
*
|
|
54
|
+
* @param {unknown} error
|
|
55
|
+
* @param {Object} [options]
|
|
56
|
+
* @param {boolean} [options.stack=true] - Include the stack in `data`
|
|
57
|
+
* @returns {{code: number, message: string, data: (Object|undefined)}}
|
|
58
|
+
*/
|
|
59
|
+
export function toRpcError(error, { stack = true } = {}) {
|
|
60
|
+
if (error instanceof RpcError) {
|
|
61
|
+
return {
|
|
62
|
+
code: error.code,
|
|
63
|
+
message: error.message,
|
|
64
|
+
...(error.data === undefined ? {} : { data: error.data }),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const { name, message } = describeError(error);
|
|
68
|
+
return {
|
|
69
|
+
code: RPC_ERROR.ENGINE_ERROR,
|
|
70
|
+
message,
|
|
71
|
+
data: {
|
|
72
|
+
name,
|
|
73
|
+
message,
|
|
74
|
+
...(stack && error instanceof Error ? { stack: error.stack } : {}),
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `browser-commander run <script.json>`: execute a command script through the
|
|
3
|
+
* same dispatcher as `serve --stdio` (docs/cli-and-bridge.md,
|
|
4
|
+
* "Command scripts").
|
|
5
|
+
*/
|
|
6
|
+
import { createDispatcher, substituteSession } from './dispatcher.js';
|
|
7
|
+
import { toRpcError } from './rpc-error.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Check the script shape: `{"steps": [{"method": "…", "params": {…}}]}`.
|
|
11
|
+
*
|
|
12
|
+
* @param {unknown} script
|
|
13
|
+
* @returns {Object[]} The steps
|
|
14
|
+
*/
|
|
15
|
+
export function validateScript(script) {
|
|
16
|
+
const steps = script?.steps;
|
|
17
|
+
if (!Array.isArray(steps)) {
|
|
18
|
+
throw new TypeError('A command script must be {"steps": [...]}');
|
|
19
|
+
}
|
|
20
|
+
steps.forEach((step, index) => {
|
|
21
|
+
if (typeof step?.method !== 'string') {
|
|
22
|
+
throw new TypeError(`Step ${index + 1} has no "method"`);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
return steps;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function runStep(dispatcher, step, params) {
|
|
29
|
+
try {
|
|
30
|
+
return {
|
|
31
|
+
method: step.method,
|
|
32
|
+
result: (await dispatcher.dispatch(step.method, params)) ?? null,
|
|
33
|
+
};
|
|
34
|
+
} catch (error) {
|
|
35
|
+
// Stacks differ between machines and languages; the name and message
|
|
36
|
+
// are what the cross-language comparison needs.
|
|
37
|
+
return { method: step.method, error: toRpcError(error, { stack: false }) };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Run every step in order. A failed step is recorded and the script goes on,
|
|
43
|
+
* so later steps (such as `session.close`) still run. Sessions left open are
|
|
44
|
+
* closed at the end.
|
|
45
|
+
*
|
|
46
|
+
* @param {Object} script - The parsed command script
|
|
47
|
+
* @param {Object} [options]
|
|
48
|
+
* @param {Object} [options.dependencies] - Dispatcher dependencies (tests)
|
|
49
|
+
* @param {Object} [options.launchDefaults] - `session.launch` params used when a step leaves them out
|
|
50
|
+
* @returns {Promise<{results: Object[], failed: boolean}>}
|
|
51
|
+
*/
|
|
52
|
+
export async function runScript(script, options = {}) {
|
|
53
|
+
const { dependencies, launchDefaults = {} } = options;
|
|
54
|
+
const steps = validateScript(script);
|
|
55
|
+
const dispatcher = createDispatcher({ dependencies });
|
|
56
|
+
const results = [];
|
|
57
|
+
let latestSession = null;
|
|
58
|
+
try {
|
|
59
|
+
for (const step of steps) {
|
|
60
|
+
const params = substituteSession(step.params ?? {}, latestSession);
|
|
61
|
+
const entry = await runStep(
|
|
62
|
+
dispatcher,
|
|
63
|
+
step,
|
|
64
|
+
step.method === 'session.launch'
|
|
65
|
+
? { ...launchDefaults, ...params }
|
|
66
|
+
: params
|
|
67
|
+
);
|
|
68
|
+
if (typeof entry.result?.session === 'string') {
|
|
69
|
+
latestSession = entry.result.session;
|
|
70
|
+
}
|
|
71
|
+
results.push(entry);
|
|
72
|
+
}
|
|
73
|
+
} finally {
|
|
74
|
+
await dispatcher.close();
|
|
75
|
+
}
|
|
76
|
+
return { results, failed: results.some((entry) => 'error' in entry) };
|
|
77
|
+
}
|