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,374 @@
1
+ import {
2
+ copyFile,
3
+ mkdir,
4
+ open,
5
+ readdir,
6
+ readFile,
7
+ stat,
8
+ writeFile,
9
+ } from 'node:fs/promises';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+
13
+ import BetterSqlite3 from 'better-sqlite3';
14
+
15
+ import {
16
+ browserProfileRoot,
17
+ normalizeCookieBrowser,
18
+ } from '../browser-profiles.js';
19
+ import { pathExists } from '../migration/fs-utils.js';
20
+ import { withDatabaseSnapshot } from '../migration/sqlite-snapshot.js';
21
+ import {
22
+ createTemporaryUserDataDir,
23
+ LOCAL_STATE_FILE,
24
+ prepareUserDataDir,
25
+ removeUserDataDir,
26
+ } from '../profile-directory.js';
27
+
28
+ /**
29
+ * Snapshot of the real data folder (issue #102, addendum, mode 1).
30
+ *
31
+ * Chrome 136 and later ignore `--remote-debugging-port` for the default user
32
+ * data directory, so the closest thing to "the real folder" that CDP allows is
33
+ * a copy of it in a temporary user data directory. The copy is made read-only
34
+ * from the source, also while the source browser runs:
35
+ *
36
+ * 1. `Local State` (profile-wide settings and, on Windows, the cookie and
37
+ * password key) is copied from `<root>/Local State` to `<target>/Local
38
+ * State`.
39
+ * 2. The one profile directory `<root>/<profile>` is copied to
40
+ * `<target>/<profile>` recursively, keeping the relative layout, so the
41
+ * target is a user data directory with that profile.
42
+ * 3. Entries matching {@link snapshotSkipReason} are not copied and are listed
43
+ * in `skipped`: lock files and sockets, caches, crash reports, the open-tabs
44
+ * session files, and SQLite `-journal`/`-wal`/`-shm` sidecars (the backup
45
+ * snapshot already contains their committed content). Symbolic links and
46
+ * special files are never followed or copied.
47
+ * 4. A file whose first 16 bytes are the SQLite header `SQLite format 3\0` is
48
+ * copied through the SQLite Online Backup API snapshot
49
+ * (`withDatabaseSnapshot`), which is consistent even while the browser
50
+ * writes to it; every other file is a plain byte copy.
51
+ * 5. A file or directory that cannot be read is listed in `skipped` with
52
+ * reason `unreadable` and the error message; it never fails the snapshot.
53
+ * 6. The target gets Chrome's `First Run` sentinel (and the initial Local
54
+ * State when the source had none), and in the copied `Preferences`
55
+ * `profile.exit_type` is set to `Normal` and `profile.exited_cleanly` to
56
+ * true, because a running browser records itself as crashed until it
57
+ * exits and the copy would otherwise show the "restore pages" bubble.
58
+ *
59
+ * Nothing is ever written to the source. Only Chromium-family browsers
60
+ * (chrome, chromium, edge, brave) are supported.
61
+ */
62
+
63
+ /** The 16-byte header every SQLite 3 database file starts with. */
64
+ export const SQLITE_HEADER = Buffer.from('SQLite format 3\0', 'latin1');
65
+
66
+ /** Directory names (at any depth) that are caches. */
67
+ const CACHE_NAMES = new Set([
68
+ 'Cache',
69
+ 'Code Cache',
70
+ 'GPUCache',
71
+ 'DawnCache',
72
+ 'DawnGraphiteCache',
73
+ 'DawnWebGPUCache',
74
+ 'GrShaderCache',
75
+ 'ShaderCache',
76
+ ]);
77
+
78
+ /** Open-tabs session state; the launch opens its own start page. */
79
+ const SESSION_NAMES = new Set([
80
+ 'Sessions',
81
+ 'Current Session',
82
+ 'Current Tabs',
83
+ 'Last Session',
84
+ 'Last Tabs',
85
+ ]);
86
+
87
+ const SQLITE_SIDECAR_SUFFIXES = ['-journal', '-wal', '-shm'];
88
+
89
+ /**
90
+ * Why an entry is not copied, or null when it is.
91
+ *
92
+ * @param {string} relativePath - Path relative to the source root, with `/`
93
+ * separators (for example `Default/Service Worker/CacheStorage`)
94
+ * @returns {('lock'|'cache'|'crash-reports'|'session'|'sqlite-sidecar'|null)}
95
+ */
96
+ export function snapshotSkipReason(relativePath) {
97
+ const name = relativePath.split('/').at(-1);
98
+ if (name.startsWith('Singleton') || name === 'lockfile' || name === 'LOCK') {
99
+ return 'lock';
100
+ }
101
+ if (
102
+ CACHE_NAMES.has(name) ||
103
+ relativePath.endsWith('Service Worker/CacheStorage')
104
+ ) {
105
+ return 'cache';
106
+ }
107
+ if (name === 'Crashpad') {
108
+ return 'crash-reports';
109
+ }
110
+ if (SESSION_NAMES.has(name)) {
111
+ return 'session';
112
+ }
113
+ if (SQLITE_SIDECAR_SUFFIXES.some((suffix) => name.endsWith(suffix))) {
114
+ return 'sqlite-sidecar';
115
+ }
116
+ return null;
117
+ }
118
+
119
+ /**
120
+ * True when a file starts with the SQLite 3 header.
121
+ *
122
+ * @param {string} filePath
123
+ * @returns {Promise<boolean>}
124
+ */
125
+ export async function isSqliteFile(filePath) {
126
+ const handle = await open(filePath, 'r');
127
+ try {
128
+ const header = Buffer.alloc(SQLITE_HEADER.length);
129
+ const { bytesRead } = await handle.read(header, 0, header.length, 0);
130
+ return bytesRead === header.length && header.equals(SQLITE_HEADER);
131
+ } finally {
132
+ await handle.close();
133
+ }
134
+ }
135
+
136
+ function assertProfileName(profile) {
137
+ if (
138
+ typeof profile !== 'string' ||
139
+ profile === '' ||
140
+ profile === '.' ||
141
+ profile === '..' ||
142
+ /[/\\]/u.test(profile)
143
+ ) {
144
+ throw new TypeError(
145
+ `profile must be a profile directory name such as "Default" or "Profile 1", got ${JSON.stringify(profile)}`
146
+ );
147
+ }
148
+ }
149
+
150
+ function isInside(child, parent) {
151
+ const relative = path.relative(path.resolve(parent), path.resolve(child));
152
+ return (
153
+ relative === '' ||
154
+ (!relative.startsWith('..') && !path.isAbsolute(relative))
155
+ );
156
+ }
157
+
158
+ async function prepareTarget(to, sourceRoot) {
159
+ if (!to) {
160
+ return await createTemporaryUserDataDir();
161
+ }
162
+ if (isInside(to, sourceRoot)) {
163
+ throw new Error(
164
+ `The snapshot target ${to} is inside the source ${sourceRoot}`
165
+ );
166
+ }
167
+ await mkdir(to, { recursive: true });
168
+ if ((await readdir(to)).length > 0) {
169
+ throw new Error(`The snapshot target ${to} is not empty`);
170
+ }
171
+ return to;
172
+ }
173
+
174
+ /**
175
+ * Copy a database snapshot to its place in the target. The Online Backup API
176
+ * snapshot is a single self-contained file; the locked-file fallback is a copy
177
+ * with its `-wal`/`-journal` sidecars, which is folded into one file with
178
+ * another backup (of the temporary copy, never of the source).
179
+ */
180
+ async function copyDatabase(snapshotPath, destination) {
181
+ const hasSidecar =
182
+ (await pathExists(`${snapshotPath}-wal`)) ||
183
+ (await pathExists(`${snapshotPath}-journal`));
184
+ if (!hasSidecar) {
185
+ await copyFile(snapshotPath, destination);
186
+ return;
187
+ }
188
+ const database = new BetterSqlite3(snapshotPath, { fileMustExist: true });
189
+ try {
190
+ await database.backup(destination);
191
+ } finally {
192
+ database.close();
193
+ }
194
+ }
195
+
196
+ /** Copy the directory tree below one entry, recording the outcome. */
197
+ class SnapshotCopier {
198
+ constructor(sourceRoot, target) {
199
+ this.sourceRoot = sourceRoot;
200
+ this.target = target;
201
+ this.copied = { files: 0, databases: 0 };
202
+ this.skipped = [];
203
+ }
204
+
205
+ skip(item, reason, detail) {
206
+ this.skipped.push(detail ? { item, reason, detail } : { item, reason });
207
+ }
208
+
209
+ async copyFile(relativePath) {
210
+ const source = path.join(this.sourceRoot, ...relativePath.split('/'));
211
+ const destination = path.join(this.target, ...relativePath.split('/'));
212
+ try {
213
+ if (await isSqliteFile(source)) {
214
+ await withDatabaseSnapshot({
215
+ sourcePath: source,
216
+ read: (snapshotPath) => copyDatabase(snapshotPath, destination),
217
+ });
218
+ this.copied.databases += 1;
219
+ } else {
220
+ await copyFile(source, destination);
221
+ this.copied.files += 1;
222
+ }
223
+ } catch (error) {
224
+ this.skip(relativePath, 'unreadable', error.message);
225
+ }
226
+ }
227
+
228
+ async copyDirectory(relativePath) {
229
+ let entries;
230
+ try {
231
+ entries = await readdir(
232
+ path.join(this.sourceRoot, ...relativePath.split('/')),
233
+ { withFileTypes: true }
234
+ );
235
+ } catch (error) {
236
+ this.skip(relativePath, 'unreadable', error.message);
237
+ return;
238
+ }
239
+ await mkdir(path.join(this.target, ...relativePath.split('/')), {
240
+ recursive: true,
241
+ });
242
+ entries.sort((left, right) => left.name.localeCompare(right.name));
243
+ for (const entry of entries) {
244
+ await this.copyEntry(`${relativePath}/${entry.name}`, entry);
245
+ }
246
+ }
247
+
248
+ async copyEntry(relativePath, entry) {
249
+ const reason = snapshotSkipReason(relativePath);
250
+ if (reason) {
251
+ this.skip(relativePath, reason);
252
+ } else if (entry.isSymbolicLink()) {
253
+ this.skip(relativePath, 'symlink');
254
+ } else if (entry.isDirectory()) {
255
+ await this.copyDirectory(relativePath);
256
+ } else if (entry.isFile()) {
257
+ await this.copyFile(relativePath);
258
+ } else {
259
+ this.skip(relativePath, 'special-file');
260
+ }
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Mark the copied profile as cleanly exited, so the copy of a running browser
266
+ * does not offer to restore pages. The source is never touched.
267
+ */
268
+ async function markExitedCleanly(profileDir) {
269
+ const preferencesPath = path.join(profileDir, 'Preferences');
270
+ let preferences;
271
+ try {
272
+ preferences = JSON.parse(await readFile(preferencesPath, 'utf8'));
273
+ } catch {
274
+ return;
275
+ }
276
+ if (!preferences || typeof preferences !== 'object') {
277
+ return;
278
+ }
279
+ preferences.profile = {
280
+ ...(preferences.profile ?? {}),
281
+ exit_type: 'Normal',
282
+ exited_cleanly: true,
283
+ };
284
+ await writeFile(preferencesPath, JSON.stringify(preferences));
285
+ }
286
+
287
+ /**
288
+ * Copy a real Chromium profile into a user data directory that can be
289
+ * launched with remote debugging.
290
+ *
291
+ * @param {Object} options
292
+ * @param {string} options.browser - chrome, chromium, edge (msedge) or brave
293
+ * @param {string} [options.profile='Default'] - Profile directory name
294
+ * @param {string} [options.userDataDir] - Source user data directory; defaults to the browser's real one
295
+ * @param {string} [options.to] - Target user data directory (created; must be empty); defaults to a new temporary one
296
+ * @param {string} [options.platform=process.platform]
297
+ * @param {string} [options.homeDir=os.homedir()]
298
+ * @param {Object<string,string>} [options.environment=process.env]
299
+ * @returns {Promise<{source: {browser: string, profile: string, userDataDir: string}, target: string, copied: {files: number, databases: number}, skipped: Array<{item: string, reason: string, detail: (string|undefined)}>, warnings: Array<{item: string, reason: string, detail: string}>}>}
300
+ */
301
+ export async function snapshotUserDataDir({
302
+ browser,
303
+ profile = 'Default',
304
+ userDataDir,
305
+ to,
306
+ platform = process.platform,
307
+ homeDir = os.homedir(),
308
+ environment = process.env,
309
+ } = {}) {
310
+ if (!browser) {
311
+ throw new TypeError('snapshotUserDataDir requires a browser');
312
+ }
313
+ const normalizedBrowser = normalizeCookieBrowser(browser);
314
+ if (normalizedBrowser === 'firefox') {
315
+ throw new Error(
316
+ 'A profile snapshot is only supported for Chromium-family browsers (chrome, chromium, edge, brave): Firefox cannot be driven over CDP by this launcher'
317
+ );
318
+ }
319
+ assertProfileName(profile);
320
+ const sourceRoot =
321
+ userDataDir ??
322
+ browserProfileRoot(normalizedBrowser, { platform, homeDir, environment });
323
+ const sourceProfile = path.join(sourceRoot, profile);
324
+ const profileStat = await stat(sourceProfile).catch(() => null);
325
+ if (!profileStat?.isDirectory()) {
326
+ throw new Error(
327
+ `No ${normalizedBrowser} profile "${profile}" found at ${sourceProfile}`
328
+ );
329
+ }
330
+
331
+ const target = await prepareTarget(to, sourceRoot);
332
+ try {
333
+ return await copyProfile({
334
+ sourceRoot,
335
+ profile,
336
+ target,
337
+ source: { browser: normalizedBrowser, profile, userDataDir: sourceRoot },
338
+ });
339
+ } catch (error) {
340
+ if (!to) {
341
+ await removeUserDataDir(target);
342
+ }
343
+ throw error;
344
+ }
345
+ }
346
+
347
+ async function copyProfile({ sourceRoot, profile, target, source }) {
348
+ const copier = new SnapshotCopier(sourceRoot, target);
349
+ const warnings = [];
350
+ const localState = await stat(path.join(sourceRoot, LOCAL_STATE_FILE)).catch(
351
+ () => null
352
+ );
353
+ if (localState?.isFile()) {
354
+ await copier.copyFile(LOCAL_STATE_FILE);
355
+ } else {
356
+ warnings.push({
357
+ item: LOCAL_STATE_FILE,
358
+ reason: 'local-state-missing',
359
+ detail:
360
+ 'The source has no Local State; on Windows it holds the key cookies and passwords are encrypted with, so they cannot be decrypted in the copy.',
361
+ });
362
+ }
363
+ await copier.copyDirectory(profile);
364
+ await prepareUserDataDir(target);
365
+ await markExitedCleanly(path.join(target, profile));
366
+
367
+ return {
368
+ source,
369
+ target,
370
+ copied: copier.copied,
371
+ skipped: copier.skipped,
372
+ warnings,
373
+ };
374
+ }