browser-commander 0.21.2 → 0.23.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.
@@ -0,0 +1,246 @@
1
+ // feature-parity: sources.safari-cookies@native-typed
2
+ import assert from 'node:assert/strict';
3
+ import { EventEmitter } from 'node:events';
4
+ import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { PassThrough } from 'node:stream';
7
+ import { describe, it } from 'node:test';
8
+
9
+ import {
10
+ listCookieSources,
11
+ readBrowserCookiesWithDependencies,
12
+ resolveImportSource,
13
+ } from '../../../src/browser/browser-cookies.js';
14
+ import { listBrowserProfiles } from '../../../src/browser/browser-profiles.js';
15
+ import { migrateProfile } from '../../../src/browser/migration/index.js';
16
+ import { runCli } from '../../../src/cli/main.js';
17
+ import { runScript } from '../../../src/cli/script.js';
18
+ import {
19
+ findSafariCookieFile,
20
+ parseSafariCookies,
21
+ readSafariCookieFile,
22
+ } from '../../../src/browser/safari-cookies.js';
23
+ import { repoPath } from '../../helpers/repo.js';
24
+ import { useTempDirectories } from '../../helpers/temp-directory.js';
25
+
26
+ const fixture = await readFile(
27
+ repoPath('tests/fixtures/safari/Cookies.binarycookies')
28
+ );
29
+ const expected = JSON.parse(
30
+ await readFile(repoPath('tests/fixtures/safari/expected.json'), 'utf8')
31
+ );
32
+ const makeDirectory = useTempDirectories();
33
+ const safariDefault = async () =>
34
+ '( { LSHandlerURLScheme = https; LSHandlerRoleAll = "com.apple.Safari"; } )';
35
+
36
+ async function installed(browser = 'safari', legacy = false) {
37
+ const homeDir = await makeDirectory('bc-safari-');
38
+ const bundle =
39
+ browser === 'safari'
40
+ ? 'com.apple.Safari'
41
+ : 'com.apple.SafariTechnologyPreview';
42
+ const root = legacy
43
+ ? path.join(homeDir, 'Library')
44
+ : path.join(homeDir, 'Library', 'Containers', bundle, 'Data', 'Library');
45
+ await mkdir(path.join(root, 'Cookies'), { recursive: true });
46
+ const file = path.join(root, 'Cookies', 'Cookies.binarycookies');
47
+ await copyFile(repoPath('tests/fixtures/safari/Cookies.binarycookies'), file);
48
+ return { homeDir, root, file, platform: 'darwin', environment: {} };
49
+ }
50
+
51
+ describe('Safari cookies', { timeout: 10000 }, () => {
52
+ it('decodes multiple pages, flags, UTF-8 and Apple epoch without guessing session cookies', () => {
53
+ assert.deepEqual(parseSafariCookies(fixture), expected);
54
+ });
55
+
56
+ it('rejects truncated data and forged counts, record sizes, offsets, strings and expiry', () => {
57
+ // The last eight bytes are an optional footer; every shorter prefix is invalid.
58
+ for (let end = 0; end < fixture.length - 8; end += 1) {
59
+ assert.throws(
60
+ () => parseSafariCookies(fixture.subarray(0, end)),
61
+ /Invalid Safari binarycookies/
62
+ );
63
+ }
64
+ for (const [offset, value] of [
65
+ [4, 0xffffffff],
66
+ [24, 0xffffffff],
67
+ [28, 0xffffffff],
68
+ [40, 0xffffffff],
69
+ [56, 0],
70
+ ]) {
71
+ const broken = Buffer.from(fixture);
72
+ broken.writeUInt32LE(value, offset);
73
+ assert.throws(
74
+ () => parseSafariCookies(broken),
75
+ /Invalid Safari binarycookies/
76
+ );
77
+ }
78
+ const broken = Buffer.from(fixture);
79
+ broken.writeDoubleLE(NaN, 80);
80
+ assert.throws(
81
+ () => parseSafariCookies(broken),
82
+ /Invalid Safari binarycookies/
83
+ );
84
+ });
85
+
86
+ it('reads both Safari variants and the legacy path without a keystore', async () => {
87
+ for (const [browser, legacy] of [
88
+ ['safari', false],
89
+ ['safari-technology-preview', false],
90
+ ['safari', true],
91
+ ]) {
92
+ const options = await installed(browser, legacy);
93
+ const before = await readFile(options.file);
94
+ const profiles = await listBrowserProfiles({ ...options, browser });
95
+ assert.equal(profiles[0].path, options.root);
96
+ const cookies = await readBrowserCookiesWithDependencies(
97
+ { browser, cache: false },
98
+ options
99
+ );
100
+ assert.deepEqual(cookies, expected);
101
+ assert.deepEqual(await readFile(options.file), before);
102
+ }
103
+ });
104
+
105
+ it('lists counts without decoding cookie values and supports default domain import', async () => {
106
+ const options = await installed();
107
+ const damagedValue = Buffer.from(fixture);
108
+ damagedValue[40 + damagedValue.readUInt32LE(68)] = 0xff;
109
+ await writeFile(options.file, damagedValue);
110
+ const sources = await listCookieSources({
111
+ ...options,
112
+ domains: ['GITHUB.COM'],
113
+ });
114
+ assert.equal(sources.length, 1);
115
+ assert.equal(sources[0].cookies, 4);
116
+ assert.deepEqual(sources[0].byDomain, { 'GITHUB.COM': 2 });
117
+ assert.ok(!JSON.stringify(sources).includes('fixture-token'));
118
+ await writeFile(options.file, fixture);
119
+ const source = await resolveImportSource({
120
+ ...options,
121
+ browser: 'default',
122
+ domains: ['github.com'],
123
+ runCommand: safariDefault,
124
+ });
125
+ assert.equal(source.browser, 'safari');
126
+ assert.equal(source.warning, null);
127
+ const report = await migrateProfile({
128
+ ...options,
129
+ from: { browser: 'auto' },
130
+ to: path.join(options.homeDir, 'target'),
131
+ domains: ['github.com', 'GITHUB.COM'],
132
+ runCommand: safariDefault,
133
+ });
134
+ assert.deepEqual(report.cookies, expected.slice(0, 2));
135
+ assert.equal(report.migrated.cookies, 2);
136
+ assert.equal(report.skipped.length, 5);
137
+ assert.equal(
138
+ report.skipped.find((entry) => entry.type === 'passwords').reason,
139
+ 'safari-password-export-required'
140
+ );
141
+ assert.ok(
142
+ report.warnings.some(
143
+ (entry) => entry.reason === 'safari-samesite-unavailable'
144
+ )
145
+ );
146
+ });
147
+
148
+ it('reads an explicit source directory and reports actionable Full Disk Access errors', async () => {
149
+ const options = await installed();
150
+ const cookies = await readBrowserCookiesWithDependencies(
151
+ {
152
+ browser: 'safari',
153
+ profileDir: options.root,
154
+ domainFilter: 'github.com',
155
+ cache: false,
156
+ },
157
+ { homeDir: path.join(options.homeDir, 'empty') }
158
+ );
159
+ assert.deepEqual(cookies, expected.slice(0, 2));
160
+ const error = Object.assign(new Error('Operation not permitted'), {
161
+ code: 'EPERM',
162
+ });
163
+ assert.ok(
164
+ await findSafariCookieFile(options.root, async () => {
165
+ throw error;
166
+ })
167
+ );
168
+ await assert.rejects(
169
+ readSafariCookieFile(options.file, {
170
+ environment: { TERM_PROGRAM: 'TestTerminal' },
171
+ readFile: async () => {
172
+ throw error;
173
+ },
174
+ }),
175
+ /Full Disk Access.*TestTerminal.*x-apple.systempreferences:com.apple.preference.security\?Privacy_AllFiles/
176
+ );
177
+ });
178
+
179
+ it('preserves unreadable source errors instead of silently treating the default as empty', async () => {
180
+ const options = await installed();
181
+ await writeFile(options.file, Buffer.from('corrupt'));
182
+ const sources = await listCookieSources({
183
+ ...options,
184
+ domains: ['github.com'],
185
+ });
186
+ assert.match(sources[0].error, /Invalid Safari binarycookies/);
187
+ await assert.rejects(
188
+ resolveImportSource({
189
+ ...options,
190
+ browser: 'default',
191
+ domains: ['github.com'],
192
+ runCommand: safariDefault,
193
+ }),
194
+ /Could not inspect the default browser.*Invalid Safari binarycookies/
195
+ );
196
+ });
197
+
198
+ it('imports through the CLI and command-stream dispatcher using the real reader', async () => {
199
+ const options = await installed();
200
+ const to = path.join(options.homeDir, 'target');
201
+ const output = new PassThrough();
202
+ const chunks = [];
203
+ output.on('data', (chunk) => chunks.push(chunk.toString()));
204
+ const exitCode = await runCli(
205
+ [
206
+ 'profile',
207
+ 'migrate',
208
+ '--from',
209
+ 'safari',
210
+ '--user-data-dir',
211
+ options.root,
212
+ '--include',
213
+ 'cookies',
214
+ '--domain',
215
+ 'github.com',
216
+ '--to',
217
+ to,
218
+ ],
219
+ {
220
+ stdout: output,
221
+ stdin: new PassThrough(),
222
+ signals: new EventEmitter(),
223
+ }
224
+ );
225
+ const report = JSON.parse(chunks.join(''));
226
+ assert.equal(exitCode, 0, JSON.stringify(report));
227
+ assert.deepEqual(report.cookies, expected.slice(0, 2));
228
+ const script = await runScript({
229
+ steps: [
230
+ {
231
+ method: 'profile.migrate',
232
+ params: {
233
+ from: 'safari',
234
+ userDataDir: options.root,
235
+ to,
236
+ include: ['cookies'],
237
+ domains: ['github.com'],
238
+ },
239
+ },
240
+ ],
241
+ });
242
+ assert.equal(script.failed, false, JSON.stringify(script));
243
+ assert.deepEqual(script.results[0].result.cookies, expected.slice(0, 2));
244
+ assert.deepEqual(await readFile(options.file), fixture);
245
+ });
246
+ });
@@ -249,11 +249,38 @@ describe('runCli', () => {
249
249
  'a.test',
250
250
  ]);
251
251
 
252
- assert.deepEqual(document.options, {
253
- from: 'chrome',
254
- include: ['cookies', 'bookmarks'],
255
- domains: ['a.test'],
256
- });
252
+ // The source browser is grouped under `from` so migrateProfile receives
253
+ // `from.browser` (a flat string here was a bug that threw).
254
+ assert.equal(document.options.from.browser, 'chrome');
255
+ assert.deepEqual(document.options.include, ['cookies', 'bookmarks']);
256
+ assert.deepEqual(document.options.domains, ['a.test']);
257
+ });
258
+
259
+ it('lists cookie sources with per-domain counts', async () => {
260
+ const { exitCode, document } = await cli([
261
+ 'cookies',
262
+ 'sources',
263
+ '--domain',
264
+ 'example.com',
265
+ ]);
266
+
267
+ assert.equal(exitCode, 0);
268
+ assert.equal(document.sources[0].browser, 'chrome');
269
+ assert.equal(document.sources[0].cookies, 7);
270
+ assert.deepEqual(document.sources[0].byDomain, { 'example.com': 1 });
271
+ });
272
+
273
+ it('lists installed profiles for profile sources', async () => {
274
+ const { exitCode, document } = await cli([
275
+ 'profile',
276
+ 'sources',
277
+ '--browser',
278
+ 'firefox',
279
+ ]);
280
+
281
+ assert.equal(exitCode, 0);
282
+ assert.equal(document.profiles[0].browser, 'firefox');
283
+ assert.equal(document.profiles[0].isDefault, true);
257
284
  });
258
285
 
259
286
  it('prints a profile snapshot report', async () => {
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The generated importable-browser support matrix (issue #114).
3
+ *
4
+ * `scripts/generate-browser-support.mjs` turns the shared catalogue
5
+ * `js/src/browser/browser-sources.json` into the table between the
6
+ * `browser-support:generated` markers in README.md. These tests pin the
7
+ * rendering against a fixture catalogue, then pin the repository itself: the
8
+ * committed README must be what the generator writes, so a browser added to
9
+ * the JSON without regenerating fails CI.
10
+ */
11
+
12
+ import assert from 'node:assert/strict';
13
+ import * as fs from 'node:fs';
14
+ import { describe, it } from 'node:test';
15
+ import { pathToFileURL } from 'node:url';
16
+
17
+ import { repoPath } from '../../helpers/repo.js';
18
+
19
+ const { BEGIN_MARKER, END_MARKER, readCatalogue, renderBrowserSupport } =
20
+ await import(pathToFileURL(repoPath('scripts/generate-browser-support.mjs')));
21
+
22
+ describe('generate-browser-support', () => {
23
+ it('renders a row per browser with per-platform availability', () => {
24
+ const table = renderBrowserSupport([
25
+ {
26
+ id: 'chrome',
27
+ family: 'chromium',
28
+ roots: { darwin: ['a'], win32: ['b'], linux: ['c'] },
29
+ },
30
+ {
31
+ id: 'arc',
32
+ family: 'chromium',
33
+ roots: { darwin: ['a'], win32: ['b'] },
34
+ },
35
+ { id: 'firefox', family: 'firefox', roots: { linux: ['c'] } },
36
+ ]);
37
+ // Columns are padded the way Prettier formats Markdown tables.
38
+ const lines = table.split('\n');
39
+ assert.equal(lines[0], '| Browser | Family | macOS | Windows | Linux |');
40
+ assert.equal(lines[2], '| chrome | Chromium | Yes | Yes | Yes |');
41
+ // A browser absent on a platform shows an em dash, not a blank cell.
42
+ assert.equal(lines[3], '| arc | Chromium | Yes | Yes | — |');
43
+ assert.equal(lines[4], '| firefox | Firefox | — | — | Yes |');
44
+ });
45
+
46
+ it('keeps the committed README in sync with the catalogue', () => {
47
+ const readme = fs
48
+ .readFileSync(repoPath('README.md'), 'utf8')
49
+ .replaceAll('\r\n', '\n');
50
+ const begin = readme.indexOf(BEGIN_MARKER);
51
+ const end = readme.indexOf(END_MARKER);
52
+ assert.ok(begin !== -1 && end > begin, 'README is missing the markers');
53
+ const between = readme.slice(begin + BEGIN_MARKER.length, end).trim();
54
+ assert.equal(between, renderBrowserSupport(readCatalogue(repoPath('.'))));
55
+ });
56
+ });