start-command 0.37.9 → 0.39.0

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 (78) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +7 -2
  3. package/bunfig.toml +4 -0
  4. package/eslint.config.mjs +1 -0
  5. package/package.json +4 -4
  6. package/src/bin/cli.js +62 -26
  7. package/src/lib/args-parser-queries.js +45 -1
  8. package/src/lib/args-parser.js +19 -0
  9. package/src/lib/cgroup-memory.js +95 -23
  10. package/src/lib/command-builder.js +4 -0
  11. package/src/lib/container-removal.js +4 -5
  12. package/src/lib/detached-finalize.js +1 -0
  13. package/src/lib/docker-command-handoff.js +34 -6
  14. package/src/lib/docker-init-option.js +65 -0
  15. package/src/lib/docker-inspect-batch.js +176 -0
  16. package/src/lib/docker-resource-options.js +21 -2
  17. package/src/lib/docker-runtime-args.js +19 -3
  18. package/src/lib/execution-attempt.js +19 -9
  19. package/src/lib/execution-chain.js +182 -0
  20. package/src/lib/execution-control.js +8 -6
  21. package/src/lib/execution-env.js +120 -0
  22. package/src/lib/execution-identity.js +180 -0
  23. package/src/lib/execution-recovery.js +21 -1
  24. package/src/lib/execution-resume.js +21 -1
  25. package/src/lib/execution-store.js +30 -23
  26. package/src/lib/exit-evidence.js +139 -7
  27. package/src/lib/isolation.js +21 -3
  28. package/src/lib/launch-identity-options.js +81 -0
  29. package/src/lib/launch-identity.js +411 -0
  30. package/src/lib/log-uploader.js +15 -1
  31. package/src/lib/output-blocks.js +11 -0
  32. package/src/lib/query-commands.js +18 -3
  33. package/src/lib/resume-resources.js +2 -2
  34. package/src/lib/screen-isolation.js +11 -2
  35. package/src/lib/spawn-helpers.js +33 -16
  36. package/src/lib/status-formatter.js +39 -131
  37. package/src/lib/status-probe.js +149 -0
  38. package/src/lib/store-decode-cache.js +141 -0
  39. package/src/lib/usage.js +11 -1
  40. package/test/args-parser-attach-resume.js +1 -1
  41. package/test/args-parser-control.js +2 -2
  42. package/test/cargo-lock-version.mjs +121 -0
  43. package/test/check-rust-coverage.mjs +71 -0
  44. package/test/check-web-archive.mjs +154 -0
  45. package/test/ci-workflow-invariants.js +204 -0
  46. package/test/command-builder-docker-cleanup.js +24 -0
  47. package/test/command-exit-codes.mjs +91 -0
  48. package/test/docker-runtime-options.js +2 -0
  49. package/test/docker-snapshot-safety.js +39 -0
  50. package/test/execution-resume.js +2 -0
  51. package/test/fixtures/fake-npm.sh +64 -0
  52. package/test/fixtures/lychee-report.md +39 -0
  53. package/test/javascript-first-ci.js +69 -0
  54. package/test/links-workflow.mjs +127 -0
  55. package/test/lint-gate.mjs +56 -0
  56. package/test/npm-publish.mjs +181 -0
  57. package/test/recheck-broken-links.mjs +452 -0
  58. package/test/regression-176.js +2 -0
  59. package/test/regression-187.js +2 -0
  60. package/test/regression-191.js +4 -0
  61. package/test/regression-193.js +2 -0
  62. package/test/regression-198.js +2 -0
  63. package/test/regression-207.js +8 -2
  64. package/test/regression-224.js +2 -0
  65. package/test/regression-229.js +267 -0
  66. package/test/regression-230.js +234 -0
  67. package/test/regression-231.js +298 -0
  68. package/test/regression-236.js +250 -0
  69. package/test/regression-238.js +129 -0
  70. package/test/regression-241.js +254 -0
  71. package/test/regression-242.js +241 -0
  72. package/test/regression-243.js +493 -0
  73. package/test/regression-244.js +471 -0
  74. package/test/release-push-retry.mjs +172 -0
  75. package/test/run-js-tests.mjs +15 -3
  76. package/test/setup/private-snapshot-lock.js +17 -0
  77. package/test/shared-golden.js +44 -2
  78. package/test/transient-links.mjs +139 -0
@@ -303,6 +303,7 @@ describe('buildDockerRuntimeMetadata', () => {
303
303
  labels: null,
304
304
  commandHandoff: null,
305
305
  privileged: null,
306
+ init: null,
306
307
  network: null,
307
308
  networks: null,
308
309
  networkAliases: null,
@@ -332,6 +333,7 @@ describe('buildDockerRuntimeMetadata', () => {
332
333
  mounts: ['type=bind,src=/h,dst=/c'],
333
334
  env: ['FOO=bar'],
334
335
  privileged: true,
336
+ init: null,
335
337
  network: 'hive-formal-ai',
336
338
  networks: ['hive-formal-ai', 'public-egress'],
337
339
  networkAliases: ['formal-ai', 'checker'],
@@ -1,3 +1,5 @@
1
+ // Own snapshot lock even without the bunfig.toml preload (issue #234).
2
+ require('./setup/private-snapshot-lock');
1
3
  const { test } = require('node:test');
2
4
  const assert = require('node:assert/strict');
3
5
  const fs = require('node:fs');
@@ -104,3 +106,40 @@ test('snapshot lock is separate from store locks and excludes concurrent process
104
106
  fs.rmSync(dir, { recursive: true, force: true });
105
107
  }
106
108
  });
109
+
110
+ test('tests never share the host-wide snapshot lock (issue #234)', () => {
111
+ // bunfig.toml preloads test/setup/private-snapshot-lock.js. Without it a
112
+ // real `start --resume` holding the default lock failed every resume test.
113
+ const lockPath = process.env.START_SNAPSHOT_LOCK;
114
+ assert.ok(lockPath, 'START_SNAPSHOT_LOCK is not set by the test preload');
115
+ assert.notEqual(
116
+ lockPath,
117
+ path.join(
118
+ process.platform === 'win32' ? os.tmpdir() : '/tmp',
119
+ 'start-command-docker-snapshot.lock'
120
+ )
121
+ );
122
+ const lock = acquireSnapshotLock({ holder: 'issue-234' });
123
+ try {
124
+ assert.ok(fs.existsSync(lockPath));
125
+ } finally {
126
+ lock.release();
127
+ }
128
+ });
129
+
130
+ test('every snapshot test requires its private lock itself (issue #234)', () => {
131
+ // `bun test ./js/test/x.js` from the repository root skips js/bunfig.toml,
132
+ // so the preload alone left 18 tests on the host-wide lock.
133
+ const dir = __dirname;
134
+ const offenders = fs
135
+ .readdirSync(dir)
136
+ .filter((name) => name.endsWith('.js'))
137
+ .filter((name) => {
138
+ const source = fs.readFileSync(path.join(dir, name), 'utf8');
139
+ return (
140
+ /\b(resumeExecution|acquireSnapshotLock)\(/.test(source) &&
141
+ !source.includes("require('./setup/private-snapshot-lock')")
142
+ );
143
+ });
144
+ assert.deepEqual(offenders, []);
145
+ });
@@ -2,6 +2,8 @@
2
2
  * Tests for --resume and --resume-all (issue #162)
3
3
  */
4
4
 
5
+ // Own snapshot lock even without the bunfig.toml preload (issue #234).
6
+ require('./setup/private-snapshot-lock');
5
7
  const { test, describe } = require('node:test');
6
8
  const assert = require('node:assert');
7
9
  const {
@@ -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/
@@ -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
+ });