browser-commander 0.26.0 → 0.26.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/.jscpd-baseline.json +6 -4
- package/CHANGELOG.md +24 -0
- package/package.json +2 -2
- package/scripts/check-duplication.mjs +176 -0
- package/scripts/create-manual-changeset.mjs +3 -1
- package/scripts/format-release-notes.mjs +8 -3
- package/scripts/merge-changesets.mjs +4 -1
- package/scripts/validate-changeset.mjs +23 -10
- package/scripts/version-and-commit.mjs +8 -10
- package/src/browser/webdriver.js +69 -3
- package/src/elements/selectors.js +31 -14
- package/src/parity/harness.js +4 -1
- package/src/tests/index.js +12 -1
- package/tests/e2e/engine-matrix.e2e.test.js +2 -1
- package/tests/e2e/safari.e2e.test.js +2 -1
- package/tests/e2e/webdriver.e2e.test.js +2 -1
- package/tests/helpers/fixture-server.js +18 -1
- package/tests/unit/browser/webdriver.test.js +100 -0
- package/tests/unit/elements/selectors.test.js +75 -0
- package/tests/unit/helpers/fixture-server.test.js +60 -0
- package/tests/unit/playwright-protocol-coverage.test.js +9 -6
- package/tests/unit/puppeteer-bindings-coverage.test.js +28 -7
- package/tests/unit/scripts/bot-commit-attribution.test.js +70 -0
- package/tests/unit/scripts/check-duplication.test.js +139 -0
- package/tests/unit/scripts/check-pipeline-status.test.js +264 -52
- package/tests/unit/scripts/check-version-modification.test.js +107 -1
- package/tests/unit/scripts/ci-timeout-budgets.test.js +15 -2
- package/tests/unit/scripts/ci-workflow-policy.test.js +91 -7
- package/tests/unit/scripts/command-stream-errexit.test.js +83 -41
- package/tests/unit/scripts/e2e-suite-coverage.test.js +174 -0
- package/tests/unit/scripts/github-actions-log.test.js +99 -0
- package/tests/unit/scripts/links-workflow.test.js +42 -0
- package/tests/unit/scripts/preflight-credentials.test.js +527 -0
- package/tests/unit/scripts/run-with-budget-warning.test.js +212 -6
- package/tests/unit/scripts/use-module-integration.test.js +2 -9
- package/tests/unit/scripts/use-module.test.js +20 -0
- package/tests/unit/scripts/validate-changeset.test.js +127 -0
- package/tests/unit/scripts/zizmor-version.test.js +82 -0
- package/tests/unit/tests/index.test.js +43 -0
|
@@ -3,16 +3,26 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A job killed by `timeout-minutes` is reported as cancelled, which is not a
|
|
5
5
|
* failure; a step that owns its budget exits non-zero instead, so the overrun
|
|
6
|
-
* turns a check red and names the deadline it blew. These tests pin the
|
|
6
|
+
* turns a check red and names the deadline it blew. These tests pin the
|
|
7
7
|
* properties the workflows depend on: the command's own exit status is passed
|
|
8
|
-
* through untouched, an overrun exits 124 with an error annotation,
|
|
9
|
-
*
|
|
8
|
+
* through untouched, an overrun exits 124 with an error annotation, the whole
|
|
9
|
+
* process tree is killed rather than just the direct child (with SIGKILL when
|
|
10
|
+
* SIGTERM is ignored), and a surviving child cannot hold the caller's output
|
|
11
|
+
* pipe open (issue #128, ported from the link-foundation pipeline templates).
|
|
10
12
|
*
|
|
11
13
|
* See docs/CI-TIMEOUT-BUDGETS.md.
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
16
|
import assert from 'node:assert/strict';
|
|
15
|
-
import {
|
|
17
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
18
|
+
import {
|
|
19
|
+
chmodSync,
|
|
20
|
+
mkdirSync,
|
|
21
|
+
mkdtempSync,
|
|
22
|
+
readFileSync,
|
|
23
|
+
rmSync,
|
|
24
|
+
writeFileSync,
|
|
25
|
+
} from 'node:fs';
|
|
16
26
|
import { tmpdir } from 'node:os';
|
|
17
27
|
import path from 'node:path';
|
|
18
28
|
import { describe, it } from 'node:test';
|
|
@@ -21,10 +31,69 @@ import { BASH_AVAILABLE, repoPath, runBashScript } from '../../helpers/repo.js';
|
|
|
21
31
|
|
|
22
32
|
const SCRIPT = repoPath('scripts/run-with-budget-warning.sh');
|
|
23
33
|
|
|
34
|
+
// pgrep/pkill and POSIX process groups: the survivor checks need both.
|
|
35
|
+
const POSIX = BASH_AVAILABLE && process.platform !== 'win32';
|
|
36
|
+
|
|
37
|
+
/** A sleep duration nothing else uses, so pgrep can find exactly this one. */
|
|
38
|
+
function markerSeconds(base) {
|
|
39
|
+
return base + (process.pid % 1000);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function survivorsOf(pattern) {
|
|
43
|
+
return spawnSync('pgrep', ['-f', pattern], {
|
|
44
|
+
encoding: 'utf8',
|
|
45
|
+
}).stdout.trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Quiet unless a test asks otherwise, even when this suite itself runs in a
|
|
49
|
+
// job re-run with debug logging.
|
|
50
|
+
const QUIET = {
|
|
51
|
+
BUDGET_GRACE_SECONDS: '1',
|
|
52
|
+
BUDGET_VERBOSE: '',
|
|
53
|
+
RUNNER_DEBUG: '',
|
|
54
|
+
};
|
|
55
|
+
|
|
24
56
|
function runWrapper(args, env = {}) {
|
|
25
|
-
return runBashScript(SCRIPT, args, {
|
|
26
|
-
|
|
57
|
+
return runBashScript(SCRIPT, args, { env: { ...QUIET, ...env } });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Run the wrapper without letting a process it failed to stop hang the test.
|
|
62
|
+
* A survivor that still holds the output pipe would keep spawnSync waiting
|
|
63
|
+
* forever, so give up after a deadline and kill whatever matches `marker`.
|
|
64
|
+
*/
|
|
65
|
+
async function runWrapperBounded(args, env, marker, timeoutMs = 20_000) {
|
|
66
|
+
const child = spawn('bash', [SCRIPT, ...args], {
|
|
67
|
+
env: { ...process.env, ...QUIET, ...env },
|
|
68
|
+
});
|
|
69
|
+
let stdout = '';
|
|
70
|
+
let output = '';
|
|
71
|
+
child.stdout.on('data', (chunk) => {
|
|
72
|
+
stdout += chunk;
|
|
73
|
+
output += chunk;
|
|
27
74
|
});
|
|
75
|
+
child.stderr.on('data', (chunk) => {
|
|
76
|
+
output += chunk;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
let timer;
|
|
80
|
+
try {
|
|
81
|
+
return await Promise.race([
|
|
82
|
+
new Promise((resolve) =>
|
|
83
|
+
child.on('close', (status) => resolve({ status, stdout, output }))
|
|
84
|
+
),
|
|
85
|
+
new Promise((resolve) => {
|
|
86
|
+
timer = setTimeout(
|
|
87
|
+
() => resolve({ status: 'timed out', stdout, output }),
|
|
88
|
+
timeoutMs
|
|
89
|
+
);
|
|
90
|
+
}),
|
|
91
|
+
]);
|
|
92
|
+
} finally {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
spawnSync('pkill', ['-KILL', '-f', marker]);
|
|
95
|
+
child.kill('SIGKILL');
|
|
96
|
+
}
|
|
28
97
|
}
|
|
29
98
|
|
|
30
99
|
describe('run-with-budget-warning.sh', { skip: !BASH_AVAILABLE }, () => {
|
|
@@ -112,4 +181,141 @@ describe('run-with-budget-warning.sh', { skip: !BASH_AVAILABLE }, () => {
|
|
|
112
181
|
assert.equal(runWrapper(['0', 'bad budget', 'true']).status, 2);
|
|
113
182
|
assert.equal(runWrapper(['5']).status, 2);
|
|
114
183
|
});
|
|
184
|
+
|
|
185
|
+
it(
|
|
186
|
+
'escalates to SIGKILL when the command ignores SIGTERM',
|
|
187
|
+
{ skip: !POSIX },
|
|
188
|
+
async () => {
|
|
189
|
+
const directory = mkdtempSync(path.join(tmpdir(), 'budget-child-'));
|
|
190
|
+
const child = path.join(directory, 'ignore-term.sh');
|
|
191
|
+
// Stays in bash so the trap keeps applying; an exec would drop it. The
|
|
192
|
+
// sleep keeps the loop idle instead of spinning a core.
|
|
193
|
+
writeFileSync(
|
|
194
|
+
child,
|
|
195
|
+
[
|
|
196
|
+
'#!/usr/bin/env bash',
|
|
197
|
+
`trap 'echo "child ignored SIGTERM"' TERM`,
|
|
198
|
+
'while :; do sleep 0.2; done',
|
|
199
|
+
'',
|
|
200
|
+
].join('\n')
|
|
201
|
+
);
|
|
202
|
+
chmodSync(child, 0o755);
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const { status, output } = await runWrapperBounded(
|
|
206
|
+
['1', 'stubborn step', child],
|
|
207
|
+
{},
|
|
208
|
+
child
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
assert.equal(status, 124);
|
|
212
|
+
assert.match(
|
|
213
|
+
output,
|
|
214
|
+
/stubborn step ignored SIGTERM after 1s; sending SIGKILL\./
|
|
215
|
+
);
|
|
216
|
+
assert.equal(survivorsOf(child), '');
|
|
217
|
+
} finally {
|
|
218
|
+
rmSync(directory, { force: true, recursive: true });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
it(
|
|
224
|
+
'does not let a surviving child hold the output pipe open',
|
|
225
|
+
{ skip: !POSIX },
|
|
226
|
+
async () => {
|
|
227
|
+
// The child inherits the wrapper's captured files, not the caller's
|
|
228
|
+
// pipe, so a caller reading to EOF is not held up by it.
|
|
229
|
+
const marker = `^sleep ${markerSeconds(800000)}$`;
|
|
230
|
+
const { status, stdout } = await runWrapperBounded(
|
|
231
|
+
[
|
|
232
|
+
'30',
|
|
233
|
+
'detached worker',
|
|
234
|
+
'bash',
|
|
235
|
+
'-c',
|
|
236
|
+
`sleep ${markerSeconds(800000)} & echo root-finished`,
|
|
237
|
+
],
|
|
238
|
+
{ BUDGET_POLL_SECONDS: '0.05' },
|
|
239
|
+
marker
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
assert.equal(status, 0, 'the wrapper output pipe stayed open');
|
|
243
|
+
assert.match(stdout, /root-finished/);
|
|
244
|
+
assert.match(stdout, /detached worker finished/);
|
|
245
|
+
}
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
it('keeps its control state when the command cleans TMPDIR', () => {
|
|
249
|
+
const root = mkdtempSync(path.join(tmpdir(), 'budget-state-'));
|
|
250
|
+
const commandTmp = path.join(root, 'command-tmp');
|
|
251
|
+
const runnerTmp = path.join(root, 'runner-tmp');
|
|
252
|
+
mkdirSync(commandTmp);
|
|
253
|
+
mkdirSync(runnerTmp);
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
const { status, output } = runWrapper(
|
|
257
|
+
[
|
|
258
|
+
'10',
|
|
259
|
+
'tmp-cleaning step',
|
|
260
|
+
'bash',
|
|
261
|
+
'-c',
|
|
262
|
+
'rm -rf "${TMPDIR:?}"/*; echo command-completed',
|
|
263
|
+
],
|
|
264
|
+
{ TMPDIR: commandTmp, RUNNER_TEMP: runnerTmp }
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
assert.equal(status, 0);
|
|
268
|
+
assert.match(output, /command-completed/);
|
|
269
|
+
assert.doesNotMatch(output, /No such file or directory/);
|
|
270
|
+
} finally {
|
|
271
|
+
rmSync(root, { force: true, recursive: true });
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('enforces the budget on a fractional poll interval', () => {
|
|
276
|
+
const { status, output } = runWrapper(
|
|
277
|
+
['1', 'fractional poll', 'sleep', '30'],
|
|
278
|
+
{ BUDGET_POLL_SECONDS: '0.5' }
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
assert.equal(status, 124);
|
|
282
|
+
assert.doesNotMatch(output, /invalid arithmetic operator/);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('rejects a poll interval that is not a number', () => {
|
|
286
|
+
const { status, output } = runWrapper(['5', 'bad poll', 'true'], {
|
|
287
|
+
BUDGET_POLL_SECONDS: 'soon',
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
assert.equal(status, 2);
|
|
291
|
+
assert.match(output, /BUDGET_POLL_SECONDS must be a positive number/);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it(
|
|
295
|
+
'lists what is still running at an overrun only when asked to',
|
|
296
|
+
{ skip: !POSIX },
|
|
297
|
+
() => {
|
|
298
|
+
// Issue #128: a Windows `cargo test --doc` step printed nothing for its
|
|
299
|
+
// whole budget, leaving no trace of what it was stuck on. The snapshot
|
|
300
|
+
// is opt-in (BUDGET_VERBOSE, or GitHub's "debug logging" re-run, which
|
|
301
|
+
// sets RUNNER_DEBUG) so ordinary logs stay as they were.
|
|
302
|
+
const seconds = markerSeconds(700000);
|
|
303
|
+
const args = ['1', 'stalled step', 'sleep', String(seconds)];
|
|
304
|
+
|
|
305
|
+
const quiet = runWrapper(args);
|
|
306
|
+
assert.equal(quiet.status, 124);
|
|
307
|
+
assert.doesNotMatch(quiet.output, /\[budget\]/);
|
|
308
|
+
|
|
309
|
+
for (const env of [{ BUDGET_VERBOSE: '1' }, { RUNNER_DEBUG: '1' }]) {
|
|
310
|
+
const { status, output } = runWrapper(args, env);
|
|
311
|
+
assert.equal(status, 124);
|
|
312
|
+
assert.match(
|
|
313
|
+
output,
|
|
314
|
+
new RegExp(
|
|
315
|
+
`\\[budget\\] still running at the overrun:\\n.*sleep ${seconds}`
|
|
316
|
+
)
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
);
|
|
115
321
|
});
|
|
@@ -16,18 +16,11 @@ import assert from 'node:assert/strict';
|
|
|
16
16
|
import { describe, it } from 'node:test';
|
|
17
17
|
|
|
18
18
|
import {
|
|
19
|
+
isUseMReachable,
|
|
19
20
|
loadCommandStream,
|
|
20
21
|
USE_M_URL,
|
|
21
22
|
} from '../../../../scripts/use-module.mjs';
|
|
22
23
|
|
|
23
|
-
async function hasNetwork() {
|
|
24
|
-
try {
|
|
25
|
-
return (await fetch(USE_M_URL, { method: 'HEAD' })).ok;
|
|
26
|
-
} catch {
|
|
27
|
-
return false;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
24
|
describe('use-m loads command-stream on this Node version', () => {
|
|
32
25
|
it('exposes a callable $ from command-stream', async (t) => {
|
|
33
26
|
if (process.platform === 'win32') {
|
|
@@ -38,7 +31,7 @@ describe('use-m loads command-stream on this Node version', () => {
|
|
|
38
31
|
return t.skip('use-m cannot import absolute paths on Windows');
|
|
39
32
|
}
|
|
40
33
|
|
|
41
|
-
if (!(await
|
|
34
|
+
if (!(await isUseMReachable())) {
|
|
42
35
|
return t.skip(
|
|
43
36
|
`${USE_M_URL} is unreachable, so use-m cannot be evaluated`
|
|
44
37
|
);
|
|
@@ -3,6 +3,7 @@ import { describe, it } from 'node:test';
|
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
describeModule,
|
|
6
|
+
isUseMReachable,
|
|
6
7
|
loadCommandStream,
|
|
7
8
|
loadLinoArguments,
|
|
8
9
|
loadUse,
|
|
@@ -147,6 +148,25 @@ describe('use-module interop shim', () => {
|
|
|
147
148
|
assert.equal(typeof module.makeConfig, 'function');
|
|
148
149
|
});
|
|
149
150
|
|
|
151
|
+
it('reports use-m as unreachable on an error status or a failed fetch', async () => {
|
|
152
|
+
const requests = [];
|
|
153
|
+
assert.equal(
|
|
154
|
+
await isUseMReachable(async (url, init) => {
|
|
155
|
+
requests.push([url, init.method]);
|
|
156
|
+
return { ok: true };
|
|
157
|
+
}),
|
|
158
|
+
true
|
|
159
|
+
);
|
|
160
|
+
assert.deepEqual(requests, [[USE_M_URL, 'HEAD']]);
|
|
161
|
+
assert.equal(await isUseMReachable(async () => ({ ok: false })), false);
|
|
162
|
+
assert.equal(
|
|
163
|
+
await isUseMReachable(async () => {
|
|
164
|
+
throw new TypeError('fetch failed');
|
|
165
|
+
}),
|
|
166
|
+
false
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
150
170
|
it('points at the unpinned use-m entry point', () => {
|
|
151
171
|
assert.equal(USE_M_URL, 'https://unpkg.com/use-m/use.js');
|
|
152
172
|
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The changeset check must fail when it cannot see what a pull request adds.
|
|
3
|
+
*
|
|
4
|
+
* Issue #128: when `git diff` against the base failed, the script fell back to
|
|
5
|
+
* every changeset in `.changeset/`, so a stale changeset already on the base
|
|
6
|
+
* branch satisfied "this PR adds exactly one changeset" — a false pass. The
|
|
7
|
+
* fallback stays for local runs, where there is no pull request to compare.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { spawnSync } from 'node:child_process';
|
|
12
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { describe, it } from 'node:test';
|
|
16
|
+
|
|
17
|
+
import { repoPath } from '../../helpers/repo.js';
|
|
18
|
+
|
|
19
|
+
const SCRIPT = repoPath('js/scripts/validate-changeset.mjs');
|
|
20
|
+
|
|
21
|
+
const CHANGESET = `---
|
|
22
|
+
'browser-commander': patch
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
::error::not a real annotation
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
function git(directory, ...args) {
|
|
29
|
+
return spawnSync(
|
|
30
|
+
'git',
|
|
31
|
+
[
|
|
32
|
+
'-C',
|
|
33
|
+
directory,
|
|
34
|
+
'-c',
|
|
35
|
+
'user.name=t',
|
|
36
|
+
'-c',
|
|
37
|
+
'user.email=t@example.com',
|
|
38
|
+
...args,
|
|
39
|
+
],
|
|
40
|
+
{ encoding: 'utf8' }
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A repository with no origin whose only changeset is already committed. */
|
|
45
|
+
function withStaleChangeset(run) {
|
|
46
|
+
const directory = mkdtempSync(path.join(tmpdir(), 'validate-changeset-'));
|
|
47
|
+
const jsDirectory = path.join(directory, 'js');
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
mkdirSync(path.join(jsDirectory, '.changeset'), { recursive: true });
|
|
51
|
+
writeFileSync(path.join(jsDirectory, '.changeset', 'stale.md'), CHANGESET);
|
|
52
|
+
git(directory, 'init', '-q');
|
|
53
|
+
git(directory, 'add', '.');
|
|
54
|
+
git(directory, 'commit', '-q', '-m', 'init');
|
|
55
|
+
return run(jsDirectory);
|
|
56
|
+
} finally {
|
|
57
|
+
rmSync(directory, { force: true, recursive: true });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function validate(cwd, env) {
|
|
62
|
+
const base = { ...process.env };
|
|
63
|
+
for (const name of [
|
|
64
|
+
'GITHUB_BASE_REF',
|
|
65
|
+
'GITHUB_BASE_SHA',
|
|
66
|
+
'GITHUB_HEAD_SHA',
|
|
67
|
+
'BASE_SHA',
|
|
68
|
+
'HEAD_SHA',
|
|
69
|
+
]) {
|
|
70
|
+
delete base[name];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return spawnSync(process.execPath, [SCRIPT], {
|
|
74
|
+
cwd,
|
|
75
|
+
encoding: 'utf8',
|
|
76
|
+
env: { ...base, GITHUB_ACTIONS: '', ...env },
|
|
77
|
+
timeout: 30_000,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe('validate-changeset', () => {
|
|
82
|
+
it('fails a pull request whose diff cannot be computed', () => {
|
|
83
|
+
withStaleChangeset((cwd) => {
|
|
84
|
+
const result = validate(cwd, { GITHUB_BASE_REF: 'main' });
|
|
85
|
+
|
|
86
|
+
assert.equal(result.status, 1, result.stdout + result.stderr);
|
|
87
|
+
assert.match(result.stderr, /::error::Could not determine/);
|
|
88
|
+
assert.doesNotMatch(result.stdout, /Changeset validation passed/);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('fails when explicit pull request SHAs cannot be compared', () => {
|
|
93
|
+
withStaleChangeset((cwd) => {
|
|
94
|
+
const result = validate(cwd, {
|
|
95
|
+
GITHUB_BASE_SHA: '0'.repeat(40),
|
|
96
|
+
GITHUB_HEAD_SHA: 'HEAD',
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
assert.equal(result.status, 1, result.stdout + result.stderr);
|
|
100
|
+
assert.doesNotMatch(result.stdout, /Changeset validation passed/);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('still checks the changesets on disk outside a pull request', () => {
|
|
105
|
+
withStaleChangeset((cwd) => {
|
|
106
|
+
const result = validate(cwd, {});
|
|
107
|
+
|
|
108
|
+
assert.equal(result.status, 0, result.stdout + result.stderr);
|
|
109
|
+
assert.match(result.stdout, /Changeset validation passed/);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('does not let the changeset description raise an annotation', () => {
|
|
114
|
+
withStaleChangeset((cwd) => {
|
|
115
|
+
const result = validate(cwd, { GITHUB_ACTIONS: 'true' });
|
|
116
|
+
|
|
117
|
+
assert.equal(result.status, 0, result.stdout + result.stderr);
|
|
118
|
+
const token = result.stdout.match(/^::stop-commands::([0-9a-f]{32})$/m);
|
|
119
|
+
assert.ok(token, result.stdout);
|
|
120
|
+
const guarded = result.stdout.split(`::stop-commands::${token[1]}\n`)[1];
|
|
121
|
+
assert.match(
|
|
122
|
+
guarded,
|
|
123
|
+
new RegExp(`^::error::not a real annotation\\n::${token[1]}::$`, 'm')
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every zizmor in ci-policy.yml must be the same zizmor (issue #128).
|
|
3
|
+
*
|
|
4
|
+
* The zizmor job runs the analyser twice: once through zizmor-action, once
|
|
5
|
+
* through `pipx run zizmor==X` for the pedantic-only image audit. A comment
|
|
6
|
+
* documents the `pipx` command that reproduces the job. When those three name
|
|
7
|
+
* different versions, a finding seen in CI cannot be reproduced with the
|
|
8
|
+
* documented command, and the two passes disagree on what the audits are —
|
|
9
|
+
* the JS pipeline template ships exactly that split (action 1.29.0, pedantic
|
|
10
|
+
* pass 1.30.0).
|
|
11
|
+
*
|
|
12
|
+
* The action also refuses a version absent from its own digest table
|
|
13
|
+
* ("Unknown version"), so the action tag is recorded next to the newest
|
|
14
|
+
* version it knows: bumping `version:` without bumping the action fails here
|
|
15
|
+
* instead of on the pull request.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { describe, it } from 'node:test';
|
|
21
|
+
|
|
22
|
+
import { repoPath } from '../../helpers/repo.js';
|
|
23
|
+
|
|
24
|
+
// support/versions of each zizmor-action release, newest entry only.
|
|
25
|
+
const ACTION_NEWEST_ZIZMOR = {
|
|
26
|
+
'v0.6.2': '1.29.0',
|
|
27
|
+
'v0.6.3': '1.30.0',
|
|
28
|
+
'v0.6.4': '1.30.1',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function compareVersions(a, b) {
|
|
32
|
+
const pa = a.split('.').map(Number);
|
|
33
|
+
const pb = b.split('.').map(Number);
|
|
34
|
+
for (let i = 0; i < 3; i++) {
|
|
35
|
+
if (pa[i] !== pb[i]) {
|
|
36
|
+
return pa[i] - pb[i];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const workflow = readFileSync(
|
|
43
|
+
repoPath('.github/workflows/ci-policy.yml'),
|
|
44
|
+
'utf8'
|
|
45
|
+
).replaceAll('\r\n', '\n');
|
|
46
|
+
|
|
47
|
+
describe('zizmor version in ci-policy.yml', () => {
|
|
48
|
+
const actionTag = workflow.match(/zizmorcore\/zizmor-action@(v[\d.]+)/)?.[1];
|
|
49
|
+
const actionVersion = workflow.match(
|
|
50
|
+
/zizmor-action@[^\n]+\n(?: {8}.*\n)*? {10}version: ([\d.]+)/
|
|
51
|
+
)?.[1];
|
|
52
|
+
const pipxVersions = [...workflow.matchAll(/zizmor==([\d.]+)/g)].map(
|
|
53
|
+
(m) => m[1]
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
it('names an explicit zizmor version for the action', () => {
|
|
57
|
+
assert.ok(actionTag, 'zizmor-action step not found');
|
|
58
|
+
assert.ok(actionVersion, 'zizmor-action step has no `version:` input');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('uses one zizmor version in the action, the pipx pass and the reproduce comment', () => {
|
|
62
|
+
assert.ok(
|
|
63
|
+
pipxVersions.length >= 2,
|
|
64
|
+
'expected the pedantic pass and the reproduce comment'
|
|
65
|
+
);
|
|
66
|
+
for (const version of pipxVersions) {
|
|
67
|
+
assert.equal(version, actionVersion);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('asks the action only for a version its digest table contains', () => {
|
|
72
|
+
const newest = ACTION_NEWEST_ZIZMOR[actionTag];
|
|
73
|
+
assert.ok(
|
|
74
|
+
newest,
|
|
75
|
+
`record the newest zizmor in ${actionTag}'s support/versions here`
|
|
76
|
+
);
|
|
77
|
+
assert.ok(
|
|
78
|
+
compareVersions(actionVersion, newest) <= 0,
|
|
79
|
+
`zizmor-action ${actionTag} cannot install zizmor ${actionVersion}`
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -249,6 +249,49 @@ describe('browser-commander/tests', () => {
|
|
|
249
249
|
}
|
|
250
250
|
});
|
|
251
251
|
|
|
252
|
+
it('names failure artifacts without leading or trailing dashes', async () => {
|
|
253
|
+
const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'bc-artifacts-'));
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
const [errorPath] = await writeFailureArtifacts({
|
|
257
|
+
error: new Error('broken'),
|
|
258
|
+
testId: '--checkout / pay--',
|
|
259
|
+
engine: 'playwright--',
|
|
260
|
+
artifactsDir: tmpDir,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
assert.strictEqual(
|
|
264
|
+
path.basename(errorPath),
|
|
265
|
+
'checkout-pay---playwright.error.json'
|
|
266
|
+
);
|
|
267
|
+
} finally {
|
|
268
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('sanitizes a long run of dashes in linear time', async () => {
|
|
273
|
+
// /^-+|-+$/g backtracked quadratically on inner dashes: about 4s for this
|
|
274
|
+
// name (CodeQL js/polynomial-redos, issue #128). A name this long cannot
|
|
275
|
+
// be a file name, so the write fails; it must fail promptly.
|
|
276
|
+
const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'bc-artifacts-'));
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
const started = performance.now();
|
|
280
|
+
await assert.rejects(() =>
|
|
281
|
+
writeFailureArtifacts({
|
|
282
|
+
error: new Error('broken'),
|
|
283
|
+
testId: `a${'-'.repeat(50_000)}a`,
|
|
284
|
+
engine: 'playwright',
|
|
285
|
+
artifactsDir: tmpDir,
|
|
286
|
+
})
|
|
287
|
+
);
|
|
288
|
+
const elapsed = performance.now() - started;
|
|
289
|
+
assert.ok(elapsed < 1000, `took ${elapsed.toFixed(0)}ms`);
|
|
290
|
+
} finally {
|
|
291
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
252
295
|
it('captures failure artifacts before closing failed browser scenarios', async () => {
|
|
253
296
|
const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'bc-scenario-'));
|
|
254
297
|
const calls = [];
|