browser-commander 0.16.1 → 0.17.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/.changeset/config.json +1 -1
- package/.changeset/merged-loud-river.md +7 -0
- package/.jscpd-baseline.json +257 -0
- package/.jscpd.json +1 -3
- package/.prettierignore +1 -0
- package/CHANGELOG.md +8 -0
- package/eslint.config.js +23 -1
- package/package.json +13 -24
- package/scripts/changeset-version.mjs +2 -6
- package/scripts/create-github-release.mjs +7 -8
- package/scripts/create-manual-changeset.mjs +7 -8
- package/scripts/format-github-release.mjs +6 -7
- package/scripts/format-release-notes.mjs +7 -8
- package/scripts/instant-version-bump.mjs +7 -8
- package/scripts/publish-to-npm.mjs +7 -8
- package/scripts/setup-npm.mjs +2 -6
- package/scripts/version-and-commit.mjs +7 -8
- package/src/browser/browser-cookie-credentials.js +3 -1
- package/src/browser/browser-cookies.js +6 -2
- package/src/browser/browser-profiles.js +1 -1
- package/src/browser/launch-options.js +31 -8
- package/src/browser/launcher.js +16 -0
- package/src/exports.js +34 -0
- package/src/fingerprint/apply.js +116 -0
- package/src/fingerprint/automation-parity.js +224 -0
- package/src/fingerprint/cdp-overrides.js +176 -0
- package/src/fingerprint/derive.js +104 -0
- package/src/fingerprint/init-payload.js +181 -0
- package/src/fingerprint/init-script.js +104 -0
- package/src/fingerprint/limitations.js +107 -0
- package/src/fingerprint/limitations.json +96 -0
- package/src/fingerprint/presets.js +244 -0
- package/src/fingerprint/profile.js +428 -0
- package/src/tests/index.js +2 -1
- package/tests/e2e/fingerprint-parity.e2e.test.js +212 -0
- package/tests/helpers/mocks.js +4 -2
- package/tests/helpers/repo.js +74 -0
- package/tests/unit/browser/launch-options.test.js +9 -1
- package/tests/unit/experiments/fingerprint-parity-harness.test.js +97 -0
- package/tests/unit/fingerprint/apply.test.js +271 -0
- package/tests/unit/fingerprint/automation-parity.test.js +212 -0
- package/tests/unit/fingerprint/cdp-overrides.test.js +227 -0
- package/tests/unit/fingerprint/derive.test.js +105 -0
- package/tests/unit/fingerprint/init-script.test.js +357 -0
- package/tests/unit/fingerprint/limitations.test.js +184 -0
- package/tests/unit/fingerprint/presets.test.js +135 -0
- package/tests/unit/fingerprint/profile.test.js +282 -0
- package/tests/unit/scripts/check-pipeline-status.test.js +133 -0
- package/tests/unit/scripts/check-web-archive.test.js +191 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +311 -0
- package/tests/unit/scripts/ci-workflow-policy.test.js +193 -157
- package/tests/unit/scripts/debug-print.test.js +67 -0
- package/tests/unit/scripts/jscpd-config.test.js +141 -0
- package/tests/unit/scripts/pre-commit-config.test.js +209 -0
- package/tests/unit/scripts/read-manifest.test.js +194 -0
- package/tests/unit/scripts/run-with-budget-warning.test.js +115 -0
- package/tests/unit/scripts/use-module-adoption.test.js +74 -0
- package/tests/unit/scripts/use-module-integration.test.js +64 -0
- package/tests/unit/scripts/use-module.test.js +182 -0
- package/.husky/pre-commit +0 -1
- package/.prettierrc +0 -10
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
FINGERPRINT_FIELD_MECHANISMS,
|
|
6
|
+
resolveFingerprintProfile,
|
|
7
|
+
} from '../../../src/fingerprint/profile.js';
|
|
8
|
+
|
|
9
|
+
const CHROME_UA =
|
|
10
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
|
|
11
|
+
'(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
|
|
12
|
+
|
|
13
|
+
describe('fingerprint profile', () => {
|
|
14
|
+
it('returns an empty frozen profile for an empty input', () => {
|
|
15
|
+
const profile = resolveFingerprintProfile();
|
|
16
|
+
|
|
17
|
+
assert.deepEqual(profile, {});
|
|
18
|
+
assert.ok(Object.isFrozen(profile));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('drops fields that were not supplied instead of filling in defaults', () => {
|
|
22
|
+
const profile = resolveFingerprintProfile({ locale: 'de-DE' });
|
|
23
|
+
|
|
24
|
+
assert.deepEqual(Object.keys(profile), ['locale']);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('rejects an unknown field rather than silently ignoring it', () => {
|
|
28
|
+
assert.throws(
|
|
29
|
+
() => resolveFingerprintProfile({ hardwareConcurency: 8 }),
|
|
30
|
+
/unknown fingerprint profile field "hardwareConcurency"/u
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('rejects a profile that is not a plain object', () => {
|
|
35
|
+
for (const input of [null, [], 'profile', 42]) {
|
|
36
|
+
assert.throws(
|
|
37
|
+
() => resolveFingerprintProfile(input),
|
|
38
|
+
/fingerprint profile must be an object/u
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('derives acceptLanguage from languages when only languages is given', () => {
|
|
44
|
+
const profile = resolveFingerprintProfile({
|
|
45
|
+
languages: ['de-DE', 'de', 'en'],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
assert.equal(profile.acceptLanguage, 'de-DE,de,en');
|
|
49
|
+
assert.deepEqual(profile.languages, ['de-DE', 'de', 'en']);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('keeps an explicit acceptLanguage over the derived one', () => {
|
|
53
|
+
const profile = resolveFingerprintProfile({
|
|
54
|
+
languages: ['de-DE', 'de'],
|
|
55
|
+
acceptLanguage: 'fr-FR,fr',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
assert.equal(profile.acceptLanguage, 'fr-FR,fr');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Chrome splits acceptLanguage on commas without stripping q-values, so a
|
|
62
|
+
// q-value ends up inside a language tag and doubled in the header.
|
|
63
|
+
it('rejects q-values in acceptLanguage, which Chrome would misparse', () => {
|
|
64
|
+
assert.throws(
|
|
65
|
+
() => resolveFingerprintProfile({ acceptLanguage: 'de-DE,de;q=0.9' }),
|
|
66
|
+
/must be a plain comma-separated language list without q-values/u
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('derives client hints from a Chrome user agent', () => {
|
|
71
|
+
const profile = resolveFingerprintProfile({ userAgent: CHROME_UA });
|
|
72
|
+
|
|
73
|
+
assert.equal(profile.userAgentData.platform, 'Windows');
|
|
74
|
+
assert.equal(profile.userAgentData.architecture, 'x86');
|
|
75
|
+
assert.equal(profile.userAgentData.bitness, '64');
|
|
76
|
+
assert.equal(profile.userAgentData.mobile, false);
|
|
77
|
+
assert.ok(
|
|
78
|
+
profile.userAgentData.brands.some(
|
|
79
|
+
(entry) => entry.brand === 'Google Chrome' && entry.version === '140'
|
|
80
|
+
)
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('fills uaFullVersion from the Chrome entry of fullVersionList', () => {
|
|
85
|
+
const profile = resolveFingerprintProfile({
|
|
86
|
+
userAgentData: {
|
|
87
|
+
fullVersionList: [
|
|
88
|
+
{ brand: 'Not=A?Brand', version: '24.0.0.0' },
|
|
89
|
+
{ brand: 'Google Chrome', version: '140.0.7000.1' },
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
assert.equal(profile.userAgentData.fullVersion, '140.0.7000.1');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('keeps an explicit fullVersion instead of deriving one', () => {
|
|
98
|
+
const profile = resolveFingerprintProfile({
|
|
99
|
+
userAgentData: {
|
|
100
|
+
fullVersion: '99.1.2.3',
|
|
101
|
+
fullVersionList: [{ brand: 'Google Chrome', version: '140.0.0.0' }],
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
assert.equal(profile.userAgentData.fullVersion, '99.1.2.3');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('lets an explicit userAgentData win over the one derived from the user agent', () => {
|
|
109
|
+
const profile = resolveFingerprintProfile({
|
|
110
|
+
userAgent: CHROME_UA,
|
|
111
|
+
userAgentData: { platform: 'macOS' },
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
assert.equal(profile.userAgentData.platform, 'macOS');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('accepts every configurable field at once', () => {
|
|
118
|
+
const profile = resolveFingerprintProfile({
|
|
119
|
+
userAgent: CHROME_UA,
|
|
120
|
+
languages: ['de-DE', 'de'],
|
|
121
|
+
locale: 'de-DE',
|
|
122
|
+
timezoneId: 'Europe/Berlin',
|
|
123
|
+
platform: 'Win32',
|
|
124
|
+
vendor: 'Google Inc.',
|
|
125
|
+
hardwareConcurrency: 24,
|
|
126
|
+
deviceMemory: 32,
|
|
127
|
+
maxTouchPoints: 5,
|
|
128
|
+
doNotTrack: '1',
|
|
129
|
+
screen: {
|
|
130
|
+
width: 3840,
|
|
131
|
+
height: 2160,
|
|
132
|
+
availWidth: 3840,
|
|
133
|
+
availHeight: 2100,
|
|
134
|
+
colorDepth: 30,
|
|
135
|
+
pixelDepth: 30,
|
|
136
|
+
},
|
|
137
|
+
viewport: {
|
|
138
|
+
width: 1600,
|
|
139
|
+
height: 900,
|
|
140
|
+
deviceScaleFactor: 2,
|
|
141
|
+
mobile: false,
|
|
142
|
+
},
|
|
143
|
+
webgl: { unmaskedVendor: 'NVIDIA', unmaskedRenderer: 'RTX 4090' },
|
|
144
|
+
geolocation: { latitude: 52.52, longitude: 13.405, accuracy: 12 },
|
|
145
|
+
colorScheme: 'dark',
|
|
146
|
+
reducedMotion: 'reduce',
|
|
147
|
+
forcedColors: 'active',
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
assert.equal(profile.hardwareConcurrency, 24);
|
|
151
|
+
assert.equal(profile.deviceMemory, 32);
|
|
152
|
+
assert.equal(profile.screen.colorDepth, 30);
|
|
153
|
+
assert.equal(profile.viewport.deviceScaleFactor, 2);
|
|
154
|
+
assert.equal(profile.webgl.unmaskedRenderer, 'RTX 4090');
|
|
155
|
+
assert.equal(profile.geolocation.accuracy, 12);
|
|
156
|
+
assert.equal(profile.forcedColors, 'active');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('rejects a hardwareConcurrency that is not a positive integer', () => {
|
|
160
|
+
for (const value of [0, -4, 2.5, '8']) {
|
|
161
|
+
assert.throws(
|
|
162
|
+
() => resolveFingerprintProfile({ hardwareConcurrency: value }),
|
|
163
|
+
/hardwareConcurrency must be a positive integer/u
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('allows maxTouchPoints to be zero but not negative', () => {
|
|
169
|
+
assert.equal(
|
|
170
|
+
resolveFingerprintProfile({ maxTouchPoints: 0 }).maxTouchPoints,
|
|
171
|
+
0
|
|
172
|
+
);
|
|
173
|
+
assert.throws(
|
|
174
|
+
() => resolveFingerprintProfile({ maxTouchPoints: -1 }),
|
|
175
|
+
/maxTouchPoints must be a non-negative integer/u
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('requires screen width and height to be given together', () => {
|
|
180
|
+
assert.throws(
|
|
181
|
+
() => resolveFingerprintProfile({ screen: { width: 1920 } }),
|
|
182
|
+
/screen.width and screen.height must be provided together/u
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('requires viewport width and height to be given together', () => {
|
|
187
|
+
assert.throws(
|
|
188
|
+
() => resolveFingerprintProfile({ viewport: { height: 900 } }),
|
|
189
|
+
/viewport.width and viewport.height must be provided together/u
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('rejects out-of-range coordinates', () => {
|
|
194
|
+
assert.throws(
|
|
195
|
+
() =>
|
|
196
|
+
resolveFingerprintProfile({
|
|
197
|
+
geolocation: { latitude: 91, longitude: 0 },
|
|
198
|
+
}),
|
|
199
|
+
/latitude must be between -90 and 90/u
|
|
200
|
+
);
|
|
201
|
+
assert.throws(
|
|
202
|
+
() =>
|
|
203
|
+
resolveFingerprintProfile({
|
|
204
|
+
geolocation: { latitude: 0, longitude: -181 },
|
|
205
|
+
}),
|
|
206
|
+
/longitude must be between -180 and 180/u
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('rejects an unsupported enum value', () => {
|
|
211
|
+
assert.throws(
|
|
212
|
+
() => resolveFingerprintProfile({ colorScheme: 'sepia' }),
|
|
213
|
+
/colorScheme must be one of light, dark, no-preference/u
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('rejects an empty languages array', () => {
|
|
218
|
+
assert.throws(
|
|
219
|
+
() => resolveFingerprintProfile({ languages: [] }),
|
|
220
|
+
/languages must not be empty/u
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('rejects a brand entry with a non-string version', () => {
|
|
225
|
+
assert.throws(
|
|
226
|
+
() =>
|
|
227
|
+
resolveFingerprintProfile({
|
|
228
|
+
userAgentData: { brands: [{ brand: 'Chromium', version: 140 }] },
|
|
229
|
+
}),
|
|
230
|
+
/userAgentData.brands\[0\].version must be a string/u
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('treats null the same as an omitted field', () => {
|
|
235
|
+
const profile = resolveFingerprintProfile({
|
|
236
|
+
locale: null,
|
|
237
|
+
screen: null,
|
|
238
|
+
webgl: null,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
assert.deepEqual(profile, {});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('copies arrays so a later mutation of the input cannot reach the profile', () => {
|
|
245
|
+
const languages = ['de-DE', 'de'];
|
|
246
|
+
const profile = resolveFingerprintProfile({ languages });
|
|
247
|
+
languages.push('en');
|
|
248
|
+
|
|
249
|
+
assert.deepEqual(profile.languages, ['de-DE', 'de']);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('records a mechanism for every field the profile accepts', () => {
|
|
253
|
+
const everyField = resolveFingerprintProfile({
|
|
254
|
+
userAgent: CHROME_UA,
|
|
255
|
+
acceptLanguage: 'en-US,en',
|
|
256
|
+
languages: ['en-US', 'en'],
|
|
257
|
+
locale: 'en-US',
|
|
258
|
+
timezoneId: 'UTC',
|
|
259
|
+
platform: 'Win32',
|
|
260
|
+
vendor: 'Google Inc.',
|
|
261
|
+
hardwareConcurrency: 8,
|
|
262
|
+
deviceMemory: 8,
|
|
263
|
+
maxTouchPoints: 0,
|
|
264
|
+
doNotTrack: '1',
|
|
265
|
+
screen: { width: 1920, height: 1080 },
|
|
266
|
+
viewport: { width: 1280, height: 720 },
|
|
267
|
+
webgl: { unmaskedVendor: 'Intel' },
|
|
268
|
+
geolocation: { latitude: 0, longitude: 0 },
|
|
269
|
+
colorScheme: 'dark',
|
|
270
|
+
reducedMotion: 'reduce',
|
|
271
|
+
forcedColors: 'active',
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
for (const field of Object.keys(everyField)) {
|
|
275
|
+
assert.ok(
|
|
276
|
+
FINGERPRINT_FIELD_MECHANISMS[field] === 'browser' ||
|
|
277
|
+
FINGERPRINT_FIELD_MECHANISMS[field] === 'script',
|
|
278
|
+
`no mechanism recorded for "${field}"`
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gate that makes a cancelled job visible.
|
|
3
|
+
*
|
|
4
|
+
* GitHub reports a job killed by its `timeout-minutes` as *cancelled*, not
|
|
5
|
+
* *failed*, and a run whose only casualty is a cancelled job is filed under
|
|
6
|
+
* `cancelled` as well - run 24045269874 of this repository is one such run on
|
|
7
|
+
* `main`. scripts/check-pipeline-status.sh is the only thing that looks at
|
|
8
|
+
* that, so these tests pin the four readings it has to get right: a failure is
|
|
9
|
+
* always an error, a cancellation off the default branch is a warning, a
|
|
10
|
+
* cancellation on the default branch is an error unless a newer run has already
|
|
11
|
+
* taken over the branch head.
|
|
12
|
+
*
|
|
13
|
+
* See docs/CI-TIMEOUT-BUDGETS.md.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
import { describe, it } from 'node:test';
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
BASH_AVAILABLE,
|
|
25
|
+
REPO_ROOT,
|
|
26
|
+
repoPath,
|
|
27
|
+
runBashScript,
|
|
28
|
+
} from '../../helpers/repo.js';
|
|
29
|
+
|
|
30
|
+
const SCRIPT = repoPath('scripts/check-pipeline-status.sh');
|
|
31
|
+
|
|
32
|
+
function runGate(needs, env = {}, options = {}) {
|
|
33
|
+
return runBashScript(SCRIPT, [], {
|
|
34
|
+
cwd: options.cwd,
|
|
35
|
+
env: { NEEDS_JSON: JSON.stringify(needs), ...env },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function results(map) {
|
|
40
|
+
return Object.fromEntries(
|
|
41
|
+
Object.entries(map).map(([job, result]) => [job, { result }])
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('check-pipeline-status.sh', { skip: !BASH_AVAILABLE }, () => {
|
|
46
|
+
it('passes when every job succeeded or was legitimately skipped', () => {
|
|
47
|
+
const { status, output } = runGate(
|
|
48
|
+
results({ lint: 'success', release: 'skipped' }),
|
|
49
|
+
{ IS_MAIN: 'true', RUN_SHA: 'abc', BRANCH_HEAD_SHA: 'abc' }
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
assert.equal(status, 0);
|
|
53
|
+
assert.match(
|
|
54
|
+
output,
|
|
55
|
+
/All required jobs succeeded or were legitimately skipped/
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('fails on a failed job and names it', () => {
|
|
60
|
+
const { status, output } = runGate(
|
|
61
|
+
results({ lint: 'failure', test: 'success' }),
|
|
62
|
+
{ IS_MAIN: 'true', RUN_SHA: 'abc', BRANCH_HEAD_SHA: 'abc' }
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
assert.equal(status, 1);
|
|
66
|
+
assert.match(output, /::error::Pipeline failed\. Failing jobs: lint/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('only warns about a cancellation off the default branch', () => {
|
|
70
|
+
const { status, output } = runGate(results({ test: 'cancelled' }), {
|
|
71
|
+
IS_MAIN: 'false',
|
|
72
|
+
RUN_SHA: 'abc',
|
|
73
|
+
BRANCH_HEAD_SHA: 'abc',
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
assert.equal(status, 0);
|
|
77
|
+
assert.match(output, /::warning::Cancelled jobs: test/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('fails on a cancellation on the default branch head', () => {
|
|
81
|
+
const { status, output } = runGate(results({ test: 'cancelled' }), {
|
|
82
|
+
IS_MAIN: 'true',
|
|
83
|
+
RUN_SHA: 'abc',
|
|
84
|
+
BRANCH_HEAD_SHA: 'abc',
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
assert.equal(status, 1);
|
|
88
|
+
assert.match(output, /::error::Pipeline has cancelled jobs on main: test/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('only warns when a newer commit already superseded the run', () => {
|
|
92
|
+
const { status, output } = runGate(results({ test: 'cancelled' }), {
|
|
93
|
+
IS_MAIN: 'true',
|
|
94
|
+
RUN_SHA: 'abc',
|
|
95
|
+
BRANCH_HEAD_SHA: 'def',
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
assert.equal(status, 0);
|
|
99
|
+
assert.match(output, /This run tests abc; main is at def/);
|
|
100
|
+
assert.match(output, /::warning::Cancelled jobs: test/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('fails rather than guesses when the branch head cannot be resolved', () => {
|
|
104
|
+
// A missed supersede costs one noisy warning; a missed overrun on main
|
|
105
|
+
// costs a silent failure, so the unresolvable case has to be the loud one.
|
|
106
|
+
const directory = mkdtempSync(path.join(tmpdir(), 'pipeline-status-'));
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const { status, output } = runGate(
|
|
110
|
+
results({ test: 'cancelled' }),
|
|
111
|
+
{ IS_MAIN: 'true', RUN_SHA: 'abc', BRANCH_HEAD_SHA: '' },
|
|
112
|
+
{ cwd: directory }
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
assert.equal(status, 1);
|
|
116
|
+
assert.match(output, /assuming it is current/);
|
|
117
|
+
assert.match(output, /::error::Pipeline has cancelled jobs on main/);
|
|
118
|
+
} finally {
|
|
119
|
+
rmSync(directory, { force: true, recursive: true });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('refuses to run without the job results', () => {
|
|
124
|
+
const result = spawnSync('bash', [SCRIPT], {
|
|
125
|
+
cwd: REPO_ROOT,
|
|
126
|
+
encoding: 'utf8',
|
|
127
|
+
env: { ...process.env, NEEDS_JSON: '' },
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
assert.notEqual(result.status, 0);
|
|
131
|
+
assert.match(result.stderr, /NEEDS_JSON is required/);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The link checker used to fail pull requests for links that were not broken.
|
|
3
|
+
*
|
|
4
|
+
* Run 33959793880 is the reference case: lychee reported exactly one error, a
|
|
5
|
+
* `[502]` on a GitHub issue that answered 200 minutes later, and listed a
|
|
6
|
+
* healthy `[302]` redirect in a separate section. Both were reported as broken
|
|
7
|
+
* links with no archived copy, and the job failed. The fixture below is that
|
|
8
|
+
* report; the assertions pin each step of the decision that now keeps such a
|
|
9
|
+
* run green while still failing on a link that is really gone.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { describe, it } from 'node:test';
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
classifyStatus,
|
|
17
|
+
extractRejectedLinks,
|
|
18
|
+
judge,
|
|
19
|
+
} from '../../../../scripts/check-web-archive.mjs';
|
|
20
|
+
|
|
21
|
+
// Verbatim shape of lychee/out.md from run 33959793880.
|
|
22
|
+
const REPORT = `# Summary
|
|
23
|
+
|
|
24
|
+
| Status | Count |
|
|
25
|
+
|----------------|-------|
|
|
26
|
+
| 🔍 Total | 170 |
|
|
27
|
+
| 🔗 Unique | 123 |
|
|
28
|
+
| ✅ Successful | 166 |
|
|
29
|
+
| ⏳ Timeouts | 0 |
|
|
30
|
+
| 🔀 Redirected | 1 |
|
|
31
|
+
| 👻 Excluded | 3 |
|
|
32
|
+
| ❓ Unknown | 0 |
|
|
33
|
+
| 🚫 Errors | 1 |
|
|
34
|
+
| ⛔ Unsupported | 0 |
|
|
35
|
+
|
|
36
|
+
## Errors per input
|
|
37
|
+
|
|
38
|
+
### Errors in docs/case-studies/issue-53/README.md
|
|
39
|
+
|
|
40
|
+
* [502] <https://github.com/microsoft/playwright/issues/35743> (at 94:99) | Rejected status code: 502 Bad Gateway
|
|
41
|
+
|
|
42
|
+
## Redirects per input
|
|
43
|
+
|
|
44
|
+
### Redirects in docs/case-studies/issue-33/README.md
|
|
45
|
+
|
|
46
|
+
* https://docs.github.com/actions/security-guides/using-secrets-in-github-actions --[302]--> https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
describe('reading lychee’s report', () => {
|
|
50
|
+
it('takes the rejected links from the errors section only', () => {
|
|
51
|
+
assert.deepEqual(extractRejectedLinks(REPORT), [
|
|
52
|
+
{
|
|
53
|
+
url: 'https://github.com/microsoft/playwright/issues/35743',
|
|
54
|
+
status: '502',
|
|
55
|
+
},
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('never reports a redirect as broken', () => {
|
|
60
|
+
const urls = extractRejectedLinks(REPORT).map((link) => link.url);
|
|
61
|
+
assert.ok(
|
|
62
|
+
!urls.some((url) => url.startsWith('https://docs.github.com/')),
|
|
63
|
+
'a 302 listed under "Redirects per input" is a working link'
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('collects every input listed under the errors section', () => {
|
|
68
|
+
const report = [
|
|
69
|
+
'## Errors per input',
|
|
70
|
+
'',
|
|
71
|
+
'### Errors in a.md',
|
|
72
|
+
'',
|
|
73
|
+
'* [404] <https://example.com/gone> (at 1:1) | Not Found',
|
|
74
|
+
'',
|
|
75
|
+
'### Errors in b.md',
|
|
76
|
+
'',
|
|
77
|
+
'* [ERROR] <https://example.invalid/x> (at 2:2) | dns error',
|
|
78
|
+
'* [404] <https://example.com/gone> (at 3:3) | Not Found',
|
|
79
|
+
'',
|
|
80
|
+
'## Suggestions per input',
|
|
81
|
+
'',
|
|
82
|
+
'* [404] <https://example.com/not-an-error> (at 4:4)',
|
|
83
|
+
'',
|
|
84
|
+
].join('\n');
|
|
85
|
+
|
|
86
|
+
assert.deepEqual(extractRejectedLinks(report), [
|
|
87
|
+
{ url: 'https://example.com/gone', status: '404' },
|
|
88
|
+
{ url: 'https://example.invalid/x', status: 'ERROR' },
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('classifying a lychee status', () => {
|
|
94
|
+
it('treats a refusal to answer as saying nothing about the link', () => {
|
|
95
|
+
assert.equal(classifyStatus('429'), 'transient');
|
|
96
|
+
assert.equal(classifyStatus('500'), 'transient');
|
|
97
|
+
assert.equal(classifyStatus('502'), 'transient');
|
|
98
|
+
assert.equal(classifyStatus('503'), 'transient');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('treats a 4xx answer as the resource being gone', () => {
|
|
102
|
+
assert.equal(classifyStatus('404'), 'gone');
|
|
103
|
+
assert.equal(classifyStatus('410'), 'gone');
|
|
104
|
+
assert.equal(classifyStatus('403'), 'gone');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('keeps network-level failures out of both buckets', () => {
|
|
108
|
+
assert.equal(classifyStatus('ERROR'), 'unknown');
|
|
109
|
+
assert.equal(classifyStatus('TIMEOUT'), 'unknown');
|
|
110
|
+
assert.equal(classifyStatus('UNKNOWN'), 'unknown');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Stubs for the two questions `judge` asks. Naming them keeps each case below
|
|
115
|
+
// to the one thing it is about: what the second look said, and whether the
|
|
116
|
+
// Wayback Machine had a copy.
|
|
117
|
+
const answers = (status) => async () => ({ alive: status < 400, status });
|
|
118
|
+
const unreachable = () => async () => ({ alive: false, status: null });
|
|
119
|
+
|
|
120
|
+
const waybackNever = () => async () => {
|
|
121
|
+
throw new Error('the Wayback Machine must not be consulted here');
|
|
122
|
+
};
|
|
123
|
+
const waybackNothing = () => async () => ({
|
|
124
|
+
available: false,
|
|
125
|
+
archiveUrl: null,
|
|
126
|
+
timestamp: null,
|
|
127
|
+
});
|
|
128
|
+
const waybackHas = (timestamp, archiveUrl) => async () => ({
|
|
129
|
+
available: true,
|
|
130
|
+
archiveUrl,
|
|
131
|
+
timestamp,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const verdictFor = (link, recheckImpl, waybackImpl) =>
|
|
135
|
+
judge(link, { recheckImpl, waybackImpl });
|
|
136
|
+
|
|
137
|
+
describe('judging one rejected link', () => {
|
|
138
|
+
const playwright = {
|
|
139
|
+
url: 'https://github.com/microsoft/playwright/issues/35743',
|
|
140
|
+
status: '502',
|
|
141
|
+
};
|
|
142
|
+
const gone = { url: 'https://example.com/gone', status: '404' };
|
|
143
|
+
|
|
144
|
+
it('clears a link that answers on the second look', async () => {
|
|
145
|
+
const verdict = await verdictFor(playwright, answers(200), waybackNever());
|
|
146
|
+
assert.equal(verdict.verdict, 'alive');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('does not fail for a host that is down twice', async () => {
|
|
150
|
+
const verdict = await verdictFor(playwright, answers(503), waybackNever());
|
|
151
|
+
assert.equal(verdict.verdict, 'transient');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('does not fail for a host that keeps rate-limiting the runner', async () => {
|
|
155
|
+
const verdict = await verdictFor(
|
|
156
|
+
{ url: 'https://example.com/x', status: '429' },
|
|
157
|
+
answers(429),
|
|
158
|
+
waybackNever()
|
|
159
|
+
);
|
|
160
|
+
assert.equal(verdict.verdict, 'transient');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('suggests the snapshot when a confirmed 404 is archived', async () => {
|
|
164
|
+
const verdict = await verdictFor(
|
|
165
|
+
gone,
|
|
166
|
+
answers(404),
|
|
167
|
+
waybackHas(
|
|
168
|
+
'20231015143022',
|
|
169
|
+
'https://web.archive.org/web/20231015143022/https://example.com/gone'
|
|
170
|
+
)
|
|
171
|
+
);
|
|
172
|
+
assert.equal(verdict.verdict, 'archived');
|
|
173
|
+
assert.equal(verdict.date, '2023-10-15');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('fails on a confirmed 404 with no snapshot', async () => {
|
|
177
|
+
const verdict = await verdictFor(gone, answers(404), waybackNothing());
|
|
178
|
+
assert.equal(verdict.verdict, 'gone');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('still fails on a domain that no longer resolves', async () => {
|
|
182
|
+
// A dead domain and a runner with no DNS look identical to lychee, so an
|
|
183
|
+
// unreachable host is not waved through the way a 5xx is.
|
|
184
|
+
const verdict = await verdictFor(
|
|
185
|
+
{ url: 'https://example.invalid/x', status: 'ERROR' },
|
|
186
|
+
unreachable(),
|
|
187
|
+
waybackNothing()
|
|
188
|
+
);
|
|
189
|
+
assert.equal(verdict.verdict, 'gone');
|
|
190
|
+
});
|
|
191
|
+
});
|