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.
Files changed (52) hide show
  1. package/.jscpd-baseline.json +6 -4
  2. package/CHANGELOG.md +32 -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/browser-sources.json +10 -2
  11. package/src/browser/launcher.js +14 -0
  12. package/src/browser/real-browser.js +5 -0
  13. package/src/browser/safari-launch.js +42 -0
  14. package/src/browser/safari-support.js +98 -0
  15. package/src/browser/storage-state.js +28 -8
  16. package/src/browser/webdriver.js +132 -21
  17. package/src/cli/sessions.js +5 -0
  18. package/src/core/webdriver-page.js +62 -0
  19. package/src/elements/selectors.js +31 -14
  20. package/src/exports.js +6 -0
  21. package/src/parity/harness.js +4 -1
  22. package/src/tests/index.js +12 -1
  23. package/src/traces/recorder.js +5 -0
  24. package/tests/e2e/engine-matrix.e2e.test.js +2 -1
  25. package/tests/e2e/safari.e2e.test.js +105 -0
  26. package/tests/e2e/webdriver.e2e.test.js +2 -1
  27. package/tests/helpers/fixture-server.js +18 -1
  28. package/tests/unit/browser/real-browser.test.js +1 -1
  29. package/tests/unit/browser/safari.test.js +192 -0
  30. package/tests/unit/browser/webdriver.test.js +148 -3
  31. package/tests/unit/cli/dispatcher.test.js +1 -1
  32. package/tests/unit/elements/selectors.test.js +75 -0
  33. package/tests/unit/helpers/fixture-server.test.js +60 -0
  34. package/tests/unit/playwright-protocol-coverage.test.js +9 -6
  35. package/tests/unit/puppeteer-bindings-coverage.test.js +28 -7
  36. package/tests/unit/scripts/bot-commit-attribution.test.js +70 -0
  37. package/tests/unit/scripts/check-duplication.test.js +139 -0
  38. package/tests/unit/scripts/check-pipeline-status.test.js +264 -52
  39. package/tests/unit/scripts/check-version-modification.test.js +107 -1
  40. package/tests/unit/scripts/ci-timeout-budgets.test.js +15 -2
  41. package/tests/unit/scripts/ci-workflow-policy.test.js +91 -7
  42. package/tests/unit/scripts/command-stream-errexit.test.js +83 -41
  43. package/tests/unit/scripts/e2e-suite-coverage.test.js +174 -0
  44. package/tests/unit/scripts/github-actions-log.test.js +99 -0
  45. package/tests/unit/scripts/links-workflow.test.js +42 -0
  46. package/tests/unit/scripts/preflight-credentials.test.js +527 -0
  47. package/tests/unit/scripts/run-with-budget-warning.test.js +212 -6
  48. package/tests/unit/scripts/use-module-integration.test.js +2 -9
  49. package/tests/unit/scripts/use-module.test.js +20 -0
  50. package/tests/unit/scripts/validate-changeset.test.js +127 -0
  51. package/tests/unit/scripts/zizmor-version.test.js +82 -0
  52. 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 { findManualVersionChanges } from '../../../../scripts/check-version-modification.mjs';
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-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
+ });