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,390 @@
1
+ // feature-parity: attach.snapshot
2
+ import assert from 'node:assert';
3
+ import { createHash } from 'node:crypto';
4
+ import {
5
+ chmod,
6
+ mkdir,
7
+ readdir,
8
+ readFile,
9
+ stat,
10
+ symlink,
11
+ writeFile,
12
+ } from 'node:fs/promises';
13
+ import path from 'node:path';
14
+ import { describe, it } from 'node:test';
15
+
16
+ import BetterSqlite3 from 'better-sqlite3';
17
+
18
+ import {
19
+ isSqliteFile,
20
+ snapshotSkipReason,
21
+ snapshotUserDataDir,
22
+ } from '../../../../src/browser/attach/snapshot.js';
23
+ import { removeUserDataDir } from '../../../../src/browser/profile-directory.js';
24
+ import {
25
+ readProfileJson,
26
+ writeChromiumHistory,
27
+ writeProfileJson,
28
+ } from '../../../helpers/migration-fixtures.js';
29
+ import { useTempDirectories } from '../../../helpers/temp-directory.js';
30
+
31
+ const makeTempDir = useTempDirectories('bc-attach-snapshot-');
32
+
33
+ function snapshotChrome(root, to) {
34
+ return snapshotUserDataDir({ browser: 'chrome', userDataDir: root, to });
35
+ }
36
+
37
+ /** Write `content` to `root/relativePath`, creating the parent folders. */
38
+ async function put(root, relativePath, content = relativePath) {
39
+ const filePath = path.join(root, ...relativePath.split('/'));
40
+ await mkdir(path.dirname(filePath), { recursive: true });
41
+ await writeFile(filePath, content);
42
+ return filePath;
43
+ }
44
+
45
+ /**
46
+ * A Chromium user data directory with one `Default` profile holding a mix of
47
+ * data, caches, locks and session files, plus a second profile.
48
+ */
49
+ async function makeUserDataDir() {
50
+ const root = await makeTempDir();
51
+ await put(root, 'Local State', JSON.stringify({ os_crypt: { key: 'k' } }));
52
+ await put(root, 'SingletonLock');
53
+ const profile = path.join(root, 'Default');
54
+ await mkdir(profile);
55
+ await writeProfileJson(profile, 'Preferences', {
56
+ profile: { name: 'Person 1', exit_type: 'Crashed', exited_cleanly: false },
57
+ });
58
+ await writeProfileJson(profile, 'Bookmarks', { roots: {} });
59
+ writeChromiumHistory(profile, 3);
60
+ for (const item of [
61
+ 'LOCK',
62
+ 'Cache/Cache_Data/data_0',
63
+ 'Code Cache/js/index',
64
+ 'GPUCache/data_1',
65
+ 'Service Worker/CacheStorage/abc/index',
66
+ 'Service Worker/Database/000003.log',
67
+ 'Sessions/Session_1',
68
+ 'Current Session',
69
+ 'Local Storage/leveldb/000003.log',
70
+ 'Local Storage/leveldb/LOCK',
71
+ 'Cookies-journal',
72
+ ]) {
73
+ await put(profile, item);
74
+ }
75
+ await put(root, 'Profile 1/Preferences', '{}');
76
+ return root;
77
+ }
78
+
79
+ /** Hash, size and mtime of every file under a directory. */
80
+ async function fingerprintTree(root) {
81
+ const entries = await readdir(root, { recursive: true, withFileTypes: true });
82
+ const result = {};
83
+ for (const entry of entries) {
84
+ if (!entry.isFile()) {
85
+ continue;
86
+ }
87
+ const filePath = path.join(entry.parentPath ?? entry.path, entry.name);
88
+ const { mtimeMs, size } = await stat(filePath);
89
+ const hash = createHash('sha256')
90
+ .update(await readFile(filePath))
91
+ .digest('hex');
92
+ result[path.relative(root, filePath)] = { hash, mtimeMs, size };
93
+ }
94
+ return result;
95
+ }
96
+
97
+ function countRows(databasePath) {
98
+ const database = new BetterSqlite3(databasePath, {
99
+ readonly: true,
100
+ fileMustExist: true,
101
+ });
102
+ try {
103
+ return database.prepare('SELECT COUNT(*) AS c FROM urls').get().c;
104
+ } finally {
105
+ database.close();
106
+ }
107
+ }
108
+
109
+ describe('snapshotSkipReason', () => {
110
+ it('names why locks, caches, crash reports, sessions and sidecars are skipped', () => {
111
+ const cases = {
112
+ SingletonLock: 'lock',
113
+ SingletonSocket: 'lock',
114
+ 'Default/LOCK': 'lock',
115
+ 'Default/lockfile': 'lock',
116
+ 'Default/Cache': 'cache',
117
+ 'Default/Code Cache': 'cache',
118
+ 'Default/GPUCache': 'cache',
119
+ 'Default/DawnGraphiteCache': 'cache',
120
+ 'Default/Service Worker/CacheStorage': 'cache',
121
+ Crashpad: 'crash-reports',
122
+ 'Default/Sessions': 'session',
123
+ 'Default/Current Tabs': 'session',
124
+ 'Default/Last Session': 'session',
125
+ 'Default/History-journal': 'sqlite-sidecar',
126
+ 'Default/Cookies-wal': 'sqlite-sidecar',
127
+ 'Default/Cookies-shm': 'sqlite-sidecar',
128
+ 'Default/History': null,
129
+ 'Default/Preferences': null,
130
+ 'Default/Service Worker/Database': null,
131
+ 'Default/Local Storage/leveldb/000003.log': null,
132
+ };
133
+ for (const [relativePath, reason] of Object.entries(cases)) {
134
+ assert.equal(snapshotSkipReason(relativePath), reason, relativePath);
135
+ }
136
+ });
137
+ });
138
+
139
+ describe('snapshotUserDataDir', () => {
140
+ it('copies Local State and the one profile, skipping locks and caches', async () => {
141
+ const root = await makeUserDataDir();
142
+ const to = path.join(await makeTempDir(), 'copy');
143
+
144
+ const report = await snapshotChrome(root, to);
145
+
146
+ assert.deepEqual(report.source, {
147
+ browser: 'chrome',
148
+ profile: 'Default',
149
+ userDataDir: root,
150
+ });
151
+ assert.equal(report.target, to);
152
+ assert.deepEqual(report.warnings, []);
153
+ assert.equal(report.copied.databases, 1);
154
+ assert.equal(
155
+ await readFile(path.join(to, 'Local State'), 'utf8'),
156
+ JSON.stringify({ os_crypt: { key: 'k' } })
157
+ );
158
+ assert.deepEqual(
159
+ await readProfileJson(path.join(to, 'Default'), 'Bookmarks'),
160
+ {
161
+ roots: {},
162
+ }
163
+ );
164
+ assert.equal(countRows(path.join(to, 'Default', 'History')), 3);
165
+ assert.equal(await isSqliteFile(path.join(to, 'Default', 'History')), true);
166
+ for (const kept of [
167
+ 'Default/Service Worker/Database/000003.log',
168
+ 'Default/Local Storage/leveldb/000003.log',
169
+ 'First Run',
170
+ ]) {
171
+ await stat(path.join(to, kept));
172
+ }
173
+
174
+ const skipped = Object.fromEntries(
175
+ report.skipped.map(({ item, reason }) => [item, reason])
176
+ );
177
+ assert.deepEqual(skipped, {
178
+ 'Default/Cache': 'cache',
179
+ 'Default/Code Cache': 'cache',
180
+ 'Default/Cookies-journal': 'sqlite-sidecar',
181
+ 'Default/Current Session': 'session',
182
+ 'Default/GPUCache': 'cache',
183
+ 'Default/LOCK': 'lock',
184
+ 'Default/Local Storage/leveldb/LOCK': 'lock',
185
+ 'Default/Service Worker/CacheStorage': 'cache',
186
+ 'Default/Sessions': 'session',
187
+ });
188
+ for (const absent of [
189
+ 'SingletonLock',
190
+ 'Profile 1',
191
+ 'Default/Cache',
192
+ 'Default/Sessions',
193
+ 'Default/LOCK',
194
+ 'Default/Cookies-journal',
195
+ ]) {
196
+ await assert.rejects(stat(path.join(to, absent)), { code: 'ENOENT' });
197
+ }
198
+ });
199
+
200
+ it('never modifies the source', async () => {
201
+ const root = await makeUserDataDir();
202
+ const before = await fingerprintTree(root);
203
+
204
+ const report = await snapshotUserDataDir({
205
+ browser: 'chrome',
206
+ userDataDir: root,
207
+ to: path.join(await makeTempDir(), 'copy'),
208
+ });
209
+
210
+ assert.deepEqual(await fingerprintTree(root), before);
211
+ assert.ok(report.copied.files > 0);
212
+ });
213
+
214
+ it('marks only the copy as cleanly exited', async () => {
215
+ const root = await makeUserDataDir();
216
+ const to = path.join(await makeTempDir(), 'copy');
217
+
218
+ await snapshotUserDataDir({ browser: 'edge', userDataDir: root, to });
219
+
220
+ assert.deepEqual(
221
+ (await readProfileJson(path.join(to, 'Default'), 'Preferences')).profile,
222
+ { name: 'Person 1', exit_type: 'Normal', exited_cleanly: true }
223
+ );
224
+ assert.equal(
225
+ (await readProfileJson(path.join(root, 'Default'), 'Preferences')).profile
226
+ .exit_type,
227
+ 'Crashed'
228
+ );
229
+ });
230
+
231
+ for (const journalMode of ['wal', 'delete']) {
232
+ it(`snapshots a ${journalMode}-mode database while a write transaction holds it`, async () => {
233
+ const root = await makeTempDir();
234
+ const profile = path.join(root, 'Default');
235
+ await mkdir(profile);
236
+ const historyPath = writeChromiumHistory(profile, 5);
237
+ const live = new BetterSqlite3(historyPath);
238
+ try {
239
+ live.pragma(`journal_mode = ${journalMode}`);
240
+ // Keep committed rows in the WAL, as a running Chrome does.
241
+ live.pragma('wal_autocheckpoint = 0');
242
+ const insert = live.prepare('INSERT INTO urls (url) VALUES (?)');
243
+ insert.run('https://committed.test/');
244
+ live.exec('BEGIN IMMEDIATE');
245
+ for (let index = 0; index < 50; index += 1) {
246
+ insert.run(`https://uncommitted.test/${index}`);
247
+ }
248
+ const sourceFiles = [
249
+ 'History',
250
+ `History-${journalMode === 'wal' ? 'wal' : 'journal'}`,
251
+ ];
252
+ const before = await Promise.all(
253
+ sourceFiles.map((name) => readFile(path.join(profile, name)))
254
+ );
255
+ const to = path.join(await makeTempDir(), 'copy');
256
+
257
+ const report = await snapshotChrome(root, to);
258
+
259
+ const copy = path.join(to, 'Default', 'History');
260
+ // One self-contained file: the committed WAL content is folded in.
261
+ assert.deepEqual(await readdir(path.join(to, 'Default')), ['History']);
262
+ assert.equal(report.copied.databases, 1);
263
+ assert.equal(countRows(copy), 6);
264
+ const database = new BetterSqlite3(copy, { readonly: true });
265
+ try {
266
+ assert.equal(
267
+ database.pragma('integrity_check', { simple: true }),
268
+ 'ok'
269
+ );
270
+ } finally {
271
+ database.close();
272
+ }
273
+ assert.ok(
274
+ report.skipped.every(({ reason }) => reason === 'sqlite-sidecar'),
275
+ JSON.stringify(report.skipped)
276
+ );
277
+ const after = await Promise.all(
278
+ sourceFiles.map((name) => readFile(path.join(profile, name)))
279
+ );
280
+ assert.deepEqual(after, before);
281
+ } finally {
282
+ if (live.inTransaction) {
283
+ live.exec('ROLLBACK');
284
+ }
285
+ live.close();
286
+ }
287
+ });
288
+ }
289
+
290
+ it(
291
+ 'reports symlinks and unreadable entries instead of failing',
292
+ {
293
+ skip:
294
+ process.platform === 'win32' && 'symlinks and modes differ on Windows',
295
+ },
296
+ async () => {
297
+ const root = await makeTempDir();
298
+ const profile = path.join(root, 'Default');
299
+ await put(profile, 'Preferences', '{}');
300
+ const secret = await put(profile, 'Secret');
301
+ await symlink(path.join(root, 'elsewhere'), path.join(profile, 'Link'));
302
+ const runsAsRoot = process.getuid?.() === 0;
303
+ await chmod(secret, 0o000);
304
+ const to = path.join(await makeTempDir(), 'copy');
305
+
306
+ try {
307
+ const report = await snapshotUserDataDir({
308
+ browser: 'chrome',
309
+ userDataDir: root,
310
+ to,
311
+ });
312
+ const reasons = Object.fromEntries(
313
+ report.skipped.map(({ item, reason }) => [item, reason])
314
+ );
315
+ assert.equal(reasons['Default/Link'], 'symlink');
316
+ if (!runsAsRoot) {
317
+ assert.equal(reasons['Default/Secret'], 'unreadable');
318
+ }
319
+ assert.equal(report.warnings[0].reason, 'local-state-missing');
320
+ } finally {
321
+ await chmod(secret, 0o600);
322
+ }
323
+ }
324
+ );
325
+
326
+ it('creates a temporary target when none is given', async () => {
327
+ const root = await makeUserDataDir();
328
+
329
+ const report = await snapshotUserDataDir({
330
+ browser: 'chrome',
331
+ profile: 'Profile 1',
332
+ userDataDir: root,
333
+ });
334
+
335
+ try {
336
+ assert.equal(report.source.profile, 'Profile 1');
337
+ await stat(path.join(report.target, 'Profile 1', 'Preferences'));
338
+ await assert.rejects(stat(path.join(report.target, 'Default')), {
339
+ code: 'ENOENT',
340
+ });
341
+ } finally {
342
+ await removeUserDataDir(report.target);
343
+ }
344
+ });
345
+
346
+ it('rejects Firefox, bad profile names and missing profiles', async () => {
347
+ const root = await makeUserDataDir();
348
+
349
+ await assert.rejects(
350
+ snapshotUserDataDir({ browser: 'firefox', userDataDir: root }),
351
+ /only supported for Chromium-family browsers/u
352
+ );
353
+ await assert.rejects(snapshotUserDataDir({}), TypeError);
354
+ for (const profile of ['', '..', 'a/b', 'a\\b']) {
355
+ await assert.rejects(
356
+ snapshotUserDataDir({ browser: 'chrome', profile, userDataDir: root }),
357
+ TypeError,
358
+ profile
359
+ );
360
+ }
361
+ await assert.rejects(
362
+ snapshotUserDataDir({
363
+ browser: 'chrome',
364
+ profile: 'Profile 9',
365
+ userDataDir: root,
366
+ }),
367
+ /No chrome profile "Profile 9"/u
368
+ );
369
+ });
370
+
371
+ it('refuses a non-empty target and a target inside the source', async () => {
372
+ const root = await makeUserDataDir();
373
+ const busy = await makeTempDir();
374
+ await put(busy, 'something');
375
+
376
+ await assert.rejects(
377
+ snapshotUserDataDir({ browser: 'chrome', userDataDir: root, to: busy }),
378
+ /is not empty/u
379
+ );
380
+ await assert.rejects(
381
+ snapshotUserDataDir({
382
+ browser: 'chrome',
383
+ userDataDir: root,
384
+ to: path.join(root, 'copy'),
385
+ }),
386
+ /inside the source/u
387
+ );
388
+ await assert.rejects(stat(path.join(root, 'copy')), { code: 'ENOENT' });
389
+ });
390
+ });
@@ -0,0 +1,240 @@
1
+ import assert from 'node:assert';
2
+ import { once } from 'node:events';
3
+ import http from 'node:http';
4
+ import net from 'node:net';
5
+ import { afterEach, describe, it } from 'node:test';
6
+
7
+ import {
8
+ acceptUpgrade,
9
+ CLOSE_CODES,
10
+ computeAcceptKey,
11
+ connectWebSocket,
12
+ encodeFrame,
13
+ FrameDecoder,
14
+ OPCODES,
15
+ WebSocketProtocolError,
16
+ } from '../../../../src/browser/attach/websocket.js';
17
+
18
+ const servers = [];
19
+
20
+ afterEach(async () => {
21
+ for (const server of servers.splice(0)) {
22
+ server.closeAllConnections?.();
23
+ await new Promise((resolve) => server.close(resolve));
24
+ }
25
+ });
26
+
27
+ /**
28
+ * An HTTP server that upgrades every request and hands the server-side
29
+ * connection to the test.
30
+ */
31
+ async function startServer() {
32
+ const connections = [];
33
+ const server = http.createServer((request, response) => {
34
+ response.writeHead(426).end();
35
+ });
36
+ server.on('upgrade', (request, socket, head) => {
37
+ const connection = acceptUpgrade(request, socket, head, {
38
+ maxPayload: 1024,
39
+ });
40
+ if (connection) {
41
+ connections.push(connection);
42
+ server.emit('websocket', connection);
43
+ }
44
+ });
45
+ server.listen(0, '127.0.0.1');
46
+ await once(server, 'listening');
47
+ servers.push(server);
48
+ return {
49
+ url: `ws://127.0.0.1:${server.address().port}/`,
50
+ server,
51
+ connections,
52
+ };
53
+ }
54
+
55
+ async function connectPair() {
56
+ const { url, server } = await startServer();
57
+ const accepted = once(server, 'websocket');
58
+ const client = await connectWebSocket(url);
59
+ const [serverSide] = await accepted;
60
+ return { client, serverSide, url };
61
+ }
62
+
63
+ describe('WebSocket framing', () => {
64
+ it('computes the accept key of RFC 6455 section 1.3', () => {
65
+ assert.equal(
66
+ computeAcceptKey('dGhlIHNhbXBsZSBub25jZQ=='),
67
+ 's3pPLMBiTxaQ9kYGzzhZRbK+xOo='
68
+ );
69
+ });
70
+
71
+ it('round-trips 7-bit, 16-bit and 64-bit lengths, masked, byte by byte', () => {
72
+ for (const size of [0, 125, 126, 65_535, 70_000]) {
73
+ const payload = Buffer.alloc(size, 'x');
74
+ const decoder = new FrameDecoder({ requireMask: true });
75
+ const frame = encodeFrame(OPCODES.text, payload, { mask: true });
76
+ const frames = [];
77
+ const step = size > 1000 ? 4096 : 1;
78
+ for (let offset = 0; offset < frame.length; offset += step) {
79
+ frames.push(...decoder.push(frame.subarray(offset, offset + step)));
80
+ }
81
+ assert.equal(frames.length, 1, `size ${size}`);
82
+ assert.equal(frames[0].opcode, OPCODES.text);
83
+ assert.equal(frames[0].fin, true);
84
+ assert.ok(frames[0].payload.equals(payload), `size ${size}`);
85
+ }
86
+ });
87
+
88
+ it('decodes several frames from one chunk', () => {
89
+ const decoder = new FrameDecoder();
90
+ const frames = decoder.push(
91
+ Buffer.concat([
92
+ encodeFrame(OPCODES.text, 'a', { fin: false }),
93
+ encodeFrame(OPCODES.continuation, 'b'),
94
+ encodeFrame(OPCODES.ping, 'p'),
95
+ ])
96
+ );
97
+ assert.deepEqual(
98
+ frames.map(({ fin, opcode, payload }) => [fin, opcode, String(payload)]),
99
+ [
100
+ [false, OPCODES.text, 'a'],
101
+ [true, OPCODES.continuation, 'b'],
102
+ [true, OPCODES.ping, 'p'],
103
+ ]
104
+ );
105
+ });
106
+
107
+ it('rejects frames that break the protocol', () => {
108
+ const cases = [
109
+ [{ requireMask: true }, encodeFrame(OPCODES.text, 'x'), 1002],
110
+ [
111
+ { forbidMask: true },
112
+ encodeFrame(OPCODES.text, 'x', { mask: true }),
113
+ 1002,
114
+ ],
115
+ [{}, Buffer.from([0xc1, 0x00]), 1002],
116
+ [{}, Buffer.from([0x83, 0x00]), 1002],
117
+ [{}, encodeFrame(OPCODES.ping, Buffer.alloc(126)), 1002],
118
+ [{}, encodeFrame(OPCODES.ping, 'x', { fin: false }), 1002],
119
+ [{ maxPayload: 10 }, encodeFrame(OPCODES.text, Buffer.alloc(11)), 1009],
120
+ [
121
+ { maxPayload: 10 },
122
+ encodeFrame(OPCODES.text, Buffer.alloc(70_000)),
123
+ 1009,
124
+ ],
125
+ ];
126
+ for (const [options, frame, code] of cases) {
127
+ assert.throws(
128
+ () => new FrameDecoder(options).push(frame),
129
+ (error) =>
130
+ error instanceof WebSocketProtocolError && error.code === code
131
+ );
132
+ }
133
+ });
134
+ });
135
+
136
+ describe('WebSocketConnection', () => {
137
+ it('exchanges text messages both ways and answers pings', async () => {
138
+ const { client, serverSide } = await connectPair();
139
+
140
+ const toServer = once(serverSide, 'message');
141
+ client.send('héllo');
142
+ assert.deepEqual(await toServer, ['héllo']);
143
+
144
+ const toClient = once(client, 'message');
145
+ serverSide.send(JSON.stringify({ id: 1 }));
146
+ assert.deepEqual(await toClient, ['{"id":1}']);
147
+
148
+ const pong = once(client, 'pong');
149
+ client.ping(Buffer.from('beat'));
150
+ assert.equal(String((await pong)[0]), 'beat');
151
+
152
+ const closed = Promise.all([
153
+ once(client, 'close'),
154
+ once(serverSide, 'close'),
155
+ ]);
156
+ client.close(CLOSE_CODES.normal, 'bye');
157
+ const [[clientClose], [serverClose]] = await closed;
158
+ assert.deepEqual(serverClose, { code: 1000, reason: 'bye' });
159
+ assert.equal(clientClose.code, 1000);
160
+ assert.equal(client.send('late'), false);
161
+ });
162
+
163
+ it('joins fragmented messages', async () => {
164
+ const { client, serverSide } = await connectPair();
165
+ const message = once(serverSide, 'message');
166
+ client.socket.write(
167
+ Buffer.concat([
168
+ encodeFrame(OPCODES.text, 'frag', { mask: true, fin: false }),
169
+ encodeFrame(OPCODES.ping, '', { mask: true }),
170
+ encodeFrame(OPCODES.continuation, 'mented', { mask: true }),
171
+ ])
172
+ );
173
+ assert.deepEqual(await message, ['fragmented']);
174
+ client.terminate();
175
+ });
176
+
177
+ for (const [name, frame, code] of [
178
+ ['an unmasked client frame', encodeFrame(OPCODES.text, 'x'), 1002],
179
+ [
180
+ 'invalid UTF-8',
181
+ encodeFrame(OPCODES.text, Buffer.from([0xc3, 0x28]), { mask: true }),
182
+ 1007,
183
+ ],
184
+ [
185
+ 'a binary message',
186
+ encodeFrame(OPCODES.binary, 'x', { mask: true }),
187
+ 1003,
188
+ ],
189
+ [
190
+ 'a message over maxPayload',
191
+ encodeFrame(OPCODES.text, Buffer.alloc(2048), { mask: true }),
192
+ 1009,
193
+ ],
194
+ [
195
+ 'a stray continuation frame',
196
+ encodeFrame(OPCODES.continuation, 'x', { mask: true }),
197
+ 1002,
198
+ ],
199
+ ]) {
200
+ it(`closes with ${code} on ${name}`, async () => {
201
+ const { client, serverSide } = await connectPair();
202
+ const closed = once(client, 'close');
203
+ const serverClosed = once(serverSide, 'close');
204
+ client.socket.write(frame);
205
+ const [{ code: received }] = await closed;
206
+ assert.equal(received, code);
207
+ await serverClosed;
208
+ });
209
+ }
210
+
211
+ it('refuses a bad handshake with 400 and an old version with 426', async () => {
212
+ const { url } = await startServer();
213
+ const { port } = new URL(url);
214
+ const statusOf = (headers) =>
215
+ new Promise((resolve, reject) => {
216
+ const socket = net.connect(port, '127.0.0.1', () => {
217
+ socket.write(`GET / HTTP/1.1\r\nHost: x\r\n${headers}\r\n`);
218
+ });
219
+ let text = '';
220
+ socket.on('data', (chunk) => {
221
+ text += chunk;
222
+ });
223
+ socket.on('end', () => resolve(Number(text.split(' ')[1])));
224
+ socket.on('error', reject);
225
+ });
226
+ assert.equal(
227
+ await statusOf(
228
+ 'Connection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Key: short\r\nSec-WebSocket-Version: 13\r\n'
229
+ ),
230
+ 400
231
+ );
232
+ assert.equal(
233
+ await statusOf(
234
+ 'Connection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 8\r\n'
235
+ ),
236
+ 426
237
+ );
238
+ assert.throws(() => connectWebSocket('http://127.0.0.1/'), TypeError);
239
+ });
240
+ });
@@ -37,6 +37,7 @@ import {
37
37
  listBrowserProfiles as publicListBrowserProfiles,
38
38
  readBrowserCookies as publicReadBrowserCookies,
39
39
  } from '../../../src/index.js';
40
+ import { writeFirefoxCookies } from '../../helpers/migration-fixtures.js';
40
41
 
41
42
  const CHROME_EPOCH_OFFSET_SECONDS = 11_644_473_600;
42
43
  const execFile = promisify(execFileCallback);
@@ -136,43 +137,12 @@ async function createFirefoxProfile({ homeDir, rows }) {
136
137
  const root = path.join(homeDir, '.mozilla', 'firefox');
137
138
  const profileName = 'fixture.default-release';
138
139
  const profilePath = path.join(root, profileName);
139
- const cookiePath = path.join(profilePath, 'cookies.sqlite');
140
140
  await mkdir(profilePath, { recursive: true });
141
141
  await writeFile(
142
142
  path.join(root, 'profiles.ini'),
143
143
  `[Profile0]\nName=default-release\nIsRelative=1\nPath=${profileName}\nDefault=1\n`
144
144
  );
145
- const database = await openSqliteDatabase(cookiePath);
146
- database.exec(`
147
- CREATE TABLE moz_cookies (
148
- name TEXT,
149
- value TEXT,
150
- host TEXT,
151
- path TEXT,
152
- expiry INTEGER,
153
- isSecure INTEGER,
154
- isHttpOnly INTEGER,
155
- sameSite INTEGER
156
- );
157
- `);
158
- const insert = database.prepare(`
159
- INSERT INTO moz_cookies
160
- (name, value, host, path, expiry, isSecure, isHttpOnly, sameSite)
161
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
162
- `);
163
- for (const row of rows) {
164
- insert.run(
165
- row.name,
166
- row.value,
167
- row.host,
168
- row.path ?? '/',
169
- row.expiry ?? 0,
170
- row.secure ? 1 : 0,
171
- row.httpOnly ? 1 : 0,
172
- row.sameSite ?? 0
173
- );
174
- }
175
- database.close();
145
+ const cookiePath = await writeFirefoxCookies(profilePath, rows);
176
146
  return { cookiePath, profilePath, root };
177
147
  }
178
148