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.
- package/CHANGELOG.md +16 -0
- package/README.md +48 -5
- package/package.json +1 -1
- package/src/browser/chrome-for-testing-cache.js +198 -0
- package/src/browser/launch-executable.js +203 -0
- package/src/browser/launch-restrictions.json +7 -1
- package/src/browser/launcher.js +78 -65
- package/src/browser/parity.js +6 -1
- package/src/browser/profile-directory.js +8 -1
- package/src/browser/real-browser.js +29 -9
- package/src/browser/webdriver.js +22 -3
- package/src/capture/screenshot.js +16 -1
- package/src/capture/viewport.js +2 -1
- package/src/exports.js +4 -0
- package/src/traces/recorder.js +18 -2
- package/tests/e2e/startup-readiness.e2e.test.js +2 -0
- package/tests/unit/browser/chrome-for-testing-cache.test.js +139 -0
- package/tests/unit/browser/launch-building-blocks.test.js +9 -2
- package/tests/unit/browser/launch-executable.test.js +93 -0
- package/tests/unit/browser/launcher.test.js +235 -31
- package/tests/unit/browser/parity.test.js +19 -0
- package/tests/unit/browser/real-browser.test.js +52 -0
- package/tests/unit/browser/webdriver.test.js +39 -0
- package/tests/unit/scripts/preflight-credentials.test.js +156 -10
- package/tests/unit/scripts/trusted-publishing.test.js +29 -7
- package/tests/unit/traces/issue-162-defaults.test.js +1 -1
- package/tests/unit/traces/issue-166-policy.test.js +1 -1
- package/tests/unit/traces/issue-180-screenshots.test.js +173 -0
|
@@ -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
|
|
353
|
-
const { status, output } = await probeCrates(
|
|
354
|
-
|
|
355
|
-
{
|
|
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('
|
|
370
|
-
const { status, output } = await
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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}
|
|
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
|
-
|
|
24
|
-
|
|
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('
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
+
});
|