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,136 @@
1
+ /**
2
+ * Generic handle methods (docs/cli-and-bridge.md, "Generic handle methods").
3
+ *
4
+ * They reach every public method of Playwright and Puppeteer through remote
5
+ * object handles, so a Rust or Python program - or a shell script - can call
6
+ * `page.route()`, `locator.boundingBox()` or `cdpSession.send()` without a
7
+ * native binding for each of them.
8
+ */
9
+ import { decodeValue, describeObject, encodeValue } from './handles.js';
10
+ import { invalidParams, requireString } from './rpc-error.js';
11
+
12
+ const ENGINE_ROOTS = Object.freeze(['playwright', 'puppeteer']);
13
+
14
+ function targetOf(state, params) {
15
+ const handle = params.handle;
16
+ const id = typeof handle === 'object' && handle ? handle.$handle : handle;
17
+ return state.handles.get(requireString({ id }, 'id'));
18
+ }
19
+
20
+ /** Roots are always handles, even when the engine object is a plain object. */
21
+ function handleOrNull(state, value) {
22
+ return value === null || value === undefined
23
+ ? null
24
+ : state.handles.register(value);
25
+ }
26
+
27
+ /**
28
+ * `playwright` and `puppeteer` resolve to the engine's entry object;
29
+ * `session:<id>` to the session's browser, context and page.
30
+ */
31
+ async function root(state, params) {
32
+ const name = requireString(params, 'name');
33
+ if (name.startsWith('session:')) {
34
+ const session = state.sessions.get(name.slice('session:'.length));
35
+ return {
36
+ browser: handleOrNull(state, session.browser),
37
+ context: handleOrNull(state, session.context),
38
+ page: handleOrNull(state, session.page),
39
+ };
40
+ }
41
+ if (!ENGINE_ROOTS.includes(name)) {
42
+ throw invalidParams(
43
+ `Unknown root "${name}"; expected playwright, puppeteer or session:<id>`
44
+ );
45
+ }
46
+ const module = await state.dependencies.loadEngine(name);
47
+ return state.handles.register(module.default ?? module);
48
+ }
49
+
50
+ async function call(state, params) {
51
+ const target = targetOf(state, params);
52
+ const method = requireString(params, 'method');
53
+ const args = params.args ?? [];
54
+ if (!Array.isArray(args)) {
55
+ throw invalidParams('args must be an array');
56
+ }
57
+ if (typeof target?.[method] !== 'function') {
58
+ throw invalidParams(`${method} is not a method of this handle`);
59
+ }
60
+ const result = await target[method](...decodeValue(args, state.handles));
61
+ return encodeValue(result, state.handles);
62
+ }
63
+
64
+ async function get(state, params) {
65
+ const target = targetOf(state, params);
66
+ const property = requireString(params, 'property');
67
+ const value = target?.[property];
68
+ return encodeValue(
69
+ value instanceof Promise ? await value : value,
70
+ state.handles
71
+ );
72
+ }
73
+
74
+ function dispose(state, params) {
75
+ const id =
76
+ typeof params.handle === 'object' && params.handle
77
+ ? params.handle.$handle
78
+ : params.handle;
79
+ if (!state.handles.dispose(requireString({ id }, 'id'))) {
80
+ throw invalidParams(`Unknown handle: ${id}`);
81
+ }
82
+ return { disposed: true };
83
+ }
84
+
85
+ function describe(state, params) {
86
+ return describeObject(targetOf(state, params));
87
+ }
88
+
89
+ function subscribe(state, params) {
90
+ const target = targetOf(state, params);
91
+ const event = requireString(params, 'event');
92
+ if (typeof target?.on !== 'function') {
93
+ throw invalidParams('This handle does not emit events');
94
+ }
95
+ const subscription = `e${state.nextSubscription++}`;
96
+ const listener = (...args) =>
97
+ state.notify('events.emit', {
98
+ subscription,
99
+ args: args.map((arg) => encodeValue(arg, state.handles)),
100
+ });
101
+ target.on(event, listener);
102
+ state.subscriptions.set(subscription, { target, event, listener });
103
+ return { subscription };
104
+ }
105
+
106
+ /** Remove one subscription's listener. */
107
+ export function removeSubscription(state, subscription) {
108
+ const entry = state.subscriptions.get(subscription);
109
+ if (!entry) {
110
+ return false;
111
+ }
112
+ state.subscriptions.delete(subscription);
113
+ const { target, event, listener } = entry;
114
+ const off = target.off ?? target.removeListener;
115
+ off?.call(target, event, listener);
116
+ return true;
117
+ }
118
+
119
+ function unsubscribe(state, params) {
120
+ const subscription = requireString(params, 'subscription');
121
+ if (!removeSubscription(state, subscription)) {
122
+ throw invalidParams(`Unknown subscription: ${subscription}`);
123
+ }
124
+ return { unsubscribed: true };
125
+ }
126
+
127
+ /** Method name to implementation. */
128
+ export const HANDLE_METHODS = Object.freeze({
129
+ 'handle.root': root,
130
+ 'handle.call': call,
131
+ 'handle.get': get,
132
+ 'handle.dispose': dispose,
133
+ 'handle.describe': describe,
134
+ 'events.subscribe': subscribe,
135
+ 'events.unsubscribe': unsubscribe,
136
+ });
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Remote object handles and the value encoding of the `serve --stdio` bridge
3
+ * (issue #104, docs/cli-and-bridge.md "Value encoding").
4
+ *
5
+ * JSON values cross the bridge unchanged. Anything else - an engine object, a
6
+ * function, a buffer, a date - is tagged, so a client in any language can hold
7
+ * a Playwright `Page` or a Puppeteer `ElementHandle` as `{"$handle": "h3"}`
8
+ * and pass it back as an argument.
9
+ */
10
+ import { invalidParams } from './rpc-error.js';
11
+
12
+ const TAGS = Object.freeze([
13
+ '$handle',
14
+ '$binary',
15
+ '$function',
16
+ '$undefined',
17
+ '$date',
18
+ '$regexp',
19
+ '$bigint',
20
+ '$error',
21
+ ]);
22
+
23
+ function isModuleNamespace(value) {
24
+ return Object.prototype.toString.call(value) === '[object Module]';
25
+ }
26
+
27
+ /** True for `{}` literals and null-prototype records, not class instances. */
28
+ export function isPlainObject(value) {
29
+ if (value === null || typeof value !== 'object' || isModuleNamespace(value)) {
30
+ return false;
31
+ }
32
+ const prototype = Object.getPrototypeOf(value);
33
+ return prototype === Object.prototype || prototype === null;
34
+ }
35
+
36
+ function isBinary(value) {
37
+ return value instanceof Uint8Array || value instanceof ArrayBuffer;
38
+ }
39
+
40
+ /**
41
+ * The class name a client sees for a handle.
42
+ *
43
+ * Bundlers rename colliding classes (`Page2`), so a trailing number after a
44
+ * lowercase letter is dropped to keep the public name.
45
+ */
46
+ export function typeName(value) {
47
+ if (typeof value === 'function') {
48
+ return 'Function';
49
+ }
50
+ const name = value?.constructor?.name;
51
+ if (!name || isModuleNamespace(value)) {
52
+ return 'Object';
53
+ }
54
+ return name.replace(/(?<=[a-z])\d+$/u, '');
55
+ }
56
+
57
+ /**
58
+ * A table of remote objects with deterministic ids (`h1`, `h2`, …).
59
+ *
60
+ * The same object always gets the same id while it is registered, so a page
61
+ * returned by two calls is one handle.
62
+ */
63
+ export class HandleTable {
64
+ constructor() {
65
+ this.nextId = 1;
66
+ this.byId = new Map();
67
+ this.idOf = new Map();
68
+ }
69
+
70
+ /** Register an object (or reuse its id) and return the encoded handle. */
71
+ register(value) {
72
+ let id = this.idOf.get(value);
73
+ if (!id) {
74
+ id = `h${this.nextId++}`;
75
+ this.byId.set(id, value);
76
+ this.idOf.set(value, id);
77
+ }
78
+ return { $handle: id, type: typeName(value) };
79
+ }
80
+
81
+ /** The object behind an id, or an invalid-params error. */
82
+ get(id) {
83
+ if (!this.byId.has(id)) {
84
+ throw invalidParams(`Unknown handle: ${id}`);
85
+ }
86
+ return this.byId.get(id);
87
+ }
88
+
89
+ /** Forget a handle. Returns whether it existed. */
90
+ dispose(id) {
91
+ if (!this.byId.has(id)) {
92
+ return false;
93
+ }
94
+ this.idOf.delete(this.byId.get(id));
95
+ this.byId.delete(id);
96
+ return true;
97
+ }
98
+ }
99
+
100
+ function encodeError(error) {
101
+ return { $error: { name: error.name, message: error.message } };
102
+ }
103
+
104
+ /**
105
+ * Encode a value for the wire.
106
+ *
107
+ * @param {unknown} value
108
+ * @param {HandleTable} handles - Where non-JSON objects are registered
109
+ * @param {Set<Object>} [seen] - Cycle guard for plain objects and arrays
110
+ * @returns {unknown} JSON-compatible value
111
+ */
112
+ export function encodeValue(value, handles, seen = new Set()) {
113
+ if (value === undefined) {
114
+ return { $undefined: true };
115
+ }
116
+ if (value === null || ['boolean', 'string'].includes(typeof value)) {
117
+ return value;
118
+ }
119
+ if (typeof value === 'number') {
120
+ return Number.isFinite(value) ? value : null;
121
+ }
122
+ if (typeof value === 'bigint') {
123
+ return { $bigint: value.toString() };
124
+ }
125
+ if (typeof value === 'symbol') {
126
+ return value.toString();
127
+ }
128
+ if (isBinary(value)) {
129
+ return { $binary: Buffer.from(value).toString('base64') };
130
+ }
131
+ if (value instanceof Date) {
132
+ return { $date: value.toISOString() };
133
+ }
134
+ if (value instanceof RegExp) {
135
+ return { $regexp: { source: value.source, flags: value.flags } };
136
+ }
137
+ if (value instanceof Error) {
138
+ return encodeError(value);
139
+ }
140
+ const container = Array.isArray(value) || isPlainObject(value);
141
+ if (!container || seen.has(value)) {
142
+ return handles.register(value);
143
+ }
144
+ seen.add(value);
145
+ const encoded = Array.isArray(value)
146
+ ? value.map((item) => encodeValue(item, handles, seen))
147
+ : Object.fromEntries(
148
+ Object.entries(value).map(([key, item]) => [
149
+ key,
150
+ encodeValue(item, handles, seen),
151
+ ])
152
+ );
153
+ seen.delete(value);
154
+ return encoded;
155
+ }
156
+
157
+ /**
158
+ * Compile a `$function` source on the server.
159
+ *
160
+ * The result's `toString()` is the original source, which is what both
161
+ * engines serialize when the function is evaluated in the page.
162
+ */
163
+ export function compileFunction(source) {
164
+ if (typeof source !== 'string' || source.trim() === '') {
165
+ throw invalidParams('$function must be a function source string');
166
+ }
167
+ let compiled;
168
+ try {
169
+ compiled = (0, eval)(`(${source})`);
170
+ } catch (error) {
171
+ throw invalidParams(`Invalid $function source: ${error.message}`);
172
+ }
173
+ if (typeof compiled !== 'function') {
174
+ throw invalidParams('$function source did not evaluate to a function');
175
+ }
176
+ return compiled;
177
+ }
178
+
179
+ function decodeError({ name = 'Error', message = '' } = {}) {
180
+ const error = new Error(message);
181
+ error.name = name;
182
+ return error;
183
+ }
184
+
185
+ const DECODERS = {
186
+ $handle: (tag, handles) => handles.get(tag),
187
+ $binary: (tag) => Buffer.from(String(tag), 'base64'),
188
+ $function: (tag) => compileFunction(tag),
189
+ $undefined: () => undefined,
190
+ $date: (tag) => new Date(tag),
191
+ $regexp: (tag) => new RegExp(tag?.source ?? '', tag?.flags ?? ''),
192
+ $bigint: (tag) => BigInt(tag),
193
+ $error: (tag) => decodeError(tag),
194
+ };
195
+
196
+ function tagOf(value) {
197
+ return TAGS.find((tag) => Object.hasOwn(value, tag));
198
+ }
199
+
200
+ /**
201
+ * Decode a value received from a client.
202
+ *
203
+ * @param {unknown} value
204
+ * @param {HandleTable} handles - Resolves `$handle` references
205
+ * @returns {unknown}
206
+ */
207
+ export function decodeValue(value, handles) {
208
+ if (Array.isArray(value)) {
209
+ return value.map((item) => decodeValue(item, handles));
210
+ }
211
+ if (value === null || typeof value !== 'object') {
212
+ return value;
213
+ }
214
+ const tag = tagOf(value);
215
+ if (tag) {
216
+ return DECODERS[tag](value[tag], handles);
217
+ }
218
+ return Object.fromEntries(
219
+ Object.entries(value).map(([key, item]) => [
220
+ key,
221
+ decodeValue(item, handles),
222
+ ])
223
+ );
224
+ }
225
+
226
+ function isPublicName(name) {
227
+ return (
228
+ typeof name === 'string' && name !== 'constructor' && !name.startsWith('_')
229
+ );
230
+ }
231
+
232
+ function collectMembers(target) {
233
+ const methods = new Set();
234
+ const properties = new Set();
235
+ let current = target;
236
+ while (current && current !== Object.prototype) {
237
+ for (const name of Object.getOwnPropertyNames(current)) {
238
+ if (!isPublicName(name) || (current === target && name === 'prototype')) {
239
+ continue;
240
+ }
241
+ const descriptor = Object.getOwnPropertyDescriptor(current, name);
242
+ if (typeof descriptor.value === 'function') {
243
+ methods.add(name);
244
+ } else {
245
+ properties.add(name);
246
+ }
247
+ }
248
+ current = Object.getPrototypeOf(current);
249
+ }
250
+ for (const name of methods) {
251
+ properties.delete(name);
252
+ }
253
+ return { methods, properties };
254
+ }
255
+
256
+ /**
257
+ * Describe a remote object: every callable member on the object and its
258
+ * prototype chain, and its other public properties.
259
+ *
260
+ * @param {unknown} target
261
+ * @returns {{type: string, methods: string[], properties: string[]}}
262
+ */
263
+ export function describeObject(target) {
264
+ const { methods, properties } =
265
+ target !== null && ['object', 'function'].includes(typeof target)
266
+ ? collectMembers(target)
267
+ : { methods: new Set(), properties: new Set() };
268
+ return {
269
+ type: typeName(target),
270
+ methods: [...methods].sort(),
271
+ properties: [...properties].sort(),
272
+ };
273
+ }
@@ -0,0 +1,310 @@
1
+ /**
2
+ * High-level JSON-RPC methods (docs/cli-and-bridge.md, "High-level methods").
3
+ * They mirror the CLI commands and return plain JSON, so the same command
4
+ * script gives the same results in JS, Rust and Python.
5
+ *
6
+ * Every method is `(state, params) => result`, where `state` is the
7
+ * dispatcher state built by `createDispatcher()`.
8
+ */
9
+ import path from 'node:path';
10
+
11
+ import { invalidParams, requireString } from './rpc-error.js';
12
+ import {
13
+ buildLaunchOptions,
14
+ resolveEngine,
15
+ sessionFromConnect,
16
+ sessionFromLaunch,
17
+ } from './sessions.js';
18
+
19
+ function sessionOf(state, params) {
20
+ return state.sessions.get(params.session);
21
+ }
22
+
23
+ async function launchSession(state, params) {
24
+ const options = buildLaunchOptions(params);
25
+ const launched = await state.dependencies.launchBrowser(options);
26
+ const session = state.sessions.add(
27
+ sessionFromLaunch(options.engine, launched)
28
+ );
29
+ return {
30
+ session,
31
+ cdpEndpoint: launched.cdpEndpoint ?? null,
32
+ remoteDebuggingPort: launched.remoteDebuggingPort ?? null,
33
+ userDataDir: launched.userDataDir ?? null,
34
+ temporaryProfile: launched.temporaryProfile ?? false,
35
+ ...(launched.attach ? { attach: launched.attach } : {}),
36
+ };
37
+ }
38
+
39
+ async function connectSession(state, params) {
40
+ const cdpEndpoint = requireString(params, 'cdpEndpoint');
41
+ const engine = resolveEngine(params.engine);
42
+ const connected = await state.dependencies.connectBrowser({
43
+ engine,
44
+ cdpEndpoint,
45
+ });
46
+ const session = state.sessions.add(sessionFromConnect(engine, connected));
47
+ return { session, cdpEndpoint };
48
+ }
49
+
50
+ async function closeSession(state, params) {
51
+ await state.sessions.close(params.session);
52
+ return { closed: true };
53
+ }
54
+
55
+ async function goto(state, params) {
56
+ const { page } = sessionOf(state, params);
57
+ await page.goto(requireString(params, 'url'));
58
+ return { url: page.url(), title: await page.title() };
59
+ }
60
+
61
+ async function click(state, params) {
62
+ const selector = requireString(params, 'selector');
63
+ await sessionOf(state, params).page.click(selector);
64
+ return { clicked: selector };
65
+ }
66
+
67
+ async function fill(state, params) {
68
+ const selector = requireString(params, 'selector');
69
+ const value = params.value === undefined ? '' : String(params.value);
70
+ const { engine, page } = sessionOf(state, params);
71
+ if (engine === 'playwright') {
72
+ await page.fill(selector, value);
73
+ } else {
74
+ await page.locator(selector).fill(value);
75
+ }
76
+ return { filled: selector, value };
77
+ }
78
+
79
+ async function evaluate(state, params) {
80
+ const expression = requireString(params, 'expression');
81
+ const value = await sessionOf(state, params).page.evaluate(expression);
82
+ return { value: value === undefined ? null : value };
83
+ }
84
+
85
+ /** Write to `path` when given, otherwise return the bytes inline. */
86
+ async function capture(params, write) {
87
+ if (params.path !== undefined) {
88
+ const target = path.resolve(requireString(params, 'path'));
89
+ const bytes = await write({ path: target });
90
+ return { path: target, bytes: bytes.length };
91
+ }
92
+ const bytes = await write({});
93
+ return { data: { $binary: Buffer.from(bytes).toString('base64') } };
94
+ }
95
+
96
+ function screenshot(state, params) {
97
+ const { page } = sessionOf(state, params);
98
+ return capture(params, (options) =>
99
+ page.screenshot({ ...options, fullPage: params.fullPage === true })
100
+ );
101
+ }
102
+
103
+ function pdf(state, params) {
104
+ const { page } = sessionOf(state, params);
105
+ return capture(params, (options) => page.pdf(options));
106
+ }
107
+
108
+ async function traceStart(state, params) {
109
+ const session = sessionOf(state, params);
110
+ if (session.trace) {
111
+ throw invalidParams('A trace is already recording in this session');
112
+ }
113
+ const output = path.resolve(requireString(params, 'out'));
114
+ session.trace = await state.dependencies.startTrace({
115
+ page: session.page,
116
+ output,
117
+ });
118
+ return { trace: session.trace.path ?? output };
119
+ }
120
+
121
+ async function traceStop(state, params) {
122
+ const session = sessionOf(state, params);
123
+ if (!session.trace) {
124
+ throw invalidParams('No trace is recording in this session');
125
+ }
126
+ const { trace } = session;
127
+ session.trace = null;
128
+ await trace.stop();
129
+ return { trace: trace.path };
130
+ }
131
+
132
+ function cookieKey(cookie) {
133
+ return `${cookie.domain}\t${cookie.path}\t${cookie.name}`;
134
+ }
135
+
136
+ async function readCookies(state, params) {
137
+ const from = requireString(params, 'from');
138
+ const domains = params.domains ?? [];
139
+ if (!Array.isArray(domains)) {
140
+ throw invalidParams('domains must be an array of strings');
141
+ }
142
+ const filters = domains.length > 0 ? domains : [undefined];
143
+ const unique = new Map();
144
+ for (const domainFilter of filters) {
145
+ const cookies = await state.dependencies.readBrowserCookies({
146
+ browser: from,
147
+ profile: params.profile,
148
+ domainFilter,
149
+ ignoreDecryptionErrors: true,
150
+ });
151
+ for (const cookie of cookies) {
152
+ unique.set(cookieKey(cookie), cookie);
153
+ }
154
+ }
155
+ return [...unique.values()];
156
+ }
157
+
158
+ function cookieAdder({ engine, context, page }) {
159
+ return engine === 'playwright'
160
+ ? (cookies) => context.addCookies(cookies)
161
+ : (cookies) => page.setCookie(...cookies);
162
+ }
163
+
164
+ /**
165
+ * Import cookies from an installed browser profile into the session. A batch
166
+ * that the engine rejects is retried one cookie at a time, so one malformed
167
+ * cookie is reported in `skipped` instead of failing the whole import.
168
+ */
169
+ async function importCookies(state, params) {
170
+ const session = sessionOf(state, params);
171
+ const cookies = await readCookies(state, params);
172
+ const add = cookieAdder(session);
173
+ if (cookies.length === 0) {
174
+ return { imported: 0, skipped: [] };
175
+ }
176
+ try {
177
+ await add(cookies);
178
+ return { imported: cookies.length, skipped: [] };
179
+ } catch {
180
+ const skipped = [];
181
+ for (const cookie of cookies) {
182
+ await add([cookie]).catch((error) =>
183
+ skipped.push({
184
+ item: `${cookie.domain} ${cookie.name}`,
185
+ reason: error.message,
186
+ })
187
+ );
188
+ }
189
+ return { imported: cookies.length - skipped.length, skipped };
190
+ }
191
+ }
192
+
193
+ function migrateProfile(state, params) {
194
+ return state.dependencies.migrateProfile({
195
+ from: params.from,
196
+ profile: params.profile,
197
+ to: params.to,
198
+ include: params.include,
199
+ domains: params.domains,
200
+ });
201
+ }
202
+
203
+ function snapshotProfile(state, params) {
204
+ return state.dependencies.snapshotUserDataDir({
205
+ browser: requireString(params, 'from'),
206
+ profile: params.profile,
207
+ userDataDir: params.userDataDir,
208
+ to: params.to,
209
+ });
210
+ }
211
+
212
+ function relayOf(state) {
213
+ if (!state.relay) {
214
+ throw invalidParams('No extension is attached; call attach first');
215
+ }
216
+ return state.relay;
217
+ }
218
+
219
+ /**
220
+ * Start the extension relay and wait for the companion extension. The relay
221
+ * stays open until `attach.close` or the end of the connection.
222
+ */
223
+ async function attach(state, params) {
224
+ if (params.mode !== 'extension') {
225
+ throw invalidParams(
226
+ `attach supports mode "extension", got ${JSON.stringify(params.mode)}; use session.launch with attach for a snapshot and open for the user's browser`
227
+ );
228
+ }
229
+ if (state.relay) {
230
+ throw invalidParams('An extension is already attached; call attach.close');
231
+ }
232
+ const relay = await state.dependencies.attachUserBrowser({
233
+ mode: 'extension',
234
+ port: params.port,
235
+ host: params.host,
236
+ timeoutMs: params.timeoutMs,
237
+ allowedExtensionIds: params.allowedExtensionIds,
238
+ });
239
+ state.relay = relay;
240
+ return {
241
+ mode: relay.mode,
242
+ extension: relay.extension,
243
+ tabs: await relay.tabs(),
244
+ differences: relay.differences,
245
+ };
246
+ }
247
+
248
+ async function attachTabs(state) {
249
+ return await relayOf(state).tabs();
250
+ }
251
+
252
+ /** Send one CDP command to a tab of the attached browser. */
253
+ async function attachSend(state, params) {
254
+ const relay = relayOf(state);
255
+ if (!Number.isInteger(params.tabId)) {
256
+ throw invalidParams('tabId must be an integer');
257
+ }
258
+ const session = await relay.session(params.tabId);
259
+ return await session.send(
260
+ requireString(params, 'method'),
261
+ params.params ?? {}
262
+ );
263
+ }
264
+
265
+ async function attachClose(state) {
266
+ const { relay } = state;
267
+ state.relay = null;
268
+ await relay?.close();
269
+ return { closed: Boolean(relay) };
270
+ }
271
+
272
+ async function open(state, params) {
273
+ const url = requireString(params, 'url');
274
+ const result = await state.dependencies.openInUserBrowser(url);
275
+ return result && typeof result === 'object' ? result : { opened: url };
276
+ }
277
+
278
+ /** `measureParity()` takes launch options: engine, channel, executablePath… */
279
+ function doctor(state, params) {
280
+ return state.dependencies.measureParity(buildLaunchOptions(params));
281
+ }
282
+
283
+ function version(state) {
284
+ return state.dependencies.packageInfo();
285
+ }
286
+
287
+ /** Method name to implementation. */
288
+ export const HIGH_LEVEL_METHODS = Object.freeze({
289
+ 'session.launch': launchSession,
290
+ 'session.connect': connectSession,
291
+ 'session.close': closeSession,
292
+ 'page.goto': goto,
293
+ 'page.click': click,
294
+ 'page.fill': fill,
295
+ 'page.eval': evaluate,
296
+ 'page.screenshot': screenshot,
297
+ 'page.pdf': pdf,
298
+ 'trace.start': traceStart,
299
+ 'trace.stop': traceStop,
300
+ 'cookies.import': importCookies,
301
+ 'profile.migrate': migrateProfile,
302
+ 'profile.snapshot': snapshotProfile,
303
+ attach,
304
+ 'attach.tabs': attachTabs,
305
+ 'attach.send': attachSend,
306
+ 'attach.close': attachClose,
307
+ open,
308
+ doctor,
309
+ version,
310
+ });