start-command 0.32.1 → 0.34.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 (46) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +3 -0
  3. package/eslint.config.mjs +8 -0
  4. package/package.json +7 -3
  5. package/src/bin/cli.js +32 -137
  6. package/src/lib/args-parser-queries.js +228 -0
  7. package/src/lib/args-parser.js +38 -179
  8. package/src/lib/detached-finalize.js +281 -0
  9. package/src/lib/docker-cleanup.js +240 -39
  10. package/src/lib/docker-post-mortem.js +289 -0
  11. package/src/lib/execution-attach.js +245 -0
  12. package/src/lib/execution-resume-all.js +211 -0
  13. package/src/lib/execution-resume.js +455 -0
  14. package/src/lib/execution-store.js +93 -6
  15. package/src/lib/exit-reason.js +264 -0
  16. package/src/lib/failure-handler.js +43 -41
  17. package/src/lib/isolation-log-utils.js +66 -1
  18. package/src/lib/isolation.js +28 -15
  19. package/src/lib/query-commands.js +202 -0
  20. package/src/lib/screen-isolation.js +2 -1
  21. package/src/lib/session-probe.js +121 -0
  22. package/src/lib/shell-utils.js +164 -10
  23. package/src/lib/status-formatter.js +233 -69
  24. package/src/lib/usage.js +13 -1
  25. package/src/lib/user-manager.js +18 -1
  26. package/test/args-parser-attach-resume.js +159 -0
  27. package/test/args-parser-control.js +3 -3
  28. package/test/args-parser.js +1 -1
  29. package/test/check-file-size.js +91 -0
  30. package/test/ci-workflow-invariants.js +191 -0
  31. package/test/echo-integration.js +20 -13
  32. package/test/execution-attach.js +221 -0
  33. package/test/execution-resume.js +433 -0
  34. package/test/execution-store.js +57 -2
  35. package/test/exit-reason.js +209 -0
  36. package/test/failure-handler.js +107 -0
  37. package/test/load-command-stream.mjs +83 -0
  38. package/test/regression-162.js +320 -0
  39. package/test/regression-164.js +308 -0
  40. package/test/regression-165.js +310 -0
  41. package/test/regression-170.js +321 -0
  42. package/test/regression-171.js +562 -0
  43. package/test/regression-84.js +8 -3
  44. package/test/release-name.mjs +57 -0
  45. package/test/session-probe.js +150 -0
  46. package/test/user-manager.js +23 -0
@@ -0,0 +1,562 @@
1
+ /**
2
+ * Regression tests for issue #171: the "Container kept for investigation" log
3
+ * omits the post-mortem facts docker already has.
4
+ *
5
+ * 171.1 the watcher's single `docker inspect` must also collect
6
+ * `StartedAt`, `FinishedAt` and `Error`;
7
+ * 171.2 a post-mortem block must be appended whenever the container is kept;
8
+ * 171.3 the removal path must state the same facts in one line;
9
+ * 171.4 signal decoding (`128+n` -> name) lives in one shared helper used by
10
+ * both the watcher (completion time) and the status formatter.
11
+ */
12
+
13
+ const { describe, it, expect, afterEach } = require('bun:test');
14
+ const { spawnSync } = require('child_process');
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const os = require('os');
18
+
19
+ const {
20
+ buildDetachedDockerCompletionScript,
21
+ readDockerContainerState,
22
+ recordAttachedDockerPostMortem,
23
+ } = require('../src/lib/docker-cleanup');
24
+ const {
25
+ DOCKER_STATE_INSPECT_FORMAT,
26
+ buildDockerPostMortemSnippet,
27
+ buildDockerRemovalNoteSnippet,
28
+ buildDockerStateSnippet,
29
+ describeExitCode,
30
+ formatContainerPostMortem,
31
+ formatContainerRemovalNote,
32
+ formatLifetime,
33
+ normalizeDockerTimestamp,
34
+ } = require('../src/lib/docker-post-mortem');
35
+ const {
36
+ describeExitCode: reExported,
37
+ } = require('../src/lib/isolation-log-utils');
38
+
39
+ const tempDirs = [];
40
+
41
+ function makeTempDir(prefix) {
42
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
43
+ tempDirs.push(dir);
44
+ return dir;
45
+ }
46
+
47
+ afterEach(() => {
48
+ while (tempDirs.length > 0) {
49
+ const dir = tempDirs.pop();
50
+ fs.rmSync(dir, { recursive: true, force: true });
51
+ }
52
+ });
53
+
54
+ describe('issue #171.4: one shared exit-code describer', () => {
55
+ it('decodes 128+n into the signal name', () => {
56
+ expect(describeExitCode(137)).toEqual({
57
+ code: 137,
58
+ signal: 'SIGKILL',
59
+ text: '137 (SIGKILL - 128+9)',
60
+ });
61
+ expect(describeExitCode(143).signal).toBe('SIGTERM');
62
+ expect(describeExitCode(139).signal).toBe('SIGSEGV');
63
+ });
64
+
65
+ it('leaves ordinary exit codes alone', () => {
66
+ expect(describeExitCode(0)).toEqual({ code: 0, signal: null, text: '0' });
67
+ expect(describeExitCode(1).text).toBe('1');
68
+ expect(describeExitCode(-1).text).toBe('-1');
69
+ });
70
+
71
+ it('reports unknown for a non numeric code', () => {
72
+ expect(describeExitCode(null).text).toBe('unknown');
73
+ expect(describeExitCode(undefined).signal).toBeNull();
74
+ });
75
+
76
+ it('is re-exported from isolation-log-utils so both callers share it', () => {
77
+ expect(reExported).toBe(describeExitCode);
78
+ });
79
+ });
80
+
81
+ describe('issue #171: docker timestamp and lifetime helpers', () => {
82
+ it('rejects the docker zero-time sentinel', () => {
83
+ expect(normalizeDockerTimestamp('0001-01-01T00:00:00Z')).toBeNull();
84
+ expect(normalizeDockerTimestamp('')).toBeNull();
85
+ expect(normalizeDockerTimestamp('unknown')).toBeNull();
86
+ expect(normalizeDockerTimestamp('<no value>')).toBeNull();
87
+ expect(normalizeDockerTimestamp('2026-09-15T22:21:46.740817278Z')).toBe(
88
+ '2026-09-15T22:21:46.740817278Z'
89
+ );
90
+ });
91
+
92
+ it('computes the container lifetime', () => {
93
+ expect(
94
+ formatLifetime(
95
+ '2026-09-15T22:21:40.942007645Z',
96
+ '2026-09-15T22:21:46.740817278Z'
97
+ )
98
+ ).toBe('5.798s');
99
+ expect(formatLifetime('unknown', 'unknown')).toBeNull();
100
+ expect(
101
+ formatLifetime('2026-09-15T22:21:46.000Z', '0001-01-01T00:00:00Z')
102
+ ).toBeNull();
103
+ });
104
+ });
105
+
106
+ describe('issue #171.2: the kept-container log carries the post-mortem', () => {
107
+ it('formats the block in the documented shape', () => {
108
+ const block = formatContainerPostMortem({
109
+ containerName: 'demo',
110
+ exitCode: 137,
111
+ oomKilled: false,
112
+ startedAt: '2026-09-15T22:21:40.942007645Z',
113
+ finishedAt: '2026-09-15T22:21:46.740817278Z',
114
+ error: '',
115
+ });
116
+
117
+ expect(block).toContain('=== Container post-mortem ===');
118
+ expect(block).toContain('Exit Code: 137 (SIGKILL - 128+9)');
119
+ expect(block).toContain('OOMKilled: false');
120
+ expect(block).toContain('StartedAt: 2026-09-15T22:21:40.942007645Z');
121
+ expect(block).toContain('FinishedAt: 2026-09-15T22:21:46.740817278Z');
122
+ expect(block).toContain('Lifetime: 5.798s');
123
+ expect(block).toContain('Error: (none)');
124
+ });
125
+
126
+ it('renders unknown facts instead of empty fields', () => {
127
+ const block = formatContainerPostMortem({
128
+ containerName: 'demo',
129
+ exitCode: null,
130
+ oomKilled: null,
131
+ startedAt: '0001-01-01T00:00:00Z',
132
+ finishedAt: '',
133
+ error: 'OCI runtime create failed',
134
+ });
135
+ expect(block).toContain('Exit Code: unknown');
136
+ expect(block).toContain('StartedAt: unknown');
137
+ expect(block).toContain('Lifetime: unknown');
138
+ expect(block).toContain('Error: OCI runtime create failed');
139
+ });
140
+ });
141
+
142
+ describe('issue #171.3: the removal path states the same facts', () => {
143
+ it('formats a single line', () => {
144
+ expect(
145
+ formatContainerRemovalNote({
146
+ containerName: 'demo',
147
+ exitCode: 137,
148
+ oomKilled: false,
149
+ startedAt: '2026-09-15T22:21:40.942007645Z',
150
+ finishedAt: '2026-09-15T22:21:46.740817278Z',
151
+ })
152
+ ).toBe(
153
+ 'Container removed: demo (exit 137, SIGKILL, lifetime 5.798s, oomKilled=false)\n'
154
+ );
155
+ });
156
+ });
157
+
158
+ describe('issue #171.1: the watcher collects the full state in one inspect', () => {
159
+ it('asks docker for exit code, oom flag and both timestamps', () => {
160
+ expect(DOCKER_STATE_INSPECT_FORMAT).toContain('{{.State.ExitCode}}');
161
+ expect(DOCKER_STATE_INSPECT_FORMAT).toContain('{{.State.OOMKilled}}');
162
+ expect(DOCKER_STATE_INSPECT_FORMAT).toContain('{{.State.StartedAt}}');
163
+ expect(DOCKER_STATE_INSPECT_FORMAT).toContain('{{.State.FinishedAt}}');
164
+ });
165
+ });
166
+
167
+ describe('issue #171: the generated watcher shell writes the facts', () => {
168
+ // The watcher body is a POSIX `sh` fragment that docker-cleanup.js runs with
169
+ // `sh -c`. Windows has no `/bin/sh`, so there is nothing to assert there; the
170
+ // pure formatters above cover the same rendering on every platform.
171
+ const needsPosixShell = () => {
172
+ if (process.platform !== 'win32') {
173
+ return false;
174
+ }
175
+ console.log(' Skipping: the watcher body needs a POSIX /bin/sh');
176
+ return true;
177
+ };
178
+
179
+ function runWatcherBody(snippetFactory, dockerState, dockerError, dateStub) {
180
+ const dir = makeTempDir('watcher-171-');
181
+ const logPath = path.join(dir, 'run.log');
182
+ const snippet = snippetFactory(logPath);
183
+ const binDir = path.join(dir, 'bin');
184
+ fs.mkdirSync(binDir);
185
+ if (dateStub) {
186
+ const datePath = path.join(binDir, 'date');
187
+ fs.writeFileSync(datePath, dateStub, 'utf8');
188
+ fs.chmodSync(datePath, 0o755);
189
+ }
190
+ const dockerPath = path.join(binDir, 'docker');
191
+ fs.writeFileSync(
192
+ dockerPath,
193
+ [
194
+ '#!/bin/sh',
195
+ '[ "$1" = "inspect" ] || exit 1',
196
+ 'case "$3" in',
197
+ ` *State.Error*) printf '%s\\n' ${JSON.stringify(dockerError || '')} ;;`,
198
+ ` *) printf '%s\\n' ${JSON.stringify(dockerState)} ;;`,
199
+ 'esac',
200
+ '',
201
+ ].join('\n'),
202
+ 'utf8'
203
+ );
204
+ fs.chmodSync(dockerPath, 0o755);
205
+
206
+ spawnSync('/bin/sh', ['-c', snippet], {
207
+ encoding: 'utf8',
208
+ env: {
209
+ ...process.env,
210
+ PATH: `${binDir}${path.delimiter}${process.env.PATH}`,
211
+ },
212
+ timeout: 20000,
213
+ });
214
+ return fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : '';
215
+ }
216
+
217
+ it('appends the post-mortem block for a kept container', () => {
218
+ if (needsPosixShell()) {
219
+ return;
220
+ }
221
+ const log = runWatcherBody(
222
+ (logPath) =>
223
+ `${buildDockerStateSnippet('demo')}; ${buildDockerPostMortemSnippet(
224
+ 'demo',
225
+ `'${logPath}'`
226
+ )}`,
227
+ '137 false 2026-09-15T22:21:40.942007645Z 2026-09-15T22:21:46.740817278Z'
228
+ );
229
+
230
+ expect(log).toContain('=== Container post-mortem ===');
231
+ expect(log).toContain('Exit Code: 137 (SIGKILL - 128+9)');
232
+ expect(log).toContain('OOMKilled: false');
233
+ expect(log).toContain('StartedAt: 2026-09-15T22:21:40.942007645Z');
234
+ expect(log).toContain('FinishedAt: 2026-09-15T22:21:46.740817278Z');
235
+ expect(log).toContain('Lifetime: 5.798s');
236
+ expect(log).toContain('Error: (none)');
237
+ });
238
+
239
+ it('appends a one line note for a removed container', () => {
240
+ if (needsPosixShell()) {
241
+ return;
242
+ }
243
+ const log = runWatcherBody(
244
+ (logPath) =>
245
+ `${buildDockerStateSnippet('demo')}; ${buildDockerRemovalNoteSnippet(
246
+ 'demo',
247
+ `'${logPath}'`
248
+ )}`,
249
+ '0 false 2026-09-15T22:21:40.000000000Z 2026-09-15T22:21:41.500000000Z'
250
+ );
251
+
252
+ expect(log).toContain(
253
+ 'Container removed: demo (exit 0, lifetime 1.500s, oomKilled=false)'
254
+ );
255
+ });
256
+
257
+ // A `date` shaped like BSD's: it rejects GNU's `-d` and `%N` and only parses
258
+ // through `-j -f`. That makes the fallback branch reachable on Linux, where
259
+ // it would otherwise never execute.
260
+ const BSD_DATE_STUB = [
261
+ '#!/bin/sh',
262
+ 'if [ "$1" = "-u" ] && [ "$2" = "-j" ] && [ "$3" = "-f" ]; then',
263
+ // On macOS /bin/date is already BSD, so it understands these flags as they
264
+ // stand; on Linux it is GNU and rejects them, so translate instead.
265
+ ' /bin/date "$@" 2>/dev/null && exit 0',
266
+ ' exec /bin/date -u -d "$(echo "$5" | tr \'T\' \' \')" "$6"',
267
+ 'fi',
268
+ 'exit 1',
269
+ '',
270
+ ].join('\n');
271
+
272
+ it('keeps the lifetime exact on a host whose date is BSD', () => {
273
+ if (needsPosixShell()) {
274
+ return;
275
+ }
276
+ const log = runWatcherBody(
277
+ (logPath) =>
278
+ `${buildDockerStateSnippet('demo')}; ${buildDockerPostMortemSnippet(
279
+ 'demo',
280
+ `'${logPath}'`
281
+ )}`,
282
+ '137 false 2026-09-15T22:21:40.942007645Z 2026-09-15T22:21:46.740817278Z',
283
+ '',
284
+ BSD_DATE_STUB
285
+ );
286
+
287
+ // Truncating both timestamps to whole seconds would report 6.000s here.
288
+ expect(log).toContain('Lifetime: 5.798s');
289
+ });
290
+
291
+ it('keeps sub-second lifetimes exact on a BSD date host', () => {
292
+ if (needsPosixShell()) {
293
+ return;
294
+ }
295
+ const log = runWatcherBody(
296
+ (logPath) =>
297
+ `${buildDockerStateSnippet('demo')}; ${buildDockerRemovalNoteSnippet(
298
+ 'demo',
299
+ `'${logPath}'`
300
+ )}`,
301
+ '0 false 2026-09-15T22:21:40.000000000Z 2026-09-15T22:21:41.500000000Z',
302
+ '',
303
+ BSD_DATE_STUB
304
+ );
305
+
306
+ // Truncating would collapse this to 1.000s.
307
+ expect(log).toContain(
308
+ 'Container removed: demo (exit 0, lifetime 1.500s, oomKilled=false)'
309
+ );
310
+ });
311
+
312
+ it('survives an inspect failure without writing garbage', () => {
313
+ if (needsPosixShell()) {
314
+ return;
315
+ }
316
+ const dir = makeTempDir('watcher-171-fail-');
317
+ const logPath = path.join(dir, 'run.log');
318
+ const binDir = path.join(dir, 'bin');
319
+ fs.mkdirSync(binDir);
320
+ fs.writeFileSync(
321
+ path.join(binDir, 'docker'),
322
+ ['#!/bin/sh', 'exit 1', ''].join('\n'),
323
+ 'utf8'
324
+ );
325
+ fs.chmodSync(path.join(binDir, 'docker'), 0o755);
326
+
327
+ const snippet = `${buildDockerStateSnippet(
328
+ 'demo'
329
+ )}; ${buildDockerPostMortemSnippet('demo', `'${logPath}'`)}`;
330
+ const result = spawnSync('/bin/sh', ['-c', snippet], {
331
+ encoding: 'utf8',
332
+ env: {
333
+ ...process.env,
334
+ PATH: `${binDir}${path.delimiter}${process.env.PATH}`,
335
+ },
336
+ timeout: 20000,
337
+ });
338
+
339
+ expect(result.status).toBe(0);
340
+ const log = fs.readFileSync(logPath, 'utf8');
341
+ expect(log).toContain('Exit Code: -1');
342
+ expect(log).toContain('StartedAt: unknown');
343
+ expect(log).toContain('Lifetime: unknown');
344
+ expect(log).not.toContain('0001-01-01');
345
+ });
346
+ });
347
+
348
+ describe('issue #171: the completion script wires the facts into every path', () => {
349
+ it('collects the full state and writes a post-mortem on the kept path', () => {
350
+ const script = buildDetachedDockerCompletionScript(
351
+ 'demo',
352
+ 'keep-on-fail',
353
+ '/tmp/demo.log'
354
+ );
355
+
356
+ expect(script).toContain('{{.State.StartedAt}}');
357
+ expect(script).toContain('{{.State.FinishedAt}}');
358
+ expect(script).toContain('{{.State.Error}}');
359
+ expect(script).toContain('=== Container post-mortem ===');
360
+ expect(script).toContain('Container removed:');
361
+ expect(script).toContain('Container kept for investigation');
362
+ });
363
+
364
+ it('writes a post-mortem even when the container is always kept', () => {
365
+ const script = buildDetachedDockerCompletionScript(
366
+ 'demo',
367
+ 'keep',
368
+ '/tmp/demo.log'
369
+ );
370
+ expect(script).toContain('=== Container post-mortem ===');
371
+ // The only `docker rm -f` left is the copy-paste hint in the kept message.
372
+ expect(script).not.toContain("docker rm -f 'demo' >>");
373
+ });
374
+
375
+ it('writes the removal note when the container is always removed', () => {
376
+ const script = buildDetachedDockerCompletionScript(
377
+ 'demo',
378
+ 'always',
379
+ '/tmp/demo.log'
380
+ );
381
+ expect(script).toContain('Container removed:');
382
+ expect(script).toContain("docker rm -f 'demo' >>");
383
+ });
384
+ });
385
+
386
+ /**
387
+ * A fake `docker` that answers the two `inspect` templates
388
+ * `readDockerContainerState()` issues: the whitespace-separated state line and
389
+ * the free-form `State.Error`.
390
+ *
391
+ * Windows gets a `.cmd` batch stub: `spawnSync()` there cannot execute an
392
+ * extensionless `#!/bin/sh` script, and the attached reader is a plain
393
+ * `spawnSync()` that must keep working on every platform.
394
+ */
395
+ function withFakeDockerState({ state, error = '' }, fn) {
396
+ const fakeBin = makeTempDir('fake-docker-171-');
397
+ const isWindows = process.platform === 'win32';
398
+ const dockerPath = path.join(fakeBin, isWindows ? 'docker.cmd' : 'docker');
399
+ fs.writeFileSync(
400
+ dockerPath,
401
+ isWindows
402
+ ? [
403
+ '@echo off',
404
+ 'if not "%1"=="inspect" exit /b 1',
405
+ // `%*`, not `%3`: `shell: true` on Windows passes the argument
406
+ // vector verbatim, so a template containing spaces lands in several
407
+ // positional parameters.
408
+ 'echo %* | findstr /C:"State.Error" >nul',
409
+ 'if %errorlevel%==0 (',
410
+ // `echo` with nothing after it prints "ECHO is on."; `echo.` is the
411
+ // batch idiom for an empty line, which is what an empty State.Error is.
412
+ ` echo${error ? ` ${error}` : '.'}`,
413
+ ') else (',
414
+ ` echo ${state}`,
415
+ ')',
416
+ 'exit /b 0',
417
+ '',
418
+ ].join('\r\n')
419
+ : [
420
+ '#!/bin/sh',
421
+ '[ "$1" = "inspect" ] || exit 1',
422
+ 'case "$3" in',
423
+ ` *State.Error*) printf '%s\\n' ${JSON.stringify(error)} ;;`,
424
+ ` *) printf '%s\\n' ${JSON.stringify(state)} ;;`,
425
+ 'esac',
426
+ '',
427
+ ].join('\n'),
428
+ 'utf8'
429
+ );
430
+ fs.chmodSync(dockerPath, 0o755);
431
+ const original = process.env.START_DOCKER_BIN;
432
+ process.env.START_DOCKER_BIN = dockerPath;
433
+ try {
434
+ return fn();
435
+ } finally {
436
+ if (original === undefined) {
437
+ delete process.env.START_DOCKER_BIN;
438
+ } else {
439
+ process.env.START_DOCKER_BIN = original;
440
+ }
441
+ }
442
+ }
443
+
444
+ describe('issue #171: the attached path records the same facts', () => {
445
+ it('reads every documented fact in one inspect', () => {
446
+ const state = withFakeDockerState(
447
+ {
448
+ state:
449
+ '137 false 2026-09-15T22:21:40.942007645Z 2026-09-15T22:21:46.740817278Z',
450
+ },
451
+ () => readDockerContainerState('demo')
452
+ );
453
+
454
+ expect(state).toEqual({
455
+ containerName: 'demo',
456
+ exitCode: 137,
457
+ oomKilled: false,
458
+ startedAt: '2026-09-15T22:21:40.942007645Z',
459
+ finishedAt: '2026-09-15T22:21:46.740817278Z',
460
+ error: null,
461
+ });
462
+ });
463
+
464
+ it('rejects the zero-time sentinel for a container that never started', () => {
465
+ const state = withFakeDockerState(
466
+ {
467
+ state: '125 false 0001-01-01T00:00:00Z 0001-01-01T00:00:00Z',
468
+ error: 'no such file or directory',
469
+ },
470
+ () => readDockerContainerState('demo')
471
+ );
472
+
473
+ expect(state.startedAt).toBeNull();
474
+ expect(state.finishedAt).toBeNull();
475
+ expect(state.error).toBe('no such file or directory');
476
+ });
477
+
478
+ it('returns null when the container cannot be inspected at all', () => {
479
+ const fakeBin = makeTempDir('fake-docker-gone-171-');
480
+ const dockerPath = path.join(fakeBin, 'docker');
481
+ fs.writeFileSync(dockerPath, '#!/bin/sh\nexit 1\n', 'utf8');
482
+ fs.chmodSync(dockerPath, 0o755);
483
+ const original = process.env.START_DOCKER_BIN;
484
+ process.env.START_DOCKER_BIN = dockerPath;
485
+ try {
486
+ expect(readDockerContainerState('demo')).toBeNull();
487
+ } finally {
488
+ if (original === undefined) {
489
+ delete process.env.START_DOCKER_BIN;
490
+ } else {
491
+ process.env.START_DOCKER_BIN = original;
492
+ }
493
+ }
494
+ });
495
+
496
+ it('appends the post-mortem block to a kept attached run log', () => {
497
+ const dir = makeTempDir('attached-kept-171-');
498
+ const logPath = path.join(dir, 'run.log');
499
+ fs.writeFileSync(logPath, 'output\n', 'utf8');
500
+
501
+ const message = recordAttachedDockerPostMortem({
502
+ containerName: 'demo',
503
+ state: {
504
+ exitCode: 137,
505
+ oomKilled: false,
506
+ startedAt: '2026-09-15T22:21:40.942007645Z',
507
+ finishedAt: '2026-09-15T22:21:46.740817278Z',
508
+ error: null,
509
+ },
510
+ logPath,
511
+ removed: false,
512
+ });
513
+
514
+ const log = fs.readFileSync(logPath, 'utf8');
515
+ expect(log).toContain('=== Container post-mortem ===');
516
+ expect(log).toContain('Exit Code: 137 (SIGKILL - 128+9)');
517
+ expect(log).toContain('Lifetime: 5.798s');
518
+ expect(message).toContain('=== Container post-mortem ===');
519
+ });
520
+
521
+ it('appends the one line note when the attached container is removed', () => {
522
+ const dir = makeTempDir('attached-removed-171-');
523
+ const logPath = path.join(dir, 'run.log');
524
+ fs.writeFileSync(logPath, 'output\n', 'utf8');
525
+
526
+ const message = recordAttachedDockerPostMortem({
527
+ containerName: 'demo',
528
+ state: {
529
+ exitCode: 0,
530
+ oomKilled: false,
531
+ startedAt: '2026-09-15T22:21:40.942007645Z',
532
+ finishedAt: '2026-09-15T22:21:46.740817278Z',
533
+ error: null,
534
+ },
535
+ logPath,
536
+ removed: true,
537
+ });
538
+
539
+ expect(fs.readFileSync(logPath, 'utf8')).toContain(
540
+ 'Container removed: demo (exit 0, lifetime 5.798s, oomKilled=false)'
541
+ );
542
+ expect(message).toBe(
543
+ '\nContainer removed: demo (exit 0, lifetime 5.798s, oomKilled=false)'
544
+ );
545
+ });
546
+
547
+ it('stays silent when docker could not be inspected', () => {
548
+ const dir = makeTempDir('attached-silent-171-');
549
+ const logPath = path.join(dir, 'run.log');
550
+ fs.writeFileSync(logPath, 'output\n', 'utf8');
551
+
552
+ expect(
553
+ recordAttachedDockerPostMortem({
554
+ containerName: 'demo',
555
+ state: null,
556
+ logPath,
557
+ removed: false,
558
+ })
559
+ ).toBe('');
560
+ expect(fs.readFileSync(logPath, 'utf8')).toBe('output\n');
561
+ });
562
+ });
@@ -250,9 +250,14 @@ describe('Docker not installed check (issue #84)', () => {
250
250
  message.toLowerCase().includes('not installed'),
251
251
  'Message must indicate Docker is not installed'
252
252
  );
253
- assert.ok(
254
- message.includes('https://docs.docker.com/get-docker/'),
255
- 'Message must include an installation URL'
253
+ // Compare the whole URL, not a substring of the message: a substring test
254
+ // also passes for a look-alike such as https://evil.invalid/docs.docker.com
255
+ // (CodeQL js/incomplete-url-substring-sanitization, issue #168).
256
+ const [installUrl] = message.match(/https?:\/\/\S+/) ?? [];
257
+ assert.equal(
258
+ installUrl,
259
+ 'https://docs.docker.com/get-docker/',
260
+ 'Message must include the Docker installation URL'
256
261
  );
257
262
  });
258
263
 
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'bun:test';
2
2
  import {
3
+ containsPackageVersionBadge,
3
4
  encodeShieldsStaticBadgeSegment,
4
5
  extractChangelogEntry,
5
6
  normalizeReleaseVersionForBadge,
@@ -115,3 +116,59 @@ describe('extractChangelogEntry', () => {
115
116
  );
116
117
  });
117
118
  });
119
+
120
+ describe('containsPackageVersionBadge', () => {
121
+ it('detects the badge this repository generates', () => {
122
+ const badge = packageVersionBadge({
123
+ packageType: 'npm',
124
+ packageName: 'start-command',
125
+ releaseVersion: 'js-v1.2.3',
126
+ });
127
+
128
+ expect(containsPackageVersionBadge(`Notes\n\n---\n\n${badge}`)).toBe(true);
129
+ });
130
+
131
+ it('reports unformatted release notes', () => {
132
+ expect(containsPackageVersionBadge('### Patch Changes\n\n- Fix.')).toBe(
133
+ false
134
+ );
135
+ expect(containsPackageVersionBadge('')).toBe(false);
136
+ expect(containsPackageVersionBadge(undefined)).toBe(false);
137
+ });
138
+
139
+ it('does not treat a foreign host that merely mentions the badge host as a badge', () => {
140
+ // CodeQL js/incomplete-url-substring-sanitization (issue #168): the former
141
+ // `body.includes('img.shields.io')` matched all of these.
142
+ expect(
143
+ containsPackageVersionBadge(
144
+ '![x](https://example.invalid/img.shields.io)'
145
+ )
146
+ ).toBe(false);
147
+ expect(
148
+ containsPackageVersionBadge(
149
+ '![x](https://example.invalid/?u=img.shields.io)'
150
+ )
151
+ ).toBe(false);
152
+ expect(
153
+ containsPackageVersionBadge(
154
+ '![x](https://img.shields.io.example.invalid/b.svg)'
155
+ )
156
+ ).toBe(false);
157
+ expect(containsPackageVersionBadge('see https://img.shields.io')).toBe(
158
+ false
159
+ );
160
+ });
161
+
162
+ it('ignores relative and malformed image targets', () => {
163
+ expect(containsPackageVersionBadge('![x](./local/badge.svg)')).toBe(false);
164
+ expect(containsPackageVersionBadge('![x]()')).toBe(false);
165
+ });
166
+
167
+ it('accepts a badge carrying a markdown title', () => {
168
+ expect(
169
+ containsPackageVersionBadge(
170
+ '![npm version](https://img.shields.io/badge/npm-1.2.3-blue.svg "npm")'
171
+ )
172
+ ).toBe(true);
173
+ });
174
+ });