browser-commander 0.26.0 → 0.26.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.jscpd-baseline.json +6 -4
  2. package/CHANGELOG.md +30 -0
  3. package/package.json +2 -2
  4. package/scripts/check-duplication.mjs +176 -0
  5. package/scripts/create-manual-changeset.mjs +3 -1
  6. package/scripts/format-release-notes.mjs +8 -3
  7. package/scripts/merge-changesets.mjs +4 -1
  8. package/scripts/validate-changeset.mjs +23 -10
  9. package/scripts/version-and-commit.mjs +8 -10
  10. package/src/browser/webdriver.js +69 -3
  11. package/src/elements/selectors.js +31 -14
  12. package/src/parity/harness.js +4 -1
  13. package/src/tests/index.js +12 -1
  14. package/tests/e2e/engine-matrix.e2e.test.js +2 -1
  15. package/tests/e2e/safari.e2e.test.js +2 -1
  16. package/tests/e2e/webdriver.e2e.test.js +2 -1
  17. package/tests/helpers/fixture-server.js +18 -1
  18. package/tests/unit/browser/webdriver.test.js +100 -0
  19. package/tests/unit/elements/selectors.test.js +75 -0
  20. package/tests/unit/helpers/fixture-server.test.js +60 -0
  21. package/tests/unit/playwright-protocol-coverage.test.js +9 -6
  22. package/tests/unit/puppeteer-bindings-coverage.test.js +28 -7
  23. package/tests/unit/scripts/bot-commit-attribution.test.js +70 -0
  24. package/tests/unit/scripts/check-duplication.test.js +139 -0
  25. package/tests/unit/scripts/check-pipeline-status.test.js +264 -52
  26. package/tests/unit/scripts/check-version-modification.test.js +107 -1
  27. package/tests/unit/scripts/ci-timeout-budgets.test.js +15 -2
  28. package/tests/unit/scripts/ci-workflow-policy.test.js +91 -7
  29. package/tests/unit/scripts/command-stream-errexit.test.js +83 -41
  30. package/tests/unit/scripts/e2e-suite-coverage.test.js +174 -0
  31. package/tests/unit/scripts/github-actions-log.test.js +99 -0
  32. package/tests/unit/scripts/links-workflow.test.js +42 -0
  33. package/tests/unit/scripts/preflight-credentials.test.js +555 -0
  34. package/tests/unit/scripts/run-with-budget-warning.test.js +212 -6
  35. package/tests/unit/scripts/trusted-publishing.test.js +78 -0
  36. package/tests/unit/scripts/use-module-integration.test.js +2 -9
  37. package/tests/unit/scripts/use-module.test.js +20 -0
  38. package/tests/unit/scripts/validate-changeset.test.js +127 -0
  39. package/tests/unit/scripts/zizmor-version.test.js +82 -0
  40. 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 three
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, and the
9
- * whole process tree is killed rather than just the direct child.
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 { mkdtempSync, readFileSync, rmSync } from 'node:fs';
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
- env: { BUDGET_GRACE_SECONDS: '1', ...env },
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
  });
@@ -0,0 +1,78 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'node:test';
3
+ import { parse } from 'yaml';
4
+
5
+ import { readRepoText } from '../../helpers/repo.js';
6
+
7
+ describe('trusted publishing configuration', () => {
8
+ const rust = parse(readRepoText('.github/workflows/rust.yml'));
9
+
10
+ for (const jobId of ['auto-release', 'manual-release']) {
11
+ it(`${jobId} uses a fresh OIDC token only when publishing`, () => {
12
+ const job = rust.jobs[jobId];
13
+ assert.equal(job.permissions['id-token'], 'write');
14
+ const auth = job.steps.find((step) => step.id === 'crates_auth');
15
+ const publish = job.steps.find((step) => step.id === 'publish-crate');
16
+ assert.ok(auth, 'missing crates.io authentication step');
17
+ assert.match(
18
+ auth.uses,
19
+ /^rust-lang\/crates-io-auth-action@[a-f0-9]{40}$/
20
+ );
21
+ assert.equal(auth.if, publish.if);
22
+ assert.equal(
23
+ publish.env.CARGO_REGISTRY_TOKEN,
24
+ '${{ steps.crates_auth.outputs.token }}'
25
+ );
26
+ assert.ok(job.steps.indexOf(auth) < job.steps.indexOf(publish));
27
+ assert.ok(
28
+ job.steps.indexOf(auth) >
29
+ job.steps.findIndex((step) => step.name === 'Build release')
30
+ );
31
+ });
32
+ }
33
+
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/
38
+ );
39
+ });
40
+
41
+ it('keeps the registered PyPI identity identical across preflight and releases', () => {
42
+ const python = parse(readRepoText('.github/workflows/python.yml'));
43
+ for (const jobId of [
44
+ 'release-preflight',
45
+ 'auto-release',
46
+ 'manual-release',
47
+ ]) {
48
+ assert.equal(python.jobs[jobId].permissions['id-token'], 'write');
49
+ assert.equal(python.jobs[jobId].environment, undefined);
50
+ }
51
+ });
52
+
53
+ it('marks the template snapshot as private without hiding the real npm package', () => {
54
+ assert.equal(
55
+ JSON.parse(
56
+ readRepoText(
57
+ 'docs/case-studies/issue-55/template-snapshots/js/package.json'
58
+ )
59
+ ).private,
60
+ true
61
+ );
62
+ assert.notEqual(JSON.parse(readRepoText('js/package.json')).private, true);
63
+ });
64
+
65
+ it('disables publishing for the cookie equivalence experiment only', () => {
66
+ const fixture = readRepoText(
67
+ 'experiments/issue-128/cookie-fixture-equivalence/Cargo.toml'
68
+ );
69
+ const packageTable = fixture
70
+ .split(/^\[package\]\s*$/m)[1]
71
+ ?.split(/^\[/m)[0];
72
+ assert.match(packageTable ?? '', /^publish\s*=\s*false\s*$/m);
73
+ assert.doesNotMatch(
74
+ readRepoText('rust/Cargo.toml'),
75
+ /^publish\s*=\s*false\s*$/m
76
+ );
77
+ });
78
+ });
@@ -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 hasNetwork())) {
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 = [];