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.
- package/.jscpd-baseline.json +48 -30
- package/CHANGELOG.md +32 -0
- package/README.md +19 -7
- package/eslint.config.js +1 -1
- package/examples/react-test-app/package-lock.json +890 -0
- package/examples/react-test-app/package.json +4 -4
- package/package.json +20 -17
- package/src/browser/attach/snapshot.js +4 -28
- package/src/browser/browser-cookie-credentials.js +15 -8
- package/src/browser/browser-cookies.js +36 -30
- package/src/browser/browser-profile-files.js +31 -0
- package/src/browser/browser-profiles.js +11 -13
- package/src/browser/browser-sources.js +39 -9
- package/src/browser/browser-sources.json +600 -23
- package/src/browser/connector.js +46 -4
- package/src/browser/launcher.js +26 -1
- package/src/browser/migration/capabilities.json +117 -0
- package/src/browser/migration/chromium-history.sql +22 -0
- package/src/browser/migration/chromium-writers.js +85 -0
- package/src/browser/migration/cookies.js +4 -1
- package/src/browser/migration/data-classes.js +68 -0
- package/src/browser/migration/domains.js +32 -0
- package/src/browser/migration/firefox-history.js +106 -0
- package/src/browser/migration/firefox.js +39 -25
- package/src/browser/migration/history.js +161 -2
- package/src/browser/migration/index.js +229 -83
- package/src/browser/migration/os-crypt-keys.js +1 -5
- package/src/browser/migration/password-metadata.js +95 -0
- package/src/browser/migration/passwords.js +78 -41
- package/src/browser/migration/safari-import.js +100 -0
- package/src/browser/migration/safari.js +110 -0
- package/src/browser/migration/sqlite-snapshot.js +31 -65
- package/src/browser/migration/validation.js +48 -0
- package/src/browser/real-browser.js +21 -3
- package/src/browser/safari-access.js +45 -0
- package/src/browser/safari-cookies.js +39 -36
- package/src/browser/safari-profiles.js +83 -0
- package/src/browser/storage-state.js +27 -0
- package/src/browser/system-browser.js +31 -143
- package/src/browser/webdriver.js +18 -2
- package/src/cli/args.js +7 -0
- package/src/cli/commands.js +12 -6
- package/src/cli/handle-methods.js +19 -2
- package/src/cli/high-level-methods.js +22 -3
- package/src/cli/modules.js +5 -1
- package/src/cli/sessions.js +14 -2
- package/src/tests/index.js +1 -0
- package/tests/e2e/engine-matrix.e2e.test.js +107 -0
- package/tests/e2e/playwright.e2e.test.js +11 -17
- package/tests/e2e/puppeteer.e2e.test.js +13 -17
- package/tests/unit/browser/attach/snapshot.test.js +30 -0
- package/tests/unit/browser/browser-cookies.test.js +35 -0
- package/tests/unit/browser/catalogue-launch.test.js +93 -0
- package/tests/unit/browser/cookie-sources.test.js +92 -17
- package/tests/unit/browser/credential-catalogue.test.js +50 -0
- package/tests/unit/browser/firefox-cookie-expiry.test.js +79 -0
- package/tests/unit/browser/migration/data-classes.test.js +89 -0
- package/tests/unit/browser/migration/domain-isolation.test.js +212 -0
- package/tests/unit/browser/migration/firefox-history.test.js +130 -0
- package/tests/unit/browser/migration/history-metadata.test.js +74 -0
- package/tests/unit/browser/migration/index.test.js +16 -1
- package/tests/unit/browser/migration/opera-keys.test.js +22 -0
- package/tests/unit/browser/migration/safari-data.test.js +143 -0
- package/tests/unit/browser/migration/sqlite-snapshot.test.js +56 -1
- package/tests/unit/browser/migration/validation.test.js +63 -0
- package/tests/unit/browser/migration/yandex.test.js +84 -0
- package/tests/unit/browser/real-browser.test.js +19 -0
- package/tests/unit/browser/safari-cookies.test.js +26 -1
- package/tests/unit/browser/safari-profiles.test.js +111 -0
- package/tests/unit/cli/dispatcher.test.js +6 -2
- package/tests/unit/cli/main.test.js +5 -0
- package/tests/unit/cli/selenium-parity.test.js +186 -0
- package/tests/unit/scripts/browser-support.test.js +14 -7
- package/tests/unit/scripts/check-pipeline-status.test.js +33 -1
- package/tests/unit/scripts/migration-support.test.js +31 -0
- package/tests/unit/tests/index.test.js +2 -1
- 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:**
|
|
28
|
-
*
|
|
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
|
|
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
|
-
|
|
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({
|
|
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
|
}
|