browser-commander 0.25.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 +32 -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/browser-sources.json +10 -2
- package/src/browser/launcher.js +14 -0
- package/src/browser/real-browser.js +5 -0
- package/src/browser/safari-launch.js +42 -0
- package/src/browser/safari-support.js +98 -0
- package/src/browser/storage-state.js +28 -8
- package/src/browser/webdriver.js +132 -21
- package/src/cli/sessions.js +5 -0
- package/src/core/webdriver-page.js +62 -0
- package/src/elements/selectors.js +31 -14
- package/src/exports.js +6 -0
- package/src/parity/harness.js +4 -1
- package/src/tests/index.js +12 -1
- package/src/traces/recorder.js +5 -0
- package/tests/e2e/engine-matrix.e2e.test.js +2 -1
- package/tests/e2e/safari.e2e.test.js +105 -0
- package/tests/e2e/webdriver.e2e.test.js +2 -1
- package/tests/helpers/fixture-server.js +18 -1
- package/tests/unit/browser/real-browser.test.js +1 -1
- package/tests/unit/browser/safari.test.js +192 -0
- package/tests/unit/browser/webdriver.test.js +148 -3
- package/tests/unit/cli/dispatcher.test.js +1 -1
- 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
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
2
6
|
import { describe, it } from 'node:test';
|
|
3
7
|
|
|
4
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
checkVersionModification,
|
|
10
|
+
findManualVersionChanges,
|
|
11
|
+
} from '../../../../scripts/check-version-modification.mjs';
|
|
12
|
+
import { repoPath } from '../../helpers/repo.js';
|
|
13
|
+
|
|
14
|
+
const SCRIPT = repoPath('scripts/check-version-modification.mjs');
|
|
5
15
|
|
|
6
16
|
// The real implementation shells out to git; injecting the diff keeps these
|
|
7
17
|
// cases independent of the branch the suite happens to run on.
|
|
@@ -68,3 +78,99 @@ describe('check-version-modification', () => {
|
|
|
68
78
|
assert.deepEqual(findManualVersionChanges('main', diff), []);
|
|
69
79
|
});
|
|
70
80
|
});
|
|
81
|
+
|
|
82
|
+
describe('check-version-modification when git cannot diff', () => {
|
|
83
|
+
// Issue #128 (link-foundation/rust-ai-driven-development-pipeline-template
|
|
84
|
+
// #174): a failed `git diff` used to read as "nothing changed", so a missing
|
|
85
|
+
// base ref turned the check into a pass.
|
|
86
|
+
function failingDiff() {
|
|
87
|
+
throw new Error("fatal: ambiguous argument 'origin/main...HEAD'");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
it('fails rather than reporting no change', () => {
|
|
91
|
+
assert.throws(
|
|
92
|
+
() => findManualVersionChanges('main', failingDiff),
|
|
93
|
+
/Could not diff js\/package\.json against origin\/main/
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('fetches the base once and retries before giving up', () => {
|
|
98
|
+
let fetched = 0;
|
|
99
|
+
const diff = (_baseRef, path) => {
|
|
100
|
+
if (fetched === 0) {
|
|
101
|
+
failingDiff();
|
|
102
|
+
}
|
|
103
|
+
return path === 'rust/Cargo.toml' ? '+version = "0.9.1"' : '';
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
assert.deepEqual(
|
|
107
|
+
checkVersionModification('main', {
|
|
108
|
+
diff,
|
|
109
|
+
fetchBase: () => {
|
|
110
|
+
fetched += 1;
|
|
111
|
+
},
|
|
112
|
+
}),
|
|
113
|
+
['rust/Cargo.toml']
|
|
114
|
+
);
|
|
115
|
+
assert.equal(fetched, 1);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('reports the failure when the retry fails too', () => {
|
|
119
|
+
let fetched = 0;
|
|
120
|
+
|
|
121
|
+
assert.throws(
|
|
122
|
+
() =>
|
|
123
|
+
checkVersionModification('main', {
|
|
124
|
+
diff: failingDiff,
|
|
125
|
+
fetchBase: () => {
|
|
126
|
+
fetched += 1;
|
|
127
|
+
},
|
|
128
|
+
}),
|
|
129
|
+
/Could not diff/
|
|
130
|
+
);
|
|
131
|
+
assert.equal(fetched, 1);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('exits 1 with an error annotation from the command line', () => {
|
|
135
|
+
const directory = mkdtempSync(path.join(tmpdir(), 'version-check-'));
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
// A repository with no origin: the base ref cannot exist, and neither
|
|
139
|
+
// can the fetch that would bring it in.
|
|
140
|
+
spawnSync('git', ['init', '-q', directory]);
|
|
141
|
+
spawnSync(
|
|
142
|
+
'git',
|
|
143
|
+
[
|
|
144
|
+
'-C',
|
|
145
|
+
directory,
|
|
146
|
+
'-c',
|
|
147
|
+
'user.name=t',
|
|
148
|
+
'-c',
|
|
149
|
+
'user.email=t@example.com',
|
|
150
|
+
'commit',
|
|
151
|
+
'-q',
|
|
152
|
+
'--allow-empty',
|
|
153
|
+
'-m',
|
|
154
|
+
'init',
|
|
155
|
+
],
|
|
156
|
+
{ encoding: 'utf8' }
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const result = spawnSync(process.execPath, [SCRIPT], {
|
|
160
|
+
cwd: directory,
|
|
161
|
+
encoding: 'utf8',
|
|
162
|
+
env: {
|
|
163
|
+
...process.env,
|
|
164
|
+
GITHUB_BASE_REF: 'main',
|
|
165
|
+
GITHUB_HEAD_REF: 'feature',
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
assert.equal(result.status, 1, result.stdout + result.stderr);
|
|
170
|
+
assert.match(result.stderr, /::error::Could not diff/);
|
|
171
|
+
assert.doesNotMatch(result.stdout, /No manual version changes/);
|
|
172
|
+
} finally {
|
|
173
|
+
rmSync(directory, { force: true, recursive: true });
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -199,18 +199,31 @@ describe('CI execution budgets', () => {
|
|
|
199
199
|
'parity.yml cli CLI and API coverage suites',
|
|
200
200
|
'parity.yml cli Cross-language CLI contract',
|
|
201
201
|
'parity.yml cli Rust CLI build',
|
|
202
|
+
'parity.yml engine-suites Engine lifecycle suites',
|
|
203
|
+
'parity.yml fixture-suites React fixture suites',
|
|
202
204
|
'parity.yml parity Fingerprint parity suite',
|
|
203
205
|
'parity.yml parity WebDriver suite',
|
|
206
|
+
'parity.yml snapshots Python headful real-browser launches',
|
|
204
207
|
'parity.yml snapshots Python native snapshot launches',
|
|
208
|
+
'parity.yml snapshots Rust launch and connect smokes',
|
|
205
209
|
'parity.yml snapshots Rust native snapshots and parity',
|
|
206
210
|
'parity.yml storage-state Rust storage state transfer',
|
|
207
211
|
'python.yml test pytest suite',
|
|
208
212
|
'rust.yml coverage Rust code coverage',
|
|
209
|
-
'rust.yml test Rust doc tests',
|
|
210
213
|
'rust.yml test Rust test suite',
|
|
211
214
|
]);
|
|
212
215
|
});
|
|
213
216
|
|
|
217
|
+
it('runs the Rust doc tests once, inside the main test suite', () => {
|
|
218
|
+
// `cargo test --all-features` already runs them. A second `cargo test
|
|
219
|
+
// --doc` with the default features rebuilt the crate and, on Windows,
|
|
220
|
+
// stalled silently until its budget killed it (issue #128).
|
|
221
|
+
const block = getJobBlock(readWorkflow('rust.yml'), 'test');
|
|
222
|
+
|
|
223
|
+
assert.match(block, /cargo test --all-features/);
|
|
224
|
+
assert.doesNotMatch(block, /cargo test --doc/);
|
|
225
|
+
});
|
|
226
|
+
|
|
214
227
|
it('warns at the same share of a budget that this invariant allows', () => {
|
|
215
228
|
const wrapper = readFileSync(
|
|
216
229
|
repoPath('scripts/run-with-budget-warning.sh'),
|
|
@@ -313,7 +326,7 @@ describe('pipeline status gate', () => {
|
|
|
313
326
|
|
|
314
327
|
assert.match(
|
|
315
328
|
block,
|
|
316
|
-
/RUN_SHA: \$\{\{ github\.sha \}\}/,
|
|
329
|
+
/RUN_SHA: \$\{\{ github\.event\.pull_request\.head\.sha \|\| github\.sha \}\}/,
|
|
317
330
|
`${fileName}: ${GATE_JOB} cannot tell a supersede from an overrun without RUN_SHA`
|
|
318
331
|
);
|
|
319
332
|
}
|
|
@@ -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
|
+
});
|