start-command 0.38.0 → 0.39.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/CHANGELOG.md +28 -0
- package/README.md +4 -2
- package/bunfig.toml +4 -0
- package/eslint.config.mjs +1 -0
- package/package.json +4 -4
- package/src/lib/args-parser.js +9 -0
- package/src/lib/cgroup-memory.js +120 -24
- package/src/lib/command-builder.js +4 -0
- package/src/lib/container-removal.js +4 -5
- package/src/lib/detached-finalize.js +1 -0
- package/src/lib/docker-init-option.js +65 -0
- package/src/lib/docker-inspect-batch.js +176 -0
- package/src/lib/docker-resource-options.js +21 -2
- package/src/lib/docker-runtime-args.js +12 -3
- package/src/lib/execution-control.js +8 -6
- package/src/lib/execution-recovery.js +4 -1
- package/src/lib/execution-resume.js +4 -1
- package/src/lib/execution-store.js +19 -1
- package/src/lib/exit-evidence.js +139 -7
- package/src/lib/output-blocks.js +11 -0
- package/src/lib/resume-resources.js +2 -2
- package/src/lib/screen-isolation.js +72 -42
- package/src/lib/status-formatter.js +34 -130
- package/src/lib/status-probe.js +149 -0
- package/src/lib/store-decode-cache.js +141 -0
- package/src/lib/usage.js +3 -0
- package/test/cargo-lock-version.mjs +121 -0
- package/test/check-rust-coverage.mjs +71 -0
- package/test/check-web-archive.mjs +154 -0
- package/test/ci-workflow-invariants.js +204 -0
- package/test/command-builder-docker-cleanup.js +24 -0
- package/test/command-exit-codes.mjs +91 -0
- package/test/docker-runtime-options.js +2 -0
- package/test/docker-snapshot-safety.js +39 -0
- package/test/execution-resume.js +2 -0
- package/test/fixtures/fake-npm.sh +64 -0
- package/test/fixtures/lychee-report.md +39 -0
- package/test/isolation.js +32 -0
- package/test/javascript-first-ci.js +69 -0
- package/test/links-workflow.mjs +127 -0
- package/test/lint-gate.mjs +56 -0
- package/test/npm-publish.mjs +181 -0
- package/test/recheck-broken-links.mjs +452 -0
- package/test/regression-176.js +2 -0
- package/test/regression-187.js +2 -0
- package/test/regression-191.js +4 -0
- package/test/regression-193.js +2 -0
- package/test/regression-198.js +2 -0
- package/test/regression-207.js +8 -2
- package/test/regression-224.js +2 -0
- package/test/regression-236.js +250 -0
- package/test/regression-238.js +129 -0
- package/test/regression-241.js +254 -0
- package/test/regression-242.js +241 -0
- package/test/regression-243.js +493 -0
- package/test/regression-244.js +471 -0
- package/test/regression-247.js +140 -0
- package/test/release-push-retry.mjs +172 -0
- package/test/run-js-tests.mjs +15 -3
- package/test/setup/private-snapshot-lock.js +17 -0
- package/test/shared-golden.js +29 -2
- package/test/transient-links.mjs +139 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Fake `npm` for js/test/npm-publish.mjs (issue #234). State lives in
|
|
3
|
+
# $FAKE_NPM_STATE: `published` marks the version as on the registry,
|
|
4
|
+
# `mode` picks how `npm run changeset:publish` behaves, `calls` logs argv.
|
|
5
|
+
# `pending` holds how many more `npm view` calls miss a version that has
|
|
6
|
+
# landed but not yet propagated to the registry's read path.
|
|
7
|
+
# Any other command goes to $REAL_NPM when set (use-m runs `npm root -g`).
|
|
8
|
+
state="${FAKE_NPM_STATE:?}"
|
|
9
|
+
echo "$*" >> "$state/calls"
|
|
10
|
+
case "$1" in
|
|
11
|
+
view)
|
|
12
|
+
if [ -f "$state/outage" ]; then
|
|
13
|
+
echo "npm error code ECONNRESET" >&2
|
|
14
|
+
exit 1
|
|
15
|
+
fi
|
|
16
|
+
if [ -f "$state/pending" ]; then
|
|
17
|
+
left="$(cat "$state/pending")"
|
|
18
|
+
if [ "$left" -le 1 ]; then
|
|
19
|
+
rm "$state/pending"
|
|
20
|
+
echo "$FAKE_NPM_VERSION" > "$state/published"
|
|
21
|
+
else
|
|
22
|
+
echo $((left - 1)) > "$state/pending"
|
|
23
|
+
fi
|
|
24
|
+
echo "npm error code E404" >&2
|
|
25
|
+
exit 1
|
|
26
|
+
fi
|
|
27
|
+
if [ -f "$state/published" ]; then
|
|
28
|
+
cat "$state/published"
|
|
29
|
+
exit 0
|
|
30
|
+
fi
|
|
31
|
+
echo "npm error code E404" >&2
|
|
32
|
+
exit 1
|
|
33
|
+
;;
|
|
34
|
+
run)
|
|
35
|
+
mode="$(cat "$state/mode" 2>/dev/null || echo succeed)"
|
|
36
|
+
case "$mode" in
|
|
37
|
+
succeed) echo "$FAKE_NPM_VERSION" > "$state/published"; exit 0 ;;
|
|
38
|
+
fail) echo "npm error code E403" >&2; exit 1 ;;
|
|
39
|
+
exit-0-without-publishing) exit 0 ;;
|
|
40
|
+
fail-after-landing) echo "$FAKE_NPM_VERSION" > "$state/published"; exit 1 ;;
|
|
41
|
+
land-late) echo 20 > "$state/pending"; exit 0 ;;
|
|
42
|
+
time-out-while-landing)
|
|
43
|
+
if [ -f "$state/pending" ] || [ -f "$state/published" ]; then
|
|
44
|
+
echo "npm error code E403" >&2
|
|
45
|
+
echo "npm error You cannot publish over the previously published versions: $FAKE_NPM_VERSION." >&2
|
|
46
|
+
exit 1
|
|
47
|
+
fi
|
|
48
|
+
echo 20 > "$state/pending"
|
|
49
|
+
echo "npm error code ETIMEDOUT" >&2
|
|
50
|
+
exit 1
|
|
51
|
+
;;
|
|
52
|
+
fail-once)
|
|
53
|
+
echo succeed > "$state/mode"
|
|
54
|
+
echo "npm error code E503" >&2
|
|
55
|
+
exit 1
|
|
56
|
+
;;
|
|
57
|
+
esac
|
|
58
|
+
;;
|
|
59
|
+
esac
|
|
60
|
+
if [ -n "${REAL_NPM:-}" ]; then
|
|
61
|
+
exec "$REAL_NPM" "$@"
|
|
62
|
+
fi
|
|
63
|
+
echo "fake npm: unexpected arguments: $*" >&2
|
|
64
|
+
exit 2
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Link Checker Report
|
|
2
|
+
|
|
3
|
+
| Status | Count |
|
|
4
|
+
| -------------- | ----- |
|
|
5
|
+
| 🔍 Total | 120 |
|
|
6
|
+
| ✅ Successful | 108 |
|
|
7
|
+
| ⏳ Timeouts | 0 |
|
|
8
|
+
| 🔀 Redirected | 6 |
|
|
9
|
+
| 👻 Excluded | 2 |
|
|
10
|
+
| ❓ Unknown | 0 |
|
|
11
|
+
| 🚫 Errors | 4 |
|
|
12
|
+
| ⛔ Unsupported | 0 |
|
|
13
|
+
|
|
14
|
+
## Errors per input
|
|
15
|
+
|
|
16
|
+
### Errors in csharp/docs/index.md
|
|
17
|
+
|
|
18
|
+
- [ERROR] <file:///home/runner/work/repo/repo/csharp/docs/api/Foundation.Data.Doublets.Cli.yml> (at 15:12) | File not found. Check if file exists and path is correct
|
|
19
|
+
|
|
20
|
+
### Errors in js/index.html
|
|
21
|
+
|
|
22
|
+
- [ERROR] <error:> (at 10:49) | Cannot resolve root-relative link '/favicon.svg': To resolve root-relative links in local files, provide a root dir
|
|
23
|
+
|
|
24
|
+
### Errors in README.md
|
|
25
|
+
|
|
26
|
+
- [404] <https://example.com/csharp/> (at 48:130) | Rejected status code: 404 Not Found
|
|
27
|
+
- [404] <https://example.com/rust/link_cli/> (at 49:62) | Rejected status code: 404 Not Found
|
|
28
|
+
|
|
29
|
+
## Redirects per input
|
|
30
|
+
|
|
31
|
+
### Redirects in README.md
|
|
32
|
+
|
|
33
|
+
- https://example.com/docs --[302]--> https://example.com/docs/latest/
|
|
34
|
+
- https://example.com/protocols --[301]--> https://example.com/links-notation
|
|
35
|
+
- https://example.com/articles/804617 --[301]--> https://example.com/articles/804617/ --[302]--> https://example.com/companies/articles/804617/
|
|
36
|
+
|
|
37
|
+
### Redirects in rust/README.md
|
|
38
|
+
|
|
39
|
+
- https://example.com/docs --[302]--> https://example.com/docs/latest/
|
package/test/isolation.js
CHANGED
|
@@ -812,3 +812,35 @@ describe('isInteractiveShellCommand (issue #84)', () => {
|
|
|
812
812
|
assert.strictEqual(isInteractiveShellCommand(' '), false);
|
|
813
813
|
});
|
|
814
814
|
});
|
|
815
|
+
|
|
816
|
+
describe('isScreenRunFinished', () => {
|
|
817
|
+
// `screen -dmS` can return before `screen -ls` lists the session; a failed
|
|
818
|
+
// command then read as exit 0 (Rust CI run 39873000384).
|
|
819
|
+
const {
|
|
820
|
+
SCREEN_UNLISTED_GRACE_POLLS,
|
|
821
|
+
isScreenRunFinished,
|
|
822
|
+
} = require('../src/lib/screen-isolation');
|
|
823
|
+
|
|
824
|
+
it('waits while the session is listed', () => {
|
|
825
|
+
assert.strictEqual(isScreenRunFinished(true, true, 0), false);
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
it('finishes once the session is gone and its exit code is written', () => {
|
|
829
|
+
assert.strictEqual(isScreenRunFinished(false, true, 1), true);
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
it('keeps polling an unlisted session without an exit code', () => {
|
|
833
|
+
assert.strictEqual(isScreenRunFinished(false, false, 1), false);
|
|
834
|
+
assert.strictEqual(
|
|
835
|
+
isScreenRunFinished(false, false, SCREEN_UNLISTED_GRACE_POLLS - 1),
|
|
836
|
+
false
|
|
837
|
+
);
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
it('gives up after the grace polls', () => {
|
|
841
|
+
assert.strictEqual(
|
|
842
|
+
isScreenRunFinished(false, false, SCREEN_UNLISTED_GRACE_POLLS),
|
|
843
|
+
true
|
|
844
|
+
);
|
|
845
|
+
});
|
|
846
|
+
});
|
|
@@ -9,6 +9,7 @@ const {
|
|
|
9
9
|
} = require('../../scripts/check-javascript-first.mjs');
|
|
10
10
|
const {
|
|
11
11
|
checkFeatureChanges,
|
|
12
|
+
checkRegressionPairs,
|
|
12
13
|
} = require('../../scripts/check-feature-parity.mjs');
|
|
13
14
|
|
|
14
15
|
const root = resolve(__dirname, '../..');
|
|
@@ -178,6 +179,44 @@ describe('JavaScript first CI gate', () => {
|
|
|
178
179
|
assert.equal(check.status, 0);
|
|
179
180
|
});
|
|
180
181
|
|
|
182
|
+
const releaseCheck = (isMain, release, test = 'success') =>
|
|
183
|
+
spawnSync(
|
|
184
|
+
process.execPath,
|
|
185
|
+
[
|
|
186
|
+
resolve(root, 'scripts/check-stage-status.mjs'),
|
|
187
|
+
'--required',
|
|
188
|
+
'test',
|
|
189
|
+
'--release',
|
|
190
|
+
'release:test',
|
|
191
|
+
],
|
|
192
|
+
{
|
|
193
|
+
encoding: 'utf8',
|
|
194
|
+
env: {
|
|
195
|
+
...process.env,
|
|
196
|
+
IS_MAIN: String(isMain),
|
|
197
|
+
NEEDS_JSON: JSON.stringify({
|
|
198
|
+
test: { result: test },
|
|
199
|
+
release: { result: release },
|
|
200
|
+
}),
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
it('fails a main push whose release was skipped after its needs passed', () => {
|
|
206
|
+
// Every main release was silently skipped since #234 (issue #240).
|
|
207
|
+
const check = releaseCheck(true, 'skipped');
|
|
208
|
+
assert.equal(check.status, 1);
|
|
209
|
+
assert.match(check.stderr, /release was skipped on a main push/);
|
|
210
|
+
// The terms of the release condition are printed for the next skip.
|
|
211
|
+
assert.match(check.stdout, /needs\.test\.result=success/);
|
|
212
|
+
assert.match(check.stdout, /release: skipped; expected to run: true/);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('accepts a skipped release outside main pushes and a run release on main', () => {
|
|
216
|
+
assert.equal(releaseCheck(false, 'skipped').status, 0);
|
|
217
|
+
assert.equal(releaseCheck(true, 'success').status, 0);
|
|
218
|
+
});
|
|
219
|
+
|
|
181
220
|
it('rejects a change to only one implementation of a feature', () => {
|
|
182
221
|
const features = [
|
|
183
222
|
{
|
|
@@ -194,6 +233,36 @@ describe('JavaScript first CI gate', () => {
|
|
|
194
233
|
);
|
|
195
234
|
});
|
|
196
235
|
|
|
236
|
+
it('keeps both halves of a regression test under one feature', () => {
|
|
237
|
+
const feature = (id, js, rust) => ({
|
|
238
|
+
id,
|
|
239
|
+
javascript: { implementation: [], tests: js },
|
|
240
|
+
rust: { implementation: [], tests: rust },
|
|
241
|
+
});
|
|
242
|
+
assert.equal(
|
|
243
|
+
checkRegressionPairs([
|
|
244
|
+
feature('a', ['js/test/regression-193.js'], []),
|
|
245
|
+
feature('b', [], ['rust/tests/regression_193.rs']),
|
|
246
|
+
]).length,
|
|
247
|
+
1
|
|
248
|
+
);
|
|
249
|
+
assert.deepEqual(
|
|
250
|
+
checkRegressionPairs([
|
|
251
|
+
feature(
|
|
252
|
+
'a',
|
|
253
|
+
['js/test/regression-201.js'],
|
|
254
|
+
['rust/tests/regression_201.rs']
|
|
255
|
+
),
|
|
256
|
+
feature('b', [], ['rust/tests/regression_201.rs']),
|
|
257
|
+
]),
|
|
258
|
+
[]
|
|
259
|
+
);
|
|
260
|
+
const manifest = JSON.parse(
|
|
261
|
+
readFileSync(resolve(root, 'parity/features.json'), 'utf8')
|
|
262
|
+
);
|
|
263
|
+
assert.deepEqual(checkRegressionPairs(manifest.features), []);
|
|
264
|
+
});
|
|
265
|
+
|
|
197
266
|
it('makes Rust callable only after the complete JavaScript stage', () => {
|
|
198
267
|
const files = workflows();
|
|
199
268
|
assert.deepEqual(Object.keys(files['rust.yml'].on), ['workflow_call']);
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { describe, expect, it } from 'bun:test';
|
|
5
|
+
|
|
6
|
+
const root = path.resolve(
|
|
7
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
8
|
+
'../..'
|
|
9
|
+
);
|
|
10
|
+
const workflow = readFileSync(
|
|
11
|
+
path.join(root, '.github/workflows/links.yml'),
|
|
12
|
+
'utf8'
|
|
13
|
+
).replaceAll('\r\n', '\n');
|
|
14
|
+
|
|
15
|
+
function getStepBlock(stepName) {
|
|
16
|
+
const lines = workflow.split('\n');
|
|
17
|
+
const header = ` - name: ${stepName}`;
|
|
18
|
+
const start = lines.findIndex((line) => line === header);
|
|
19
|
+
|
|
20
|
+
if (start === -1) {
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const end = lines.findIndex(
|
|
25
|
+
(line, index) => index > start && line.startsWith(' - ')
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
return lines.slice(start, end === -1 ? lines.length : end).join('\n');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('broken link workflow', () => {
|
|
32
|
+
it('fails for every nonzero Lychee exit even when an archive exists', () => {
|
|
33
|
+
const failureStep = getStepBlock('Fail if broken links were found');
|
|
34
|
+
|
|
35
|
+
expect(failureStep).toContain('!cancelled()');
|
|
36
|
+
expect(failureStep).toContain('steps.lychee.outputs.exit_code != 0');
|
|
37
|
+
expect(failureStep).not.toContain('steps.webarchive.outputs.all_archived');
|
|
38
|
+
expect(failureStep).toContain(
|
|
39
|
+
'An archive is a suggested replacement; it does not make the live link valid.'
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('re-asks the links that never got an answer before giving up', () => {
|
|
44
|
+
const recheckStep = getStepBlock('Re-check transient link failures');
|
|
45
|
+
|
|
46
|
+
expect(recheckStep).toContain('id: recheck');
|
|
47
|
+
expect(recheckStep).toContain('node scripts/recheck-broken-links.mjs');
|
|
48
|
+
expect(recheckStep).toContain('if: steps.lychee.outputs.exit_code != 0');
|
|
49
|
+
expect(recheckStep).toContain('LYCHEE_OUTPUT: lychee/out.md');
|
|
50
|
+
expect(recheckStep).toContain('RECOVERED_OUTPUT: lychee/recovered.txt');
|
|
51
|
+
expect(recheckStep).toContain('GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// `== 'false'` would read a skipped or crashed re-check as "nothing was
|
|
55
|
+
// recovered" and skip the failure step on an empty output. Only the
|
|
56
|
+
// negated form fails safe.
|
|
57
|
+
it('downgrades a failure only when every unanswered link recovered', () => {
|
|
58
|
+
const webarchiveStep = getStepBlock(
|
|
59
|
+
'Check broken links against Web Archive'
|
|
60
|
+
);
|
|
61
|
+
const failureStep = getStepBlock('Fail if broken links were found');
|
|
62
|
+
|
|
63
|
+
for (const step of [webarchiveStep, failureStep]) {
|
|
64
|
+
expect(step).toContain("steps.recheck.outputs.all_recovered != 'true'");
|
|
65
|
+
expect(step).not.toMatch(/all_recovered\s*==\s*'false'/);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('feeds the recovered list to the Web Archive check', () => {
|
|
70
|
+
const webarchiveStep = getStepBlock(
|
|
71
|
+
'Check broken links against Web Archive'
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
expect(webarchiveStep).toContain('RECOVERED_URLS: lychee/recovered.txt');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// The checker, the archive fallback, and the re-check run from files the
|
|
78
|
+
// paths filters must name; otherwise editing them silently skips the link
|
|
79
|
+
// check on the pull request that changed them.
|
|
80
|
+
it('triggers on changes to every file the check depends on', () => {
|
|
81
|
+
const expected = [
|
|
82
|
+
'.lycheeignore',
|
|
83
|
+
'scripts/check-web-archive.mjs',
|
|
84
|
+
'scripts/recheck-broken-links.mjs',
|
|
85
|
+
'js/test/fixtures/lychee-report.md',
|
|
86
|
+
'js/test/links-workflow.mjs',
|
|
87
|
+
'js/test/recheck-broken-links.mjs',
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
for (const trigger of ['push', 'pull_request']) {
|
|
91
|
+
const triggerBlock = workflow.match(
|
|
92
|
+
new RegExp(
|
|
93
|
+
`^ ${trigger}:\\n(?: .*\\n)*? paths:\\n((?: .*\\n)+)`,
|
|
94
|
+
'm'
|
|
95
|
+
)
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(Boolean(triggerBlock)).toBe(true);
|
|
99
|
+
|
|
100
|
+
for (const path of expected) {
|
|
101
|
+
expect(triggerBlock[1]).toContain(`- '${path}'`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// `--cache` writes .lycheecache into the workspace, which a fresh runner
|
|
107
|
+
// discards; without a cache step `--max-cache-age` never applies (#234).
|
|
108
|
+
it('persists the lychee cache it asks for', () => {
|
|
109
|
+
expect(workflow).toContain('--cache');
|
|
110
|
+
const cacheStep = getStepBlock('Restore lychee cache');
|
|
111
|
+
expect(cacheStep).toContain('uses: actions/cache@');
|
|
112
|
+
expect(cacheStep).toContain('path: .lycheecache');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// The failure message sends readers to lychee/out.md; it has to exist
|
|
116
|
+
// outside the runner that wrote it (#234).
|
|
117
|
+
it('uploads the report the failure message points at', () => {
|
|
118
|
+
expect(getStepBlock('Fail if broken links were found')).toContain(
|
|
119
|
+
'lychee-report workflow artifact'
|
|
120
|
+
);
|
|
121
|
+
const uploadStep = getStepBlock('Upload lychee report');
|
|
122
|
+
expect(uploadStep).toContain('uses: actions/upload-artifact@');
|
|
123
|
+
expect(uploadStep).toContain('name: lychee-report');
|
|
124
|
+
expect(uploadStep).toContain('path: lychee/');
|
|
125
|
+
expect(uploadStep).toContain('!cancelled()');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// ESLint exits 0 when it finds only warnings, so `eslint .` let every
|
|
2
|
+
// warn-level rule (require-await) through CI unnoticed (issue #234). Each lint
|
|
3
|
+
// command CI and the pre-commit hook run must reject a warning-only file.
|
|
4
|
+
import { describe, expect, it } from 'bun:test';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
const jsRoot = join(import.meta.dir, '..');
|
|
10
|
+
const eslint = join(jsRoot, 'node_modules', '.bin', 'eslint');
|
|
11
|
+
const pkg = JSON.parse(readFileSync(join(jsRoot, 'package.json'), 'utf8'));
|
|
12
|
+
|
|
13
|
+
// Prettier-clean, so the only finding is the require-await warning.
|
|
14
|
+
const warningOnly = 'export async function f() {\n return 1;\n}\n';
|
|
15
|
+
|
|
16
|
+
/** Run an `eslint ...` command on stdin in place of the paths it lints. */
|
|
17
|
+
function lintWarningOnly(command, cwd, filename) {
|
|
18
|
+
const [bin, ...args] = command.trim().split(/\s+/);
|
|
19
|
+
expect(bin).toBe('eslint');
|
|
20
|
+
const flags = args.filter((arg) => arg !== '.');
|
|
21
|
+
return spawnSync(
|
|
22
|
+
eslint,
|
|
23
|
+
[...flags, '--stdin', '--stdin-filename', filename],
|
|
24
|
+
{ cwd, input: warningOnly, encoding: 'utf8' }
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('lint gate', () => {
|
|
29
|
+
it('fails `bun run lint` on a warning', () => {
|
|
30
|
+
const result = lintWarningOnly(pkg.scripts.lint, jsRoot, 'src/probe.mjs');
|
|
31
|
+
expect(result.stdout).toContain('require-await');
|
|
32
|
+
expect(result.status).toBe(1);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('fails `bun run lint:scripts` on a warning', () => {
|
|
36
|
+
const command = pkg.scripts['lint:scripts'].replace(/^cd \.\. && /, '');
|
|
37
|
+
const result = lintWarningOnly(
|
|
38
|
+
command,
|
|
39
|
+
join(jsRoot, '..'),
|
|
40
|
+
'scripts/probe.mjs'
|
|
41
|
+
);
|
|
42
|
+
expect(result.stdout).toContain('require-await');
|
|
43
|
+
expect(result.status).toBe(1);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('fails the pre-commit hook on a warning', () => {
|
|
47
|
+
const [command] = pkg['lint-staged']['*.{js,mjs,cjs}'];
|
|
48
|
+
const result = lintWarningOnly(
|
|
49
|
+
command.replace(' --fix', ''),
|
|
50
|
+
jsRoot,
|
|
51
|
+
'src/probe.mjs'
|
|
52
|
+
);
|
|
53
|
+
expect(result.stdout).toContain('require-await');
|
|
54
|
+
expect(result.status).toBe(1);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
copyFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { $ } from 'command-stream';
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
DEFAULT_VERIFY_ATTEMPTS,
|
|
17
|
+
isAlreadyPublishedError,
|
|
18
|
+
isNpmVersionPublished,
|
|
19
|
+
publishToNpm,
|
|
20
|
+
} from '../../scripts/npm-publish.mjs';
|
|
21
|
+
|
|
22
|
+
const NAME = 'start-command';
|
|
23
|
+
const VERSION = '9.9.9';
|
|
24
|
+
const noSleep = async () => {};
|
|
25
|
+
|
|
26
|
+
let dir;
|
|
27
|
+
let $npm;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Run fixtures/fake-npm.sh wherever the script under test runs `npm`. It is
|
|
31
|
+
* called by absolute path, not found through PATH: command-stream runs
|
|
32
|
+
* commands in a login shell (`sh -l -c`), and on macOS /etc/profile puts the
|
|
33
|
+
* system directories, which hold the real npm, before ours (issue #234;
|
|
34
|
+
* experiments/issue-234/login-shell-path-reorder.sh). The env goes to `$`
|
|
35
|
+
* explicitly: Bun hands spawned children its startup environment, not
|
|
36
|
+
* later `process.env` writes.
|
|
37
|
+
*/
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
dir = mkdtempSync(join(tmpdir(), 'npm-publish-'));
|
|
40
|
+
const fakeNpm = join(dir, 'npm');
|
|
41
|
+
copyFileSync(join(import.meta.dir, 'fixtures', 'fake-npm.sh'), fakeNpm);
|
|
42
|
+
const $env = $({
|
|
43
|
+
env: {
|
|
44
|
+
...process.env,
|
|
45
|
+
FAKE_NPM_STATE: dir,
|
|
46
|
+
FAKE_NPM_VERSION: VERSION,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
$npm = (strings, ...values) =>
|
|
50
|
+
$env(
|
|
51
|
+
[strings[0].replace(/^npm(?= )/, `'${fakeNpm}'`), ...strings.slice(1)],
|
|
52
|
+
...values
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
rmSync(dir, { recursive: true, force: true });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
function mode(value) {
|
|
61
|
+
writeFileSync(join(dir, 'mode'), value);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function publishCalls() {
|
|
65
|
+
return readFileSync(join(dir, 'calls'), 'utf8')
|
|
66
|
+
.split('\n')
|
|
67
|
+
.filter((line) => line.startsWith('run changeset:publish')).length;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const options = { name: NAME, version: VERSION, sleep: noSleep };
|
|
71
|
+
|
|
72
|
+
describe.skipIf(process.platform === 'win32')('npm-publish', () => {
|
|
73
|
+
it('fails when every publish attempt exits non-zero (issue #234)', async () => {
|
|
74
|
+
// command-stream resolves on a non-zero exit; the old try/catch loop
|
|
75
|
+
// reported "✅ Published" and set published=true here.
|
|
76
|
+
mode('fail');
|
|
77
|
+
await expect(publishToNpm($npm, options)).rejects.toThrow(
|
|
78
|
+
`Failed to publish ${NAME}@${VERSION} after 3 attempts`
|
|
79
|
+
);
|
|
80
|
+
expect(publishCalls()).toBe(3);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('fails when publish exits 0 but the registry never shows the version', async () => {
|
|
84
|
+
mode('exit-0-without-publishing');
|
|
85
|
+
await expect(
|
|
86
|
+
publishToNpm($npm, { ...options, verifyAttempts: 2 })
|
|
87
|
+
).rejects.toThrow('exited 0 but start-command@9.9.9 is not on npm');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('publishes and confirms the version on the registry', async () => {
|
|
91
|
+
mode('succeed');
|
|
92
|
+
expect(await publishToNpm($npm, options)).toEqual({
|
|
93
|
+
alreadyPublished: false,
|
|
94
|
+
});
|
|
95
|
+
expect(existsSync(join(dir, 'published'))).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('retries a failed attempt', async () => {
|
|
99
|
+
mode('fail-once');
|
|
100
|
+
expect(await publishToNpm($npm, options)).toEqual({
|
|
101
|
+
alreadyPublished: false,
|
|
102
|
+
});
|
|
103
|
+
expect(publishCalls()).toBe(2);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('accepts an attempt that failed client-side after the version landed', async () => {
|
|
107
|
+
mode('fail-after-landing');
|
|
108
|
+
expect(await publishToNpm($npm, options)).toEqual({
|
|
109
|
+
alreadyPublished: false,
|
|
110
|
+
});
|
|
111
|
+
expect(publishCalls()).toBe(1);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// npm can take minutes to show a version it accepted; the template has
|
|
115
|
+
// seen 874s. A 60-second wait failed a release that had published (#234).
|
|
116
|
+
it('waits as long as npm takes to show a published version', async () => {
|
|
117
|
+
mode('land-late');
|
|
118
|
+
expect(await publishToNpm($npm, options)).toEqual({
|
|
119
|
+
alreadyPublished: false,
|
|
120
|
+
});
|
|
121
|
+
expect(publishCalls()).toBe(1);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// A retry after a timed-out attempt that landed is rejected with "cannot
|
|
125
|
+
// publish over the previously published versions". That is a cue to wait
|
|
126
|
+
// for the registry, not a failure to retry (#234).
|
|
127
|
+
it('waits instead of republishing when npm says the version exists', async () => {
|
|
128
|
+
mode('time-out-while-landing');
|
|
129
|
+
expect(await publishToNpm($npm, options)).toEqual({
|
|
130
|
+
alreadyPublished: false,
|
|
131
|
+
});
|
|
132
|
+
expect(publishCalls()).toBe(2);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('skips publishing a version that is already on npm', async () => {
|
|
136
|
+
writeFileSync(join(dir, 'published'), `${VERSION}\n`);
|
|
137
|
+
expect(await publishToNpm($npm, options)).toEqual({
|
|
138
|
+
alreadyPublished: true,
|
|
139
|
+
});
|
|
140
|
+
expect(existsSync(join(dir, 'mode'))).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('treats only E404 as "not published" and fails closed otherwise', async () => {
|
|
144
|
+
expect(await isNpmVersionPublished($npm, NAME, VERSION)).toBe(false);
|
|
145
|
+
writeFileSync(join(dir, 'outage'), '');
|
|
146
|
+
await expect(isNpmVersionPublished($npm, NAME, VERSION)).rejects.toThrow(
|
|
147
|
+
'ECONNRESET'
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('reads every "npm already has this version" answer as landed', () => {
|
|
152
|
+
for (const output of [
|
|
153
|
+
'npm error code E403\nnpm error You cannot publish over the previously published versions: 9.9.9.',
|
|
154
|
+
'npm error code E409\nnpm error 409 Conflict - PUT https://registry.npmjs.org/start-command - Cannot publish over previously staged version 9.9.9',
|
|
155
|
+
'npm ERR! code EPUBLISHCONFLICT',
|
|
156
|
+
]) {
|
|
157
|
+
expect(isAlreadyPublishedError(output)).toBe(true);
|
|
158
|
+
}
|
|
159
|
+
expect(
|
|
160
|
+
isAlreadyPublishedError('npm error code E403\nnpm error 403 Forbidden')
|
|
161
|
+
).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// The registry wait is useless if the job is killed first (#234).
|
|
165
|
+
it('gives every publishing job time to outlast the registry wait', () => {
|
|
166
|
+
const workflow = readFileSync(
|
|
167
|
+
join(import.meta.dir, '..', '..', '.github', 'workflows', 'js.yml'),
|
|
168
|
+
'utf8'
|
|
169
|
+
);
|
|
170
|
+
const jobs = workflow.split(/\n(?= {2}[a-z-]+:\n)/);
|
|
171
|
+
const publishers = jobs.filter((job) =>
|
|
172
|
+
job.includes('scripts/publish-to-npm.mjs')
|
|
173
|
+
);
|
|
174
|
+
expect(publishers.length).toBe(2);
|
|
175
|
+
const waitMinutes = (DEFAULT_VERIFY_ATTEMPTS * 10) / 60;
|
|
176
|
+
for (const job of publishers) {
|
|
177
|
+
const minutes = Number(/^ {4}timeout-minutes: (\d+)$/m.exec(job)[1]);
|
|
178
|
+
expect(minutes).toBeGreaterThanOrEqual(waitMinutes + 15);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
});
|