browser-commander 0.32.0 → 0.33.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.
@@ -381,6 +381,45 @@ describe('launchWebDriver', () => {
381
381
  await session.close(); // idempotent
382
382
  });
383
383
 
384
+ it('adds the Chrome for Testing defaults for the browser it resolves (issue #179)', async () => {
385
+ const launchWith = async (browserPath, options = {}) => {
386
+ const { mock, dependencies } = launchDependencies({
387
+ runCommand: async () =>
388
+ managerOutput({
389
+ driver_path: '/cache/chromedriver',
390
+ browser_path: browserPath,
391
+ }),
392
+ });
393
+ const session = await launchWebDriver(options, dependencies);
394
+ await session.close();
395
+ const { args } = mock.record.builders[0].options;
396
+ return {
397
+ defaults: session.defaultRestrictions,
398
+ switches: args.filter((arg) =>
399
+ ['--test-type=gpu', '--use-mock-keychain'].includes(arg)
400
+ ),
401
+ };
402
+ };
403
+
404
+ // Selenium Manager downloads Chrome for Testing when no Chrome is installed.
405
+ const cft = await launchWith(
406
+ '/home/u/.cache/selenium/chrome/linux64/131.0.6778.85/chrome'
407
+ );
408
+ assert.deepEqual(cft.defaults, [
409
+ 'no-chrome-for-testing-infobar',
410
+ 'mock-keychain',
411
+ ]);
412
+ assert.deepEqual(cft.switches, ['--test-type=gpu', '--use-mock-keychain']);
413
+
414
+ const chrome = await launchWith('/opt/google/chrome/chrome');
415
+ assert.deepEqual(chrome, { defaults: [], switches: [] });
416
+ const off = await launchWith(
417
+ '/home/u/.cache/selenium/chrome/linux64/131.0.6778.85/chrome',
418
+ { defaultRestrictions: [] }
419
+ );
420
+ assert.deepEqual(off, { defaults: [], switches: [] });
421
+ });
422
+
384
423
  it('starts geckodriver for Firefox with a -profile argument', async () => {
385
424
  const { mock, started, dependencies } = launchDependencies({
386
425
  environment: { PATH: '/usr/bin' },
@@ -90,6 +90,7 @@ function environment(overrides) {
90
90
  NPM_REGISTRY: `${stub.origin}/npm`,
91
91
  CRATES_API: `${stub.origin}/crates`,
92
92
  NPM_PACKAGE: '@link-foundation/probe',
93
+ CRATES_PACKAGE: 'probe-crate',
93
94
  CARGO_REGISTRY_TOKEN: '',
94
95
  CARGO_TOKEN: '',
95
96
  GITHUB_STEP_SUMMARY: '',
@@ -133,6 +134,13 @@ const EXCHANGE =
133
134
  'POST /npm/-/npm/v1/oidc/token/exchange/package/@link-foundation%2Fprobe';
134
135
  const CRATES_MINT = 'POST /crates/api/v1/trusted_publishing/tokens';
135
136
  const CRATES_REVOKE = 'DELETE /crates/api/v1/trusted_publishing/tokens';
137
+ const CRATES_PUBLISH = 'PUT /crates/api/v1/crates/new';
138
+ // What crates.io answers to an authorised publish that omits the archive.
139
+ const TOKEN_ACCEPTED = [
140
+ 400,
141
+ { errors: [{ detail: 'invalid tarball length' }] },
142
+ ];
143
+ const TOKEN_REJECTED = [403, { errors: [{ detail: 'authentication failed' }] }];
136
144
  const INVALID_PUBLISHER = [
137
145
  422,
138
146
  {
@@ -157,6 +165,33 @@ function probeCrates(answer, overrides = {}) {
157
165
  return preflight({ PREFLIGHT_REGISTRIES: 'crates', ...overrides });
158
166
  }
159
167
 
168
+ /**
169
+ * Refuse the crates.io OIDC exchange and answer the token publish probe with
170
+ * `answer`, so the outcome rests on the token fallback.
171
+ */
172
+ function probeFallback(answer, overrides = {}) {
173
+ stub.routes[CRATES_PUBLISH] = answer;
174
+ return probeCrates([403, {}], { CARGO_TOKEN: CARGO_SECRET, ...overrides });
175
+ }
176
+
177
+ /** Run the crates.io probe in a job without `id-token: write`. */
178
+ function preflightWithoutOidc(overrides = {}) {
179
+ return preflight({
180
+ PREFLIGHT_REGISTRIES: 'crates',
181
+ ACTIONS_ID_TOKEN_REQUEST_URL: '',
182
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: '',
183
+ ...overrides,
184
+ });
185
+ }
186
+
187
+ /** The metadata of a token publish probe: a u32 length, then JSON. */
188
+ function probeMetadata(body) {
189
+ const bytes = Buffer.from(body, 'latin1');
190
+ const length = bytes.readUInt32LE(0);
191
+ assert.equal(bytes.length, 4 + length, 'the probe must carry no archive');
192
+ return JSON.parse(bytes.subarray(4).toString('utf8'));
193
+ }
194
+
160
195
  describe('scripts/preflight-credentials.sh', scriptOptions, () => {
161
196
  before(async () => {
162
197
  stub.server = createServer(respond);
@@ -349,11 +384,11 @@ describe('scripts/preflight-credentials.sh', scriptOptions, () => {
349
384
  assertNoSecrets(output);
350
385
  });
351
386
 
352
- it('fails a release when no trusted publisher matches, even with a legacy secret', async () => {
353
- const { status, output } = await probeCrates(
354
- [403, { errors: [{ detail: 'no matching publisher' }] }],
355
- { CARGO_TOKEN: CARGO_SECRET }
356
- );
387
+ it('fails a release when no trusted publisher matches and no token is set', async () => {
388
+ const { status, output } = await probeCrates([
389
+ 403,
390
+ { errors: [{ detail: 'no matching publisher' }] },
391
+ ]);
357
392
 
358
393
  assert.equal(status, 1, output);
359
394
  assert.match(
@@ -362,18 +397,80 @@ describe('scripts/preflight-credentials.sh', scriptOptions, () => {
362
397
  );
363
398
  assert.match(output, /rust\.yml/);
364
399
  assert.match(output, /docs\/PUBLISHING\.md/);
400
+ assert.match(output, /neither CARGO_REGISTRY_TOKEN nor CARGO_TOKEN/);
365
401
  assert.equal(stub.requests.length, 2);
402
+ });
403
+
404
+ it('passes a release on the token fallback when no trusted publisher matches', async () => {
405
+ const { status, output } = await probeFallback(TOKEN_ACCEPTED);
406
+
407
+ assert.equal(status, 0, output);
408
+ assert.match(output, /1 verified, 0 failed, 0 unknown/);
409
+ assert.match(
410
+ output,
411
+ /PASS: crates.io accepted the CARGO_REGISTRY_TOKEN\/CARGO_TOKEN fallback for probe-crate/
412
+ );
413
+ assert.match(
414
+ output,
415
+ /::warning::release-preflight: crates.io refused the OIDC token exchange \(403\).*falls back to the CARGO_REGISTRY_TOKEN\/CARGO_TOKEN secret/
416
+ );
417
+ assert.doesNotMatch(output, /::error::/);
418
+ const publish = stub.requests.at(-1);
419
+ assert.equal(publish.route, CRATES_PUBLISH);
420
+ assert.equal(publish.authorization, CARGO_SECRET);
421
+ assert.match(publish.userAgent, /^release-preflight /);
422
+ assert.deepEqual(probeMetadata(publish.body), {
423
+ name: 'probe-crate',
424
+ vers: '0.0.0-preflight',
425
+ });
426
+ assert.equal(stub.requests.length, 3);
366
427
  assertNoSecrets(output);
367
428
  });
368
429
 
369
- it('fails when the job has no OIDC permission, with no secret fallback', async () => {
370
- const { status, output } = await preflight({
371
- PREFLIGHT_REGISTRIES: 'crates',
372
- ACTIONS_ID_TOKEN_REQUEST_URL: '',
373
- ACTIONS_ID_TOKEN_REQUEST_TOKEN: '',
430
+ it('prefers CARGO_REGISTRY_TOKEN over CARGO_TOKEN for the fallback', async () => {
431
+ const { status, output } = await probeFallback(TOKEN_ACCEPTED, {
432
+ CARGO_REGISTRY_TOKEN: CARGO_SECRET,
433
+ CARGO_TOKEN: 'cio-other-token-must-not-be-used',
434
+ });
435
+
436
+ assert.equal(status, 0, output);
437
+ assert.equal(stub.requests.at(-1).authorization, CARGO_SECRET);
438
+ });
439
+
440
+ it('fails a release when trusted publishing and the token fallback are both refused', async () => {
441
+ const { status, output } = await probeFallback(TOKEN_REJECTED);
442
+
443
+ assert.equal(status, 1, output);
444
+ assert.match(output, /0 verified, 1 failed, 0 unknown/);
445
+ assert.match(
446
+ output,
447
+ /::error::release-preflight: crates.io refused the OIDC token exchange \(403\).*; crates.io rejected the CARGO_REGISTRY_TOKEN\/CARGO_TOKEN fallback for probe-crate \(403\)/
448
+ );
449
+ assert.equal(stub.requests.length, 3);
450
+ assertNoSecrets(output);
451
+ });
452
+
453
+ it('falls back to the token when the job has no OIDC permission', async () => {
454
+ stub.routes[CRATES_PUBLISH] = TOKEN_ACCEPTED;
455
+ const { status, output } = await preflightWithoutOidc({
374
456
  CARGO_REGISTRY_TOKEN: CARGO_SECRET,
375
457
  });
376
458
 
459
+ assert.equal(status, 0, output);
460
+ assert.match(
461
+ output,
462
+ /::warning::release-preflight: crates.io: no OIDC token available: .*id-token: write/
463
+ );
464
+ assert.deepEqual(
465
+ stub.requests.map(({ route }) => route),
466
+ [CRATES_PUBLISH]
467
+ );
468
+ assertNoSecrets(output);
469
+ });
470
+
471
+ it('fails when the job has no OIDC permission and no token is set', async () => {
472
+ const { status, output } = await preflightWithoutOidc();
473
+
377
474
  assert.equal(status, 1, output);
378
475
  assert.match(
379
476
  output,
@@ -382,6 +479,54 @@ describe('scripts/preflight-credentials.sh', scriptOptions, () => {
382
479
  assert.equal(stub.requests.length, 0);
383
480
  });
384
481
 
482
+ it('treats an unproven token fallback as unknown, never as a pass', async () => {
483
+ for (const answer of [
484
+ [400, { errors: [{ detail: 'invalid upload request' }] }],
485
+ [429, {}],
486
+ [500, { token: CARGO_SECRET }],
487
+ ]) {
488
+ const { status, output } = await probeFallback(answer);
489
+ assert.equal(status, 1, output);
490
+ assert.match(output, /0 verified, 0 failed, 1 unknown/);
491
+ assert.match(output, /verified nothing/);
492
+ assertNoSecrets(output);
493
+ }
494
+ });
495
+
496
+ it('reads the crate name from Cargo.toml when CRATES_PACKAGE is unset', async () => {
497
+ writeFileSync(
498
+ path.join(workDir, 'Cargo.toml'),
499
+ [
500
+ '[package]',
501
+ 'name = "from-manifest"',
502
+ 'version = "1.2.3"',
503
+ '',
504
+ '[dependencies.serde]',
505
+ 'name = "not-this-one"',
506
+ '',
507
+ ].join('\n')
508
+ );
509
+ const { status, output } = await probeFallback(TOKEN_ACCEPTED, {
510
+ CRATES_PACKAGE: '',
511
+ });
512
+
513
+ assert.equal(status, 0, output);
514
+ assert.equal(
515
+ probeMetadata(stub.requests.at(-1).body).name,
516
+ 'from-manifest'
517
+ );
518
+ });
519
+
520
+ it('cannot probe the token without a crate name', async () => {
521
+ const { status, output } = await probeFallback(TOKEN_ACCEPTED, {
522
+ CRATES_PACKAGE: '',
523
+ });
524
+
525
+ assert.equal(status, 1, output);
526
+ assert.match(output, /cannot tell which crate to probe/);
527
+ assert.equal(stub.requests.length, 2);
528
+ });
529
+
385
530
  it('keeps missing publishers advisory on pull requests', async () => {
386
531
  const { status, output } = await probeCrates([403, {}], {
387
532
  PREFLIGHT_MODE: 'report',
@@ -429,6 +574,7 @@ describe('scripts/preflight-credentials.sh', scriptOptions, () => {
429
574
  stub.routes[MINT] = INVALID_PUBLISHER;
430
575
  stub.routes[EXCHANGE] = [403, { message: 'forbidden' }];
431
576
  stub.routes[CRATES_MINT] = [401, { errors: [{ detail: 'bad token' }] }];
577
+ stub.routes[CRATES_PUBLISH] = TOKEN_REJECTED;
432
578
  const summary = path.join(workDir, 'summary.md');
433
579
  writeFileSync(summary, '');
434
580
 
@@ -7,8 +7,13 @@ import { readRepoText } from '../../helpers/repo.js';
7
7
  describe('trusted publishing configuration', () => {
8
8
  const rust = parse(readRepoText('.github/workflows/rust.yml'));
9
9
 
10
+ // Organization policy: trusted publishing (OIDC) first, the token secret
11
+ // only when OIDC is unavailable.
12
+ const FALLBACK =
13
+ '${{ steps.crates_auth.outputs.token || secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }}';
14
+
10
15
  for (const jobId of ['auto-release', 'manual-release']) {
11
- it(`${jobId} uses a fresh OIDC token only when publishing`, () => {
16
+ it(`${jobId} publishes with a fresh OIDC token, falling back to the token secret`, () => {
12
17
  const job = rust.jobs[jobId];
13
18
  assert.equal(job.permissions['id-token'], 'write');
14
19
  const auth = job.steps.find((step) => step.id === 'crates_auth');
@@ -20,9 +25,11 @@ describe('trusted publishing configuration', () => {
20
25
  );
21
26
  assert.equal(auth.if, publish.if);
22
27
  assert.equal(
23
- publish.env.CARGO_REGISTRY_TOKEN,
24
- '${{ steps.crates_auth.outputs.token }}'
28
+ auth['continue-on-error'],
29
+ true,
30
+ 'a trusted publishing failure must leave the token fallback reachable'
25
31
  );
32
+ assert.equal(publish.env.CARGO_REGISTRY_TOKEN, FALLBACK);
26
33
  assert.ok(job.steps.indexOf(auth) < job.steps.indexOf(publish));
27
34
  assert.ok(
28
35
  job.steps.indexOf(auth) >
@@ -31,13 +38,28 @@ describe('trusted publishing configuration', () => {
31
38
  });
32
39
  }
33
40
 
34
- it('never reads the long-lived Cargo secret in the Rust workflow', () => {
35
- assert.doesNotMatch(
36
- readRepoText('.github/workflows/rust.yml'),
37
- /secrets\.CARGO_TOKEN/
41
+ it('gives the release preflight the same token fallback', () => {
42
+ const probe = rust.jobs['release-preflight'].steps.find(
43
+ (step) => step.name === 'Probe crates.io trusted publishing'
44
+ );
45
+ assert.equal(
46
+ probe.env.CARGO_REGISTRY_TOKEN,
47
+ '${{ secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }}'
38
48
  );
39
49
  });
40
50
 
51
+ it('reads the Cargo secrets only as the trusted publishing fallback', () => {
52
+ const uses = readRepoText('.github/workflows/rust.yml')
53
+ .split('\n')
54
+ .filter((line) => /secrets\.CARGO_/.test(line))
55
+ .map((line) => line.trim());
56
+ assert.deepEqual(uses, [
57
+ 'CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN || secrets.CARGO_TOKEN }}',
58
+ `CARGO_REGISTRY_TOKEN: ${FALLBACK}`,
59
+ `CARGO_REGISTRY_TOKEN: ${FALLBACK}`,
60
+ ]);
61
+ });
62
+
41
63
  it('keeps the registered PyPI identity identical across preflight and releases', () => {
42
64
  const python = parse(readRepoText('.github/workflows/python.yml'));
43
65
  for (const jobId of [
@@ -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
+ });