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
|
@@ -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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
{
|
|
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('
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
BRANCH_HEAD_SHA: 'abc',
|
|
274
|
+
...superseded,
|
|
275
|
+
WORKFLOW_FILE: '',
|
|
117
276
|
});
|
|
118
277
|
|
|
119
278
|
assert.equal(status, 1);
|
|
120
|
-
assert.match(output,
|
|
279
|
+
assert.match(output, /could not identify its workflow/);
|
|
121
280
|
});
|
|
122
281
|
|
|
123
|
-
it('
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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, /
|
|
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
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
+
});
|