start-command 0.38.0 → 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 (59) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +4 -2
  3. package/bunfig.toml +4 -0
  4. package/eslint.config.mjs +1 -0
  5. package/package.json +4 -4
  6. package/src/lib/args-parser.js +9 -0
  7. package/src/lib/cgroup-memory.js +95 -23
  8. package/src/lib/command-builder.js +4 -0
  9. package/src/lib/container-removal.js +4 -5
  10. package/src/lib/detached-finalize.js +1 -0
  11. package/src/lib/docker-init-option.js +65 -0
  12. package/src/lib/docker-inspect-batch.js +176 -0
  13. package/src/lib/docker-resource-options.js +21 -2
  14. package/src/lib/docker-runtime-args.js +12 -3
  15. package/src/lib/execution-control.js +8 -6
  16. package/src/lib/execution-recovery.js +4 -1
  17. package/src/lib/execution-resume.js +4 -1
  18. package/src/lib/execution-store.js +19 -1
  19. package/src/lib/exit-evidence.js +139 -7
  20. package/src/lib/output-blocks.js +11 -0
  21. package/src/lib/resume-resources.js +2 -2
  22. package/src/lib/status-formatter.js +34 -130
  23. package/src/lib/status-probe.js +149 -0
  24. package/src/lib/store-decode-cache.js +141 -0
  25. package/src/lib/usage.js +3 -0
  26. package/test/cargo-lock-version.mjs +121 -0
  27. package/test/check-rust-coverage.mjs +71 -0
  28. package/test/check-web-archive.mjs +154 -0
  29. package/test/ci-workflow-invariants.js +204 -0
  30. package/test/command-builder-docker-cleanup.js +24 -0
  31. package/test/command-exit-codes.mjs +91 -0
  32. package/test/docker-runtime-options.js +2 -0
  33. package/test/docker-snapshot-safety.js +39 -0
  34. package/test/execution-resume.js +2 -0
  35. package/test/fixtures/fake-npm.sh +64 -0
  36. package/test/fixtures/lychee-report.md +39 -0
  37. package/test/javascript-first-ci.js +69 -0
  38. package/test/links-workflow.mjs +127 -0
  39. package/test/lint-gate.mjs +56 -0
  40. package/test/npm-publish.mjs +181 -0
  41. package/test/recheck-broken-links.mjs +452 -0
  42. package/test/regression-176.js +2 -0
  43. package/test/regression-187.js +2 -0
  44. package/test/regression-191.js +4 -0
  45. package/test/regression-193.js +2 -0
  46. package/test/regression-198.js +2 -0
  47. package/test/regression-207.js +8 -2
  48. package/test/regression-224.js +2 -0
  49. package/test/regression-236.js +250 -0
  50. package/test/regression-238.js +129 -0
  51. package/test/regression-241.js +254 -0
  52. package/test/regression-242.js +241 -0
  53. package/test/regression-243.js +493 -0
  54. package/test/regression-244.js +471 -0
  55. package/test/release-push-retry.mjs +172 -0
  56. package/test/run-js-tests.mjs +15 -3
  57. package/test/setup/private-snapshot-lock.js +17 -0
  58. package/test/shared-golden.js +29 -2
  59. package/test/transient-links.mjs +139 -0
@@ -0,0 +1,471 @@
1
+ /**
2
+ * Issue #244: `--init` / `--no-init` run Docker's bundled tini as PID 1, so
3
+ * orphaned processes of a long-running task are reaped instead of piling up
4
+ * as zombies. The default stays off; START_COMMAND_DOCKER_INIT=1 turns it on
5
+ * per host. The flag is stored with the record, carried into resumes and
6
+ * stacked launches, and changes neither exit codes nor OOM evidence.
7
+ */
8
+ // Own snapshot lock even without the bunfig.toml preload (issue #234).
9
+ require('./setup/private-snapshot-lock');
10
+ const { describe, it, expect } = require('bun:test');
11
+ const { spawnSync } = require('child_process');
12
+ const fs = require('fs');
13
+ const os = require('os');
14
+ const path = require('path');
15
+ const { parseArgs } = require('../src/lib/args-parser');
16
+ const {
17
+ DOCKER_INIT_ENV,
18
+ dockerInitFromEnv,
19
+ validate: validateDockerInitOption,
20
+ } = require('../src/lib/docker-init-option');
21
+ const {
22
+ buildDockerRuntimeArgs,
23
+ buildDockerRuntimeStatusLines,
24
+ buildDockerRuntimeMetadata,
25
+ } = require('../src/lib/docker-runtime-args');
26
+ const {
27
+ buildLaunchOptions,
28
+ buildResumePlan,
29
+ } = require('../src/lib/execution-resume');
30
+ const { SessionState } = require('../src/lib/session-probe');
31
+ const { buildNextLevelCommand } = require('../src/lib/command-builder');
32
+ const {
33
+ parseIsolationMetadata,
34
+ generateIsolationLines,
35
+ } = require('../src/lib/output-blocks');
36
+ const { canRunLinuxDockerImages } = require('../src/lib/isolation');
37
+
38
+ const CLI = path.join(__dirname, '../src/bin/cli.js');
39
+ const IMAGE = 'alpine:3.23';
40
+
41
+ function parse(args, env = {}) {
42
+ const saved = process.env[DOCKER_INIT_ENV];
43
+ if (env[DOCKER_INIT_ENV] === undefined) {
44
+ delete process.env[DOCKER_INIT_ENV];
45
+ } else {
46
+ process.env[DOCKER_INIT_ENV] = env[DOCKER_INIT_ENV];
47
+ }
48
+ try {
49
+ return parseArgs(args).wrapperOptions;
50
+ } finally {
51
+ if (saved === undefined) {
52
+ delete process.env[DOCKER_INIT_ENV];
53
+ } else {
54
+ process.env[DOCKER_INIT_ENV] = saved;
55
+ }
56
+ }
57
+ }
58
+
59
+ describe('issue #244: parsing --init / --no-init', () => {
60
+ it('turns init on with --init and off with --no-init', () => {
61
+ expect(parse(['-i', 'docker', '--init', '--', 'ls']).init).toBe(true);
62
+ expect(parse(['-i', 'docker', '--no-init', '--', 'ls']).init).toBe(false);
63
+ });
64
+
65
+ it('defaults to off', () => {
66
+ expect(parse(['-i', 'docker', '--', 'ls']).init).toBe(false);
67
+ expect(parse(['--', 'ls']).init).toBe(false);
68
+ });
69
+
70
+ it('takes the default from START_COMMAND_DOCKER_INIT', () => {
71
+ for (const value of ['1', 'true', 'YES', ' on ']) {
72
+ const env = { [DOCKER_INIT_ENV]: value };
73
+ expect(parse(['-i', 'docker', '--', 'ls'], env).init).toBe(true);
74
+ }
75
+ for (const value of ['0', 'false', 'off', '']) {
76
+ const env = { [DOCKER_INIT_ENV]: value };
77
+ expect(parse(['-i', 'docker', '--', 'ls'], env).init).toBe(false);
78
+ }
79
+ });
80
+
81
+ it('lets --no-init override the environment default', () => {
82
+ const env = { [DOCKER_INIT_ENV]: '1' };
83
+ expect(parse(['-i', 'docker', '--no-init', '--', 'ls'], env).init).toBe(
84
+ false
85
+ );
86
+ });
87
+
88
+ it('ignores the environment default without docker, without an error', () => {
89
+ const env = { [DOCKER_INIT_ENV]: '1' };
90
+ expect(parse(['--', 'ls'], env).init).toBe(false);
91
+ expect(parse(['-i', 'screen', '-d', '--', 'ls'], env).init).toBe(false);
92
+ });
93
+
94
+ it('rejects --init without docker in the stack', () => {
95
+ expect(() => parse(['--init', '--', 'ls'])).toThrow(
96
+ '--init option is only valid when isolation stack includes docker'
97
+ );
98
+ expect(() => parse(['-i', 'screen', '-d', '--init', '--', 'ls'])).toThrow(
99
+ '--init option is only valid when isolation stack includes docker'
100
+ );
101
+ });
102
+
103
+ it('accepts --no-init without docker, since it is the default', () => {
104
+ expect(parse(['--no-init', '--', 'ls']).init).toBe(false);
105
+ });
106
+
107
+ it('validates again without changing the result', () => {
108
+ const { validateOptions } = require('../src/lib/args-parser');
109
+ const env = { [DOCKER_INIT_ENV]: '1' };
110
+ for (const args of [
111
+ ['--', 'ls'],
112
+ ['-i', 'docker', '--', 'ls'],
113
+ ['-i', 'docker', '--no-init', '--', 'ls'],
114
+ ['-i', 'docker', '--init', '--', 'ls'],
115
+ ]) {
116
+ const options = parse(args, env);
117
+ const before = options.init;
118
+ validateOptions(options);
119
+ expect(options.init).toBe(before);
120
+ }
121
+ });
122
+
123
+ it('accepts a stack that contains docker', () => {
124
+ expect(
125
+ parse(['-i', 'screen docker', '--init', '--', 'ls']).init
126
+ ).toBeTrue();
127
+ });
128
+
129
+ it('reads the environment value case-insensitively', () => {
130
+ expect(dockerInitFromEnv({ [DOCKER_INIT_ENV]: 'True' })).toBe(true);
131
+ expect(dockerInitFromEnv({})).toBe(false);
132
+ });
133
+
134
+ it('validates with an explicit environment', () => {
135
+ const options = { isolated: 'docker', isolatedStack: ['docker'] };
136
+ options.init = null;
137
+ validateDockerInitOption(options, { [DOCKER_INIT_ENV]: 'on' });
138
+ expect(options.init).toBe(true);
139
+ });
140
+ });
141
+
142
+ describe('issue #244: docker run arguments, status and metadata', () => {
143
+ it('puts --init right after --privileged and before -e', () => {
144
+ expect(
145
+ buildDockerRuntimeArgs({ privileged: true, init: true, env: ['A=1'] })
146
+ ).toEqual(['--privileged', '--init', '-e', 'A=1']);
147
+ expect(buildDockerRuntimeArgs({ init: true })).toEqual(['--init']);
148
+ expect(buildDockerRuntimeArgs({ init: false })).not.toContain('--init');
149
+ });
150
+
151
+ it('shows the init status line only when on', () => {
152
+ expect(buildDockerRuntimeStatusLines({ init: true })).toContain(
153
+ '[Isolation] Init: true'
154
+ );
155
+ expect(
156
+ buildDockerRuntimeStatusLines({ init: false }).join('\n')
157
+ ).not.toContain('Init');
158
+ });
159
+
160
+ it('stores init in the record metadata', () => {
161
+ expect(buildDockerRuntimeMetadata({ init: true }).init).toBe(true);
162
+ expect(buildDockerRuntimeMetadata({ init: false }).init).toBeNull();
163
+ });
164
+
165
+ it('renders an init row in the start block, before user', () => {
166
+ const metadata = parseIsolationMetadata([
167
+ '[Isolation] Environment: docker, Mode: detached',
168
+ '[Isolation] Image: alpine:3.23',
169
+ '[Isolation] Init: true',
170
+ '[Isolation] User: box (isolated)',
171
+ ]);
172
+ expect(metadata.init).toBe(true);
173
+ const lines = generateIsolationLines(metadata, 'box').join('\n');
174
+ expect(lines).toMatch(/init\s+true/);
175
+ expect(lines.indexOf('init')).toBeLessThan(lines.indexOf('user'));
176
+ const off = parseIsolationMetadata(['[Isolation] Environment: docker']);
177
+ expect(generateIsolationLines(off, 'box').join('\n')).not.toMatch(/init/);
178
+ });
179
+ });
180
+
181
+ function makeRecord(options = {}) {
182
+ return {
183
+ uuid: '24400000-0000-4000-8000-000000000244',
184
+ status: 'executed',
185
+ command: 'npm test',
186
+ logPath: '/tmp/session.log',
187
+ options: {
188
+ isolated: 'docker',
189
+ isolationMode: 'detached',
190
+ sessionName: 'box',
191
+ image: 'ubuntu:24.04',
192
+ ...options,
193
+ },
194
+ };
195
+ }
196
+
197
+ const stopped = {
198
+ backend: 'docker',
199
+ sessionName: 'box',
200
+ state: SessionState.STOPPED,
201
+ alive: false,
202
+ containerStatus: 'exited',
203
+ };
204
+
205
+ describe('issue #244: resume and stacked launches keep the choice', () => {
206
+ it('relaunches with the stored init flag', () => {
207
+ expect(buildLaunchOptions(makeRecord({ init: true })).init).toBe(true);
208
+ expect(buildLaunchOptions(makeRecord()).init).toBe(false);
209
+ });
210
+
211
+ it('carries --init into the snapshot run', () => {
212
+ const plan = buildResumePlan(
213
+ makeRecord({ init: true }),
214
+ 'echo hi',
215
+ stopped
216
+ );
217
+ expect(plan.steps[1].args).toContain('--init');
218
+ });
219
+
220
+ it('starts records from before #244 without --init', () => {
221
+ const plan = buildResumePlan(makeRecord(), 'echo hi', stopped);
222
+ expect(plan.steps[1].args).not.toContain('--init');
223
+ });
224
+
225
+ it('passes --init to the nested level of a stack', () => {
226
+ const command = buildNextLevelCommand(
227
+ {
228
+ isolated: 'screen',
229
+ isolatedStack: ['screen', 'docker'],
230
+ imageStack: [null, IMAGE],
231
+ init: true,
232
+ },
233
+ 'ls'
234
+ );
235
+ expect(command).toContain('--init');
236
+ expect(
237
+ buildNextLevelCommand(
238
+ { isolated: 'screen', isolatedStack: ['screen', 'docker'] },
239
+ 'ls'
240
+ )
241
+ ).not.toContain('--init');
242
+ });
243
+ });
244
+
245
+ const dockerReady = (() => {
246
+ try {
247
+ return canRunLinuxDockerImages();
248
+ } catch {
249
+ return false;
250
+ }
251
+ })();
252
+
253
+ function docker(...args) {
254
+ const r = spawnSync('docker', args, { encoding: 'utf8', timeout: 60000 });
255
+ return { status: r.status, out: (r.stdout || '').trim(), err: r.stderr };
256
+ }
257
+
258
+ function waitUntil(check, timeoutMs) {
259
+ const end = Date.now() + timeoutMs;
260
+ while (Date.now() < end) {
261
+ const value = check();
262
+ if (value) {
263
+ return value;
264
+ }
265
+ spawnSync('sleep', ['0.2']);
266
+ }
267
+ return null;
268
+ }
269
+
270
+ /** Start a detached, kept container through the CLI. */
271
+ function launch(name, flags, command, root) {
272
+ return spawnSync(
273
+ process.execPath,
274
+ [
275
+ CLI,
276
+ '--isolated',
277
+ 'docker',
278
+ ...flags,
279
+ '--detached',
280
+ '--keep-container',
281
+ '--image',
282
+ IMAGE,
283
+ '--session',
284
+ name,
285
+ '--',
286
+ command,
287
+ ],
288
+ {
289
+ encoding: 'utf8',
290
+ timeout: 60000,
291
+ env: {
292
+ ...process.env,
293
+ HOME: root,
294
+ START_APP_FOLDER: path.join(root, 'app'),
295
+ START_LOG_DIR: path.join(root, 'logs'),
296
+ START_DISABLE_AUTO_ISSUE: '1',
297
+ START_DISABLE_LOG_UPLOAD: '1',
298
+ START_EXECUTION_ID: '',
299
+ [DOCKER_INIT_ENV]: '',
300
+ },
301
+ }
302
+ );
303
+ }
304
+
305
+ function status(name, root) {
306
+ const r = spawnSync(
307
+ process.execPath,
308
+ [CLI, '--status', name, '--output-format', 'json'],
309
+ {
310
+ encoding: 'utf8',
311
+ timeout: 60000,
312
+ env: {
313
+ ...process.env,
314
+ HOME: root,
315
+ START_APP_FOLDER: path.join(root, 'app'),
316
+ START_LOG_DIR: path.join(root, 'logs'),
317
+ START_DISABLE_AUTO_ISSUE: '1',
318
+ },
319
+ }
320
+ );
321
+ return r.stdout || '';
322
+ }
323
+
324
+ describe('issue #244: real docker', () => {
325
+ it.skipIf(!dockerReady)(
326
+ 'runs docker-init as PID 1 and reaps an orphan, recorded in --status',
327
+ () => {
328
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'start-244-'));
329
+ const names = [`t244-init-${process.pid}`, `t244-plain-${process.pid}`];
330
+ try {
331
+ // The subshell exits at once; its `sleep 0.2` child becomes an
332
+ // orphan of PID 1 and exits 0.2 s later.
333
+ const command = '(sleep 0.2 &) ; sleep 30';
334
+ for (const [name, flags] of [
335
+ [names[0], ['--init']],
336
+ [names[1], []],
337
+ ]) {
338
+ const r = launch(name, flags, command, root);
339
+ expect(r.status).toBe(0);
340
+ }
341
+ spawnSync('sleep', ['1.5']);
342
+ const probe = (name) => ({
343
+ init: docker('inspect', '-f', '{{.HostConfig.Init}}', name).out,
344
+ pid1: docker('exec', name, 'cat', '/proc/1/comm').out,
345
+ zombies: docker(
346
+ 'exec',
347
+ name,
348
+ 'sh',
349
+ '-c',
350
+ 'grep -l "^State:.*Z" /proc/[0-9]*/status 2>/dev/null | wc -l'
351
+ ).out,
352
+ });
353
+ const withInit = probe(names[0]);
354
+ const without = probe(names[1]);
355
+ expect(withInit).toEqual({
356
+ init: 'true',
357
+ pid1: 'docker-init',
358
+ zombies: '0',
359
+ });
360
+ // Unset, Docker reports `<nil>` (no --init flag on the run).
361
+ expect(['false', '<nil>']).toContain(without.init);
362
+ expect(without.pid1).not.toBe('docker-init');
363
+ expect(without.zombies).toBe('1');
364
+
365
+ expect(status(names[0], root)).toMatch(/"init":\s*true/);
366
+ expect(status(names[1], root)).not.toMatch(/"init":\s*true/);
367
+ } finally {
368
+ docker('rm', '-f', ...names);
369
+ fs.rmSync(root, { recursive: true, force: true });
370
+ }
371
+ },
372
+ 90000
373
+ );
374
+
375
+ it.skipIf(!dockerReady)(
376
+ 'keeps exit codes the same with and without --init',
377
+ () => {
378
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'start-244-'));
379
+ const names = [];
380
+ try {
381
+ const cases = [
382
+ ['exit3', 'exit 3', 3],
383
+ ['segv', 'sh -c "kill -SEGV \\$\\$"; exit $?', 139],
384
+ ];
385
+ for (const [label, command, expected] of cases) {
386
+ for (const flags of [['--init'], []]) {
387
+ const name = `t244-${label}-${flags.length}-${process.pid}`;
388
+ names.push(name);
389
+ expect(launch(name, flags, command, root).status).toBe(0);
390
+ const code = waitUntil(() => {
391
+ const r = docker(
392
+ 'inspect',
393
+ '-f',
394
+ '{{.State.Status}} {{.State.ExitCode}}',
395
+ name
396
+ );
397
+ const [state, exitCode] = r.out.split(' ');
398
+ return state === 'exited' ? Number(exitCode) : null;
399
+ }, 30000);
400
+ expect(code).toBe(expected);
401
+ }
402
+ }
403
+ // Both editions' --status agree on the exit code once the watcher
404
+ // has recorded it.
405
+ for (const name of names) {
406
+ const expected = name.includes('exit3') ? 3 : 139;
407
+ const recorded = waitUntil(() => {
408
+ const match = status(name, root).match(/"exitCode":\s*(-?\d+)/);
409
+ return match && Number(match[1]) === expected;
410
+ }, 30000);
411
+ expect(recorded).toBe(true);
412
+ }
413
+ } finally {
414
+ if (names.length) {
415
+ docker('rm', '-f', ...names);
416
+ }
417
+ fs.rmSync(root, { recursive: true, force: true });
418
+ }
419
+ },
420
+ 120000
421
+ );
422
+
423
+ it.skipIf(!dockerReady)(
424
+ 'docker kill gives 137 either way; docker stop gives 143 under tini',
425
+ () => {
426
+ const names = [`t244-k1-${process.pid}`, `t244-k0-${process.pid}`];
427
+ try {
428
+ const results = {};
429
+ for (const name of names) {
430
+ const init = name.includes('-k1-');
431
+ const run = docker(
432
+ 'run',
433
+ '-d',
434
+ '--name',
435
+ name,
436
+ ...(init ? ['--init'] : []),
437
+ IMAGE,
438
+ 'sleep',
439
+ '30'
440
+ );
441
+ expect(run.status).toBe(0);
442
+ docker('stop', '-t', '2', name);
443
+ results[init ? 'stopInit' : 'stopPlain'] = docker(
444
+ 'inspect',
445
+ '-f',
446
+ '{{.State.ExitCode}}',
447
+ name
448
+ ).out;
449
+ docker('start', name);
450
+ docker('kill', name);
451
+ docker('wait', name);
452
+ results[init ? 'killInit' : 'killPlain'] = docker(
453
+ 'inspect',
454
+ '-f',
455
+ '{{.State.ExitCode}}',
456
+ name
457
+ ).out;
458
+ }
459
+ expect(results).toEqual({
460
+ stopInit: '143',
461
+ stopPlain: '137',
462
+ killInit: '137',
463
+ killPlain: '137',
464
+ });
465
+ } finally {
466
+ docker('rm', '-f', ...names);
467
+ }
468
+ },
469
+ 90000
470
+ );
471
+ });
@@ -0,0 +1,172 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { execFileSync } from 'node:child_process';
3
+ import {
4
+ chmodSync,
5
+ existsSync,
6
+ mkdtempSync,
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
+ checked,
17
+ classifyPushRejection,
18
+ pushWithRetry,
19
+ } from '../../scripts/release-git.mjs';
20
+
21
+ function git(cwd, ...args) {
22
+ return execFileSync('git', args, { cwd, stdio: 'pipe' }).toString().trim();
23
+ }
24
+
25
+ function clone(dir, remote, name) {
26
+ const work = join(dir, name);
27
+ git(dir, 'clone', '-q', remote, work);
28
+ git(work, 'config', 'user.name', name);
29
+ git(work, 'config', 'user.email', `${name}@example.com`);
30
+ return work;
31
+ }
32
+
33
+ /**
34
+ * Two writers on one bare `main`: `other` lands first, then `ours` pushes a
35
+ * commit made on the stale tip - what happens when the main-writer
36
+ * concurrency group runs two release jobs back to back (#234).
37
+ */
38
+ async function withWriters({ otherFile, ourFile, rule = false }, callback) {
39
+ const dir = mkdtempSync(join(tmpdir(), 'release-push-'));
40
+ const previousCwd = process.cwd();
41
+ try {
42
+ const remote = join(dir, 'remote.git');
43
+ git(dir, 'init', '-q', '--bare', '-b', 'main', remote);
44
+ const seed = clone(dir, remote, 'seed');
45
+ writeFileSync(join(seed, 'package.json'), '{"version":"0.1.0"}\n');
46
+ git(seed, 'add', '.');
47
+ git(seed, 'commit', '-q', '-m', 'initial');
48
+ git(seed, 'push', '-q', 'origin', 'main');
49
+
50
+ const ours = clone(dir, remote, 'ours');
51
+ const other = clone(dir, remote, 'other');
52
+ writeFileSync(join(other, otherFile), 'other writer\n');
53
+ git(other, 'add', '.');
54
+ git(other, 'commit', '-q', '-m', 'other release');
55
+ git(other, 'push', '-q', 'origin', 'main');
56
+
57
+ writeFileSync(join(ours, ourFile), 'our release\n');
58
+ git(ours, 'add', '.');
59
+ git(ours, 'commit', '-q', '-m', 'our release');
60
+
61
+ const pushes = join(dir, 'pushes');
62
+ const hook = join(remote, 'hooks', 'pre-receive');
63
+ writeFileSync(
64
+ hook,
65
+ rule
66
+ ? `#!/bin/sh\necho x >> '${pushes}'\necho "error: GH013: Repository rule violations found for refs/heads/main." >&2\nexit 1\n`
67
+ : `#!/bin/sh\necho x >> '${pushes}'\n`
68
+ );
69
+ chmodSync(hook, 0o755);
70
+ process.chdir(ours);
71
+ return await callback({ ours, remote, pushes });
72
+ } finally {
73
+ process.chdir(previousCwd);
74
+ rmSync(dir, { recursive: true, force: true });
75
+ }
76
+ }
77
+
78
+ const noSleep = () => Promise.resolve();
79
+
80
+ describe('pushWithRetry (#234)', () => {
81
+ it('reproduces the bug: a writer queued behind another is rejected', async () => {
82
+ await withWriters(
83
+ { otherFile: 'Cargo.toml', ourFile: 'package.json' },
84
+ async () => {
85
+ await expect(
86
+ checked(
87
+ $`git push origin main`.run({ capture: true, mirror: false }),
88
+ 'git push origin main'
89
+ )
90
+ ).rejects.toThrow('git push origin main failed with exit code 1');
91
+ }
92
+ );
93
+ });
94
+
95
+ it('rebases onto the writer that landed first and pushes', async () => {
96
+ await withWriters(
97
+ { otherFile: 'Cargo.toml', ourFile: 'package.json' },
98
+ async ({ ours, remote }) => {
99
+ expect(await pushWithRetry($, { sleep: noSleep })).toEqual({
100
+ attempt: 2,
101
+ });
102
+ expect(git(remote, 'log', '--format=%s', 'main')).toBe(
103
+ 'our release\nother release\ninitial'
104
+ );
105
+ expect(git(ours, 'status', '--porcelain')).toBe('');
106
+ }
107
+ );
108
+ });
109
+
110
+ it('fails loudly when the other writer changed the same files', async () => {
111
+ await withWriters(
112
+ { otherFile: 'package.json', ourFile: 'package.json' },
113
+ async ({ remote }) => {
114
+ await expect(pushWithRetry($, { sleep: noSleep })).rejects.toThrow(
115
+ 'git pull --rebase origin main'
116
+ );
117
+ expect(git(remote, 'log', '-1', '--format=%s', 'main')).toBe(
118
+ 'other release'
119
+ );
120
+ }
121
+ );
122
+ });
123
+
124
+ it('does not retry a push a repository rule rejected', async () => {
125
+ await withWriters(
126
+ { otherFile: 'Cargo.toml', ourFile: 'package.json', rule: true },
127
+ async ({ pushes }) => {
128
+ // Land on the tip first so only the rule can reject the push.
129
+ await checked(
130
+ $`git pull --rebase origin main`.run({
131
+ capture: true,
132
+ mirror: false,
133
+ }),
134
+ 'git pull --rebase'
135
+ );
136
+ await expect(pushWithRetry($, { sleep: noSleep })).rejects.toThrow(
137
+ 'blocked by a repository rule'
138
+ );
139
+ expect(existsSync(pushes)).toBe(true);
140
+ expect(execFileSync('wc', ['-l', pushes]).toString()).toMatch(/^\s*1 /);
141
+ }
142
+ );
143
+ });
144
+
145
+ it('classifies real git push output', () => {
146
+ expect(
147
+ classifyPushRejection(
148
+ ' ! [rejected] main -> main (fetch first)\nerror: failed to push some refs'
149
+ )
150
+ ).toBe('non-fast-forward');
151
+ expect(
152
+ classifyPushRejection(
153
+ ' ! [rejected] main -> main (non-fast-forward)'
154
+ )
155
+ ).toBe('non-fast-forward');
156
+ expect(
157
+ classifyPushRejection(
158
+ 'remote: error: GH013: Repository rule violations found for refs/heads/main.\n ! [remote rejected] main -> main (push declined due to repository rule violations)'
159
+ )
160
+ ).toBe('rule');
161
+ expect(
162
+ classifyPushRejection(
163
+ 'remote: error: GH006: Protected branch update failed for refs/heads/main.'
164
+ )
165
+ ).toBe('rule');
166
+ expect(
167
+ classifyPushRejection(
168
+ "fatal: unable to access 'https://github.com/x/y/': Could not resolve host"
169
+ )
170
+ ).toBe('other');
171
+ });
172
+ });
@@ -28,8 +28,20 @@ describe('run-js-tests timeout defaults', () => {
28
28
  ]);
29
29
  });
30
30
 
31
- test('preserves Bun short timeout flag', () => {
32
- expect(hasExplicitTimeout(['-t', '45000'])).toBe(true);
33
- expect(applyDefaultTimeout(['-t', '45000'])).toEqual(['-t', '45000']);
31
+ // `bun test -t` is --test-name-pattern, not a timeout (#234).
32
+ test('keeps the default timeout for a -t name pattern', () => {
33
+ expect(hasExplicitTimeout(['-t', 'slow'])).toBe(false);
34
+ expect(applyDefaultTimeout(['-t', 'slow'])).toEqual([
35
+ '--timeout',
36
+ '30000',
37
+ '-t',
38
+ 'slow',
39
+ ]);
40
+ expect(applyDefaultTimeout(['--test-name-pattern', 'slow'])).toEqual([
41
+ '--timeout',
42
+ '30000',
43
+ '--test-name-pattern',
44
+ 'slow',
45
+ ]);
34
46
  });
35
47
  });
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Test preload (bunfig.toml): give this test process its own Docker snapshot
3
+ * lock. The default lock is host-wide, so a real `start --resume` or another
4
+ * test process on the same host holding it made every snapshot-resume test
5
+ * fail after the 1s default wait (issue #234). A path set by the caller wins,
6
+ * so experiments can still point the tests at a shared lock.
7
+ */
8
+ const { mkdtempSync } = require('fs');
9
+ const { tmpdir } = require('os');
10
+ const { join } = require('path');
11
+
12
+ if (!process.env.START_SNAPSHOT_LOCK) {
13
+ process.env.START_SNAPSHOT_LOCK = join(
14
+ mkdtempSync(join(tmpdir(), 'start-test-snapshot-lock-')),
15
+ 'snapshot.lock'
16
+ );
17
+ }