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
package/src/cli/args.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command-line parsing for `browser-commander` (docs/cli-and-bridge.md,
|
|
3
|
+
* "Commands"). Built on `node:util` `parseArgs`; option names are kebab-case
|
|
4
|
+
* on the command line and camelCase in the parsed result.
|
|
5
|
+
*/
|
|
6
|
+
import { parseArgs } from 'node:util';
|
|
7
|
+
|
|
8
|
+
/** A malformed command line. The CLI prints it as JSON and exits 64. */
|
|
9
|
+
export class UsageError extends Error {
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'UsageError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const STRING = { type: 'string' };
|
|
17
|
+
const STRINGS = { type: 'string', multiple: true };
|
|
18
|
+
const FLAG = { type: 'boolean' };
|
|
19
|
+
|
|
20
|
+
/** Options that choose and start a browser. */
|
|
21
|
+
const BROWSER_OPTIONS = Object.freeze({
|
|
22
|
+
engine: STRING,
|
|
23
|
+
browser: STRING,
|
|
24
|
+
'executable-path': STRING,
|
|
25
|
+
'user-data-dir': STRING,
|
|
26
|
+
headless: FLAG,
|
|
27
|
+
launch: STRING,
|
|
28
|
+
restriction: STRINGS,
|
|
29
|
+
arg: STRINGS,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** Options accepted by every page command. */
|
|
33
|
+
const PAGE_OPTIONS = Object.freeze({
|
|
34
|
+
...BROWSER_OPTIONS,
|
|
35
|
+
'cdp-endpoint': STRING,
|
|
36
|
+
url: STRING,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Every command: its positional argument names and its options.
|
|
41
|
+
* Two-word commands (`trace start`) are keyed by both words.
|
|
42
|
+
*/
|
|
43
|
+
export const COMMANDS = Object.freeze({
|
|
44
|
+
version: { positionals: [], options: {} },
|
|
45
|
+
launch: {
|
|
46
|
+
positionals: [],
|
|
47
|
+
options: {
|
|
48
|
+
...BROWSER_OPTIONS,
|
|
49
|
+
'keep-open': FLAG,
|
|
50
|
+
attach: STRING,
|
|
51
|
+
from: STRING,
|
|
52
|
+
profile: STRING,
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
open: { positionals: ['url'], options: {} },
|
|
56
|
+
goto: { positionals: ['url'], options: PAGE_OPTIONS },
|
|
57
|
+
click: { positionals: ['selector'], options: PAGE_OPTIONS },
|
|
58
|
+
fill: { positionals: ['selector', 'value'], options: PAGE_OPTIONS },
|
|
59
|
+
eval: { positionals: ['expression'], options: PAGE_OPTIONS },
|
|
60
|
+
screenshot: {
|
|
61
|
+
positionals: ['path'],
|
|
62
|
+
options: { ...PAGE_OPTIONS, 'full-page': FLAG },
|
|
63
|
+
},
|
|
64
|
+
pdf: { positionals: ['path'], options: PAGE_OPTIONS },
|
|
65
|
+
'trace start': { positionals: [], options: { ...PAGE_OPTIONS, out: STRING } },
|
|
66
|
+
'trace stop': { positionals: [], options: { out: STRING } },
|
|
67
|
+
'trace view': { positionals: ['dir'], options: { out: STRING } },
|
|
68
|
+
'cookies import': {
|
|
69
|
+
positionals: [],
|
|
70
|
+
options: {
|
|
71
|
+
...PAGE_OPTIONS,
|
|
72
|
+
from: STRING,
|
|
73
|
+
profile: STRING,
|
|
74
|
+
domain: STRINGS,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
'profile migrate': {
|
|
78
|
+
positionals: [],
|
|
79
|
+
options: {
|
|
80
|
+
from: STRING,
|
|
81
|
+
profile: STRING,
|
|
82
|
+
to: STRING,
|
|
83
|
+
include: STRING,
|
|
84
|
+
domain: STRINGS,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
'profile snapshot': {
|
|
88
|
+
positionals: [],
|
|
89
|
+
options: { from: STRING, profile: STRING, to: STRING },
|
|
90
|
+
},
|
|
91
|
+
attach: {
|
|
92
|
+
positionals: [],
|
|
93
|
+
options: { mode: STRING, port: STRING, timeout: STRING },
|
|
94
|
+
},
|
|
95
|
+
doctor: { positionals: [], options: BROWSER_OPTIONS },
|
|
96
|
+
run: { positionals: ['script'], options: BROWSER_OPTIONS },
|
|
97
|
+
serve: { positionals: [], options: { stdio: FLAG } },
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const GROUPS = new Set(
|
|
101
|
+
Object.keys(COMMANDS)
|
|
102
|
+
.filter((name) => name.includes(' '))
|
|
103
|
+
.map((name) => name.split(' ')[0])
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
/** `executable-path` → `executablePath`. */
|
|
107
|
+
export function camelCase(name) {
|
|
108
|
+
return name.replace(/-([a-z])/gu, (_, letter) => letter.toUpperCase());
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function resolveCommand(argv) {
|
|
112
|
+
const [first, second] = argv;
|
|
113
|
+
if (first === undefined || first.startsWith('-')) {
|
|
114
|
+
throw new UsageError(
|
|
115
|
+
`Missing command; expected one of: ${Object.keys(COMMANDS).join(', ')}`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
if (GROUPS.has(first)) {
|
|
119
|
+
const name = `${first} ${second}`;
|
|
120
|
+
if (!Object.hasOwn(COMMANDS, name)) {
|
|
121
|
+
const choices = Object.keys(COMMANDS)
|
|
122
|
+
.filter((command) => command.startsWith(`${first} `))
|
|
123
|
+
.join(', ');
|
|
124
|
+
throw new UsageError(`Unknown command "${name}"; expected ${choices}`);
|
|
125
|
+
}
|
|
126
|
+
return { name, rest: argv.slice(2) };
|
|
127
|
+
}
|
|
128
|
+
if (!Object.hasOwn(COMMANDS, first)) {
|
|
129
|
+
throw new UsageError(`Unknown command "${first}"`);
|
|
130
|
+
}
|
|
131
|
+
return { name: first, rest: argv.slice(1) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Parse a command line.
|
|
136
|
+
*
|
|
137
|
+
* @param {string[]} argv - Arguments after the program name
|
|
138
|
+
* @returns {{command: string, args: Object<string,string>, options: Object}}
|
|
139
|
+
* @throws {UsageError}
|
|
140
|
+
*/
|
|
141
|
+
export function parseCommandLine(argv) {
|
|
142
|
+
const { name, rest } = resolveCommand(argv);
|
|
143
|
+
const spec = COMMANDS[name];
|
|
144
|
+
let parsed;
|
|
145
|
+
try {
|
|
146
|
+
parsed = parseArgs({
|
|
147
|
+
args: rest,
|
|
148
|
+
options: spec.options,
|
|
149
|
+
allowPositionals: true,
|
|
150
|
+
strict: true,
|
|
151
|
+
});
|
|
152
|
+
} catch (error) {
|
|
153
|
+
throw new UsageError(`${name}: ${error.message}`);
|
|
154
|
+
}
|
|
155
|
+
if (parsed.positionals.length !== spec.positionals.length) {
|
|
156
|
+
const expected = spec.positionals.map((item) => `<${item}>`).join(' ');
|
|
157
|
+
throw new UsageError(
|
|
158
|
+
`Usage: browser-commander ${name}${expected ? ` ${expected}` : ''}`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const args = Object.fromEntries(
|
|
162
|
+
spec.positionals.map((item, index) => [item, parsed.positionals[index]])
|
|
163
|
+
);
|
|
164
|
+
const options = Object.fromEntries(
|
|
165
|
+
Object.entries(parsed.values).map(([key, value]) => [camelCase(key), value])
|
|
166
|
+
);
|
|
167
|
+
return { command: name, args, options };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Throw a usage error unless an option was given. */
|
|
171
|
+
export function requireOption(options, name, command) {
|
|
172
|
+
if (options[name] === undefined || options[name] === '') {
|
|
173
|
+
throw new UsageError(
|
|
174
|
+
`${command} requires --${name.replace(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`)}`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
return options[name];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* `session.launch` params from browser options (`--arg` → `args`,
|
|
182
|
+
* `--restriction` → `restrictions`), with unset options left out.
|
|
183
|
+
*/
|
|
184
|
+
export function launchParams(options) {
|
|
185
|
+
const params = {
|
|
186
|
+
engine: options.engine,
|
|
187
|
+
browser: options.browser,
|
|
188
|
+
executablePath: options.executablePath,
|
|
189
|
+
userDataDir: options.userDataDir,
|
|
190
|
+
headless: options.headless,
|
|
191
|
+
launch: options.launch,
|
|
192
|
+
restrictions: options.restriction,
|
|
193
|
+
args: options.arg,
|
|
194
|
+
};
|
|
195
|
+
return Object.fromEntries(
|
|
196
|
+
Object.entries(params).filter(([, value]) => value !== undefined)
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The `attach` launch param of `launch --attach snapshot --from BROWSER
|
|
202
|
+
* --profile NAME`, or undefined without `--attach`.
|
|
203
|
+
*
|
|
204
|
+
* @throws {UsageError}
|
|
205
|
+
*/
|
|
206
|
+
export function launchAttachParams(options) {
|
|
207
|
+
if (options.attach === undefined) {
|
|
208
|
+
if (options.from !== undefined || options.profile !== undefined) {
|
|
209
|
+
throw new UsageError(
|
|
210
|
+
'launch: --from and --profile need --attach snapshot'
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
if (options.attach !== 'snapshot') {
|
|
216
|
+
throw new UsageError(
|
|
217
|
+
`launch: --attach supports "snapshot", got "${options.attach}"; use attach --mode extension or open <url> for the other modes`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
if (options.userDataDir !== undefined) {
|
|
221
|
+
throw new UsageError(
|
|
222
|
+
'launch: --attach snapshot and --user-data-dir are mutually exclusive'
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
if (options.launch === 'engine') {
|
|
226
|
+
throw new UsageError(
|
|
227
|
+
'launch: --attach snapshot needs --launch real; an engine launch starts its own profile'
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
return Object.fromEntries(
|
|
231
|
+
Object.entries({
|
|
232
|
+
mode: 'snapshot',
|
|
233
|
+
browser: options.from,
|
|
234
|
+
profile: options.profile,
|
|
235
|
+
}).filter(([, value]) => value !== undefined)
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* A non-negative integer option such as `--port 9333`, or undefined.
|
|
241
|
+
*
|
|
242
|
+
* @throws {UsageError}
|
|
243
|
+
*/
|
|
244
|
+
export function integerOption(options, name, command) {
|
|
245
|
+
const value = options[name];
|
|
246
|
+
if (value === undefined) {
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
if (!/^\d+$/u.test(value)) {
|
|
250
|
+
throw new UsageError(`${command}: --${name} must be an integer`);
|
|
251
|
+
}
|
|
252
|
+
return Number.parseInt(value, 10);
|
|
253
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `browser-commander` commands (docs/cli-and-bridge.md, "Commands").
|
|
3
|
+
*
|
|
4
|
+
* Page commands run through the same dispatcher as `serve --stdio`: open a
|
|
5
|
+
* session (attach with `--cdp-endpoint`, or launch a temporary browser), run
|
|
6
|
+
* one high-level method, and close the session again.
|
|
7
|
+
*
|
|
8
|
+
* Every command is `(parsed, io) => {document, exitCode}`. A command that
|
|
9
|
+
* prints its document early (`launch --keep-open`) returns `document: null`.
|
|
10
|
+
*/
|
|
11
|
+
import { readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
integerOption,
|
|
16
|
+
launchAttachParams,
|
|
17
|
+
launchParams,
|
|
18
|
+
requireOption,
|
|
19
|
+
UsageError,
|
|
20
|
+
} from './args.js';
|
|
21
|
+
import { createDispatcher } from './dispatcher.js';
|
|
22
|
+
import { DEFAULT_DEPENDENCIES } from './modules.js';
|
|
23
|
+
import { runScript } from './script.js';
|
|
24
|
+
import { serveStdio } from './serve.js';
|
|
25
|
+
|
|
26
|
+
/** Where `trace stop` asks a running `trace start` to finish. */
|
|
27
|
+
export const TRACE_STOP_MARKER = '.stop';
|
|
28
|
+
|
|
29
|
+
const TRACE_POLL_MS = 250;
|
|
30
|
+
|
|
31
|
+
function done(document, exitCode = 0) {
|
|
32
|
+
return { document, exitCode };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve once any stop source fires: SIGINT/SIGTERM, the end of stdin when
|
|
37
|
+
* `stdin` is given, or any of the `extra` promises.
|
|
38
|
+
*/
|
|
39
|
+
export function waitForStop({ signals, stdin, extra = [] }) {
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
const cleanups = [];
|
|
42
|
+
const finish = () => {
|
|
43
|
+
cleanups.forEach((cleanup) => cleanup());
|
|
44
|
+
resolve();
|
|
45
|
+
};
|
|
46
|
+
const listen = (emitter, event) => {
|
|
47
|
+
emitter.once(event, finish);
|
|
48
|
+
cleanups.push(() => emitter.off(event, finish));
|
|
49
|
+
};
|
|
50
|
+
listen(signals, 'SIGINT');
|
|
51
|
+
listen(signals, 'SIGTERM');
|
|
52
|
+
if (stdin) {
|
|
53
|
+
listen(stdin, 'end');
|
|
54
|
+
listen(stdin, 'close');
|
|
55
|
+
stdin.resume?.();
|
|
56
|
+
}
|
|
57
|
+
extra.forEach((promise) => promise.then(finish, finish));
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Resolve when the browser goes away (the user closed the window). */
|
|
62
|
+
function browserGone(session) {
|
|
63
|
+
const { browser } = session;
|
|
64
|
+
if (typeof browser?.once !== 'function') {
|
|
65
|
+
return new Promise(() => {});
|
|
66
|
+
}
|
|
67
|
+
return new Promise((resolve) => browser.once('disconnected', resolve));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Open a session for a page command, run `work(sessionId, dispatcher)`, and
|
|
72
|
+
* close the session (a connected browser keeps running).
|
|
73
|
+
*/
|
|
74
|
+
async function withPageSession(parsed, io, work, { skipUrl = false } = {}) {
|
|
75
|
+
const { options } = parsed;
|
|
76
|
+
const dispatcher = createDispatcher({ dependencies: io.dependencies });
|
|
77
|
+
try {
|
|
78
|
+
const opened = options.cdpEndpoint
|
|
79
|
+
? await dispatcher.dispatch('session.connect', {
|
|
80
|
+
cdpEndpoint: options.cdpEndpoint,
|
|
81
|
+
engine: options.engine,
|
|
82
|
+
})
|
|
83
|
+
: await dispatcher.dispatch('session.launch', launchParams(options));
|
|
84
|
+
if (options.url && !skipUrl) {
|
|
85
|
+
await dispatcher.dispatch('page.goto', {
|
|
86
|
+
session: opened.session,
|
|
87
|
+
url: options.url,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return await work(opened.session, dispatcher);
|
|
91
|
+
} finally {
|
|
92
|
+
await dispatcher.close();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function pageCommand(method, paramsOf, { skipUrl = false } = {}) {
|
|
97
|
+
return (parsed, io) => {
|
|
98
|
+
// Params are read first, so a usage error never starts a browser.
|
|
99
|
+
const params = paramsOf(parsed);
|
|
100
|
+
return withPageSession(
|
|
101
|
+
parsed,
|
|
102
|
+
io,
|
|
103
|
+
async (session, dispatcher) =>
|
|
104
|
+
done(await dispatcher.dispatch(method, { session, ...params })),
|
|
105
|
+
{ skipUrl }
|
|
106
|
+
);
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function dispatchOnce(io, method, params = {}) {
|
|
111
|
+
const dispatcher = createDispatcher({ dependencies: io.dependencies });
|
|
112
|
+
try {
|
|
113
|
+
return await dispatcher.dispatch(method, params);
|
|
114
|
+
} finally {
|
|
115
|
+
await dispatcher.close();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function version(parsed, io) {
|
|
120
|
+
return done(await dispatchOnce(io, 'version'));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Launch a browser and print its endpoint. Without `--keep-open` the browser
|
|
125
|
+
* is closed right after printing, which is only useful to check a launch.
|
|
126
|
+
*/
|
|
127
|
+
async function launch(parsed, io) {
|
|
128
|
+
const attach = launchAttachParams(parsed.options);
|
|
129
|
+
const dispatcher = createDispatcher({ dependencies: io.dependencies });
|
|
130
|
+
try {
|
|
131
|
+
const opened = await dispatcher.dispatch('session.launch', {
|
|
132
|
+
...launchParams(parsed.options),
|
|
133
|
+
...(attach ? { attach } : {}),
|
|
134
|
+
});
|
|
135
|
+
const session = dispatcher.state.sessions.get(opened.session);
|
|
136
|
+
const document = {
|
|
137
|
+
cdpEndpoint: opened.cdpEndpoint,
|
|
138
|
+
remoteDebuggingPort: opened.remoteDebuggingPort,
|
|
139
|
+
userDataDir: opened.userDataDir,
|
|
140
|
+
temporaryProfile: opened.temporaryProfile,
|
|
141
|
+
args: session.args,
|
|
142
|
+
...(opened.attach ? { attach: opened.attach } : {}),
|
|
143
|
+
};
|
|
144
|
+
if (!parsed.options.keepOpen) {
|
|
145
|
+
return done(document);
|
|
146
|
+
}
|
|
147
|
+
io.print(document);
|
|
148
|
+
await waitForStop({
|
|
149
|
+
signals: io.signals,
|
|
150
|
+
stdin: io.stdin,
|
|
151
|
+
extra: [browserGone(session)],
|
|
152
|
+
});
|
|
153
|
+
return done(null);
|
|
154
|
+
} finally {
|
|
155
|
+
await dispatcher.close();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function open(parsed, io) {
|
|
160
|
+
return done(await dispatchOnce(io, 'open', { url: parsed.args.url }));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function markerAppears(marker, stopped) {
|
|
164
|
+
while (!stopped()) {
|
|
165
|
+
if (await stat(marker).catch(() => null)) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
await new Promise((resolve) => setTimeout(resolve, TRACE_POLL_MS));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Record until `trace stop --out DIR` writes the marker, or SIGINT. */
|
|
173
|
+
function traceStart(parsed, io) {
|
|
174
|
+
const out = path.resolve(requireOption(parsed.options, 'out', 'trace start'));
|
|
175
|
+
const marker = path.join(out, TRACE_STOP_MARKER);
|
|
176
|
+
return withPageSession(parsed, io, async (session, dispatcher) => {
|
|
177
|
+
await rm(marker, { force: true });
|
|
178
|
+
const { trace } = await dispatcher.dispatch('trace.start', {
|
|
179
|
+
session,
|
|
180
|
+
out,
|
|
181
|
+
});
|
|
182
|
+
let stopped = false;
|
|
183
|
+
await waitForStop({
|
|
184
|
+
signals: io.signals,
|
|
185
|
+
extra: [markerAppears(marker, () => stopped)],
|
|
186
|
+
});
|
|
187
|
+
stopped = true;
|
|
188
|
+
await rm(marker, { force: true });
|
|
189
|
+
await dispatcher.dispatch('trace.stop', { session });
|
|
190
|
+
return done({ trace, stopped: true });
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function traceStop(parsed) {
|
|
195
|
+
const out = path.resolve(requireOption(parsed.options, 'out', 'trace stop'));
|
|
196
|
+
await writeFile(path.join(out, TRACE_STOP_MARKER), '');
|
|
197
|
+
return done({ trace: out, stopRequested: true });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function traceView(parsed, io) {
|
|
201
|
+
const bundle = path.resolve(parsed.args.dir);
|
|
202
|
+
const output = parsed.options.out && path.resolve(parsed.options.out);
|
|
203
|
+
const { writeTraceViewer } = { ...DEFAULT_DEPENDENCIES, ...io.dependencies };
|
|
204
|
+
return done({ viewer: await writeTraceViewer(bundle, output) });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function profileMigrate(parsed, io) {
|
|
208
|
+
const { options } = parsed;
|
|
209
|
+
return done(
|
|
210
|
+
await dispatchOnce(io, 'profile.migrate', {
|
|
211
|
+
from: requireOption(options, 'from', 'profile migrate'),
|
|
212
|
+
profile: options.profile,
|
|
213
|
+
to: options.to,
|
|
214
|
+
include: options.include
|
|
215
|
+
?.split(',')
|
|
216
|
+
.map((item) => item.trim())
|
|
217
|
+
.filter(Boolean),
|
|
218
|
+
domains: options.domain,
|
|
219
|
+
})
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Copy a real profile into `--to` (or a new temporary directory). */
|
|
224
|
+
async function profileSnapshot(parsed, io) {
|
|
225
|
+
const { options } = parsed;
|
|
226
|
+
return done(
|
|
227
|
+
await dispatchOnce(io, 'profile.snapshot', {
|
|
228
|
+
from: requireOption(options, 'from', 'profile snapshot'),
|
|
229
|
+
profile: options.profile,
|
|
230
|
+
to: options.to && path.resolve(options.to),
|
|
231
|
+
})
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Wait for the companion extension, print its tabs, and close the relay.
|
|
237
|
+
* `serve --stdio` keeps the relay open instead (`attach` ... `attach.close`).
|
|
238
|
+
*/
|
|
239
|
+
async function attach(parsed, io) {
|
|
240
|
+
const { options } = parsed;
|
|
241
|
+
const mode = requireOption(options, 'mode', 'attach');
|
|
242
|
+
if (mode !== 'extension') {
|
|
243
|
+
throw new UsageError(
|
|
244
|
+
`attach: --mode supports "extension", got "${mode}"; use launch --attach snapshot or open <url> for the other modes`
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
const params = { mode };
|
|
248
|
+
const port = integerOption(options, 'port', 'attach');
|
|
249
|
+
const timeoutMs = integerOption(options, 'timeout', 'attach');
|
|
250
|
+
if (port !== undefined) {
|
|
251
|
+
params.port = port;
|
|
252
|
+
}
|
|
253
|
+
if (timeoutMs !== undefined) {
|
|
254
|
+
params.timeoutMs = timeoutMs;
|
|
255
|
+
}
|
|
256
|
+
return done(await dispatchOnce(io, 'attach', params));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Exit 2 when the report has a difference not listed in limitations.json. */
|
|
260
|
+
async function doctor(parsed, io) {
|
|
261
|
+
const report = await dispatchOnce(io, 'doctor', launchParams(parsed.options));
|
|
262
|
+
const unlisted = report?.ok === false || report?.unlisted?.length > 0;
|
|
263
|
+
return done(report, unlisted ? 2 : 0);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function run(parsed, io) {
|
|
267
|
+
const scriptPath = path.resolve(parsed.args.script);
|
|
268
|
+
let script;
|
|
269
|
+
try {
|
|
270
|
+
script = JSON.parse(await readFile(scriptPath, 'utf8'));
|
|
271
|
+
} catch (error) {
|
|
272
|
+
throw new Error(`Cannot read ${scriptPath}: ${error.message}`, {
|
|
273
|
+
cause: error,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
const { results, failed } = await runScript(script, {
|
|
277
|
+
dependencies: io.dependencies,
|
|
278
|
+
launchDefaults: launchParams(parsed.options),
|
|
279
|
+
});
|
|
280
|
+
return done({ results }, failed ? 1 : 0);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function serve(parsed, io) {
|
|
284
|
+
if (!parsed.options.stdio) {
|
|
285
|
+
throw new UsageError('serve requires --stdio');
|
|
286
|
+
}
|
|
287
|
+
await serveStdio({
|
|
288
|
+
input: io.stdin,
|
|
289
|
+
write: io.write,
|
|
290
|
+
dependencies: io.dependencies,
|
|
291
|
+
});
|
|
292
|
+
return done(null);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Command name to implementation. */
|
|
296
|
+
export const COMMAND_HANDLERS = Object.freeze({
|
|
297
|
+
version,
|
|
298
|
+
launch,
|
|
299
|
+
open,
|
|
300
|
+
goto: pageCommand('page.goto', ({ args }) => ({ url: args.url }), {
|
|
301
|
+
skipUrl: true,
|
|
302
|
+
}),
|
|
303
|
+
click: pageCommand('page.click', ({ args }) => ({
|
|
304
|
+
selector: args.selector,
|
|
305
|
+
})),
|
|
306
|
+
fill: pageCommand('page.fill', ({ args }) => ({
|
|
307
|
+
selector: args.selector,
|
|
308
|
+
value: args.value,
|
|
309
|
+
})),
|
|
310
|
+
eval: pageCommand('page.eval', ({ args }) => ({
|
|
311
|
+
expression: args.expression,
|
|
312
|
+
})),
|
|
313
|
+
screenshot: pageCommand('page.screenshot', ({ args, options }) => ({
|
|
314
|
+
path: args.path,
|
|
315
|
+
fullPage: options.fullPage === true,
|
|
316
|
+
})),
|
|
317
|
+
pdf: pageCommand('page.pdf', ({ args }) => ({ path: args.path })),
|
|
318
|
+
'trace start': traceStart,
|
|
319
|
+
'trace stop': traceStop,
|
|
320
|
+
'trace view': traceView,
|
|
321
|
+
'cookies import': pageCommand('cookies.import', ({ options }) => ({
|
|
322
|
+
from: requireOption(options, 'from', 'cookies import'),
|
|
323
|
+
profile: options.profile,
|
|
324
|
+
domains: options.domain,
|
|
325
|
+
})),
|
|
326
|
+
'profile migrate': profileMigrate,
|
|
327
|
+
'profile snapshot': profileSnapshot,
|
|
328
|
+
attach,
|
|
329
|
+
doctor,
|
|
330
|
+
run,
|
|
331
|
+
serve,
|
|
332
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one dispatcher behind `serve --stdio` and `run` (issue #104).
|
|
3
|
+
*
|
|
4
|
+
* It owns the handle table, the sessions and the event subscriptions of one
|
|
5
|
+
* connection, and maps a JSON-RPC method name and params to a result or a
|
|
6
|
+
* thrown error. Transports (the stdio server and the script runner) only
|
|
7
|
+
* frame messages around it.
|
|
8
|
+
*/
|
|
9
|
+
import { HANDLE_METHODS, removeSubscription } from './handle-methods.js';
|
|
10
|
+
import { HandleTable } from './handles.js';
|
|
11
|
+
import { HIGH_LEVEL_METHODS } from './high-level-methods.js';
|
|
12
|
+
import { DEFAULT_DEPENDENCIES } from './modules.js';
|
|
13
|
+
import { RPC_ERROR, RpcError, invalidParams } from './rpc-error.js';
|
|
14
|
+
import { SessionTable } from './sessions.js';
|
|
15
|
+
|
|
16
|
+
const METHODS = Object.freeze({ ...HIGH_LEVEL_METHODS, ...HANDLE_METHODS });
|
|
17
|
+
|
|
18
|
+
/** Every method name the dispatcher answers. */
|
|
19
|
+
export const METHOD_NAMES = Object.freeze(Object.keys(METHODS).sort());
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Create a dispatcher.
|
|
23
|
+
*
|
|
24
|
+
* @param {Object} [options]
|
|
25
|
+
* @param {Object} [options.dependencies] - Replacements for {@link DEFAULT_DEPENDENCIES}
|
|
26
|
+
* @param {function(string, Object): void} [options.notify] - Sends a server notification (`events.emit`)
|
|
27
|
+
* @returns {{dispatch: function(string, Object=): Promise<unknown>, close: function(): Promise<void>, state: Object}}
|
|
28
|
+
*/
|
|
29
|
+
export function createDispatcher({
|
|
30
|
+
dependencies = {},
|
|
31
|
+
notify = () => {},
|
|
32
|
+
} = {}) {
|
|
33
|
+
const state = {
|
|
34
|
+
dependencies: { ...DEFAULT_DEPENDENCIES, ...dependencies },
|
|
35
|
+
handles: new HandleTable(),
|
|
36
|
+
sessions: new SessionTable(),
|
|
37
|
+
subscriptions: new Map(),
|
|
38
|
+
nextSubscription: 1,
|
|
39
|
+
relay: null,
|
|
40
|
+
notify,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
async function dispatch(method, params = {}) {
|
|
44
|
+
if (typeof method !== 'string' || !Object.hasOwn(METHODS, method)) {
|
|
45
|
+
throw new RpcError(
|
|
46
|
+
RPC_ERROR.METHOD_NOT_FOUND,
|
|
47
|
+
`Unknown method: ${method}`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (
|
|
51
|
+
params === null ||
|
|
52
|
+
typeof params !== 'object' ||
|
|
53
|
+
Array.isArray(params)
|
|
54
|
+
) {
|
|
55
|
+
throw invalidParams('params must be an object');
|
|
56
|
+
}
|
|
57
|
+
return await METHODS[method](state, params);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Remove every subscription, close every session and the relay. */
|
|
61
|
+
async function close() {
|
|
62
|
+
for (const subscription of [...state.subscriptions.keys()]) {
|
|
63
|
+
removeSubscription(state, subscription);
|
|
64
|
+
}
|
|
65
|
+
const { relay } = state;
|
|
66
|
+
state.relay = null;
|
|
67
|
+
await Promise.all([
|
|
68
|
+
state.sessions.closeAll(),
|
|
69
|
+
relay?.close().catch(() => {}),
|
|
70
|
+
]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { dispatch, close, state };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Replace `"$session"` anywhere in params with the latest session id.
|
|
78
|
+
*
|
|
79
|
+
* @param {unknown} value - Step params
|
|
80
|
+
* @param {string|null} session - Latest session id
|
|
81
|
+
* @returns {unknown}
|
|
82
|
+
*/
|
|
83
|
+
export function substituteSession(value, session) {
|
|
84
|
+
if (value === '$session') {
|
|
85
|
+
return session ?? value;
|
|
86
|
+
}
|
|
87
|
+
if (Array.isArray(value)) {
|
|
88
|
+
return value.map((item) => substituteSession(item, session));
|
|
89
|
+
}
|
|
90
|
+
if (value && typeof value === 'object') {
|
|
91
|
+
return Object.fromEntries(
|
|
92
|
+
Object.entries(value).map(([key, item]) => [
|
|
93
|
+
key,
|
|
94
|
+
substituteSession(item, session),
|
|
95
|
+
])
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return value;
|
|
99
|
+
}
|