start-command 0.32.0 → 0.33.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 (43) 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 +23 -136
  6. package/src/lib/args-parser-queries.js +228 -0
  7. package/src/lib/args-parser.js +38 -179
  8. package/src/lib/docker-cleanup.js +80 -5
  9. package/src/lib/execution-attach.js +245 -0
  10. package/src/lib/execution-resume-all.js +210 -0
  11. package/src/lib/execution-resume.js +454 -0
  12. package/src/lib/execution-store.js +47 -4
  13. package/src/lib/exit-reason.js +234 -0
  14. package/src/lib/failure-handler.js +43 -41
  15. package/src/lib/isolation-log-utils.js +58 -0
  16. package/src/lib/isolation.js +13 -10
  17. package/src/lib/query-commands.js +202 -0
  18. package/src/lib/screen-isolation.js +2 -1
  19. package/src/lib/session-probe.js +121 -0
  20. package/src/lib/shell-utils.js +164 -10
  21. package/src/lib/status-formatter.js +130 -57
  22. package/src/lib/usage.js +13 -1
  23. package/src/lib/user-manager.js +18 -1
  24. package/test/args-parser-attach-resume.js +159 -0
  25. package/test/args-parser-control.js +3 -3
  26. package/test/args-parser.js +1 -1
  27. package/test/check-file-size.js +91 -0
  28. package/test/ci-workflow-invariants.js +191 -0
  29. package/test/docker-network-integration.js +22 -14
  30. package/test/echo-integration.js +20 -13
  31. package/test/execution-attach.js +221 -0
  32. package/test/execution-resume.js +433 -0
  33. package/test/execution-store.js +52 -0
  34. package/test/exit-reason.js +209 -0
  35. package/test/failure-handler.js +107 -0
  36. package/test/load-command-stream.mjs +83 -0
  37. package/test/regression-162.js +320 -0
  38. package/test/regression-164.js +308 -0
  39. package/test/regression-165.js +310 -0
  40. package/test/regression-84.js +8 -3
  41. package/test/release-name.mjs +57 -0
  42. package/test/session-probe.js +150 -0
  43. package/test/user-manager.js +23 -0
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Regression tests for issue #164:
3
+ * "Command argv is flattened with join(' '), so quoted arguments are re-parsed
4
+ * by the inner shell (`$ node -e \"console.log('hi')\"` fails)"
5
+ *
6
+ * Root cause:
7
+ * parseArgs() returned `commandArgs.join(' ')`, so every argument boundary and
8
+ * every quote the user typed was destroyed before the command reached
9
+ * `sh -c`. `node -e "console.log('hi')"` became `node -e console.log('hi')`
10
+ * (a syntax error), and `echo "a b"` became `echo a b` (re-split into two
11
+ * words). `buildShellWithArgsCmdArgs()` repaired only the `<shell> -c …`
12
+ * shape (issue #91) and only on the isolation code paths.
13
+ *
14
+ * Fix:
15
+ * parseArgs() rebuilds the command with buildCommandString(): a single
16
+ * argument is a shell script the user quoted as a whole and stays verbatim,
17
+ * while multiple arguments are each shell-quoted so the inner shell sees the
18
+ * argv the user typed.
19
+ *
20
+ * Reference: https://github.com/link-foundation/start/issues/164
21
+ */
22
+
23
+ const { describe, it, expect } = require('bun:test');
24
+ const { spawnSync } = require('child_process');
25
+ const os = require('os');
26
+ const path = require('path');
27
+
28
+ const { parseArgs } = require('../src/lib/args-parser');
29
+ const {
30
+ buildCommandString,
31
+ defaultShellQuotingStyle,
32
+ buildDisplayCommand,
33
+ buildShellWithArgsCmdArgs,
34
+ getCommandName,
35
+ isInteractiveShellCommand,
36
+ isShellInvocationWithArgs,
37
+ quoteShellArg,
38
+ splitShellWords,
39
+ toShellWords,
40
+ } = require('../src/lib/shell-utils');
41
+
42
+ const CLI_PATH = path.join(__dirname, '../src/bin/cli.js');
43
+ // PowerShell (the host shell on Windows) escapes a quote by doubling it, so the
44
+ // rebuilt command line differs from the POSIX one (issue #164).
45
+ const IS_POWERSHELL = defaultShellQuotingStyle() === 'powershell';
46
+ const TEST_APP_FOLDER = path.join(
47
+ os.tmpdir(),
48
+ `regression-164-${process.pid}-${Date.now()}`
49
+ );
50
+
51
+ /** Run the CLI the way a shell would: one process argument per argv element. */
52
+ function runCli(argv) {
53
+ const result = spawnSync(process.execPath, [CLI_PATH, ...argv], {
54
+ encoding: 'utf8',
55
+ env: {
56
+ ...process.env,
57
+ START_APP_FOLDER: TEST_APP_FOLDER,
58
+ START_DISABLE_AUTO_ISSUE: '1',
59
+ },
60
+ timeout: 20000,
61
+ });
62
+ return {
63
+ stdout: result.stdout || '',
64
+ stderr: result.stderr || '',
65
+ exitCode: result.status,
66
+ };
67
+ }
68
+
69
+ /** The command output sits between the start block and the finish block. */
70
+ function commandOutput(stdout) {
71
+ // PowerShell prefixes its output with a UTF-8 BOM and ends its lines with a
72
+ // carriage return on Windows, and the finish block is preceded by one more
73
+ // blank line there, so trailing blank lines are trimmed (issue #164).
74
+ const lines = stdout
75
+ .replace(/\uFEFF/g, '')
76
+ .replace(/\r/g, '')
77
+ .split('\n');
78
+ const start = lines.findIndex((line) => line.startsWith('$ '));
79
+ const finish = lines.findIndex((line) => line === '✓' || line === '✗');
80
+ return lines
81
+ .slice(start + 2, finish - 1)
82
+ .join('\n')
83
+ .replace(/\n+$/, '');
84
+ }
85
+
86
+ function displayedCommand(stdout) {
87
+ return (stdout.split('\n').find((line) => line.startsWith('$ ')) || '').slice(
88
+ 2
89
+ );
90
+ }
91
+
92
+ describe('parseArgs keeps argv boundaries (issue #164)', () => {
93
+ it('quotes an argument that contains a shell metacharacter', () => {
94
+ const { command, rawCommand } = parseArgs([
95
+ 'node',
96
+ '-e',
97
+ "console.log('hi')",
98
+ ]);
99
+ expect(command).toBe(
100
+ IS_POWERSHELL
101
+ ? "node -e 'console.log(''hi'')'"
102
+ : "node -e 'console.log('\\''hi'\\'')'"
103
+ );
104
+ expect(rawCommand).toEqual(['node', '-e', "console.log('hi')"]);
105
+ });
106
+
107
+ it('quotes an argument that contains repeated spaces', () => {
108
+ expect(parseArgs(['echo', 'a b']).command).toBe("echo 'a b'");
109
+ });
110
+
111
+ it('keeps a single argument verbatim so shell scripts still work', () => {
112
+ expect(parseArgs(['ls | wc -l']).command).toBe('ls | wc -l');
113
+ });
114
+
115
+ it('leaves ordinary arguments unquoted', () => {
116
+ // `%` is an alias for ForEach-Object in PowerShell, so it has to be quoted
117
+ // there while a POSIX shell leaves it alone.
118
+ expect(parseArgs(['git', 'log', '-1', '--pretty=%s']).command).toBe(
119
+ IS_POWERSHELL ? "git log -1 '--pretty=%s'" : 'git log -1 --pretty=%s'
120
+ );
121
+ });
122
+
123
+ it('keeps boundaries after the -- separator', () => {
124
+ const { command } = parseArgs([
125
+ '--isolated',
126
+ 'docker',
127
+ '--',
128
+ 'git',
129
+ 'commit',
130
+ '-m',
131
+ 'msg with spaces',
132
+ ]);
133
+ expect(command).toBe("git commit -m 'msg with spaces'");
134
+ });
135
+
136
+ it('returns an empty command when no command was given', () => {
137
+ expect(parseArgs(['--list']).command).toBe('');
138
+ });
139
+ });
140
+
141
+ describe('buildCommandString and quoteShellArg (issue #164)', () => {
142
+ it('returns an empty string for empty argv', () => {
143
+ expect(buildCommandString([])).toBe('');
144
+ });
145
+
146
+ it('escapes embedded single quotes for a POSIX shell', () => {
147
+ expect(quoteShellArg("it's", 'posix')).toBe("'it'\\''s'");
148
+ });
149
+
150
+ it('escapes embedded single quotes for PowerShell by doubling them', () => {
151
+ expect(quoteShellArg("it's", 'powershell')).toBe("'it''s'");
152
+ });
153
+
154
+ it('quotes characters PowerShell treats specially but a POSIX shell does not', () => {
155
+ expect(quoteShellArg('a,b', 'posix')).toBe('a,b');
156
+ expect(quoteShellArg('a,b', 'powershell')).toBe("'a,b'");
157
+ });
158
+
159
+ it('uses the host shell dialect by default', () => {
160
+ expect(quoteShellArg("it's")).toBe(
161
+ quoteShellArg("it's", defaultShellQuotingStyle())
162
+ );
163
+ });
164
+
165
+ it('quotes an empty argument so it survives as an argument', () => {
166
+ expect(buildCommandString(['echo', ''])).toBe("echo ''");
167
+ });
168
+
169
+ it('round-trips through splitShellWords in both dialects', () => {
170
+ const argv = ['node', '-e', "console.log('a b')", 'x$y', ''];
171
+ for (const style of ['posix', 'powershell']) {
172
+ expect(splitShellWords(buildCommandString(argv, style), style)).toEqual(
173
+ argv
174
+ );
175
+ }
176
+ });
177
+ });
178
+
179
+ describe('splitShellWords (issue #164)', () => {
180
+ it('splits on unquoted whitespace', () => {
181
+ expect(splitShellWords('echo a b')).toEqual(['echo', 'a', 'b']);
182
+ });
183
+
184
+ it('keeps double-quoted words together', () => {
185
+ expect(splitShellWords('echo "a b"')).toEqual(['echo', 'a b']);
186
+ });
187
+
188
+ it('keeps single-quoted words together', () => {
189
+ expect(splitShellWords("echo 'a b'")).toEqual(['echo', 'a b']);
190
+ });
191
+
192
+ it('honours backslash escapes in a POSIX shell', () => {
193
+ expect(splitShellWords('echo a\\ b', 'posix')).toEqual(['echo', 'a b']);
194
+ });
195
+
196
+ it('keeps backslashes literal for PowerShell, where they are separators', () => {
197
+ expect(splitShellWords('echo C:\\tmp', 'powershell')).toEqual([
198
+ 'echo',
199
+ 'C:\\tmp',
200
+ ]);
201
+ });
202
+
203
+ it('reads a doubled quote inside a PowerShell string as one quote', () => {
204
+ expect(splitShellWords("echo 'it''s'", 'powershell')).toEqual([
205
+ 'echo',
206
+ "it's",
207
+ ]);
208
+ });
209
+
210
+ it('returns null when quoting is unbalanced', () => {
211
+ expect(splitShellWords('echo "a')).toBe(null);
212
+ });
213
+ });
214
+
215
+ describe('toShellWords (issue #164)', () => {
216
+ it('keeps a quoted bare-shell argument in one argv element', () => {
217
+ expect(toShellWords("bash --rcfile 'my file'")).toEqual([
218
+ 'bash',
219
+ '--rcfile',
220
+ 'my file',
221
+ ]);
222
+ });
223
+
224
+ it('falls back to whitespace splitting when quoting is unbalanced', () => {
225
+ expect(toShellWords('echo "a b')).toEqual(['echo', '"a', 'b']);
226
+ });
227
+ });
228
+
229
+ describe('shell detection stays correct for quoted commands (issue #164)', () => {
230
+ it('still detects a bare shell invocation', () => {
231
+ expect(isInteractiveShellCommand('bash')).toBe(true);
232
+ expect(isInteractiveShellCommand("bash -c 'echo hi'")).toBe(false);
233
+ });
234
+
235
+ it('detects a shell invocation whose script argument is quoted', () => {
236
+ expect(isShellInvocationWithArgs("bash -c 'echo hi'")).toBe(true);
237
+ });
238
+
239
+ it('passes the quoted script through as a single argv element', () => {
240
+ expect(buildShellWithArgsCmdArgs("bash -i -c 'nvm --version'")).toEqual([
241
+ 'bash',
242
+ '-i',
243
+ '-c',
244
+ 'nvm --version',
245
+ ]);
246
+ });
247
+
248
+ it('reads the command name from the first shell word', () => {
249
+ expect(getCommandName("'my command' --flag")).toBe('my command');
250
+ });
251
+ });
252
+
253
+ describe('buildDisplayCommand shows the argv the user typed (issue #164)', () => {
254
+ it('re-quotes an argument that was quoted by the parser', () => {
255
+ expect(buildDisplayCommand(parseArgs(['echo', 'a b']).command)).toBe(
256
+ 'echo "a b"'
257
+ );
258
+ });
259
+
260
+ it('re-quotes an argument containing shell metacharacters', () => {
261
+ expect(
262
+ buildDisplayCommand(
263
+ parseArgs(['node', '-e', "console.log('hi')"]).command
264
+ )
265
+ ).toBe('node -e "console.log(\'hi\')"');
266
+ });
267
+
268
+ it('shows a single-argument shell script verbatim', () => {
269
+ expect(buildDisplayCommand('ls | wc -l')).toBe('ls | wc -l');
270
+ });
271
+ });
272
+
273
+ describe('CLI end to end (issue #164)', () => {
274
+ it('runs node -e with a quoted script', () => {
275
+ const result = runCli(['node', '-e', "console.log('hi')"]);
276
+ expect(result.exitCode).toBe(0);
277
+ expect(commandOutput(result.stdout)).toBe('hi');
278
+ });
279
+
280
+ it('preserves repeated spaces inside a quoted argument', () => {
281
+ const result = runCli(['echo', 'a b']);
282
+ expect(result.exitCode).toBe(0);
283
+ expect(commandOutput(result.stdout)).toBe('a b');
284
+ });
285
+
286
+ it('keeps `bash -c` scripts intact on the direct path (issue #91 shape)', () => {
287
+ const result = runCli(['bash', '-c', 'echo hello world']);
288
+ expect(result.exitCode).toBe(0);
289
+ expect(commandOutput(result.stdout)).toBe('hello world');
290
+ });
291
+
292
+ it('keeps arithmetic expansion inside `bash -c` intact', () => {
293
+ const result = runCli(['bash', '-c', 'echo $((1+1))']);
294
+ expect(result.exitCode).toBe(0);
295
+ expect(commandOutput(result.stdout)).toBe('2');
296
+ });
297
+
298
+ it('still runs a single quoted argument as a shell script', () => {
299
+ const result = runCli(['echo one | tr a-z A-Z']);
300
+ expect(result.exitCode).toBe(0);
301
+ expect(commandOutput(result.stdout)).toBe('ONE');
302
+ });
303
+
304
+ it('displays the command with the quoting the user typed', () => {
305
+ const result = runCli(['echo', 'a b']);
306
+ expect(displayedCommand(result.stdout)).toBe('echo "a b"');
307
+ });
308
+ });
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Regression tests for issue #165.
3
+ *
4
+ * A runtime that aborts on its own heap limit (`FATAL ERROR: Reached heap limit
5
+ * ...`) dies below the container limit, so every container signal `start`
6
+ * consults is correct and useless: `State.OOMKilled` is `false` and the cgroup
7
+ * `oom_kill` counter is `0`. The only evidence is what the runtime printed into
8
+ * the log, which is why `--status` now carries `memoryExhausted` /
9
+ * `memoryExhaustedReason` alongside `oomKilled`, and why the kept-container
10
+ * footer no longer asserts a bare `oomKilled=false` next to a fatal marker.
11
+ */
12
+
13
+ const { describe, it, expect, beforeEach, afterEach } = require('bun:test');
14
+ const { spawnSync } = require('child_process');
15
+ const fs = require('fs');
16
+ const os = require('os');
17
+ const path = require('path');
18
+
19
+ const {
20
+ ExecutionRecord,
21
+ ExecutionStore,
22
+ } = require('../src/lib/execution-store');
23
+ const {
24
+ buildAttachedDockerKeptMessage,
25
+ buildDetachedDockerCompletionScript,
26
+ buildDockerKeptReasonSnippet,
27
+ } = require('../src/lib/docker-cleanup');
28
+
29
+ const TEST_APP_FOLDER = path.join(
30
+ os.tmpdir(),
31
+ `regression-165-${process.pid}-${Date.now()}`
32
+ );
33
+ const CLI_PATH = path.join(__dirname, '../src/bin/cli.js');
34
+
35
+ const HEAP_LIMIT_MARKER =
36
+ 'FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory';
37
+
38
+ function cleanupTestDir() {
39
+ fs.rmSync(TEST_APP_FOLDER, { recursive: true, force: true });
40
+ }
41
+
42
+ function runCli(args) {
43
+ const result = spawnSync(process.execPath, [CLI_PATH, ...args], {
44
+ encoding: 'utf8',
45
+ env: { ...process.env, START_APP_FOLDER: TEST_APP_FOLDER },
46
+ timeout: 20000,
47
+ });
48
+ return {
49
+ stdout: result.stdout || '',
50
+ stderr: result.stderr || '',
51
+ exitCode: result.status,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Write a log that ends the way the incident log does: command output, the
57
+ * dying runtime's fatal marker, an optional native stack trace, and the
58
+ * terminal footer `start` appends itself.
59
+ */
60
+ function writeOomLog(
61
+ name,
62
+ { stackTraceBytes = 0, marker = HEAP_LIMIT_MARKER }
63
+ ) {
64
+ const logPath = path.join(TEST_APP_FOLDER, name);
65
+ const stackFrame =
66
+ ' 1: 0x757658 node::OOMErrorHandler(char const*, v8::OOMDetails const&) [node]\n';
67
+ const frames = stackTraceBytes
68
+ ? stackFrame.repeat(Math.ceil(stackTraceBytes / stackFrame.length))
69
+ : '';
70
+ fs.writeFileSync(
71
+ logPath,
72
+ [
73
+ 'building...',
74
+ '',
75
+ '<--- Last few GCs --->',
76
+ '[1:0xa485000] 790 ms: Mark-Compact 66.6 (99.8) -> 64.4 (194.2) MB',
77
+ '',
78
+ marker,
79
+ '----- Native stack trace -----',
80
+ frames,
81
+ 'Docker container "docker-165" exited with code 139',
82
+ 'Container kept because the command failed.',
83
+ '',
84
+ '==================================================',
85
+ 'Finished: 2026-09-03 08:14:20.707',
86
+ 'Exit Code: 139',
87
+ '',
88
+ ].join('\n'),
89
+ 'utf8'
90
+ );
91
+ return logPath;
92
+ }
93
+
94
+ function saveRecord(store, overrides = {}) {
95
+ const record = new ExecutionRecord({
96
+ command: overrides.command || 'node build.mjs',
97
+ pid: 4242,
98
+ logPath: overrides.logPath || null,
99
+ workingDirectory: '/tmp',
100
+ shell: '/bin/bash',
101
+ options: overrides.options || {},
102
+ });
103
+ if (overrides.exitCode !== undefined) {
104
+ record.complete(overrides.exitCode);
105
+ }
106
+ store.save(record);
107
+ return record;
108
+ }
109
+
110
+ function statusJson(uuid) {
111
+ const result = runCli(['--status', uuid, '--output-format', 'json']);
112
+ expect(result.stderr).toBe('');
113
+ expect(result.exitCode).toBe(0);
114
+ return JSON.parse(result.stdout);
115
+ }
116
+
117
+ describe('Issue #165: memory exhaustion observed from the log', () => {
118
+ let store;
119
+
120
+ beforeEach(() => {
121
+ cleanupTestDir();
122
+ fs.mkdirSync(TEST_APP_FOLDER, { recursive: true });
123
+ store = new ExecutionStore({
124
+ appFolder: TEST_APP_FOLDER,
125
+ useLinks: false,
126
+ });
127
+ });
128
+
129
+ afterEach(() => {
130
+ cleanupTestDir();
131
+ });
132
+
133
+ it('reports memoryExhausted for an attached docker session that self-aborted', () => {
134
+ const record = saveRecord(store, {
135
+ logPath: writeOomLog('attached.log', {}),
136
+ exitCode: 139,
137
+ options: { isolated: 'docker', isolationMode: 'attached' },
138
+ });
139
+
140
+ const parsed = statusJson(record.uuid);
141
+
142
+ expect(parsed.exitCode).toBe(139);
143
+ // The container flag is still absent/false — the observation does not
144
+ // contradict it, it complements it.
145
+ expect(parsed.oomKilled).toBeUndefined();
146
+ expect(parsed.memoryExhausted).toBe(true);
147
+ expect(parsed.memoryExhaustedReason).toBe(HEAP_LIMIT_MARKER);
148
+ expect(parsed.exitReason).toBe('memory-exhaustion (v8-heap-limit)');
149
+ });
150
+
151
+ it('finds a marker pushed far from EOF by the native stack trace', () => {
152
+ const logPath = writeOomLog('long-trace.log', {
153
+ stackTraceBytes: 40 * 1024,
154
+ });
155
+ expect(fs.statSync(logPath).size).toBeGreaterThan(32 * 1024);
156
+ const record = saveRecord(store, { logPath, exitCode: 134 });
157
+
158
+ const parsed = statusJson(record.uuid);
159
+
160
+ expect(parsed.memoryExhausted).toBe(true);
161
+ expect(parsed.memoryExhaustedReason).toBe(HEAP_LIMIT_MARKER);
162
+ });
163
+
164
+ it('detects a Rust allocation failure', () => {
165
+ const record = saveRecord(store, {
166
+ logPath: writeOomLog('rust.log', {
167
+ marker: 'memory allocation of 1073741824 bytes failed',
168
+ }),
169
+ exitCode: 134,
170
+ });
171
+
172
+ const parsed = statusJson(record.uuid);
173
+
174
+ expect(parsed.memoryExhausted).toBe(true);
175
+ expect(parsed.memoryExhaustedReason).toBe(
176
+ 'memory allocation of 1073741824 bytes failed'
177
+ );
178
+ });
179
+
180
+ it('never turns a clean run into a reported memory failure', () => {
181
+ // The marker is present in the output — the command merely printed it —
182
+ // but the run succeeded, so there is nothing to explain (issue #151's rule:
183
+ // an observation, never a verdict).
184
+ const logPath = path.join(TEST_APP_FOLDER, 'quoted.log');
185
+ fs.writeFileSync(
186
+ logPath,
187
+ [
188
+ `grep found: ${HEAP_LIMIT_MARKER}`,
189
+ '==================================================',
190
+ 'Finished: 2026-09-03 08:14:20.707',
191
+ 'Exit Code: 0',
192
+ '',
193
+ ].join('\n'),
194
+ 'utf8'
195
+ );
196
+ const record = saveRecord(store, { logPath, exitCode: 0 });
197
+
198
+ const parsed = statusJson(record.uuid);
199
+
200
+ expect(parsed.exitCode).toBe(0);
201
+ expect(parsed.memoryExhausted).toBeUndefined();
202
+ expect(parsed.memoryExhaustedReason).toBeUndefined();
203
+ });
204
+
205
+ it('leaves an ordinary failure without a memory observation', () => {
206
+ const logPath = path.join(TEST_APP_FOLDER, 'plain-failure.log');
207
+ fs.writeFileSync(logPath, 'error: missing file\n', 'utf8');
208
+ const record = saveRecord(store, { logPath, exitCode: 1 });
209
+
210
+ const parsed = statusJson(record.uuid);
211
+
212
+ expect(parsed.memoryExhausted).toBeUndefined();
213
+ });
214
+
215
+ it('shows the observation in the human-readable and links-notation output', () => {
216
+ const record = saveRecord(store, {
217
+ logPath: writeOomLog('formats.log', {}),
218
+ exitCode: 139,
219
+ });
220
+
221
+ const text = runCli(['--status', record.uuid, '--output-format', 'text']);
222
+ expect(text.exitCode).toBe(0);
223
+ expect(text.stdout).toContain('Memory Exhausted: true');
224
+ expect(text.stdout).toContain(`Memory Evidence: ${HEAP_LIMIT_MARKER}`);
225
+
226
+ const lino = runCli(['--status', record.uuid]);
227
+ expect(lino.exitCode).toBe(0);
228
+ expect(lino.stdout).toContain('memoryExhausted true');
229
+ expect(lino.stdout).toContain('memoryExhaustedReason');
230
+ });
231
+ });
232
+
233
+ describe('Issue #165: kept-container footer', () => {
234
+ const evaluateReason = (exitCode, oomKilled) => {
235
+ const snippet = buildDockerKeptReasonSnippet();
236
+ const result = spawnSync(
237
+ 'sh',
238
+ [
239
+ '-c',
240
+ `__start_command_exit=${exitCode}; __start_command_oom=${oomKilled}; ${snippet}; printf '%s' "$__start_command_reason"`,
241
+ ],
242
+ { encoding: 'utf8' }
243
+ );
244
+ expect(result.status).toBe(0);
245
+ return result.stdout;
246
+ };
247
+
248
+ it('does not assert a bare oomKilled=false for a self-abort exit code', () => {
249
+ for (const exitCode of [134, 139]) {
250
+ const reason = evaluateReason(exitCode, 'false');
251
+ expect(reason).toContain(`exitCode=${exitCode} oomKilled=false`);
252
+ expect(reason).toContain('invisible to this flag');
253
+ }
254
+ });
255
+
256
+ it('keeps the plain reason for other exit codes and for a real OOM kill', () => {
257
+ expect(evaluateReason(1, 'false')).toBe('exitCode=1 oomKilled=false');
258
+ expect(evaluateReason(137, 'true')).toBe('exitCode=137 oomKilled=true');
259
+ });
260
+
261
+ it('embeds the reason computation in the detached completion script', () => {
262
+ const script = buildDetachedDockerCompletionScript(
263
+ 'container-165',
264
+ 'default',
265
+ '/tmp/regression-165.log'
266
+ );
267
+ expect(script).toContain('__start_command_reason=');
268
+ expect(script).toContain('Reason: %s');
269
+ });
270
+ });
271
+
272
+ describe('Issue #165: attached kept-container message', () => {
273
+ beforeEach(() => {
274
+ cleanupTestDir();
275
+ fs.mkdirSync(TEST_APP_FOLDER, { recursive: true });
276
+ });
277
+
278
+ afterEach(() => {
279
+ cleanupTestDir();
280
+ });
281
+
282
+ it('names the evidence the runtime printed into the log', () => {
283
+ const message = buildAttachedDockerKeptMessage({
284
+ containerName: 'container-165',
285
+ exitCode: 139,
286
+ oomKilled: false,
287
+ logPath: writeOomLog('attached-message.log', {}),
288
+ });
289
+
290
+ expect(message).toContain('Container kept because the command failed.');
291
+ expect(message).toContain(
292
+ `Memory exhaustion detected in the log: ${HEAP_LIMIT_MARKER}`
293
+ );
294
+ expect(message).toContain('Remove when done: docker rm -f container-165');
295
+ });
296
+
297
+ it('stays silent for an ordinary failure', () => {
298
+ const logPath = path.join(TEST_APP_FOLDER, 'attached-plain.log');
299
+ fs.writeFileSync(logPath, 'error: missing file\n', 'utf8');
300
+
301
+ const message = buildAttachedDockerKeptMessage({
302
+ containerName: 'container-165',
303
+ exitCode: 1,
304
+ oomKilled: false,
305
+ logPath,
306
+ });
307
+
308
+ expect(message).not.toContain('Memory exhaustion');
309
+ });
310
+ });
@@ -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
+ });