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.
Files changed (39) hide show
  1. package/.jscpd-baseline.json +6 -4
  2. package/CHANGELOG.md +24 -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 +527 -0
  34. package/tests/unit/scripts/run-with-budget-warning.test.js +212 -6
  35. package/tests/unit/scripts/use-module-integration.test.js +2 -9
  36. package/tests/unit/scripts/use-module.test.js +20 -0
  37. package/tests/unit/scripts/validate-changeset.test.js +127 -0
  38. package/tests/unit/scripts/zizmor-version.test.js +82 -0
  39. package/tests/unit/tests/index.test.js +43 -0
@@ -36,19 +36,21 @@ function workflow({ on = 'push', preamble = '', job = 'test', steps }) {
36
36
  ' GIT_CONFIG_KEY_0: init.defaultBranch',
37
37
  'jobs:',
38
38
  ` ${job}:`,
39
- ' runs-on: ubuntu-latest',
39
+ ' runs-on: ubuntu-24.04',
40
40
  ' timeout-minutes: 5',
41
41
  ' concurrency:',
42
42
  ` group: \${{ github.workflow }}-\${{ github.ref }}-${job}`,
43
43
  ' cancel-in-progress: true',
44
44
  ' steps:',
45
45
  ' - uses: actions/checkout@v6',
46
+ ' with:',
47
+ ' persist-credentials: false',
46
48
  steps,
47
49
  // Every workflow has to end in the gate that reads the other jobs'
48
50
  // results, so the scaffolding carries one; the gate's own tests are in
49
51
  // ci-timeout-budgets.test.js.
50
52
  ' pipeline-status:',
51
- ' runs-on: ubuntu-latest',
53
+ ' runs-on: ubuntu-24.04',
52
54
  ' timeout-minutes: 5',
53
55
  ' concurrency:',
54
56
  ' group: ${{ github.workflow }}-${{ github.ref }}-pipeline-status',
@@ -100,9 +102,11 @@ concurrency:
100
102
  cancel-in-progress: true
101
103
  jobs:
102
104
  test:
103
- runs-on: ubuntu-latest
105
+ runs-on: ubuntu-24.04
104
106
  steps:
105
107
  - uses: actions/checkout@v6
108
+ with:
109
+ persist-credentials: false
106
110
  - run: npm install
107
111
  - run: echo "\${{ github.head_ref }}"
108
112
  - uses: codecov/codecov-action@v6
@@ -163,6 +167,82 @@ jobs:
163
167
  assert.equal(countFailures(content), 1);
164
168
  });
165
169
 
170
+ it('rejects the moving ubuntu-latest label in runs-on and in a matrix', () => {
171
+ // ubuntu-latest moves to Ubuntu 26.04 from 2026-10-19
172
+ // (actions/runner-images#14748), and every job using it carried that
173
+ // migration warning in issue #128's runs.
174
+ const content = workflow({
175
+ preamble: '',
176
+ steps: [
177
+ ' strategy:',
178
+ ' matrix:',
179
+ ' os: [ubuntu-latest, windows-latest]',
180
+ ].join('\n'),
181
+ }).replace(' runs-on: ubuntu-24.04', ' runs-on: ubuntu-latest');
182
+
183
+ assert.equal(countFailures(content), 2);
184
+ });
185
+
186
+ it('rejects a checker that npx fetches at whatever version is newest', () => {
187
+ // `npx --yes -p secretlint` resolves the latest release on every run, so a
188
+ // new rule set could fail a pull request that changed nothing it scans.
189
+ const content = workflow({
190
+ steps: [
191
+ ' - run: npx --yes -p secretlint -p @secretlint/secretlint-rule-preset-recommend secretlint "**/*"',
192
+ ' - run: npx --yes -p secretlint@13.0.7 -p @secretlint/secretlint-rule-preset-recommend@13.0.7 secretlint "**/*"',
193
+ ].join('\n'),
194
+ });
195
+
196
+ assert.equal(countFailures(content), 1);
197
+ });
198
+
199
+ it('rejects a cargo audit that passes on advisory warnings', () => {
200
+ // Without --deny warnings, an unmaintained, unsound or yanked crate prints
201
+ // "warning: 1 allowed warning found" and exits 0 (issue #128,
202
+ // reproductions/cargo-audit-deny-warnings.log).
203
+ const content = workflow({
204
+ steps: [
205
+ ' - run: cargo audit --file Cargo.lock',
206
+ ' - run: cargo audit --file Cargo.lock --deny warnings',
207
+ ].join('\n'),
208
+ });
209
+
210
+ assert.equal(countFailures(content), 1);
211
+ });
212
+
213
+ it('rejects a checkout that leaves the job token in .git/config', () => {
214
+ // actions/checkout persists the token unless told not to, so any later
215
+ // step - an install script, an artifact upload of the workspace - can
216
+ // read it. zizmor reports this as artipacked at low confidence, below the
217
+ // floor the audit ran with, so 38 checkouts did it unreported (#128).
218
+ const content = workflow({ steps: '' }).replace(
219
+ ' with:\n persist-credentials: false\n',
220
+ ''
221
+ );
222
+
223
+ assert.equal(countFailures(content), 1);
224
+ });
225
+
226
+ it('rejects a persisted credential in a job that does not write to main', () => {
227
+ const content = workflow({ steps: '' }).replace(
228
+ 'persist-credentials: false',
229
+ 'persist-credentials: true'
230
+ );
231
+
232
+ assert.equal(countFailures(content), 1);
233
+ });
234
+
235
+ it('accepts a persisted credential in a main-writer job that pushes', () => {
236
+ const content = workflow({ job: 'release', steps: '' })
237
+ .replace(
238
+ 'group: ${{ github.workflow }}-${{ github.ref }}-release\n cancel-in-progress: true',
239
+ 'group: main-writer-${{ github.repository }}-main\n cancel-in-progress: false'
240
+ )
241
+ .replace('persist-credentials: false', 'persist-credentials: true');
242
+
243
+ assert.equal(countFailures(content), 0);
244
+ });
245
+
166
246
  it('accepts a base_ref bound to any environment variable name', () => {
167
247
  const content = workflow({
168
248
  on: 'pull_request',
@@ -229,7 +309,7 @@ jobs:
229
309
  steps: ' - run: npm ci --ignore-scripts',
230
310
  })}
231
311
  drifted:
232
- runs-on: ubuntu-latest
312
+ runs-on: ubuntu-24.04
233
313
  timeout-minutes: 5
234
314
  concurrency:
235
315
  group: \${{ github.workflow }}-\${{ github.ref }}-drifted
@@ -286,15 +366,17 @@ function lintAndTest({ needs, condition = '' }) {
286
366
  ' GIT_CONFIG_KEY_0: init.defaultBranch',
287
367
  'jobs:',
288
368
  ' lint:',
289
- ' runs-on: ubuntu-latest',
369
+ ' runs-on: ubuntu-24.04',
290
370
  ' timeout-minutes: 5',
291
371
  ' concurrency:',
292
372
  ' group: ${{ github.workflow }}-${{ github.ref }}-lint',
293
373
  ' cancel-in-progress: true',
294
374
  ' steps:',
295
375
  ' - uses: actions/checkout@v6',
376
+ ' with:',
377
+ ' persist-credentials: false',
296
378
  ' test:',
297
- ' runs-on: ubuntu-latest',
379
+ ' runs-on: ubuntu-24.04',
298
380
  ' timeout-minutes: 5',
299
381
  ' concurrency:',
300
382
  ' group: ${{ github.workflow }}-${{ github.ref }}-test',
@@ -303,8 +385,10 @@ function lintAndTest({ needs, condition = '' }) {
303
385
  condition,
304
386
  ' steps:',
305
387
  ' - uses: actions/checkout@v6',
388
+ ' with:',
389
+ ' persist-credentials: false',
306
390
  ' pipeline-status:',
307
- ' runs-on: ubuntu-latest',
391
+ ' runs-on: ubuntu-24.04',
308
392
  ' timeout-minutes: 5',
309
393
  ' concurrency:',
310
394
  ' group: ${{ github.workflow }}-${{ github.ref }}-pipeline-status',
@@ -23,20 +23,18 @@
23
23
  */
24
24
 
25
25
  import assert from 'node:assert/strict';
26
+ import { readdirSync, readFileSync } from 'node:fs';
27
+ import { dirname, join } from 'node:path';
26
28
  import { describe, it } from 'node:test';
29
+ import { fileURLToPath } from 'node:url';
27
30
 
28
31
  import {
32
+ isUseMReachable,
29
33
  loadCommandStream,
30
34
  USE_M_URL,
31
35
  } from '../../../../scripts/use-module.mjs';
32
36
 
33
- async function hasNetwork() {
34
- try {
35
- return (await fetch(USE_M_URL, { method: 'HEAD' })).ok;
36
- } catch {
37
- return false;
38
- }
39
- }
37
+ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../../../..');
40
38
 
41
39
  /**
42
40
  * Load command-stream, or a reason to skip.
@@ -51,7 +49,7 @@ async function load(t) {
51
49
  t.skip('use-m cannot import absolute paths on Windows');
52
50
  return null;
53
51
  }
54
- if (!(await hasNetwork())) {
52
+ if (!(await isUseMReachable())) {
55
53
  t.skip(`${USE_M_URL} is unreachable, so use-m cannot be evaluated`);
56
54
  return null;
57
55
  }
@@ -70,6 +68,20 @@ async function load(t) {
70
68
  }
71
69
  }
72
70
 
71
+ /**
72
+ * A test that runs commands through `$`, skipped when it cannot be loaded.
73
+ * @param {string} name
74
+ * @param {(shell: (...args: any[]) => Promise<any>) => Promise<void>} body
75
+ */
76
+ function itWithShell(name, body) {
77
+ it(name, async (t) => {
78
+ const $ = (await load(t))?.$;
79
+ if ($) {
80
+ await body($);
81
+ }
82
+ });
83
+ }
84
+
73
85
  describe('loadCommandStream makes a failed command fail', () => {
74
86
  it('turns errexit on, so the release scripts catch what they try to catch', async (t) => {
75
87
  const cs = await load(t);
@@ -85,48 +97,78 @@ describe('loadCommandStream makes a failed command fail', () => {
85
97
  );
86
98
  });
87
99
 
88
- it('rejects on a non-zero exit and reports the exit code', async (t) => {
89
- const cs = await load(t);
90
- if (!cs) {
91
- return;
92
- }
93
- const { $ } = cs;
94
-
95
- await assert.rejects(
96
- () => $`exit 7`,
97
- (error) => {
98
- assert.equal(error.code, 7, 'the exit code must survive on the error');
99
- return true;
100
- },
101
- 'a command exiting 7 must reject, not resolve'
102
- );
103
- });
104
-
105
- it('still resolves with stdout on success', async (t) => {
106
- const cs = await load(t);
107
- if (!cs) {
108
- return;
100
+ itWithShell(
101
+ 'rejects on a non-zero exit and reports the exit code',
102
+ async ($) => {
103
+ // A real process, as in the release scripts. command-stream's virtual
104
+ // `exit` builtin would also reject, but it writes "Command failed with
105
+ // exit code 7" to stderr on top, which surfaced as a stray line at the end
106
+ // of every CI test log (issue #128).
107
+ await assert.rejects(
108
+ () => $`sh -c "exit 7"`,
109
+ (error) => {
110
+ assert.equal(
111
+ error.code,
112
+ 7,
113
+ 'the exit code must survive on the error'
114
+ );
115
+ return true;
116
+ },
117
+ 'a command exiting 7 must reject, not resolve'
118
+ );
109
119
  }
110
- const { $ } = cs;
120
+ );
111
121
 
122
+ itWithShell('still resolves with stdout on success', async ($) => {
112
123
  // errexit must not turn every command into a throw: the success path is
113
124
  // what the release scripts read version numbers and git status out of.
114
125
  const result = await $`echo errexit-ok`;
115
126
  assert.match(String(result.stdout), /errexit-ok/);
116
127
  });
117
128
 
118
- it('reports a non-zero exit for the exact command that broke the Rust release', async (t) => {
119
- const cs = await load(t);
120
- if (!cs) {
121
- return;
129
+ itWithShell(
130
+ 'reports a non-zero exit for the exact command that broke the Rust release',
131
+ async ($) => {
132
+ // `git diff --cached --quiet` exits 1 when there ARE staged changes.
133
+ // rust/scripts/version-and-commit.mjs relied on that becoming a rejection
134
+ // and, because it did not, reported "No changes to commit" after staging
135
+ // two modified files. `false` stands in for it here so the test needs no
136
+ // git fixture; the contract under test is identical.
137
+ await assert.rejects(() => $`false`, 'a command exiting 1 must reject');
122
138
  }
123
- const { $ } = cs;
139
+ );
140
+ });
141
+
142
+ describe('command-stream quotes interpolated values itself', () => {
143
+ // `${value}` in a `$` template is passed through as one argument, quoted by
144
+ // the library. Escaping by hand on top of that is not a no-op: inside
145
+ // `"${…}"` the backslashes survive into the argument. CodeQL flagged the
146
+ // hand escaping in js/scripts/version-and-commit.mjs
147
+ // (js/incomplete-sanitization, issue #128); this pins why it was removed.
148
+ const message = 'a "quoted" $HOME `id` back\\slash';
149
+
150
+ itWithShell('passes a bare interpolation through unchanged', async ($) => {
151
+ const result = await $({ mirror: false })`printf '%s' ${message}`;
152
+ assert.equal(String(result.stdout), message);
153
+ });
124
154
 
125
- // `git diff --cached --quiet` exits 1 when there ARE staged changes.
126
- // rust/scripts/version-and-commit.mjs relied on that becoming a rejection
127
- // and, because it did not, reported "No changes to commit" after staging
128
- // two modified files. `false` stands in for it here so the test needs no
129
- // git fixture; the contract under test is identical.
130
- await assert.rejects(() => $`false`, 'a command exiting 1 must reject');
155
+ itWithShell('keeps hand-added backslashes inside quotes', async ($) => {
156
+ // What the removed code produced: `"` escaped, nothing else.
157
+ const handEscaped = 'a \\"quoted\\" $HOME `id` back\\slash';
158
+ const result = await $({ mirror: false })`printf '%s' "${handEscaped}"`;
159
+ assert.notEqual(String(result.stdout), message);
160
+ });
161
+
162
+ it('no release script escapes quotes by hand before interpolating', () => {
163
+ const offenders = ['js/scripts', 'rust/scripts']
164
+ .flatMap((dir) =>
165
+ readdirSync(join(REPO_ROOT, dir))
166
+ .filter((name) => name.endsWith('.mjs'))
167
+ .map((name) => join(dir, name))
168
+ )
169
+ .filter((file) =>
170
+ /\.replace\(\/"\/g/.test(readFileSync(join(REPO_ROOT, file), 'utf8'))
171
+ );
172
+ assert.deepEqual(offenders, []);
131
173
  });
132
174
  });
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Every real-browser suite is run by some workflow, or says why it is not.
3
+ *
4
+ * An end-to-end suite skips itself without `RUN_E2E` (or, in Rust, is marked
5
+ * `#[ignore]`), and a skipped suite exits zero. So a suite that no workflow
6
+ * opts into is not a failing check: it is no check at all, and CI stays green
7
+ * while it rots. Issue #128 found ten of them across the three languages.
8
+ *
9
+ * This test lists the suites on disk and fails when one is neither invoked by
10
+ * a workflow nor named in MANUAL_ONLY with a reason a reviewer can judge.
11
+ * "Invoked" means the suite's file (JavaScript, Python) or test target (Rust,
12
+ * together with `--ignored`) appears in a command a workflow runs, directly or
13
+ * through the js/package.json script the command calls. Comments and `paths:`
14
+ * filters do not count, and neither does a whole-directory run such as
15
+ * `pytest tests/`, which collects the suites only to skip them.
16
+ */
17
+
18
+ import assert from 'node:assert/strict';
19
+ import { readdirSync } from 'node:fs';
20
+ import { describe, it } from 'node:test';
21
+
22
+ import { readRepoText, repoPath } from '../../helpers/repo.js';
23
+
24
+ /**
25
+ * Suites no workflow can run, keyed by repository path. The reason is the
26
+ * point: it is what a reviewer weighs when deciding whether the gap is real.
27
+ */
28
+ const MANUAL_ONLY = {
29
+ 'js/tests/e2e/google-signin-probe.e2e.test.js':
30
+ "Drives Google's live sign-in page, so a CI run would depend on a third-party site and risk tripping its abuse detection from a shared runner IP; it is opt-in through RUN_GOOGLE_PROBE.",
31
+ };
32
+
33
+ function listFiles(directory, pattern) {
34
+ return readdirSync(repoPath(directory))
35
+ .filter((name) => pattern.test(name))
36
+ .sort()
37
+ .map((name) => `${directory}/${name}`);
38
+ }
39
+
40
+ function baseName(file) {
41
+ return file.slice(file.lastIndexOf('/') + 1);
42
+ }
43
+
44
+ const SUITES = [
45
+ ...listFiles('js/tests/e2e', /\.e2e\.test\.js$/),
46
+ ...listFiles('python/tests/e2e', /^test_.*\.py$/),
47
+ ...listFiles('rust/tests', /\.rs$/).filter((file) =>
48
+ /#\[ignore/.test(readRepoText(file))
49
+ ),
50
+ ];
51
+
52
+ /**
53
+ * The commands a workflow runs: comment lines dropped, shell line
54
+ * continuations joined so one command is one line, and the `on:` trigger
55
+ * section (whose `paths:` filters list file names) left out.
56
+ */
57
+ function workflowCommands(text) {
58
+ const jobsStart = text.indexOf('\njobs:\n');
59
+
60
+ return (jobsStart === -1 ? '' : text.slice(jobsStart))
61
+ .replace(/\\\n\s*/g, ' ')
62
+ .split('\n')
63
+ .filter((line) => !/^\s*#/.test(line))
64
+ .join('\n');
65
+ }
66
+
67
+ const COMMANDS = readdirSync(repoPath('.github/workflows'))
68
+ .filter((name) => name.endsWith('.yml'))
69
+ .sort()
70
+ .map((name) => ({
71
+ workflow: name,
72
+ commands: workflowCommands(readRepoText('.github/workflows', name)),
73
+ }));
74
+
75
+ const PACKAGE_SCRIPTS = JSON.parse(readRepoText('js/package.json')).scripts;
76
+
77
+ function tokens(command) {
78
+ return command.split(/[\s'"]+/).filter(Boolean);
79
+ }
80
+
81
+ /** The js/package.json scripts whose command names this file. */
82
+ function scriptsRunning(file) {
83
+ const relative = file.replace(/^js\//, '');
84
+
85
+ return Object.entries(PACKAGE_SCRIPTS)
86
+ .filter(([, command]) => tokens(command).includes(relative))
87
+ .map(([name]) => name);
88
+ }
89
+
90
+ /** Where the suite is run, as "<workflow>: <how>" strings. */
91
+ function findRunners(file) {
92
+ const name = baseName(file);
93
+ const runners = [];
94
+
95
+ for (const { workflow, commands } of COMMANDS) {
96
+ const lines = commands.split('\n');
97
+
98
+ if (file.startsWith('rust/')) {
99
+ const target = name.replace(/\.rs$/, '');
100
+ const runsIt = lines.some((line) => {
101
+ const words = tokens(line);
102
+ return (
103
+ words.includes('--ignored') &&
104
+ words.some(
105
+ (word, index) => word === '--test' && words[index + 1] === target
106
+ )
107
+ );
108
+ });
109
+ if (runsIt) {
110
+ runners.push(`${workflow}: --test ${target} --ignored`);
111
+ }
112
+ continue;
113
+ }
114
+
115
+ if (
116
+ lines.some((line) =>
117
+ tokens(line).some((word) => word === name || word.endsWith(`/${name}`))
118
+ )
119
+ ) {
120
+ runners.push(`${workflow}: ${name}`);
121
+ }
122
+
123
+ for (const script of scriptsRunning(file)) {
124
+ const runsScript = lines.some((line) =>
125
+ tokens(line).some(
126
+ (word, index, words) =>
127
+ word === script &&
128
+ words[index - 1] === 'run' &&
129
+ words[index - 2] === 'npm'
130
+ )
131
+ );
132
+ if (runsScript) {
133
+ runners.push(`${workflow}: npm run ${script}`);
134
+ }
135
+ }
136
+ }
137
+
138
+ return runners;
139
+ }
140
+
141
+ describe('end-to-end suite coverage', () => {
142
+ it('finds the suites to check', () => {
143
+ for (const language of ['js/', 'python/', 'rust/']) {
144
+ assert.ok(
145
+ SUITES.some((file) => file.startsWith(language)),
146
+ `no ${language} end-to-end suites found; the file patterns are stale`
147
+ );
148
+ }
149
+ });
150
+
151
+ it('runs every end-to-end suite in some workflow, or says why not', () => {
152
+ const unrun = SUITES.filter(
153
+ (file) => !(file in MANUAL_ONLY) && findRunners(file).length === 0
154
+ );
155
+
156
+ assert.deepEqual(
157
+ unrun,
158
+ [],
159
+ 'these suites skip in every workflow, so CI is green whether or not they pass; run them in a workflow (.github/workflows/parity.yml) or add them to MANUAL_ONLY with a reason'
160
+ );
161
+ });
162
+
163
+ it('keeps MANUAL_ONLY to suites that exist and that no workflow runs', () => {
164
+ for (const [file, reason] of Object.entries(MANUAL_ONLY)) {
165
+ assert.ok(SUITES.includes(file), `${file} is not an end-to-end suite`);
166
+ assert.ok(reason.length > 40, `${file} needs a real reason`);
167
+ assert.deepEqual(
168
+ findRunners(file),
169
+ [],
170
+ `${file} is run by a workflow; drop it from MANUAL_ONLY`
171
+ );
172
+ }
173
+ });
174
+ });
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Contributor-authored text must not become a workflow command.
3
+ *
4
+ * GitHub Actions reads any log line that starts with `::name::` or `##[name]`
5
+ * as a command, so a changeset that quotes `::error::` printed bare raises a
6
+ * fake annotation — and `::add-mask::`, `::stop-commands::` or `::debug::` can
7
+ * change what the rest of the log shows. Issue #128 adopts the
8
+ * link-foundation templates' guard (rust template issue #173).
9
+ */
10
+
11
+ import assert from 'node:assert/strict';
12
+ import { readFileSync } from 'node:fs';
13
+ import { describe, it } from 'node:test';
14
+
15
+ import { printUntrusted } from '../../../../scripts/github-actions-log.mjs';
16
+ import { repoPath } from '../../helpers/repo.js';
17
+
18
+ function capturePrint(value, options = {}) {
19
+ let output = '';
20
+ printUntrusted(value, {
21
+ stream: { write: (chunk) => (output += chunk) },
22
+ ...options,
23
+ });
24
+ return output;
25
+ }
26
+
27
+ describe('github-actions-log printUntrusted', () => {
28
+ it('prints local output unchanged', () => {
29
+ assert.equal(
30
+ capturePrint('quoted ::error:: text', { githubActions: false }),
31
+ 'quoted ::error:: text\n'
32
+ );
33
+ });
34
+
35
+ it('brackets the whole value while GitHub interprets commands', () => {
36
+ const output = capturePrint('first\n::error::not a real annotation', {
37
+ githubActions: true,
38
+ tokenFactory: () => '0123456789abcdef0123456789abcdef',
39
+ });
40
+
41
+ assert.equal(
42
+ output,
43
+ [
44
+ '::stop-commands::0123456789abcdef0123456789abcdef',
45
+ 'first',
46
+ '::error::not a real annotation',
47
+ '::0123456789abcdef0123456789abcdef::',
48
+ '',
49
+ ].join('\n')
50
+ );
51
+ });
52
+
53
+ it('uses a fresh 128-bit token for every print', () => {
54
+ const tokenPattern = /^::stop-commands::([0-9a-f]{32})$/m;
55
+ const first = capturePrint('a', { githubActions: true });
56
+ const second = capturePrint('b', { githubActions: true });
57
+ const firstToken = first.match(tokenPattern)?.[1];
58
+ const secondToken = second.match(tokenPattern)?.[1];
59
+
60
+ assert.ok(firstToken && secondToken);
61
+ assert.notEqual(firstToken, secondToken);
62
+ assert.ok(first.endsWith(`::${firstToken}::\n`));
63
+ });
64
+
65
+ it('follows GITHUB_ACTIONS when not told otherwise', () => {
66
+ const saved = process.env.GITHUB_ACTIONS;
67
+
68
+ try {
69
+ process.env.GITHUB_ACTIONS = 'true';
70
+ assert.match(capturePrint('x'), /^::stop-commands::/);
71
+ process.env.GITHUB_ACTIONS = '';
72
+ assert.equal(capturePrint('x'), 'x\n');
73
+ } finally {
74
+ if (saved === undefined) {
75
+ delete process.env.GITHUB_ACTIONS;
76
+ } else {
77
+ process.env.GITHUB_ACTIONS = saved;
78
+ }
79
+ }
80
+ });
81
+
82
+ it('guards every script that prints a changeset or release description', () => {
83
+ for (const file of [
84
+ 'js/scripts/validate-changeset.mjs',
85
+ 'js/scripts/merge-changesets.mjs',
86
+ 'js/scripts/create-manual-changeset.mjs',
87
+ 'js/scripts/version-and-commit.mjs',
88
+ ]) {
89
+ const source = readFileSync(repoPath(file), 'utf8');
90
+
91
+ assert.match(
92
+ source,
93
+ /from '\.\.\/\.\.\/scripts\/github-actions-log\.mjs'/,
94
+ file
95
+ );
96
+ assert.match(source, /printUntrusted\(/, file);
97
+ }
98
+ });
99
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * links.yml runs lychee with `--cache`, which reads and writes `.lycheecache`
3
+ * in the workspace. Every job starts from a fresh checkout, so without a step
4
+ * that restores and saves that file the flag does nothing: each run checked
5
+ * every link from scratch, while the job comment promised "<1min with the
6
+ * lychee cache" (issue #128). The lychee-action README pairs the flag with
7
+ * actions/cache for this reason.
8
+ */
9
+
10
+ import assert from 'node:assert/strict';
11
+ import { readFileSync } from 'node:fs';
12
+ import { describe, it } from 'node:test';
13
+
14
+ import { repoPath } from '../../helpers/repo.js';
15
+
16
+ const workflow = readFileSync(
17
+ repoPath('.github/workflows/links.yml'),
18
+ 'utf8'
19
+ ).replaceAll('\r\n', '\n');
20
+
21
+ describe('links.yml lychee cache', () => {
22
+ const lycheeStep = workflow.indexOf('uses: lycheeverse/lychee-action@');
23
+ const usesCacheFlag = /^\s+--cache\s*$/m.test(workflow);
24
+
25
+ it('runs lychee', () => {
26
+ assert.ok(lycheeStep > 0, 'lychee step not found');
27
+ });
28
+
29
+ it('restores .lycheecache before lychee when --cache is on', () => {
30
+ if (!usesCacheFlag) {
31
+ return;
32
+ }
33
+ const cacheStep = workflow.search(
34
+ /uses: actions\/cache@\S+\n\s+with:\n\s+path: \.lycheecache\n/
35
+ );
36
+ assert.ok(
37
+ cacheStep > 0,
38
+ '--cache needs an actions/cache step for .lycheecache'
39
+ );
40
+ assert.ok(cacheStep < lycheeStep, 'the cache must be restored first');
41
+ });
42
+ });