browser-commander 0.22.0 → 0.24.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
+ });
@@ -272,7 +272,7 @@ describe('dispatcher: high-level methods', () => {
272
272
  await assert.rejects(dispatcher.dispatch('nope'), { code: -32601 });
273
273
  await assert.rejects(dispatcher.dispatch('version', []), { code: -32602 });
274
274
  await assert.rejects(
275
- dispatcher.dispatch('session.launch', { engine: 'selenium' }),
275
+ dispatcher.dispatch('session.launch', { engine: 'unknown' }),
276
276
  { code: -32602 }
277
277
  );
278
278
  await assert.rejects(
@@ -351,7 +351,7 @@ describe('dispatcher: generic handle methods', () => {
351
351
  'launched with puppeteer'
352
352
  );
353
353
  await assert.rejects(
354
- dispatcher.dispatch('handle.root', { name: 'selenium' }),
354
+ dispatcher.dispatch('handle.root', { name: 'unknown' }),
355
355
  { code: -32602 }
356
356
  );
357
357
  });
@@ -0,0 +1,186 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'node:test';
3
+ import { createDispatcher } from '../../../src/cli/dispatcher.js';
4
+ import { buildLaunchOptions } from '../../../src/cli/sessions.js';
5
+ import { launchBrowserWithDependencies } from '../../../src/browser/launcher.js';
6
+ import { connectBrowserWithDependencies } from '../../../src/browser/connector.js';
7
+
8
+ // feature-parity: engines.webdriver@native-typed engines.cli-matrix@native-typed
9
+
10
+ describe('Selenium uses the common engine entry points', () => {
11
+ it('restores portable cookies and origin state through the common launcher', async () => {
12
+ const visited = [];
13
+ const seeded = [];
14
+ const page = {
15
+ url: () => 'about:blank',
16
+ goto: async (url) => {
17
+ visited.push(url);
18
+ },
19
+ setCookie: async (...cookies) => {
20
+ seeded.push(...cookies);
21
+ },
22
+ evaluateOnNewDocument: async () => {},
23
+ evaluate: async () => {},
24
+ bringToFront: async () => {},
25
+ };
26
+ const cookie = {
27
+ name: 'session',
28
+ value: 'value',
29
+ domain: 'example.com',
30
+ path: '/',
31
+ secure: true,
32
+ };
33
+ const result = await launchBrowserWithDependencies(
34
+ {
35
+ engine: 'selenium',
36
+ launch: 'engine',
37
+ storageState: {
38
+ cookies: [cookie],
39
+ origins: [
40
+ {
41
+ origin: 'https://example.com',
42
+ localStorage: [{ name: 'theme', value: 'dark' }],
43
+ },
44
+ ],
45
+ },
46
+ },
47
+ {
48
+ launchWebDriver: async () => ({
49
+ driver: {},
50
+ page,
51
+ close: async () => {},
52
+ }),
53
+ settleMs: 0,
54
+ }
55
+ );
56
+ assert.deepEqual(seeded, [cookie]);
57
+ assert.deepEqual(visited, ['https://example.com', 'about:blank']);
58
+ await result.close();
59
+ });
60
+ it('preserves WebDriver browser and driver options in the CLI', () => {
61
+ assert.deepEqual(
62
+ buildLaunchOptions({
63
+ engine: 'selenium',
64
+ browser: 'firefox',
65
+ driverPath: '/driver',
66
+ bidi: true,
67
+ }),
68
+ {
69
+ engine: 'selenium',
70
+ headless: false,
71
+ browser: 'firefox',
72
+ driverPath: '/driver',
73
+ bidi: true,
74
+ }
75
+ );
76
+ });
77
+
78
+ it('launches through the native driver without loading a CDP engine', async () => {
79
+ const page = { bringToFront: async () => {} };
80
+ const driver = {};
81
+ let options;
82
+ let closed = false;
83
+ const result = await launchBrowserWithDependencies(
84
+ { engine: 'selenium', launch: 'engine', extraArgs: ['--no-sandbox'] },
85
+ {
86
+ launchWebDriver: async (value) => {
87
+ options = value;
88
+ return {
89
+ driver,
90
+ page,
91
+ close: async () => {
92
+ closed = true;
93
+ },
94
+ };
95
+ },
96
+ settleMs: 0,
97
+ }
98
+ );
99
+ assert.equal(result.browser, driver);
100
+ assert.equal(result.page, page);
101
+ assert.deepEqual(options.args, ['--no-sandbox']);
102
+ await result.close();
103
+ assert.equal(closed, true);
104
+ });
105
+
106
+ it('connects to a Selenium Grid through the common connector', async () => {
107
+ const driver = {};
108
+ const page = {};
109
+ let options;
110
+ const result = await connectBrowserWithDependencies(
111
+ {
112
+ engine: 'selenium',
113
+ serverUrl: 'http://grid:4444',
114
+ capabilities: { browserName: 'firefox' },
115
+ },
116
+ {
117
+ connectWebDriver: async (value) => {
118
+ options = value;
119
+ return { driver, page, close: async () => {} };
120
+ },
121
+ }
122
+ );
123
+ assert.equal(result.browser, driver);
124
+ assert.equal(options.capabilities.browserName, 'firefox');
125
+ });
126
+
127
+ it('exposes the raw Selenium module and driver over generic handles', async () => {
128
+ const driver = { getTitle: async () => 'native driver' };
129
+ const page = {
130
+ waitForSelector: async () => ({
131
+ clear: async () => {},
132
+ sendKeys: async () => {},
133
+ }),
134
+ url: () => 'about:blank',
135
+ };
136
+ const dispatcher = createDispatcher({
137
+ dependencies: {
138
+ loadEngine: async (name) => {
139
+ assert.equal(name, 'selenium');
140
+ return { Builder: class Builder {} };
141
+ },
142
+ launchBrowser: async () => ({
143
+ browser: driver,
144
+ driver,
145
+ page,
146
+ close: async () => {},
147
+ }),
148
+ },
149
+ });
150
+ try {
151
+ const root = await dispatcher.dispatch('handle.root', {
152
+ name: 'selenium',
153
+ });
154
+ assert.ok(root.$handle);
155
+ const builder = await dispatcher.dispatch('handle.get', {
156
+ handle: root,
157
+ property: 'Builder',
158
+ });
159
+ const instance = await dispatcher.dispatch('handle.construct', {
160
+ handle: builder,
161
+ });
162
+ assert.equal(
163
+ (await dispatcher.dispatch('handle.describe', { handle: instance }))
164
+ .type,
165
+ 'Builder'
166
+ );
167
+ await dispatcher.dispatch('session.launch', { engine: 'selenium' });
168
+ await dispatcher.dispatch('page.fill', {
169
+ selector: '#name',
170
+ value: 'Ada',
171
+ });
172
+ const handles = await dispatcher.dispatch('handle.root', {
173
+ name: 'session:s1',
174
+ });
175
+ assert.equal(
176
+ await dispatcher.dispatch('handle.call', {
177
+ handle: handles.browser,
178
+ method: 'getTitle',
179
+ }),
180
+ 'native driver'
181
+ );
182
+ } finally {
183
+ await dispatcher.close();
184
+ }
185
+ });
186
+ });
@@ -26,12 +26,13 @@ describe('browser-commander/tests', () => {
26
26
  assert.deepStrictEqual(normalizeBrowserTestEngines(), [
27
27
  'playwright',
28
28
  'puppeteer',
29
+ 'selenium',
29
30
  ]);
30
31
  assert.deepStrictEqual(normalizeBrowserTestEngines('Playwright'), [
31
32
  'playwright',
32
33
  ]);
33
34
  assert.throws(
34
- () => normalizeBrowserTestEngines('selenium'),
35
+ () => normalizeBrowserTestEngines('unknown'),
35
36
  /Unsupported browser test engine/
36
37
  );
37
38
  });
@@ -544,8 +544,8 @@ describe('trace links export (issue #94)', () => {
544
544
  // The notation's formatter and parser decide whether an export can be read
545
545
  // back, so the range this package depends on and the version the tests
546
546
  // proved are the same range.
547
- assert.strictEqual(manifest.dependencies['links-notation'], '^0.20.0');
548
- assert.match(installed.version, /^0\.20\./);
547
+ assert.strictEqual(manifest.dependencies['links-notation'], '^0.22.0');
548
+ assert.match(installed.version, /^0\.22\./);
549
549
  });
550
550
 
551
551
  it('should export a bundle named by path as readily as an open one', async () => {