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,353 @@
1
+ /**
2
+ * The companion extension's side of the Browser Commander relay (issue #102,
3
+ * addendum, mode 2).
4
+ *
5
+ * The extension lets Browser Commander drive tabs of the user's running,
6
+ * already signed-in browser without any debugging switch: its service worker
7
+ * connects to a relay server on loopback and forwards requests to the
8
+ * `chrome.tabs` and `chrome.debugger` APIs. Chrome shows its "started
9
+ * debugging this browser" infobar while a tab is attached, and the user can
10
+ * end the session from there.
11
+ *
12
+ * Protocol (JSON text frames on `ws://127.0.0.1:<port>/browser-commander`):
13
+ *
14
+ * - extension -> server, on open:
15
+ * `{"type":"hello","extensionVersion":"...","userAgent":"..."}`
16
+ * - server -> extension: `{"id":n,"method":M,"params":{...}}` with M one of
17
+ * `tabs.list` -> `[{tabId,url,title,active}]`, `tabs.create` {url} ->
18
+ * `{tabId}`, `debugger.attach` {tabId} -> `{attached:true}`,
19
+ * `debugger.detach` {tabId} -> `{detached:true}` and `cdp.send`
20
+ * {tabId,method,params} -> the CDP result
21
+ * - extension -> server: `{"id":n,"result":...}` or
22
+ * `{"id":n,"error":{"message":"..."}}`
23
+ * - extension -> server, unsolicited:
24
+ * `{"type":"event","tabId":n,"method":"...","params":{...}}` for CDP events
25
+ * of attached tabs, `{"type":"detached","tabId":n,"reason":"..."}` when a
26
+ * debugger session ends, and `{"type":"keepalive"}` every 20 seconds, which
27
+ * keeps the MV3 service worker alive (Chrome 116+) and is ignored by the
28
+ * server.
29
+ *
30
+ * This module has no Node or browser imports: `chrome`, `WebSocket` and the
31
+ * timers are passed in, so the tests run it against fakes.
32
+ */
33
+
34
+ /** Relay port when `chrome.storage.local` has no `port`. */
35
+ export const DEFAULT_PORT = 9333;
36
+
37
+ /** Path of the relay's WebSocket endpoint. */
38
+ export const RELAY_PATH = '/browser-commander';
39
+
40
+ /** chrome.alarms name that wakes the service worker to reconnect. */
41
+ export const KEEPALIVE_ALARM = 'browser-commander-relay';
42
+
43
+ /** chrome.debugger protocol version. */
44
+ export const DEBUGGER_PROTOCOL_VERSION = '1.3';
45
+
46
+ /**
47
+ * The relay URL for a port.
48
+ *
49
+ * @param {number} [port=DEFAULT_PORT]
50
+ * @returns {string}
51
+ */
52
+ export function relayUrl(port = DEFAULT_PORT) {
53
+ return `ws://127.0.0.1:${port}${RELAY_PATH}`;
54
+ }
55
+
56
+ function requireTabId(params) {
57
+ if (!Number.isInteger(params.tabId)) {
58
+ throw new Error('params.tabId must be an integer tab id');
59
+ }
60
+ return params.tabId;
61
+ }
62
+
63
+ function describeTab(tab) {
64
+ return {
65
+ tabId: tab.id,
66
+ url: tab.url ?? tab.pendingUrl ?? '',
67
+ title: tab.title ?? '',
68
+ active: Boolean(tab.active),
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Run one relay request against the extension APIs.
74
+ *
75
+ * @param {Object} chrome - The `chrome` namespace (or a fake)
76
+ * @param {{method: string, params: (Object|undefined)}} request
77
+ * @param {Set<number>} attachedTabs - Tabs this extension has attached the debugger to; updated in place
78
+ * @returns {Promise<*>} The `result` of the response
79
+ */
80
+ export async function handleRelayRequest(
81
+ chrome,
82
+ { method, params = {} },
83
+ attachedTabs
84
+ ) {
85
+ switch (method) {
86
+ case 'tabs.list': {
87
+ const tabs = await chrome.tabs.query({});
88
+ return tabs.filter((tab) => Number.isInteger(tab.id)).map(describeTab);
89
+ }
90
+ case 'tabs.create': {
91
+ if (params.url !== undefined && typeof params.url !== 'string') {
92
+ throw new Error('params.url must be a string');
93
+ }
94
+ const tab = await chrome.tabs.create(
95
+ params.url === undefined ? {} : { url: params.url }
96
+ );
97
+ return { tabId: tab.id };
98
+ }
99
+ case 'debugger.attach': {
100
+ const tabId = requireTabId(params);
101
+ if (!attachedTabs.has(tabId)) {
102
+ await chrome.debugger.attach({ tabId }, DEBUGGER_PROTOCOL_VERSION);
103
+ attachedTabs.add(tabId);
104
+ }
105
+ return { attached: true };
106
+ }
107
+ case 'debugger.detach': {
108
+ const tabId = requireTabId(params);
109
+ if (attachedTabs.has(tabId)) {
110
+ attachedTabs.delete(tabId);
111
+ await chrome.debugger.detach({ tabId });
112
+ }
113
+ return { detached: true };
114
+ }
115
+ case 'cdp.send': {
116
+ const tabId = requireTabId(params);
117
+ if (typeof params.method !== 'string') {
118
+ throw new Error('params.method must be a CDP method name');
119
+ }
120
+ const result = await chrome.debugger.sendCommand(
121
+ { tabId },
122
+ params.method,
123
+ params.params ?? {}
124
+ );
125
+ return result ?? {};
126
+ }
127
+ default:
128
+ throw new Error(`Unknown relay method: ${method}`);
129
+ }
130
+ }
131
+
132
+ async function readPort(chrome) {
133
+ try {
134
+ const { port } = await chrome.storage.local.get('port');
135
+ const number = Number(port);
136
+ return Number.isInteger(number) && number > 0 && number < 65536
137
+ ? number
138
+ : DEFAULT_PORT;
139
+ } catch {
140
+ return DEFAULT_PORT;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * The service worker's connection to the relay: connects, retries every
146
+ * `retryMs` while the server is down, answers requests, forwards debugger
147
+ * events, and detaches every tab it attached when the connection ends.
148
+ *
149
+ * @param {Object} options
150
+ * @param {Object} options.chrome - The `chrome` namespace
151
+ * @param {Function} options.WebSocket - WebSocket constructor
152
+ * @param {string} [options.userAgent='']
153
+ * @param {number} [options.retryMs=3000]
154
+ * @param {number} [options.keepaliveMs=20000]
155
+ * @param {Function} [options.setTimeout]
156
+ * @param {Function} [options.clearTimeout]
157
+ * @param {Function} [options.setInterval]
158
+ * @param {Function} [options.clearInterval]
159
+ * @returns {{start: function(): void, connect: function(): Promise<void>, stop: function(): void, connected: function(): boolean, attachedTabs: Set<number>}}
160
+ */
161
+ export function createRelayClient({
162
+ chrome,
163
+ WebSocket,
164
+ userAgent = '',
165
+ retryMs = 3_000,
166
+ keepaliveMs = 20_000,
167
+ setTimeout: schedule = globalThis.setTimeout,
168
+ clearTimeout: cancel = globalThis.clearTimeout,
169
+ setInterval: repeat = globalThis.setInterval,
170
+ clearInterval: cancelRepeat = globalThis.clearInterval,
171
+ }) {
172
+ const attachedTabs = new Set();
173
+ let socket = null;
174
+ let connecting = false;
175
+ let stopped = false;
176
+ let retryTimer = null;
177
+ let keepaliveTimer = null;
178
+
179
+ const isOpen = () => socket !== null && socket.readyState === WebSocket.OPEN;
180
+
181
+ function post(message) {
182
+ if (isOpen()) {
183
+ socket.send(JSON.stringify(message));
184
+ }
185
+ }
186
+
187
+ function scheduleRetry() {
188
+ if (!stopped && retryTimer === null) {
189
+ retryTimer = schedule(() => {
190
+ retryTimer = null;
191
+ connect();
192
+ }, retryMs);
193
+ }
194
+ }
195
+
196
+ function detachAll() {
197
+ for (const tabId of [...attachedTabs]) {
198
+ attachedTabs.delete(tabId);
199
+ Promise.resolve()
200
+ .then(() => chrome.debugger.detach({ tabId }))
201
+ .catch(() => {});
202
+ }
203
+ }
204
+
205
+ async function respond(message) {
206
+ try {
207
+ const result = await handleRelayRequest(chrome, message, attachedTabs);
208
+ post({ id: message.id, result: result ?? {} });
209
+ } catch (error) {
210
+ post({
211
+ id: message.id,
212
+ error: { message: error?.message ?? String(error) },
213
+ });
214
+ }
215
+ }
216
+
217
+ function onMessage(event) {
218
+ let message;
219
+ try {
220
+ message = JSON.parse(event.data);
221
+ } catch {
222
+ return;
223
+ }
224
+ if (message && Number.isInteger(message.id) && message.method) {
225
+ respond(message);
226
+ }
227
+ }
228
+
229
+ function onOpen(opened) {
230
+ if (socket !== opened) {
231
+ return;
232
+ }
233
+ post({
234
+ type: 'hello',
235
+ extensionVersion: chrome.runtime.getManifest().version,
236
+ userAgent,
237
+ });
238
+ keepaliveTimer = repeat(() => post({ type: 'keepalive' }), keepaliveMs);
239
+ }
240
+
241
+ function onClose(closed) {
242
+ if (socket !== closed) {
243
+ return;
244
+ }
245
+ socket = null;
246
+ if (keepaliveTimer !== null) {
247
+ cancelRepeat(keepaliveTimer);
248
+ keepaliveTimer = null;
249
+ }
250
+ detachAll();
251
+ scheduleRetry();
252
+ }
253
+
254
+ /** Connect unless a connection is open or being opened. */
255
+ async function connect() {
256
+ if (stopped || connecting || socket !== null) {
257
+ return;
258
+ }
259
+ connecting = true;
260
+ try {
261
+ if (retryTimer !== null) {
262
+ cancel(retryTimer);
263
+ retryTimer = null;
264
+ }
265
+ const port = await readPort(chrome);
266
+ if (stopped) {
267
+ return;
268
+ }
269
+ let opened;
270
+ try {
271
+ opened = new WebSocket(relayUrl(port));
272
+ } catch {
273
+ scheduleRetry();
274
+ return;
275
+ }
276
+ socket = opened;
277
+ opened.onopen = () => onOpen(opened);
278
+ opened.onmessage = onMessage;
279
+ opened.onclose = () => onClose(opened);
280
+ // An error is always followed by close, which schedules the retry.
281
+ opened.onerror = () => {};
282
+ } finally {
283
+ connecting = false;
284
+ }
285
+ }
286
+
287
+ function reconnect() {
288
+ const current = socket;
289
+ if (current) {
290
+ current.close();
291
+ onClose(current);
292
+ }
293
+ connect();
294
+ }
295
+
296
+ /**
297
+ * Register the listeners and connect. MV3 requires listeners to be added
298
+ * synchronously when the service worker starts, so call this at top level.
299
+ */
300
+ function start() {
301
+ chrome.debugger.onEvent.addListener((source, method, params) => {
302
+ if (attachedTabs.has(source.tabId)) {
303
+ post({ type: 'event', tabId: source.tabId, method, params });
304
+ }
305
+ });
306
+ chrome.debugger.onDetach.addListener((source, reason) => {
307
+ if (attachedTabs.delete(source.tabId)) {
308
+ post({ type: 'detached', tabId: source.tabId, reason });
309
+ }
310
+ });
311
+ chrome.alarms.onAlarm.addListener((alarm) => {
312
+ if (alarm.name === KEEPALIVE_ALARM) {
313
+ connect();
314
+ }
315
+ });
316
+ chrome.storage.onChanged.addListener((changes, area) => {
317
+ if (area === 'local' && changes.port) {
318
+ reconnect();
319
+ }
320
+ });
321
+ chrome.runtime.onStartup.addListener(() => connect());
322
+ chrome.runtime.onInstalled.addListener(() => connect());
323
+ // Wakes a suspended service worker to reconnect; 30 seconds is the
324
+ // shortest period Chrome allows.
325
+ Promise.resolve()
326
+ .then(() =>
327
+ chrome.alarms.create(KEEPALIVE_ALARM, { periodInMinutes: 0.5 })
328
+ )
329
+ .catch(() => {});
330
+ connect();
331
+ }
332
+
333
+ function stop() {
334
+ stopped = true;
335
+ if (retryTimer !== null) {
336
+ cancel(retryTimer);
337
+ retryTimer = null;
338
+ }
339
+ const current = socket;
340
+ if (current) {
341
+ current.close();
342
+ onClose(current);
343
+ }
344
+ }
345
+
346
+ return {
347
+ start,
348
+ connect,
349
+ stop,
350
+ connected: isOpen,
351
+ attachedTabs,
352
+ };
353
+ }
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "browser-commander",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Universal browser automation library that supports both Playwright and Puppeteer with a unified API",
5
5
  "main": "src/index.js",
6
+ "bin": {
7
+ "browser-commander": "bin/browser-commander.js"
8
+ },
6
9
  "type": "module",
7
10
  "exports": {
8
11
  ".": "./src/index.js",
@@ -15,10 +18,14 @@
15
18
  "test:e2e": "node scripts/run-tests.mjs --env RUN_E2E=true tests/e2e",
16
19
  "test:e2e:playwright": "node scripts/run-tests.mjs --env RUN_E2E=true tests/e2e/playwright.e2e.test.js",
17
20
  "test:e2e:puppeteer": "node scripts/run-tests.mjs --env RUN_E2E=true tests/e2e/puppeteer.e2e.test.js",
18
- "test:e2e:parity": "node scripts/run-tests.mjs --env RUN_E2E=true tests/e2e/fingerprint-parity.e2e.test.js",
21
+ "test:e2e:webdriver": "node scripts/run-tests.mjs --env RUN_E2E=true tests/e2e/webdriver.e2e.test.js",
22
+ "test:e2e:parity": "node scripts/run-tests.mjs --env RUN_E2E=true tests/e2e/fingerprint-parity.e2e.test.js tests/e2e/measure-parity.e2e.test.js",
19
23
  "test:e2e:click-readiness": "node scripts/run-tests.mjs --env RUN_E2E=true tests/e2e/click-readiness.e2e.test.js",
20
24
  "test:e2e:downloads": "node scripts/run-tests.mjs --env RUN_E2E=true tests/e2e/downloads.e2e.test.js",
21
25
  "test:e2e:traces": "node scripts/run-tests.mjs --env RUN_E2E=true tests/e2e/traces.e2e.test.js",
26
+ "test:e2e:cli": "node scripts/run-tests.mjs --env RUN_E2E=true tests/e2e/cli.e2e.test.js",
27
+ "test:e2e:api-coverage": "node scripts/run-tests.mjs --env RUN_E2E=true tests/e2e/api-coverage.e2e.test.js",
28
+ "test:e2e:google-probe": "node scripts/run-tests.mjs --env RUN_E2E=true --env RUN_GOOGLE_PROBE=true tests/e2e/google-signin-probe.e2e.test.js",
22
29
  "test:all": "npm run test:unit && npm run test:e2e",
23
30
  "lint": "eslint .",
24
31
  "lint:fix": "eslint . --fix",
@@ -42,6 +49,8 @@
42
49
  "automation",
43
50
  "playwright",
44
51
  "puppeteer",
52
+ "selenium",
53
+ "webdriver",
45
54
  "testing",
46
55
  "e2e"
47
56
  ],
@@ -52,11 +61,12 @@
52
61
  },
53
62
  "homepage": "https://github.com/link-foundation/browser-commander#readme",
54
63
  "engines": {
55
- "node": ">=20.0.0"
64
+ "node": ">=22.0.0"
56
65
  },
57
66
  "peerDependencies": {
58
67
  "playwright": ">=1.40.0",
59
- "puppeteer": ">=21.0.0"
68
+ "puppeteer": ">=21.0.0",
69
+ "selenium-webdriver": ">=4.20.0"
60
70
  },
61
71
  "peerDependenciesMeta": {
62
72
  "playwright": {
@@ -64,10 +74,14 @@
64
74
  },
65
75
  "puppeteer": {
66
76
  "optional": true
77
+ },
78
+ "selenium-webdriver": {
79
+ "optional": true
67
80
  }
68
81
  },
69
82
  "dependencies": {
70
- "better-sqlite3": "^12.11.1",
83
+ "better-sqlite3": "^13.0.3",
84
+ "command-stream": "^1.2.0",
71
85
  "links-notation": "^0.20.0",
72
86
  "log-lazy": "^1.0.4",
73
87
  "test-anywhere": "^0.9.1"
@@ -82,6 +96,11 @@
82
96
  "jsdoc": "^4.0.4",
83
97
  "playwright": "^1.62.1",
84
98
  "prettier": "^3.9.6",
85
- "puppeteer": "^25.10.0"
99
+ "puppeteer": "^25.10.0",
100
+ "selenium-webdriver": "^4.49.0"
101
+ },
102
+ "allowScripts": {
103
+ "better-sqlite3@13.0.3": true,
104
+ "puppeteer": false
86
105
  }
87
106
  }
@@ -147,13 +147,15 @@ export function buildNodeTestArgs(
147
147
  testFiles,
148
148
  nodeVersion = process.versions.node
149
149
  ) {
150
- const args = ['--test'];
150
+ // A resource leak that is a warning today becomes a runtime failure in a
151
+ // later Node release. Make deprecations fail at the point that creates them.
152
+ const args = ['--test', '--throw-deprecation'];
151
153
 
152
154
  // Node's default process isolation serializes each test file's results over
153
155
  // a child-process channel. Node 24 on macOS can intermittently corrupt that
154
156
  // stream (ERR_TEST_FAILURE: "Unable to deserialize cloned data"). Running
155
157
  // supported runtimes in-process removes that transport while keeping the
156
- // Node 20-compatible fallback for consumers on the minimum engine version.
158
+ // Node 22-compatible fallback for consumers on the minimum engine version.
157
159
  if (supportsTestIsolation(nodeVersion)) {
158
160
  args.push('--test-isolation=none');
159
161
  }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * What each way of reaching "the user's real browser" differs in from that
3
+ * browser (issue #102, addendum).
4
+ *
5
+ * Chrome 136 and later ignore the remote debugging switches for the default
6
+ * user data directory, so no mode can drive the real profile in place. Each
7
+ * mode trades something else away, and consumers pick knowingly from these
8
+ * lists:
9
+ *
10
+ * - `clean`: the default `launchRealBrowser()` - a fresh dedicated profile.
11
+ * - `snapshot`: `launchRealBrowser({ attach: { mode: 'snapshot' } })` - a
12
+ * read-only copy of the real profile in a temporary user data directory.
13
+ * - `extension`: `attachUserBrowser({ mode: 'extension' })` - the running real
14
+ * browser, driven through the companion extension's `chrome.debugger` API.
15
+ * - `open`: `openInUserBrowser(url)` - the user's own browser with no
16
+ * automation at all, so there is nothing that differs.
17
+ *
18
+ * Every entry is `{ aspect, description }`; `aspect` is a stable identifier
19
+ * shared by the JS, Python and Rust implementations. The lists are static:
20
+ * what a page can actually observe is measured by `measureParity()` (issue
21
+ * #103), which the `fingerprint` entry points to.
22
+ */
23
+
24
+ /** The modes {@link describeAttachDifferences} knows. */
25
+ export const ATTACH_MODES = Object.freeze([
26
+ 'clean',
27
+ 'snapshot',
28
+ 'extension',
29
+ 'open',
30
+ ]);
31
+
32
+ const REMOTE_DEBUGGING = {
33
+ aspect: 'remote-debugging',
34
+ description:
35
+ 'A fixed loopback --remote-debugging-port is on the command line; issue #101 measured that a page cannot observe it and that navigator.webdriver stays false.',
36
+ };
37
+
38
+ const FINGERPRINT = {
39
+ aspect: 'fingerprint',
40
+ description:
41
+ 'measureParity({ session }) measures the command line and everything a page can read against the same browser started by hand.',
42
+ };
43
+
44
+ const DIFFERENCES = {
45
+ clean: [
46
+ {
47
+ aspect: 'user-data-dir',
48
+ description:
49
+ 'A fresh dedicated profile: no cookies, sign-in, history, bookmarks, passwords or extensions of the real profile. Signing in with Google works (#101) and Chrome sync restores the rest.',
50
+ },
51
+ {
52
+ aspect: 'first-run',
53
+ description:
54
+ "The profile gets Chrome's own First Run sentinel and a Local State that skips the What's New and Edge first-run tabs, instead of --no-first-run.",
55
+ },
56
+ REMOTE_DEBUGGING,
57
+ FINGERPRINT,
58
+ ],
59
+ snapshot: [
60
+ {
61
+ aspect: 'user-data-dir',
62
+ description:
63
+ 'The browser runs on a read-only copy of the real profile in a temporary user data directory. Changes are not written back, and the copy is deleted on close.',
64
+ },
65
+ {
66
+ aspect: 'profile-directory',
67
+ description:
68
+ 'A profile other than Default is selected with --profile-directory=<name>, as a profile shortcut does.',
69
+ },
70
+ {
71
+ aspect: 'not-copied',
72
+ description:
73
+ 'Caches (HTTP, code, GPU and shader caches, Service Worker CacheStorage), crash reports, lock files and the open-tabs session files are not copied, so first loads are cold and the tabs of the running browser are not restored.',
74
+ },
75
+ {
76
+ aspect: 'encrypted-data',
77
+ description:
78
+ 'Cookies and saved passwords stay encrypted with the OS keystore key and decrypt only for the same browser, and only when neither the mock-keychain nor the basic-password-store restriction is used.',
79
+ },
80
+ {
81
+ aspect: 'dbsc',
82
+ description:
83
+ 'Device-bound Google sessions (DBSC) cannot be used from a copy because their key never leaves the original profile; Google needs a fresh sign-in. Other sites keep their sessions.',
84
+ },
85
+ {
86
+ aspect: 'extensions-mac',
87
+ description:
88
+ 'Extension settings are protected by a machine-bound MAC in Secure Preferences. The copy is made on the same machine, so they stay valid; a snapshot moved to another machine has its extensions reset by Chrome.',
89
+ },
90
+ REMOTE_DEBUGGING,
91
+ FINGERPRINT,
92
+ ],
93
+ extension: [
94
+ {
95
+ aspect: 'debugger-infobar',
96
+ description:
97
+ 'While a tab is attached, Chrome shows its "started debugging this browser" infobar; the user can end the session with its Cancel button.',
98
+ },
99
+ {
100
+ aspect: 'extension-install',
101
+ description:
102
+ 'The companion extension (js/extension) has to be installed once with the user\'s consent through chrome://extensions "Load unpacked".',
103
+ },
104
+ {
105
+ aspect: 'cdp-scope',
106
+ description:
107
+ 'CDP is limited to what chrome.debugger allows: per-tab sessions only, no Browser.* or Target.* domain commands. New tabs are opened through the extension (tabs.create).',
108
+ },
109
+ {
110
+ aspect: 'data',
111
+ description:
112
+ "None: the profile, cookies, sign-in, extensions and open tabs are the user's own; nothing is copied and there is no debugging switch on the command line.",
113
+ },
114
+ ],
115
+ open: [],
116
+ };
117
+
118
+ /**
119
+ * The documented differences from the real browser for one mode.
120
+ *
121
+ * @param {'clean'|'snapshot'|'extension'|'open'} mode
122
+ * @returns {Array<{aspect: string, description: string}>} A fresh copy
123
+ */
124
+ export function describeAttachDifferences(mode) {
125
+ if (!Object.hasOwn(DIFFERENCES, mode)) {
126
+ throw new TypeError(
127
+ `Unknown attach mode "${mode}"; expected one of ${ATTACH_MODES.join(', ')}`
128
+ );
129
+ }
130
+ return DIFFERENCES[mode].map((difference) => ({ ...difference }));
131
+ }