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
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { it } from 'node:test';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
ALL_DATA_CLASSES,
|
|
8
|
+
migrateProfile,
|
|
9
|
+
} from '../../../../src/browser/migration/index.js';
|
|
10
|
+
import { repoPath } from '../../../helpers/repo.js';
|
|
11
|
+
import { useTempDirectories } from '../../../helpers/temp-directory.js';
|
|
12
|
+
|
|
13
|
+
const makeTempDir = useTempDirectories('bc-data-classes-');
|
|
14
|
+
const classes = JSON.parse(
|
|
15
|
+
await readFile(repoPath('tests/fixtures/migration-data-classes.json'), 'utf8')
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
it('requires an actual boolean for payment-card consent before target writes', async () => {
|
|
19
|
+
const target = path.join(await makeTempDir(), 'new-profile');
|
|
20
|
+
await assert.rejects(
|
|
21
|
+
migrateProfile({
|
|
22
|
+
from: { browser: 'chrome', userDataDir: await makeTempDir() },
|
|
23
|
+
to: target,
|
|
24
|
+
include: ['paymentCards'],
|
|
25
|
+
includePaymentCards: 'true',
|
|
26
|
+
}),
|
|
27
|
+
/must be a boolean/u
|
|
28
|
+
);
|
|
29
|
+
await assert.rejects(readFile(path.join(target, 'Web Data')), {
|
|
30
|
+
code: 'ENOENT',
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('reports consented payment cards as unsupported until a native writer exists', async () => {
|
|
35
|
+
const report = await migrateProfile({
|
|
36
|
+
from: { browser: 'chrome', userDataDir: await makeTempDir() },
|
|
37
|
+
to: path.join(await makeTempDir(), 'new-profile'),
|
|
38
|
+
include: ['paymentCards'],
|
|
39
|
+
includePaymentCards: true,
|
|
40
|
+
});
|
|
41
|
+
assert.equal(report.migrated.paymentCards, 0);
|
|
42
|
+
assert.equal(report.skipped[0].reason, 'data-class-not-supported');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
for (const browser of ['chrome', 'firefox', 'safari']) {
|
|
46
|
+
it(`reports every selected additional data class for ${browser} without reading protected card data`, async () => {
|
|
47
|
+
const source = await makeTempDir();
|
|
48
|
+
const target = path.join(await makeTempDir(), 'new-profile');
|
|
49
|
+
const profile =
|
|
50
|
+
browser === 'chrome' ? path.join(source, 'Default') : source;
|
|
51
|
+
await mkdir(profile, { recursive: true });
|
|
52
|
+
const marker = Buffer.from(
|
|
53
|
+
'protected card store must remain unread and unchanged'
|
|
54
|
+
);
|
|
55
|
+
await writeFile(path.join(profile, 'Web Data'), marker);
|
|
56
|
+
const report = await migrateProfile({
|
|
57
|
+
from: { browser, userDataDir: source },
|
|
58
|
+
to: target,
|
|
59
|
+
include: classes.slice(6),
|
|
60
|
+
platform: 'darwin',
|
|
61
|
+
});
|
|
62
|
+
assert.deepEqual(ALL_DATA_CLASSES, classes);
|
|
63
|
+
assert.deepEqual(Object.keys(report.migrated), classes);
|
|
64
|
+
assert.ok(Object.values(report.migrated).every((count) => count === 0));
|
|
65
|
+
for (const type of classes.slice(6)) {
|
|
66
|
+
assert.ok(
|
|
67
|
+
report.skipped.some((entry) => entry.type === type),
|
|
68
|
+
type
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
assert.equal(
|
|
72
|
+
report.skipped.find(({ type }) => type === 'paymentCards').reason,
|
|
73
|
+
'payment-card-consent-required'
|
|
74
|
+
);
|
|
75
|
+
const passkeys = report.skipped.filter(({ type }) => type === 'passkeys');
|
|
76
|
+
assert.deepEqual(
|
|
77
|
+
passkeys.map(({ item }) => item),
|
|
78
|
+
['iCloud Keychain', 'Google Password Manager', 'Windows Hello']
|
|
79
|
+
);
|
|
80
|
+
for (const entry of passkeys) {
|
|
81
|
+
assert.equal(entry.reason, 'passkey-not-exportable');
|
|
82
|
+
assert.match(entry.detail, /persistent profile/u);
|
|
83
|
+
}
|
|
84
|
+
await assert.rejects(readFile(path.join(target, 'Web Data')), {
|
|
85
|
+
code: 'ENOENT',
|
|
86
|
+
});
|
|
87
|
+
assert.deepEqual(await readFile(path.join(profile, 'Web Data')), marker);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import Database from 'better-sqlite3';
|
|
7
|
+
import { useTempDirectories } from '../../../helpers/temp-directory.js';
|
|
8
|
+
import { migrateHistory } from '../../../../src/browser/migration/history.js';
|
|
9
|
+
import { migratePasswords } from '../../../../src/browser/migration/passwords.js';
|
|
10
|
+
import { encryptChromiumValue } from '../../../../src/browser/migration/chromium-crypto.js';
|
|
11
|
+
import {
|
|
12
|
+
decryptChromiumCookie,
|
|
13
|
+
deriveChromiumCookieKey,
|
|
14
|
+
} from '../../../../src/browser/browser-cookie-crypto.js';
|
|
15
|
+
|
|
16
|
+
const temp = useTempDirectories('bc-domain-isolation-');
|
|
17
|
+
describe('per-site domain isolation (#119)', () => {
|
|
18
|
+
for (const domains of [['github.com'], []]) {
|
|
19
|
+
it(`re-encrypts login notes and ${domains.length ? 'filters' : 'preserves'} domain metadata`, async () => {
|
|
20
|
+
const source = await temp();
|
|
21
|
+
const target = await temp();
|
|
22
|
+
const sourceKey = randomBytes(16);
|
|
23
|
+
const targetKey = randomBytes(16);
|
|
24
|
+
const sourcePath = path.join(source, 'Login Data');
|
|
25
|
+
const db = new Database(sourcePath);
|
|
26
|
+
db.exec(
|
|
27
|
+
await readFile(
|
|
28
|
+
new URL(
|
|
29
|
+
'../../../../../tests/fixtures/password-domain-isolation.sql',
|
|
30
|
+
import.meta.url
|
|
31
|
+
),
|
|
32
|
+
'utf8'
|
|
33
|
+
)
|
|
34
|
+
);
|
|
35
|
+
const encrypted = (plaintext) =>
|
|
36
|
+
encryptChromiumValue({
|
|
37
|
+
plaintext,
|
|
38
|
+
key: sourceKey,
|
|
39
|
+
platform: 'linux',
|
|
40
|
+
prefix: 'v11',
|
|
41
|
+
});
|
|
42
|
+
db.prepare('UPDATE logins SET password_value=? WHERE id IN (7,8)').run(
|
|
43
|
+
encrypted('password')
|
|
44
|
+
);
|
|
45
|
+
const sourceNote = encrypted('private note ☃');
|
|
46
|
+
db.prepare(
|
|
47
|
+
'UPDATE password_notes SET value=? WHERE id IN (70,71,72)'
|
|
48
|
+
).run(sourceNote);
|
|
49
|
+
db.close();
|
|
50
|
+
const original = await readFile(sourcePath);
|
|
51
|
+
const report = await migratePasswords({
|
|
52
|
+
sourceProfileDir: source,
|
|
53
|
+
targetProfileDir: target,
|
|
54
|
+
domains,
|
|
55
|
+
platform: 'linux',
|
|
56
|
+
resolveSourceKey: () => sourceKey,
|
|
57
|
+
targetKey,
|
|
58
|
+
});
|
|
59
|
+
const filtered = domains.length > 0;
|
|
60
|
+
assert.equal(report.migrated, filtered ? 1 : 2);
|
|
61
|
+
const copy = new Database(path.join(target, 'Login Data'));
|
|
62
|
+
try {
|
|
63
|
+
assert.deepEqual(
|
|
64
|
+
copy.prepare('SELECT parent_id FROM insecure_credentials').all(),
|
|
65
|
+
filtered ? [{ parent_id: 7 }] : [{ parent_id: 7 }, { parent_id: 8 }]
|
|
66
|
+
);
|
|
67
|
+
assert.deepEqual(
|
|
68
|
+
copy.prepare('SELECT origin_domain FROM stats').all(),
|
|
69
|
+
filtered
|
|
70
|
+
? [{ origin_domain: 'https://github.com' }]
|
|
71
|
+
: [
|
|
72
|
+
{ origin_domain: 'https://github.com' },
|
|
73
|
+
{ origin_domain: 'https://notgithub.com' },
|
|
74
|
+
]
|
|
75
|
+
);
|
|
76
|
+
const notes = copy
|
|
77
|
+
.prepare(
|
|
78
|
+
'SELECT id,value,date_created,confidential FROM password_notes'
|
|
79
|
+
)
|
|
80
|
+
.all();
|
|
81
|
+
assert.equal(notes.length, filtered ? 1 : 2);
|
|
82
|
+
assert.equal(notes[0].id, 70);
|
|
83
|
+
assert.equal(notes[0].date_created, 123);
|
|
84
|
+
assert.equal(notes[0].confidential, 1);
|
|
85
|
+
for (const note of notes) {
|
|
86
|
+
assert.equal(
|
|
87
|
+
decryptChromiumCookie({
|
|
88
|
+
encryptedValue: note.value,
|
|
89
|
+
databaseVersion: 0,
|
|
90
|
+
platform: 'linux',
|
|
91
|
+
key: targetKey,
|
|
92
|
+
}),
|
|
93
|
+
'private note ☃'
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
for (const table of [
|
|
97
|
+
'sync_entities_metadata',
|
|
98
|
+
'sync_model_metadata',
|
|
99
|
+
'future_password_metadata',
|
|
100
|
+
]) {
|
|
101
|
+
const preserved = table === 'future_password_metadata' && !filtered;
|
|
102
|
+
assert.equal(
|
|
103
|
+
copy.prepare(`SELECT count(*) AS n FROM ${table}`).get().n,
|
|
104
|
+
preserved ? 1 : 0,
|
|
105
|
+
table
|
|
106
|
+
);
|
|
107
|
+
assert.equal(
|
|
108
|
+
report.warnings.some((warning) => warning.item === table),
|
|
109
|
+
!preserved
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
assert.ok(
|
|
113
|
+
report.skipped.some(
|
|
114
|
+
(entry) =>
|
|
115
|
+
entry.item === 'password_notes/73' &&
|
|
116
|
+
entry.reason === 'app-bound-v20'
|
|
117
|
+
)
|
|
118
|
+
);
|
|
119
|
+
} finally {
|
|
120
|
+
copy.close();
|
|
121
|
+
}
|
|
122
|
+
const bytes = await readFile(path.join(target, 'Login Data'));
|
|
123
|
+
assert.equal(bytes.includes(Buffer.from('unrelated-sync-marker')), false);
|
|
124
|
+
assert.equal(
|
|
125
|
+
bytes.includes(Buffer.from('unrelated-future-marker')),
|
|
126
|
+
!filtered
|
|
127
|
+
);
|
|
128
|
+
assert.equal(bytes.includes(sourceNote), false);
|
|
129
|
+
assert.deepEqual(await readFile(sourcePath), original);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
it('excludes unrelated history URLs, their visits, and Top Sites', async () => {
|
|
134
|
+
const source = await temp();
|
|
135
|
+
const target = await temp();
|
|
136
|
+
const db = new Database(path.join(source, 'History'));
|
|
137
|
+
db.exec(
|
|
138
|
+
await readFile(
|
|
139
|
+
new URL(
|
|
140
|
+
'../../../../../tests/fixtures/history-domain-isolation.sql',
|
|
141
|
+
import.meta.url
|
|
142
|
+
),
|
|
143
|
+
'utf8'
|
|
144
|
+
)
|
|
145
|
+
);
|
|
146
|
+
db.close();
|
|
147
|
+
await migrateHistory({
|
|
148
|
+
sourceProfileDir: source,
|
|
149
|
+
targetProfileDir: target,
|
|
150
|
+
domains: ['github.com'],
|
|
151
|
+
});
|
|
152
|
+
const copy = new Database(path.join(target, 'History'));
|
|
153
|
+
try {
|
|
154
|
+
assert.equal(copy.prepare('SELECT count(*) AS n FROM urls').get().n, 1);
|
|
155
|
+
assert.equal(copy.prepare('SELECT count(*) AS n FROM visits').get().n, 1);
|
|
156
|
+
for (const table of [
|
|
157
|
+
'content_annotations',
|
|
158
|
+
'context_annotations',
|
|
159
|
+
'segment_usage',
|
|
160
|
+
'downloads',
|
|
161
|
+
'downloads_url_chains',
|
|
162
|
+
'downloads_slices',
|
|
163
|
+
]) {
|
|
164
|
+
assert.equal(
|
|
165
|
+
copy.prepare(`SELECT count(*) AS n FROM ${table}`).get().n,
|
|
166
|
+
1,
|
|
167
|
+
table
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
} finally {
|
|
171
|
+
copy.close();
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('filters passwords before decryption and removes undecryptable source values', async () => {
|
|
176
|
+
const source = await temp();
|
|
177
|
+
const target = await temp();
|
|
178
|
+
const key = deriveChromiumCookieKey('source', 'linux');
|
|
179
|
+
const db = new Database(path.join(source, 'Login Data'));
|
|
180
|
+
db.exec('CREATE TABLE logins(origin_url TEXT, password_value BLOB)');
|
|
181
|
+
const insert = db.prepare('INSERT INTO logins VALUES (?,?)');
|
|
182
|
+
insert.run(
|
|
183
|
+
'https://github.com',
|
|
184
|
+
encryptChromiumValue({
|
|
185
|
+
plaintext: 'secret',
|
|
186
|
+
key,
|
|
187
|
+
platform: 'linux',
|
|
188
|
+
prefix: 'v11',
|
|
189
|
+
})
|
|
190
|
+
);
|
|
191
|
+
insert.run('https://notgithub.com', Buffer.from('v20unreadable'));
|
|
192
|
+
insert.run('https://locked.github.com', Buffer.from('v20unreadable'));
|
|
193
|
+
db.close();
|
|
194
|
+
const report = await migratePasswords({
|
|
195
|
+
sourceProfileDir: source,
|
|
196
|
+
targetProfileDir: target,
|
|
197
|
+
domains: ['github.com'],
|
|
198
|
+
platform: 'linux',
|
|
199
|
+
resolveSourceKey: () => key,
|
|
200
|
+
targetKey: key,
|
|
201
|
+
});
|
|
202
|
+
assert.equal(report.migrated, 1);
|
|
203
|
+
const copy = new Database(path.join(target, 'Login Data'));
|
|
204
|
+
try {
|
|
205
|
+
assert.deepEqual(copy.prepare('SELECT origin_url FROM logins').all(), [
|
|
206
|
+
{ origin_url: 'https://github.com' },
|
|
207
|
+
]);
|
|
208
|
+
} finally {
|
|
209
|
+
copy.close();
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { it } from 'node:test';
|
|
5
|
+
|
|
6
|
+
import BetterSqlite3 from 'better-sqlite3';
|
|
7
|
+
|
|
8
|
+
import { migrateProfile } from '../../../../src/browser/migration/index.js';
|
|
9
|
+
import { repoPath } from '../../../helpers/repo.js';
|
|
10
|
+
import { useTempDirectories } from '../../../helpers/temp-directory.js';
|
|
11
|
+
|
|
12
|
+
const makeTempDir = useTempDirectories('bc-ff-history-');
|
|
13
|
+
const schema = await readFile(
|
|
14
|
+
repoPath('tests/fixtures/firefox-history.sql'),
|
|
15
|
+
'utf8'
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
async function importHistory({ sql = schema, domains = [] } = {}) {
|
|
19
|
+
const source = await makeTempDir();
|
|
20
|
+
const target = path.join(await makeTempDir(), 'new-profile');
|
|
21
|
+
const filename = path.join(source, 'places.sqlite');
|
|
22
|
+
const db = new BetterSqlite3(filename);
|
|
23
|
+
try {
|
|
24
|
+
db.exec(sql);
|
|
25
|
+
} finally {
|
|
26
|
+
db.close();
|
|
27
|
+
}
|
|
28
|
+
const before = await readFile(filename);
|
|
29
|
+
const report = await migrateProfile({
|
|
30
|
+
from: { browser: 'firefox', userDataDir: source },
|
|
31
|
+
to: target,
|
|
32
|
+
include: ['history'],
|
|
33
|
+
domains,
|
|
34
|
+
});
|
|
35
|
+
assert.deepEqual(await readFile(filename), before);
|
|
36
|
+
return { report, target };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const domains of [[], ['GITHUB.COM.']]) {
|
|
40
|
+
it(`translates Firefox visits with ${domains.length ? 'exact domain filtering' : 'all domains'}`, async () => {
|
|
41
|
+
const { report, target } = await importHistory({ domains });
|
|
42
|
+
const count = domains.length ? 3 : 4;
|
|
43
|
+
assert.equal(report.migrated.history, count);
|
|
44
|
+
assert.deepEqual(report.skipped, []);
|
|
45
|
+
assert.equal(
|
|
46
|
+
report.warnings[0].reason,
|
|
47
|
+
'firefox-history-metadata-not-translated'
|
|
48
|
+
);
|
|
49
|
+
const db = new BetterSqlite3(path.join(target, 'History'), {
|
|
50
|
+
readonly: true,
|
|
51
|
+
});
|
|
52
|
+
try {
|
|
53
|
+
const rows = db
|
|
54
|
+
.prepare(
|
|
55
|
+
'SELECT url,title,visit_count,last_visit_time FROM urls ORDER BY url'
|
|
56
|
+
)
|
|
57
|
+
.safeIntegers()
|
|
58
|
+
.all();
|
|
59
|
+
assert.deepEqual(rows.slice(0, 2), [
|
|
60
|
+
{
|
|
61
|
+
url: 'https://docs.github.com/second',
|
|
62
|
+
title: '',
|
|
63
|
+
visit_count: 1n,
|
|
64
|
+
last_visit_time: 13344473600000003n,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
url: 'https://github.com/first',
|
|
68
|
+
title: 'First Ω',
|
|
69
|
+
visit_count: 2n,
|
|
70
|
+
last_visit_time: 13344473600000002n,
|
|
71
|
+
},
|
|
72
|
+
]);
|
|
73
|
+
const times = db
|
|
74
|
+
.prepare('SELECT visit_time FROM visits ORDER BY visit_time')
|
|
75
|
+
.safeIntegers()
|
|
76
|
+
.all();
|
|
77
|
+
assert.deepEqual(
|
|
78
|
+
times.map(({ visit_time }) => visit_time),
|
|
79
|
+
[
|
|
80
|
+
13344473600000001n,
|
|
81
|
+
13344473600000002n,
|
|
82
|
+
13344473600000003n,
|
|
83
|
+
13344473600000004n,
|
|
84
|
+
].slice(0, count)
|
|
85
|
+
);
|
|
86
|
+
} finally {
|
|
87
|
+
db.close();
|
|
88
|
+
}
|
|
89
|
+
await assert.rejects(readFile(path.join(target, 'places.sqlite')), {
|
|
90
|
+
code: 'ENOENT',
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
it('reports a missing Firefox visit table before creating the target', async () => {
|
|
96
|
+
const { report, target } = await importHistory({
|
|
97
|
+
sql: 'CREATE TABLE moz_places (id INTEGER PRIMARY KEY, url TEXT, title TEXT)',
|
|
98
|
+
});
|
|
99
|
+
assert.equal(report.migrated.history, 0);
|
|
100
|
+
assert.equal(report.skipped[0].reason, 'source-format-unsupported');
|
|
101
|
+
assert.match(report.skipped[0].detail, /moz_historyvisits/u);
|
|
102
|
+
await assert.rejects(readFile(path.join(target, 'History')), {
|
|
103
|
+
code: 'ENOENT',
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
for (const [field, values] of [
|
|
108
|
+
[
|
|
109
|
+
'timestamp',
|
|
110
|
+
['NULL', "'invalid'", '1.5', '-11644473600000001', '9223372036854775807'],
|
|
111
|
+
],
|
|
112
|
+
['url', ['NULL', "''", "x'0102'"]],
|
|
113
|
+
]) {
|
|
114
|
+
it(`reports invalid visit ${field} without losing valid visits`, async () => {
|
|
115
|
+
for (const value of values) {
|
|
116
|
+
const extra =
|
|
117
|
+
field === 'timestamp'
|
|
118
|
+
? `INSERT INTO moz_historyvisits VALUES (5,1,${value},1)`
|
|
119
|
+
: `INSERT INTO moz_places VALUES (5,${value},'Invalid'); INSERT INTO moz_historyvisits VALUES (5,5,1700000000000005,1)`;
|
|
120
|
+
const { report } = await importHistory({
|
|
121
|
+
sql: `${schema}\n${extra}`,
|
|
122
|
+
domains: ['github.com'],
|
|
123
|
+
});
|
|
124
|
+
assert.equal(report.migrated.history, 3);
|
|
125
|
+
assert.equal(report.skipped.length, 1);
|
|
126
|
+
assert.equal(report.skipped[0].reason, `invalid-history-${field}`);
|
|
127
|
+
assert.equal(report.skipped[0].item, 'places.sqlite visit 5');
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { it } from 'node:test';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import Database from 'better-sqlite3';
|
|
6
|
+
import { useTempDirectories } from '../../../helpers/temp-directory.js';
|
|
7
|
+
import { migrateHistory } from '../../../../src/browser/migration/history.js';
|
|
8
|
+
|
|
9
|
+
const temporary = useTempDirectories('bc-history-metadata-');
|
|
10
|
+
for (const domains of [['github.com'], []]) {
|
|
11
|
+
it(`${domains.length ? 'omits with warnings' : 'preserves'} derived history metadata`, async () => {
|
|
12
|
+
const source = await temporary();
|
|
13
|
+
const target = await temporary();
|
|
14
|
+
const filename = path.join(source, 'History');
|
|
15
|
+
const db = new Database(filename);
|
|
16
|
+
db.exec(
|
|
17
|
+
await readFile(
|
|
18
|
+
new URL(
|
|
19
|
+
'../../../../../tests/fixtures/history-opaque-metadata.sql',
|
|
20
|
+
import.meta.url
|
|
21
|
+
),
|
|
22
|
+
'utf8'
|
|
23
|
+
)
|
|
24
|
+
);
|
|
25
|
+
db.close();
|
|
26
|
+
const before = await readFile(filename);
|
|
27
|
+
const report = await migrateHistory({
|
|
28
|
+
sourceProfileDir: source,
|
|
29
|
+
targetProfileDir: target,
|
|
30
|
+
domains,
|
|
31
|
+
});
|
|
32
|
+
assert.equal(report.migrated, 1);
|
|
33
|
+
const copy = new Database(path.join(target, 'History'));
|
|
34
|
+
try {
|
|
35
|
+
for (const [table, unfilteredCount] of [
|
|
36
|
+
['clusters', 2],
|
|
37
|
+
['clusters_and_visits', 3],
|
|
38
|
+
['cluster_keywords', 2],
|
|
39
|
+
['cluster_visit_duplicates', 2],
|
|
40
|
+
['future "history" metadata', 1],
|
|
41
|
+
]) {
|
|
42
|
+
const quoted = `"${table.replaceAll('"', '""')}"`;
|
|
43
|
+
const count = copy
|
|
44
|
+
.prepare(`SELECT count(*) AS n FROM ${quoted}`)
|
|
45
|
+
.get().n;
|
|
46
|
+
assert.equal(count, domains.length ? 0 : unfilteredCount, table);
|
|
47
|
+
assert.equal(
|
|
48
|
+
report.warnings.some(
|
|
49
|
+
(entry) =>
|
|
50
|
+
entry.item === table &&
|
|
51
|
+
entry.reason === 'unsupported-history-metadata'
|
|
52
|
+
),
|
|
53
|
+
Boolean(domains.length)
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
assert.equal(
|
|
57
|
+
copy.prepare('SELECT count(*) AS n FROM urls').get().n,
|
|
58
|
+
domains.length ? 1 : 2
|
|
59
|
+
);
|
|
60
|
+
assert.equal(
|
|
61
|
+
copy.prepare('SELECT value FROM meta WHERE key=?').get('version').value,
|
|
62
|
+
70
|
|
63
|
+
);
|
|
64
|
+
} finally {
|
|
65
|
+
copy.close();
|
|
66
|
+
}
|
|
67
|
+
const bytes = await readFile(path.join(target, 'History'));
|
|
68
|
+
assert.equal(
|
|
69
|
+
bytes.includes(Buffer.from('unrelated-history-marker')),
|
|
70
|
+
!domains.length
|
|
71
|
+
);
|
|
72
|
+
assert.deepEqual(await readFile(filename), before);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
@@ -67,7 +67,7 @@ describe('migrateProfile', () => {
|
|
|
67
67
|
assert.equal(report.migrated.cookies, 1);
|
|
68
68
|
assert.equal(report.migrated.bookmarks, 1);
|
|
69
69
|
assert.equal(report.migrated.passwords, 1);
|
|
70
|
-
//
|
|
70
|
+
// This bookmark-only fixture has no Firefox visit table.
|
|
71
71
|
assert.equal(report.migrated.history, 0);
|
|
72
72
|
assert.equal(report.migrated.preferences, 0);
|
|
73
73
|
assert.equal(report.migrated.extensions, 0);
|
|
@@ -97,6 +97,21 @@ describe('migrateProfile', () => {
|
|
|
97
97
|
await assert.rejects(() => readFile(path.join(target, 'Login Data')));
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
+
it('reports unsupported Firefox preferences and extensions explicitly', async () => {
|
|
101
|
+
const report = await migrateProfile({
|
|
102
|
+
from: { browser: 'firefox', userDataDir: await makeTempDir() },
|
|
103
|
+
to: await makeTempDir(),
|
|
104
|
+
include: ['preferences', 'extensions'],
|
|
105
|
+
});
|
|
106
|
+
assert.deepEqual(
|
|
107
|
+
report.skipped.map(({ type, reason }) => [type, reason]),
|
|
108
|
+
[
|
|
109
|
+
['preferences', 'firefox-class-not-supported'],
|
|
110
|
+
['extensions', 'firefox-class-not-supported'],
|
|
111
|
+
]
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
100
115
|
it('skips passwords with a warning when no target key is available on Windows', async () => {
|
|
101
116
|
const source = await makeTempDir();
|
|
102
117
|
const target = await makeTempDir();
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { it } from 'node:test';
|
|
6
|
+
import { localStatePathForProfile } from '../../../../src/browser/migration/os-crypt-keys.js';
|
|
7
|
+
|
|
8
|
+
it('uses Opera single-profile Local State before a parent fallback', async () => {
|
|
9
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'bc-opera-'));
|
|
10
|
+
try {
|
|
11
|
+
const profile = path.join(root, 'Opera Stable');
|
|
12
|
+
await mkdir(profile);
|
|
13
|
+
await writeFile(path.join(root, 'Local State'), '{}');
|
|
14
|
+
await writeFile(path.join(profile, 'Local State'), '{"os_crypt":{}}');
|
|
15
|
+
assert.equal(
|
|
16
|
+
localStatePathForProfile(profile),
|
|
17
|
+
path.join(profile, 'Local State')
|
|
18
|
+
);
|
|
19
|
+
} finally {
|
|
20
|
+
await rm(root, { recursive: true, force: true });
|
|
21
|
+
}
|
|
22
|
+
});
|