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,9 +1,10 @@
1
1
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { matchesDomains } from './domains.js';
3
4
  import { profileFileIfPresent } from './fs-utils.js';
4
5
 
5
6
  import { openSqliteDatabase } from '../browser-cookie-database.js';
6
- import { mapFirefoxCookieRows } from '../browser-cookies.js';
7
+ import { mapFirefoxCookieRows, readFirefoxRows } from '../browser-cookies.js';
7
8
  import { readDatabaseSnapshot } from './sqlite-snapshot.js';
8
9
  import { encryptChromiumValue } from './chromium-crypto.js';
9
10
  import { firefoxBookmarksToChrome } from './firefox-bookmarks.js';
@@ -24,8 +25,8 @@ import {
24
25
  * them over CDP.
25
26
  * - **bookmarks:** read from `places.sqlite` (`moz_bookmarks` + `moz_places`)
26
27
  * and converted to Chrome's `Bookmarks` JSON.
27
- * - **history:** counted from `places.sqlite` and reported; it is not written,
28
- * because Chrome's `History` schema is incompatible with Firefox's.
28
+ * - **history:** translated from `moz_historyvisits` into Chrome's `History`,
29
+ * preserving individual visit dates and reporting untranslated metadata.
29
30
  * - **passwords:** decrypted from `logins.json` with the NSS key in `key4.db`
30
31
  * and re-encrypted into a Chrome `Login Data`. When a primary password is set
31
32
  * and not supplied, they are reported as `primary-password-set`.
@@ -102,20 +103,11 @@ export async function readFirefoxCookies({ profileDir, domains }) {
102
103
  return readDatabaseSnapshot({
103
104
  sourcePath: cookiePath,
104
105
  read: (db) => {
105
- const rows = db
106
- .prepare(
107
- `SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite
108
- FROM moz_cookies ORDER BY host, name, path`
109
- )
110
- .all();
106
+ const rows = readFirefoxRows(db);
111
107
  // The installed-browser cookie reader owns the row → cookie mapping, so
112
108
  // a migrated cookie and an imported one always have the same shape.
113
109
  return mapFirefoxCookieRows(
114
- rows.filter((row) =>
115
- Array.isArray(domains) && domains.length > 0
116
- ? domains.some((domain) => (row.host ?? '').includes(domain))
117
- : true
118
- )
110
+ rows.filter((row) => matchesDomains(row.host ?? '', domains))
119
111
  );
120
112
  },
121
113
  });
@@ -228,6 +220,7 @@ export async function migrateFirefoxPasswords({
228
220
  targetKey,
229
221
  targetPrefix = platform === 'win32' ? 'v10' : 'v11',
230
222
  primaryPassword = Buffer.alloc(0),
223
+ domains,
231
224
  }) {
232
225
  const loginsPath = await profileFileIfPresent(profileDir, 'logins.json');
233
226
  const key4Path = await profileFileIfPresent(profileDir, 'key4.db');
@@ -271,6 +264,9 @@ export async function migrateFirefoxPasswords({
271
264
  const decrypted = [];
272
265
  const skipped = [];
273
266
  for (const login of logins) {
267
+ if (!matchesDomains(login.hostname ?? '', domains)) {
268
+ continue;
269
+ }
274
270
  try {
275
271
  decrypted.push({
276
272
  origin: login.hostname,
@@ -287,6 +283,33 @@ export async function migrateFirefoxPasswords({
287
283
  }
288
284
  }
289
285
 
286
+ const migrated = await writeChromiumPasswords({
287
+ entries: decrypted,
288
+ targetProfileDir,
289
+ platform,
290
+ targetKey,
291
+ targetPrefix,
292
+ });
293
+
294
+ const warnings = [];
295
+ if (migrated > 0) {
296
+ warnings.push({
297
+ type: 'passwords',
298
+ item: 'Login Data',
299
+ reason: 'reencrypted-for-chrome',
300
+ detail: `${migrated} Firefox logins were decrypted and re-encrypted into a Chrome Login Data; Chrome may re-key the store on first launch.`,
301
+ });
302
+ }
303
+ return { migrated, skipped, warnings };
304
+ }
305
+
306
+ export async function writeChromiumPasswords({
307
+ entries,
308
+ targetProfileDir,
309
+ platform,
310
+ targetKey,
311
+ targetPrefix,
312
+ }) {
290
313
  await mkdir(targetProfileDir, { recursive: true });
291
314
  const targetPath = path.join(targetProfileDir, 'Login Data');
292
315
  const db = await openSqliteDatabase(targetPath);
@@ -301,7 +324,7 @@ export async function migrateFirefoxPasswords({
301
324
  date_last_used, date_password_modified
302
325
  ) VALUES (?, ?, '', ?, '', ?, '', ?, 0, 0, 0, 0, 0, 0, 0)`
303
326
  );
304
- for (const entry of decrypted) {
327
+ for (const entry of entries) {
305
328
  const encrypted = encryptChromiumValue({
306
329
  plaintext: entry.password,
307
330
  key: targetKey,
@@ -321,14 +344,5 @@ export async function migrateFirefoxPasswords({
321
344
  db.close();
322
345
  }
323
346
 
324
- const warnings = [];
325
- if (migrated > 0) {
326
- warnings.push({
327
- type: 'passwords',
328
- item: 'Login Data',
329
- reason: 'reencrypted-for-chrome',
330
- detail: `${migrated} Firefox logins were decrypted and re-encrypted into a Chrome Login Data; Chrome may re-key the store on first launch.`,
331
- });
332
- }
333
- return { migrated, skipped, warnings };
347
+ return migrated;
334
348
  }
@@ -3,6 +3,156 @@ import path from 'node:path';
3
3
  import { pathExists } from './fs-utils.js';
4
4
 
5
5
  import { withDatabaseSnapshot } from './sqlite-snapshot.js';
6
+ import { matchesDomains } from './domains.js';
7
+ import { openSqliteDatabase } from '../browser-cookie-database.js';
8
+
9
+ async function filterHistory(targetPath, domains, warnings) {
10
+ if (!domains?.length) {
11
+ return;
12
+ }
13
+ const db = await openSqliteDatabase(targetPath);
14
+ try {
15
+ const tables = new Set(
16
+ db
17
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
18
+ .all()
19
+ .map((row) => row.name)
20
+ );
21
+ if (tables.has('urls')) {
22
+ const remove = db.prepare('DELETE FROM urls WHERE id = ?');
23
+ for (const row of db.prepare('SELECT id,url FROM urls').all()) {
24
+ if (!matchesDomains(row.url, domains)) {
25
+ remove.run(row.id);
26
+ }
27
+ }
28
+ for (const [table, column] of [
29
+ ['visits', 'url'],
30
+ ['segments', 'url_id'],
31
+ ['keyword_search_terms', 'url_id'],
32
+ ]) {
33
+ if (tables.has(table)) {
34
+ db.exec(
35
+ `DELETE FROM ${table} WHERE ${column} NOT IN (SELECT id FROM urls)`
36
+ );
37
+ }
38
+ }
39
+ if (tables.has('visits')) {
40
+ for (const [table, column] of [
41
+ ['visit_source', 'id'],
42
+ ['content_annotations', 'visit_id'],
43
+ ['context_annotations', 'visit_id'],
44
+ ]) {
45
+ if (tables.has(table)) {
46
+ db.exec(
47
+ `DELETE FROM ${table} WHERE ${column} NOT IN (SELECT id FROM visits)`
48
+ );
49
+ }
50
+ }
51
+ }
52
+ if (tables.has('segments') && tables.has('segment_usage')) {
53
+ db.exec(
54
+ 'DELETE FROM segment_usage WHERE segment_id NOT IN (SELECT id FROM segments)'
55
+ );
56
+ }
57
+ }
58
+ filterDownloads(db, tables, domains);
59
+ if (tables.has('top_sites')) {
60
+ const remove = db.prepare('DELETE FROM top_sites WHERE url = ?');
61
+ for (const row of db.prepare('SELECT url FROM top_sites').all()) {
62
+ if (!matchesDomains(row.url, domains)) {
63
+ remove.run(row.url);
64
+ }
65
+ }
66
+ }
67
+ omitUnfilteredMetadata(db, tables, warnings);
68
+ db.exec('VACUUM');
69
+ } finally {
70
+ db.close();
71
+ }
72
+ }
73
+
74
+ function omitUnfilteredMetadata(db, tables, warnings) {
75
+ const filtered = new Set([
76
+ 'urls',
77
+ 'downloads',
78
+ 'top_sites',
79
+ 'meta',
80
+ 'sqlite_sequence',
81
+ ]);
82
+ if (tables.has('urls')) {
83
+ for (const table of ['visits', 'segments', 'keyword_search_terms']) {
84
+ filtered.add(table);
85
+ }
86
+ if (tables.has('visits')) {
87
+ for (const table of [
88
+ 'visit_source',
89
+ 'content_annotations',
90
+ 'context_annotations',
91
+ ]) {
92
+ filtered.add(table);
93
+ }
94
+ }
95
+ if (tables.has('segments')) {
96
+ filtered.add('segment_usage');
97
+ }
98
+ }
99
+ if (tables.has('downloads')) {
100
+ filtered.add('downloads_url_chains');
101
+ filtered.add('downloads_slices');
102
+ }
103
+ // Cluster labels and keywords can describe excluded visits even when a
104
+ // cluster also contains selected visits. Unknown schemas have no safe join.
105
+ for (const table of [...tables].sort()) {
106
+ if (filtered.has(table)) {
107
+ continue;
108
+ }
109
+ const identifier = `"${table.replaceAll('"', '""')}"`;
110
+ const count = db.prepare(`SELECT COUNT(*) AS c FROM ${identifier}`).get().c;
111
+ db.exec(`DELETE FROM ${identifier}`);
112
+ if (count > 0) {
113
+ warnings.push({
114
+ type: 'history',
115
+ item: table,
116
+ reason: 'unsupported-history-metadata',
117
+ detail: `${count} copied metadata rows removed`,
118
+ });
119
+ }
120
+ }
121
+ }
122
+
123
+ function filterDownloads(db, tables, domains) {
124
+ if (!tables.has('downloads')) {
125
+ return;
126
+ }
127
+ const remove = db.prepare('DELETE FROM downloads WHERE id = ?');
128
+ for (const row of db.prepare('SELECT * FROM downloads').all()) {
129
+ const urls = ['url', 'site_url', 'tab_url', 'referrer', 'tab_referrer_url']
130
+ .map((column) => row[column])
131
+ .filter(Boolean);
132
+ if (tables.has('downloads_url_chains')) {
133
+ urls.push(
134
+ ...db
135
+ .prepare('SELECT url FROM downloads_url_chains WHERE id = ?')
136
+ .all(row.id)
137
+ .map((chain) => chain.url)
138
+ );
139
+ }
140
+ // A redirect outside the selected sites is still unrelated browsing data.
141
+ if (!urls.length || urls.some((url) => !matchesDomains(url, domains))) {
142
+ remove.run(row.id);
143
+ }
144
+ }
145
+ for (const [table, column] of [
146
+ ['downloads_url_chains', 'id'],
147
+ ['downloads_slices', 'download_id'],
148
+ ]) {
149
+ if (tables.has(table)) {
150
+ db.exec(
151
+ `DELETE FROM ${table} WHERE ${column} NOT IN (SELECT id FROM downloads)`
152
+ );
153
+ }
154
+ }
155
+ }
6
156
 
7
157
  /**
8
158
  * History and Top Sites migration.
@@ -20,12 +170,13 @@ import { withDatabaseSnapshot } from './sqlite-snapshot.js';
20
170
  * already guarantees a point-in-time consistent copy.
21
171
  */
22
172
 
23
- function snapshotInto({ sourcePath, targetPath }) {
173
+ function snapshotInto({ sourcePath, targetPath, domains, warnings }) {
24
174
  return withDatabaseSnapshot({
25
175
  sourcePath,
26
176
  read: async (snapshotPath) => {
27
177
  const { copyFile } = await import('node:fs/promises');
28
178
  await copyFile(snapshotPath, targetPath);
179
+ await filterHistory(targetPath, domains, warnings);
29
180
  // Best-effort URL count for the report.
30
181
  try {
31
182
  const { openSqliteDatabase } =
@@ -52,7 +203,11 @@ function snapshotInto({ sourcePath, targetPath }) {
52
203
  * @param {string} options.targetProfileDir
53
204
  * @returns {Promise<{migrated: number, skipped: Array, warnings: Array}>}
54
205
  */
55
- export async function migrateHistory({ sourceProfileDir, targetProfileDir }) {
206
+ export async function migrateHistory({
207
+ sourceProfileDir,
208
+ targetProfileDir,
209
+ domains,
210
+ }) {
56
211
  const skipped = [];
57
212
  const warnings = [];
58
213
  await mkdir(targetProfileDir, { recursive: true });
@@ -64,6 +219,8 @@ export async function migrateHistory({ sourceProfileDir, targetProfileDir }) {
64
219
  const count = await snapshotInto({
65
220
  sourcePath: historySource,
66
221
  targetPath: path.join(targetProfileDir, 'History'),
222
+ domains,
223
+ warnings,
67
224
  });
68
225
  urlCount = count ?? 0;
69
226
  migratedDatabases += 1;
@@ -80,6 +237,8 @@ export async function migrateHistory({ sourceProfileDir, targetProfileDir }) {
80
237
  await snapshotInto({
81
238
  sourcePath: topSitesSource,
82
239
  targetPath: path.join(targetProfileDir, 'Top Sites'),
240
+ domains,
241
+ warnings,
83
242
  });
84
243
  migratedDatabases += 1;
85
244
  }