browser-commander 0.20.0 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/.jscpd.json +2 -1
  2. package/.prettierignore +1 -0
  3. package/CHANGELOG.md +159 -0
  4. package/README.md +216 -32
  5. package/bin/browser-commander.js +16 -0
  6. package/eslint.config.js +10 -0
  7. package/examples/react-test-app/src/App.jsx +24 -23
  8. package/extension/background.js +14 -0
  9. package/extension/manifest.json +12 -0
  10. package/extension/relay-handler.js +353 -0
  11. package/package.json +21 -4
  12. package/src/browser/attach/differences.js +131 -0
  13. package/src/browser/attach/extension-relay.js +402 -0
  14. package/src/browser/attach/index.js +81 -0
  15. package/src/browser/attach/snapshot-launch.js +146 -0
  16. package/src/browser/attach/snapshot.js +374 -0
  17. package/src/browser/attach/websocket.js +615 -0
  18. package/src/browser/browser-cookie-cache.js +26 -19
  19. package/src/browser/browser-cookie-credentials.js +6 -9
  20. package/src/browser/browser-cookies.js +5 -2
  21. package/src/browser/cdp-session.js +94 -0
  22. package/src/browser/connector.js +42 -4
  23. package/src/browser/debugging-port.js +155 -0
  24. package/src/browser/launch-options.js +63 -52
  25. package/src/browser/launch-restrictions.json +147 -0
  26. package/src/browser/launcher.js +329 -133
  27. package/src/browser/media.js +5 -0
  28. package/src/browser/migration/bookmarks.js +73 -0
  29. package/src/browser/migration/chromium-crypto.js +89 -0
  30. package/src/browser/migration/cookies.js +155 -0
  31. package/src/browser/migration/extensions.js +187 -0
  32. package/src/browser/migration/firefox-bookmarks.js +144 -0
  33. package/src/browser/migration/firefox-der.js +84 -0
  34. package/src/browser/migration/firefox-nss.js +281 -0
  35. package/src/browser/migration/firefox.js +334 -0
  36. package/src/browser/migration/fs-utils.js +51 -0
  37. package/src/browser/migration/history.js +103 -0
  38. package/src/browser/migration/index.js +307 -0
  39. package/src/browser/migration/os-crypt-keys.js +106 -0
  40. package/src/browser/migration/passwords.js +171 -0
  41. package/src/browser/migration/preferences.js +132 -0
  42. package/src/browser/migration/sqlite-snapshot.js +138 -0
  43. package/src/browser/open-in-user-browser.js +112 -0
  44. package/src/browser/parity.js +439 -0
  45. package/src/browser/profile-directory.js +205 -0
  46. package/src/browser/real-browser.js +545 -306
  47. package/src/browser/restrictions.js +148 -0
  48. package/src/browser/system-browser.js +232 -0
  49. package/src/browser/webdriver.js +572 -0
  50. package/src/cli/args.js +300 -0
  51. package/src/cli/commands.js +332 -0
  52. package/src/cli/dispatcher.js +99 -0
  53. package/src/cli/handle-methods.js +136 -0
  54. package/src/cli/handles.js +273 -0
  55. package/src/cli/high-level-methods.js +310 -0
  56. package/src/cli/main.js +67 -0
  57. package/src/cli/modules.js +100 -0
  58. package/src/cli/rpc-error.js +77 -0
  59. package/src/cli/script.js +77 -0
  60. package/src/cli/serve.js +144 -0
  61. package/src/cli/sessions.js +199 -0
  62. package/src/core/constants.js +7 -1
  63. package/src/core/engine-adapter.js +107 -1
  64. package/src/core/engine-detection.js +18 -3
  65. package/src/core/navigation-safety.js +4 -0
  66. package/src/core/webdriver-conversions.js +347 -0
  67. package/src/core/webdriver-events.js +229 -0
  68. package/src/core/webdriver-page.js +716 -0
  69. package/src/downloads/attach.js +6 -0
  70. package/src/elements/selectors.js +3 -2
  71. package/src/elements/visibility.js +4 -0
  72. package/src/exports.js +61 -2
  73. package/src/factory.js +19 -4
  74. package/src/fingerprint/apply.js +6 -0
  75. package/src/fingerprint/automation-parity.js +7 -8
  76. package/src/fingerprint/limitations.js +1 -1
  77. package/src/fingerprint/limitations.json +45 -2
  78. package/src/parity/harness.js +303 -0
  79. package/src/parity/probe.js +838 -0
  80. package/src/parity/version-page.js +144 -0
  81. package/src/traces/bundle.js +3 -2
  82. package/src/traces/observers.js +14 -5
  83. package/src/traces/recorder.js +3 -0
  84. package/src/traces/viewer.js +16 -8
  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/fingerprint-parity.e2e.test.js +104 -46
  89. package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
  90. package/tests/e2e/measure-parity.e2e.test.js +118 -0
  91. package/tests/e2e/playwright.e2e.test.js +26 -17
  92. package/tests/e2e/puppeteer.e2e.test.js +18 -23
  93. package/tests/e2e/webdriver.e2e.test.js +239 -0
  94. package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
  95. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.html +2 -0
  96. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.png +1 -0
  97. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.state.json +54 -0
  98. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +2 -0
  99. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.png +1 -0
  100. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.state.json +60 -0
  101. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +2 -0
  102. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.png +1 -0
  103. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.state.json +60 -0
  104. package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +18 -0
  105. package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +77 -0
  106. package/tests/fixtures/traces/conformance/expected/bundle/mutations/0001.ndjson +2 -0
  107. package/tests/fixtures/traces/conformance/expected/bundle/mutations/0002.ndjson +1 -0
  108. package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +309 -0
  109. package/tests/fixtures/traces/conformance/expected/redaction.json +86 -0
  110. package/tests/fixtures/traces/conformance/expected/trace.lino +27 -0
  111. package/tests/fixtures/traces/conformance/scenario.json +249 -0
  112. package/tests/helpers/cli-fakes.js +214 -0
  113. package/tests/helpers/declared-api.js +80 -0
  114. package/tests/helpers/e2e-browser.js +40 -14
  115. package/tests/helpers/fake-chrome.js +124 -0
  116. package/tests/helpers/migration-fixtures.js +242 -0
  117. package/tests/helpers/temp-directory.js +24 -0
  118. package/tests/helpers/webdriver-mocks.js +183 -0
  119. package/tests/unit/browser/attach/extension-relay.test.js +360 -0
  120. package/tests/unit/browser/attach/snapshot-launch.test.js +321 -0
  121. package/tests/unit/browser/attach/snapshot.test.js +409 -0
  122. package/tests/unit/browser/attach/websocket.test.js +240 -0
  123. package/tests/unit/browser/browser-cookie-lock.test.js +102 -0
  124. package/tests/unit/browser/browser-cookies.test.js +2 -32
  125. package/tests/unit/browser/cdp-session.test.js +162 -0
  126. package/tests/unit/browser/connector.test.js +23 -0
  127. package/tests/unit/browser/launch-building-blocks.test.js +361 -0
  128. package/tests/unit/browser/launch-options.test.js +41 -22
  129. package/tests/unit/browser/launcher.test.js +281 -102
  130. package/tests/unit/browser/media.test.js +13 -1
  131. package/tests/unit/browser/migration/bookmarks.test.js +69 -0
  132. package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
  133. package/tests/unit/browser/migration/cookies.test.js +94 -0
  134. package/tests/unit/browser/migration/extensions.test.js +104 -0
  135. package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
  136. package/tests/unit/browser/migration/firefox.test.js +139 -0
  137. package/tests/unit/browser/migration/history.test.js +48 -0
  138. package/tests/unit/browser/migration/index.test.js +117 -0
  139. package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
  140. package/tests/unit/browser/migration/passwords.test.js +144 -0
  141. package/tests/unit/browser/migration/preferences.test.js +77 -0
  142. package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
  143. package/tests/unit/browser/open-in-user-browser.test.js +123 -0
  144. package/tests/unit/browser/parity.test.js +281 -0
  145. package/tests/unit/browser/real-browser.test.js +458 -33
  146. package/tests/unit/browser/storage-state.test.js +2 -0
  147. package/tests/unit/browser/webdriver.test.js +473 -0
  148. package/tests/unit/cli/bin.test.js +69 -0
  149. package/tests/unit/cli/declared-api.test.js +49 -0
  150. package/tests/unit/cli/dispatcher.test.js +429 -0
  151. package/tests/unit/cli/handles.test.js +170 -0
  152. package/tests/unit/cli/main.test.js +437 -0
  153. package/tests/unit/cli/serve.test.js +167 -0
  154. package/tests/unit/core/navigation-manager.test.js +11 -1
  155. package/tests/unit/core/webdriver-conversions.test.js +235 -0
  156. package/tests/unit/core/webdriver-page.test.js +534 -0
  157. package/tests/unit/fingerprint/automation-parity.test.js +17 -4
  158. package/tests/unit/fingerprint/limitations.test.js +1 -1
  159. package/tests/unit/parity/harness.test.js +138 -0
  160. package/tests/unit/playwright-protocol-coverage.test.js +231 -0
  161. package/tests/unit/puppeteer-bindings-coverage.test.js +214 -0
  162. package/tests/unit/scripts/ci-timeout-budgets.test.js +27 -0
  163. package/tests/unit/scripts/feature-parity.test.js +327 -0
  164. package/tests/unit/traces/conformance.test.js +92 -0
  165. package/tests/unit/traces/reader.test.js +1 -0
  166. package/tests/unit/traces/recorder.test.js +1 -0
  167. package/tests/unit/traces/viewer.test.js +78 -0
  168. package/tests/unit/utilities/command-stream-only.test.js +28 -0
  169. package/tests/unit/utilities/subprocess.test.js +80 -0
@@ -0,0 +1,402 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import http from 'node:http';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ import { describeAttachDifferences } from './differences.js';
6
+ import { acceptUpgrade, CLOSE_CODES, rejectUpgrade } from './websocket.js';
7
+
8
+ /**
9
+ * Relay server for the companion extension (issue #102, addendum, mode 2).
10
+ *
11
+ * The user's running, signed-in browser cannot be reached over CDP (Chrome 136
12
+ * and later ignore the debugging switches for the default profile, and
13
+ * restarting it with one would change its command line). Instead, the
14
+ * extension in js/extension, installed once with "Load unpacked", connects
15
+ * from its service worker to this relay on loopback and forwards requests to
16
+ * `chrome.tabs` and `chrome.debugger`. The protocol is documented in
17
+ * js/extension/relay-handler.js.
18
+ *
19
+ * Security: the server listens on a loopback address only; an upgrade whose
20
+ * `Origin` is not `chrome-extension://<id>` (or whose id is not in
21
+ * `allowedExtensionIds`) is refused with HTTP 403, so a web page cannot
22
+ * connect; and only one extension connection is accepted at a time (a second
23
+ * one gets HTTP 409).
24
+ */
25
+
26
+ /** Relay port the extension connects to unless configured otherwise. */
27
+ export const DEFAULT_RELAY_PORT = 9333;
28
+
29
+ /** Path of the relay's WebSocket endpoint. */
30
+ export const RELAY_PATH = '/browser-commander';
31
+
32
+ /** The unpacked extension to load in chrome://extensions. */
33
+ export const EXTENSION_DIRECTORY = fileURLToPath(
34
+ new URL('../../../extension/', import.meta.url)
35
+ );
36
+
37
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
38
+
39
+ const EXTENSION_ORIGIN = /^chrome-extension:\/\/([a-z0-9]+)$/u;
40
+
41
+ /**
42
+ * The extension id of a `chrome-extension://<id>` origin, or null.
43
+ *
44
+ * @param {string|undefined} origin
45
+ * @returns {string|null}
46
+ */
47
+ export function extensionIdFromOrigin(origin) {
48
+ return EXTENSION_ORIGIN.exec(origin ?? '')?.[1] ?? null;
49
+ }
50
+
51
+ /**
52
+ * Decide whether to accept a WebSocket upgrade.
53
+ *
54
+ * @param {Object} request
55
+ * @param {string} request.path - Request path, possibly with a query
56
+ * @param {string} [request.origin] - The Origin header
57
+ * @param {string[]} [request.allowedExtensionIds] - Accept only these ids
58
+ * @param {boolean} [request.connected=false] - An extension is already connected
59
+ * @returns {{accept: true, extensionId: string}|{accept: false, status: number, message: string}}
60
+ */
61
+ export function checkRelayUpgrade({
62
+ path,
63
+ origin,
64
+ allowedExtensionIds,
65
+ connected = false,
66
+ }) {
67
+ if (new URL(path, 'http://relay').pathname !== RELAY_PATH) {
68
+ return { accept: false, status: 404, message: 'Not found' };
69
+ }
70
+ const extensionId = extensionIdFromOrigin(origin);
71
+ if (!extensionId) {
72
+ return {
73
+ accept: false,
74
+ status: 403,
75
+ message: 'Only the Browser Commander Relay extension may connect',
76
+ };
77
+ }
78
+ if (allowedExtensionIds && !allowedExtensionIds.includes(extensionId)) {
79
+ return {
80
+ accept: false,
81
+ status: 403,
82
+ message: `Extension ${extensionId} is not allowed`,
83
+ };
84
+ }
85
+ if (connected) {
86
+ return {
87
+ accept: false,
88
+ status: 409,
89
+ message: 'Another extension is already connected',
90
+ };
91
+ }
92
+ return { accept: true, extensionId };
93
+ }
94
+
95
+ function assertRelayOptions({ port, host, timeoutMs, allowedExtensionIds }) {
96
+ if (!LOOPBACK_HOSTS.has(host)) {
97
+ throw new TypeError(
98
+ `The relay only listens on loopback (127.0.0.1, ::1 or localhost), got ${host}`
99
+ );
100
+ }
101
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
102
+ throw new TypeError(`port must be an integer 0-65535, got ${port}`);
103
+ }
104
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
105
+ throw new TypeError(
106
+ `timeoutMs must be a positive number, got ${timeoutMs}`
107
+ );
108
+ }
109
+ if (
110
+ allowedExtensionIds !== undefined &&
111
+ (!Array.isArray(allowedExtensionIds) ||
112
+ allowedExtensionIds.some((id) => typeof id !== 'string'))
113
+ ) {
114
+ throw new TypeError('allowedExtensionIds must be an array of strings');
115
+ }
116
+ }
117
+
118
+ function listen(server, port, host) {
119
+ return new Promise((resolve, reject) => {
120
+ const onError = (error) => {
121
+ reject(
122
+ error.code === 'EADDRINUSE'
123
+ ? new Error(
124
+ `The relay port ${host}:${port} is in use; pass another port and set it as "port" in the extension's storage`,
125
+ { cause: error }
126
+ )
127
+ : error
128
+ );
129
+ };
130
+ server.once('error', onError);
131
+ server.listen(port, host, () => {
132
+ server.off('error', onError);
133
+ resolve(server.address().port);
134
+ });
135
+ });
136
+ }
137
+
138
+ function formatHost(host) {
139
+ return host.includes(':') ? `[${host}]` : host;
140
+ }
141
+
142
+ /** One extension connection and its requests, tabs and events. */
143
+ class RelayState {
144
+ constructor() {
145
+ this.connection = null;
146
+ this.extension = null;
147
+ this.nextId = 1;
148
+ this.pending = new Map();
149
+ this.sessions = new Map();
150
+ this.helloWaiters = new Set();
151
+ this.helloTimer = undefined;
152
+ }
153
+
154
+ request(method, params = {}) {
155
+ const { connection } = this;
156
+ if (!connection || !this.extension) {
157
+ return Promise.reject(
158
+ new Error('The Browser Commander Relay extension is not connected')
159
+ );
160
+ }
161
+ const id = this.nextId++;
162
+ return new Promise((resolve, reject) => {
163
+ this.pending.set(id, { resolve, reject, method });
164
+ connection.send(JSON.stringify({ id, method, params }));
165
+ });
166
+ }
167
+
168
+ handleMessage(extensionId, text) {
169
+ let message;
170
+ try {
171
+ message = JSON.parse(text);
172
+ } catch {
173
+ return;
174
+ }
175
+ if (!message || typeof message !== 'object') {
176
+ return;
177
+ }
178
+ if (message.type === 'hello') {
179
+ this.extension = {
180
+ id: extensionId,
181
+ version: String(message.extensionVersion ?? ''),
182
+ userAgent: String(message.userAgent ?? ''),
183
+ };
184
+ for (const waiter of this.helloWaiters) {
185
+ waiter(this.extension);
186
+ }
187
+ this.helloWaiters.clear();
188
+ } else if (message.type === 'event') {
189
+ this.sessions.get(message.tabId)?.emit(message.method, message.params);
190
+ } else if (message.type === 'detached') {
191
+ this.markDetached(message.tabId, message.reason);
192
+ } else if (Number.isInteger(message.id)) {
193
+ this.settle(message);
194
+ }
195
+ }
196
+
197
+ settle({ id, result, error }) {
198
+ const entry = this.pending.get(id);
199
+ if (!entry) {
200
+ return;
201
+ }
202
+ this.pending.delete(id);
203
+ if (error) {
204
+ entry.reject(
205
+ new Error(`${entry.method} failed: ${error.message ?? 'unknown error'}`)
206
+ );
207
+ } else {
208
+ entry.resolve(result);
209
+ }
210
+ }
211
+
212
+ markDetached(tabId, reason) {
213
+ const emitter = this.sessions.get(tabId);
214
+ if (emitter) {
215
+ this.sessions.delete(tabId);
216
+ emitter.detached = true;
217
+ emitter.emit('detached', { tabId, reason });
218
+ }
219
+ }
220
+
221
+ disconnected(reason) {
222
+ this.connection = null;
223
+ this.extension = null;
224
+ for (const entry of this.pending.values()) {
225
+ entry.reject(new Error(`${entry.method} failed: ${reason}`));
226
+ }
227
+ this.pending.clear();
228
+ for (const tabId of [...this.sessions.keys()]) {
229
+ this.markDetached(tabId, reason);
230
+ }
231
+ }
232
+ }
233
+
234
+ function createSession(state, tabId, emitter) {
235
+ const session = {
236
+ tabId,
237
+ /**
238
+ * Send a CDP command to the tab.
239
+ * @param {string} method
240
+ * @param {Object} [params]
241
+ * @returns {Promise<Object>}
242
+ */
243
+ send(method, params = {}) {
244
+ if (emitter.detached) {
245
+ return Promise.reject(
246
+ new Error(`The debugger session of tab ${tabId} is detached`)
247
+ );
248
+ }
249
+ return state.request('cdp.send', { tabId, method, params });
250
+ },
251
+ on(event, listener) {
252
+ emitter.on(event, listener);
253
+ return session;
254
+ },
255
+ once(event, listener) {
256
+ emitter.once(event, listener);
257
+ return session;
258
+ },
259
+ off(event, listener) {
260
+ emitter.off(event, listener);
261
+ return session;
262
+ },
263
+ async detach() {
264
+ if (emitter.detached) {
265
+ return;
266
+ }
267
+ await state.request('debugger.detach', { tabId });
268
+ state.markDetached(tabId, 'detached_by_client');
269
+ },
270
+ };
271
+ return session;
272
+ }
273
+
274
+ function waitForHello(state, timeoutMs, url) {
275
+ return new Promise((resolve, reject) => {
276
+ state.helloTimer = setTimeout(() => {
277
+ state.helloWaiters.delete(resolve);
278
+ reject(
279
+ new Error(
280
+ `No Browser Commander Relay extension connected to ${url} within ${timeoutMs} ms. Install it once from ${EXTENSION_DIRECTORY} with chrome://extensions > Developer mode > "Load unpacked"; it connects to port ${DEFAULT_RELAY_PORT} unless "port" is set in its storage.`
281
+ )
282
+ );
283
+ }, timeoutMs);
284
+ state.helloWaiters.add((extension) => {
285
+ clearTimeout(state.helloTimer);
286
+ resolve(extension);
287
+ });
288
+ });
289
+ }
290
+
291
+ /**
292
+ * Start the relay and wait for the companion extension to connect.
293
+ *
294
+ * @param {Object} [options]
295
+ * @param {number} [options.port=9333] - Loopback port; 0 picks a free one (see `onListening`)
296
+ * @param {string} [options.host='127.0.0.1'] - Loopback address
297
+ * @param {number} [options.timeoutMs=60000] - How long to wait for the extension's hello
298
+ * @param {string[]} [options.allowedExtensionIds] - Accept only these extension ids
299
+ * @param {function({port: number, url: string}): void} [options.onListening] - Called once the server listens, before the extension connects
300
+ * @returns {Promise<{mode: 'extension', extension: {id: string, version: string, userAgent: string}, url: string, port: number, differences: Array<{aspect: string, description: string}>, connected: function(): boolean, tabs: function(): Promise<Array<{tabId: number, url: string, title: string, active: boolean}>>, newTab: function(string=): Promise<{tabId: number}>, session: function(number): Promise<Object>, close: function(): Promise<void>}>}
301
+ */
302
+ export async function attachViaExtension({
303
+ port = DEFAULT_RELAY_PORT,
304
+ host = '127.0.0.1',
305
+ timeoutMs = 60_000,
306
+ allowedExtensionIds,
307
+ onListening,
308
+ } = {}) {
309
+ assertRelayOptions({ port, host, timeoutMs, allowedExtensionIds });
310
+ const state = new RelayState();
311
+ const sockets = new Set();
312
+ const server = http.createServer((request, response) => {
313
+ response.writeHead(426, { 'Content-Type': 'text/plain; charset=utf-8' });
314
+ response.end('Browser Commander relay: WebSocket only');
315
+ });
316
+ server.on('connection', (socket) => {
317
+ sockets.add(socket);
318
+ socket.once('close', () => sockets.delete(socket));
319
+ });
320
+ server.on('upgrade', (request, socket, head) => {
321
+ const decision = checkRelayUpgrade({
322
+ path: request.url,
323
+ origin: request.headers.origin,
324
+ allowedExtensionIds,
325
+ connected: state.connection !== null,
326
+ });
327
+ if (!decision.accept) {
328
+ rejectUpgrade(socket, decision.status, decision.message);
329
+ return;
330
+ }
331
+ const connection = acceptUpgrade(request, socket, head);
332
+ if (!connection) {
333
+ return;
334
+ }
335
+ state.connection = connection;
336
+ connection.on('message', (text) =>
337
+ state.handleMessage(decision.extensionId, text)
338
+ );
339
+ connection.on('error', () => {});
340
+ connection.once('close', ({ code }) =>
341
+ state.disconnected(`the extension disconnected (${code})`)
342
+ );
343
+ });
344
+
345
+ const actualPort = await listen(server, port, host);
346
+ const url = `ws://${formatHost(host)}:${actualPort}${RELAY_PATH}`;
347
+ let closing;
348
+ const close = () => {
349
+ closing ??= new Promise((resolve) => {
350
+ clearTimeout(state.helloTimer);
351
+ state.connection?.close(CLOSE_CODES.goingAway, 'relay closed');
352
+ state.disconnected('the relay was closed');
353
+ server.close(() => resolve());
354
+ const timer = setTimeout(() => {
355
+ for (const socket of sockets) {
356
+ socket.destroy();
357
+ }
358
+ }, 500);
359
+ timer.unref?.();
360
+ });
361
+ return closing;
362
+ };
363
+
364
+ const hello = waitForHello(state, timeoutMs, url);
365
+ try {
366
+ onListening?.({ port: actualPort, url });
367
+ await hello;
368
+ } catch (error) {
369
+ await close();
370
+ throw error;
371
+ }
372
+
373
+ const handle = {
374
+ mode: 'extension',
375
+ get extension() {
376
+ return state.extension ? { ...state.extension } : null;
377
+ },
378
+ url,
379
+ port: actualPort,
380
+ differences: describeAttachDifferences('extension'),
381
+ connected: () => state.extension !== null,
382
+ tabs: () => state.request('tabs.list'),
383
+ newTab: (tabUrl) =>
384
+ state.request('tabs.create', tabUrl === undefined ? {} : { url: tabUrl }),
385
+ async session(tabId) {
386
+ if (!Number.isInteger(tabId)) {
387
+ throw new TypeError(`tabId must be an integer, got ${tabId}`);
388
+ }
389
+ await state.request('debugger.attach', { tabId });
390
+ let emitter = state.sessions.get(tabId);
391
+ if (!emitter) {
392
+ emitter = new EventEmitter();
393
+ emitter.detached = false;
394
+ state.sessions.set(tabId, emitter);
395
+ }
396
+ emitter.session ??= createSession(state, tabId, emitter);
397
+ return emitter.session;
398
+ },
399
+ close,
400
+ };
401
+ return handle;
402
+ }
@@ -0,0 +1,81 @@
1
+ import { openInUserBrowser } from '../open-in-user-browser.js';
2
+ import { launchRealBrowser } from '../real-browser.js';
3
+ import { ATTACH_MODES, describeAttachDifferences } from './differences.js';
4
+ import { attachViaExtension } from './extension-relay.js';
5
+
6
+ export { ATTACH_MODES, describeAttachDifferences } from './differences.js';
7
+ export {
8
+ attachViaExtension,
9
+ checkRelayUpgrade,
10
+ DEFAULT_RELAY_PORT,
11
+ EXTENSION_DIRECTORY,
12
+ extensionIdFromOrigin,
13
+ RELAY_PATH,
14
+ } from './extension-relay.js';
15
+ export {
16
+ isSqliteFile,
17
+ snapshotSkipReason,
18
+ snapshotUserDataDir,
19
+ } from './snapshot.js';
20
+ export { snapshotBrowserForChannel } from './snapshot-launch.js';
21
+
22
+ /**
23
+ * Reach the user's real browser in one of the issue #102 addendum modes. Each
24
+ * result carries `differences`: the documented ways the mode differs from the
25
+ * real browser, see {@link describeAttachDifferences}.
26
+ *
27
+ * - `snapshot`: {@link launchRealBrowser} on a read-only copy of a real
28
+ * profile. Options are launch options plus `browser`, `profile` and
29
+ * `userDataDir` (the source), which become `attach`. Returns the launched
30
+ * session with `mode`, `differences` and the `attach` report.
31
+ * - `extension`: {@link attachViaExtension} - drive tabs of the running
32
+ * browser through the companion extension. Options: `port`, `host`,
33
+ * `timeoutMs`, `allowedExtensionIds`, `onListening`.
34
+ * - `open`: {@link openInUserBrowser} - open `url` in the user's default
35
+ * browser without automation. Returns `{mode, opened, command,
36
+ * differences}`.
37
+ *
38
+ * @param {Object} options
39
+ * @param {'snapshot'|'extension'|'open'} options.mode
40
+ * @param {Object} [dependencies] - Test seams: launchRealBrowser, attachViaExtension, openInUserBrowser
41
+ * @returns {Promise<Object>}
42
+ */
43
+ export async function attachUserBrowser(options = {}, dependencies = {}) {
44
+ const { mode, ...rest } = options;
45
+ switch (mode) {
46
+ case 'snapshot': {
47
+ const { browser, profile, userDataDir, ...launchOptions } = rest;
48
+ const launch = dependencies.launchRealBrowser ?? launchRealBrowser;
49
+ const session = await launch({
50
+ ...launchOptions,
51
+ attach: { mode: 'snapshot', browser, profile, userDataDir },
52
+ });
53
+ return {
54
+ ...session,
55
+ mode: 'snapshot',
56
+ differences: describeAttachDifferences('snapshot'),
57
+ };
58
+ }
59
+ case 'extension': {
60
+ const attach = dependencies.attachViaExtension ?? attachViaExtension;
61
+ return await attach(rest);
62
+ }
63
+ case 'open': {
64
+ const { url, ...openOptions } = rest;
65
+ if (typeof url !== 'string' || url === '') {
66
+ throw new TypeError('attachUserBrowser open mode requires a url');
67
+ }
68
+ const open = dependencies.openInUserBrowser ?? openInUserBrowser;
69
+ const result = await open(url, openOptions);
70
+ return {
71
+ mode: 'open',
72
+ ...result,
73
+ differences: describeAttachDifferences('open'),
74
+ };
75
+ }
76
+ default:
77
+ throw new TypeError(
78
+ `Unknown attach mode "${mode}"; expected snapshot, extension or open (${ATTACH_MODES.join(', ')} are described by describeAttachDifferences)`
79
+ );
80
+ }
81
+ }
@@ -0,0 +1,146 @@
1
+ import { resolveRestrictions } from '../restrictions.js';
2
+ import { describeAttachDifferences } from './differences.js';
3
+ import { snapshotUserDataDir } from './snapshot.js';
4
+
5
+ /**
6
+ * The `attach: { mode: 'snapshot' }` option of `launchRealBrowser()` (issue
7
+ * #102, addendum): validation, the snapshot itself, and the extra switch.
8
+ * Kept apart from real-browser.js so the launcher stays one flow.
9
+ */
10
+
11
+ /** Switches that make the OS-keystore-encrypted data of a copy unreadable. */
12
+ const KEYSTORE_BYPASS_SWITCHES = ['--use-mock-keychain', '--password-store='];
13
+
14
+ /**
15
+ * The browser whose profile is snapshotted when `attach.browser` is omitted:
16
+ * the one the launch channel belongs to.
17
+ *
18
+ * @param {string} channel - Launch channel such as chrome, msedge-beta or brave
19
+ * @returns {string}
20
+ */
21
+ export function snapshotBrowserForChannel(channel) {
22
+ if (channel.startsWith('msedge')) {
23
+ return 'edge';
24
+ }
25
+ if (channel === 'brave' || channel === 'chromium') {
26
+ return channel;
27
+ }
28
+ return 'chrome';
29
+ }
30
+
31
+ /**
32
+ * Reject an `attach` option that the launcher cannot honour, before anything
33
+ * touches the disk.
34
+ *
35
+ * @param {Object} options
36
+ * @param {*} options.attach - The `attach` launch option
37
+ * @param {Object} [options.migrateFrom]
38
+ * @param {string} [options.userDataDir]
39
+ * @param {string[]} [options.customArgs] - args and extraArgs
40
+ * @returns {void}
41
+ * @throws {TypeError}
42
+ */
43
+ export function validateAttachOption({
44
+ attach,
45
+ migrateFrom,
46
+ userDataDir,
47
+ customArgs = [],
48
+ }) {
49
+ if (attach === undefined) {
50
+ return;
51
+ }
52
+ if (!attach || typeof attach !== 'object' || Array.isArray(attach)) {
53
+ throw new TypeError(
54
+ "attach must be an object such as { mode: 'snapshot', browser: 'chrome' }"
55
+ );
56
+ }
57
+ if (attach.mode !== 'snapshot') {
58
+ throw new TypeError(
59
+ `launchRealBrowser only launches attach mode "snapshot", got ${JSON.stringify(attach.mode)}; use attachUserBrowser({ mode: 'extension' }) or openInUserBrowser(url) for the other modes`
60
+ );
61
+ }
62
+ if (migrateFrom) {
63
+ throw new TypeError(
64
+ 'attach and migrateFrom are mutually exclusive: a snapshot already copies the whole profile'
65
+ );
66
+ }
67
+ if (userDataDir) {
68
+ throw new TypeError(
69
+ 'attach and userDataDir are mutually exclusive: a snapshot is launched from its own temporary user data directory'
70
+ );
71
+ }
72
+ if (
73
+ customArgs.some(
74
+ (argument) =>
75
+ argument === '--profile-directory' ||
76
+ argument.startsWith('--profile-directory=')
77
+ )
78
+ ) {
79
+ throw new TypeError(
80
+ '--profile-directory is managed by the snapshot attach mode; pass attach.profile instead'
81
+ );
82
+ }
83
+ }
84
+
85
+ function keystoreWarnings({ restrictions, customArgs }) {
86
+ const switches = [...resolveRestrictions(restrictions).args, ...customArgs];
87
+ return switches
88
+ .filter((argument) =>
89
+ KEYSTORE_BYPASS_SWITCHES.some((bypass) => argument.startsWith(bypass))
90
+ )
91
+ .filter((argument) => argument !== '--password-store=detect')
92
+ .map((argument) => ({
93
+ item: argument,
94
+ reason: 'encrypted-data-unreadable',
95
+ detail: `${argument} bypasses the OS keystore, so the copied cookies and saved passwords cannot be decrypted and the snapshot starts signed out`,
96
+ }));
97
+ }
98
+
99
+ /**
100
+ * Snapshot the real profile into a new temporary user data directory for a
101
+ * launch.
102
+ *
103
+ * @param {Object} options
104
+ * @param {{mode: 'snapshot', browser: (string|undefined), profile: (string|undefined), userDataDir: (string|undefined)}} options.attach
105
+ * @param {string} options.channel - Launch channel
106
+ * @param {string[]} options.restrictions
107
+ * @param {string[]} options.customArgs - args and extraArgs
108
+ * @param {function(Object): Promise<Object>} [options.snapshot=snapshotUserDataDir]
109
+ * @returns {Promise<{userDataDir: string, args: string[], attach: {mode: 'snapshot', snapshot: Object, differences: Array<Object>}}>}
110
+ * The snapshot directory, the switches to put before the custom args, and the `attach` result entry
111
+ */
112
+ export async function prepareSnapshotLaunch({
113
+ attach,
114
+ channel,
115
+ restrictions,
116
+ customArgs,
117
+ snapshot = snapshotUserDataDir,
118
+ }) {
119
+ const browser = attach.browser ?? snapshotBrowserForChannel(channel);
120
+ const profile = attach.profile ?? 'Default';
121
+ const report = await snapshot({
122
+ browser,
123
+ profile,
124
+ userDataDir: attach.userDataDir,
125
+ });
126
+ const warnings = [
127
+ ...(report.warnings ?? []),
128
+ ...keystoreWarnings({ restrictions, customArgs }),
129
+ ];
130
+ if (report.source.browser !== snapshotBrowserForChannel(channel)) {
131
+ warnings.push({
132
+ item: channel,
133
+ reason: 'browser-mismatch',
134
+ detail: `A ${report.source.browser} profile launched in ${channel} cannot decrypt its cookies and saved passwords`,
135
+ });
136
+ }
137
+ return {
138
+ userDataDir: report.target,
139
+ args: profile === 'Default' ? [] : [`--profile-directory=${profile}`],
140
+ attach: {
141
+ mode: 'snapshot',
142
+ snapshot: { ...report, warnings },
143
+ differences: describeAttachDifferences('snapshot'),
144
+ },
145
+ };
146
+ }