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
@@ -198,12 +198,15 @@ describe('Rust Playwright protocol bindings', () => {
198
198
  const installed = JSON.parse(
199
199
  fs.readFileSync(path.join(root, 'package.json'), 'utf8')
200
200
  ).version;
201
- if (installed !== version) {
202
- t.skip(
203
- `playwright-core ${installed} is installed, the spec is ${version}`
204
- );
205
- return;
206
- }
201
+ // A version mismatch is the drift this test exists to catch, so it fails
202
+ // rather than skips (issue #128: the same skip in the Puppeteer bindings
203
+ // test hid a stale manifest on every CI run).
204
+ assert.equal(
205
+ version,
206
+ installed,
207
+ `playwright-core ${installed} is installed, the spec is ${version}; ` +
208
+ 'refresh rust/protocol/playwright/ as its README describes'
209
+ );
207
210
  // The driver validates every message against these schemes; one exists per
208
211
  // command (`<Interface><Command>Params`) and event (`<Interface><Event>Event`).
209
212
  const bundle = fs.readFileSync(
@@ -7,7 +7,8 @@
7
7
  * extracts from puppeteer-core's `lib/types.d.ts`. These tests fail when:
8
8
  *
9
9
  * - the committed wrappers differ from what the generator writes;
10
- * - the committed manifest differs from the installed puppeteer-core;
10
+ * - the committed manifest differs from the installed puppeteer-core,
11
+ * including its version (a mismatch fails rather than skips);
11
12
  * - a public method or getter declared in `lib/types.d.ts` (own or inherited)
12
13
  * has no typed entry point in Rust or Python.
13
14
  */
@@ -131,12 +132,15 @@ describe('Puppeteer bindings (Rust and Python)', () => {
131
132
  t.skip('puppeteer-core is not installed');
132
133
  return;
133
134
  }
134
- if (installed.version !== manifest.version) {
135
- t.skip(
136
- `puppeteer-core ${installed.version} is installed, the manifest is ${manifest.version}`
137
- );
138
- return;
139
- }
135
+ // A version mismatch is exactly the drift this test exists to catch (issue
136
+ // #128: puppeteer-core was upgraded to 25.12.0 while the manifest stayed
137
+ // at 25.10.0, and a skip here hid it), so it fails instead of skipping.
138
+ assert.equal(
139
+ manifest.version,
140
+ installed.version,
141
+ `puppeteer-core ${installed.version} is installed, the manifest is ${manifest.version}; ` +
142
+ 'run node scripts/generate-puppeteer-bindings.mjs --update-api'
143
+ );
140
144
  const fresh = {
141
145
  package: 'puppeteer-core',
142
146
  version: installed.version,
@@ -149,6 +153,23 @@ describe('Puppeteer bindings (Rust and Python)', () => {
149
153
  );
150
154
  });
151
155
 
156
+ it('are read through virtual paths the TypeScript API can find on Windows', () => {
157
+ // TypeScript 7's virtual file system matches file names as exact keys, and
158
+ // the native compiler asks for them with forward slashes. Keys built with
159
+ // path.join on Windows (`D:\a\…\tsconfig.json`) were never found, so the
160
+ // project came back undefined (issue #128, run 37526767522).
161
+ const paths = extractor.virtualProjectPaths(
162
+ 'D:\\a\\browser-commander\\browser-commander'
163
+ );
164
+ assert.deepEqual(paths, {
165
+ cwd: 'D:/a/browser-commander/browser-commander/experiments/puppeteer-api-virtual',
166
+ config:
167
+ 'D:/a/browser-commander/browser-commander/experiments/puppeteer-api-virtual/tsconfig.json',
168
+ declaration:
169
+ 'D:/a/browser-commander/browser-commander/experiments/puppeteer-api-virtual/types.d.ts',
170
+ });
171
+ });
172
+
152
173
  it('give every declared Puppeteer method a typed entry point', () => {
153
174
  const missing = [];
154
175
  let checked = 0;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * CI commits use the bot's documented no-reply address (issue #128).
3
+ *
4
+ * GitHub documents a user's no-reply address as
5
+ * `{user.id}+{user.login}@users.noreply.github.com`, and the actions/checkout
6
+ * README configures `41898282+github-actions[bot]@users.noreply.github.com`
7
+ * for commits pushed from a workflow. The three release paths here used the
8
+ * legacy `github-actions[bot]@users.noreply.github.com` instead.
9
+ *
10
+ * Checked on 2026-10-06: GitHub still links the legacy address to the bot
11
+ * (the commits API returns `author.login: github-actions[bot]` for release
12
+ * commits 9f8a552, cca988f, fda33e7 and d159aeb), so no release was ever
13
+ * unattributed here. The JS pipeline template switched for that reason
14
+ * (link-foundation/js-ai-driven-development-pipeline-template#144), which
15
+ * this repository could not reproduce. The test pins the documented form so
16
+ * the three packages agree with each other and with the template, without
17
+ * relying on GitHub continuing to resolve the undocumented one.
18
+ */
19
+
20
+ import assert from 'node:assert/strict';
21
+ import { execFileSync } from 'node:child_process';
22
+ import { readFileSync } from 'node:fs';
23
+ import { describe, it } from 'node:test';
24
+
25
+ import { REPO_ROOT, repoPath } from '../../helpers/repo.js';
26
+
27
+ const BOT_EMAIL = '41898282+github-actions[bot]@users.noreply.github.com';
28
+ const UNPREFIXED = 'github-actions[bot]@users.noreply.github.com';
29
+
30
+ // Every place that configures the commit author for CI, per language.
31
+ const ROOTS = [
32
+ '.github/workflows',
33
+ 'scripts',
34
+ 'js/scripts',
35
+ 'python/scripts',
36
+ 'rust/scripts',
37
+ ];
38
+
39
+ // Tracked files only: a local __pycache__ holds stale copies of the scripts.
40
+ function trackedFiles(roots) {
41
+ return execFileSync('git', ['ls-files', '-z', '--', ...roots], {
42
+ cwd: REPO_ROOT,
43
+ encoding: 'utf8',
44
+ })
45
+ .split('\0')
46
+ .filter(Boolean);
47
+ }
48
+
49
+ describe('github-actions[bot] commit attribution', () => {
50
+ const files = trackedFiles(ROOTS);
51
+
52
+ it('configures the prefixed bot e-mail somewhere', () => {
53
+ assert.ok(
54
+ files.some((file) =>
55
+ readFileSync(repoPath(file), 'utf8').includes(BOT_EMAIL)
56
+ ),
57
+ `no file configures ${BOT_EMAIL}`
58
+ );
59
+ });
60
+
61
+ it('never configures the unprefixed bot e-mail', () => {
62
+ const offenders = files.filter((file) =>
63
+ readFileSync(repoPath(file), 'utf8')
64
+ .split(BOT_EMAIL)
65
+ .join('')
66
+ .includes(UNPREFIXED)
67
+ );
68
+ assert.deepEqual(offenders, []);
69
+ });
70
+ });
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The duplication gate must print the clones it fails on, not the baseline
3
+ * (issue #128).
4
+ *
5
+ * With the console reporter, `jscpd --baseline … --fail-on-new-clones` lists
6
+ * every clone it finds. A passing run printed 272 "Clone found" blocks, about
7
+ * 840 lines, all of them clones the baseline already accepts. The one clone
8
+ * that fails a run was marked only by a `[NEW]` tag somewhere in that list.
9
+ */
10
+
11
+ import assert from 'node:assert/strict';
12
+ import { spawnSync } from 'node:child_process';
13
+ import fs from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import path from 'node:path';
16
+ import { describe, it } from 'node:test';
17
+
18
+ import {
19
+ formatNewClones,
20
+ resolveJscpdCli,
21
+ } from '../../../scripts/check-duplication.mjs';
22
+ import { repoPath } from '../../helpers/repo.js';
23
+
24
+ const SCRIPT = repoPath('js/scripts/check-duplication.mjs');
25
+
26
+ function clone(first, second, isNew) {
27
+ const side = (name, start, end) => ({ name, start, end });
28
+ return {
29
+ format: 'javascript',
30
+ lines: second[2] - second[1] + 1,
31
+ isNew,
32
+ firstFile: side(...first),
33
+ secondFile: side(...second),
34
+ };
35
+ }
36
+
37
+ const REPORT = {
38
+ duplicates: [
39
+ clone(['README.md:javascript', 61, 92], ['src/README.md', 29, 60], false),
40
+ clone(['src/a.js', 21, 40], ['src/b,c.js', 3, 22], true),
41
+ ],
42
+ };
43
+
44
+ describe('formatNewClones', () => {
45
+ it('lists only the clones missing from the baseline', () => {
46
+ const text = formatNewClones(REPORT, { githubActions: false });
47
+ assert.match(text, /src\/a\.js:21-40 ~ src\/b,c\.js:3-22 \(20 lines\)/);
48
+ assert.doesNotMatch(text, /README/);
49
+ });
50
+
51
+ it('raises one error annotation per new clone, on the repository path', () => {
52
+ const text = formatNewClones(REPORT, {
53
+ githubActions: true,
54
+ cwd: repoPath('js'),
55
+ workspace: repoPath(),
56
+ });
57
+ const lines = text.trim().split('\n');
58
+ assert.equal(lines.length, 1);
59
+ assert.equal(
60
+ lines[0],
61
+ '::error file=js/src/a.js,line=21,endLine=40,title=New duplicated code::' +
62
+ '20 lines duplicate js/src/b,c.js:3-22. Extract the shared code, or ' +
63
+ 'accept the clone with `npm run check:duplication:update`.'
64
+ );
65
+ });
66
+
67
+ it('strips the embedded-language suffix jscpd adds to Markdown code', () => {
68
+ const report = {
69
+ duplicates: [
70
+ clone(
71
+ ['README.md:javascript', 5, 9],
72
+ ['docs/x.md:markdown', 1, 5],
73
+ true
74
+ ),
75
+ ],
76
+ };
77
+ assert.match(
78
+ formatNewClones(report, {
79
+ githubActions: true,
80
+ cwd: repoPath('js'),
81
+ workspace: repoPath(),
82
+ }),
83
+ /^::error file=js\/README\.md,line=5,endLine=9,/
84
+ );
85
+ });
86
+ });
87
+
88
+ describe('check-duplication.mjs end to end', () => {
89
+ function run(cwd) {
90
+ return spawnSync(process.execPath, [SCRIPT], {
91
+ cwd,
92
+ encoding: 'utf8',
93
+ env: { ...process.env, GITHUB_ACTIONS: '', NO_COLOR: '1' },
94
+ });
95
+ }
96
+
97
+ it('is quiet about accepted clones and names a new one', (t) => {
98
+ if (!resolveJscpdCli()) {
99
+ return t.skip('jscpd is not installed; run npm ci in js/');
100
+ }
101
+ const fixture = fs.mkdtempSync(path.join(tmpdir(), 'check-duplication-'));
102
+ try {
103
+ fs.copyFileSync(
104
+ repoPath('js/.jscpd.json'),
105
+ path.join(fixture, '.jscpd.json')
106
+ );
107
+ const body = fs.readFileSync(repoPath('js/src/core/dialog-manager.js'));
108
+ fs.writeFileSync(path.join(fixture, 'one.js'), body);
109
+ fs.writeFileSync(path.join(fixture, 'two.js'), body);
110
+ const update = spawnSync(
111
+ process.execPath,
112
+ [
113
+ resolveJscpdCli(),
114
+ '.',
115
+ '--baseline',
116
+ '.jscpd-baseline.json',
117
+ '--update-baseline',
118
+ '--reporters',
119
+ 'silent',
120
+ ],
121
+ { cwd: fixture, encoding: 'utf8' }
122
+ );
123
+ assert.equal(update.status, 0, update.stderr);
124
+
125
+ const accepted = run(fixture);
126
+ assert.equal(accepted.status, 0, accepted.stdout + accepted.stderr);
127
+ assert.doesNotMatch(accepted.stdout, /Clone found|one\.js/);
128
+ assert.match(accepted.stdout, /\d+ clones?, 0 new/);
129
+
130
+ fs.writeFileSync(path.join(fixture, 'three.js'), body);
131
+ const failed = run(fixture);
132
+ assert.equal(failed.status, 1, failed.stdout + failed.stderr);
133
+ assert.match(failed.stdout, /^New clone: \S+:\d+-\d+ ~ /m);
134
+ assert.match(failed.stdout, /clones, [1-9]\d* new/);
135
+ } finally {
136
+ fs.rmSync(fixture, { recursive: true, force: true });
137
+ }
138
+ });
139
+ });
@@ -6,9 +6,17 @@
6
6
  * `cancelled` as well - run 24045269874 of this repository is one such run on
7
7
  * `main`. scripts/check-pipeline-status.sh is the only thing that looks at
8
8
  * that, so these tests pin the readings it has to get right: a failure is
9
- * always an error, a cancellation off the default branch is a warning, a
10
- * cancellation on the default branch is an error unless a newer run has already
11
- * taken over the branch head.
9
+ * always an error, and a cancellation is an error unless a newer commit has
10
+ * overtaken the run *and* the cancelled job is one a newer run can cancel
11
+ * (`cancel-in-progress: true`). Anything else - a job that queues instead, a
12
+ * run still at the branch head, a value the gate cannot read - is a timeout or
13
+ * a manual stop that nothing else will report.
14
+ *
15
+ * The gate used to warn about every cancellation off the default branch, so a
16
+ * pull request whose test job hit `timeout-minutes` passed its gate (issue
17
+ * #128), and it excused a cancelled release writer on a superseded `main` run
18
+ * although `cancel-in-progress: false` means no newer run can cancel it.
19
+ *
12
20
  * Hosted-runner failures can report an undocumented abandoned result; it and
13
21
  * other unexpected/missing results must fail on every branch.
14
22
  *
@@ -16,27 +24,65 @@
16
24
  */
17
25
 
18
26
  import assert from 'node:assert/strict';
19
- import { mkdtempSync, rmSync } from 'node:fs';
27
+ import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
20
28
  import { tmpdir } from 'node:os';
21
29
  import path from 'node:path';
22
30
  import { spawnSync } from 'node:child_process';
23
- import { describe, it } from 'node:test';
31
+ import { after, before, describe, it } from 'node:test';
24
32
 
33
+ import { readCancelInProgress } from '../../../../scripts/read-job-cancel-in-progress.mjs';
25
34
  import {
26
35
  BASH_AVAILABLE,
27
36
  REPO_ROOT,
37
+ readRepoText,
28
38
  repoPath,
29
39
  runBashScript,
30
40
  } from '../../helpers/repo.js';
31
41
 
32
42
  const SCRIPT = repoPath('scripts/check-pipeline-status.sh');
33
43
 
34
- function runGate(needs, env = {}, options = {}) {
35
- return runBashScript(SCRIPT, [], {
36
- cwd: options.cwd,
37
- env: { NEEDS_JSON: JSON.stringify(needs), ...env },
38
- });
39
- }
44
+ const FIXTURE_WORKFLOW = `name: Fixture
45
+
46
+ concurrency:
47
+ group: fixture-\${{ github.ref }}
48
+ cancel-in-progress: false
49
+
50
+ jobs:
51
+ test:
52
+ runs-on: ubuntu-24.04
53
+ concurrency:
54
+ group: \${{ github.workflow }}-\${{ github.ref }}-test
55
+ cancel-in-progress: true # superseded runs stop here
56
+ steps:
57
+ - run: echo test
58
+
59
+ release:
60
+ runs-on: ubuntu-24.04
61
+ concurrency:
62
+ group: main-writer
63
+ cancel-in-progress: false
64
+ steps:
65
+ - run: echo release
66
+
67
+ dynamic:
68
+ runs-on: ubuntu-24.04
69
+ concurrency:
70
+ group: dynamic
71
+ cancel-in-progress: \${{ github.event_name == 'pull_request' }}
72
+ steps:
73
+ - run: echo dynamic
74
+
75
+ inherits:
76
+ runs-on: ubuntu-24.04
77
+ steps:
78
+ - run: echo inherits
79
+
80
+ group-only:
81
+ runs-on: ubuntu-24.04
82
+ concurrency: just-a-group
83
+ steps:
84
+ - run: echo group
85
+ `;
40
86
 
41
87
  function results(map) {
42
88
  return Object.fromEntries(
@@ -44,11 +90,76 @@ function results(map) {
44
90
  );
45
91
  }
46
92
 
93
+ describe('readCancelInProgress', () => {
94
+ const policy = readCancelInProgress(FIXTURE_WORKFLOW, [
95
+ 'test',
96
+ 'release',
97
+ 'dynamic',
98
+ 'inherits',
99
+ 'group-only',
100
+ 'absent',
101
+ ]);
102
+
103
+ it('reads a literal job-level value, comments and all', () => {
104
+ assert.equal(policy.get('test'), 'true');
105
+ assert.equal(policy.get('release'), 'false');
106
+ });
107
+
108
+ it('reports an expression as unknown instead of guessing', () => {
109
+ assert.equal(policy.get('dynamic'), 'unknown');
110
+ });
111
+
112
+ it('falls back to the workflow-level group, and to false for a bare group', () => {
113
+ assert.equal(policy.get('inherits'), 'false');
114
+ assert.equal(policy.get('group-only'), 'false');
115
+ });
116
+
117
+ it('says when the workflow has no such job', () => {
118
+ assert.equal(policy.get('absent'), 'missing');
119
+ });
120
+
121
+ it('says when neither the job nor the workflow has a group', () => {
122
+ const bare = 'name: Bare\non: push\njobs:\n only:\n runs-on: x\n';
123
+ assert.equal(readCancelInProgress(bare, ['only']).get('only'), 'none');
124
+ });
125
+ });
126
+
47
127
  describe('check-pipeline-status.sh', { skip: !BASH_AVAILABLE }, () => {
128
+ let directory;
129
+ let workflowFile;
130
+
131
+ before(() => {
132
+ directory = mkdtempSync(path.join(tmpdir(), 'pipeline-status-'));
133
+ workflowFile = path.join(directory, 'fixture.yml');
134
+ writeFileSync(workflowFile, FIXTURE_WORKFLOW);
135
+ });
136
+
137
+ after(() => {
138
+ rmSync(directory, { force: true, recursive: true });
139
+ });
140
+
141
+ // GITHUB_WORKFLOW_REF is set when this suite itself runs in Actions; blank
142
+ // it so only what a test passes decides which workflow the gate reads.
143
+ function runGate(needs, env = {}, options = {}) {
144
+ return runBashScript(SCRIPT, [], {
145
+ cwd: options.cwd,
146
+ env: {
147
+ GITHUB_WORKFLOW_REF: '',
148
+ WORKFLOW_FILE: workflowFile,
149
+ BRANCH_NAME: 'main',
150
+ RUN_SHA: 'abc',
151
+ BRANCH_HEAD_SHA: 'abc',
152
+ NEEDS_JSON: JSON.stringify(needs),
153
+ ...env,
154
+ },
155
+ });
156
+ }
157
+
158
+ const superseded = { BRANCH_HEAD_SHA: 'def' };
159
+
48
160
  it('passes when every job succeeded or was legitimately skipped', () => {
49
161
  const { status, output } = runGate(
50
- results({ lint: 'success', release: 'skipped' }),
51
- { IS_MAIN: 'true', RUN_SHA: 'abc', BRANCH_HEAD_SHA: 'abc' }
162
+ results({ lint: 'success', release: 'skipped' })
52
163
  );
53
164
 
54
165
  assert.equal(status, 0);
@@ -60,19 +171,15 @@ describe('check-pipeline-status.sh', { skip: !BASH_AVAILABLE }, () => {
60
171
 
61
172
  it('fails on a failed job and names it', () => {
62
173
  const { status, output } = runGate(
63
- results({ lint: 'failure', test: 'success' }),
64
- { IS_MAIN: 'true', RUN_SHA: 'abc', BRANCH_HEAD_SHA: 'abc' }
174
+ results({ lint: 'failure', test: 'success' })
65
175
  );
66
176
 
67
177
  assert.equal(status, 1);
68
178
  assert.match(output, /::error::Pipeline failed\. Failing jobs: lint/);
69
179
  });
70
180
 
71
- for (const env of [
72
- { IS_MAIN: 'false' },
73
- { IS_MAIN: 'true', RUN_SHA: 'abc', BRANCH_HEAD_SHA: 'def' },
74
- ]) {
75
- it(`fails on an abandoned runner job with IS_MAIN=${env.IS_MAIN}`, () => {
181
+ for (const env of [{}, superseded]) {
182
+ it(`fails on an abandoned runner job (branch head ${env.BRANCH_HEAD_SHA ?? 'abc'})`, () => {
76
183
  // Hosted-runner acquisition failure in run 37362314527 left the macOS
77
184
  // check cancelled, but GitHub sent "abandoned" to the needs context.
78
185
  const { status, output } = runGate(
@@ -98,58 +205,124 @@ describe('check-pipeline-status.sh', { skip: !BASH_AVAILABLE }, () => {
98
205
  });
99
206
  }
100
207
 
101
- it('only warns about a cancellation off the default branch', () => {
208
+ it('fails on a cancellation in a pull request run that nothing overtook', () => {
209
+ // The old gate only warned off the default branch, so a pull request whose
210
+ // test job hit timeout-minutes passed its gate.
102
211
  const { status, output } = runGate(results({ test: 'cancelled' }), {
103
- IS_MAIN: 'false',
104
- RUN_SHA: 'abc',
105
- BRANCH_HEAD_SHA: 'abc',
212
+ BRANCH_NAME: 'issue-128-feature',
106
213
  });
107
214
 
215
+ assert.equal(status, 1);
216
+ assert.match(output, /This run tests abc; issue-128-feature is at abc/);
217
+ assert.match(
218
+ output,
219
+ /test: the run is still the head of issue-128-feature/
220
+ );
221
+ assert.match(output, /::error::Pipeline has cancelled jobs: test\./);
222
+ });
223
+
224
+ it('fails on a cancellation at the default branch head', () => {
225
+ const { status, output } = runGate(results({ test: 'cancelled' }));
226
+
227
+ assert.equal(status, 1);
228
+ assert.match(output, /::error::Pipeline has cancelled jobs: test\./);
229
+ });
230
+
231
+ it('only warns when a newer commit overtook a job that cancels in progress', () => {
232
+ const { status, output } = runGate(
233
+ results({ test: 'cancelled' }),
234
+ superseded
235
+ );
236
+
108
237
  assert.equal(status, 0);
109
- assert.match(output, /::warning::Cancelled jobs: test/);
238
+ assert.match(output, /This run tests abc; main is at def/);
239
+ assert.match(output, /test: it sets cancel-in-progress: true/);
240
+ assert.match(
241
+ output,
242
+ /::warning::Cancelled jobs in a superseded run: test\./
243
+ );
244
+ assert.doesNotMatch(output, /::error::/);
110
245
  });
111
246
 
112
- it('fails on a cancellation on the default branch head', () => {
247
+ for (const [job, reason] of [
248
+ ['release', /it sets cancel-in-progress: false, so a newer run queues/],
249
+ ['dynamic', /cancel-in-progress is an expression or otherwise unreadable/],
250
+ ['inherits', /it sets cancel-in-progress: false, so a newer run queues/],
251
+ ['absent', /declares no job by that name/],
252
+ ]) {
253
+ it(`fails on a superseded run's cancelled ${job} job, which no supersede explains`, () => {
254
+ const { status, output } = runGate(
255
+ results({ test: 'cancelled', [job]: 'cancelled' }),
256
+ superseded
257
+ );
258
+
259
+ assert.equal(status, 1);
260
+ assert.match(output, reason);
261
+ assert.match(
262
+ output,
263
+ /::warning::Cancelled jobs in a superseded run: test\./
264
+ );
265
+ assert.match(
266
+ output,
267
+ new RegExp(`::error::Pipeline has cancelled jobs: ${job}\\.`)
268
+ );
269
+ });
270
+ }
271
+
272
+ it('fails closed when it cannot tell which workflow it is gating', () => {
113
273
  const { status, output } = runGate(results({ test: 'cancelled' }), {
114
- IS_MAIN: 'true',
115
- RUN_SHA: 'abc',
116
- BRANCH_HEAD_SHA: 'abc',
274
+ ...superseded,
275
+ WORKFLOW_FILE: '',
117
276
  });
118
277
 
119
278
  assert.equal(status, 1);
120
- assert.match(output, /::error::Pipeline has cancelled jobs on main: test/);
279
+ assert.match(output, /could not identify its workflow/);
121
280
  });
122
281
 
123
- it('only warns when a newer commit already superseded the run', () => {
124
- const { status, output } = runGate(results({ test: 'cancelled' }), {
125
- IS_MAIN: 'true',
126
- RUN_SHA: 'abc',
127
- BRANCH_HEAD_SHA: 'def',
128
- });
282
+ it('finds its workflow through GITHUB_WORKFLOW_REF, from the repository root', () => {
283
+ // ci-policy.yml's `check` job cancels in progress; `pipeline-status` reads
284
+ // it from the checkout whichever directory the step runs in.
285
+ const { status, output } = runGate(
286
+ results({ check: 'cancelled' }),
287
+ {
288
+ ...superseded,
289
+ WORKFLOW_FILE: '',
290
+ GITHUB_WORKFLOW_REF:
291
+ 'link-foundation/browser-commander/.github/workflows/ci-policy.yml@refs/pull/129/merge',
292
+ },
293
+ { cwd: directory }
294
+ );
129
295
 
130
- assert.equal(status, 0);
131
- assert.match(output, /This run tests abc; main is at def/);
132
- assert.match(output, /::warning::Cancelled jobs: test/);
296
+ assert.equal(status, 0, output);
297
+ assert.match(output, /check: it sets cancel-in-progress: true/);
133
298
  });
134
299
 
135
300
  it('fails rather than guesses when the branch head cannot be resolved', () => {
136
301
  // A missed supersede costs one noisy warning; a missed overrun on main
137
302
  // costs a silent failure, so the unresolvable case has to be the loud one.
138
- const directory = mkdtempSync(path.join(tmpdir(), 'pipeline-status-'));
303
+ const { status, output } = runGate(
304
+ results({ test: 'cancelled' }),
305
+ { BRANCH_HEAD_SHA: '', GIT_REMOTE: 'no-such-remote' },
306
+ { cwd: directory }
307
+ );
139
308
 
140
- try {
141
- const { status, output } = runGate(
142
- results({ test: 'cancelled' }),
143
- { IS_MAIN: 'true', RUN_SHA: 'abc', BRANCH_HEAD_SHA: '' },
144
- { cwd: directory }
145
- );
309
+ assert.equal(status, 1);
310
+ assert.match(output, /assuming it is current/);
311
+ assert.match(output, /::error::Pipeline has cancelled jobs: test\./);
312
+ });
146
313
 
147
- assert.equal(status, 1);
148
- assert.match(output, /assuming it is current/);
149
- assert.match(output, /::error::Pipeline has cancelled jobs on main/);
150
- } finally {
151
- rmSync(directory, { force: true, recursive: true });
152
- }
314
+ it('prints the classification trace only when asked', () => {
315
+ const quiet = runGate(results({ test: 'cancelled' }), superseded);
316
+ const verbose = runGate(results({ test: 'cancelled' }), {
317
+ ...superseded,
318
+ PIPELINE_STATUS_VERBOSE: '1',
319
+ });
320
+
321
+ assert.doesNotMatch(quiet.output, /\[pipeline-status\]/);
322
+ assert.match(
323
+ verbose.output,
324
+ /\[pipeline-status\] cancelled test: cancel-in-progress=true, superseded=yes/
325
+ );
153
326
  });
154
327
 
155
328
  it('refuses to run without the job results', () => {
@@ -163,3 +336,42 @@ describe('check-pipeline-status.sh', { skip: !BASH_AVAILABLE }, () => {
163
336
  assert.match(result.stderr, /NEEDS_JSON is required/);
164
337
  });
165
338
  });
339
+
340
+ describe('workflows that run the gate', () => {
341
+ const workflows = readdirSync(repoPath('.github/workflows'))
342
+ .filter((name) => name.endsWith('.yml'))
343
+ .map((name) => ({ name, source: readRepoText('.github/workflows', name) }))
344
+ .filter(({ source }) => source.includes('check-pipeline-status.sh'));
345
+
346
+ it('exist', () => {
347
+ assert.ok(workflows.length > 0);
348
+ });
349
+
350
+ for (const { name, source } of workflows) {
351
+ it(`${name} tells the gate which commit and branch the run tests`, () => {
352
+ // A pull request run's github.sha is a merge commit no branch points at,
353
+ // and github.ref_name is "129/merge"; the head commit and head branch are
354
+ // what a newer push moves.
355
+ assert.match(
356
+ source,
357
+ /RUN_SHA: \$\{\{ github\.event\.pull_request\.head\.sha \|\| github\.sha \}\}/
358
+ );
359
+ assert.match(
360
+ source,
361
+ /BRANCH_NAME: \$\{\{ github\.head_ref \|\| github\.ref_name \}\}/
362
+ );
363
+ assert.doesNotMatch(source, /IS_MAIN:/);
364
+ });
365
+
366
+ it(`${name} gives every job a cancel-in-progress the gate can read`, () => {
367
+ const jobsBlock = source.slice(source.search(/^jobs:\s*$/m));
368
+ const jobs = [
369
+ ...jobsBlock.matchAll(/^ {2}([A-Za-z_][\w.-]*):\s*$/gm),
370
+ ].map((match) => match[1]);
371
+ const unreadable = [...readCancelInProgress(source, jobs)]
372
+ .filter(([, value]) => !['true', 'false'].includes(value))
373
+ .map(([job, value]) => `${job}=${value}`);
374
+ assert.deepEqual(unreadable, []);
375
+ });
376
+ }
377
+ });