browser-commander 0.32.1 → 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.
Files changed (34) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +52 -5
  3. package/package.json +1 -1
  4. package/src/browser/browser-handoff.js +4 -0
  5. package/src/browser/chrome-for-testing-cache.js +198 -0
  6. package/src/browser/launch-executable.js +203 -0
  7. package/src/browser/launch-restrictions.json +7 -1
  8. package/src/browser/launcher.js +78 -65
  9. package/src/browser/migration/firefox.js +1 -1
  10. package/src/browser/migration/index.js +10 -0
  11. package/src/browser/migration/os-crypt-keys.js +36 -6
  12. package/src/browser/migration/passwords.js +1 -1
  13. package/src/browser/parity.js +6 -1
  14. package/src/browser/real-browser.js +53 -14
  15. package/src/browser/webdriver.js +63 -8
  16. package/src/capture/screenshot.js +16 -1
  17. package/src/capture/viewport.js +2 -1
  18. package/src/exports.js +5 -0
  19. package/src/traces/recorder.js +18 -2
  20. package/tests/e2e/chrome-for-testing.e2e.test.js +105 -0
  21. package/tests/e2e/startup-readiness.e2e.test.js +2 -0
  22. package/tests/unit/browser/browser-handoff.test.js +13 -0
  23. package/tests/unit/browser/chrome-for-testing-cache.test.js +139 -0
  24. package/tests/unit/browser/launch-executable.test.js +94 -0
  25. package/tests/unit/browser/launcher.test.js +235 -31
  26. package/tests/unit/browser/migration/os-crypt-keys.test.js +57 -0
  27. package/tests/unit/browser/migration/safari-data.test.js +43 -19
  28. package/tests/unit/browser/parity.test.js +19 -0
  29. package/tests/unit/browser/real-browser.test.js +125 -0
  30. package/tests/unit/browser/webdriver.test.js +95 -14
  31. package/tests/unit/scripts/check-web-archive.test.js +77 -2
  32. package/tests/unit/traces/issue-162-defaults.test.js +1 -1
  33. package/tests/unit/traces/issue-166-policy.test.js +1 -1
  34. package/tests/unit/traces/issue-180-screenshots.test.js +173 -0
@@ -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',
@@ -381,6 +423,45 @@ describe('launchWebDriver', () => {
381
423
  await session.close(); // idempotent
382
424
  });
383
425
 
426
+ it('adds the Chrome for Testing defaults for the browser it resolves (issue #179)', async () => {
427
+ const launchWith = async (browserPath, options = {}) => {
428
+ const { mock, dependencies } = launchDependencies({
429
+ runCommand: async () =>
430
+ managerOutput({
431
+ driver_path: '/cache/chromedriver',
432
+ browser_path: browserPath,
433
+ }),
434
+ });
435
+ const session = await launchWebDriver(options, dependencies);
436
+ await session.close();
437
+ const { args } = mock.record.builders[0].options;
438
+ return {
439
+ defaults: session.defaultRestrictions,
440
+ switches: args.filter((arg) =>
441
+ ['--test-type=gpu', '--use-mock-keychain'].includes(arg)
442
+ ),
443
+ };
444
+ };
445
+
446
+ // Selenium Manager downloads Chrome for Testing when no Chrome is installed.
447
+ const cft = await launchWith(
448
+ '/home/u/.cache/selenium/chrome/linux64/131.0.6778.85/chrome'
449
+ );
450
+ assert.deepEqual(cft.defaults, [
451
+ 'no-chrome-for-testing-infobar',
452
+ 'mock-keychain',
453
+ ]);
454
+ assert.deepEqual(cft.switches, ['--test-type=gpu', '--use-mock-keychain']);
455
+
456
+ const chrome = await launchWith('/opt/google/chrome/chrome');
457
+ assert.deepEqual(chrome, { defaults: [], switches: [] });
458
+ const off = await launchWith(
459
+ '/home/u/.cache/selenium/chrome/linux64/131.0.6778.85/chrome',
460
+ { defaultRestrictions: [] }
461
+ );
462
+ assert.deepEqual(off, { defaults: [], switches: [] });
463
+ });
464
+
384
465
  it('starts geckodriver for Firefox with a -profile argument', async () => {
385
466
  const { mock, started, dependencies } = launchDependencies({
386
467
  environment: { PATH: '/usr/bin' },
@@ -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
+ });
@@ -181,7 +181,7 @@ describe('complete recording defaults (issue #162)', { timeout: 15000 }, () => {
181
181
  },
182
182
  };
183
183
  const launched = await launchBrowserWithDependencies(
184
- { debug: { output } },
184
+ { channel: 'chrome', debug: { output } },
185
185
  {
186
186
  resolveSystem: async () => '/usr/bin/google-chrome',
187
187
  settleMs: 0,
@@ -244,7 +244,7 @@ describe('opt-in recording policy (issue #166)', { timeout: 15000 }, () => {
244
244
  await fixture(async ({ root }) => {
245
245
  await assert.rejects(
246
246
  launchBrowserWithDependencies(
247
- { debug: { directory: root } },
247
+ { channel: 'chrome', debug: { directory: root } },
248
248
  {
249
249
  resolveSystem: async () => '/usr/bin/google-chrome',
250
250
  launchRealBrowser: async () => {
@@ -0,0 +1,173 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { startTrace } from '../../../src/traces/recorder.js';
6
+ import { TRACE_MODE } from '../../../src/traces/schema.js';
7
+ import { screenshot } from '../../../src/capture/index.js';
8
+ import {
9
+ createFakeCommander,
10
+ createFakePage,
11
+ useTempTraceDirectory,
12
+ } from '../../helpers/trace-fixtures.js';
13
+
14
+ /**
15
+ * A clipped engine screenshot of a headful window can leave Chrome's emulated
16
+ * size stuck after a resize (issue #180). Without a fixed viewport, captures
17
+ * go through CDP `Page.captureScreenshot` with no `clip`.
18
+ */
19
+ function cdpFake({ viewport = null, failWith, engine = 'playwright' } = {}) {
20
+ const calls = { cdp: [], engine: [], detached: 0 };
21
+ const session = {
22
+ send: async (method, params) => {
23
+ calls.cdp.push({ method, params });
24
+ if (failWith) {
25
+ throw new Error(failWith);
26
+ }
27
+ return { data: Buffer.from('cdp-png').toString('base64') };
28
+ },
29
+ detach: async () => {
30
+ calls.detached += 1;
31
+ },
32
+ };
33
+ const page = createFakePage();
34
+ page.screenshot = async (options) => {
35
+ calls.engine.push(options);
36
+ return Buffer.from('engine-png');
37
+ };
38
+ if (engine === 'playwright') {
39
+ page.context = () => ({ newCDPSession: async () => session });
40
+ page.viewportSize = () => viewport;
41
+ } else {
42
+ page.createCDPSession = async () => session;
43
+ page.viewport = () => viewport;
44
+ }
45
+ return { page, calls };
46
+ }
47
+
48
+ describe('trace checkpoint screenshots (issue #180)', () => {
49
+ const directory = useTempTraceDirectory();
50
+
51
+ async function checkpointShot(page, { withCommander = true, engine } = {}) {
52
+ const commander = createFakeCommander(page, engine ? { engine } : {});
53
+ const trace = await startTrace({
54
+ page,
55
+ ...(withCommander ? { commander } : {}),
56
+ output: path.join(directory.path, 'run'),
57
+ mode: TRACE_MODE.CHECKPOINTS,
58
+ network: false,
59
+ links: false,
60
+ screenshots: true,
61
+ });
62
+ await trace.checkpoint('resized');
63
+ const stopped = await trace.stop();
64
+ return (
65
+ await fs.readFile(path.join(stopped.path, 'checkpoints/0001.png'))
66
+ ).toString();
67
+ }
68
+
69
+ for (const engine of ['playwright', 'puppeteer']) {
70
+ for (const withCommander of [true, false]) {
71
+ it(`${engine} ${withCommander ? 'commander' : 'bare page'} without a fixed viewport sends no clip`, async () => {
72
+ const { page, calls } = cdpFake({ engine });
73
+ assert.equal(
74
+ await checkpointShot(page, { withCommander, engine }),
75
+ 'cdp-png'
76
+ );
77
+ assert.deepEqual(calls.cdp, [
78
+ {
79
+ method: 'Page.captureScreenshot',
80
+ params: {
81
+ format: 'png',
82
+ fromSurface: false,
83
+ captureBeyondViewport: false,
84
+ },
85
+ },
86
+ ]);
87
+ assert.equal(calls.engine.length, 0);
88
+ assert.equal(calls.detached, 1);
89
+ });
90
+ }
91
+ }
92
+
93
+ it('a fixed viewport keeps the engine screenshot', async () => {
94
+ const { page, calls } = cdpFake({
95
+ viewport: { width: 800, height: 600 },
96
+ });
97
+ assert.equal(await checkpointShot(page), 'engine-png');
98
+ assert.equal(calls.cdp.length, 0);
99
+ assert.deepEqual(calls.engine, [{ type: 'png' }]);
100
+ });
101
+
102
+ it('falls back to the engine screenshot when the view cannot be captured', async () => {
103
+ const { page, calls } = cdpFake({
104
+ failWith: 'Unable to capture screenshot',
105
+ });
106
+ assert.equal(await checkpointShot(page), 'engine-png');
107
+ assert.equal(calls.cdp.length, 1);
108
+ assert.equal(calls.detached, 1);
109
+ });
110
+
111
+ it('a page without CDP keeps the engine screenshot', async () => {
112
+ const page = createFakePage();
113
+ assert.equal(
114
+ await checkpointShot(page, { withCommander: false }),
115
+ 'fake-png'
116
+ );
117
+ });
118
+ });
119
+
120
+ describe('default commander screenshots (issue #180)', () => {
121
+ for (const engine of ['playwright', 'puppeteer']) {
122
+ it(`${engine} without a fixed viewport sends no clip`, async () => {
123
+ const { page, calls } = cdpFake({ engine });
124
+ assert.equal((await screenshot({ page, engine })).toString(), 'cdp-png');
125
+ assert.equal(calls.cdp.length, 1);
126
+ assert.equal('clip' in calls.cdp[0].params, false);
127
+ assert.equal(calls.engine.length, 0);
128
+ });
129
+
130
+ it(`${engine} with a fixed viewport keeps the engine screenshot`, async () => {
131
+ const { page, calls } = cdpFake({
132
+ engine,
133
+ viewport: { width: 800, height: 600 },
134
+ });
135
+ assert.equal(
136
+ (await screenshot({ page, engine })).toString(),
137
+ 'engine-png'
138
+ );
139
+ assert.equal(calls.cdp.length, 0);
140
+ assert.equal(calls.engine[0].fullPage, false);
141
+ });
142
+ }
143
+
144
+ it('JPEG quality reaches the view capture', async () => {
145
+ const { page, calls } = cdpFake();
146
+ await screenshot({ page, format: 'jpeg', quality: 60 });
147
+ assert.deepEqual(calls.cdp[0].params, {
148
+ format: 'jpeg',
149
+ fromSurface: false,
150
+ captureBeyondViewport: false,
151
+ quality: 60,
152
+ });
153
+ });
154
+
155
+ for (const options of [
156
+ { fullPage: true },
157
+ { clip: { x: 0, y: 0, width: 1, height: 1 } },
158
+ { selector: '#pager' },
159
+ { omitBackground: true },
160
+ { hideScrollbars: true },
161
+ { animations: 'disabled' },
162
+ { scale: 'css' },
163
+ { stableViewport: false },
164
+ ]) {
165
+ it(`${JSON.stringify(options)} keeps the engine screenshot`, async () => {
166
+ const { page, calls } = cdpFake();
167
+ page.locator = () => ({ first: () => page });
168
+ await screenshot({ page, ...options });
169
+ assert.equal(calls.cdp.length, 0);
170
+ assert.equal(calls.engine.length, 1);
171
+ });
172
+ }
173
+ });