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
|
@@ -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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
+
});
|