browser-commander 0.23.0 → 0.25.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 (77) hide show
  1. package/.jscpd-baseline.json +48 -30
  2. package/CHANGELOG.md +32 -0
  3. package/README.md +19 -7
  4. package/eslint.config.js +1 -1
  5. package/examples/react-test-app/package-lock.json +890 -0
  6. package/examples/react-test-app/package.json +4 -4
  7. package/package.json +20 -17
  8. package/src/browser/attach/snapshot.js +4 -28
  9. package/src/browser/browser-cookie-credentials.js +15 -8
  10. package/src/browser/browser-cookies.js +36 -30
  11. package/src/browser/browser-profile-files.js +31 -0
  12. package/src/browser/browser-profiles.js +11 -13
  13. package/src/browser/browser-sources.js +39 -9
  14. package/src/browser/browser-sources.json +600 -23
  15. package/src/browser/connector.js +46 -4
  16. package/src/browser/launcher.js +26 -1
  17. package/src/browser/migration/capabilities.json +117 -0
  18. package/src/browser/migration/chromium-history.sql +22 -0
  19. package/src/browser/migration/chromium-writers.js +85 -0
  20. package/src/browser/migration/cookies.js +4 -1
  21. package/src/browser/migration/data-classes.js +68 -0
  22. package/src/browser/migration/domains.js +32 -0
  23. package/src/browser/migration/firefox-history.js +106 -0
  24. package/src/browser/migration/firefox.js +39 -25
  25. package/src/browser/migration/history.js +161 -2
  26. package/src/browser/migration/index.js +229 -83
  27. package/src/browser/migration/os-crypt-keys.js +1 -5
  28. package/src/browser/migration/password-metadata.js +95 -0
  29. package/src/browser/migration/passwords.js +78 -41
  30. package/src/browser/migration/safari-import.js +100 -0
  31. package/src/browser/migration/safari.js +110 -0
  32. package/src/browser/migration/sqlite-snapshot.js +31 -65
  33. package/src/browser/migration/validation.js +48 -0
  34. package/src/browser/real-browser.js +21 -3
  35. package/src/browser/safari-access.js +45 -0
  36. package/src/browser/safari-cookies.js +39 -36
  37. package/src/browser/safari-profiles.js +83 -0
  38. package/src/browser/storage-state.js +27 -0
  39. package/src/browser/system-browser.js +31 -143
  40. package/src/browser/webdriver.js +18 -2
  41. package/src/cli/args.js +7 -0
  42. package/src/cli/commands.js +12 -6
  43. package/src/cli/handle-methods.js +19 -2
  44. package/src/cli/high-level-methods.js +22 -3
  45. package/src/cli/modules.js +5 -1
  46. package/src/cli/sessions.js +14 -2
  47. package/src/tests/index.js +1 -0
  48. package/tests/e2e/engine-matrix.e2e.test.js +107 -0
  49. package/tests/e2e/playwright.e2e.test.js +11 -17
  50. package/tests/e2e/puppeteer.e2e.test.js +13 -17
  51. package/tests/unit/browser/attach/snapshot.test.js +30 -0
  52. package/tests/unit/browser/browser-cookies.test.js +35 -0
  53. package/tests/unit/browser/catalogue-launch.test.js +93 -0
  54. package/tests/unit/browser/cookie-sources.test.js +92 -17
  55. package/tests/unit/browser/credential-catalogue.test.js +50 -0
  56. package/tests/unit/browser/firefox-cookie-expiry.test.js +79 -0
  57. package/tests/unit/browser/migration/data-classes.test.js +89 -0
  58. package/tests/unit/browser/migration/domain-isolation.test.js +212 -0
  59. package/tests/unit/browser/migration/firefox-history.test.js +130 -0
  60. package/tests/unit/browser/migration/history-metadata.test.js +74 -0
  61. package/tests/unit/browser/migration/index.test.js +16 -1
  62. package/tests/unit/browser/migration/opera-keys.test.js +22 -0
  63. package/tests/unit/browser/migration/safari-data.test.js +143 -0
  64. package/tests/unit/browser/migration/sqlite-snapshot.test.js +56 -1
  65. package/tests/unit/browser/migration/validation.test.js +63 -0
  66. package/tests/unit/browser/migration/yandex.test.js +84 -0
  67. package/tests/unit/browser/real-browser.test.js +19 -0
  68. package/tests/unit/browser/safari-cookies.test.js +26 -1
  69. package/tests/unit/browser/safari-profiles.test.js +111 -0
  70. package/tests/unit/cli/dispatcher.test.js +6 -2
  71. package/tests/unit/cli/main.test.js +5 -0
  72. package/tests/unit/cli/selenium-parity.test.js +186 -0
  73. package/tests/unit/scripts/browser-support.test.js +14 -7
  74. package/tests/unit/scripts/check-pipeline-status.test.js +33 -1
  75. package/tests/unit/scripts/migration-support.test.js +31 -0
  76. package/tests/unit/tests/index.test.js +2 -1
  77. package/tests/unit/traces/links.test.js +2 -2
@@ -1,8 +1,10 @@
1
1
  import { attachDownloads } from '../downloads/attach.js';
2
+ import { connectWebDriver, launchWebDriver } from './webdriver.js';
2
3
  import {
3
4
  loadStorageState,
4
5
  restorePlaywrightStorageState,
5
6
  restorePuppeteerStorageState,
7
+ restoreWebDriverStorageState,
6
8
  } from './storage-state.js';
7
9
 
8
10
  /**
@@ -11,9 +13,9 @@ import {
11
13
  * @param {string} engine
12
14
  */
13
15
  export function assertSupportedEngine(engine) {
14
- if (!['playwright', 'puppeteer'].includes(engine)) {
16
+ if (!['playwright', 'puppeteer', 'selenium'].includes(engine)) {
15
17
  throw new Error(
16
- `Invalid engine: ${engine}. Expected 'playwright' or 'puppeteer'`
18
+ `Invalid engine: ${engine}. Expected 'playwright', 'puppeteer' or 'selenium'`
17
19
  );
18
20
  }
19
21
  }
@@ -28,6 +30,28 @@ function validateConnectionOptions({ engine, cdpEndpoint, wsEndpoint }) {
28
30
  }
29
31
  }
30
32
 
33
+ async function connectSelenium(options, dependencies) {
34
+ if (Boolean(options.serverUrl) === Boolean(options.cdpEndpoint)) {
35
+ throw new Error(
36
+ 'selenium requires exactly one of serverUrl or cdpEndpoint'
37
+ );
38
+ }
39
+ if (options.wsEndpoint) {
40
+ throw new Error('selenium requires an HTTP cdpEndpoint or serverUrl');
41
+ }
42
+ const connected = options.serverUrl
43
+ ? await (dependencies.connectWebDriver ?? connectWebDriver)(options)
44
+ : await (dependencies.launchWebDriver ?? launchWebDriver)({
45
+ ...options,
46
+ debuggerAddress: new URL(options.cdpEndpoint).host,
47
+ });
48
+ return {
49
+ ...connected,
50
+ browser: connected.driver,
51
+ disconnect: connected.close,
52
+ };
53
+ }
54
+
31
55
  function addDefinedOptions(options, values) {
32
56
  for (const [key, value] of Object.entries(values)) {
33
57
  if (value !== undefined) {
@@ -135,10 +159,12 @@ async function connectPuppeteer({ options, loadPuppeteer, storageState }) {
135
159
  }
136
160
 
137
161
  /**
138
- * Attach to an already-running Chromium-family browser over CDP.
162
+ * Attach over CDP or create a native session on a WebDriver server.
139
163
  *
140
164
  * @param {Object} options - Connection options
141
- * @param {'playwright'|'puppeteer'} [options.engine='playwright'] - Automation engine
165
+ * @param {'playwright'|'puppeteer'|'selenium'} [options.engine='playwright'] - Automation engine
166
+ * @param {string} [options.serverUrl] - Selenium Grid or WebDriver server URL
167
+ * @param {Object} [options.capabilities] - Native WebDriver capabilities
142
168
  * @param {string} [options.cdpEndpoint] - HTTP DevTools endpoint, such as http://127.0.0.1:9222
143
169
  * @param {string} [options.wsEndpoint] - DevTools browser WebSocket endpoint
144
170
  * @param {number} [options.slowMo] - Delay engine operations by this many milliseconds
@@ -171,6 +197,22 @@ export async function connectBrowserWithDependencies(
171
197
  verbose: false,
172
198
  ...options,
173
199
  };
200
+ if (normalizedOptions.engine === 'selenium') {
201
+ assertSupportedEngine(normalizedOptions.engine);
202
+ const storageState = await prepareStorageState(normalizedOptions);
203
+ const result = await connectSelenium(normalizedOptions, dependencies);
204
+ try {
205
+ await restoreWebDriverStorageState({ page: result.page, storageState });
206
+ const downloads = await attachDownloads({
207
+ ...normalizedOptions,
208
+ ...result,
209
+ });
210
+ return { ...result, downloads };
211
+ } catch (error) {
212
+ await result.close();
213
+ throw error;
214
+ }
215
+ }
174
216
  validateConnectionOptions(normalizedOptions);
175
217
 
176
218
  const storageState = await prepareStorageState(normalizedOptions);
@@ -16,10 +16,12 @@ import { resolveRestrictions } from './restrictions.js';
16
16
  import { resolveSystemBrowserExecutable } from './system-browser.js';
17
17
  import { applyFingerprint } from '../fingerprint/apply.js';
18
18
  import { attachDownloads } from '../downloads/attach.js';
19
+ import { launchWebDriver } from './webdriver.js';
19
20
  import {
20
21
  loadStorageState,
21
22
  restorePlaywrightStorageState,
22
23
  restorePuppeteerStorageState,
24
+ restoreWebDriverStorageState,
23
25
  } from './storage-state.js';
24
26
 
25
27
  /** How `launchBrowser` starts the browser (issue #103). */
@@ -131,6 +133,13 @@ async function launchReal(options, dependencies) {
131
133
  }
132
134
 
133
135
  async function launchWithEngine(options, dependencies) {
136
+ if (options.engine === 'selenium') {
137
+ const launched = await (dependencies.launchWebDriver ?? launchWebDriver)({
138
+ ...options,
139
+ args: [...options.args, ...options.extraArgs],
140
+ });
141
+ return { ...launched, browser: launched.driver };
142
+ }
134
143
  const {
135
144
  engine,
136
145
  userDataDir: requestedUserDataDir,
@@ -261,7 +270,7 @@ async function unfocusAddressBar(page, verbose, settleMs = 500) {
261
270
  * limitations.json (`engine-launch-switches`).
262
271
  *
263
272
  * @param {Object} options - Configuration options
264
- * @param {string} [options.engine='playwright'] - Browser automation engine: 'playwright' or 'puppeteer'
273
+ * @param {string} [options.engine='playwright'] - Browser automation engine: 'playwright', 'puppeteer' or 'selenium'
265
274
  * @param {'real'|'engine'} [options.launch='real'] - Who starts the browser: Browser Commander (a hand-started command line) or the automation engine
266
275
  * @param {string} [options.userDataDir] - Persistent profile directory. When omitted a fresh temporary profile is created and deleted on close.
267
276
  * @param {boolean} [options.headless=false] - Run in headless mode
@@ -309,6 +318,16 @@ export async function launchBrowserWithDependencies(
309
318
  ...rest
310
319
  } = options;
311
320
  validateLaunchMode({ engine, launch, attach: rest.attach });
321
+ if (engine === 'selenium') {
322
+ if (colorScheme !== undefined || fingerprint !== undefined || downloads) {
323
+ throw new Error(
324
+ 'selenium does not support CDP emulation, fingerprint overrides or managed downloads (webdriver-no-cdp-emulation)'
325
+ );
326
+ }
327
+ if (launch === 'real' && rest.browser === 'firefox') {
328
+ throw new Error("selenium Firefox requires launch: 'engine'");
329
+ }
330
+ }
312
331
  // Validate arguments before anything is started or written to disk.
313
332
  resolveChromeArgs({ args, extraArgs, ignoreDefaultArgs, restrictions });
314
333
  const resolvedStorageState = await loadStorageState(storageState);
@@ -341,6 +360,12 @@ export async function launchBrowserWithDependencies(
341
360
  }
342
361
 
343
362
  try {
363
+ if (engine === 'selenium' && launch === 'engine') {
364
+ await restoreWebDriverStorageState({
365
+ page,
366
+ storageState: resolvedStorageState,
367
+ });
368
+ }
344
369
  // A Playwright-launched context applies colorScheme itself; an attached
345
370
  // browser and Puppeteer need page-level emulation.
346
371
  const emulateColorScheme =
@@ -0,0 +1,117 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "classes": [
4
+ "cookies",
5
+ "bookmarks",
6
+ "history",
7
+ "passwords",
8
+ "preferences",
9
+ "extensions",
10
+ "localStorage",
11
+ "indexedDB",
12
+ "sessionStorage",
13
+ "autofill",
14
+ "paymentCards",
15
+ "searchEngines",
16
+ "siteSettings",
17
+ "openTabs",
18
+ "downloads",
19
+ "readingList",
20
+ "clientCertificates",
21
+ "passkeys"
22
+ ],
23
+ "sourceFamilies": ["chromium", "firefox", "safari"],
24
+ "targetFamilies": ["chromium", "firefox", "webkit"],
25
+ "imports": {
26
+ "chromium": {
27
+ "chromium": [
28
+ "seed",
29
+ "copy",
30
+ "snapshot",
31
+ "rekey",
32
+ "copy",
33
+ "copy",
34
+ "unsupported",
35
+ "unsupported",
36
+ "unsupported",
37
+ "unsupported",
38
+ "consent/unsupported",
39
+ "unsupported",
40
+ "unsupported",
41
+ "unsupported",
42
+ "unsupported",
43
+ "unsupported",
44
+ "unsupported",
45
+ "non-exportable"
46
+ ]
47
+ },
48
+ "firefox": {
49
+ "chromium": [
50
+ "seed",
51
+ "translate",
52
+ "translate",
53
+ "NSS/rekey",
54
+ "unsupported",
55
+ "unsupported",
56
+ "unsupported",
57
+ "unsupported",
58
+ "unsupported",
59
+ "unsupported",
60
+ "consent/unsupported",
61
+ "unsupported",
62
+ "unsupported",
63
+ "unsupported",
64
+ "unsupported",
65
+ "unsupported",
66
+ "unsupported",
67
+ "non-exportable"
68
+ ]
69
+ },
70
+ "safari": {
71
+ "chromium": [
72
+ "seed",
73
+ "translate",
74
+ "translate",
75
+ "CSV/rekey",
76
+ "unsupported",
77
+ "unsupported",
78
+ "unsupported",
79
+ "unsupported",
80
+ "unsupported",
81
+ "unsupported",
82
+ "consent/unsupported",
83
+ "unsupported",
84
+ "unsupported",
85
+ "unsupported",
86
+ "unsupported",
87
+ "unsupported",
88
+ "unsupported",
89
+ "non-exportable"
90
+ ]
91
+ }
92
+ },
93
+ "wholeProfileMigration": {
94
+ "chromium": "not implemented",
95
+ "firefox": "not implemented",
96
+ "webkit": "not implemented"
97
+ },
98
+ "pendingClasses": [
99
+ "localStorage",
100
+ "indexedDB",
101
+ "sessionStorage",
102
+ "autofill",
103
+ "paymentCards",
104
+ "searchEngines",
105
+ "siteSettings",
106
+ "openTabs",
107
+ "downloads",
108
+ "readingList",
109
+ "clientCertificates"
110
+ ],
111
+ "installedBrowserLaunch": {
112
+ "chromium": "CDP",
113
+ "firefox": "explicit WebDriver setup",
114
+ "safari": "explicit safaridriver setup",
115
+ "detection": "unsupported"
116
+ }
117
+ }
@@ -0,0 +1,22 @@
1
+ -- Core tables match Chromium History schema 70 (compatible version 16).
2
+ -- Chromium initializes remaining ancillary tables when it opens the profile.
3
+ CREATE TABLE meta (key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY, value LONGVARCHAR);
4
+ INSERT INTO meta VALUES ('version', '70'), ('last_compatible_version', '16');
5
+ CREATE TABLE urls (
6
+ id INTEGER PRIMARY KEY AUTOINCREMENT, url LONGVARCHAR, title LONGVARCHAR,
7
+ visit_count INTEGER DEFAULT 0 NOT NULL, typed_count INTEGER DEFAULT 0 NOT NULL,
8
+ last_visit_time INTEGER DEFAULT 0 NOT NULL, hidden INTEGER DEFAULT 0 NOT NULL
9
+ );
10
+ CREATE TABLE visits (
11
+ id INTEGER PRIMARY KEY AUTOINCREMENT, url INTEGER NOT NULL,
12
+ visit_time INTEGER NOT NULL, from_visit INTEGER DEFAULT 0,
13
+ external_referrer_url TEXT, transition INTEGER DEFAULT 0 NOT NULL,
14
+ segment_id INTEGER DEFAULT 0, visit_duration INTEGER DEFAULT 0 NOT NULL,
15
+ incremented_omnibox_typed_score BOOLEAN DEFAULT FALSE NOT NULL,
16
+ opener_visit INTEGER DEFAULT 0, originator_cache_guid TEXT DEFAULT '',
17
+ originator_visit_id INTEGER DEFAULT 0, originator_from_visit INTEGER DEFAULT 0,
18
+ originator_opener_visit INTEGER DEFAULT 0, is_known_to_sync BOOLEAN DEFAULT FALSE NOT NULL,
19
+ consider_for_ntp_most_visited BOOLEAN DEFAULT FALSE NOT NULL,
20
+ visited_link_id INTEGER DEFAULT 0 NOT NULL, app_id TEXT
21
+ );
22
+ CREATE TABLE visit_source (id INTEGER PRIMARY KEY, source INTEGER NOT NULL);
@@ -0,0 +1,85 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { openSqliteDatabase } from '../browser-cookie-database.js';
4
+
5
+ export async function writeChromiumBookmarks(targetProfileDir, entries) {
6
+ let id = 3;
7
+ let count = 0;
8
+ function convert(node) {
9
+ const result = {
10
+ id: String(++id),
11
+ date_added: '13300000000000000',
12
+ name: node.name,
13
+ type: node.type,
14
+ };
15
+ if (node.type === 'url') {
16
+ count += 1;
17
+ result.url = node.url;
18
+ } else {
19
+ result.date_modified = result.date_added;
20
+ result.children = (node.children ?? []).map(convert);
21
+ }
22
+ return result;
23
+ }
24
+ const roots = Object.fromEntries(
25
+ ['bookmark_bar', 'other', 'synced'].map((name, index) => [
26
+ name,
27
+ {
28
+ id: String(index + 1),
29
+ name,
30
+ type: 'folder',
31
+ date_added: '13300000000000000',
32
+ date_modified: '13300000000000000',
33
+ children: name === 'other' ? entries.map(convert) : [],
34
+ },
35
+ ])
36
+ );
37
+ await mkdir(targetProfileDir, { recursive: true });
38
+ await writeFile(
39
+ path.join(targetProfileDir, 'Bookmarks'),
40
+ JSON.stringify({ version: 1, roots })
41
+ );
42
+ return count;
43
+ }
44
+
45
+ export async function writeChromiumHistory(targetProfileDir, entries) {
46
+ await mkdir(targetProfileDir, { recursive: true });
47
+ const db = await openSqliteDatabase(path.join(targetProfileDir, 'History'));
48
+ try {
49
+ db.exec(
50
+ await readFile(new URL('./chromium-history.sql', import.meta.url), 'utf8')
51
+ );
52
+ const urls = new Map();
53
+ const insertUrl = db.prepare(
54
+ 'INSERT INTO urls (url,title,visit_count,last_visit_time) VALUES (?,?,0,0)'
55
+ );
56
+ const updateUrl = db.prepare(
57
+ 'UPDATE urls SET title=?, visit_count=visit_count+1,last_visit_time=max(last_visit_time,?) WHERE id=?'
58
+ );
59
+ const insertVisit = db.prepare(
60
+ 'INSERT INTO visits (url,visit_time) VALUES (?,?)'
61
+ );
62
+ db.exec('BEGIN');
63
+ try {
64
+ for (const entry of entries) {
65
+ if (!urls.has(entry.url)) {
66
+ urls.set(
67
+ entry.url,
68
+ insertUrl.run(entry.url, entry.title).lastInsertRowid
69
+ );
70
+ }
71
+ const id = urls.get(entry.url);
72
+ const time = BigInt(entry.time) + 11644473600000000n;
73
+ updateUrl.run(entry.title, time, id);
74
+ insertVisit.run(id, time);
75
+ }
76
+ db.exec('COMMIT');
77
+ } catch (error) {
78
+ db.exec('ROLLBACK');
79
+ throw error;
80
+ }
81
+ } finally {
82
+ db.close();
83
+ }
84
+ return entries.length;
85
+ }
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
 
3
3
  import { pathExists } from './fs-utils.js';
4
+ import { matchesDomains } from './domains.js';
4
5
 
5
6
  import { readBrowserCookies } from '../browser-cookies.js';
6
7
 
@@ -130,7 +131,9 @@ export async function migrateCookies({
130
131
  );
131
132
  }
132
133
  }
133
- const cookies = [...seen.values()];
134
+ const cookies = [...seen.values()].filter((cookie) =>
135
+ matchesDomains(cookie.domain, domains)
136
+ );
134
137
 
135
138
  const skipped = [];
136
139
  for (const cookie of cookies) {
@@ -0,0 +1,68 @@
1
+ /** Explicit reports for selected classes without a native target writer. */
2
+ export const ADDITIONAL_DATA_CLASSES = Object.freeze([
3
+ 'localStorage',
4
+ 'indexedDB',
5
+ 'sessionStorage',
6
+ 'autofill',
7
+ 'paymentCards',
8
+ 'searchEngines',
9
+ 'siteSettings',
10
+ 'openTabs',
11
+ 'downloads',
12
+ 'readingList',
13
+ 'clientCertificates',
14
+ 'passkeys',
15
+ ]);
16
+
17
+ export function reportAdditionalClasses({
18
+ selected,
19
+ profileDir,
20
+ includePaymentCards,
21
+ }) {
22
+ return ADDITIONAL_DATA_CLASSES.filter((type) => selected.has(type)).flatMap(
23
+ (type) => reportAdditionalClass({ type, profileDir, includePaymentCards })
24
+ );
25
+ }
26
+
27
+ export function reportAdditionalClass({
28
+ type,
29
+ profileDir,
30
+ includePaymentCards,
31
+ }) {
32
+ const entry = (item, reason, detail) => ({ type, item, reason, detail });
33
+ if (type === 'passkeys') {
34
+ return ['iCloud Keychain', 'Google Password Manager', 'Windows Hello'].map(
35
+ (provider) =>
36
+ entry(
37
+ provider,
38
+ 'passkey-not-exportable',
39
+ `${provider} private keys cannot be exported by copying browser profile files. Provider-authorized transfer may be available separately. Sign in once with the platform passkey in a dedicated persistent profile and retain its session.`
40
+ )
41
+ );
42
+ }
43
+ if (type === 'paymentCards' && !includePaymentCards) {
44
+ return [
45
+ entry(
46
+ profileDir,
47
+ 'payment-card-consent-required',
48
+ 'Payment cards require includePaymentCards=true (Python/Rust: include_payment_cards; CLI: --include-payment-cards). Selecting the class alone does not grant consent.'
49
+ ),
50
+ ];
51
+ }
52
+ if (type === 'clientCertificates') {
53
+ return [
54
+ entry(
55
+ profileDir,
56
+ 'client-certificates-os-export-required',
57
+ 'Client certificate private keys require an authorized export from the OS or NSS store. Hardware-backed or non-exportable keys cannot be copied from browser profile files.'
58
+ ),
59
+ ];
60
+ }
61
+ return [
62
+ entry(
63
+ profileDir,
64
+ 'data-class-not-supported',
65
+ `A native source reader and compatible target writer for ${type} are not implemented; no data was copied.`
66
+ ),
67
+ ];
68
+ }
@@ -0,0 +1,32 @@
1
+ /** Site filters match a host or its subdomains, never an unrelated suffix. */
2
+ export function matchesDomains(value, domains) {
3
+ if (!domains?.length) {
4
+ return true;
5
+ }
6
+ let host;
7
+ try {
8
+ host = value.includes('://') ? new URL(value).hostname : value;
9
+ } catch {
10
+ return false;
11
+ }
12
+ host = host.replace(/^\./, '').toLowerCase().replace(/\.$/, '');
13
+ return domains.some((domain) => {
14
+ const filter = domain.replace(/^\./, '').toLowerCase().replace(/\.$/, '');
15
+ return host === filter || host.endsWith(`.${filter}`);
16
+ });
17
+ }
18
+
19
+ export function validateDomains(domains) {
20
+ if (domains === undefined) {
21
+ return;
22
+ }
23
+ if (
24
+ !Array.isArray(domains) ||
25
+ domains.some(
26
+ (domain) =>
27
+ typeof domain !== 'string' || !domain || /[\s/:]/u.test(domain)
28
+ )
29
+ ) {
30
+ throw new TypeError('domains must be an array of host names');
31
+ }
32
+ }
@@ -0,0 +1,106 @@
1
+ import { matchesDomains } from './domains.js';
2
+ import { profileFileIfPresent } from './fs-utils.js';
3
+ import { readDatabaseSnapshot } from './sqlite-snapshot.js';
4
+ import { writeChromiumHistory } from './chromium-writers.js';
5
+ import { preserveIntegerPrecision } from '../browser-cookie-database.js';
6
+
7
+ /** Translate individual Firefox visits using a consistent read-only snapshot. */
8
+ export async function migrateFirefoxHistory({
9
+ profileDir,
10
+ targetProfileDir,
11
+ domains,
12
+ }) {
13
+ const sourcePath = await profileFileIfPresent(profileDir, 'places.sqlite');
14
+ const entry = (reason, detail, item = 'places.sqlite') => ({
15
+ type: 'history',
16
+ item,
17
+ reason,
18
+ detail,
19
+ });
20
+ if (!sourcePath) {
21
+ return { migrated: 0, skipped: [entry('source-missing')], warnings: [] };
22
+ }
23
+ const rows = await readDatabaseSnapshot({
24
+ sourcePath,
25
+ read: (db) => {
26
+ for (const [table, required] of [
27
+ ['moz_places', ['id', 'url', 'title']],
28
+ ['moz_historyvisits', ['id', 'place_id', 'visit_date']],
29
+ ]) {
30
+ const columns = new Set(
31
+ db
32
+ .prepare(`PRAGMA table_info(${table})`)
33
+ .all()
34
+ .map(({ name }) => name)
35
+ );
36
+ if (!required.every((name) => columns.has(name))) {
37
+ return null;
38
+ }
39
+ }
40
+ return preserveIntegerPrecision(
41
+ db.prepare(
42
+ 'SELECT p.url,p.title,v.id,v.visit_date AS time FROM moz_historyvisits v JOIN moz_places p ON v.place_id=p.id ORDER BY v.visit_date,v.id'
43
+ )
44
+ ).all();
45
+ },
46
+ });
47
+ if (rows === null) {
48
+ return {
49
+ migrated: 0,
50
+ skipped: [
51
+ entry(
52
+ 'source-format-unsupported',
53
+ 'Firefox history requires moz_places and moz_historyvisits with URL, title and integer visit dates; no target was written.'
54
+ ),
55
+ ],
56
+ warnings: [],
57
+ };
58
+ }
59
+ const skipped = [];
60
+ const visits = [];
61
+ for (const row of rows) {
62
+ if (typeof row.url !== 'string' || !row.url) {
63
+ skipped.push(
64
+ entry(
65
+ 'invalid-history-url',
66
+ 'The visit URL is not a nonempty string.',
67
+ `places.sqlite visit ${row.id}`
68
+ )
69
+ );
70
+ continue;
71
+ }
72
+ if (!matchesDomains(row.url, domains)) {
73
+ continue;
74
+ }
75
+ if (
76
+ typeof row.time !== 'bigint' ||
77
+ row.time < -11644473600000000n ||
78
+ row.time > 9211727563254775807n
79
+ ) {
80
+ skipped.push(
81
+ entry(
82
+ 'invalid-history-timestamp',
83
+ 'The visit date is not an integer representable in the target history format.',
84
+ `places.sqlite visit ${row.id}`
85
+ )
86
+ );
87
+ continue;
88
+ }
89
+ visits.push({ url: row.url, title: row.title ?? '', time: row.time });
90
+ }
91
+ const migrated = visits.length
92
+ ? await writeChromiumHistory(targetProfileDir, visits)
93
+ : 0;
94
+ return {
95
+ migrated,
96
+ skipped,
97
+ warnings: migrated
98
+ ? [
99
+ entry(
100
+ 'firefox-history-metadata-not-translated',
101
+ 'URL/title and visit dates were translated; Firefox transition types, referring visits and sync metadata were not copied.'
102
+ ),
103
+ ]
104
+ : [],
105
+ };
106
+ }