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,433 @@
1
+ /**
2
+ * Tests for --resume and --resume-all (issue #162)
3
+ */
4
+
5
+ const { test, describe } = require('node:test');
6
+ const assert = require('node:assert');
7
+ const {
8
+ ResumeMode,
9
+ buildResumePlan,
10
+ buildSnapshotImageName,
11
+ buildResumedSessionName,
12
+ resumeExecution,
13
+ resumeAllExecutions,
14
+ formatResumeResultAsLinksNotation,
15
+ } = require('../src/lib/execution-resume');
16
+ const { SessionState } = require('../src/lib/session-probe');
17
+
18
+ function makeRecord(options = {}, extra = {}) {
19
+ return {
20
+ uuid: '11111111-2222-3333-4444-555555555555',
21
+ status: 'executing',
22
+ command: 'npm test',
23
+ logPath: '/tmp/session.log',
24
+ options: {
25
+ isolated: 'docker',
26
+ isolationMode: 'detached',
27
+ sessionName: 'box',
28
+ image: 'ubuntu:24.04',
29
+ ...options,
30
+ },
31
+ ...extra,
32
+ };
33
+ }
34
+
35
+ function probeWith(state, containerStatus = null) {
36
+ return {
37
+ backend: 'docker',
38
+ sessionName: 'box',
39
+ state,
40
+ alive: state === SessionState.RUNNING,
41
+ containerStatus,
42
+ };
43
+ }
44
+
45
+ describe('buildSnapshotImageName', () => {
46
+ test('produces a lowercase docker-safe tag', () => {
47
+ assert.strictEqual(
48
+ buildSnapshotImageName('My_Session Name', 2),
49
+ 'start-command-resume/my_session-name:2'
50
+ );
51
+ });
52
+ });
53
+
54
+ describe('buildResumedSessionName', () => {
55
+ test('appends the resume counter', () => {
56
+ assert.strictEqual(buildResumedSessionName('box', 3), 'box-resume-3');
57
+ });
58
+ });
59
+
60
+ describe('buildResumePlan', () => {
61
+ test('refuses to resume a live session and points at --attach', () => {
62
+ const plan = buildResumePlan(
63
+ makeRecord(),
64
+ null,
65
+ probeWith(SessionState.RUNNING, 'running')
66
+ );
67
+ assert.match(plan.error, /still running/);
68
+ assert.match(plan.error, /--attach/);
69
+ });
70
+
71
+ test('restarts a stopped container with the stored command', () => {
72
+ const plan = buildResumePlan(
73
+ makeRecord(),
74
+ null,
75
+ probeWith(SessionState.STOPPED, 'exited')
76
+ );
77
+ assert.strictEqual(plan.mode, ResumeMode.DOCKER_START);
78
+ assert.strictEqual(plan.sessionName, 'box');
79
+ assert.deepStrictEqual(plan.steps[0].args, ['start', 'box']);
80
+ assert.strictEqual(plan.command, 'npm test');
81
+ });
82
+
83
+ test('snapshots a stopped container to run a new command', () => {
84
+ const plan = buildResumePlan(
85
+ makeRecord(),
86
+ 'npm run build',
87
+ probeWith(SessionState.STOPPED, 'exited')
88
+ );
89
+ assert.strictEqual(plan.mode, ResumeMode.DOCKER_SNAPSHOT);
90
+ assert.strictEqual(plan.snapshotImage, 'start-command-resume/box:1');
91
+ assert.strictEqual(plan.newSessionName, 'box-resume-1');
92
+ assert.deepStrictEqual(plan.steps[0].args, [
93
+ 'commit',
94
+ 'box',
95
+ 'start-command-resume/box:1',
96
+ ]);
97
+ const runArgs = plan.steps[1].args;
98
+ assert.deepStrictEqual(runArgs.slice(0, 4), [
99
+ 'run',
100
+ '-d',
101
+ '--name',
102
+ 'box-resume-1',
103
+ ]);
104
+ assert.deepStrictEqual(runArgs.slice(-4), [
105
+ 'start-command-resume/box:1',
106
+ 'sh',
107
+ '-c',
108
+ 'npm run build',
109
+ ]);
110
+ });
111
+
112
+ test('carries stored docker runtime options into the snapshot run', () => {
113
+ const record = makeRecord({
114
+ privileged: true,
115
+ env: ['A=1'],
116
+ volumes: ['/host:/container'],
117
+ networks: ['testnet'],
118
+ });
119
+ const plan = buildResumePlan(
120
+ record,
121
+ 'echo hi',
122
+ probeWith(SessionState.STOPPED, 'exited')
123
+ );
124
+ const runArgs = plan.steps[1].args;
125
+ assert.ok(runArgs.includes('--privileged'));
126
+ assert.ok(runArgs.includes('/host:/container'));
127
+ assert.ok(runArgs.includes('A=1'));
128
+ assert.ok(runArgs.includes('testnet'));
129
+ });
130
+
131
+ test('rebuilds a multi-network snapshot with create/connect/start', () => {
132
+ // `docker run` joins one network only, so extra networks must be connected
133
+ // between create and start, exactly as the original launch does.
134
+ const record = makeRecord({
135
+ networks: ['frontend', 'backend', 'metrics'],
136
+ });
137
+ const plan = buildResumePlan(
138
+ record,
139
+ 'echo hi',
140
+ probeWith(SessionState.STOPPED, 'exited')
141
+ );
142
+ const steps = plan.steps.map((step) => step.args);
143
+ assert.strictEqual(steps.length, 5);
144
+ assert.strictEqual(steps[0][0], 'commit');
145
+ assert.deepStrictEqual(steps[1].slice(0, 3), [
146
+ 'create',
147
+ '--name',
148
+ 'box-resume-1',
149
+ ]);
150
+ assert.ok(
151
+ steps[1].includes('frontend'),
152
+ 'the first network still goes to --network'
153
+ );
154
+ assert.deepStrictEqual(steps[2], [
155
+ 'network',
156
+ 'connect',
157
+ 'backend',
158
+ 'box-resume-1',
159
+ ]);
160
+ assert.deepStrictEqual(steps[3], [
161
+ 'network',
162
+ 'connect',
163
+ 'metrics',
164
+ 'box-resume-1',
165
+ ]);
166
+ assert.deepStrictEqual(steps[4], ['start', 'box-resume-1']);
167
+ });
168
+
169
+ test('increments the counter across repeated resumes', () => {
170
+ const record = makeRecord({ resumeCount: 2 });
171
+ const plan = buildResumePlan(
172
+ record,
173
+ 'echo hi',
174
+ probeWith(SessionState.STOPPED, 'exited')
175
+ );
176
+ assert.strictEqual(plan.snapshotImage, 'start-command-resume/box:3');
177
+ assert.strictEqual(plan.newSessionName, 'box-resume-3');
178
+ });
179
+
180
+ test('relaunches when the container is gone', () => {
181
+ const plan = buildResumePlan(
182
+ makeRecord(),
183
+ null,
184
+ probeWith(SessionState.MISSING)
185
+ );
186
+ assert.strictEqual(plan.mode, ResumeMode.RELAUNCH);
187
+ assert.strictEqual(plan.sessionName, 'box');
188
+ assert.strictEqual(plan.command, 'npm test');
189
+ assert.strictEqual(plan.launchOptions.image, 'ubuntu:24.04');
190
+ assert.strictEqual(plan.launchOptions.detached, true);
191
+ assert.strictEqual(plan.launchOptions.logPath, '/tmp/session.log');
192
+ });
193
+
194
+ test('relaunches a dead screen session', () => {
195
+ const record = makeRecord({ isolated: 'screen', sessionName: 'work' });
196
+ const plan = buildResumePlan(record, null, {
197
+ backend: 'screen',
198
+ sessionName: 'work',
199
+ state: SessionState.MISSING,
200
+ alive: false,
201
+ });
202
+ assert.strictEqual(plan.mode, ResumeMode.RELAUNCH);
203
+ assert.strictEqual(plan.backend, 'screen');
204
+ });
205
+
206
+ test('relaunches a dead screen session with a new command', () => {
207
+ const record = makeRecord({ isolated: 'screen', sessionName: 'work' });
208
+ const plan = buildResumePlan(record, 'echo hi', {
209
+ backend: 'screen',
210
+ sessionName: 'work',
211
+ state: SessionState.MISSING,
212
+ alive: false,
213
+ });
214
+ assert.strictEqual(plan.command, 'echo hi');
215
+ });
216
+
217
+ test('rejects records without a session name', () => {
218
+ const record = makeRecord({ sessionName: null });
219
+ const plan = buildResumePlan(record, null, probeWith(SessionState.MISSING));
220
+ assert.match(plan.error, /does not contain an isolation session name/);
221
+ });
222
+
223
+ test('rejects non-detached executions', () => {
224
+ const record = makeRecord({ isolationMode: 'attached' });
225
+ const plan = buildResumePlan(record, null, probeWith(SessionState.STOPPED));
226
+ assert.match(plan.error, /Only detached isolated executions/);
227
+ });
228
+
229
+ test('rejects records with no command to resume', () => {
230
+ const record = makeRecord({}, { command: '' });
231
+ const plan = buildResumePlan(record, null, probeWith(SessionState.MISSING));
232
+ assert.match(plan.error, /no stored command/);
233
+ });
234
+ });
235
+
236
+ describe('resumeExecution', () => {
237
+ function makeStore(record) {
238
+ const saved = [];
239
+ return {
240
+ saved,
241
+ get: () => record,
242
+ save: (r) => saved.push(r),
243
+ };
244
+ }
245
+
246
+ test('reports disabled tracking', async () => {
247
+ const result = await resumeExecution(null, 'box', {});
248
+ assert.strictEqual(result.success, false);
249
+ assert.match(result.error, /Execution tracking is disabled/);
250
+ });
251
+
252
+ test('reports an unknown identifier', async () => {
253
+ const result = await resumeExecution({ get: () => null }, 'nope', {});
254
+ assert.strictEqual(result.success, false);
255
+ assert.match(result.error, /No execution found/);
256
+ });
257
+
258
+ test('starts a stopped container and re-attaches the watcher', async () => {
259
+ const record = makeRecord();
260
+ const store = makeStore(record);
261
+ const calls = [];
262
+ const watchers = [];
263
+ const result = await resumeExecution(store, 'box', {
264
+ probe: () => probeWith(SessionState.STOPPED, 'exited'),
265
+ runner: (command, args) => {
266
+ calls.push([command, ...args]);
267
+ return { success: true, stdout: 'abc123\n', stderr: '', status: 0 };
268
+ },
269
+ startWatcher: (name, policy, logPath) =>
270
+ watchers.push([name, policy, logPath]),
271
+ });
272
+ assert.strictEqual(result.success, true, result.error);
273
+ assert.deepStrictEqual(calls, [['docker', 'start', 'box']]);
274
+ assert.strictEqual(watchers.length, 1);
275
+ assert.strictEqual(watchers[0][0], 'box');
276
+ assert.strictEqual(watchers[0][2], '/tmp/session.log');
277
+ assert.strictEqual(store.saved.length, 1);
278
+ assert.strictEqual(store.saved[0].uuid, record.uuid);
279
+ assert.strictEqual(store.saved[0].status, 'executing');
280
+ assert.strictEqual(store.saved[0].options.resumeCount, 1);
281
+ });
282
+
283
+ test('keeps the same UUID and remembers the previous session name', async () => {
284
+ const record = makeRecord();
285
+ const store = makeStore(record);
286
+ const result = await resumeExecution(store, 'box', {
287
+ command: 'npm run build',
288
+ probe: () => probeWith(SessionState.STOPPED, 'exited'),
289
+ runner: () => ({
290
+ success: true,
291
+ stdout: 'deadbeef\n',
292
+ stderr: '',
293
+ status: 0,
294
+ }),
295
+ startWatcher: () => {},
296
+ });
297
+ assert.strictEqual(result.success, true, result.error);
298
+ const saved = store.saved[0];
299
+ assert.strictEqual(saved.uuid, record.uuid);
300
+ assert.strictEqual(saved.command, 'npm run build');
301
+ assert.strictEqual(saved.options.sessionName, 'box-resume-1');
302
+ assert.deepStrictEqual(saved.options.sessionNameHistory, ['box']);
303
+ assert.strictEqual(saved.options.containerId, 'deadbeef');
304
+ assert.strictEqual(saved.exitCode, null);
305
+ assert.strictEqual(saved.endTime, null);
306
+ });
307
+
308
+ test('fails when a docker step fails', async () => {
309
+ const record = makeRecord();
310
+ const store = makeStore(record);
311
+ const result = await resumeExecution(store, 'box', {
312
+ probe: () => probeWith(SessionState.STOPPED, 'exited'),
313
+ runner: () => ({
314
+ success: false,
315
+ stdout: '',
316
+ stderr: 'no such container',
317
+ status: 1,
318
+ }),
319
+ startWatcher: () => {},
320
+ });
321
+ assert.strictEqual(result.success, false);
322
+ assert.match(result.error, /no such container/);
323
+ assert.strictEqual(store.saved.length, 0);
324
+ });
325
+
326
+ test('relaunches through the isolation backend when the container is gone', async () => {
327
+ const record = makeRecord();
328
+ const store = makeStore(record);
329
+ const launches = [];
330
+ const result = await resumeExecution(store, 'box', {
331
+ probe: () => probeWith(SessionState.MISSING),
332
+ runIsolated: (backend, command, options) => {
333
+ launches.push({ backend, command, options });
334
+ return Promise.resolve({ success: true, containerId: 'newid' });
335
+ },
336
+ });
337
+ assert.strictEqual(result.success, true, result.error);
338
+ assert.strictEqual(launches.length, 1);
339
+ assert.strictEqual(launches[0].backend, 'docker');
340
+ assert.strictEqual(launches[0].command, 'npm test');
341
+ assert.strictEqual(launches[0].options.session, 'box');
342
+ assert.strictEqual(store.saved[0].uuid, record.uuid);
343
+ });
344
+
345
+ test('surfaces relaunch failures', async () => {
346
+ const record = makeRecord();
347
+ const store = makeStore(record);
348
+ const result = await resumeExecution(store, 'box', {
349
+ probe: () => probeWith(SessionState.MISSING),
350
+ runIsolated: () =>
351
+ Promise.resolve({ success: false, message: 'docker is not running' }),
352
+ });
353
+ assert.strictEqual(result.success, false);
354
+ assert.match(result.error, /docker is not running/);
355
+ });
356
+ });
357
+
358
+ describe('resumeAllExecutions', () => {
359
+ test('reports disabled tracking', async () => {
360
+ const result = await resumeAllExecutions(null, {});
361
+ assert.strictEqual(result.success, false);
362
+ assert.match(result.error, /Execution tracking is disabled/);
363
+ });
364
+
365
+ test('reports an empty set', async () => {
366
+ const result = await resumeAllExecutions({ getExecuting: () => [] }, {});
367
+ assert.strictEqual(result.success, true);
368
+ assert.match(result.output, /count 0/);
369
+ });
370
+
371
+ test('re-attaches watchers for live docker sessions', async () => {
372
+ const record = makeRecord();
373
+ const watchers = [];
374
+ const result = await resumeAllExecutions(
375
+ { getExecuting: () => [record], save: () => {} },
376
+ {
377
+ probe: () => probeWith(SessionState.RUNNING, 'running'),
378
+ startWatcher: (name) => watchers.push(name),
379
+ }
380
+ );
381
+ assert.strictEqual(result.success, true);
382
+ assert.deepStrictEqual(watchers, ['box']);
383
+ assert.match(result.output, /reattached/);
384
+ });
385
+
386
+ test('reconciles executions whose session is gone', async () => {
387
+ const record = makeRecord();
388
+ const saved = [];
389
+ const result = await resumeAllExecutions(
390
+ { getExecuting: () => [record], save: (r) => saved.push(r) },
391
+ {
392
+ probe: () => probeWith(SessionState.MISSING),
393
+ reconcile: (r) => ({ ...r, status: 'executed', exitCode: 139 }),
394
+ }
395
+ );
396
+ assert.strictEqual(result.success, true);
397
+ assert.match(result.output, /reconciled/);
398
+ assert.strictEqual(saved.length, 1);
399
+ assert.strictEqual(saved[0].status, 'executed');
400
+ });
401
+
402
+ test('leaves live screen sessions untouched', async () => {
403
+ const record = makeRecord({ isolated: 'screen', sessionName: 'work' });
404
+ const result = await resumeAllExecutions(
405
+ { getExecuting: () => [record], save: () => {} },
406
+ {
407
+ probe: () => ({
408
+ backend: 'screen',
409
+ sessionName: 'work',
410
+ state: SessionState.RUNNING,
411
+ alive: true,
412
+ }),
413
+ }
414
+ );
415
+ assert.match(result.output, /running/);
416
+ });
417
+ });
418
+
419
+ describe('formatResumeResultAsLinksNotation', () => {
420
+ test('emits a nested links-notation block', () => {
421
+ const output = formatResumeResultAsLinksNotation({
422
+ identifier: 'box',
423
+ uuid: 'u-1',
424
+ backend: 'docker',
425
+ mode: ResumeMode.DOCKER_START,
426
+ sessionName: 'box',
427
+ command: 'npm test',
428
+ message: 'Resumed detached docker container: box',
429
+ });
430
+ assert.match(output, /^executionResume\n/);
431
+ assert.match(output, /\n {2}mode docker-start\n/);
432
+ });
433
+ });
@@ -601,4 +601,56 @@ describe('ExecutionStore cleanupStale', () => {
601
601
  });
602
602
  });
603
603
 
604
+ describe('ExecutionStore.execClink', () => {
605
+ // Regression for issue #168: the query was interpolated into a shell string
606
+ // as `clink '<query>' --db "<path>"`, so a single quote in any recorded
607
+ // value (a command such as `echo it's fine`) broke out of the quoting.
608
+ it('passes the query to clink as a single argument, quotes included', () => {
609
+ if (process.platform === 'win32') {
610
+ return; // /bin/sh stub is POSIX-only
611
+ }
612
+
613
+ const { execFileSync } = require('child_process');
614
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-clink-'));
615
+ try {
616
+ const argvFile = path.join(dir, 'argv');
617
+ const clinkPath = path.join(dir, 'clink');
618
+ fs.writeFileSync(
619
+ clinkPath,
620
+ [
621
+ '#!/bin/sh',
622
+ `printf '%s\\0' "$@" > "${argvFile}"`,
623
+ 'echo ok',
624
+ '',
625
+ ].join('\n')
626
+ );
627
+ fs.chmodSync(clinkPath, 0o755);
628
+
629
+ const query = `() (("x.command: command it's fine"))`;
630
+ const modulePath = require.resolve('../src/lib/execution-store');
631
+ // A child process is required: the runtime resolves PATH at startup, so
632
+ // mutating process.env.PATH in-process does not affect command lookup.
633
+ const script = `
634
+ const { ExecutionStore } = require(${JSON.stringify(modulePath)});
635
+ const store = new ExecutionStore({ appFolder: ${JSON.stringify(dir)} });
636
+ const result = store.execClink(${JSON.stringify(query)});
637
+ process.stdout.write(JSON.stringify(result));
638
+ `;
639
+ const stdout = execFileSync(process.execPath, ['-e', script], {
640
+ encoding: 'utf8',
641
+ env: {
642
+ ...process.env,
643
+ PATH: `${dir}${path.delimiter}${process.env.PATH}`,
644
+ },
645
+ });
646
+
647
+ expect(JSON.parse(stdout).success).toBe(true);
648
+ const argv = fs.readFileSync(argvFile, 'utf8').split('\0').slice(0, -1);
649
+ expect(argv).toEqual([query, '--db', path.join(dir, LINKS_DB_FILE)]);
650
+ } finally {
651
+ fs.rmSync(dir, { recursive: true, force: true });
652
+ }
653
+ });
654
+ });
655
+
604
656
  console.log('=== Execution Store Unit Tests ===');
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Unit tests for exit-reason detection (issue #162).
4
+ *
5
+ * `exitCode 139` with `oomKilled false` is not actionable on its own: the
6
+ * kernel/cgroup OOM flag is unset when the process aborts itself after hitting
7
+ * a runtime memory limit. The log almost always carries the real cause, so the
8
+ * footer scan surfaces it as `exitReason`.
9
+ */
10
+
11
+ const { describe, it } = require('node:test');
12
+ const assert = require('assert');
13
+
14
+ const {
15
+ detectExitReason,
16
+ detectMemoryMarker,
17
+ resolveExitReason,
18
+ resolveMemoryExhaustion,
19
+ signalNameForExitCode,
20
+ } = require('../src/lib/exit-reason');
21
+
22
+ describe('detectExitReason', () => {
23
+ it('should detect the V8 heap limit marker from the incident log', () => {
24
+ const tail = [
25
+ 'some command output',
26
+ '',
27
+ '<--- Last few GCs --->',
28
+ '',
29
+ 'FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory',
30
+ '',
31
+ '==================================================',
32
+ 'Finished: 2026-09-01 12:00:00.000',
33
+ 'Exit Code: 139',
34
+ ].join('\n');
35
+
36
+ assert.strictEqual(
37
+ detectExitReason(tail),
38
+ 'memory-exhaustion (v8-heap-limit)'
39
+ );
40
+ });
41
+
42
+ it('should detect a bare JavaScript heap out of memory line', () => {
43
+ assert.strictEqual(
44
+ detectExitReason('JavaScript heap out of memory\n'),
45
+ 'memory-exhaustion (v8-heap-limit)'
46
+ );
47
+ });
48
+
49
+ it('should detect other runtimes reporting their own exhaustion', () => {
50
+ assert.strictEqual(
51
+ detectExitReason('fatal error: runtime: out of memory'),
52
+ 'memory-exhaustion (go-runtime)'
53
+ );
54
+ assert.strictEqual(
55
+ detectExitReason('Array buffer allocation failed'),
56
+ 'memory-exhaustion (allocation-failure)'
57
+ );
58
+ });
59
+
60
+ it('should detect the kernel OOM killer marker', () => {
61
+ assert.strictEqual(
62
+ detectExitReason('Out of memory: Killed process 4242 (bun)\n'),
63
+ 'memory-exhaustion (kernel-oom-killer)'
64
+ );
65
+ });
66
+
67
+ it('should detect allocation failures', () => {
68
+ assert.strictEqual(
69
+ detectExitReason('terminate called after throwing std::bad_alloc'),
70
+ 'memory-exhaustion (allocation-failure)'
71
+ );
72
+ assert.strictEqual(
73
+ detectExitReason('memory allocation of 1048576 bytes failed'),
74
+ 'memory-exhaustion (allocation-failure)'
75
+ );
76
+ });
77
+
78
+ it('should not invent a reason for ordinary output', () => {
79
+ assert.strictEqual(detectExitReason('all tests passed\n'), null);
80
+ assert.strictEqual(detectExitReason(''), null);
81
+ assert.strictEqual(detectExitReason(null), null);
82
+ });
83
+ });
84
+
85
+ describe('signalNameForExitCode', () => {
86
+ it('should map 139 to SIGSEGV', () => {
87
+ assert.strictEqual(signalNameForExitCode(139), 'SIGSEGV');
88
+ });
89
+
90
+ it('should map 137 to SIGKILL', () => {
91
+ assert.strictEqual(signalNameForExitCode(137), 'SIGKILL');
92
+ });
93
+
94
+ it('should ignore ordinary exit codes', () => {
95
+ assert.strictEqual(signalNameForExitCode(0), null);
96
+ assert.strictEqual(signalNameForExitCode(1), null);
97
+ assert.strictEqual(signalNameForExitCode(-1), null);
98
+ assert.strictEqual(signalNameForExitCode(null), null);
99
+ });
100
+ });
101
+
102
+ describe('resolveExitReason', () => {
103
+ it('should prefer the log marker over the signal fallback', () => {
104
+ const reason = resolveExitReason({
105
+ exitCode: 139,
106
+ logTail:
107
+ 'FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory',
108
+ oomKilled: false,
109
+ });
110
+ assert.strictEqual(reason, 'memory-exhaustion (v8-heap-limit)');
111
+ });
112
+
113
+ it('should report the cgroup OOM observation when there is no marker', () => {
114
+ const reason = resolveExitReason({ exitCode: 137, oomKilled: true });
115
+ assert.strictEqual(reason, 'memory-exhaustion (cgroup-oom-killer)');
116
+ });
117
+
118
+ it('should fall back to the signal name for signal exit codes', () => {
119
+ assert.strictEqual(
120
+ resolveExitReason({ exitCode: 139, oomKilled: false }),
121
+ 'signal (SIGSEGV)'
122
+ );
123
+ });
124
+
125
+ it('should return null when nothing is known', () => {
126
+ assert.strictEqual(resolveExitReason({ exitCode: 0 }), null);
127
+ assert.strictEqual(resolveExitReason({}), null);
128
+ assert.strictEqual(resolveExitReason(null), null);
129
+ });
130
+ });
131
+
132
+ describe('detectMemoryMarker', () => {
133
+ it('should return the whole line carrying the marker', () => {
134
+ const tail = [
135
+ '<--- Last few GCs --->',
136
+ 'FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory',
137
+ '----- Native stack trace -----',
138
+ ].join('\n');
139
+
140
+ assert.deepStrictEqual(detectMemoryMarker(tail), {
141
+ reason: 'memory-exhaustion (v8-heap-limit)',
142
+ line: 'FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory',
143
+ });
144
+ });
145
+
146
+ it('should bound an absurdly long line', () => {
147
+ const marker = detectMemoryMarker(
148
+ `${'x'.repeat(5000)} JavaScript heap out of memory`
149
+ );
150
+
151
+ assert.ok(marker.line.length <= 304);
152
+ assert.ok(marker.line.endsWith('...'));
153
+ });
154
+ });
155
+
156
+ describe('resolveMemoryExhaustion', () => {
157
+ const marker =
158
+ 'FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory';
159
+
160
+ it('should report the evidence for an abnormal exit', () => {
161
+ assert.deepStrictEqual(
162
+ resolveMemoryExhaustion({
163
+ exitCode: 139,
164
+ logTail: `working\n${marker}\n`,
165
+ oomKilled: false,
166
+ }),
167
+ { memoryExhausted: true, memoryExhaustedReason: marker }
168
+ );
169
+ });
170
+
171
+ it('should fall back to the container flag when the log says nothing', () => {
172
+ assert.deepStrictEqual(
173
+ resolveMemoryExhaustion({
174
+ exitCode: 137,
175
+ logTail: 'killed\n',
176
+ oomKilled: true,
177
+ }),
178
+ {
179
+ memoryExhausted: true,
180
+ memoryExhaustedReason: 'Docker reported State.OOMKilled=true',
181
+ }
182
+ );
183
+ });
184
+
185
+ it('should stay silent for a clean or unfinished run', () => {
186
+ // An observation, never a verdict (#151): a log that merely quotes the
187
+ // marker cannot turn a successful run into a memory failure.
188
+ assert.strictEqual(
189
+ resolveMemoryExhaustion({ exitCode: 0, logTail: marker }),
190
+ null
191
+ );
192
+ assert.strictEqual(
193
+ resolveMemoryExhaustion({ exitCode: null, logTail: marker }),
194
+ null
195
+ );
196
+ });
197
+
198
+ it('should stay silent for an ordinary failure', () => {
199
+ assert.strictEqual(
200
+ resolveMemoryExhaustion({
201
+ exitCode: 1,
202
+ logTail: 'error: file not found\n',
203
+ oomKilled: false,
204
+ }),
205
+ null
206
+ );
207
+ assert.strictEqual(resolveMemoryExhaustion(null), null);
208
+ });
209
+ });