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/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
|
+
}
|
package/src/cli/serve.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `browser-commander serve --stdio`: JSON-RPC 2.0 over stdin/stdout, one
|
|
3
|
+
* message per line (issue #104). Requests may be pipelined; each response
|
|
4
|
+
* carries its request id, and server notifications (`events.emit`) have none.
|
|
5
|
+
*/
|
|
6
|
+
import { once } from 'node:events';
|
|
7
|
+
import readline from 'node:readline';
|
|
8
|
+
|
|
9
|
+
import { createDispatcher } from './dispatcher.js';
|
|
10
|
+
import { RPC_ERROR, toRpcError } from './rpc-error.js';
|
|
11
|
+
|
|
12
|
+
/** How long pending requests may run after stdin closes. */
|
|
13
|
+
export const DEFAULT_DRAIN_TIMEOUT_MS = 5_000;
|
|
14
|
+
|
|
15
|
+
function errorResponse(id, code, message) {
|
|
16
|
+
return { jsonrpc: '2.0', id, error: { code, message } };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isValidId(id) {
|
|
20
|
+
return id === null || typeof id === 'string' || typeof id === 'number';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isRequest(message) {
|
|
24
|
+
return (
|
|
25
|
+
message !== null &&
|
|
26
|
+
typeof message === 'object' &&
|
|
27
|
+
!Array.isArray(message) &&
|
|
28
|
+
message.jsonrpc === '2.0' &&
|
|
29
|
+
typeof message.method === 'string' &&
|
|
30
|
+
(!('id' in message) || isValidId(message.id))
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build the handler for one JSON-RPC message or batch.
|
|
36
|
+
*
|
|
37
|
+
* @param {{dispatch: Function}} dispatcher
|
|
38
|
+
* @returns {function(unknown): Promise<Object|Array<Object>|null>} The response, or null for notifications
|
|
39
|
+
*/
|
|
40
|
+
export function createMessageHandler(dispatcher) {
|
|
41
|
+
async function handleOne(message) {
|
|
42
|
+
if (!isRequest(message)) {
|
|
43
|
+
const id = isValidId(message?.id) ? message.id : null;
|
|
44
|
+
return errorResponse(id, RPC_ERROR.INVALID_REQUEST, 'Invalid request');
|
|
45
|
+
}
|
|
46
|
+
const notification = !('id' in message);
|
|
47
|
+
try {
|
|
48
|
+
const result = await dispatcher.dispatch(
|
|
49
|
+
message.method,
|
|
50
|
+
message.params ?? {}
|
|
51
|
+
);
|
|
52
|
+
return notification
|
|
53
|
+
? null
|
|
54
|
+
: { jsonrpc: '2.0', id: message.id, result: result ?? null };
|
|
55
|
+
} catch (error) {
|
|
56
|
+
return notification
|
|
57
|
+
? null
|
|
58
|
+
: { jsonrpc: '2.0', id: message.id, error: toRpcError(error) };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return async function handleMessage(message) {
|
|
63
|
+
if (!Array.isArray(message)) {
|
|
64
|
+
return await handleOne(message);
|
|
65
|
+
}
|
|
66
|
+
if (message.length === 0) {
|
|
67
|
+
return errorResponse(null, RPC_ERROR.INVALID_REQUEST, 'Empty batch');
|
|
68
|
+
}
|
|
69
|
+
const responses = (await Promise.all(message.map(handleOne))).filter(
|
|
70
|
+
Boolean
|
|
71
|
+
);
|
|
72
|
+
return responses.length > 0 ? responses : null;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Parse one line and answer it.
|
|
78
|
+
*
|
|
79
|
+
* @param {string} line
|
|
80
|
+
* @param {Function} handleMessage - From {@link createMessageHandler}
|
|
81
|
+
* @returns {Promise<Object|Object[]|null>}
|
|
82
|
+
*/
|
|
83
|
+
export async function handleLine(line, handleMessage) {
|
|
84
|
+
if (line.trim() === '') {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
let message;
|
|
88
|
+
try {
|
|
89
|
+
message = JSON.parse(line);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return errorResponse(
|
|
92
|
+
null,
|
|
93
|
+
RPC_ERROR.PARSE_ERROR,
|
|
94
|
+
`Parse error: ${error.message}`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
return await handleMessage(message);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function delay(ms) {
|
|
101
|
+
return new Promise((resolve) => {
|
|
102
|
+
setTimeout(resolve, ms).unref?.();
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Serve JSON-RPC until `input` ends, then close every session.
|
|
108
|
+
*
|
|
109
|
+
* @param {Object} options
|
|
110
|
+
* @param {Object} options.input - Usually process.stdin
|
|
111
|
+
* @param {function(string): void} options.write - Writes one line to stdout
|
|
112
|
+
* @param {Object} [options.dependencies] - Dispatcher dependencies (tests)
|
|
113
|
+
* @param {number} [options.drainTimeoutMs] - Grace for in-flight requests
|
|
114
|
+
* @returns {Promise<void>}
|
|
115
|
+
*/
|
|
116
|
+
export async function serveStdio({
|
|
117
|
+
input,
|
|
118
|
+
write,
|
|
119
|
+
dependencies,
|
|
120
|
+
drainTimeoutMs = DEFAULT_DRAIN_TIMEOUT_MS,
|
|
121
|
+
}) {
|
|
122
|
+
const send = (message) => write(`${JSON.stringify(message)}\n`);
|
|
123
|
+
const dispatcher = createDispatcher({
|
|
124
|
+
dependencies,
|
|
125
|
+
notify: (method, params) => send({ jsonrpc: '2.0', method, params }),
|
|
126
|
+
});
|
|
127
|
+
const handleMessage = createMessageHandler(dispatcher);
|
|
128
|
+
const pending = new Set();
|
|
129
|
+
const lines = readline.createInterface({ input, crlfDelay: Infinity });
|
|
130
|
+
lines.on('line', (line) => {
|
|
131
|
+
const task = handleLine(line, handleMessage)
|
|
132
|
+
.then((response) => {
|
|
133
|
+
if (response) {
|
|
134
|
+
send(response);
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
.catch(() => {});
|
|
138
|
+
pending.add(task);
|
|
139
|
+
task.finally(() => pending.delete(task));
|
|
140
|
+
});
|
|
141
|
+
await once(lines, 'close');
|
|
142
|
+
await Promise.race([Promise.allSettled([...pending]), delay(drainTimeoutMs)]);
|
|
143
|
+
await dispatcher.close();
|
|
144
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser sessions owned by one `serve --stdio` or `run` dispatcher
|
|
3
|
+
* (issue #104). A session is a launched or attached browser with the engine
|
|
4
|
+
* objects behind it: `browser`, `context` and the `page` commands act on.
|
|
5
|
+
*/
|
|
6
|
+
import { invalidParams } from './rpc-error.js';
|
|
7
|
+
|
|
8
|
+
/** `--browser` names mapped to installed-browser channels. */
|
|
9
|
+
export const BROWSER_CHANNELS = Object.freeze({
|
|
10
|
+
chrome: 'chrome',
|
|
11
|
+
edge: 'msedge',
|
|
12
|
+
msedge: 'msedge',
|
|
13
|
+
brave: 'brave',
|
|
14
|
+
chromium: 'chromium',
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const ENGINES = Object.freeze(['playwright', 'puppeteer']);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Launch options that are forwarded to `launchBrowser()` as they are, so the
|
|
21
|
+
* bridge can express everything the old Rust bridge's `launch` operation did.
|
|
22
|
+
*/
|
|
23
|
+
const LAUNCH_PASSTHROUGH = Object.freeze([
|
|
24
|
+
'args',
|
|
25
|
+
'extraArgs',
|
|
26
|
+
'ignoreDefaultArgs',
|
|
27
|
+
'slowMo',
|
|
28
|
+
'colorScheme',
|
|
29
|
+
'channel',
|
|
30
|
+
'storageState',
|
|
31
|
+
'fingerprint',
|
|
32
|
+
'remoteDebuggingPort',
|
|
33
|
+
'automationParity',
|
|
34
|
+
'env',
|
|
35
|
+
'startupTimeout',
|
|
36
|
+
'attach',
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
/** Validate an engine name, defaulting to Playwright. */
|
|
40
|
+
export function resolveEngine(engine = 'playwright') {
|
|
41
|
+
if (!ENGINES.includes(engine)) {
|
|
42
|
+
throw invalidParams(
|
|
43
|
+
`Unsupported engine "${engine}"; expected ${ENGINES.join(' or ')}`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return engine;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resolveChannel(browser) {
|
|
50
|
+
if (browser === undefined) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
const channel = BROWSER_CHANNELS[browser];
|
|
54
|
+
if (!channel) {
|
|
55
|
+
throw invalidParams(
|
|
56
|
+
`Unsupported browser "${browser}"; expected chrome, edge, brave or chromium`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return channel;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function assertStringList(value, name) {
|
|
63
|
+
if (
|
|
64
|
+
value !== undefined &&
|
|
65
|
+
(!Array.isArray(value) || value.some((item) => typeof item !== 'string'))
|
|
66
|
+
) {
|
|
67
|
+
throw invalidParams(`${name} must be an array of strings`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Translate `session.launch` params into `launchBrowser()` options.
|
|
73
|
+
*
|
|
74
|
+
* @param {Object} params - JSON-RPC params
|
|
75
|
+
* @returns {Object} launchBrowser options
|
|
76
|
+
*/
|
|
77
|
+
export function buildLaunchOptions(params = {}) {
|
|
78
|
+
assertStringList(params.restrictions, 'restrictions');
|
|
79
|
+
assertStringList(params.args, 'args');
|
|
80
|
+
const options = {
|
|
81
|
+
engine: resolveEngine(params.engine),
|
|
82
|
+
headless: params.headless === true,
|
|
83
|
+
};
|
|
84
|
+
const channel = resolveChannel(params.browser);
|
|
85
|
+
const defined = {
|
|
86
|
+
launch: params.launch,
|
|
87
|
+
executablePath: params.executablePath,
|
|
88
|
+
userDataDir: params.userDataDir,
|
|
89
|
+
restrictions: params.restrictions,
|
|
90
|
+
channel,
|
|
91
|
+
};
|
|
92
|
+
for (const name of LAUNCH_PASSTHROUGH) {
|
|
93
|
+
defined[name] ??= params[name];
|
|
94
|
+
}
|
|
95
|
+
for (const [key, value] of Object.entries(defined)) {
|
|
96
|
+
if (value !== undefined) {
|
|
97
|
+
options[key] = value;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return options;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build a session from a `launchBrowser()` result.
|
|
105
|
+
*
|
|
106
|
+
* Playwright's real launch returns the default context as `browser` and the
|
|
107
|
+
* CDP-connected `Browser` as `connectedBrowser`; the engine launch returns a
|
|
108
|
+
* persistent context whose `browser()` is null. Puppeteer returns a Browser.
|
|
109
|
+
*/
|
|
110
|
+
export function sessionFromLaunch(engine, launched) {
|
|
111
|
+
const playwright = engine === 'playwright';
|
|
112
|
+
const handle = launched.browser;
|
|
113
|
+
return {
|
|
114
|
+
engine,
|
|
115
|
+
browser: playwright
|
|
116
|
+
? (launched.connectedBrowser ?? handle?.browser?.() ?? null)
|
|
117
|
+
: handle,
|
|
118
|
+
context: playwright ? handle : (handle?.defaultBrowserContext?.() ?? null),
|
|
119
|
+
page: launched.page,
|
|
120
|
+
args: launched.args ?? [],
|
|
121
|
+
close: () => launched.close(),
|
|
122
|
+
connected: false,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Build a session from a `connectBrowser()` result. Closing it only
|
|
128
|
+
* disconnects: the browser keeps running for the next command.
|
|
129
|
+
*/
|
|
130
|
+
export function sessionFromConnect(engine, connected) {
|
|
131
|
+
const { browser, page } = connected;
|
|
132
|
+
const playwright = engine === 'playwright';
|
|
133
|
+
return {
|
|
134
|
+
engine,
|
|
135
|
+
browser,
|
|
136
|
+
context: playwright
|
|
137
|
+
? (page?.context?.() ?? null)
|
|
138
|
+
: (browser?.defaultBrowserContext?.() ?? null),
|
|
139
|
+
page,
|
|
140
|
+
// Playwright's close() on a connectOverCDP browser disconnects;
|
|
141
|
+
// Puppeteer's close() would quit the browser, disconnect() does not.
|
|
142
|
+
close: async () => {
|
|
143
|
+
await connected.downloads?.dispose?.();
|
|
144
|
+
await (playwright ? browser.close() : browser.disconnect());
|
|
145
|
+
},
|
|
146
|
+
connected: true,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Sessions with deterministic ids `s1`, `s2`, … */
|
|
151
|
+
export class SessionTable {
|
|
152
|
+
constructor() {
|
|
153
|
+
this.nextId = 1;
|
|
154
|
+
this.sessions = new Map();
|
|
155
|
+
this.latest = null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Add a session and return its id. */
|
|
159
|
+
add(session) {
|
|
160
|
+
const id = `s${this.nextId++}`;
|
|
161
|
+
this.sessions.set(id, session);
|
|
162
|
+
this.latest = id;
|
|
163
|
+
return id;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The session behind an id (or the latest one when omitted). */
|
|
167
|
+
get(id) {
|
|
168
|
+
const resolved = id ?? this.latest;
|
|
169
|
+
const session = resolved ? this.sessions.get(resolved) : undefined;
|
|
170
|
+
if (!session) {
|
|
171
|
+
throw invalidParams(
|
|
172
|
+
id ? `Unknown session: ${id}` : 'No session; call session.launch first'
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return session;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Close one session and forget it. */
|
|
179
|
+
async close(id) {
|
|
180
|
+
const resolved = id ?? this.latest;
|
|
181
|
+
const session = this.get(resolved);
|
|
182
|
+
this.sessions.delete(resolved);
|
|
183
|
+
if (this.latest === resolved) {
|
|
184
|
+
this.latest = [...this.sessions.keys()].at(-1) ?? null;
|
|
185
|
+
}
|
|
186
|
+
await session.trace?.stop?.().catch(() => {});
|
|
187
|
+
await session.close();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Close every session, ignoring individual failures. */
|
|
191
|
+
async closeAll() {
|
|
192
|
+
const ids = [...this.sessions.keys()];
|
|
193
|
+
await Promise.all(ids.map((id) => this.close(id).catch(() => {})));
|
|
194
|
+
}
|
|
195
|
+
}
|
package/src/core/constants.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The switches Browser Commander added to every launch before issue #103.
|
|
3
|
+
*
|
|
4
|
+
* They are no longer added by default: a launch starts Chrome with only
|
|
5
|
+
* `--user-data-dir` and `--remote-debugging-port`. The list is kept for
|
|
6
|
+
* callers that relied on it and equals the `legacy-defaults` restriction
|
|
7
|
+
* preset, so `launchBrowser({ restrictions: ['legacy-defaults'] })` restores
|
|
8
|
+
* the old command line.
|
|
3
9
|
*/
|
|
4
10
|
export const CHROME_ARGS = [
|
|
5
11
|
'--disable-session-crashed-bubble',
|