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.
Files changed (155) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/README.md +216 -32
  3. package/bin/browser-commander.js +16 -0
  4. package/eslint.config.js +10 -0
  5. package/examples/react-test-app/src/App.jsx +24 -23
  6. package/extension/background.js +14 -0
  7. package/extension/manifest.json +12 -0
  8. package/extension/relay-handler.js +353 -0
  9. package/package.json +25 -6
  10. package/scripts/run-tests.mjs +4 -2
  11. package/src/browser/attach/differences.js +131 -0
  12. package/src/browser/attach/extension-relay.js +402 -0
  13. package/src/browser/attach/index.js +81 -0
  14. package/src/browser/attach/snapshot-launch.js +146 -0
  15. package/src/browser/attach/snapshot.js +374 -0
  16. package/src/browser/attach/websocket.js +615 -0
  17. package/src/browser/browser-cookie-cache.js +12 -17
  18. package/src/browser/browser-cookie-credentials.js +6 -9
  19. package/src/browser/browser-cookies.js +5 -2
  20. package/src/browser/cdp-session.js +94 -0
  21. package/src/browser/connector.js +42 -4
  22. package/src/browser/debugging-port.js +155 -0
  23. package/src/browser/launch-options.js +63 -52
  24. package/src/browser/launch-restrictions.json +147 -0
  25. package/src/browser/launcher.js +329 -133
  26. package/src/browser/media.js +5 -0
  27. package/src/browser/migration/bookmarks.js +73 -0
  28. package/src/browser/migration/chromium-crypto.js +89 -0
  29. package/src/browser/migration/cookies.js +155 -0
  30. package/src/browser/migration/extensions.js +187 -0
  31. package/src/browser/migration/firefox-bookmarks.js +144 -0
  32. package/src/browser/migration/firefox-der.js +84 -0
  33. package/src/browser/migration/firefox-nss.js +281 -0
  34. package/src/browser/migration/firefox.js +334 -0
  35. package/src/browser/migration/fs-utils.js +51 -0
  36. package/src/browser/migration/history.js +103 -0
  37. package/src/browser/migration/index.js +307 -0
  38. package/src/browser/migration/os-crypt-keys.js +106 -0
  39. package/src/browser/migration/passwords.js +171 -0
  40. package/src/browser/migration/preferences.js +132 -0
  41. package/src/browser/migration/sqlite-snapshot.js +138 -0
  42. package/src/browser/open-in-user-browser.js +112 -0
  43. package/src/browser/parity.js +439 -0
  44. package/src/browser/profile-directory.js +102 -0
  45. package/src/browser/real-browser.js +513 -306
  46. package/src/browser/restrictions.js +148 -0
  47. package/src/browser/system-browser.js +232 -0
  48. package/src/browser/webdriver.js +572 -0
  49. package/src/cli/args.js +253 -0
  50. package/src/cli/commands.js +332 -0
  51. package/src/cli/dispatcher.js +99 -0
  52. package/src/cli/handle-methods.js +136 -0
  53. package/src/cli/handles.js +273 -0
  54. package/src/cli/high-level-methods.js +310 -0
  55. package/src/cli/main.js +67 -0
  56. package/src/cli/modules.js +100 -0
  57. package/src/cli/rpc-error.js +77 -0
  58. package/src/cli/script.js +77 -0
  59. package/src/cli/serve.js +144 -0
  60. package/src/cli/sessions.js +195 -0
  61. package/src/core/constants.js +7 -1
  62. package/src/core/engine-adapter.js +107 -1
  63. package/src/core/engine-detection.js +18 -3
  64. package/src/core/navigation-safety.js +4 -0
  65. package/src/core/webdriver-conversions.js +347 -0
  66. package/src/core/webdriver-events.js +229 -0
  67. package/src/core/webdriver-page.js +716 -0
  68. package/src/downloads/attach.js +6 -0
  69. package/src/downloads/manager.js +9 -0
  70. package/src/downloads/sources.js +71 -0
  71. package/src/downloads/staging.js +21 -9
  72. package/src/elements/selectors.js +3 -2
  73. package/src/elements/visibility.js +4 -0
  74. package/src/exports.js +61 -2
  75. package/src/factory.js +19 -4
  76. package/src/fingerprint/apply.js +6 -0
  77. package/src/fingerprint/automation-parity.js +7 -8
  78. package/src/fingerprint/limitations.js +1 -1
  79. package/src/fingerprint/limitations.json +45 -2
  80. package/src/parity/harness.js +303 -0
  81. package/src/parity/probe.js +838 -0
  82. package/src/parity/version-page.js +144 -0
  83. package/src/traces/bundle.js +10 -2
  84. package/src/traces/recorder.js +16 -3
  85. package/src/utilities/subprocess.js +199 -0
  86. package/tests/e2e/api-coverage.e2e.test.js +174 -0
  87. package/tests/e2e/cli.e2e.test.js +160 -0
  88. package/tests/e2e/downloads.e2e.test.js +79 -2
  89. package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
  90. package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
  91. package/tests/e2e/measure-parity.e2e.test.js +118 -0
  92. package/tests/e2e/playwright.e2e.test.js +25 -17
  93. package/tests/e2e/puppeteer.e2e.test.js +17 -23
  94. package/tests/e2e/webdriver.e2e.test.js +239 -0
  95. package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
  96. package/tests/helpers/cli-fakes.js +214 -0
  97. package/tests/helpers/declared-api.js +80 -0
  98. package/tests/helpers/e2e-browser.js +40 -14
  99. package/tests/helpers/fake-chrome.js +124 -0
  100. package/tests/helpers/migration-fixtures.js +242 -0
  101. package/tests/helpers/temp-directory.js +24 -0
  102. package/tests/helpers/trace-fixtures.js +18 -0
  103. package/tests/helpers/webdriver-mocks.js +183 -0
  104. package/tests/unit/browser/attach/extension-relay.test.js +360 -0
  105. package/tests/unit/browser/attach/snapshot-launch.test.js +312 -0
  106. package/tests/unit/browser/attach/snapshot.test.js +390 -0
  107. package/tests/unit/browser/attach/websocket.test.js +240 -0
  108. package/tests/unit/browser/browser-cookies.test.js +2 -32
  109. package/tests/unit/browser/cdp-session.test.js +162 -0
  110. package/tests/unit/browser/connector.test.js +23 -0
  111. package/tests/unit/browser/launch-building-blocks.test.js +238 -0
  112. package/tests/unit/browser/launch-options.test.js +41 -22
  113. package/tests/unit/browser/launcher.test.js +281 -102
  114. package/tests/unit/browser/media.test.js +13 -1
  115. package/tests/unit/browser/migration/bookmarks.test.js +69 -0
  116. package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
  117. package/tests/unit/browser/migration/cookies.test.js +94 -0
  118. package/tests/unit/browser/migration/extensions.test.js +104 -0
  119. package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
  120. package/tests/unit/browser/migration/firefox.test.js +139 -0
  121. package/tests/unit/browser/migration/history.test.js +48 -0
  122. package/tests/unit/browser/migration/index.test.js +117 -0
  123. package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
  124. package/tests/unit/browser/migration/passwords.test.js +144 -0
  125. package/tests/unit/browser/migration/preferences.test.js +77 -0
  126. package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
  127. package/tests/unit/browser/open-in-user-browser.test.js +123 -0
  128. package/tests/unit/browser/parity.test.js +281 -0
  129. package/tests/unit/browser/real-browser.test.js +458 -33
  130. package/tests/unit/browser/webdriver.test.js +473 -0
  131. package/tests/unit/cli/bin.test.js +69 -0
  132. package/tests/unit/cli/declared-api.test.js +49 -0
  133. package/tests/unit/cli/dispatcher.test.js +429 -0
  134. package/tests/unit/cli/handles.test.js +170 -0
  135. package/tests/unit/cli/main.test.js +409 -0
  136. package/tests/unit/cli/serve.test.js +167 -0
  137. package/tests/unit/core/webdriver-conversions.test.js +235 -0
  138. package/tests/unit/core/webdriver-page.test.js +534 -0
  139. package/tests/unit/downloads/manager.test.js +82 -9
  140. package/tests/unit/downloads/staging.test.js +24 -2
  141. package/tests/unit/fingerprint/automation-parity.test.js +17 -4
  142. package/tests/unit/fingerprint/limitations.test.js +1 -1
  143. package/tests/unit/parity/harness.test.js +138 -0
  144. package/tests/unit/scripts/ci-timeout-budgets.test.js +4 -0
  145. package/tests/unit/scripts/ci-workflow-policy.test.js +8 -0
  146. package/tests/unit/scripts/feature-parity.test.js +221 -0
  147. package/tests/unit/scripts/npm-release-helpers.test.js +3 -2
  148. package/tests/unit/scripts/pre-commit-config.test.js +2 -1
  149. package/tests/unit/traces/bundle.test.js +24 -3
  150. package/tests/unit/traces/links.test.js +7 -0
  151. package/tests/unit/traces/reader.test.js +11 -4
  152. package/tests/unit/traces/recorder.test.js +4 -0
  153. package/tests/unit/traces/viewer.test.js +4 -0
  154. package/tests/unit/utilities/command-stream-only.test.js +28 -0
  155. package/tests/unit/utilities/subprocess.test.js +80 -0
@@ -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
+ }
@@ -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
+ }
@@ -1,5 +1,11 @@
1
1
  /**
2
- * Common Chrome arguments used across both Playwright and Puppeteer
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',