browser-commander 0.33.0 → 0.33.1

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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.33.1
4
+
5
+ ### Patch Changes
6
+
7
+ - d75bce4: Selenium drives Chrome for Testing with the ChromeDriver of its own release (issue #179): a `chromedriver` on `PATH` belongs to the installed Google Chrome and is skipped, Selenium Manager is asked with `--skip-driver-in-path`, and the real-browser launch and the hand-off reattach give ChromeDriver the browser that started. `defaultRestrictionsFor()` is exported.
8
+
9
+ `migrateFrom` re-encrypts passwords with the key of the browser that starts (issue #179): Chrome for Testing reads "Chromium Safe Storage", and with its default `mock-keychain` restriction (or `--password-store=basic` on Linux) Chromium's fixed mock password, so migrated passwords decrypt in it. macOS logins are written with the `v10` prefix Chromium uses there.
10
+
3
11
  ## 0.33.0
4
12
 
5
13
  ### Minor Changes
package/README.md CHANGED
@@ -412,6 +412,10 @@ with.
412
412
 
413
413
  #### Chrome for Testing by default
414
414
 
415
+ See [Chrome for Testing by default](../docs/chrome-for-testing.md) for the
416
+ download each engine starts, its matching ChromeDriver and the Ubuntu sandbox
417
+ note.
418
+
415
419
  Without `channel` or `executablePath`, a launch starts Chrome for Testing - the
416
420
  one Playwright or Puppeteer downloaded, or else another build in its download
417
421
  cache (`ms-playwright`, moved by `PLAYWRIGHT_BROWSERS_PATH`; `~/.cache/puppeteer`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-commander",
3
- "version": "0.33.0",
3
+ "version": "0.33.1",
4
4
  "description": "Universal browser automation library that supports Playwright, Puppeteer, and Selenium with a unified API",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -249,6 +249,10 @@ export async function reconnectBrowserWithDependencies(
249
249
  cdpEndpoint: descriptor.cdpEndpoint,
250
250
  targetId: descriptor.targetId,
251
251
  fallback: false,
252
+ // ChromeDriver must match the browser it reattaches to (issue #179).
253
+ ...(engine === 'selenium' && descriptor.executablePath
254
+ ? { executablePath: descriptor.executablePath }
255
+ : {}),
252
256
  });
253
257
  } catch (error) {
254
258
  throw new BrowserHandoffError(
@@ -218,7 +218,7 @@ export async function migrateFirefoxPasswords({
218
218
  targetProfileDir,
219
219
  platform,
220
220
  targetKey,
221
- targetPrefix = platform === 'win32' ? 'v10' : 'v11',
221
+ targetPrefix = platform === 'linux' ? 'v11' : 'v10',
222
222
  primaryPassword = Buffer.alloc(0),
223
223
  domains,
224
224
  }) {
@@ -122,6 +122,7 @@ async function resolvePasswordKeys({
122
122
  keys,
123
123
  browser,
124
124
  targetBrowser,
125
+ targetKeystore,
125
126
  platform,
126
127
  sourceProfileDir,
127
128
  environment,
@@ -135,6 +136,7 @@ async function resolvePasswordKeys({
135
136
  const { key: targetKey, prefix: targetPrefix } = await resolveTargetKey({
136
137
  browser: targetBrowser,
137
138
  platform,
139
+ keystore: targetKeystore,
138
140
  environment,
139
141
  });
140
142
  const resolveSourceKey = isChromiumBrowser(browser)
@@ -159,6 +161,7 @@ async function resolvePasswordKeys({
159
161
  * @param {boolean} [options.includePaymentCards=false] - Separate explicit payment-card consent
160
162
  * @param {string} [options.platform=process.platform]
161
163
  * @param {string} [options.targetBrowser] - Launching browser channel (key derivation)
164
+ * @param {'mock'|'os'} [options.targetKeystore='os'] - 'mock' when the launch bypasses the OS keystore (`targetKeystoreFor`)
162
165
  * @param {Object} [options.keys] - Injected password keys {resolveSourceKey, targetKey, targetPrefix, primaryPassword}
163
166
  * @param {string} [options.homeDir]
164
167
  * @param {Object} [options.environment]
@@ -171,6 +174,7 @@ export async function migrateProfile({
171
174
  domains,
172
175
  platform = process.platform,
173
176
  targetBrowser,
177
+ targetKeystore,
174
178
  keys,
175
179
  homeDir = os.homedir(),
176
180
  environment = process.env,
@@ -272,6 +276,7 @@ export async function migrateProfile({
272
276
  keys,
273
277
  browser,
274
278
  targetBrowser,
279
+ targetKeystore,
275
280
  platform,
276
281
  sourceProfileDir,
277
282
  environment,
@@ -352,6 +357,7 @@ export async function migrateProfile({
352
357
  keys,
353
358
  browser,
354
359
  targetBrowser,
360
+ targetKeystore,
355
361
  platform,
356
362
  sourceProfileDir,
357
363
  environment,
@@ -377,6 +383,7 @@ async function migrateSafariProfile({
377
383
  selected,
378
384
  sourceProfileDir,
379
385
  targetBrowser,
386
+ targetKeystore,
380
387
  to,
381
388
  }) {
382
389
  const passwordKeys =
@@ -385,6 +392,7 @@ async function migrateSafariProfile({
385
392
  keys,
386
393
  browser,
387
394
  targetBrowser: targetBrowser ?? 'chrome',
395
+ targetKeystore,
388
396
  platform,
389
397
  sourceProfileDir,
390
398
  environment,
@@ -442,6 +450,7 @@ async function migratePasswordClass(
442
450
  platform,
443
451
  sourceProfileDir,
444
452
  targetBrowser,
453
+ targetKeystore,
445
454
  to,
446
455
  }
447
456
  ) {
@@ -464,6 +473,7 @@ async function migratePasswordClass(
464
473
  keys,
465
474
  browser,
466
475
  targetBrowser: targetBrowser ?? (isFirefox ? 'chrome' : browser),
476
+ targetKeystore,
467
477
  platform,
468
478
  sourceProfileDir,
469
479
  environment,
@@ -73,12 +73,34 @@ export function createSourceKeyResolver({
73
73
  };
74
74
  }
75
75
 
76
+ /**
77
+ * Which keystore a launch encrypts its new profile with: `'mock'` when the
78
+ * launch switches bypass the OS keystore (`--use-mock-keychain` on macOS, as
79
+ * Chrome for Testing gets by default, or `--password-store=basic` on Linux),
80
+ * `'os'` otherwise.
81
+ *
82
+ * @param {string[]} args - The browser command-line switches
83
+ * @param {string} platform
84
+ * @returns {'mock'|'os'}
85
+ */
86
+ export function targetKeystoreFor(args, platform) {
87
+ const bypass =
88
+ platform === 'darwin' ? '--use-mock-keychain' : '--password-store=basic';
89
+ return platform !== 'win32' && args.includes(bypass) ? 'mock' : 'os';
90
+ }
91
+
76
92
  /**
77
93
  * Resolve the target (launching) profile's encryption key on macOS/Linux.
78
94
  *
95
+ * macOS Chromium only knows the `v10` tag; Linux tags keyring keys `v11` and
96
+ * its fixed basic-store key `v10`. A `'mock'` keystore uses Chromium's fixed
97
+ * mock passwords: `mock_password` for the mock keychain, `peanuts` for the
98
+ * basic store.
99
+ *
79
100
  * @param {Object} options
80
101
  * @param {string} options.browser - Launching browser channel
81
102
  * @param {string} options.platform
103
+ * @param {'mock'|'os'} [options.keystore='os'] - See `targetKeystoreFor`
82
104
  * @param {Object} [options.environment]
83
105
  * @param {Function} [options.readSafeStoragePassword]
84
106
  * @returns {Promise<{key: Buffer, prefix: string}>}
@@ -86,16 +108,24 @@ export function createSourceKeyResolver({
86
108
  export async function resolveTargetKey({
87
109
  browser,
88
110
  platform,
111
+ keystore = 'os',
89
112
  environment = process.env,
90
113
  readSafeStoragePassword: readSafeStorage = readSafeStoragePassword,
91
114
  }) {
92
- if (platform === 'darwin' || platform === 'linux') {
93
- const password = await readSafeStorage({ browser, platform, environment });
94
- return { key: deriveChromiumCookieKey(password, platform), prefix: 'v11' };
115
+ if (platform !== 'darwin' && platform !== 'linux') {
116
+ throw new Error(
117
+ `resolveTargetKey does not derive a Windows key; use createWindowsProfileKey`
118
+ );
95
119
  }
96
- throw new Error(
97
- `resolveTargetKey does not derive a Windows key; use createWindowsProfileKey`
98
- );
120
+ if (keystore === 'mock') {
121
+ const password = platform === 'darwin' ? 'mock_password' : 'peanuts';
122
+ return { key: deriveChromiumCookieKey(password, platform), prefix: 'v10' };
123
+ }
124
+ const password = await readSafeStorage({ browser, platform, environment });
125
+ return {
126
+ key: deriveChromiumCookieKey(password, platform),
127
+ prefix: platform === 'darwin' ? 'v10' : 'v11',
128
+ };
99
129
  }
100
130
 
101
131
  /** Convenience: the Local State path next to a profile directory. */
@@ -108,7 +108,7 @@ export async function migratePasswords({
108
108
  platform = process.platform,
109
109
  resolveSourceKey,
110
110
  targetKey,
111
- targetPrefix = platform === 'win32' ? 'v10' : 'v11',
111
+ targetPrefix = platform === 'linux' ? 'v11' : 'v10',
112
112
  domains,
113
113
  }) {
114
114
  const sourcePath = path.join(sourceProfileDir, 'Login Data');
@@ -23,6 +23,7 @@ import {
23
23
  } from './attach/snapshot-launch.js';
24
24
  import { connectBrowser } from './connector.js';
25
25
  import { migrateProfile } from './migration/index.js';
26
+ import { targetKeystoreFor } from './migration/os-crypt-keys.js';
26
27
  import {
27
28
  assertFixedDebuggingPort,
28
29
  classifyDevToolsOwnership,
@@ -50,6 +51,7 @@ import {
50
51
  } from './system-browser.js';
51
52
  import {
52
53
  defaultRestrictionsFor,
54
+ isChromeForTesting,
53
55
  resolveLaunchExecutable,
54
56
  } from './launch-executable.js';
55
57
 
@@ -372,10 +374,12 @@ function createCloser({
372
374
 
373
375
  /**
374
376
  * Selenium attaches through a chromedriver the launch starts; the driver
375
- * follows the browser's interruption ownership (issue #174).
377
+ * follows the browser's interruption ownership (issue #174) and must match
378
+ * the browser just started, not the installed Chrome Selenium Manager would
379
+ * otherwise pick (issue #179).
376
380
  */
377
- function driverInterruption(engine, interruption) {
378
- return engine === 'selenium' ? { interruption } : {};
381
+ function driverOptions(engine, { interruption, executablePath }) {
382
+ return engine === 'selenium' ? { interruption, executablePath } : {};
379
383
  }
380
384
 
381
385
  /**
@@ -602,6 +606,9 @@ async function runPreLaunchMigration({
602
606
  migrateFrom,
603
607
  userDataDir,
604
608
  channel,
609
+ executablePath,
610
+ argOptions,
611
+ platform,
605
612
  temporaryProfile,
606
613
  migrate,
607
614
  }) {
@@ -610,6 +617,14 @@ async function runPreLaunchMigration({
610
617
  }
611
618
  const { include, domains, passwordCsv, includePaymentCards, ...from } =
612
619
  migrateFrom;
620
+ // Passwords are re-encrypted with the key of the browser that starts:
621
+ // Chrome for Testing keeps "Chromium Safe Storage", and its default mock
622
+ // keychain uses Chromium's fixed mock password (issue #179).
623
+ const launchArgs = [
624
+ ...resolveRestrictions(argOptions.restrictions).args,
625
+ ...argOptions.args,
626
+ ...argOptions.extraArgs,
627
+ ];
613
628
  try {
614
629
  const report = await migrate({
615
630
  from,
@@ -618,7 +633,8 @@ async function runPreLaunchMigration({
618
633
  domains,
619
634
  passwordCsv,
620
635
  includePaymentCards,
621
- targetBrowser: channel,
636
+ targetBrowser: isChromeForTesting(executablePath) ? 'chromium' : channel,
637
+ targetKeystore: targetKeystoreFor(launchArgs, platform),
622
638
  });
623
639
  const { cookies, ...migration } = report;
624
640
  return { migration, migratedCookies: cookies ?? [] };
@@ -753,6 +769,9 @@ export async function launchAndConnectRealBrowserWithDependencies(
753
769
  migrateFrom,
754
770
  userDataDir,
755
771
  channel,
772
+ executablePath,
773
+ argOptions,
774
+ platform: dependencies.platform ?? process.platform,
756
775
  temporaryProfile,
757
776
  migrate: dependencies.migrateProfile ?? migrateProfile,
758
777
  });
@@ -806,7 +825,7 @@ export async function launchAndConnectRealBrowserWithDependencies(
806
825
  engine,
807
826
  cdpEndpoint: resolvedCdpEndpoint,
808
827
  ...connectionOptions,
809
- ...driverInterruption(engine, interruption),
828
+ ...driverOptions(engine, { interruption, executablePath }),
810
829
  seedCookies,
811
830
  verbose,
812
831
  });
@@ -42,7 +42,10 @@ import {
42
42
  } from './profile-directory.js';
43
43
  import { buildRealBrowserArgs } from './real-browser.js';
44
44
  import { resolveRestrictions } from './restrictions.js';
45
- import { defaultRestrictionsFor } from './launch-executable.js';
45
+ import {
46
+ defaultRestrictionsFor,
47
+ isChromeForTesting,
48
+ } from './launch-executable.js';
46
49
  import { findBrowserSource } from './browser-sources.js';
47
50
  import {
48
51
  isSafariChannel,
@@ -219,7 +222,12 @@ export async function resolveWebDriverExecutable({
219
222
  };
220
223
  }
221
224
 
222
- const onPath = await findOnPath(name, { environment, platform, checkAccess });
225
+ // A driver on PATH belongs to an installed browser; Chrome for Testing gets
226
+ // the driver of its own release from Selenium Manager (issue #179).
227
+ const ownDriver = isChromeForTesting(executablePath);
228
+ const onPath =
229
+ !ownDriver &&
230
+ (await findOnPath(name, { environment, platform, checkAccess }));
223
231
  if (onPath) {
224
232
  return {
225
233
  driverPath: onPath,
@@ -228,11 +236,39 @@ export async function resolveWebDriverExecutable({
228
236
  };
229
237
  }
230
238
 
239
+ return await askSeleniumManager({
240
+ browser,
241
+ name,
242
+ executablePath,
243
+ ownDriver,
244
+ environment,
245
+ platform,
246
+ runCommand,
247
+ managerPath,
248
+ });
249
+ }
250
+
251
+ /** Ask Selenium Manager for the driver of `browser` (and the browser itself). */
252
+ async function askSeleniumManager({
253
+ browser,
254
+ name,
255
+ executablePath,
256
+ ownDriver,
257
+ environment,
258
+ platform,
259
+ runCommand,
260
+ managerPath,
261
+ }) {
231
262
  const manager = managerPath ?? seleniumManagerPath({ platform, environment });
232
- const args = ['--browser', browser, '--output', 'json'];
233
- if (executablePath) {
234
- args.push('--browser-path', executablePath);
235
- }
263
+ const args = [
264
+ '--browser',
265
+ browser,
266
+ '--output',
267
+ 'json',
268
+ ...(executablePath ? ['--browser-path', executablePath] : []),
269
+ // Selenium Manager itself prefers a driver on PATH, whatever its release.
270
+ ...(ownDriver ? ['--skip-driver-in-path'] : []),
271
+ ];
236
272
  const { stdout, code } = await runCommand(manager, args, { check: false });
237
273
  let output;
238
274
  try {
package/src/exports.js CHANGED
@@ -106,6 +106,7 @@ export {
106
106
  } from './browser/launcher.js';
107
107
  export {
108
108
  CHROME_FOR_TESTING_RESTRICTIONS,
109
+ defaultRestrictionsFor,
109
110
  isChromeForTesting,
110
111
  } from './browser/launch-executable.js';
111
112
  export {
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Issue #179: without a channel or executablePath every engine starts Chrome
3
+ * for Testing, hides its "only for automated testing" infobar and keeps it
4
+ * away from the user's keychain.
5
+ */
6
+ // feature-parity: launch.chrome-for-testing@native-typed
7
+ import assert from 'node:assert/strict';
8
+ import { describe, it } from 'node:test';
9
+
10
+ import { isChromeForTesting } from '../../src/browser/launch-executable.js';
11
+ import { launchBrowser } from '../../src/browser/launcher.js';
12
+ import { readBrowserVersionPage } from '../../src/browser/parity.js';
13
+ import { SANDBOX_ARGS } from '../helpers/e2e-browser.js';
14
+
15
+ function skipReason() {
16
+ if (!process.env.RUN_E2E) {
17
+ return 'set RUN_E2E=true';
18
+ }
19
+ if (process.platform === 'linux' && !process.env.DISPLAY) {
20
+ return 'the infobar is only drawn in a window; run under xvfb-run';
21
+ }
22
+ return false;
23
+ }
24
+
25
+ /** What chrome://version reports about the browser a session started. */
26
+ async function versionPage(engine, session) {
27
+ if (engine !== 'selenium') {
28
+ return await readBrowserVersionPage(session.page);
29
+ }
30
+ await session.browser.get('chrome://version');
31
+ return await session.browser.executeScript(`
32
+ const text = (id) => (document.getElementById(id)?.textContent ?? '').trim();
33
+ return { commandLine: text('command_line'), executablePath: text('executable_path') };
34
+ `);
35
+ }
36
+
37
+ /** Height of the browser UI above the page: tab strip, toolbar, infobars. */
38
+ async function frameHeight(session) {
39
+ await session.page.goto('data:text/html,<title>frame</title>');
40
+ // The infobar appears just after the first window is shown.
41
+ await new Promise((resolve) => setTimeout(resolve, 1500));
42
+ return await session.page.evaluate(
43
+ () => globalThis.outerHeight - globalThis.innerHeight
44
+ );
45
+ }
46
+
47
+ describe(
48
+ 'Chrome for Testing by default (issue #179)',
49
+ { skip: skipReason(), timeout: 180000 },
50
+ () => {
51
+ for (const engine of ['playwright', 'puppeteer', 'selenium']) {
52
+ it(`${engine} starts Chrome for Testing without its infobar or keychain`, async () => {
53
+ const session = await launchBrowser({
54
+ engine,
55
+ headless: false,
56
+ args: SANDBOX_ARGS,
57
+ });
58
+ try {
59
+ const { commandLine, executablePath } = await versionPage(
60
+ engine,
61
+ session
62
+ );
63
+ assert.equal(
64
+ isChromeForTesting(executablePath),
65
+ true,
66
+ executablePath
67
+ );
68
+ assert.deepEqual(session.defaultRestrictions, [
69
+ 'no-chrome-for-testing-infobar',
70
+ 'mock-keychain',
71
+ ]);
72
+ assert.match(commandLine, /--test-type=gpu(\s|$)/u);
73
+ assert.match(commandLine, /--use-mock-keychain(\s|$)/u);
74
+ } finally {
75
+ await session.close();
76
+ }
77
+ });
78
+ }
79
+
80
+ it('the default restrictions remove the infobar the opt-out shows', async () => {
81
+ const heights = {};
82
+ for (const [name, defaultRestrictions] of [
83
+ ['defaults', undefined],
84
+ ['opt-out', []],
85
+ ]) {
86
+ const session = await launchBrowser({
87
+ engine: 'playwright',
88
+ headless: false,
89
+ args: SANDBOX_ARGS,
90
+ ...(defaultRestrictions ? { defaultRestrictions } : {}),
91
+ });
92
+ try {
93
+ heights[name] = await frameHeight(session);
94
+ } finally {
95
+ await session.close();
96
+ }
97
+ }
98
+ // The infobar is about 40px tall; nothing else differs between the two.
99
+ assert.ok(
100
+ heights['opt-out'] - heights.defaults >= 20,
101
+ JSON.stringify(heights)
102
+ );
103
+ });
104
+ }
105
+ );
@@ -109,6 +109,19 @@ describe('browser hand-off (issue #174)', () => {
109
109
  assert.deepEqual(await session.handoff(), handoffFor(endpoint));
110
110
  });
111
111
 
112
+ it('reattaches Selenium with the driver of the handed-off browser (issue #179)', async () => {
113
+ const endpoint = await fakeEndpoint();
114
+ const cft =
115
+ '/home/u/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome';
116
+ const calls = [];
117
+ await reconnectBrowserWithDependencies(
118
+ handoffFor(endpoint, { executablePath: cft }),
119
+ {},
120
+ { connect: fakeConnect(calls) }
121
+ );
122
+ assert.equal(calls[0][1].executablePath, cft);
123
+ });
124
+
112
125
  it('reads a hand-off from a JSON file', async () => {
113
126
  const endpoint = await fakeEndpoint();
114
127
  const directory = await mkdtemp(path.join(os.tmpdir(), 'bc-handoff-'));
@@ -89,5 +89,6 @@ describe('defaultRestrictionsFor (issue #179)', () => {
89
89
  'mock-keychain',
90
90
  ]);
91
91
  assert.equal(api.isChromeForTesting, isChromeForTesting);
92
+ assert.equal(api.defaultRestrictionsFor, defaultRestrictionsFor);
92
93
  });
93
94
  });
@@ -6,6 +6,7 @@ import {
6
6
  createSourceKeyResolver,
7
7
  localStatePathForProfile,
8
8
  resolveTargetKey,
9
+ targetKeystoreFor,
9
10
  } from '../../../../src/browser/migration/os-crypt-keys.js';
10
11
  import { deriveChromiumCookieKey } from '../../../../src/browser/browser-cookie-crypto.js';
11
12
 
@@ -77,6 +78,45 @@ describe('resolveTargetKey', () => {
77
78
  assert.equal(result.prefix, 'v11');
78
79
  });
79
80
 
81
+ it('tags macOS keys v10, the only tag macOS Chromium reads', async () => {
82
+ const result = await resolveTargetKey({
83
+ browser: 'chromium',
84
+ platform: 'darwin',
85
+ readSafeStoragePassword: async () => 'target-pass',
86
+ });
87
+ assert.deepEqual(
88
+ result.key,
89
+ deriveChromiumCookieKey('target-pass', 'darwin')
90
+ );
91
+ assert.equal(result.prefix, 'v10');
92
+ });
93
+
94
+ it("uses Chromium's fixed mock passwords for a mock keystore (issue #179)", async () => {
95
+ const unreadable = async () => {
96
+ throw new Error('the OS keystore must not be asked');
97
+ };
98
+ const mac = await resolveTargetKey({
99
+ browser: 'chromium',
100
+ platform: 'darwin',
101
+ keystore: 'mock',
102
+ readSafeStoragePassword: unreadable,
103
+ });
104
+ assert.deepEqual(mac, {
105
+ key: deriveChromiumCookieKey('mock_password', 'darwin'),
106
+ prefix: 'v10',
107
+ });
108
+ const linux = await resolveTargetKey({
109
+ browser: 'chrome',
110
+ platform: 'linux',
111
+ keystore: 'mock',
112
+ readSafeStoragePassword: unreadable,
113
+ });
114
+ assert.deepEqual(linux, {
115
+ key: deriveChromiumCookieKey('peanuts', 'linux'),
116
+ prefix: 'v10',
117
+ });
118
+ });
119
+
80
120
  it('refuses to derive a Windows key', async () => {
81
121
  await assert.rejects(
82
122
  resolveTargetKey({ browser: 'chrome', platform: 'win32' }),
@@ -85,6 +125,23 @@ describe('resolveTargetKey', () => {
85
125
  });
86
126
  });
87
127
 
128
+ describe('targetKeystoreFor (issue #179)', () => {
129
+ it('reports the switches that bypass the OS keystore', () => {
130
+ assert.equal(targetKeystoreFor(['--use-mock-keychain'], 'darwin'), 'mock');
131
+ assert.equal(
132
+ targetKeystoreFor(['--password-store=basic'], 'linux'),
133
+ 'mock'
134
+ );
135
+ assert.equal(targetKeystoreFor(['--use-mock-keychain'], 'linux'), 'os');
136
+ assert.equal(
137
+ targetKeystoreFor(['--password-store=gnome-libsecret'], 'linux'),
138
+ 'os'
139
+ );
140
+ assert.equal(targetKeystoreFor([], 'darwin'), 'os');
141
+ assert.equal(targetKeystoreFor(['--use-mock-keychain'], 'win32'), 'os');
142
+ });
143
+ });
144
+
88
145
  describe('localStatePathForProfile', () => {
89
146
  it('points at Local State next to the profile directory', () => {
90
147
  assert.equal(
@@ -24,6 +24,24 @@ const fixture = (name) =>
24
24
  )
25
25
  );
26
26
 
27
+ function migratedPasswords(profile) {
28
+ const db = new Database(path.join(profile, 'Login Data'));
29
+ try {
30
+ return db.prepare('SELECT origin_url,password_value FROM logins').all();
31
+ } finally {
32
+ db.close();
33
+ }
34
+ }
35
+
36
+ const decryptPassword = (row, key, platform) =>
37
+ decryptChromiumCookie({
38
+ encryptedValue: row.password_value,
39
+ key,
40
+ platform,
41
+ databaseVersion: 0,
42
+ host: 'github.com',
43
+ });
44
+
27
45
  describe('Safari data sources', () => {
28
46
  for (const format of ['binary', 'xml']) {
29
47
  it(`preserves hierarchy and reading list from ${format} plist`, async () => {
@@ -103,25 +121,31 @@ describe('Safari data sources', () => {
103
121
  ({ reason }) => reason === 'safari-reading-list-translated'
104
122
  )
105
123
  );
106
- const db = new Database(path.join(target, 'Login Data'));
107
- try {
108
- const rows = db
109
- .prepare('SELECT origin_url,password_value FROM logins')
110
- .all();
111
- assert.equal(rows.length, 1);
112
- assert.equal(
113
- decryptChromiumCookie({
114
- encryptedValue: rows[0].password_value,
115
- key,
116
- platform: 'linux',
117
- databaseVersion: 0,
118
- host: 'github.com',
119
- }),
120
- 'p"a\nss'
121
- );
122
- } finally {
123
- db.close();
124
- }
124
+ const rows = migratedPasswords(target);
125
+ assert.equal(rows.length, 1);
126
+ assert.equal(decryptPassword(rows[0], key, 'linux'), 'p"a\nss');
127
+ });
128
+
129
+ it('encrypts migrated passwords with the mock keychain key of a Chrome for Testing launch (issue #179)', async () => {
130
+ const source = await temporary();
131
+ const target = await temporary();
132
+ // No keys are injected and the OS keystore is never asked: Chromium's
133
+ // --use-mock-keychain derives its key from the fixed "mock_password".
134
+ const report = await migrateProfile({
135
+ from: { browser: 'safari', userDataDir: source },
136
+ to: target,
137
+ include: ['passwords'],
138
+ domains: ['github.com'],
139
+ passwordCsv: fixture('Passwords.csv'),
140
+ platform: 'darwin',
141
+ targetBrowser: 'chromium',
142
+ targetKeystore: 'mock',
143
+ });
144
+ assert.equal(report.migrated.passwords, 1);
145
+ const [row] = migratedPasswords(target);
146
+ assert.equal(row.password_value.subarray(0, 3).toString(), 'v10');
147
+ const key = deriveChromiumCookieKey('mock_password', 'darwin');
148
+ assert.equal(decryptPassword(row, key, 'darwin'), 'p"a\nss');
125
149
  });
126
150
 
127
151
  it('names the executing app and retry link for non-cookie protection failures', async () => {
@@ -282,6 +282,28 @@ describe('launchAndConnectRealBrowser', () => {
282
282
  await access(path.join(temporaryDirectory, 'First Run'));
283
283
  });
284
284
 
285
+ it('gives Selenium the started browser so its driver matches it (issue #179)', async () => {
286
+ temporaryDirectory = await makeDedicatedProfile();
287
+ const cft =
288
+ '/home/u/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome';
289
+ let connectOptions;
290
+ await launchAndConnectRealBrowserWithDependencies(
291
+ { engine: 'selenium', userDataDir: temporaryDirectory },
292
+ {
293
+ resolveExecutable: async () => cft,
294
+ reservePort: async () => 9444,
295
+ spawnBrowser: () => fakeProcess([]),
296
+ waitForEndpoint: async () => 'http://127.0.0.1:9444',
297
+ connect: async (options) => {
298
+ connectOptions = options;
299
+ return { browser: null, page: null };
300
+ },
301
+ }
302
+ );
303
+ assert.equal(connectOptions.engine, 'selenium');
304
+ assert.equal(connectOptions.executablePath, cft);
305
+ });
306
+
285
307
  it('migrates a profile before launch and seeds the migrated cookies', async () => {
286
308
  temporaryDirectory = await makeDedicatedProfile();
287
309
  const calls = [];
@@ -568,6 +590,57 @@ describe('launchAndConnectRealBrowser', () => {
568
590
  ]);
569
591
  });
570
592
 
593
+ it('migrates passwords with the key of the browser that starts (issue #179)', async () => {
594
+ const cft =
595
+ '/Users/u/Library/Caches/ms-playwright/chromium-1248/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing';
596
+ const migrateTarget = async (executablePath, options = {}) => {
597
+ let migrateOptions;
598
+ const result = await launchAndConnectRealBrowserWithDependencies(
599
+ {
600
+ closeTimeout: 50,
601
+ migrateFrom: { browser: 'chrome', include: ['passwords'] },
602
+ ...options,
603
+ },
604
+ {
605
+ platform: 'darwin',
606
+ resolveExecutable: async () => executablePath,
607
+ reservePort: async () => 40007,
608
+ spawnBrowser: () => fakeProcess([]),
609
+ waitForEndpoint: async () => 'http://127.0.0.1:40007',
610
+ connect: async () => ({ browser: null, page: null }),
611
+ migrateProfile: async (migration) => {
612
+ migrateOptions = migration;
613
+ return { migrated: {}, skipped: [], warnings: [], cookies: [] };
614
+ },
615
+ }
616
+ );
617
+ await result.close();
618
+ const { targetBrowser, targetKeystore } = migrateOptions;
619
+ return { targetBrowser, targetKeystore };
620
+ };
621
+
622
+ // Chrome for Testing keeps "Chromium Safe Storage", and its default
623
+ // mock keychain encrypts with Chromium's fixed mock password.
624
+ assert.deepEqual(await migrateTarget(cft), {
625
+ targetBrowser: 'chromium',
626
+ targetKeystore: 'mock',
627
+ });
628
+ assert.deepEqual(await migrateTarget(cft, { defaultRestrictions: [] }), {
629
+ targetBrowser: 'chromium',
630
+ targetKeystore: 'os',
631
+ });
632
+ assert.deepEqual(await migrateTarget('/opt/google/chrome'), {
633
+ targetBrowser: 'chrome',
634
+ targetKeystore: 'os',
635
+ });
636
+ assert.deepEqual(
637
+ await migrateTarget('/opt/google/chrome', {
638
+ restrictions: ['mock-keychain'],
639
+ }),
640
+ { targetBrowser: 'chrome', targetKeystore: 'mock' }
641
+ );
642
+ });
643
+
571
644
  it('terminates the spawned browser when connection fails', async () => {
572
645
  temporaryDirectory = await makeDedicatedProfile();
573
646
  let killed = false;
@@ -166,6 +166,31 @@ function launchDependencies(overrides = {}) {
166
166
  };
167
167
  }
168
168
 
169
+ // Resolve through a fake Selenium Manager that answers browserPath.
170
+ async function managerResolve(
171
+ options,
172
+ { checkAccess = accessOnly(), browserPath }
173
+ ) {
174
+ const commands = [];
175
+ const resolved = await resolveWebDriverExecutable({
176
+ managerPath: '/sm',
177
+ checkAccess,
178
+ runCommand: async (file, args) => {
179
+ commands.push([file, ...args]);
180
+ return managerOutput({
181
+ driver_path: '/cache/chromedriver',
182
+ browser_path: browserPath,
183
+ });
184
+ },
185
+ ...options,
186
+ });
187
+ return { commands, resolved };
188
+ }
189
+
190
+ const SELENIUM_CFT = '/home/u/.cache/selenium/chrome/linux64/156.0/chrome';
191
+ const PLAYWRIGHT_CFT =
192
+ '/home/u/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome';
193
+
169
194
  describe('resolveWebDriverExecutable', () => {
170
195
  it('uses an explicit driverPath first', async () => {
171
196
  const resolved = await resolveWebDriverExecutable({
@@ -210,21 +235,38 @@ describe('resolveWebDriverExecutable', () => {
210
235
  assert.equal(resolved.driverPath, 'C:\\b\\chromedriver.exe');
211
236
  });
212
237
 
213
- it('then asks Selenium Manager for a matching driver', async () => {
214
- const commands = [];
215
- const resolved = await resolveWebDriverExecutable({
216
- executablePath: '/usr/bin/google-chrome',
217
- environment: { PATH: '' },
218
- managerPath: '/sm',
219
- checkAccess: accessOnly(),
220
- runCommand: async (file, args) => {
221
- commands.push([file, ...args]);
222
- return managerOutput({
223
- driver_path: '/cache/chromedriver',
224
- browser_path: '/usr/bin/google-chrome',
225
- });
226
- },
238
+ // A chromedriver on PATH drives the installed Google Chrome: never used
239
+ // for Chrome for Testing (issue #179). Selenium Manager returns a PATH
240
+ // driver of another release too, so it is told to skip it (run
241
+ // 39651456089: ChromeDriver 155 for Chrome 153).
242
+ for (const cft of [PLAYWRIGHT_CFT, SELENIUM_CFT]) {
243
+ it(`matches ${cft} with its own driver, not the one on PATH (issue #179)`, async () => {
244
+ const { commands, resolved } = await managerResolve(
245
+ { executablePath: cft, environment: { PATH: '/bin' } },
246
+ { checkAccess: accessOnly('/bin/chromedriver'), browserPath: cft }
247
+ );
248
+ assert.deepEqual(commands, [
249
+ [
250
+ '/sm',
251
+ '--browser',
252
+ 'chrome',
253
+ '--output',
254
+ 'json',
255
+ '--browser-path',
256
+ cft,
257
+ '--skip-driver-in-path',
258
+ ],
259
+ ]);
260
+ assert.equal(resolved.driverPath, '/cache/chromedriver');
261
+ assert.equal(resolved.source, 'selenium-manager');
227
262
  });
263
+ }
264
+
265
+ it('then asks Selenium Manager for a matching driver', async () => {
266
+ const { commands, resolved } = await managerResolve(
267
+ { executablePath: '/usr/bin/google-chrome', environment: { PATH: '' } },
268
+ { browserPath: '/usr/bin/google-chrome' }
269
+ );
228
270
  assert.deepEqual(commands, [
229
271
  [
230
272
  '/sm',
@@ -16,6 +16,7 @@ import {
16
16
  classifyStatus,
17
17
  extractRejectedLinks,
18
18
  judge,
19
+ ownRepositoryPath,
19
20
  } from '../../../../scripts/check-web-archive.mjs';
20
21
 
21
22
  // Verbatim shape of lychee/out.md from run 33959793880.
@@ -131,8 +132,8 @@ const waybackHas = (timestamp, archiveUrl) => async () => ({
131
132
  timestamp,
132
133
  });
133
134
 
134
- const verdictFor = (link, recheckImpl, waybackImpl) =>
135
- judge(link, { recheckImpl, waybackImpl });
135
+ const verdictFor = (link, recheckImpl, waybackImpl, extra = {}) =>
136
+ judge(link, { recheckImpl, waybackImpl, ...extra });
136
137
 
137
138
  describe('judging one rejected link', () => {
138
139
  const playwright = {
@@ -151,6 +152,14 @@ describe('judging one rejected link', () => {
151
152
  assert.equal(verdict.verdict, 'transient');
152
153
  });
153
154
 
155
+ it('does not fail for a host that answered 5xx and then timed out', async () => {
156
+ // chromium.googlesource.com answered 503 to lychee and then let the
157
+ // re-check time out: neither said the link is gone, and a host that
158
+ // answered at all still resolves.
159
+ const verdict = await verdictFor(playwright, unreachable(), waybackNever());
160
+ assert.equal(verdict.verdict, 'transient');
161
+ });
162
+
154
163
  it('does not fail for a host that keeps rate-limiting the runner', async () => {
155
164
  const verdict = await verdictFor(
156
165
  { url: 'https://example.com/x', status: '429' },
@@ -189,3 +198,69 @@ describe('judging one rejected link', () => {
189
198
  assert.equal(verdict.verdict, 'gone');
190
199
  });
191
200
  });
201
+
202
+ describe('a link to a file this branch adds', () => {
203
+ // Run 39651456194: the READMEs link the new docs/chrome-for-testing.md on
204
+ // main, which is published to npm, PyPI and crates.io, so it must be an
205
+ // absolute URL. Until the pull request merges, main has no such file.
206
+ const repository = 'link-foundation/browser-commander';
207
+ const newDoc = {
208
+ url: 'https://github.com/link-foundation/browser-commander/blob/main/docs/chrome-for-testing.md',
209
+ status: '404',
210
+ };
211
+ const checkout = (...files) => ({
212
+ repository,
213
+ fileExists: (relative) => files.includes(relative),
214
+ });
215
+
216
+ it('maps this repository’s main-branch links to checkout paths', () => {
217
+ assert.equal(
218
+ ownRepositoryPath(`${newDoc.url}#verifying-it`, repository),
219
+ 'docs/chrome-for-testing.md'
220
+ );
221
+ assert.equal(
222
+ ownRepositoryPath(
223
+ 'https://github.com/link-foundation/browser-commander/tree/main/docs/case%20studies',
224
+ repository
225
+ ),
226
+ 'docs/case studies'
227
+ );
228
+ assert.equal(
229
+ ownRepositoryPath(
230
+ 'https://github.com/link-foundation/browser-commander/blob/v1/x.md',
231
+ repository
232
+ ),
233
+ null
234
+ );
235
+ assert.equal(
236
+ ownRepositoryPath(
237
+ 'https://github.com/other/repo/blob/main/x.md',
238
+ repository
239
+ ),
240
+ null
241
+ );
242
+ assert.equal(ownRepositoryPath(newDoc.url, undefined), null);
243
+ });
244
+
245
+ it('does not fail when the checkout has the file main is about to get', async () => {
246
+ const verdict = await verdictFor(
247
+ newDoc,
248
+ () => {
249
+ throw new Error('a file in the checkout needs no network request');
250
+ },
251
+ waybackNever(),
252
+ checkout('docs/chrome-for-testing.md')
253
+ );
254
+ assert.equal(verdict.verdict, 'unmerged');
255
+ });
256
+
257
+ it('still fails when the checkout does not have the file either', async () => {
258
+ const verdict = await verdictFor(
259
+ newDoc,
260
+ answers(404),
261
+ waybackNothing(),
262
+ checkout('docs/feature-parity.md')
263
+ );
264
+ assert.equal(verdict.verdict, 'gone');
265
+ });
266
+ });