browser-commander 0.19.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.
- package/CHANGELOG.md +156 -0
- package/README.md +216 -32
- package/bin/browser-commander.js +16 -0
- package/eslint.config.js +10 -0
- package/examples/react-test-app/src/App.jsx +24 -23
- package/extension/background.js +14 -0
- package/extension/manifest.json +12 -0
- package/extension/relay-handler.js +353 -0
- package/package.json +25 -6
- package/scripts/run-tests.mjs +4 -2
- package/src/browser/attach/differences.js +131 -0
- package/src/browser/attach/extension-relay.js +402 -0
- package/src/browser/attach/index.js +81 -0
- package/src/browser/attach/snapshot-launch.js +146 -0
- package/src/browser/attach/snapshot.js +374 -0
- package/src/browser/attach/websocket.js +615 -0
- package/src/browser/browser-cookie-cache.js +12 -17
- package/src/browser/browser-cookie-credentials.js +6 -9
- package/src/browser/browser-cookies.js +5 -2
- package/src/browser/cdp-session.js +94 -0
- package/src/browser/connector.js +42 -4
- package/src/browser/debugging-port.js +155 -0
- package/src/browser/launch-options.js +63 -52
- package/src/browser/launch-restrictions.json +147 -0
- package/src/browser/launcher.js +329 -133
- package/src/browser/media.js +5 -0
- package/src/browser/migration/bookmarks.js +73 -0
- package/src/browser/migration/chromium-crypto.js +89 -0
- package/src/browser/migration/cookies.js +155 -0
- package/src/browser/migration/extensions.js +187 -0
- package/src/browser/migration/firefox-bookmarks.js +144 -0
- package/src/browser/migration/firefox-der.js +84 -0
- package/src/browser/migration/firefox-nss.js +281 -0
- package/src/browser/migration/firefox.js +334 -0
- package/src/browser/migration/fs-utils.js +51 -0
- package/src/browser/migration/history.js +103 -0
- package/src/browser/migration/index.js +307 -0
- package/src/browser/migration/os-crypt-keys.js +106 -0
- package/src/browser/migration/passwords.js +171 -0
- package/src/browser/migration/preferences.js +132 -0
- package/src/browser/migration/sqlite-snapshot.js +138 -0
- package/src/browser/open-in-user-browser.js +112 -0
- package/src/browser/parity.js +439 -0
- package/src/browser/profile-directory.js +102 -0
- package/src/browser/real-browser.js +513 -306
- package/src/browser/restrictions.js +148 -0
- package/src/browser/system-browser.js +232 -0
- package/src/browser/webdriver.js +572 -0
- package/src/cli/args.js +253 -0
- package/src/cli/commands.js +332 -0
- package/src/cli/dispatcher.js +99 -0
- package/src/cli/handle-methods.js +136 -0
- package/src/cli/handles.js +273 -0
- package/src/cli/high-level-methods.js +310 -0
- package/src/cli/main.js +67 -0
- package/src/cli/modules.js +100 -0
- package/src/cli/rpc-error.js +77 -0
- package/src/cli/script.js +77 -0
- package/src/cli/serve.js +144 -0
- package/src/cli/sessions.js +195 -0
- package/src/core/constants.js +7 -1
- package/src/core/engine-adapter.js +107 -1
- package/src/core/engine-detection.js +18 -3
- package/src/core/navigation-safety.js +4 -0
- package/src/core/webdriver-conversions.js +347 -0
- package/src/core/webdriver-events.js +229 -0
- package/src/core/webdriver-page.js +716 -0
- package/src/downloads/attach.js +6 -0
- package/src/downloads/manager.js +9 -0
- package/src/downloads/sources.js +71 -0
- package/src/downloads/staging.js +21 -9
- package/src/elements/selectors.js +3 -2
- package/src/elements/visibility.js +4 -0
- package/src/exports.js +61 -2
- package/src/factory.js +19 -4
- package/src/fingerprint/apply.js +6 -0
- package/src/fingerprint/automation-parity.js +7 -8
- package/src/fingerprint/limitations.js +1 -1
- package/src/fingerprint/limitations.json +45 -2
- package/src/parity/harness.js +303 -0
- package/src/parity/probe.js +838 -0
- package/src/parity/version-page.js +144 -0
- package/src/traces/bundle.js +10 -2
- package/src/traces/recorder.js +16 -3
- package/src/utilities/subprocess.js +199 -0
- package/tests/e2e/api-coverage.e2e.test.js +174 -0
- package/tests/e2e/cli.e2e.test.js +160 -0
- package/tests/e2e/downloads.e2e.test.js +79 -2
- package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
- package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
- package/tests/e2e/measure-parity.e2e.test.js +118 -0
- package/tests/e2e/playwright.e2e.test.js +25 -17
- package/tests/e2e/puppeteer.e2e.test.js +17 -23
- package/tests/e2e/webdriver.e2e.test.js +239 -0
- package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
- package/tests/helpers/cli-fakes.js +214 -0
- package/tests/helpers/declared-api.js +80 -0
- package/tests/helpers/e2e-browser.js +40 -14
- package/tests/helpers/fake-chrome.js +124 -0
- package/tests/helpers/migration-fixtures.js +242 -0
- package/tests/helpers/temp-directory.js +24 -0
- package/tests/helpers/trace-fixtures.js +18 -0
- package/tests/helpers/webdriver-mocks.js +183 -0
- package/tests/unit/browser/attach/extension-relay.test.js +360 -0
- package/tests/unit/browser/attach/snapshot-launch.test.js +312 -0
- package/tests/unit/browser/attach/snapshot.test.js +390 -0
- package/tests/unit/browser/attach/websocket.test.js +240 -0
- package/tests/unit/browser/browser-cookies.test.js +2 -32
- package/tests/unit/browser/cdp-session.test.js +162 -0
- package/tests/unit/browser/connector.test.js +23 -0
- package/tests/unit/browser/launch-building-blocks.test.js +238 -0
- package/tests/unit/browser/launch-options.test.js +41 -22
- package/tests/unit/browser/launcher.test.js +281 -102
- package/tests/unit/browser/media.test.js +13 -1
- package/tests/unit/browser/migration/bookmarks.test.js +69 -0
- package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
- package/tests/unit/browser/migration/cookies.test.js +94 -0
- package/tests/unit/browser/migration/extensions.test.js +104 -0
- package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
- package/tests/unit/browser/migration/firefox.test.js +139 -0
- package/tests/unit/browser/migration/history.test.js +48 -0
- package/tests/unit/browser/migration/index.test.js +117 -0
- package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
- package/tests/unit/browser/migration/passwords.test.js +144 -0
- package/tests/unit/browser/migration/preferences.test.js +77 -0
- package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
- package/tests/unit/browser/open-in-user-browser.test.js +123 -0
- package/tests/unit/browser/parity.test.js +281 -0
- package/tests/unit/browser/real-browser.test.js +458 -33
- package/tests/unit/browser/webdriver.test.js +473 -0
- package/tests/unit/cli/bin.test.js +69 -0
- package/tests/unit/cli/declared-api.test.js +49 -0
- package/tests/unit/cli/dispatcher.test.js +429 -0
- package/tests/unit/cli/handles.test.js +170 -0
- package/tests/unit/cli/main.test.js +409 -0
- package/tests/unit/cli/serve.test.js +167 -0
- package/tests/unit/core/webdriver-conversions.test.js +235 -0
- package/tests/unit/core/webdriver-page.test.js +534 -0
- package/tests/unit/downloads/manager.test.js +82 -9
- package/tests/unit/downloads/staging.test.js +24 -2
- package/tests/unit/fingerprint/automation-parity.test.js +17 -4
- package/tests/unit/fingerprint/limitations.test.js +1 -1
- package/tests/unit/parity/harness.test.js +138 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +4 -0
- package/tests/unit/scripts/ci-workflow-policy.test.js +8 -0
- package/tests/unit/scripts/feature-parity.test.js +221 -0
- package/tests/unit/scripts/npm-release-helpers.test.js +3 -2
- package/tests/unit/scripts/pre-commit-config.test.js +2 -1
- package/tests/unit/traces/bundle.test.js +24 -3
- package/tests/unit/traces/links.test.js +7 -0
- package/tests/unit/traces/reader.test.js +11 -4
- package/tests/unit/traces/recorder.test.js +4 -0
- package/tests/unit/traces/viewer.test.js +4 -0
- package/tests/unit/utilities/command-stream-only.test.js +28 -0
- package/tests/unit/utilities/subprocess.test.js +80 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
// feature-parity: attach.snapshot
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import {
|
|
5
|
+
chmod,
|
|
6
|
+
mkdir,
|
|
7
|
+
readdir,
|
|
8
|
+
readFile,
|
|
9
|
+
stat,
|
|
10
|
+
symlink,
|
|
11
|
+
writeFile,
|
|
12
|
+
} from 'node:fs/promises';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { describe, it } from 'node:test';
|
|
15
|
+
|
|
16
|
+
import BetterSqlite3 from 'better-sqlite3';
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
isSqliteFile,
|
|
20
|
+
snapshotSkipReason,
|
|
21
|
+
snapshotUserDataDir,
|
|
22
|
+
} from '../../../../src/browser/attach/snapshot.js';
|
|
23
|
+
import { removeUserDataDir } from '../../../../src/browser/profile-directory.js';
|
|
24
|
+
import {
|
|
25
|
+
readProfileJson,
|
|
26
|
+
writeChromiumHistory,
|
|
27
|
+
writeProfileJson,
|
|
28
|
+
} from '../../../helpers/migration-fixtures.js';
|
|
29
|
+
import { useTempDirectories } from '../../../helpers/temp-directory.js';
|
|
30
|
+
|
|
31
|
+
const makeTempDir = useTempDirectories('bc-attach-snapshot-');
|
|
32
|
+
|
|
33
|
+
function snapshotChrome(root, to) {
|
|
34
|
+
return snapshotUserDataDir({ browser: 'chrome', userDataDir: root, to });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Write `content` to `root/relativePath`, creating the parent folders. */
|
|
38
|
+
async function put(root, relativePath, content = relativePath) {
|
|
39
|
+
const filePath = path.join(root, ...relativePath.split('/'));
|
|
40
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
41
|
+
await writeFile(filePath, content);
|
|
42
|
+
return filePath;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A Chromium user data directory with one `Default` profile holding a mix of
|
|
47
|
+
* data, caches, locks and session files, plus a second profile.
|
|
48
|
+
*/
|
|
49
|
+
async function makeUserDataDir() {
|
|
50
|
+
const root = await makeTempDir();
|
|
51
|
+
await put(root, 'Local State', JSON.stringify({ os_crypt: { key: 'k' } }));
|
|
52
|
+
await put(root, 'SingletonLock');
|
|
53
|
+
const profile = path.join(root, 'Default');
|
|
54
|
+
await mkdir(profile);
|
|
55
|
+
await writeProfileJson(profile, 'Preferences', {
|
|
56
|
+
profile: { name: 'Person 1', exit_type: 'Crashed', exited_cleanly: false },
|
|
57
|
+
});
|
|
58
|
+
await writeProfileJson(profile, 'Bookmarks', { roots: {} });
|
|
59
|
+
writeChromiumHistory(profile, 3);
|
|
60
|
+
for (const item of [
|
|
61
|
+
'LOCK',
|
|
62
|
+
'Cache/Cache_Data/data_0',
|
|
63
|
+
'Code Cache/js/index',
|
|
64
|
+
'GPUCache/data_1',
|
|
65
|
+
'Service Worker/CacheStorage/abc/index',
|
|
66
|
+
'Service Worker/Database/000003.log',
|
|
67
|
+
'Sessions/Session_1',
|
|
68
|
+
'Current Session',
|
|
69
|
+
'Local Storage/leveldb/000003.log',
|
|
70
|
+
'Local Storage/leveldb/LOCK',
|
|
71
|
+
'Cookies-journal',
|
|
72
|
+
]) {
|
|
73
|
+
await put(profile, item);
|
|
74
|
+
}
|
|
75
|
+
await put(root, 'Profile 1/Preferences', '{}');
|
|
76
|
+
return root;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Hash, size and mtime of every file under a directory. */
|
|
80
|
+
async function fingerprintTree(root) {
|
|
81
|
+
const entries = await readdir(root, { recursive: true, withFileTypes: true });
|
|
82
|
+
const result = {};
|
|
83
|
+
for (const entry of entries) {
|
|
84
|
+
if (!entry.isFile()) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const filePath = path.join(entry.parentPath ?? entry.path, entry.name);
|
|
88
|
+
const { mtimeMs, size } = await stat(filePath);
|
|
89
|
+
const hash = createHash('sha256')
|
|
90
|
+
.update(await readFile(filePath))
|
|
91
|
+
.digest('hex');
|
|
92
|
+
result[path.relative(root, filePath)] = { hash, mtimeMs, size };
|
|
93
|
+
}
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function countRows(databasePath) {
|
|
98
|
+
const database = new BetterSqlite3(databasePath, {
|
|
99
|
+
readonly: true,
|
|
100
|
+
fileMustExist: true,
|
|
101
|
+
});
|
|
102
|
+
try {
|
|
103
|
+
return database.prepare('SELECT COUNT(*) AS c FROM urls').get().c;
|
|
104
|
+
} finally {
|
|
105
|
+
database.close();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
describe('snapshotSkipReason', () => {
|
|
110
|
+
it('names why locks, caches, crash reports, sessions and sidecars are skipped', () => {
|
|
111
|
+
const cases = {
|
|
112
|
+
SingletonLock: 'lock',
|
|
113
|
+
SingletonSocket: 'lock',
|
|
114
|
+
'Default/LOCK': 'lock',
|
|
115
|
+
'Default/lockfile': 'lock',
|
|
116
|
+
'Default/Cache': 'cache',
|
|
117
|
+
'Default/Code Cache': 'cache',
|
|
118
|
+
'Default/GPUCache': 'cache',
|
|
119
|
+
'Default/DawnGraphiteCache': 'cache',
|
|
120
|
+
'Default/Service Worker/CacheStorage': 'cache',
|
|
121
|
+
Crashpad: 'crash-reports',
|
|
122
|
+
'Default/Sessions': 'session',
|
|
123
|
+
'Default/Current Tabs': 'session',
|
|
124
|
+
'Default/Last Session': 'session',
|
|
125
|
+
'Default/History-journal': 'sqlite-sidecar',
|
|
126
|
+
'Default/Cookies-wal': 'sqlite-sidecar',
|
|
127
|
+
'Default/Cookies-shm': 'sqlite-sidecar',
|
|
128
|
+
'Default/History': null,
|
|
129
|
+
'Default/Preferences': null,
|
|
130
|
+
'Default/Service Worker/Database': null,
|
|
131
|
+
'Default/Local Storage/leveldb/000003.log': null,
|
|
132
|
+
};
|
|
133
|
+
for (const [relativePath, reason] of Object.entries(cases)) {
|
|
134
|
+
assert.equal(snapshotSkipReason(relativePath), reason, relativePath);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('snapshotUserDataDir', () => {
|
|
140
|
+
it('copies Local State and the one profile, skipping locks and caches', async () => {
|
|
141
|
+
const root = await makeUserDataDir();
|
|
142
|
+
const to = path.join(await makeTempDir(), 'copy');
|
|
143
|
+
|
|
144
|
+
const report = await snapshotChrome(root, to);
|
|
145
|
+
|
|
146
|
+
assert.deepEqual(report.source, {
|
|
147
|
+
browser: 'chrome',
|
|
148
|
+
profile: 'Default',
|
|
149
|
+
userDataDir: root,
|
|
150
|
+
});
|
|
151
|
+
assert.equal(report.target, to);
|
|
152
|
+
assert.deepEqual(report.warnings, []);
|
|
153
|
+
assert.equal(report.copied.databases, 1);
|
|
154
|
+
assert.equal(
|
|
155
|
+
await readFile(path.join(to, 'Local State'), 'utf8'),
|
|
156
|
+
JSON.stringify({ os_crypt: { key: 'k' } })
|
|
157
|
+
);
|
|
158
|
+
assert.deepEqual(
|
|
159
|
+
await readProfileJson(path.join(to, 'Default'), 'Bookmarks'),
|
|
160
|
+
{
|
|
161
|
+
roots: {},
|
|
162
|
+
}
|
|
163
|
+
);
|
|
164
|
+
assert.equal(countRows(path.join(to, 'Default', 'History')), 3);
|
|
165
|
+
assert.equal(await isSqliteFile(path.join(to, 'Default', 'History')), true);
|
|
166
|
+
for (const kept of [
|
|
167
|
+
'Default/Service Worker/Database/000003.log',
|
|
168
|
+
'Default/Local Storage/leveldb/000003.log',
|
|
169
|
+
'First Run',
|
|
170
|
+
]) {
|
|
171
|
+
await stat(path.join(to, kept));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const skipped = Object.fromEntries(
|
|
175
|
+
report.skipped.map(({ item, reason }) => [item, reason])
|
|
176
|
+
);
|
|
177
|
+
assert.deepEqual(skipped, {
|
|
178
|
+
'Default/Cache': 'cache',
|
|
179
|
+
'Default/Code Cache': 'cache',
|
|
180
|
+
'Default/Cookies-journal': 'sqlite-sidecar',
|
|
181
|
+
'Default/Current Session': 'session',
|
|
182
|
+
'Default/GPUCache': 'cache',
|
|
183
|
+
'Default/LOCK': 'lock',
|
|
184
|
+
'Default/Local Storage/leveldb/LOCK': 'lock',
|
|
185
|
+
'Default/Service Worker/CacheStorage': 'cache',
|
|
186
|
+
'Default/Sessions': 'session',
|
|
187
|
+
});
|
|
188
|
+
for (const absent of [
|
|
189
|
+
'SingletonLock',
|
|
190
|
+
'Profile 1',
|
|
191
|
+
'Default/Cache',
|
|
192
|
+
'Default/Sessions',
|
|
193
|
+
'Default/LOCK',
|
|
194
|
+
'Default/Cookies-journal',
|
|
195
|
+
]) {
|
|
196
|
+
await assert.rejects(stat(path.join(to, absent)), { code: 'ENOENT' });
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('never modifies the source', async () => {
|
|
201
|
+
const root = await makeUserDataDir();
|
|
202
|
+
const before = await fingerprintTree(root);
|
|
203
|
+
|
|
204
|
+
const report = await snapshotUserDataDir({
|
|
205
|
+
browser: 'chrome',
|
|
206
|
+
userDataDir: root,
|
|
207
|
+
to: path.join(await makeTempDir(), 'copy'),
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
assert.deepEqual(await fingerprintTree(root), before);
|
|
211
|
+
assert.ok(report.copied.files > 0);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('marks only the copy as cleanly exited', async () => {
|
|
215
|
+
const root = await makeUserDataDir();
|
|
216
|
+
const to = path.join(await makeTempDir(), 'copy');
|
|
217
|
+
|
|
218
|
+
await snapshotUserDataDir({ browser: 'edge', userDataDir: root, to });
|
|
219
|
+
|
|
220
|
+
assert.deepEqual(
|
|
221
|
+
(await readProfileJson(path.join(to, 'Default'), 'Preferences')).profile,
|
|
222
|
+
{ name: 'Person 1', exit_type: 'Normal', exited_cleanly: true }
|
|
223
|
+
);
|
|
224
|
+
assert.equal(
|
|
225
|
+
(await readProfileJson(path.join(root, 'Default'), 'Preferences')).profile
|
|
226
|
+
.exit_type,
|
|
227
|
+
'Crashed'
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
for (const journalMode of ['wal', 'delete']) {
|
|
232
|
+
it(`snapshots a ${journalMode}-mode database while a write transaction holds it`, async () => {
|
|
233
|
+
const root = await makeTempDir();
|
|
234
|
+
const profile = path.join(root, 'Default');
|
|
235
|
+
await mkdir(profile);
|
|
236
|
+
const historyPath = writeChromiumHistory(profile, 5);
|
|
237
|
+
const live = new BetterSqlite3(historyPath);
|
|
238
|
+
try {
|
|
239
|
+
live.pragma(`journal_mode = ${journalMode}`);
|
|
240
|
+
// Keep committed rows in the WAL, as a running Chrome does.
|
|
241
|
+
live.pragma('wal_autocheckpoint = 0');
|
|
242
|
+
const insert = live.prepare('INSERT INTO urls (url) VALUES (?)');
|
|
243
|
+
insert.run('https://committed.test/');
|
|
244
|
+
live.exec('BEGIN IMMEDIATE');
|
|
245
|
+
for (let index = 0; index < 50; index += 1) {
|
|
246
|
+
insert.run(`https://uncommitted.test/${index}`);
|
|
247
|
+
}
|
|
248
|
+
const sourceFiles = [
|
|
249
|
+
'History',
|
|
250
|
+
`History-${journalMode === 'wal' ? 'wal' : 'journal'}`,
|
|
251
|
+
];
|
|
252
|
+
const before = await Promise.all(
|
|
253
|
+
sourceFiles.map((name) => readFile(path.join(profile, name)))
|
|
254
|
+
);
|
|
255
|
+
const to = path.join(await makeTempDir(), 'copy');
|
|
256
|
+
|
|
257
|
+
const report = await snapshotChrome(root, to);
|
|
258
|
+
|
|
259
|
+
const copy = path.join(to, 'Default', 'History');
|
|
260
|
+
// One self-contained file: the committed WAL content is folded in.
|
|
261
|
+
assert.deepEqual(await readdir(path.join(to, 'Default')), ['History']);
|
|
262
|
+
assert.equal(report.copied.databases, 1);
|
|
263
|
+
assert.equal(countRows(copy), 6);
|
|
264
|
+
const database = new BetterSqlite3(copy, { readonly: true });
|
|
265
|
+
try {
|
|
266
|
+
assert.equal(
|
|
267
|
+
database.pragma('integrity_check', { simple: true }),
|
|
268
|
+
'ok'
|
|
269
|
+
);
|
|
270
|
+
} finally {
|
|
271
|
+
database.close();
|
|
272
|
+
}
|
|
273
|
+
assert.ok(
|
|
274
|
+
report.skipped.every(({ reason }) => reason === 'sqlite-sidecar'),
|
|
275
|
+
JSON.stringify(report.skipped)
|
|
276
|
+
);
|
|
277
|
+
const after = await Promise.all(
|
|
278
|
+
sourceFiles.map((name) => readFile(path.join(profile, name)))
|
|
279
|
+
);
|
|
280
|
+
assert.deepEqual(after, before);
|
|
281
|
+
} finally {
|
|
282
|
+
if (live.inTransaction) {
|
|
283
|
+
live.exec('ROLLBACK');
|
|
284
|
+
}
|
|
285
|
+
live.close();
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
it(
|
|
291
|
+
'reports symlinks and unreadable entries instead of failing',
|
|
292
|
+
{
|
|
293
|
+
skip:
|
|
294
|
+
process.platform === 'win32' && 'symlinks and modes differ on Windows',
|
|
295
|
+
},
|
|
296
|
+
async () => {
|
|
297
|
+
const root = await makeTempDir();
|
|
298
|
+
const profile = path.join(root, 'Default');
|
|
299
|
+
await put(profile, 'Preferences', '{}');
|
|
300
|
+
const secret = await put(profile, 'Secret');
|
|
301
|
+
await symlink(path.join(root, 'elsewhere'), path.join(profile, 'Link'));
|
|
302
|
+
const runsAsRoot = process.getuid?.() === 0;
|
|
303
|
+
await chmod(secret, 0o000);
|
|
304
|
+
const to = path.join(await makeTempDir(), 'copy');
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
const report = await snapshotUserDataDir({
|
|
308
|
+
browser: 'chrome',
|
|
309
|
+
userDataDir: root,
|
|
310
|
+
to,
|
|
311
|
+
});
|
|
312
|
+
const reasons = Object.fromEntries(
|
|
313
|
+
report.skipped.map(({ item, reason }) => [item, reason])
|
|
314
|
+
);
|
|
315
|
+
assert.equal(reasons['Default/Link'], 'symlink');
|
|
316
|
+
if (!runsAsRoot) {
|
|
317
|
+
assert.equal(reasons['Default/Secret'], 'unreadable');
|
|
318
|
+
}
|
|
319
|
+
assert.equal(report.warnings[0].reason, 'local-state-missing');
|
|
320
|
+
} finally {
|
|
321
|
+
await chmod(secret, 0o600);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
it('creates a temporary target when none is given', async () => {
|
|
327
|
+
const root = await makeUserDataDir();
|
|
328
|
+
|
|
329
|
+
const report = await snapshotUserDataDir({
|
|
330
|
+
browser: 'chrome',
|
|
331
|
+
profile: 'Profile 1',
|
|
332
|
+
userDataDir: root,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
assert.equal(report.source.profile, 'Profile 1');
|
|
337
|
+
await stat(path.join(report.target, 'Profile 1', 'Preferences'));
|
|
338
|
+
await assert.rejects(stat(path.join(report.target, 'Default')), {
|
|
339
|
+
code: 'ENOENT',
|
|
340
|
+
});
|
|
341
|
+
} finally {
|
|
342
|
+
await removeUserDataDir(report.target);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('rejects Firefox, bad profile names and missing profiles', async () => {
|
|
347
|
+
const root = await makeUserDataDir();
|
|
348
|
+
|
|
349
|
+
await assert.rejects(
|
|
350
|
+
snapshotUserDataDir({ browser: 'firefox', userDataDir: root }),
|
|
351
|
+
/only supported for Chromium-family browsers/u
|
|
352
|
+
);
|
|
353
|
+
await assert.rejects(snapshotUserDataDir({}), TypeError);
|
|
354
|
+
for (const profile of ['', '..', 'a/b', 'a\\b']) {
|
|
355
|
+
await assert.rejects(
|
|
356
|
+
snapshotUserDataDir({ browser: 'chrome', profile, userDataDir: root }),
|
|
357
|
+
TypeError,
|
|
358
|
+
profile
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
await assert.rejects(
|
|
362
|
+
snapshotUserDataDir({
|
|
363
|
+
browser: 'chrome',
|
|
364
|
+
profile: 'Profile 9',
|
|
365
|
+
userDataDir: root,
|
|
366
|
+
}),
|
|
367
|
+
/No chrome profile "Profile 9"/u
|
|
368
|
+
);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('refuses a non-empty target and a target inside the source', async () => {
|
|
372
|
+
const root = await makeUserDataDir();
|
|
373
|
+
const busy = await makeTempDir();
|
|
374
|
+
await put(busy, 'something');
|
|
375
|
+
|
|
376
|
+
await assert.rejects(
|
|
377
|
+
snapshotUserDataDir({ browser: 'chrome', userDataDir: root, to: busy }),
|
|
378
|
+
/is not empty/u
|
|
379
|
+
);
|
|
380
|
+
await assert.rejects(
|
|
381
|
+
snapshotUserDataDir({
|
|
382
|
+
browser: 'chrome',
|
|
383
|
+
userDataDir: root,
|
|
384
|
+
to: path.join(root, 'copy'),
|
|
385
|
+
}),
|
|
386
|
+
/inside the source/u
|
|
387
|
+
);
|
|
388
|
+
await assert.rejects(stat(path.join(root, 'copy')), { code: 'ENOENT' });
|
|
389
|
+
});
|
|
390
|
+
});
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { once } from 'node:events';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import { afterEach, describe, it } from 'node:test';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
acceptUpgrade,
|
|
9
|
+
CLOSE_CODES,
|
|
10
|
+
computeAcceptKey,
|
|
11
|
+
connectWebSocket,
|
|
12
|
+
encodeFrame,
|
|
13
|
+
FrameDecoder,
|
|
14
|
+
OPCODES,
|
|
15
|
+
WebSocketProtocolError,
|
|
16
|
+
} from '../../../../src/browser/attach/websocket.js';
|
|
17
|
+
|
|
18
|
+
const servers = [];
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
for (const server of servers.splice(0)) {
|
|
22
|
+
server.closeAllConnections?.();
|
|
23
|
+
await new Promise((resolve) => server.close(resolve));
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* An HTTP server that upgrades every request and hands the server-side
|
|
29
|
+
* connection to the test.
|
|
30
|
+
*/
|
|
31
|
+
async function startServer() {
|
|
32
|
+
const connections = [];
|
|
33
|
+
const server = http.createServer((request, response) => {
|
|
34
|
+
response.writeHead(426).end();
|
|
35
|
+
});
|
|
36
|
+
server.on('upgrade', (request, socket, head) => {
|
|
37
|
+
const connection = acceptUpgrade(request, socket, head, {
|
|
38
|
+
maxPayload: 1024,
|
|
39
|
+
});
|
|
40
|
+
if (connection) {
|
|
41
|
+
connections.push(connection);
|
|
42
|
+
server.emit('websocket', connection);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
server.listen(0, '127.0.0.1');
|
|
46
|
+
await once(server, 'listening');
|
|
47
|
+
servers.push(server);
|
|
48
|
+
return {
|
|
49
|
+
url: `ws://127.0.0.1:${server.address().port}/`,
|
|
50
|
+
server,
|
|
51
|
+
connections,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function connectPair() {
|
|
56
|
+
const { url, server } = await startServer();
|
|
57
|
+
const accepted = once(server, 'websocket');
|
|
58
|
+
const client = await connectWebSocket(url);
|
|
59
|
+
const [serverSide] = await accepted;
|
|
60
|
+
return { client, serverSide, url };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('WebSocket framing', () => {
|
|
64
|
+
it('computes the accept key of RFC 6455 section 1.3', () => {
|
|
65
|
+
assert.equal(
|
|
66
|
+
computeAcceptKey('dGhlIHNhbXBsZSBub25jZQ=='),
|
|
67
|
+
's3pPLMBiTxaQ9kYGzzhZRbK+xOo='
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('round-trips 7-bit, 16-bit and 64-bit lengths, masked, byte by byte', () => {
|
|
72
|
+
for (const size of [0, 125, 126, 65_535, 70_000]) {
|
|
73
|
+
const payload = Buffer.alloc(size, 'x');
|
|
74
|
+
const decoder = new FrameDecoder({ requireMask: true });
|
|
75
|
+
const frame = encodeFrame(OPCODES.text, payload, { mask: true });
|
|
76
|
+
const frames = [];
|
|
77
|
+
const step = size > 1000 ? 4096 : 1;
|
|
78
|
+
for (let offset = 0; offset < frame.length; offset += step) {
|
|
79
|
+
frames.push(...decoder.push(frame.subarray(offset, offset + step)));
|
|
80
|
+
}
|
|
81
|
+
assert.equal(frames.length, 1, `size ${size}`);
|
|
82
|
+
assert.equal(frames[0].opcode, OPCODES.text);
|
|
83
|
+
assert.equal(frames[0].fin, true);
|
|
84
|
+
assert.ok(frames[0].payload.equals(payload), `size ${size}`);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('decodes several frames from one chunk', () => {
|
|
89
|
+
const decoder = new FrameDecoder();
|
|
90
|
+
const frames = decoder.push(
|
|
91
|
+
Buffer.concat([
|
|
92
|
+
encodeFrame(OPCODES.text, 'a', { fin: false }),
|
|
93
|
+
encodeFrame(OPCODES.continuation, 'b'),
|
|
94
|
+
encodeFrame(OPCODES.ping, 'p'),
|
|
95
|
+
])
|
|
96
|
+
);
|
|
97
|
+
assert.deepEqual(
|
|
98
|
+
frames.map(({ fin, opcode, payload }) => [fin, opcode, String(payload)]),
|
|
99
|
+
[
|
|
100
|
+
[false, OPCODES.text, 'a'],
|
|
101
|
+
[true, OPCODES.continuation, 'b'],
|
|
102
|
+
[true, OPCODES.ping, 'p'],
|
|
103
|
+
]
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('rejects frames that break the protocol', () => {
|
|
108
|
+
const cases = [
|
|
109
|
+
[{ requireMask: true }, encodeFrame(OPCODES.text, 'x'), 1002],
|
|
110
|
+
[
|
|
111
|
+
{ forbidMask: true },
|
|
112
|
+
encodeFrame(OPCODES.text, 'x', { mask: true }),
|
|
113
|
+
1002,
|
|
114
|
+
],
|
|
115
|
+
[{}, Buffer.from([0xc1, 0x00]), 1002],
|
|
116
|
+
[{}, Buffer.from([0x83, 0x00]), 1002],
|
|
117
|
+
[{}, encodeFrame(OPCODES.ping, Buffer.alloc(126)), 1002],
|
|
118
|
+
[{}, encodeFrame(OPCODES.ping, 'x', { fin: false }), 1002],
|
|
119
|
+
[{ maxPayload: 10 }, encodeFrame(OPCODES.text, Buffer.alloc(11)), 1009],
|
|
120
|
+
[
|
|
121
|
+
{ maxPayload: 10 },
|
|
122
|
+
encodeFrame(OPCODES.text, Buffer.alloc(70_000)),
|
|
123
|
+
1009,
|
|
124
|
+
],
|
|
125
|
+
];
|
|
126
|
+
for (const [options, frame, code] of cases) {
|
|
127
|
+
assert.throws(
|
|
128
|
+
() => new FrameDecoder(options).push(frame),
|
|
129
|
+
(error) =>
|
|
130
|
+
error instanceof WebSocketProtocolError && error.code === code
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('WebSocketConnection', () => {
|
|
137
|
+
it('exchanges text messages both ways and answers pings', async () => {
|
|
138
|
+
const { client, serverSide } = await connectPair();
|
|
139
|
+
|
|
140
|
+
const toServer = once(serverSide, 'message');
|
|
141
|
+
client.send('héllo');
|
|
142
|
+
assert.deepEqual(await toServer, ['héllo']);
|
|
143
|
+
|
|
144
|
+
const toClient = once(client, 'message');
|
|
145
|
+
serverSide.send(JSON.stringify({ id: 1 }));
|
|
146
|
+
assert.deepEqual(await toClient, ['{"id":1}']);
|
|
147
|
+
|
|
148
|
+
const pong = once(client, 'pong');
|
|
149
|
+
client.ping(Buffer.from('beat'));
|
|
150
|
+
assert.equal(String((await pong)[0]), 'beat');
|
|
151
|
+
|
|
152
|
+
const closed = Promise.all([
|
|
153
|
+
once(client, 'close'),
|
|
154
|
+
once(serverSide, 'close'),
|
|
155
|
+
]);
|
|
156
|
+
client.close(CLOSE_CODES.normal, 'bye');
|
|
157
|
+
const [[clientClose], [serverClose]] = await closed;
|
|
158
|
+
assert.deepEqual(serverClose, { code: 1000, reason: 'bye' });
|
|
159
|
+
assert.equal(clientClose.code, 1000);
|
|
160
|
+
assert.equal(client.send('late'), false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('joins fragmented messages', async () => {
|
|
164
|
+
const { client, serverSide } = await connectPair();
|
|
165
|
+
const message = once(serverSide, 'message');
|
|
166
|
+
client.socket.write(
|
|
167
|
+
Buffer.concat([
|
|
168
|
+
encodeFrame(OPCODES.text, 'frag', { mask: true, fin: false }),
|
|
169
|
+
encodeFrame(OPCODES.ping, '', { mask: true }),
|
|
170
|
+
encodeFrame(OPCODES.continuation, 'mented', { mask: true }),
|
|
171
|
+
])
|
|
172
|
+
);
|
|
173
|
+
assert.deepEqual(await message, ['fragmented']);
|
|
174
|
+
client.terminate();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
for (const [name, frame, code] of [
|
|
178
|
+
['an unmasked client frame', encodeFrame(OPCODES.text, 'x'), 1002],
|
|
179
|
+
[
|
|
180
|
+
'invalid UTF-8',
|
|
181
|
+
encodeFrame(OPCODES.text, Buffer.from([0xc3, 0x28]), { mask: true }),
|
|
182
|
+
1007,
|
|
183
|
+
],
|
|
184
|
+
[
|
|
185
|
+
'a binary message',
|
|
186
|
+
encodeFrame(OPCODES.binary, 'x', { mask: true }),
|
|
187
|
+
1003,
|
|
188
|
+
],
|
|
189
|
+
[
|
|
190
|
+
'a message over maxPayload',
|
|
191
|
+
encodeFrame(OPCODES.text, Buffer.alloc(2048), { mask: true }),
|
|
192
|
+
1009,
|
|
193
|
+
],
|
|
194
|
+
[
|
|
195
|
+
'a stray continuation frame',
|
|
196
|
+
encodeFrame(OPCODES.continuation, 'x', { mask: true }),
|
|
197
|
+
1002,
|
|
198
|
+
],
|
|
199
|
+
]) {
|
|
200
|
+
it(`closes with ${code} on ${name}`, async () => {
|
|
201
|
+
const { client, serverSide } = await connectPair();
|
|
202
|
+
const closed = once(client, 'close');
|
|
203
|
+
const serverClosed = once(serverSide, 'close');
|
|
204
|
+
client.socket.write(frame);
|
|
205
|
+
const [{ code: received }] = await closed;
|
|
206
|
+
assert.equal(received, code);
|
|
207
|
+
await serverClosed;
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
it('refuses a bad handshake with 400 and an old version with 426', async () => {
|
|
212
|
+
const { url } = await startServer();
|
|
213
|
+
const { port } = new URL(url);
|
|
214
|
+
const statusOf = (headers) =>
|
|
215
|
+
new Promise((resolve, reject) => {
|
|
216
|
+
const socket = net.connect(port, '127.0.0.1', () => {
|
|
217
|
+
socket.write(`GET / HTTP/1.1\r\nHost: x\r\n${headers}\r\n`);
|
|
218
|
+
});
|
|
219
|
+
let text = '';
|
|
220
|
+
socket.on('data', (chunk) => {
|
|
221
|
+
text += chunk;
|
|
222
|
+
});
|
|
223
|
+
socket.on('end', () => resolve(Number(text.split(' ')[1])));
|
|
224
|
+
socket.on('error', reject);
|
|
225
|
+
});
|
|
226
|
+
assert.equal(
|
|
227
|
+
await statusOf(
|
|
228
|
+
'Connection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Key: short\r\nSec-WebSocket-Version: 13\r\n'
|
|
229
|
+
),
|
|
230
|
+
400
|
|
231
|
+
);
|
|
232
|
+
assert.equal(
|
|
233
|
+
await statusOf(
|
|
234
|
+
'Connection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 8\r\n'
|
|
235
|
+
),
|
|
236
|
+
426
|
|
237
|
+
);
|
|
238
|
+
assert.throws(() => connectWebSocket('http://127.0.0.1/'), TypeError);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
listBrowserProfiles as publicListBrowserProfiles,
|
|
38
38
|
readBrowserCookies as publicReadBrowserCookies,
|
|
39
39
|
} from '../../../src/index.js';
|
|
40
|
+
import { writeFirefoxCookies } from '../../helpers/migration-fixtures.js';
|
|
40
41
|
|
|
41
42
|
const CHROME_EPOCH_OFFSET_SECONDS = 11_644_473_600;
|
|
42
43
|
const execFile = promisify(execFileCallback);
|
|
@@ -136,43 +137,12 @@ async function createFirefoxProfile({ homeDir, rows }) {
|
|
|
136
137
|
const root = path.join(homeDir, '.mozilla', 'firefox');
|
|
137
138
|
const profileName = 'fixture.default-release';
|
|
138
139
|
const profilePath = path.join(root, profileName);
|
|
139
|
-
const cookiePath = path.join(profilePath, 'cookies.sqlite');
|
|
140
140
|
await mkdir(profilePath, { recursive: true });
|
|
141
141
|
await writeFile(
|
|
142
142
|
path.join(root, 'profiles.ini'),
|
|
143
143
|
`[Profile0]\nName=default-release\nIsRelative=1\nPath=${profileName}\nDefault=1\n`
|
|
144
144
|
);
|
|
145
|
-
const
|
|
146
|
-
database.exec(`
|
|
147
|
-
CREATE TABLE moz_cookies (
|
|
148
|
-
name TEXT,
|
|
149
|
-
value TEXT,
|
|
150
|
-
host TEXT,
|
|
151
|
-
path TEXT,
|
|
152
|
-
expiry INTEGER,
|
|
153
|
-
isSecure INTEGER,
|
|
154
|
-
isHttpOnly INTEGER,
|
|
155
|
-
sameSite INTEGER
|
|
156
|
-
);
|
|
157
|
-
`);
|
|
158
|
-
const insert = database.prepare(`
|
|
159
|
-
INSERT INTO moz_cookies
|
|
160
|
-
(name, value, host, path, expiry, isSecure, isHttpOnly, sameSite)
|
|
161
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
162
|
-
`);
|
|
163
|
-
for (const row of rows) {
|
|
164
|
-
insert.run(
|
|
165
|
-
row.name,
|
|
166
|
-
row.value,
|
|
167
|
-
row.host,
|
|
168
|
-
row.path ?? '/',
|
|
169
|
-
row.expiry ?? 0,
|
|
170
|
-
row.secure ? 1 : 0,
|
|
171
|
-
row.httpOnly ? 1 : 0,
|
|
172
|
-
row.sameSite ?? 0
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
database.close();
|
|
145
|
+
const cookiePath = await writeFirefoxCookies(profilePath, rows);
|
|
176
146
|
return { cookiePath, profilePath, root };
|
|
177
147
|
}
|
|
178
148
|
|