browser-commander 0.20.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 (139) hide show
  1. package/CHANGELOG.md +145 -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 +19 -4
  10. package/src/browser/attach/differences.js +131 -0
  11. package/src/browser/attach/extension-relay.js +402 -0
  12. package/src/browser/attach/index.js +81 -0
  13. package/src/browser/attach/snapshot-launch.js +146 -0
  14. package/src/browser/attach/snapshot.js +374 -0
  15. package/src/browser/attach/websocket.js +615 -0
  16. package/src/browser/browser-cookie-cache.js +12 -17
  17. package/src/browser/browser-cookie-credentials.js +6 -9
  18. package/src/browser/browser-cookies.js +5 -2
  19. package/src/browser/cdp-session.js +94 -0
  20. package/src/browser/connector.js +42 -4
  21. package/src/browser/debugging-port.js +155 -0
  22. package/src/browser/launch-options.js +63 -52
  23. package/src/browser/launch-restrictions.json +147 -0
  24. package/src/browser/launcher.js +329 -133
  25. package/src/browser/media.js +5 -0
  26. package/src/browser/migration/bookmarks.js +73 -0
  27. package/src/browser/migration/chromium-crypto.js +89 -0
  28. package/src/browser/migration/cookies.js +155 -0
  29. package/src/browser/migration/extensions.js +187 -0
  30. package/src/browser/migration/firefox-bookmarks.js +144 -0
  31. package/src/browser/migration/firefox-der.js +84 -0
  32. package/src/browser/migration/firefox-nss.js +281 -0
  33. package/src/browser/migration/firefox.js +334 -0
  34. package/src/browser/migration/fs-utils.js +51 -0
  35. package/src/browser/migration/history.js +103 -0
  36. package/src/browser/migration/index.js +307 -0
  37. package/src/browser/migration/os-crypt-keys.js +106 -0
  38. package/src/browser/migration/passwords.js +171 -0
  39. package/src/browser/migration/preferences.js +132 -0
  40. package/src/browser/migration/sqlite-snapshot.js +138 -0
  41. package/src/browser/open-in-user-browser.js +112 -0
  42. package/src/browser/parity.js +439 -0
  43. package/src/browser/profile-directory.js +102 -0
  44. package/src/browser/real-browser.js +513 -306
  45. package/src/browser/restrictions.js +148 -0
  46. package/src/browser/system-browser.js +232 -0
  47. package/src/browser/webdriver.js +572 -0
  48. package/src/cli/args.js +253 -0
  49. package/src/cli/commands.js +332 -0
  50. package/src/cli/dispatcher.js +99 -0
  51. package/src/cli/handle-methods.js +136 -0
  52. package/src/cli/handles.js +273 -0
  53. package/src/cli/high-level-methods.js +310 -0
  54. package/src/cli/main.js +67 -0
  55. package/src/cli/modules.js +100 -0
  56. package/src/cli/rpc-error.js +77 -0
  57. package/src/cli/script.js +77 -0
  58. package/src/cli/serve.js +144 -0
  59. package/src/cli/sessions.js +195 -0
  60. package/src/core/constants.js +7 -1
  61. package/src/core/engine-adapter.js +107 -1
  62. package/src/core/engine-detection.js +18 -3
  63. package/src/core/navigation-safety.js +4 -0
  64. package/src/core/webdriver-conversions.js +347 -0
  65. package/src/core/webdriver-events.js +229 -0
  66. package/src/core/webdriver-page.js +716 -0
  67. package/src/downloads/attach.js +6 -0
  68. package/src/elements/selectors.js +3 -2
  69. package/src/elements/visibility.js +4 -0
  70. package/src/exports.js +61 -2
  71. package/src/factory.js +19 -4
  72. package/src/fingerprint/apply.js +6 -0
  73. package/src/fingerprint/automation-parity.js +7 -8
  74. package/src/fingerprint/limitations.js +1 -1
  75. package/src/fingerprint/limitations.json +45 -2
  76. package/src/parity/harness.js +303 -0
  77. package/src/parity/probe.js +838 -0
  78. package/src/parity/version-page.js +144 -0
  79. package/src/utilities/subprocess.js +199 -0
  80. package/tests/e2e/api-coverage.e2e.test.js +174 -0
  81. package/tests/e2e/cli.e2e.test.js +160 -0
  82. package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
  83. package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
  84. package/tests/e2e/measure-parity.e2e.test.js +118 -0
  85. package/tests/e2e/playwright.e2e.test.js +25 -17
  86. package/tests/e2e/puppeteer.e2e.test.js +17 -23
  87. package/tests/e2e/webdriver.e2e.test.js +239 -0
  88. package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
  89. package/tests/helpers/cli-fakes.js +214 -0
  90. package/tests/helpers/declared-api.js +80 -0
  91. package/tests/helpers/e2e-browser.js +40 -14
  92. package/tests/helpers/fake-chrome.js +124 -0
  93. package/tests/helpers/migration-fixtures.js +242 -0
  94. package/tests/helpers/temp-directory.js +24 -0
  95. package/tests/helpers/webdriver-mocks.js +183 -0
  96. package/tests/unit/browser/attach/extension-relay.test.js +360 -0
  97. package/tests/unit/browser/attach/snapshot-launch.test.js +312 -0
  98. package/tests/unit/browser/attach/snapshot.test.js +390 -0
  99. package/tests/unit/browser/attach/websocket.test.js +240 -0
  100. package/tests/unit/browser/browser-cookies.test.js +2 -32
  101. package/tests/unit/browser/cdp-session.test.js +162 -0
  102. package/tests/unit/browser/connector.test.js +23 -0
  103. package/tests/unit/browser/launch-building-blocks.test.js +238 -0
  104. package/tests/unit/browser/launch-options.test.js +41 -22
  105. package/tests/unit/browser/launcher.test.js +281 -102
  106. package/tests/unit/browser/media.test.js +13 -1
  107. package/tests/unit/browser/migration/bookmarks.test.js +69 -0
  108. package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
  109. package/tests/unit/browser/migration/cookies.test.js +94 -0
  110. package/tests/unit/browser/migration/extensions.test.js +104 -0
  111. package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
  112. package/tests/unit/browser/migration/firefox.test.js +139 -0
  113. package/tests/unit/browser/migration/history.test.js +48 -0
  114. package/tests/unit/browser/migration/index.test.js +117 -0
  115. package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
  116. package/tests/unit/browser/migration/passwords.test.js +144 -0
  117. package/tests/unit/browser/migration/preferences.test.js +77 -0
  118. package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
  119. package/tests/unit/browser/open-in-user-browser.test.js +123 -0
  120. package/tests/unit/browser/parity.test.js +281 -0
  121. package/tests/unit/browser/real-browser.test.js +458 -33
  122. package/tests/unit/browser/webdriver.test.js +473 -0
  123. package/tests/unit/cli/bin.test.js +69 -0
  124. package/tests/unit/cli/declared-api.test.js +49 -0
  125. package/tests/unit/cli/dispatcher.test.js +429 -0
  126. package/tests/unit/cli/handles.test.js +170 -0
  127. package/tests/unit/cli/main.test.js +409 -0
  128. package/tests/unit/cli/serve.test.js +167 -0
  129. package/tests/unit/core/webdriver-conversions.test.js +235 -0
  130. package/tests/unit/core/webdriver-page.test.js +534 -0
  131. package/tests/unit/fingerprint/automation-parity.test.js +17 -4
  132. package/tests/unit/fingerprint/limitations.test.js +1 -1
  133. package/tests/unit/parity/harness.test.js +138 -0
  134. package/tests/unit/scripts/ci-timeout-budgets.test.js +4 -0
  135. package/tests/unit/scripts/feature-parity.test.js +221 -0
  136. package/tests/unit/traces/reader.test.js +1 -0
  137. package/tests/unit/traces/recorder.test.js +1 -0
  138. package/tests/unit/utilities/command-stream-only.test.js +28 -0
  139. package/tests/unit/utilities/subprocess.test.js +80 -0
@@ -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
+ });
@@ -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
+ });