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,360 @@
1
+ // feature-parity: attach.extension
2
+ import assert from 'node:assert';
3
+ import { once } from 'node:events';
4
+ import { access } from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { describe, it } from 'node:test';
7
+
8
+ import {
9
+ attachViaExtension,
10
+ checkRelayUpgrade,
11
+ EXTENSION_DIRECTORY,
12
+ extensionIdFromOrigin,
13
+ RELAY_PATH,
14
+ } from '../../../../src/browser/attach/extension-relay.js';
15
+ import { connectWebSocket } from '../../../../src/browser/attach/websocket.js';
16
+ import { createRelayClient } from '../../../../extension/relay-handler.js';
17
+ import {
18
+ createExtensionWebSocket,
19
+ createFakeChrome,
20
+ } from '../../../helpers/fake-chrome.js';
21
+
22
+ const TABS = [
23
+ { id: 1, url: 'https://mail.test/', title: 'Inbox', active: true },
24
+ { id: 2, url: 'https://docs.test/', title: 'Docs', active: false },
25
+ ];
26
+
27
+ /**
28
+ * A fake extension that speaks the protocol by hand, so the test controls
29
+ * every message.
30
+ */
31
+ async function connectFakeExtension(url, origin = 'chrome-extension://abc') {
32
+ const connection = await connectWebSocket(url, {
33
+ headers: { Origin: origin },
34
+ });
35
+ const requests = [];
36
+ connection.on('message', (text) => {
37
+ const request = JSON.parse(text);
38
+ requests.push(request);
39
+ connection.emit('request', request);
40
+ });
41
+ const post = (message) => connection.send(JSON.stringify(message));
42
+ return { connection, requests, post };
43
+ }
44
+
45
+ /** Start the relay on a free port with a hand-driven extension. */
46
+ async function attachWithFakeExtension(options = {}) {
47
+ let extension;
48
+ const relay = await attachViaExtension({
49
+ port: 0,
50
+ timeoutMs: 5_000,
51
+ ...options,
52
+ onListening: async ({ url }) => {
53
+ extension = await connectFakeExtension(url);
54
+ extension.post({
55
+ type: 'hello',
56
+ extensionVersion: '1.2.3',
57
+ userAgent: 'Mozilla/5.0 Test',
58
+ });
59
+ },
60
+ });
61
+ return { relay, extension };
62
+ }
63
+
64
+ /** Answer the next request with `reply(request)`. */
65
+ function answerNext(extension, reply) {
66
+ extension.connection.once('request', (request) =>
67
+ extension.post({ id: request.id, ...reply(request) })
68
+ );
69
+ }
70
+
71
+ async function statusOf(url, headers) {
72
+ try {
73
+ const connection = await connectWebSocket(url, { headers });
74
+ connection.terminate();
75
+ return 101;
76
+ } catch (error) {
77
+ return error.statusCode;
78
+ }
79
+ }
80
+
81
+ describe('checkRelayUpgrade', () => {
82
+ it('accepts one extension origin on the relay path only', () => {
83
+ const accept = { path: RELAY_PATH, origin: 'chrome-extension://abc' };
84
+ assert.deepEqual(checkRelayUpgrade(accept), {
85
+ accept: true,
86
+ extensionId: 'abc',
87
+ });
88
+ assert.equal(
89
+ checkRelayUpgrade({ ...accept, path: `${RELAY_PATH}?x=1` }).accept,
90
+ true
91
+ );
92
+ const status = (request) => checkRelayUpgrade(request).status;
93
+ assert.equal(status({ ...accept, path: '/' }), 404);
94
+ assert.equal(status({ ...accept, origin: 'http://evil.example' }), 403);
95
+ assert.equal(status({ ...accept, origin: undefined }), 403);
96
+ assert.equal(status({ ...accept, origin: 'null' }), 403);
97
+ assert.equal(
98
+ status({ ...accept, origin: 'chrome-extension://abc.evil.example' }),
99
+ 403
100
+ );
101
+ assert.equal(status({ ...accept, allowedExtensionIds: ['xyz'] }), 403);
102
+ assert.equal(status({ ...accept, connected: true }), 409);
103
+ assert.equal(extensionIdFromOrigin('chrome-extension://abc'), 'abc');
104
+ assert.equal(extensionIdFromOrigin('moz-extension://abc'), null);
105
+ });
106
+
107
+ it('points at the unpacked extension', async () => {
108
+ await access(path.join(EXTENSION_DIRECTORY, 'manifest.json'));
109
+ await access(path.join(EXTENSION_DIRECTORY, 'background.js'));
110
+ });
111
+ });
112
+
113
+ describe('attachViaExtension', () => {
114
+ it('lists tabs, drives a debugger session and forwards its events', async () => {
115
+ const { relay, extension } = await attachWithFakeExtension();
116
+ try {
117
+ assert.equal(relay.mode, 'extension');
118
+ assert.deepEqual(relay.extension, {
119
+ id: 'abc',
120
+ version: '1.2.3',
121
+ userAgent: 'Mozilla/5.0 Test',
122
+ });
123
+ assert.equal(relay.connected(), true);
124
+ assert.ok(
125
+ relay.differences.some(({ aspect }) => aspect === 'debugger-infobar')
126
+ );
127
+
128
+ answerNext(extension, () => ({ result: [{ tabId: 1, url: 'u' }] }));
129
+ assert.deepEqual(await relay.tabs(), [{ tabId: 1, url: 'u' }]);
130
+
131
+ answerNext(extension, () => ({ result: { attached: true } }));
132
+ const session = await relay.session(1);
133
+ // Attaching again is idempotent and hands back the same session.
134
+ answerNext(extension, () => ({ result: { attached: true } }));
135
+ assert.equal(await relay.session(1), session);
136
+
137
+ answerNext(extension, ({ params }) => ({
138
+ result: { echoed: params },
139
+ }));
140
+ assert.deepEqual(
141
+ await session.send('Runtime.evaluate', { expression: '1+1' }),
142
+ {
143
+ echoed: {
144
+ tabId: 1,
145
+ method: 'Runtime.evaluate',
146
+ params: { expression: '1+1' },
147
+ },
148
+ }
149
+ );
150
+
151
+ answerNext(extension, () => ({ error: { message: 'no such method' } }));
152
+ await assert.rejects(session.send('Bad.method'), /no such method/u);
153
+
154
+ const event = once(session, 'Page.loadEventFired');
155
+ extension.post({
156
+ type: 'event',
157
+ tabId: 1,
158
+ method: 'Page.loadEventFired',
159
+ params: { timestamp: 7 },
160
+ });
161
+ assert.deepEqual(await event, [{ timestamp: 7 }]);
162
+
163
+ const detached = new Promise((resolve) =>
164
+ session.once('detached', resolve)
165
+ );
166
+ extension.post({
167
+ type: 'detached',
168
+ tabId: 1,
169
+ reason: 'canceled_by_user',
170
+ });
171
+ assert.deepEqual(await detached, {
172
+ tabId: 1,
173
+ reason: 'canceled_by_user',
174
+ });
175
+ await assert.rejects(session.send('Page.reload'), /detached/u);
176
+
177
+ assert.deepEqual(
178
+ extension.requests.map(({ method }) => method),
179
+ [
180
+ 'tabs.list',
181
+ 'debugger.attach',
182
+ 'debugger.attach',
183
+ 'cdp.send',
184
+ 'cdp.send',
185
+ ]
186
+ );
187
+ } finally {
188
+ await relay.close();
189
+ }
190
+ });
191
+
192
+ it('refuses web origins with 403 and a second extension with 409', async () => {
193
+ const { relay } = await attachWithFakeExtension();
194
+ try {
195
+ const url = `ws://127.0.0.1:${relay.port}${RELAY_PATH}`;
196
+ assert.equal(await statusOf(url, { Origin: 'http://evil.example' }), 403);
197
+ assert.equal(await statusOf(url, {}), 403);
198
+ assert.equal(
199
+ await statusOf(url, { Origin: 'chrome-extension://other' }),
200
+ 409
201
+ );
202
+ assert.equal(
203
+ await statusOf(`ws://127.0.0.1:${relay.port}/`, {
204
+ Origin: 'chrome-extension://abc',
205
+ }),
206
+ 404
207
+ );
208
+ assert.equal(relay.connected(), true);
209
+ } finally {
210
+ await relay.close();
211
+ }
212
+ await assert.rejects(
213
+ connectWebSocket(relay.url, {
214
+ headers: { Origin: 'chrome-extension://abc' },
215
+ }),
216
+ { code: 'ECONNREFUSED' }
217
+ );
218
+ });
219
+
220
+ it('rejects pending requests and detaches sessions when the extension leaves', async () => {
221
+ const { relay, extension } = await attachWithFakeExtension();
222
+ try {
223
+ answerNext(extension, () => ({ result: { attached: true } }));
224
+ const session = await relay.session(2);
225
+ const detached = new Promise((resolve) =>
226
+ session.once('detached', resolve)
227
+ );
228
+ const pending = relay.tabs();
229
+ extension.connection.terminate();
230
+
231
+ await assert.rejects(pending, /disconnected/u);
232
+ assert.equal((await detached).tabId, 2);
233
+ assert.equal(relay.connected(), false);
234
+ await assert.rejects(relay.tabs(), /not connected/u);
235
+ } finally {
236
+ await relay.close();
237
+ }
238
+ });
239
+
240
+ it('closes the extension connection with 1001 on close()', async () => {
241
+ const { relay, extension } = await attachWithFakeExtension();
242
+ const closed = once(extension.connection, 'close');
243
+ await relay.close();
244
+ await relay.close();
245
+ assert.equal((await closed)[0].code, 1001);
246
+ });
247
+
248
+ it('accepts only allowedExtensionIds', async () => {
249
+ let refused;
250
+ await assert.rejects(
251
+ attachViaExtension({
252
+ port: 0,
253
+ timeoutMs: 300,
254
+ allowedExtensionIds: ['xyz'],
255
+ onListening: ({ url }) => {
256
+ refused = statusOf(url, { Origin: 'chrome-extension://abc' });
257
+ },
258
+ }),
259
+ /Load unpacked/u
260
+ );
261
+ assert.equal(await refused, 403);
262
+ });
263
+
264
+ it('times out with install instructions and frees the port', async () => {
265
+ let url;
266
+ await assert.rejects(
267
+ attachViaExtension({
268
+ port: 0,
269
+ timeoutMs: 100,
270
+ onListening: (listening) => {
271
+ url = listening.url;
272
+ },
273
+ }),
274
+ (error) =>
275
+ error.message.includes(EXTENSION_DIRECTORY) &&
276
+ error.message.includes('Load unpacked')
277
+ );
278
+ await assert.rejects(
279
+ connectWebSocket(url, { headers: { Origin: 'chrome-extension://abc' } }),
280
+ { code: 'ECONNREFUSED' }
281
+ );
282
+ });
283
+
284
+ it('listens on loopback only and validates its options', async () => {
285
+ for (const options of [
286
+ { host: '0.0.0.0' },
287
+ { host: '192.168.1.2' },
288
+ { port: 70_000 },
289
+ { timeoutMs: 0 },
290
+ { allowedExtensionIds: 'abc' },
291
+ ]) {
292
+ await assert.rejects(attachViaExtension(options), TypeError);
293
+ }
294
+ });
295
+
296
+ it('drives the real extension service worker code end to end', async () => {
297
+ const chrome = createFakeChrome({ tabs: TABS.map((tab) => ({ ...tab })) });
298
+ let client;
299
+ const relay = await attachViaExtension({
300
+ port: 0,
301
+ timeoutMs: 5_000,
302
+ onListening: ({ port }) => {
303
+ chrome.storage.local.get = async () => ({ port });
304
+ client = createRelayClient({
305
+ chrome,
306
+ WebSocket: createExtensionWebSocket(),
307
+ userAgent: 'Mozilla/5.0 Extension',
308
+ });
309
+ client.start();
310
+ },
311
+ });
312
+ try {
313
+ assert.deepEqual(relay.extension, {
314
+ id: 'abc',
315
+ version: '1.2.3',
316
+ userAgent: 'Mozilla/5.0 Extension',
317
+ });
318
+ assert.deepEqual(await relay.tabs(), [
319
+ { tabId: 1, url: 'https://mail.test/', title: 'Inbox', active: true },
320
+ { tabId: 2, url: 'https://docs.test/', title: 'Docs', active: false },
321
+ ]);
322
+ assert.deepEqual(await relay.newTab('https://new.test/'), {
323
+ tabId: 100,
324
+ });
325
+
326
+ const session = await relay.session(1);
327
+ assert.deepEqual(
328
+ await session.send('Runtime.evaluate', { expression: '1+1' }),
329
+ { result: { type: 'number', value: 2 } }
330
+ );
331
+ await assert.rejects(session.send('Bad.method'), /wasn't found/u);
332
+
333
+ const event = once(session, 'Network.requestWillBeSent');
334
+ chrome.debugger.onEvent.fire({ tabId: 2 }, 'Ignored.event', {});
335
+ chrome.debugger.onEvent.fire({ tabId: 1 }, 'Network.requestWillBeSent', {
336
+ requestId: 'r1',
337
+ });
338
+ assert.deepEqual(await event, [{ requestId: 'r1' }]);
339
+
340
+ await session.detach();
341
+ assert.deepEqual(
342
+ chrome.calls.filter(([name]) => name.startsWith('debugger.')),
343
+ [
344
+ ['debugger.attach', 1, '1.3'],
345
+ [
346
+ 'debugger.sendCommand',
347
+ 1,
348
+ 'Runtime.evaluate',
349
+ { expression: '1+1' },
350
+ ],
351
+ ['debugger.sendCommand', 1, 'Bad.method', {}],
352
+ ['debugger.detach', 1],
353
+ ]
354
+ );
355
+ } finally {
356
+ client?.stop();
357
+ await relay.close();
358
+ }
359
+ });
360
+ });
@@ -0,0 +1,312 @@
1
+ import assert from 'node:assert';
2
+ import { access, mkdir, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { describe, it } from 'node:test';
5
+
6
+ import {
7
+ ATTACH_MODES,
8
+ attachUserBrowser,
9
+ describeAttachDifferences,
10
+ snapshotBrowserForChannel,
11
+ snapshotUserDataDir,
12
+ } from '../../../../src/browser/attach/index.js';
13
+ import {
14
+ prepareSnapshotLaunch,
15
+ validateAttachOption,
16
+ } from '../../../../src/browser/attach/snapshot-launch.js';
17
+ import { launchBrowserWithDependencies } from '../../../../src/browser/launcher.js';
18
+ import { launchAndConnectRealBrowserWithDependencies } from '../../../../src/browser/real-browser.js';
19
+ import * as publicApi from '../../../../src/exports.js';
20
+ import { useTempDirectories } from '../../../helpers/temp-directory.js';
21
+
22
+ const makeTempDir = useTempDirectories('bc-attach-launch-');
23
+
24
+ /** A snapshot stand-in that creates an empty user data directory. */
25
+ function fakeSnapshot(calls, extra = {}) {
26
+ return async (options) => {
27
+ calls.push(options);
28
+ const target = await makeTempDir();
29
+ return {
30
+ source: {
31
+ browser: options.browser,
32
+ profile: options.profile,
33
+ userDataDir: '/home/user/.config/google-chrome',
34
+ },
35
+ target,
36
+ copied: { files: 1, databases: 0 },
37
+ skipped: [],
38
+ warnings: [],
39
+ ...extra,
40
+ };
41
+ };
42
+ }
43
+
44
+ /** Launch seams that never start a process. */
45
+ function fakeLaunchDependencies(calls) {
46
+ const exitListeners = [];
47
+ const browserProcess = {
48
+ exitCode: null,
49
+ kill() {
50
+ this.exit(143);
51
+ },
52
+ once: (event, listener) => exitListeners.push(listener),
53
+ exit(code) {
54
+ browserProcess.exitCode = code;
55
+ exitListeners.splice(0).forEach((listener) => listener(code));
56
+ },
57
+ };
58
+ return {
59
+ browserProcess,
60
+ dependencies: {
61
+ resolveExecutable: async () => '/opt/google/chrome',
62
+ reservePort: async () => 9555,
63
+ spawnBrowser: (executablePath, args) => {
64
+ calls.push(['spawn', args]);
65
+ return browserProcess;
66
+ },
67
+ waitForEndpoint: async () => 'http://127.0.0.1:9555',
68
+ connect: async () => ({ browser: { close: async () => {} }, page: {} }),
69
+ },
70
+ };
71
+ }
72
+
73
+ describe('describeAttachDifferences', () => {
74
+ it('lists the documented differences of every mode', () => {
75
+ assert.deepEqual(ATTACH_MODES, ['clean', 'snapshot', 'extension', 'open']);
76
+ const aspects = (mode) =>
77
+ describeAttachDifferences(mode).map(({ aspect }) => aspect);
78
+ assert.ok(aspects('snapshot').includes('not-copied'));
79
+ assert.ok(aspects('snapshot').includes('encrypted-data'));
80
+ assert.ok(aspects('extension').includes('debugger-infobar'));
81
+ assert.ok(aspects('clean').includes('user-data-dir'));
82
+ assert.deepEqual(describeAttachDifferences('open'), []);
83
+ for (const mode of ATTACH_MODES) {
84
+ for (const entry of describeAttachDifferences(mode)) {
85
+ assert.equal(typeof entry.description, 'string');
86
+ }
87
+ }
88
+ // Callers get their own copies.
89
+ describeAttachDifferences('snapshot')[0].aspect = 'changed';
90
+ assert.notEqual(describeAttachDifferences('snapshot')[0].aspect, 'changed');
91
+ assert.throws(() => describeAttachDifferences('remote'), TypeError);
92
+ });
93
+
94
+ it('is exported from the package API', () => {
95
+ for (const name of [
96
+ 'attachUserBrowser',
97
+ 'attachViaExtension',
98
+ 'describeAttachDifferences',
99
+ 'snapshotUserDataDir',
100
+ ]) {
101
+ assert.equal(typeof publicApi[name], 'function', name);
102
+ }
103
+ assert.equal(publicApi.DEFAULT_RELAY_PORT, 9333);
104
+ assert.equal(publicApi.RELAY_PATH, '/browser-commander');
105
+ });
106
+ });
107
+
108
+ describe('snapshot attach launch option', () => {
109
+ it('maps channels to the browser whose profile is copied', () => {
110
+ assert.equal(snapshotBrowserForChannel('chrome'), 'chrome');
111
+ assert.equal(snapshotBrowserForChannel('chrome-beta'), 'chrome');
112
+ assert.equal(snapshotBrowserForChannel('msedge-dev'), 'edge');
113
+ assert.equal(snapshotBrowserForChannel('brave'), 'brave');
114
+ assert.equal(snapshotBrowserForChannel('chromium'), 'chromium');
115
+ });
116
+
117
+ it('rejects attach with userDataDir, migrateFrom, other modes and --profile-directory', () => {
118
+ const attach = { mode: 'snapshot' };
119
+ validateAttachOption({ attach });
120
+ validateAttachOption({ attach: undefined, userDataDir: '/tmp/x' });
121
+ for (const options of [
122
+ { attach, userDataDir: '/tmp/x' },
123
+ { attach, migrateFrom: { browser: 'chrome' } },
124
+ { attach: { mode: 'extension' } },
125
+ { attach: 'snapshot' },
126
+ { attach, customArgs: ['--profile-directory=Profile 1'] },
127
+ ]) {
128
+ assert.throws(() => validateAttachOption(options), TypeError);
129
+ }
130
+ });
131
+
132
+ it('rejects attach and userDataDir before anything touches the disk', async () => {
133
+ const calls = [];
134
+ const { dependencies } = fakeLaunchDependencies(calls);
135
+ for (const options of [
136
+ { attach: { mode: 'snapshot' }, userDataDir: '/tmp/dedicated' },
137
+ { attach: { mode: 'snapshot' }, migrateFrom: { browser: 'chrome' } },
138
+ { attach: { mode: 'open' } },
139
+ ]) {
140
+ await assert.rejects(
141
+ launchAndConnectRealBrowserWithDependencies(options, {
142
+ ...dependencies,
143
+ snapshotUserDataDir: fakeSnapshot(calls),
144
+ }),
145
+ TypeError
146
+ );
147
+ }
148
+ assert.deepEqual(calls, []);
149
+ await assert.rejects(
150
+ launchBrowserWithDependencies({
151
+ launch: 'engine',
152
+ attach: { mode: 'snapshot' },
153
+ }),
154
+ /attach needs launch: 'real'/u
155
+ );
156
+ });
157
+
158
+ it('launches on the snapshot with --profile-directory and deletes it on exit', async () => {
159
+ const calls = [];
160
+ const snapshots = [];
161
+ const { browserProcess, dependencies } = fakeLaunchDependencies(calls);
162
+
163
+ const result = await launchAndConnectRealBrowserWithDependencies(
164
+ {
165
+ engine: 'puppeteer',
166
+ channel: 'msedge',
167
+ attach: { mode: 'snapshot', profile: 'Profile 1' },
168
+ args: ['--lang=en-US'],
169
+ },
170
+ { ...dependencies, snapshotUserDataDir: fakeSnapshot(snapshots) }
171
+ );
172
+
173
+ assert.deepEqual(snapshots, [
174
+ { browser: 'edge', profile: 'Profile 1', userDataDir: undefined },
175
+ ]);
176
+ assert.equal(result.temporaryProfile, true);
177
+ assert.equal(result.userDataDir, result.attach.snapshot.target);
178
+ assert.equal(result.attach.mode, 'snapshot');
179
+ assert.deepEqual(
180
+ result.attach.differences,
181
+ describeAttachDifferences('snapshot')
182
+ );
183
+ assert.deepEqual(calls[0], [
184
+ 'spawn',
185
+ [
186
+ `--user-data-dir=${result.userDataDir}`,
187
+ '--remote-debugging-port=9555',
188
+ '--profile-directory=Profile 1',
189
+ '--lang=en-US',
190
+ 'about:blank',
191
+ ],
192
+ ]);
193
+
194
+ await access(result.userDataDir);
195
+ browserProcess.exit(0);
196
+ await new Promise((resolve) => setTimeout(resolve, 50));
197
+ await assert.rejects(access(result.userDataDir), { code: 'ENOENT' });
198
+ });
199
+
200
+ it('warns when the keystore is bypassed or the browsers differ', async () => {
201
+ const calls = [];
202
+ const launch = await prepareSnapshotLaunch({
203
+ attach: { mode: 'snapshot', browser: 'chrome' },
204
+ channel: 'brave',
205
+ restrictions: [],
206
+ customArgs: [
207
+ '--use-mock-keychain',
208
+ '--password-store=basic',
209
+ '--password-store=detect',
210
+ ],
211
+ snapshot: fakeSnapshot(calls, {
212
+ warnings: [{ item: 'Local State', reason: 'local-state-missing' }],
213
+ }),
214
+ });
215
+
216
+ assert.deepEqual(launch.args, []);
217
+ assert.deepEqual(
218
+ launch.attach.snapshot.warnings.map(({ item, reason }) => [item, reason]),
219
+ [
220
+ ['Local State', 'local-state-missing'],
221
+ ['--use-mock-keychain', 'encrypted-data-unreadable'],
222
+ ['--password-store=basic', 'encrypted-data-unreadable'],
223
+ ['brave', 'browser-mismatch'],
224
+ ]
225
+ );
226
+ assert.equal(calls[0].profile, 'Default');
227
+ });
228
+ });
229
+
230
+ describe('attachUserBrowser', () => {
231
+ it('dispatches each mode to its implementation', async () => {
232
+ const seen = [];
233
+ const dependencies = {
234
+ launchRealBrowser: async (options) => {
235
+ seen.push(['launch', options]);
236
+ return { page: 'page', attach: { mode: 'snapshot' } };
237
+ },
238
+ attachViaExtension: async (options) => {
239
+ seen.push(['extension', options]);
240
+ return { mode: 'extension' };
241
+ },
242
+ openInUserBrowser: async (url, options) => {
243
+ seen.push(['open', url, options]);
244
+ return { opened: url, command: ['xdg-open', url] };
245
+ },
246
+ };
247
+
248
+ const snapshot = await attachUserBrowser(
249
+ { mode: 'snapshot', browser: 'chrome', profile: 'P', headless: true },
250
+ dependencies
251
+ );
252
+ assert.equal(snapshot.page, 'page');
253
+ assert.equal(snapshot.mode, 'snapshot');
254
+ assert.ok(snapshot.differences.length > 0);
255
+ assert.deepEqual(
256
+ await attachUserBrowser({ mode: 'extension', port: 1 }, dependencies),
257
+ { mode: 'extension' }
258
+ );
259
+ assert.deepEqual(
260
+ await attachUserBrowser(
261
+ { mode: 'open', url: 'https://a.test/' },
262
+ dependencies
263
+ ),
264
+ {
265
+ mode: 'open',
266
+ opened: 'https://a.test/',
267
+ command: ['xdg-open', 'https://a.test/'],
268
+ differences: [],
269
+ }
270
+ );
271
+ assert.deepEqual(seen, [
272
+ [
273
+ 'launch',
274
+ {
275
+ headless: true,
276
+ attach: {
277
+ mode: 'snapshot',
278
+ browser: 'chrome',
279
+ profile: 'P',
280
+ userDataDir: undefined,
281
+ },
282
+ },
283
+ ],
284
+ ['extension', { port: 1 }],
285
+ ['open', 'https://a.test/', {}],
286
+ ]);
287
+ await assert.rejects(
288
+ attachUserBrowser({ mode: 'open' }, dependencies),
289
+ TypeError
290
+ );
291
+ await assert.rejects(attachUserBrowser({ mode: 'clean' }), TypeError);
292
+ });
293
+
294
+ it('prepares a launch from a real snapshot of a directory', async () => {
295
+ const root = await makeTempDir();
296
+ await mkdir(path.join(root, 'Default'));
297
+ await writeFile(path.join(root, 'Default', 'Preferences'), '{}');
298
+ const to = path.join(await makeTempDir(), 'copy');
299
+
300
+ const launch = await prepareSnapshotLaunch({
301
+ attach: { mode: 'snapshot', userDataDir: root },
302
+ channel: 'chrome',
303
+ restrictions: [],
304
+ customArgs: [],
305
+ snapshot: (options) => snapshotUserDataDir({ ...options, to }),
306
+ });
307
+
308
+ assert.equal(launch.userDataDir, to);
309
+ assert.equal(launch.attach.snapshot.source.userDataDir, root);
310
+ await access(path.join(to, 'Default', 'Preferences'));
311
+ });
312
+ });