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
@@ -1,126 +1,305 @@
1
- import { describe, it, mock, beforeEach, afterEach } from 'node:test';
1
+ import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert';
3
+ import { access } from 'node:fs/promises';
3
4
 
4
- describe('launcher', () => {
5
- describe('launchBrowser args option', () => {
6
- let originalEnv;
7
- let mockChromium;
8
- let mockPuppeteer;
9
- let launchBrowser;
10
- let capturedPlaywrightArgs;
11
- let capturedPuppeteerArgs;
12
-
13
- beforeEach(async () => {
14
- // Save original environment
15
- originalEnv = { ...process.env };
16
-
17
- // Reset captured args
18
- capturedPlaywrightArgs = null;
19
- capturedPuppeteerArgs = null;
20
-
21
- // Create mock browser context for Playwright
22
- const mockBrowserContext = {
23
- pages: () => [
24
- {
25
- bringToFront: async () => {},
26
- },
27
- ],
28
- close: async () => {},
29
- };
5
+ import {
6
+ launchBrowser,
7
+ launchBrowserWithDependencies,
8
+ resolveLaunchExecutable,
9
+ } from '../../../src/browser/launcher.js';
10
+ import { TEMPORARY_PROFILE_PREFIX } from '../../../src/browser/profile-directory.js';
11
+
12
+ const exists = (file) =>
13
+ access(file).then(
14
+ () => true,
15
+ () => false
16
+ );
17
+
18
+ function fakePage(calls = []) {
19
+ return {
20
+ bringToFront: async () => calls.push(['bringToFront']),
21
+ emulateMedia: async (options) => calls.push(['emulateMedia', options]),
22
+ emulateMediaFeatures: async (features) =>
23
+ calls.push(['emulateMediaFeatures', features]),
24
+ };
25
+ }
26
+
27
+ /** A launchRealBrowser stand-in that records what it was asked for. */
28
+ function fakeRealLauncher(calls, { engine = 'playwright' } = {}) {
29
+ return async (options) => {
30
+ calls.push(['launchRealBrowser', options]);
31
+ const page = fakePage(calls);
32
+ const context = { pages: () => [page], close: async () => {} };
33
+ const browser =
34
+ engine === 'playwright'
35
+ ? { contexts: () => [context], close: async () => {} }
36
+ : { pages: async () => [page], close: async () => {} };
37
+ const close = async () => calls.push(['close']);
38
+ if (engine === 'puppeteer') {
39
+ browser.close = close;
40
+ }
41
+ return {
42
+ browser,
43
+ page,
44
+ downloads: null,
45
+ close,
46
+ userDataDir: '/tmp/browser-commander-profile-x',
47
+ temporaryProfile: true,
48
+ args: ['--user-data-dir=/tmp/browser-commander-profile-x'],
49
+ };
50
+ };
51
+ }
52
+
53
+ const systemChrome = async () => '/usr/bin/google-chrome';
54
+
55
+ describe('launchBrowser', () => {
56
+ it('starts the real browser by default, with nothing added (issue #103)', async () => {
57
+ const calls = [];
58
+ const googleBefore = process.env.GOOGLE_API_KEY;
59
+
60
+ const result = await launchBrowserWithDependencies(
61
+ {},
62
+ {
63
+ launchRealBrowser: fakeRealLauncher(calls),
64
+ resolveSystem: systemChrome,
65
+ settleMs: 0,
66
+ }
67
+ );
68
+
69
+ const [, options] = calls.find(([name]) => name === 'launchRealBrowser');
70
+ assert.equal(options.engine, 'playwright');
71
+ assert.equal(options.executablePath, '/usr/bin/google-chrome');
72
+ assert.equal('slowMo' in options, false);
73
+ assert.equal(options.userDataDir, undefined);
74
+ assert.deepEqual(options.restrictions, []);
75
+ assert.deepEqual(options.args, []);
76
+ assert.equal(result.launch, 'real');
77
+ assert.equal(result.temporaryProfile, true);
78
+ // The host process environment is never changed.
79
+ assert.equal(process.env.GOOGLE_API_KEY, googleBefore);
80
+
81
+ // Playwright gets the attached default context, like the persistent
82
+ // context it used to get, and closing it closes the browser.
83
+ assert.equal(typeof result.browser.pages, 'function');
84
+ await result.browser.close();
85
+ assert.deepEqual(calls.at(-1), ['close']);
86
+ });
30
87
 
31
- // Create mock browser for Puppeteer
32
- const mockBrowser = {
33
- pages: async () => [
88
+ it('returns the connected Puppeteer browser in real mode', async () => {
89
+ const calls = [];
90
+ const result = await launchBrowserWithDependencies(
91
+ { engine: 'puppeteer', restrictions: ['no-sync'], slowMo: 20 },
92
+ {
93
+ launchRealBrowser: fakeRealLauncher(calls, { engine: 'puppeteer' }),
94
+ resolveSystem: systemChrome,
95
+ settleMs: 0,
96
+ }
97
+ );
98
+
99
+ const [, options] = calls[0];
100
+ assert.deepEqual(options.restrictions, ['no-sync']);
101
+ assert.equal(options.slowMo, 20);
102
+ await result.close();
103
+ assert.deepEqual(calls.at(-1), ['close']);
104
+ });
105
+
106
+ it('emulates the colour scheme on the attached page', async () => {
107
+ const calls = [];
108
+ await launchBrowserWithDependencies(
109
+ { colorScheme: 'dark' },
110
+ {
111
+ launchRealBrowser: fakeRealLauncher(calls),
112
+ resolveSystem: systemChrome,
113
+ settleMs: 0,
114
+ }
115
+ );
116
+
117
+ assert.deepEqual(
118
+ calls.find(([name]) => name === 'emulateMedia'),
119
+ ['emulateMedia', { colorScheme: 'dark' }]
120
+ );
121
+ });
122
+
123
+ it('closes the browser when post-launch setup fails', async () => {
124
+ const calls = [];
125
+ await assert.rejects(
126
+ () =>
127
+ launchBrowserWithDependencies(
128
+ { fingerprint: { timezone: 'Not/AZone' } },
34
129
  {
35
- bringToFront: async () => {},
36
- },
37
- ],
38
- close: async () => {},
39
- };
130
+ launchRealBrowser: fakeRealLauncher(calls),
131
+ resolveSystem: systemChrome,
132
+ settleMs: 0,
133
+ }
134
+ ),
135
+ /timezone|Not\/AZone/i
136
+ );
137
+ assert.deepEqual(calls.at(-1), ['close']);
138
+ });
40
139
 
41
- // Mock Playwright's chromium
42
- mockChromium = {
43
- launchPersistentContext: async (userDataDir, options) => {
44
- capturedPlaywrightArgs = options.args;
45
- return mockBrowserContext;
46
- },
47
- };
140
+ it('rejects an invalid engine, launch mode or restriction before launching', async () => {
141
+ await assert.rejects(
142
+ () => launchBrowser({ engine: 'invalid-engine' }),
143
+ /Invalid engine: invalid-engine/
144
+ );
145
+ await assert.rejects(
146
+ () => launchBrowser({ launch: 'attach' }),
147
+ /Invalid launch mode: attach/
148
+ );
149
+ await assert.rejects(
150
+ () => launchBrowser({ restrictions: ['no-such-restriction'] }),
151
+ /Unknown launch restriction "no-such-restriction"/
152
+ );
153
+ });
48
154
 
49
- // Mock Puppeteer
50
- mockPuppeteer = {
51
- default: {
52
- launch: async (options) => {
53
- capturedPuppeteerArgs = options.args;
54
- return mockBrowser;
55
- },
155
+ describe("launch: 'engine'", () => {
156
+ function fakePlaywright(calls) {
157
+ const page = fakePage(calls);
158
+ return {
159
+ launchPersistentContext: async (userDataDir, options) => {
160
+ calls.push(['launchPersistentContext', userDataDir, options]);
161
+ return {
162
+ pages: () => [page],
163
+ close: async () => calls.push(['context.close']),
164
+ };
56
165
  },
57
166
  };
167
+ }
58
168
 
59
- // Use mock.module to mock the dynamic imports
60
- // Since we can't easily mock dynamic imports in Node.js test runner,
61
- // we'll test the CHROME_ARGS constant usage directly
62
- });
63
-
64
- afterEach(() => {
65
- // Restore original environment
66
- process.env = originalEnv;
67
- });
169
+ it('uses a fresh temporary profile, deleted on close', async () => {
170
+ const calls = [];
171
+ const result = await launchBrowserWithDependencies(
172
+ { launch: 'engine' },
173
+ { loadEngineModule: async () => fakePlaywright(calls), settleMs: 0 }
174
+ );
68
175
 
69
- it('should export CHROME_ARGS constant', async () => {
70
- const { CHROME_ARGS } = await import('../../../src/core/constants.js');
71
- assert.ok(Array.isArray(CHROME_ARGS));
72
- assert.ok(CHROME_ARGS.length > 0);
73
- });
176
+ const [, userDataDir, options] = calls[0];
177
+ assert.ok(userDataDir.includes(TEMPORARY_PROFILE_PREFIX));
178
+ assert.equal(await exists(userDataDir), true);
179
+ assert.equal(options.slowMo, 0);
180
+ // Only the automation off switch the engine launch needs; none of the
181
+ // pre-#103 CHROME_ARGS.
182
+ assert.deepEqual(options.args, [
183
+ '--disable-blink-features=AutomationControlled',
184
+ ]);
185
+ assert.equal('env' in options, false);
186
+ assert.equal(result.launch, 'engine');
74
187
 
75
- it('should include expected default Chrome args', async () => {
76
- const { CHROME_ARGS } = await import('../../../src/core/constants.js');
77
- assert.ok(CHROME_ARGS.includes('--disable-session-crashed-bubble'));
78
- assert.ok(CHROME_ARGS.includes('--no-first-run'));
79
- assert.ok(CHROME_ARGS.includes('--no-default-browser-check'));
188
+ await result.browser.close();
189
+ assert.deepEqual(calls.at(-1), ['context.close']);
190
+ assert.equal(await exists(userDataDir), false);
80
191
  });
81
192
 
82
- it('launchBrowser function should accept args option', async () => {
83
- // We can verify the function signature by checking that it
84
- // destructures args from options without throwing
85
- const { launchBrowser } =
86
- await import('../../../src/browser/launcher.js');
87
- assert.ok(typeof launchBrowser === 'function');
193
+ it('passes restriction environment to the browser process only', async () => {
194
+ const calls = [];
195
+ const googleBefore = process.env.GOOGLE_API_KEY;
196
+ const result = await launchBrowserWithDependencies(
197
+ { launch: 'engine', restrictions: ['legacy-launch-browser'] },
198
+ { loadEngineModule: async () => fakePlaywright(calls), settleMs: 0 }
199
+ );
88
200
 
89
- // The function should accept options object with args array
90
- // We can't fully test the launch without actual browsers,
91
- // but we can verify the function exists and is callable
201
+ const [, , options] = calls[0];
202
+ assert.equal(options.env.GOOGLE_API_KEY, 'no');
203
+ assert.equal(process.env.GOOGLE_API_KEY, googleBefore);
204
+ assert.ok(options.args.includes('--no-first-run'));
205
+ assert.ok(options.args.includes('--disable-features=Translate'));
206
+ await result.close();
92
207
  });
93
208
 
94
- it('launchBrowser should throw for invalid engine', async () => {
95
- const { launchBrowser } =
96
- await import('../../../src/browser/launcher.js');
97
-
98
- await assert.rejects(
99
- () => launchBrowser({ engine: 'invalid-engine' }),
100
- (error) => {
101
- assert.ok(error.message.includes('Invalid engine'));
102
- assert.ok(error.message.includes('invalid-engine'));
103
- return true;
104
- }
209
+ it('launches Puppeteer without --start-maximized', async () => {
210
+ const calls = [];
211
+ const page = fakePage(calls);
212
+ const puppeteer = {
213
+ launch: async (options) => {
214
+ calls.push(['launch', options]);
215
+ return {
216
+ pages: async () => [page],
217
+ close: async () => calls.push(['browser.close']),
218
+ };
219
+ },
220
+ };
221
+ const result = await launchBrowserWithDependencies(
222
+ { engine: 'puppeteer', launch: 'engine' },
223
+ { loadEngineModule: async () => puppeteer, settleMs: 0 }
105
224
  );
225
+
226
+ const [, options] = calls[0];
227
+ assert.equal(options.args.includes('--start-maximized'), false);
228
+ await result.close();
229
+ assert.deepEqual(calls.at(-1), ['browser.close']);
106
230
  });
231
+ });
232
+ });
107
233
 
108
- it('launchBrowser should accept args in options', async () => {
109
- const { launchBrowser } =
110
- await import('../../../src/browser/launcher.js');
234
+ describe('resolveLaunchExecutable', () => {
235
+ it('honours an explicit channel or executable path', async () => {
236
+ const requests = [];
237
+ const resolveSystem = async (request) => {
238
+ requests.push(request);
239
+ return '/resolved';
240
+ };
241
+ await resolveLaunchExecutable(
242
+ { engine: 'playwright', channel: 'msedge' },
243
+ { resolveSystem }
244
+ );
245
+ await resolveLaunchExecutable(
246
+ { engine: 'playwright', executablePath: '/opt/chrome' },
247
+ { resolveSystem }
248
+ );
249
+ assert.deepEqual(requests, [
250
+ { channel: 'msedge', executablePath: undefined },
251
+ { channel: 'chrome', executablePath: '/opt/chrome' },
252
+ ]);
253
+ });
111
254
 
112
- // Verify the function signature accepts args
113
- // This test validates that the args parameter is correctly destructured
114
- // by attempting to call with an invalid engine (which fails before browser launch)
115
- // but proves args is accepted without error during options parsing
116
- const customArgs = ['--no-sandbox', '--disable-setuid-sandbox'];
255
+ it("falls back to the engine's downloaded Chromium without an installed Chrome", async () => {
256
+ const notInstalled = async () => {
257
+ throw new Error('Chrome is not installed');
258
+ };
259
+ const executable = await resolveLaunchExecutable(
260
+ { engine: 'playwright' },
261
+ {
262
+ resolveSystem: notInstalled,
263
+ loadEngineModule: async () => ({
264
+ executablePath: () => process.execPath,
265
+ }),
266
+ }
267
+ );
268
+ assert.equal(executable, process.execPath);
117
269
 
118
- await assert.rejects(
119
- () => launchBrowser({ engine: 'invalid', args: customArgs }),
120
- /Invalid engine/
121
- );
270
+ await assert.rejects(
271
+ () =>
272
+ resolveLaunchExecutable(
273
+ { engine: 'playwright' },
274
+ {
275
+ resolveSystem: notInstalled,
276
+ loadEngineModule: async () => ({
277
+ executablePath: () => '/nonexistent/chrome',
278
+ }),
279
+ }
280
+ ),
281
+ /Chrome is not installed/
282
+ );
283
+ });
284
+ });
122
285
 
123
- // If we got here, the args option was accepted without error
124
- });
286
+ describe('public launch API', () => {
287
+ it('exports the launch modes, restrictions and profile helpers', async () => {
288
+ const api = await import('../../../src/index.js');
289
+ assert.deepEqual(api.LAUNCH_MODES, ['real', 'engine']);
290
+ assert.ok(api.LAUNCH_RESTRICTIONS.some(({ id }) => id === 'no-sync'));
291
+ assert.ok(api.LAUNCH_RESTRICTION_PRESETS['legacy-defaults']);
292
+ for (const name of [
293
+ 'resolveRestrictions',
294
+ 'mergeFeatureSwitches',
295
+ 'resolveLaunchExecutable',
296
+ 'createTemporaryUserDataDir',
297
+ 'prepareUserDataDir',
298
+ 'removeUserDataDir',
299
+ 'reserveLoopbackPort',
300
+ 'PortRaceError',
301
+ ]) {
302
+ assert.equal(typeof api[name], 'function', name);
303
+ }
125
304
  });
126
305
  });
@@ -38,12 +38,24 @@ describe('emulateMedia', () => {
38
38
  () =>
39
39
  emulateMedia({
40
40
  page: mockPage,
41
- engine: 'selenium',
41
+ engine: 'webkit-classic',
42
42
  colorScheme: 'dark',
43
43
  }),
44
44
  /Unsupported engine/
45
45
  );
46
46
  });
47
+
48
+ it('should explain that the selenium engine has no media emulation', async () => {
49
+ await assert.rejects(
50
+ () =>
51
+ emulateMedia({
52
+ page: {},
53
+ engine: 'selenium',
54
+ colorScheme: 'dark',
55
+ }),
56
+ /not available with the selenium engine.*webdriver-no-cdp-emulation/
57
+ );
58
+ });
47
59
  });
48
60
 
49
61
  describe('Playwright engine', () => {
@@ -0,0 +1,69 @@
1
+ import assert from 'node:assert';
2
+ import { describe, it } from 'node:test';
3
+
4
+ import {
5
+ countBookmarks,
6
+ migrateBookmarks,
7
+ } from '../../../../src/browser/migration/bookmarks.js';
8
+ import {
9
+ assertNothingMigrated,
10
+ migrateBetween,
11
+ readProfileJson,
12
+ writeProfileJson,
13
+ } from '../../../helpers/migration-fixtures.js';
14
+ import { useTempDirectories } from '../../../helpers/temp-directory.js';
15
+
16
+ const makeTempDir = useTempDirectories('bc-bookmarks-');
17
+
18
+ const SAMPLE_BOOKMARKS = {
19
+ checksum: 'source-checksum',
20
+ roots: {
21
+ bookmark_bar: {
22
+ type: 'folder',
23
+ children: [
24
+ { type: 'url', name: 'A', url: 'https://a.example/' },
25
+ {
26
+ type: 'folder',
27
+ children: [{ type: 'url', name: 'B', url: 'https://b.example/' }],
28
+ },
29
+ ],
30
+ },
31
+ other: { type: 'folder', children: [] },
32
+ },
33
+ };
34
+
35
+ describe('countBookmarks', () => {
36
+ it('counts url nodes recursively across roots', () => {
37
+ assert.equal(countBookmarks(SAMPLE_BOOKMARKS), 2);
38
+ });
39
+
40
+ it('returns 0 for empty or invalid trees', () => {
41
+ assert.equal(countBookmarks({}), 0);
42
+ assert.equal(countBookmarks(null), 0);
43
+ });
44
+ });
45
+
46
+ describe('migrateBookmarks', () => {
47
+ it('copies the Bookmarks JSON verbatim and reports the count', async () => {
48
+ const source = await makeTempDir();
49
+ const target = await makeTempDir();
50
+ await writeProfileJson(source, 'Bookmarks', SAMPLE_BOOKMARKS);
51
+
52
+ const report = await migrateBetween(migrateBookmarks, source, target);
53
+
54
+ assert.equal(report.migrated, 2);
55
+ assert.deepEqual(report.skipped, []);
56
+ const copied = await readProfileJson(target, 'Bookmarks');
57
+ assert.deepEqual(copied, SAMPLE_BOOKMARKS);
58
+ });
59
+
60
+ it('reports a skip when the source has no bookmarks', async () => {
61
+ const source = await makeTempDir();
62
+ const target = await makeTempDir();
63
+
64
+ const report = await migrateBetween(migrateBookmarks, source, target);
65
+
66
+ assertNothingMigrated(report, 'source-has-no-bookmarks');
67
+ assert.equal(report.skipped.length, 1);
68
+ });
69
+ });
@@ -0,0 +1,94 @@
1
+ import assert from 'node:assert';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { describe, it } from 'node:test';
4
+
5
+ import {
6
+ createWindowsProfileKey,
7
+ encryptChromiumValue,
8
+ } from '../../../../src/browser/migration/chromium-crypto.js';
9
+ import {
10
+ decryptChromiumCookie,
11
+ deriveChromiumCookieKey,
12
+ } from '../../../../src/browser/browser-cookie-crypto.js';
13
+
14
+ describe('encryptChromiumValue', () => {
15
+ for (const platform of ['darwin', 'linux']) {
16
+ it(`round-trips through AES-128-CBC on ${platform}`, () => {
17
+ const key = deriveChromiumCookieKey('safe-storage-pass', platform);
18
+ const encrypted = encryptChromiumValue({
19
+ plaintext: 'hunter2',
20
+ key,
21
+ platform,
22
+ prefix: 'v11',
23
+ });
24
+ assert.equal(encrypted.subarray(0, 3).toString('ascii'), 'v11');
25
+ const decrypted = decryptChromiumCookie({
26
+ encryptedValue: encrypted,
27
+ host: 'accounts.example.com',
28
+ databaseVersion: 0,
29
+ platform,
30
+ key,
31
+ });
32
+ assert.equal(decrypted, 'hunter2');
33
+ });
34
+ }
35
+
36
+ it('round-trips through AES-256-GCM on win32', () => {
37
+ const key = randomBytes(32);
38
+ const encrypted = encryptChromiumValue({
39
+ plaintext: 'p@ssw0rd',
40
+ key,
41
+ platform: 'win32',
42
+ prefix: 'v10',
43
+ });
44
+ assert.equal(encrypted.subarray(0, 3).toString('ascii'), 'v10');
45
+ const decrypted = decryptChromiumCookie({
46
+ encryptedValue: encrypted,
47
+ host: 'login.example.com',
48
+ databaseVersion: 0,
49
+ platform: 'win32',
50
+ key,
51
+ });
52
+ assert.equal(decrypted, 'p@ssw0rd');
53
+ });
54
+
55
+ it('requires a Buffer key and a supported platform', () => {
56
+ assert.throws(
57
+ () =>
58
+ encryptChromiumValue({
59
+ plaintext: 'x',
60
+ key: 'nope',
61
+ platform: 'linux',
62
+ }),
63
+ /target encryption key/
64
+ );
65
+ assert.throws(
66
+ () =>
67
+ encryptChromiumValue({
68
+ plaintext: 'x',
69
+ key: randomBytes(16),
70
+ platform: 'sunos',
71
+ }),
72
+ /unsupported/
73
+ );
74
+ });
75
+ });
76
+
77
+ describe('createWindowsProfileKey', () => {
78
+ it('tags the DPAPI-protected key and base64-encodes it', async () => {
79
+ const encryptDpapi = (input) => Buffer.concat([Buffer.from('ENC'), input]);
80
+ const { key, encryptedKeyBase64 } = await createWindowsProfileKey({
81
+ encryptDpapi,
82
+ generateKey: () => Buffer.alloc(32, 7),
83
+ });
84
+ assert.equal(key.length, 32);
85
+ const decoded = Buffer.from(encryptedKeyBase64, 'base64');
86
+ assert.equal(decoded.subarray(0, 5).toString('ascii'), 'DPAPI');
87
+ assert.equal(decoded.subarray(5, 8).toString('ascii'), 'ENC');
88
+ assert.deepEqual(decoded.subarray(8), Buffer.alloc(32, 7));
89
+ });
90
+
91
+ it('requires an encryptDpapi function', async () => {
92
+ await assert.rejects(createWindowsProfileKey({}), /encryptDpapi/);
93
+ });
94
+ });