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,132 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { pathExists } from './fs-utils.js';
4
+
5
+ /**
6
+ * Preferences migration.
7
+ *
8
+ * Chrome's `Preferences` file is a large JSON document. Copying it wholesale
9
+ * would drag machine- and session-specific state into the dedicated profile
10
+ * (window placement, the profile's own GAIA identity, per-profile paths), so a
11
+ * migration copies only a documented, portable subset and merges it into the
12
+ * target's existing `Preferences`.
13
+ *
14
+ * The subset is the settings a person recognizes as "my browser preferences":
15
+ *
16
+ * - `intl.accept_languages` - the Accept-Language list.
17
+ * - `intl.selected_languages` - the UI language order.
18
+ * - `spellcheck.dictionaries` - enabled spellcheck dictionaries.
19
+ * - `default_search_provider_data` - a custom default search engine.
20
+ * - `browser.theme` - the applied theme id and colors.
21
+ * - `extensions.theme` - the theme extension bookkeeping.
22
+ * - `homepage`, `homepage_is_newtabpage` - the configured homepage.
23
+ * - `session.startup_urls`, `session.restore_on_startup` - the on-startup pages.
24
+ * - `bookmark_bar.show_on_all_tabs` - whether the bookmarks bar is always shown.
25
+ *
26
+ * `download.default_directory` is deliberately excluded: a path that exists on
27
+ * the source machine's account may not exist for the dedicated profile, and a
28
+ * migrated download location is more surprising than helpful.
29
+ *
30
+ * `Secure Preferences` (the file that carries a per-setting HMAC to detect
31
+ * tampering) is not touched; the settings above live in the plain `Preferences`
32
+ * file, which Chrome does not HMAC-protect.
33
+ */
34
+
35
+ /** Dotted paths copied from the source Preferences into the target. */
36
+ export const MIGRATED_PREFERENCE_PATHS = Object.freeze([
37
+ 'intl.accept_languages',
38
+ 'intl.selected_languages',
39
+ 'spellcheck.dictionaries',
40
+ 'default_search_provider_data',
41
+ 'browser.theme',
42
+ 'extensions.theme',
43
+ 'homepage',
44
+ 'homepage_is_newtabpage',
45
+ 'session.startup_urls',
46
+ 'session.restore_on_startup',
47
+ 'bookmark_bar.show_on_all_tabs',
48
+ ]);
49
+
50
+ function getPath(object, dottedPath) {
51
+ return dottedPath
52
+ .split('.')
53
+ .reduce(
54
+ (node, key) => (node && typeof node === 'object' ? node[key] : undefined),
55
+ object
56
+ );
57
+ }
58
+
59
+ function setPath(object, dottedPath, value) {
60
+ const keys = dottedPath.split('.');
61
+ let node = object;
62
+ for (const key of keys.slice(0, -1)) {
63
+ if (!node[key] || typeof node[key] !== 'object') {
64
+ node[key] = {};
65
+ }
66
+ node = node[key];
67
+ }
68
+ node[keys.at(-1)] = value;
69
+ }
70
+
71
+ /**
72
+ * Merge the selected subset of source Preferences into a target Preferences
73
+ * object (pure, so it is easy to unit-test).
74
+ *
75
+ * @param {Object} source - Parsed source Preferences
76
+ * @param {Object} target - Parsed target Preferences (mutated and returned)
77
+ * @param {string[]} [paths=MIGRATED_PREFERENCE_PATHS]
78
+ * @returns {{merged: Object, migratedPaths: string[]}}
79
+ */
80
+ export function mergePreferenceSubset(
81
+ source,
82
+ target,
83
+ paths = MIGRATED_PREFERENCE_PATHS
84
+ ) {
85
+ const migratedPaths = [];
86
+ for (const dottedPath of paths) {
87
+ const value = getPath(source, dottedPath);
88
+ if (value !== undefined) {
89
+ setPath(target, dottedPath, value);
90
+ migratedPaths.push(dottedPath);
91
+ }
92
+ }
93
+ return { merged: target, migratedPaths };
94
+ }
95
+
96
+ /**
97
+ * Migrate the selected Preferences subset into the target profile.
98
+ *
99
+ * @param {Object} options
100
+ * @param {string} options.sourceProfileDir
101
+ * @param {string} options.targetProfileDir
102
+ * @returns {Promise<{migrated: number, skipped: Array, warnings: Array}>}
103
+ */
104
+ export async function migratePreferences({
105
+ sourceProfileDir,
106
+ targetProfileDir,
107
+ }) {
108
+ const sourcePath = path.join(sourceProfileDir, 'Preferences');
109
+ if (!(await pathExists(sourcePath))) {
110
+ return {
111
+ migrated: 0,
112
+ skipped: [
113
+ { type: 'preferences', item: 'Preferences', reason: 'source-missing' },
114
+ ],
115
+ warnings: [],
116
+ };
117
+ }
118
+ const source = JSON.parse(await readFile(sourcePath, 'utf8'));
119
+ const targetPath = path.join(targetProfileDir, 'Preferences');
120
+ let target = {};
121
+ if (await pathExists(targetPath)) {
122
+ try {
123
+ target = JSON.parse(await readFile(targetPath, 'utf8'));
124
+ } catch {
125
+ target = {};
126
+ }
127
+ }
128
+ const { merged, migratedPaths } = mergePreferenceSubset(source, target);
129
+ await mkdir(targetProfileDir, { recursive: true });
130
+ await writeFile(targetPath, JSON.stringify(merged));
131
+ return { migrated: migratedPaths.length, skipped: [], warnings: [] };
132
+ }
@@ -0,0 +1,138 @@
1
+ import { copyFile, mkdtemp, rm } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { pathExists } from './fs-utils.js';
5
+
6
+ import BetterSqlite3 from 'better-sqlite3';
7
+
8
+ import { openSqliteDatabase } from '../browser-cookie-database.js';
9
+
10
+ /**
11
+ * Read a Chromium SQLite database (History, Top Sites, Login Data, Web Data)
12
+ * while the source browser is running, without ever writing to the source.
13
+ *
14
+ * Chrome keeps these databases open in WAL mode and, on Windows, holds a share
15
+ * lock that stops another process from opening the file at all. Two techniques
16
+ * cover both cases:
17
+ *
18
+ * 1. The SQLite Online Backup API (`better-sqlite3`'s `.backup()`), which
19
+ * copies a transactionally consistent snapshot even while the source is
20
+ * being written. This is the same mechanism `browser-cookie-database.js`
21
+ * relies on for a stable read, and SQLite documents it as the correct way
22
+ * to snapshot a live database
23
+ * (https://www.sqlite.org/backup.html).
24
+ * 2. When the source cannot be opened at all (a Windows exclusive lock), the
25
+ * file and its `-wal`/`-journal` sidecars are copied to a temporary
26
+ * directory and the copy is opened instead. Copying the sidecars keeps the
27
+ * committed-but-not-checkpointed pages, so the copy is consistent.
28
+ *
29
+ * Both paths open the source with `readOnly`, so nothing is written back.
30
+ */
31
+
32
+ const SQLITE_SIDECARS = ['-wal', '-shm', '-journal'];
33
+
34
+ /**
35
+ * Copy a SQLite file and its sidecars into a temporary directory.
36
+ *
37
+ * @param {string} sourcePath
38
+ * @returns {Promise<{snapshotPath: string, cleanup: function(): Promise<void>}>}
39
+ */
40
+ async function copyDatabaseFiles(sourcePath) {
41
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'browser-commander-snap-'));
42
+ const base = path.basename(sourcePath);
43
+ const snapshotPath = path.join(dir, base);
44
+ await copyFile(sourcePath, snapshotPath);
45
+ for (const suffix of SQLITE_SIDECARS) {
46
+ const sidecar = `${sourcePath}${suffix}`;
47
+ if (await pathExists(sidecar)) {
48
+ await copyFile(sidecar, `${snapshotPath}${suffix}`);
49
+ }
50
+ }
51
+ return {
52
+ snapshotPath,
53
+ cleanup: () => rm(dir, { recursive: true, force: true, maxRetries: 3 }),
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Produce a consistent, read-only snapshot copy of a live Chromium database in
59
+ * a temporary directory, then hand its path to a reader. The snapshot is always
60
+ * deleted afterwards, and the source is never modified.
61
+ *
62
+ * @param {Object} options
63
+ * @param {string} options.sourcePath - Path to the source database
64
+ * @param {function(string): (Promise<T>|T)} options.read - Reader run against the snapshot copy
65
+ * @returns {Promise<T>}
66
+ * @template T
67
+ */
68
+ export async function withDatabaseSnapshot({ sourcePath, read }) {
69
+ if (!(await pathExists(sourcePath))) {
70
+ throw new Error(`Source database does not exist: ${sourcePath}`);
71
+ }
72
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'browser-commander-snap-'));
73
+ const snapshotPath = path.join(dir, path.basename(sourcePath));
74
+ const cleanupDir = () =>
75
+ rm(dir, { recursive: true, force: true, maxRetries: 3 });
76
+
77
+ let copyFallback;
78
+ try {
79
+ // The backup API needs to open the source. It opens read-only, so nothing
80
+ // is written to the user's real database.
81
+ let source;
82
+ try {
83
+ source = new BetterSqlite3(sourcePath, {
84
+ readonly: true,
85
+ fileMustExist: true,
86
+ });
87
+ } catch {
88
+ source = null;
89
+ }
90
+ if (source) {
91
+ try {
92
+ await source.backup(snapshotPath);
93
+ } finally {
94
+ source.close();
95
+ }
96
+ } else {
97
+ // The source is locked exclusively (Windows); fall back to copying the
98
+ // file and its sidecars, then read that copy.
99
+ await cleanupDir();
100
+ copyFallback = await copyDatabaseFiles(sourcePath);
101
+ }
102
+
103
+ const readPath = copyFallback?.snapshotPath ?? snapshotPath;
104
+ return await read(readPath);
105
+ } finally {
106
+ if (copyFallback) {
107
+ await copyFallback.cleanup();
108
+ } else {
109
+ await cleanupDir();
110
+ }
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Open a snapshot database read-only, run a reader, and close it.
116
+ *
117
+ * @param {Object} options
118
+ * @param {string} options.sourcePath
119
+ * @param {function(Object): (Promise<T>|T)} options.read
120
+ * @returns {Promise<T>}
121
+ * @template T
122
+ */
123
+ export function readDatabaseSnapshot({ sourcePath, read }) {
124
+ return withDatabaseSnapshot({
125
+ sourcePath,
126
+ read: async (snapshotPath) => {
127
+ const db = await openSqliteDatabase(snapshotPath, {
128
+ readOnly: true,
129
+ fileMustExist: true,
130
+ });
131
+ try {
132
+ return await read(db);
133
+ } finally {
134
+ db.close();
135
+ }
136
+ },
137
+ });
138
+ }
@@ -0,0 +1,112 @@
1
+ import { runCommand } from '../utilities/subprocess.js';
2
+
3
+ /**
4
+ * Hand a URL to the operating system's default browser with no automation
5
+ * (issue #102). This is the mode for flows where a consumer only needs the
6
+ * user to see and approve a page - an OAuth screen or a CLI web-login - in the
7
+ * browser where they are already signed in. Nothing is driven, nothing is
8
+ * migrated, and no dedicated profile is created.
9
+ *
10
+ * Each platform has one canonical opener:
11
+ * - macOS: `open <url>`
12
+ * - Linux: `xdg-open <url>` (the freedesktop.org standard launcher)
13
+ * - Windows: `explorer.exe <url>` (Explorer hands the URL to the registered
14
+ * browser without a `cmd.exe` parser seeing its query string)
15
+ *
16
+ * All of them run through command-stream (issue #104) with exact argv
17
+ * boundaries and no shell in between, so a URL can never be reinterpreted as
18
+ * shell syntax.
19
+ */
20
+
21
+ /** Platform openers as [file, ...fixedArgs]; the URL is appended last. */
22
+ const PLATFORM_OPENERS = {
23
+ darwin: ['open'],
24
+ linux: ['xdg-open'],
25
+ win32: ['explorer.exe'],
26
+ };
27
+
28
+ /**
29
+ * URL schemes a browser will open. `open`/`xdg-open`/`start` will also happily
30
+ * launch a local application for another scheme, so the set is deliberately
31
+ * limited to what "open this page" means.
32
+ */
33
+ const ALLOWED_PROTOCOLS = new Set([
34
+ 'http:',
35
+ 'https:',
36
+ 'file:',
37
+ 'ftp:',
38
+ 'about:',
39
+ 'chrome:',
40
+ 'edge:',
41
+ 'view-source:',
42
+ ]);
43
+
44
+ /**
45
+ * Validate a URL string before it reaches an opener.
46
+ *
47
+ * A bare string that begins with `-` would be parsed by the opener as an
48
+ * option rather than a URL, so it is rejected outright; every other string
49
+ * has to parse as a URL with an allowed scheme.
50
+ *
51
+ * @param {string} url
52
+ * @returns {string} The validated URL
53
+ */
54
+ export function validateOpenUrl(url) {
55
+ if (typeof url !== 'string' || url.length === 0) {
56
+ throw new TypeError('openInUserBrowser requires a URL string');
57
+ }
58
+ if (url.startsWith('-')) {
59
+ throw new Error(
60
+ `Refusing to open ${JSON.stringify(url)}: a URL cannot start with "-", which an opener would read as an option`
61
+ );
62
+ }
63
+ let parsed;
64
+ try {
65
+ parsed = new URL(url);
66
+ } catch {
67
+ throw new Error(
68
+ `Refusing to open ${JSON.stringify(url)}: it is not a valid absolute URL`
69
+ );
70
+ }
71
+ if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) {
72
+ throw new Error(
73
+ `Refusing to open ${JSON.stringify(url)}: ${parsed.protocol} is not one of ${[...ALLOWED_PROTOCOLS].join(', ')}`
74
+ );
75
+ }
76
+ return url;
77
+ }
78
+
79
+ /**
80
+ * Build the exact command line for the platform's opener.
81
+ *
82
+ * @param {string} url - An already validated URL
83
+ * @param {string} platform - `process.platform` value
84
+ * @returns {string[]} `[file, ...args]`
85
+ */
86
+ export function buildOpenCommand(url, platform) {
87
+ const opener = PLATFORM_OPENERS[platform];
88
+ if (!opener) {
89
+ throw new Error(
90
+ `openInUserBrowser is not supported on ${platform}; supported platforms are ${Object.keys(PLATFORM_OPENERS).join(', ')}`
91
+ );
92
+ }
93
+ return [...opener, url];
94
+ }
95
+
96
+ /**
97
+ * Open a URL in the user's default browser with no automation.
98
+ *
99
+ * @param {string} url - The page to open (http/https/file/ftp/about/...)
100
+ * @param {Object} [options]
101
+ * @param {string} [options.platform=process.platform] - Injectable for tests
102
+ * @param {function(string, Array<string>, Object=): Promise<any>} [options.runner=runCommand] - Injectable command runner
103
+ * @param {Object<string,string>} [options.env] - Environment for the opener process
104
+ * @returns {Promise<{opened: string, command: string[]}>} The opened URL and the exact command line used
105
+ */
106
+ export async function openInUserBrowser(url, options = {}) {
107
+ const { platform = process.platform, runner = runCommand, env } = options;
108
+ const validated = validateOpenUrl(url);
109
+ const [file, ...args] = buildOpenCommand(validated, platform);
110
+ await runner(file, args, env ? { env } : {});
111
+ return { opened: validated, command: [file, ...args] };
112
+ }