browser-commander 0.24.0 → 0.26.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.
Files changed (72) hide show
  1. package/.jscpd-baseline.json +1 -1
  2. package/CHANGELOG.md +32 -0
  3. package/README.md +7 -4
  4. package/package.json +4 -1
  5. package/src/browser/attach/snapshot.js +4 -28
  6. package/src/browser/browser-cookie-credentials.js +15 -8
  7. package/src/browser/browser-cookies.js +36 -30
  8. package/src/browser/browser-profile-files.js +31 -0
  9. package/src/browser/browser-profiles.js +11 -13
  10. package/src/browser/browser-sources.js +39 -9
  11. package/src/browser/browser-sources.json +610 -25
  12. package/src/browser/launcher.js +14 -0
  13. package/src/browser/migration/capabilities.json +117 -0
  14. package/src/browser/migration/chromium-history.sql +22 -0
  15. package/src/browser/migration/chromium-writers.js +85 -0
  16. package/src/browser/migration/cookies.js +4 -1
  17. package/src/browser/migration/data-classes.js +68 -0
  18. package/src/browser/migration/domains.js +32 -0
  19. package/src/browser/migration/firefox-history.js +106 -0
  20. package/src/browser/migration/firefox.js +39 -25
  21. package/src/browser/migration/history.js +161 -2
  22. package/src/browser/migration/index.js +229 -83
  23. package/src/browser/migration/os-crypt-keys.js +1 -5
  24. package/src/browser/migration/password-metadata.js +95 -0
  25. package/src/browser/migration/passwords.js +78 -41
  26. package/src/browser/migration/safari-import.js +100 -0
  27. package/src/browser/migration/safari.js +110 -0
  28. package/src/browser/migration/sqlite-snapshot.js +31 -65
  29. package/src/browser/migration/validation.js +48 -0
  30. package/src/browser/real-browser.js +18 -1
  31. package/src/browser/safari-access.js +45 -0
  32. package/src/browser/safari-cookies.js +39 -36
  33. package/src/browser/safari-launch.js +42 -0
  34. package/src/browser/safari-profiles.js +83 -0
  35. package/src/browser/safari-support.js +98 -0
  36. package/src/browser/storage-state.js +28 -8
  37. package/src/browser/system-browser.js +31 -143
  38. package/src/browser/webdriver.js +64 -19
  39. package/src/cli/args.js +2 -0
  40. package/src/cli/commands.js +2 -0
  41. package/src/cli/high-level-methods.js +2 -0
  42. package/src/cli/sessions.js +5 -0
  43. package/src/core/webdriver-page.js +62 -0
  44. package/src/exports.js +6 -0
  45. package/src/traces/recorder.js +5 -0
  46. package/tests/e2e/safari.e2e.test.js +104 -0
  47. package/tests/unit/browser/attach/snapshot.test.js +30 -0
  48. package/tests/unit/browser/browser-cookies.test.js +35 -0
  49. package/tests/unit/browser/catalogue-launch.test.js +93 -0
  50. package/tests/unit/browser/cookie-sources.test.js +92 -17
  51. package/tests/unit/browser/credential-catalogue.test.js +50 -0
  52. package/tests/unit/browser/firefox-cookie-expiry.test.js +79 -0
  53. package/tests/unit/browser/migration/data-classes.test.js +89 -0
  54. package/tests/unit/browser/migration/domain-isolation.test.js +212 -0
  55. package/tests/unit/browser/migration/firefox-history.test.js +130 -0
  56. package/tests/unit/browser/migration/history-metadata.test.js +74 -0
  57. package/tests/unit/browser/migration/index.test.js +16 -1
  58. package/tests/unit/browser/migration/opera-keys.test.js +22 -0
  59. package/tests/unit/browser/migration/safari-data.test.js +143 -0
  60. package/tests/unit/browser/migration/sqlite-snapshot.test.js +56 -1
  61. package/tests/unit/browser/migration/validation.test.js +63 -0
  62. package/tests/unit/browser/migration/yandex.test.js +84 -0
  63. package/tests/unit/browser/real-browser.test.js +19 -0
  64. package/tests/unit/browser/safari-cookies.test.js +26 -1
  65. package/tests/unit/browser/safari-profiles.test.js +111 -0
  66. package/tests/unit/browser/safari.test.js +192 -0
  67. package/tests/unit/browser/webdriver.test.js +48 -3
  68. package/tests/unit/cli/dispatcher.test.js +5 -1
  69. package/tests/unit/cli/main.test.js +5 -0
  70. package/tests/unit/scripts/browser-support.test.js +14 -7
  71. package/tests/unit/scripts/check-pipeline-status.test.js +33 -1
  72. package/tests/unit/scripts/migration-support.test.js +31 -0
@@ -40,6 +40,13 @@ import {
40
40
  } from './profile-directory.js';
41
41
  import { buildRealBrowserArgs } from './real-browser.js';
42
42
  import { resolveRestrictions } from './restrictions.js';
43
+ import { findBrowserSource } from './browser-sources.js';
44
+ import {
45
+ isSafariChannel,
46
+ SafariUnsupportedError,
47
+ safariLaunchError,
48
+ validateSafariOptions,
49
+ } from './safari-support.js';
43
50
 
44
51
  const require = createRequire(import.meta.url);
45
52
  const accessAsync = promisify(access);
@@ -76,6 +83,8 @@ export const CHROMEDRIVER_DEFAULT_SWITCHES = Object.freeze([
76
83
  const DRIVERS = Object.freeze({
77
84
  chrome: { name: 'chromedriver', builder: 'chrome' },
78
85
  firefox: { name: 'geckodriver', builder: 'firefox' },
86
+ safari: { name: 'safaridriver', builder: 'safari' },
87
+ 'safari-technology-preview': { name: 'safaridriver', builder: 'safari' },
79
88
  });
80
89
 
81
90
  /**
@@ -102,7 +111,7 @@ export function loadSelenium(subpath) {
102
111
  function assertBrowser(browser) {
103
112
  if (!DRIVERS[browser]) {
104
113
  throw new Error(
105
- `Unsupported WebDriver browser: ${browser}. Expected 'chrome' or 'firefox'`
114
+ `Unsupported WebDriver browser: ${browser}. Expected 'chrome', 'firefox' or 'safari'`
106
115
  );
107
116
  }
108
117
  return DRIVERS[browser];
@@ -182,6 +191,20 @@ export async function resolveWebDriverExecutable({
182
191
  managerPath,
183
192
  } = {}) {
184
193
  const { name } = assertBrowser(browser);
194
+ if (isSafariChannel(browser)) {
195
+ if (platform !== 'darwin') {
196
+ throw new SafariUnsupportedError('Safari launch outside macOS');
197
+ }
198
+ const file = driverPath ?? findBrowserSource(browser).executables.darwin[0];
199
+ if (!(await isExecutable(file, checkAccess))) {
200
+ throw new Error(`Safari WebDriver executable is not accessible: ${file}`);
201
+ }
202
+ return {
203
+ driverPath: file,
204
+ browserPath: null,
205
+ source: 'bundled-safaridriver',
206
+ };
207
+ }
185
208
  if (driverPath) {
186
209
  if (!(await isExecutable(driverPath, checkAccess))) {
187
210
  throw new Error(`WebDriver executable is not accessible: ${driverPath}`);
@@ -237,6 +260,9 @@ export async function resolveWebDriverExecutable({
237
260
  * @returns {string[]}
238
261
  */
239
262
  export function buildDriverServerArgs(browser, port) {
263
+ if (isSafariChannel(browser)) {
264
+ return ['--port', String(port)];
265
+ }
240
266
  if (assertBrowser(browser).name === 'geckodriver') {
241
267
  return ['--port', String(port), '--host', LOOPBACK_HOST];
242
268
  }
@@ -306,6 +332,16 @@ export function buildBrowserOptions({
306
332
  }) {
307
333
  let options;
308
334
  let browserArgs;
335
+ if (isSafariChannel(browser)) {
336
+ options = new selenium.webdriver.Capabilities();
337
+ options.set(
338
+ 'browserName',
339
+ browser === 'safari-technology-preview'
340
+ ? 'Safari Technology Preview'
341
+ : 'safari'
342
+ );
343
+ return { options, args: [] };
344
+ }
309
345
  if (browser === 'chrome') {
310
346
  options = new selenium.chrome.Options();
311
347
  if (debuggerAddress) {
@@ -387,7 +423,9 @@ function buildSession({ selenium, serverUrl, browser, options }) {
387
423
  const builder = new selenium.webdriver.Builder()
388
424
  .usingServer(serverUrl)
389
425
  .forBrowser(DRIVERS[browser].builder);
390
- if (browser === 'chrome') {
426
+ if (isSafariChannel(browser)) {
427
+ builder.withCapabilities(options);
428
+ } else if (browser === 'chrome') {
391
429
  builder.setChromeOptions(options);
392
430
  } else {
393
431
  builder.setFirefoxOptions(options);
@@ -462,6 +500,10 @@ export async function launchWebDriver(options = {}, dependencies = {}) {
462
500
  debuggerAddress,
463
501
  } = options;
464
502
  assertBrowser(browser);
503
+ const safari = isSafariChannel(browser);
504
+ if (safari) {
505
+ validateSafariOptions(options);
506
+ }
465
507
  if (debuggerAddress && browser !== 'chrome') {
466
508
  throw new Error('debuggerAddress is only supported by Chrome WebDriver');
467
509
  }
@@ -476,23 +518,17 @@ export async function launchWebDriver(options = {}, dependencies = {}) {
476
518
  const startProcess = dependencies.startProcess ?? defaultStartProcess;
477
519
 
478
520
  const resolved = await resolveWebDriverExecutable({
521
+ ...dependencies,
479
522
  browser,
480
523
  driverPath: requestedDriverPath,
481
524
  executablePath,
482
- runCommand: dependencies.runCommand ?? defaultRunCommand,
483
- ...(dependencies.checkAccess
484
- ? { checkAccess: dependencies.checkAccess }
485
- : {}),
486
- ...(dependencies.environment
487
- ? { environment: dependencies.environment }
488
- : {}),
489
- ...(dependencies.platform ? { platform: dependencies.platform } : {}),
490
525
  });
491
526
 
492
- const temporaryProfile = !debuggerAddress && !requestedUserDataDir;
493
- const userDataDir = debuggerAddress
494
- ? undefined
495
- : await resolveProfile(browser, requestedUserDataDir);
527
+ const temporaryProfile = !safari && !debuggerAddress && !requestedUserDataDir;
528
+ const userDataDir =
529
+ safari || debuggerAddress
530
+ ? undefined
531
+ : await resolveProfile(browser, requestedUserDataDir);
496
532
  const childEnv = driverEnvironment(restrictions, env);
497
533
 
498
534
  let driverProcess;
@@ -506,9 +542,11 @@ export async function launchWebDriver(options = {}, dependencies = {}) {
506
542
  { env: childEnv, forwardOutput: verbose }
507
543
  );
508
544
  let output = '';
509
- driverProcess.stderr?.on('data', (chunk) => {
510
- output = `${output}${chunk}`.slice(-4000);
511
- });
545
+ for (const stream of [driverProcess.stdout, driverProcess.stderr]) {
546
+ stream?.on('data', (chunk) => {
547
+ output = `${output}${chunk}`.slice(-4000);
548
+ });
549
+ }
512
550
  await waitForDriverReady(serverUrl, {
513
551
  fetchImplementation: dependencies.fetch ?? fetch,
514
552
  timeout: startupTimeout,
@@ -536,7 +574,14 @@ export async function launchWebDriver(options = {}, dependencies = {}) {
536
574
  browser,
537
575
  options: built.options,
538
576
  });
539
- const page = await createWebDriverPage(driver, dependencies.pageOptions);
577
+ // Fresh Safari sessions can report an empty URL until first navigation.
578
+ if (safari) {
579
+ await driver.get('about:blank');
580
+ }
581
+ const page = await createWebDriverPage(driver, {
582
+ ...dependencies.pageOptions,
583
+ ...(safari ? { browserName: 'safari' } : {}),
584
+ });
540
585
 
541
586
  const close = memoize(() =>
542
587
  teardown({ page, driver, driverProcess, temporaryProfile, userDataDir })
@@ -558,7 +603,7 @@ export async function launchWebDriver(options = {}, dependencies = {}) {
558
603
  };
559
604
  } catch (error) {
560
605
  await teardown({ driver, driverProcess, temporaryProfile, userDataDir });
561
- throw error;
606
+ throw safari ? safariLaunchError(error, browser) : error;
562
607
  }
563
608
  }
564
609
 
package/src/cli/args.js CHANGED
@@ -92,6 +92,8 @@ export const COMMANDS = Object.freeze({
92
92
  profile: STRING,
93
93
  'user-data-dir': STRING,
94
94
  'target-browser': STRING,
95
+ 'password-csv': STRING,
96
+ 'include-payment-cards': FLAG,
95
97
  to: STRING,
96
98
  include: STRING,
97
99
  domain: STRINGS,
@@ -216,6 +216,8 @@ async function profileMigrate(parsed, io) {
216
216
  profile: options.profile,
217
217
  userDataDir: options.userDataDir,
218
218
  targetBrowser: options.targetBrowser,
219
+ passwordCsv: options.passwordCsv,
220
+ includePaymentCards: options.includePaymentCards,
219
221
  to: options.to,
220
222
  include: options.include
221
223
  ?.split(',')
@@ -220,6 +220,8 @@ function migrateProfile(state, params) {
220
220
  include: params.include,
221
221
  domains: params.domains,
222
222
  targetBrowser: params.targetBrowser,
223
+ passwordCsv: params.passwordCsv,
224
+ includePaymentCards: params.includePaymentCards,
223
225
  });
224
226
  }
225
227
 
@@ -12,6 +12,9 @@ export const BROWSER_CHANNELS = Object.freeze({
12
12
  msedge: 'msedge',
13
13
  brave: 'brave',
14
14
  chromium: 'chromium',
15
+ safari: 'safari',
16
+ 'safari-technology-preview': 'safari-technology-preview',
17
+ 'safari-tp': 'safari-technology-preview',
15
18
  });
16
19
 
17
20
  const ENGINES = Object.freeze(['playwright', 'puppeteer', 'selenium']);
@@ -40,6 +43,7 @@ const LAUNCH_PASSTHROUGH = Object.freeze([
40
43
  'firstRun',
41
44
  'driverPath',
42
45
  'bidi',
46
+ 'seedCookies',
43
47
  ]);
44
48
 
45
49
  /** Validate an engine name, defaulting to Playwright. */
@@ -118,6 +122,7 @@ export function buildLaunchOptions(params = {}) {
118
122
  * persistent context whose `browser()` is null. Puppeteer returns a Browser.
119
123
  */
120
124
  export function sessionFromLaunch(engine, launched) {
125
+ engine = launched.engine ?? engine;
121
126
  const playwright = engine === 'playwright';
122
127
  const handle = launched.browser;
123
128
  return {
@@ -32,6 +32,7 @@ import {
32
32
  toWebDriverKey,
33
33
  } from './webdriver-conversions.js';
34
34
  import { createWebDriverEventBridge } from './webdriver-events.js';
35
+ import { SafariUnsupportedError } from '../browser/safari-support.js';
35
36
 
36
37
  const require = createRequire(import.meta.url);
37
38
 
@@ -99,6 +100,7 @@ export class WebDriverPage extends EventEmitter {
99
100
  }
100
101
  this.driver = driver;
101
102
  this.engine = 'selenium';
103
+ this.browserName = options.browserName;
102
104
  this.isWebDriverPage = true;
103
105
  this._url = 'about:blank';
104
106
  this._closed = false;
@@ -358,6 +360,57 @@ export class WebDriverPage extends EventEmitter {
358
360
  return result;
359
361
  }
360
362
 
363
+ /** Execute an asynchronous function using W3C execute/async. */
364
+ async evaluateAsync(pageFunction, ...args) {
365
+ const script = buildEvaluateScript(pageFunction);
366
+ const result = await this.driver.executeAsyncScript(
367
+ `const done = arguments[arguments.length - 1]; const args = Array.from(arguments).slice(0, -1); Promise.resolve((function(){${script}}).apply(null, args)).then(value => done({value}), error => done({error: String(error)}));`,
368
+ ...args
369
+ );
370
+ if (result.error) {
371
+ throw new Error(result.error);
372
+ }
373
+ const [value, href] = result.value;
374
+ this._url = href;
375
+ return value;
376
+ }
377
+
378
+ /** Fail before subscribing or creating files for unsupported Safari features. */
379
+ requireFeature(feature) {
380
+ if (this.browserName?.toLowerCase().startsWith('safari')) {
381
+ throw new SafariUnsupportedError(feature);
382
+ }
383
+ }
384
+
385
+ setRequestInterception() {
386
+ return Promise.resolve().then(() => {
387
+ this.requireFeature('network interception');
388
+ throw new Error('Network interception requires a supporting engine');
389
+ });
390
+ }
391
+
392
+ route() {
393
+ return this.setRequestInterception();
394
+ }
395
+
396
+ /** W3C window handles include both tabs and windows. */
397
+ async windows() {
398
+ return await this.driver.getAllWindowHandles();
399
+ }
400
+
401
+ async newWindow(type = 'tab') {
402
+ await this.driver.switchTo().newWindow(type);
403
+ this._contextId = await this.driver.getWindowHandle();
404
+ await this.syncUrl();
405
+ return this._contextId;
406
+ }
407
+
408
+ async switchToWindow(handle) {
409
+ await this.driver.switchTo().window(handle);
410
+ this._contextId = handle;
411
+ await this.syncUrl();
412
+ }
413
+
361
414
  /**
362
415
  * Puppeteer's evaluateOnNewDocument, as a BiDi preload script.
363
416
  *
@@ -626,6 +679,7 @@ export class WebDriverPage extends EventEmitter {
626
679
  * @returns {Promise<Buffer>}
627
680
  */
628
681
  async pdf(options = {}) {
682
+ this.requireFeature('PDF');
629
683
  const buffer = Buffer.from(
630
684
  await this.driver.printPage(toPrintOptions(options)),
631
685
  'base64'
@@ -709,6 +763,14 @@ export class WebDriverPage extends EventEmitter {
709
763
  * @returns {Promise<WebDriverPage>}
710
764
  */
711
765
  export async function createWebDriverPage(driver, options = {}) {
766
+ if (!options.browserName) {
767
+ const capabilities = await driver.getCapabilities();
768
+ options = {
769
+ ...options,
770
+ browserName:
771
+ capabilities?.get?.('browserName') ?? capabilities?.browserName,
772
+ };
773
+ }
712
774
  const page = new WebDriverPage(driver, options);
713
775
  await page.contextId();
714
776
  await page.syncUrl();
package/src/exports.js CHANGED
@@ -322,3 +322,9 @@ export {
322
322
  redactUrl,
323
323
  redactValue,
324
324
  } from './traces/redaction.js';
325
+
326
+ export {
327
+ SafariSetupError,
328
+ SafariUnsupportedError,
329
+ openSafariSettings,
330
+ } from './browser/safari-support.js';
@@ -93,6 +93,7 @@ function validateLinksOptions(links) {
93
93
  * @returns {Promise<Object>} The running trace
94
94
  */
95
95
  export async function startTrace(options = {}) {
96
+ requireTraceSupport(options);
96
97
  const {
97
98
  commander,
98
99
  page = commander?.page,
@@ -468,3 +469,7 @@ export async function startTrace(options = {}) {
468
469
  },
469
470
  };
470
471
  }
472
+
473
+ function requireTraceSupport(options) {
474
+ (options.page ?? options.commander?.page)?.requireFeature?.('tracing');
475
+ }
@@ -0,0 +1,104 @@
1
+ // feature-parity: safari.control@native-typed
2
+ import { it } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { createServer } from 'node:http';
5
+ import {
6
+ launchRealBrowser,
7
+ SafariUnsupportedError,
8
+ makeBrowserCommander,
9
+ } from '../../src/index.js';
10
+ import { createDispatcher } from '../../src/cli/dispatcher.js';
11
+
12
+ it(
13
+ 'Safari drives a local page in an isolated automation session',
14
+ {
15
+ skip:
16
+ process.platform !== 'darwin' || process.env.RUN_SAFARI_E2E !== 'true',
17
+ timeout: 60_000,
18
+ },
19
+ async () => {
20
+ const server = createServer((_request, response) => {
21
+ response.writeHead(200, { 'content-type': 'text/html' });
22
+ response.end(
23
+ '<title>Safari smoke</title><input id="name"><button id="go" onclick="document.querySelector(\'#out\').textContent=document.querySelector(\'#name\').value">Go</button><p id="out"></p>'
24
+ );
25
+ });
26
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
27
+ const url = `http://127.0.0.1:${server.address().port}/`;
28
+ let session;
29
+ let commander;
30
+ const cli = createDispatcher();
31
+ const phase = (message) =>
32
+ console.info(`[Safari smoke] ${new Date().toISOString()} ${message}`);
33
+ try {
34
+ phase('launch seeded session');
35
+ session = await launchRealBrowser({
36
+ channel: 'safari',
37
+ startupTimeout: 20_000,
38
+ seedCookies: [{ name: 'seed', value: 'yes', url, httpOnly: true }],
39
+ });
40
+ const { page, driver } = session;
41
+ phase('exercise page commands');
42
+ commander = makeBrowserCommander({ page });
43
+ await page.goto(url);
44
+ assert.equal(commander.engine, 'selenium');
45
+ await commander.fill({ selector: '#name', text: 'Safari' });
46
+ await commander.click({ selector: '#go' });
47
+ assert.equal(
48
+ await page.evaluate(() => document.querySelector('#out').textContent),
49
+ 'Safari'
50
+ );
51
+ assert.equal(await page.evaluateAsync(async (value) => value + 1, 4), 5);
52
+ assert.ok((await page.screenshot()).length > 100);
53
+ assert.equal((await driver.manage().getCookie('seed')).value, 'yes');
54
+ phase('exercise windows and tabs');
55
+ const initial = await driver.getWindowHandle();
56
+ for (const type of ['tab', 'window']) {
57
+ const handle = await page.newWindow(type);
58
+ assert.ok((await page.windows()).includes(handle));
59
+ await page.goto(url);
60
+ await driver.close();
61
+ await page.switchToWindow(initial);
62
+ }
63
+ await assert.rejects(() => page.pdf(), SafariUnsupportedError);
64
+ await assert.rejects(
65
+ () => page.setRequestInterception(true),
66
+ SafariUnsupportedError
67
+ );
68
+ phase('close seeded session');
69
+ await session.close();
70
+ phase('launch fresh session');
71
+ session = await launchRealBrowser({ channel: 'safari' });
72
+ await session.page.goto(url);
73
+ assert.equal(
74
+ (await session.driver.manage().getCookies()).some(
75
+ (cookie) => cookie.name === 'seed'
76
+ ),
77
+ false
78
+ );
79
+ phase('close fresh session');
80
+ await session.close();
81
+ phase('launch CLI session');
82
+ await cli.dispatch('session.launch', {
83
+ browser: 'safari',
84
+ engine: 'selenium',
85
+ });
86
+ phase('exercise CLI commands');
87
+ await cli.dispatch('page.goto', { url });
88
+ assert.equal(
89
+ (await cli.dispatch('page.eval', { expression: 'document.title' }))
90
+ .value,
91
+ 'Safari smoke'
92
+ );
93
+ } finally {
94
+ phase('close CLI session');
95
+ await cli.close();
96
+ phase('destroy commander');
97
+ await commander?.destroy();
98
+ phase('close remaining session and server');
99
+ await session?.close();
100
+ await new Promise((resolve) => server.close(resolve));
101
+ phase('cleanup complete');
102
+ }
103
+ }
104
+ );
@@ -34,6 +34,36 @@ function snapshotChrome(root, to) {
34
34
  return snapshotUserDataDir({ browser: 'chrome', userDataDir: root, to });
35
35
  }
36
36
 
37
+ it(
38
+ 'reports a locked database with snapshot guidance and leaves no target database',
39
+ { timeout: 5000 },
40
+ async () => {
41
+ const root = await makeTempDir();
42
+ const target = await makeTempDir();
43
+ const profile = path.join(root, 'Default');
44
+ await mkdir(profile);
45
+ const filename = await writeChromiumHistory(profile, 1);
46
+ const writer = new BetterSqlite3(filename);
47
+ writer.exec('BEGIN EXCLUSIVE');
48
+ try {
49
+ const report = await snapshotChrome(root, target);
50
+ assert.equal(report.copied.databases, 0);
51
+ assert.ok(
52
+ report.skipped.some(
53
+ ({ item, reason, detail }) =>
54
+ item === 'Default/History' &&
55
+ reason === 'unreadable' &&
56
+ detail.includes('Consistent SQLite snapshot unavailable')
57
+ )
58
+ );
59
+ await assert.rejects(stat(path.join(target, 'Default/History')));
60
+ } finally {
61
+ writer.exec('ROLLBACK');
62
+ writer.close();
63
+ }
64
+ }
65
+ );
66
+
37
67
  /** Write `content` to `root/relativePath`, creating the parent folders. */
38
68
  async function put(root, relativePath, content = relativePath) {
39
69
  const filePath = path.join(root, ...relativePath.split('/'));
@@ -12,6 +12,7 @@ import {
12
12
  mkdtemp,
13
13
  readFile,
14
14
  readdir,
15
+ rename,
15
16
  rm,
16
17
  stat,
17
18
  writeFile,
@@ -149,6 +150,40 @@ describe('installed browser cookie import', () => {
149
150
  }
150
151
  });
151
152
 
153
+ it('discovers and decrypts Yandex Cookies through its catalogue alias', async () => {
154
+ temporaryDirectory = await mkdtemp(
155
+ path.join(os.tmpdir(), 'bc-yandex-cookies-')
156
+ );
157
+ const host = '.yandex.ru';
158
+ const { root } = await createChromiumProfile({
159
+ homeDir: temporaryDirectory,
160
+ rows: [
161
+ {
162
+ host,
163
+ name: 'SID',
164
+ encryptedValue: encryptCbcCookie({
165
+ host,
166
+ value: 'yandex-session',
167
+ password: 'peanuts',
168
+ prefix: 'v10',
169
+ }),
170
+ },
171
+ ],
172
+ });
173
+ const yandexRoot = path.join(temporaryDirectory, '.config/yandex-browser');
174
+ await rename(root, yandexRoot);
175
+ const file = path.join(yandexRoot, 'Default/Network/Cookies');
176
+ const before = await readFile(file);
177
+ const cookies = await readBrowserCookiesWithDependencies(
178
+ { browser: 'yandex-browser', domainFilter: 'yandex.ru', cache: false },
179
+ { platform: 'linux', homeDir: temporaryDirectory, environment: {} }
180
+ );
181
+ assert.equal(cookies.length, 1);
182
+ assert.equal(cookies[0].domain, host);
183
+ assert.equal(cookies[0].value, 'yandex-session');
184
+ assert.deepEqual(await readFile(file), before);
185
+ });
186
+
152
187
  it('exports both public helpers', () => {
153
188
  assert.equal(publicListBrowserProfiles, listBrowserProfiles);
154
189
  assert.equal(publicReadBrowserCookies, readBrowserCookies);
@@ -0,0 +1,93 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'node:test';
3
+ import { mkdir, symlink } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { useTempDirectories } from '../../helpers/temp-directory.js';
6
+ import {
7
+ BROWSER_SOURCES,
8
+ resolveBrowserRoots,
9
+ } from '../../../src/browser/browser-sources.js';
10
+ import {
11
+ assertDedicatedUserDataDir,
12
+ CHANNEL_EXECUTABLE_NAMES,
13
+ } from '../../../src/browser/system-browser.js';
14
+
15
+ const temporary = useTempDirectories('bc-catalogue-protection-');
16
+
17
+ describe('catalogue launch and protection (#121)', () => {
18
+ it('protects a new profile beneath a symlink to a default root', async () => {
19
+ const homeDir = await temporary();
20
+ const options = { homeDir, environment: {} };
21
+ const root = resolveBrowserRoots('chrome', options)[0];
22
+ await mkdir(root, { recursive: true });
23
+ const alias = path.join(homeDir, 'profile-alias');
24
+ await symlink(
25
+ root,
26
+ alias,
27
+ process.platform === 'win32' ? 'junction' : 'dir'
28
+ );
29
+ assert.throws(
30
+ () =>
31
+ assertDedicatedUserDataDir(path.join(alias, 'new-profile'), options),
32
+ /dedicated.*default profile/
33
+ );
34
+ });
35
+
36
+ it('offers launch names for every supported Chromium source and alias', () => {
37
+ for (const browser of BROWSER_SOURCES.filter(
38
+ (entry) => entry.family === 'chromium'
39
+ )) {
40
+ assert.ok(CHANNEL_EXECUTABLE_NAMES[browser.id]?.length, browser.id);
41
+ for (const alias of browser.aliases ?? []) {
42
+ assert.deepEqual(
43
+ CHANNEL_EXECUTABLE_NAMES[alias],
44
+ CHANNEL_EXECUTABLE_NAMES[browser.id]
45
+ );
46
+ }
47
+ }
48
+ });
49
+
50
+ it('protects every catalogue root and descendant profile on every OS', () => {
51
+ for (const platform of ['linux', 'darwin', 'win32']) {
52
+ const options = {
53
+ platform,
54
+ homeDir: platform === 'win32' ? 'C:\\Users\\test' : '/users/test',
55
+ environment: {},
56
+ };
57
+ for (const browser of BROWSER_SOURCES) {
58
+ for (const root of resolveBrowserRoots(browser.id, options)) {
59
+ assert.throws(
60
+ () => assertDedicatedUserDataDir(root, options),
61
+ /dedicated.*default profile/,
62
+ `${browser.id}: ${root}`
63
+ );
64
+ assert.throws(
65
+ () =>
66
+ assertDedicatedUserDataDir(
67
+ `${root}${platform === 'win32' ? '\\' : '/'}Profile 1`,
68
+ options
69
+ ),
70
+ /dedicated.*default profile/
71
+ );
72
+ }
73
+ }
74
+ }
75
+ });
76
+
77
+ it('adds the requested desktop catalogue entries', () => {
78
+ for (const id of [
79
+ 'whale',
80
+ '360se',
81
+ '360chrome',
82
+ 'qq',
83
+ 'sogou',
84
+ 'duckduckgo',
85
+ 'tor',
86
+ ]) {
87
+ assert.ok(
88
+ BROWSER_SOURCES.some((entry) => entry.id === id),
89
+ id
90
+ );
91
+ }
92
+ });
93
+ });