start-command 0.37.8 → 0.38.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.
- package/CHANGELOG.md +16 -0
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/bin/cli.js +66 -26
- package/src/lib/args-parser-queries.js +90 -9
- package/src/lib/args-parser.js +20 -1
- package/src/lib/container-removal.js +163 -0
- package/src/lib/detached-finalize.js +29 -2
- package/src/lib/docker-cleanup.js +7 -4
- package/src/lib/docker-command-handoff.js +34 -6
- package/src/lib/docker-post-mortem.js +2 -0
- package/src/lib/docker-runtime-args.js +7 -0
- package/src/lib/docker-snapshot-resume.js +222 -0
- package/src/lib/docker-snapshot-safety.js +124 -12
- package/src/lib/execution-attempt.js +21 -9
- package/src/lib/execution-chain.js +182 -0
- package/src/lib/execution-cleanup.js +193 -0
- package/src/lib/execution-control.js +2 -1
- package/src/lib/execution-env.js +120 -0
- package/src/lib/execution-identity.js +180 -0
- package/src/lib/execution-recovery.js +19 -0
- package/src/lib/execution-resume.js +77 -6
- package/src/lib/execution-store.js +63 -22
- package/src/lib/isolation.js +25 -3
- package/src/lib/launch-identity-options.js +81 -0
- package/src/lib/launch-identity.js +411 -0
- package/src/lib/log-uploader.js +15 -1
- package/src/lib/query-commands.js +34 -6
- package/src/lib/screen-isolation.js +11 -2
- package/src/lib/snapshot-resume-options.js +174 -0
- package/src/lib/spawn-helpers.js +33 -16
- package/src/lib/status-formatter.js +64 -35
- package/src/lib/status-links-notation.js +97 -0
- package/src/lib/store-lock.js +3 -0
- package/src/lib/usage.js +15 -1
- package/test/args-parser-attach-resume.js +1 -1
- package/test/args-parser-control.js +2 -2
- package/test/regression-224.js +807 -0
- package/test/regression-225.js +415 -0
- package/test/regression-226.js +81 -0
- package/test/regression-229.js +267 -0
- package/test/regression-230.js +234 -0
- package/test/regression-231.js +298 -0
- package/test/shared-golden.js +35 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for issue #225: after a kept docker container was removed,
|
|
3
|
+
* `$ --status` kept reporting it exactly as before — same `containerId`,
|
|
4
|
+
* nothing saying the container no longer exists.
|
|
5
|
+
*
|
|
6
|
+
* `docker inspect` exits 1 with `No such object` / `No such container` for a
|
|
7
|
+
* removed container, which used to be folded into the same `null` as a docker
|
|
8
|
+
* that cannot be reached at all.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { describe, it, expect, afterEach } = require('bun:test');
|
|
12
|
+
const { spawnSync } = require('child_process');
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
|
|
17
|
+
const {
|
|
18
|
+
ExecutionRecord,
|
|
19
|
+
ExecutionStatus,
|
|
20
|
+
ExecutionStore,
|
|
21
|
+
} = require('../src/lib/execution-store');
|
|
22
|
+
const { queryStatus } = require('../src/lib/status-formatter');
|
|
23
|
+
const {
|
|
24
|
+
DOCKER_CONTAINER_CLEANUP_POLICY,
|
|
25
|
+
buildDetachedDockerCompletionScript,
|
|
26
|
+
} = require('../src/lib/docker-cleanup');
|
|
27
|
+
const { finalizeDetachedExecution } = require('../src/lib/detached-finalize');
|
|
28
|
+
const {
|
|
29
|
+
CONTAINER_STATE_REMOVED,
|
|
30
|
+
isDockerNotFound,
|
|
31
|
+
} = require('../src/lib/container-removal');
|
|
32
|
+
|
|
33
|
+
const tempDirs = [];
|
|
34
|
+
|
|
35
|
+
function makeTempDir(prefix) {
|
|
36
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
37
|
+
tempDirs.push(dir);
|
|
38
|
+
return dir;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
while (tempDirs.length > 0) {
|
|
43
|
+
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// The stubs below are POSIX `sh` scripts; Windows has no `/bin/sh`.
|
|
48
|
+
const needsPosixShell = () => {
|
|
49
|
+
if (process.platform !== 'win32') {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
console.log(' Skipping: the docker stub needs a POSIX /bin/sh');
|
|
53
|
+
return true;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const NOT_FOUND = 'printf \'error: no such object: %s\\n\' "$4" >&2; exit 1';
|
|
57
|
+
const UNREACHABLE =
|
|
58
|
+
"printf 'failed to connect to the docker API at unix:///var/run/docker.sock; " +
|
|
59
|
+
'check if the path is correct and if the daemon is running: dial unix ' +
|
|
60
|
+
"/var/run/docker.sock: connect: no such file or directory\\n' >&2; exit 1";
|
|
61
|
+
const EXITED = "echo 'false 1 false 2026-10-19T00:00:00Z 2026-10-19T00:00:10Z'";
|
|
62
|
+
|
|
63
|
+
/** Fake `docker` whose every `inspect` runs `inspectBody`. */
|
|
64
|
+
function withFakeDocker(inspectBody, fn) {
|
|
65
|
+
const dir = makeTempDir('fake-docker-225-');
|
|
66
|
+
const dockerPath = path.join(dir, 'docker');
|
|
67
|
+
fs.writeFileSync(
|
|
68
|
+
dockerPath,
|
|
69
|
+
[
|
|
70
|
+
'#!/bin/sh',
|
|
71
|
+
`echo "$*" >> '${dir}/calls'`,
|
|
72
|
+
'[ "$1" = inspect ] || exit 1',
|
|
73
|
+
inspectBody,
|
|
74
|
+
'',
|
|
75
|
+
].join('\n'),
|
|
76
|
+
'utf8'
|
|
77
|
+
);
|
|
78
|
+
fs.chmodSync(dockerPath, 0o755);
|
|
79
|
+
const original = process.env.START_DOCKER_BIN;
|
|
80
|
+
process.env.START_DOCKER_BIN = dockerPath;
|
|
81
|
+
try {
|
|
82
|
+
return fn(() =>
|
|
83
|
+
fs.existsSync(`${dir}/calls`)
|
|
84
|
+
? fs.readFileSync(`${dir}/calls`, 'utf8').trim().split('\n')
|
|
85
|
+
: []
|
|
86
|
+
);
|
|
87
|
+
} finally {
|
|
88
|
+
if (original === undefined) {
|
|
89
|
+
delete process.env.START_DOCKER_BIN;
|
|
90
|
+
} else {
|
|
91
|
+
process.env.START_DOCKER_BIN = original;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function keptContainerRecord(overrides = {}) {
|
|
97
|
+
return new ExecutionRecord({
|
|
98
|
+
command: "sh -c 'echo working; exit 1'",
|
|
99
|
+
status: ExecutionStatus.EXECUTED,
|
|
100
|
+
exitCode: 1,
|
|
101
|
+
startTime: '2026-10-19T00:00:00.000Z',
|
|
102
|
+
endTime: '2026-10-19T00:00:10.000Z',
|
|
103
|
+
endTimeSource: 'docker-finished-at',
|
|
104
|
+
options: {
|
|
105
|
+
isolated: 'docker',
|
|
106
|
+
isolationMode: 'detached',
|
|
107
|
+
sessionName: 'status-removal-demo',
|
|
108
|
+
containerId: 'd2a8c0ffee',
|
|
109
|
+
},
|
|
110
|
+
...overrides,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function storeWith(record) {
|
|
115
|
+
const store = new ExecutionStore({
|
|
116
|
+
appFolder: makeTempDir('store-225-'),
|
|
117
|
+
useLinks: false,
|
|
118
|
+
});
|
|
119
|
+
store.save(record);
|
|
120
|
+
return store;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function statusJson(store, id) {
|
|
124
|
+
const result = queryStatus(store, id, 'json');
|
|
125
|
+
expect(result.success).toBe(true);
|
|
126
|
+
return JSON.parse(result.output);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
describe('issue #225: docker not-found is told apart from docker errors', () => {
|
|
130
|
+
it('recognises the not-found answers of docker inspect', () => {
|
|
131
|
+
for (const stderr of [
|
|
132
|
+
'error: no such object: status-removal-demo',
|
|
133
|
+
'Error: No such object: status-removal-demo',
|
|
134
|
+
'Error response from daemon: No such container: status-removal-demo',
|
|
135
|
+
]) {
|
|
136
|
+
expect(isDockerNotFound({ status: 1, stderr })).toBe(true);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('does not read an unreachable daemon as a removed container', () => {
|
|
141
|
+
for (const stderr of [
|
|
142
|
+
'failed to connect to the docker API at unix:///var/run/docker.sock: connect: no such file or directory',
|
|
143
|
+
'Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?',
|
|
144
|
+
'',
|
|
145
|
+
]) {
|
|
146
|
+
expect(isDockerNotFound({ status: 1, stderr })).toBe(false);
|
|
147
|
+
}
|
|
148
|
+
expect(isDockerNotFound({ status: 0, stderr: 'No such object' })).toBe(
|
|
149
|
+
false
|
|
150
|
+
);
|
|
151
|
+
expect(isDockerNotFound({ error: new Error('ENOENT'), status: null })).toBe(
|
|
152
|
+
false
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('issue #225: --status reports a removed container', () => {
|
|
158
|
+
it('adds containerState removed and keeps containerId for history', () => {
|
|
159
|
+
if (needsPosixShell()) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const record = keptContainerRecord();
|
|
163
|
+
const store = storeWith(record);
|
|
164
|
+
withFakeDocker(NOT_FOUND, (calls) => {
|
|
165
|
+
const status = statusJson(store, 'status-removal-demo');
|
|
166
|
+
expect(status.containerState).toBe(CONTAINER_STATE_REMOVED);
|
|
167
|
+
expect(
|
|
168
|
+
Number.isFinite(Date.parse(status.containerRemovedObservedAt))
|
|
169
|
+
).toBe(true);
|
|
170
|
+
expect(status.options.containerId).toBe('d2a8c0ffee');
|
|
171
|
+
expect(status.status).toBe('executed');
|
|
172
|
+
expect(status.exitCode).toBe(1);
|
|
173
|
+
// Probed by id, so a later container reusing the name is not mistaken
|
|
174
|
+
// for this one.
|
|
175
|
+
expect(calls()).toContain('inspect -f {{.Id}} d2a8c0ffee');
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('persists the observation and keeps its first time', () => {
|
|
180
|
+
if (needsPosixShell()) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const record = keptContainerRecord();
|
|
184
|
+
const store = storeWith(record);
|
|
185
|
+
withFakeDocker(NOT_FOUND, () => {
|
|
186
|
+
const first = statusJson(store, record.uuid).containerRemovedObservedAt;
|
|
187
|
+
const stored = store.get(record.uuid);
|
|
188
|
+
expect(stored.containerState).toBe(CONTAINER_STATE_REMOVED);
|
|
189
|
+
expect(stored.containerRemovedObservedAt).toBe(first);
|
|
190
|
+
expect(statusJson(store, record.uuid).containerRemovedObservedAt).toBe(
|
|
191
|
+
first
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('shows the removal in the links-notation and text formats', () => {
|
|
197
|
+
if (needsPosixShell()) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const store = storeWith(keptContainerRecord());
|
|
201
|
+
withFakeDocker(NOT_FOUND, () => {
|
|
202
|
+
const links = queryStatus(store, 'status-removal-demo', null).output;
|
|
203
|
+
expect(links).toContain(' containerState removed');
|
|
204
|
+
expect(links).toMatch(/\n {2}containerRemovedObservedAt \S+/);
|
|
205
|
+
expect(links).toContain(' containerId d2a8c0ffee');
|
|
206
|
+
const text = queryStatus(store, 'status-removal-demo', 'text').output;
|
|
207
|
+
expect(text).toContain('Container State: removed');
|
|
208
|
+
expect(text).toMatch(/\nContainer Removed: \d{4}-/);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('falls back to the session name when no container id was recorded', () => {
|
|
213
|
+
if (needsPosixShell()) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const record = keptContainerRecord();
|
|
217
|
+
delete record.options.containerId;
|
|
218
|
+
const store = storeWith(record);
|
|
219
|
+
withFakeDocker(NOT_FOUND, () => {
|
|
220
|
+
expect(statusJson(store, record.uuid).containerState).toBe('removed');
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('says nothing when docker cannot be reached', () => {
|
|
225
|
+
if (needsPosixShell()) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const store = storeWith(keptContainerRecord());
|
|
229
|
+
withFakeDocker(UNREACHABLE, () => {
|
|
230
|
+
const status = statusJson(store, 'status-removal-demo');
|
|
231
|
+
expect(status.containerState).toBeUndefined();
|
|
232
|
+
expect(status.containerRemovedObservedAt).toBeUndefined();
|
|
233
|
+
});
|
|
234
|
+
expect(store.get('status-removal-demo').containerState).toBeUndefined();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('says nothing while the container still exists', () => {
|
|
238
|
+
if (needsPosixShell()) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const store = storeWith(keptContainerRecord());
|
|
242
|
+
withFakeDocker(EXITED, () => {
|
|
243
|
+
expect(
|
|
244
|
+
statusJson(store, 'status-removal-demo').containerState
|
|
245
|
+
).toBeUndefined();
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('leaves an executing record untouched when its container is not visible (#136)', () => {
|
|
250
|
+
if (needsPosixShell()) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const record = keptContainerRecord({
|
|
254
|
+
status: ExecutionStatus.EXECUTING,
|
|
255
|
+
exitCode: null,
|
|
256
|
+
endTime: null,
|
|
257
|
+
endTimeSource: undefined,
|
|
258
|
+
});
|
|
259
|
+
const store = storeWith(record);
|
|
260
|
+
withFakeDocker(NOT_FOUND, () => {
|
|
261
|
+
const status = statusJson(store, record.uuid);
|
|
262
|
+
expect(status.status).toBe('executing');
|
|
263
|
+
expect(status.containerState).toBeUndefined();
|
|
264
|
+
});
|
|
265
|
+
expect(store.get(record.uuid).status).toBe('executing');
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe('issue #225: start-command records its own removals', () => {
|
|
270
|
+
it('the finalizer records a removal by the cleanup policy', () => {
|
|
271
|
+
const record = keptContainerRecord({
|
|
272
|
+
status: ExecutionStatus.EXECUTING,
|
|
273
|
+
exitCode: null,
|
|
274
|
+
endTime: null,
|
|
275
|
+
endTimeSource: undefined,
|
|
276
|
+
});
|
|
277
|
+
const store = storeWith(record);
|
|
278
|
+
const outcome = finalizeDetachedExecution({
|
|
279
|
+
store,
|
|
280
|
+
executionId: record.uuid,
|
|
281
|
+
exitCode: '0',
|
|
282
|
+
oomKilled: 'false',
|
|
283
|
+
startedAt: '2026-10-19T00:00:00Z',
|
|
284
|
+
finishedAt: '2026-10-19T00:00:10Z',
|
|
285
|
+
running: 'false',
|
|
286
|
+
containerRemoved: 'true',
|
|
287
|
+
});
|
|
288
|
+
expect(outcome.updated).toBe(true);
|
|
289
|
+
const stored = store.get(record.uuid);
|
|
290
|
+
expect(stored.containerState).toBe('removed');
|
|
291
|
+
expect(stored.containerRemovedObservedAt).toBeDefined();
|
|
292
|
+
expect(stored.options.containerId).toBe('d2a8c0ffee');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('the finalizer records a removal on an already finalized record', () => {
|
|
296
|
+
const record = keptContainerRecord();
|
|
297
|
+
const store = storeWith(record);
|
|
298
|
+
const outcome = finalizeDetachedExecution({
|
|
299
|
+
store,
|
|
300
|
+
executionId: record.uuid,
|
|
301
|
+
running: 'false',
|
|
302
|
+
containerRemoved: 'true',
|
|
303
|
+
});
|
|
304
|
+
expect(outcome.reason).toBe('container-removed');
|
|
305
|
+
expect(store.get(record.uuid).containerState).toBe('removed');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('the finalizer records nothing when the container was kept', () => {
|
|
309
|
+
const record = keptContainerRecord({
|
|
310
|
+
status: ExecutionStatus.EXECUTING,
|
|
311
|
+
exitCode: null,
|
|
312
|
+
endTime: null,
|
|
313
|
+
});
|
|
314
|
+
const store = storeWith(record);
|
|
315
|
+
finalizeDetachedExecution({
|
|
316
|
+
store,
|
|
317
|
+
executionId: record.uuid,
|
|
318
|
+
exitCode: '1',
|
|
319
|
+
finishedAt: '2026-10-19T00:00:10Z',
|
|
320
|
+
startedAt: '2026-10-19T00:00:00Z',
|
|
321
|
+
running: 'false',
|
|
322
|
+
containerRemoved: 'false',
|
|
323
|
+
});
|
|
324
|
+
expect(store.get(record.uuid).containerState).toBeUndefined();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
/** Run the real watcher against a fake docker whose `rm` exits `rmStatus`. */
|
|
328
|
+
function runWatcher(policy, exitCode, rmStatus) {
|
|
329
|
+
const dir = makeTempDir('watcher-225-');
|
|
330
|
+
const binDir = path.join(dir, 'bin');
|
|
331
|
+
const appFolder = path.join(dir, 'app');
|
|
332
|
+
fs.mkdirSync(binDir);
|
|
333
|
+
const logPath = path.join(dir, 'run.log');
|
|
334
|
+
fs.writeFileSync(
|
|
335
|
+
path.join(binDir, 'docker'),
|
|
336
|
+
[
|
|
337
|
+
'#!/bin/sh',
|
|
338
|
+
'[ "$START_COMMAND_CGROUP_SAMPLING" = 1 ] && exit 1',
|
|
339
|
+
'case "$1" in',
|
|
340
|
+
' inspect)',
|
|
341
|
+
' case "$3" in',
|
|
342
|
+
' *HostConfig*) exit 1 ;;',
|
|
343
|
+
' *State.Running*) echo false ;;',
|
|
344
|
+
' *State.Error*) echo "" ;;',
|
|
345
|
+
" *'{{.Id}}'*|*State.Pid*) echo 0 ;;",
|
|
346
|
+
` *) echo '${exitCode} false 2026-10-19T00:00:00Z 2026-10-19T00:00:10Z' ;;`,
|
|
347
|
+
' esac ;;',
|
|
348
|
+
' logs) echo work ;;',
|
|
349
|
+
' wait) echo 0 ;;',
|
|
350
|
+
` rm) exit ${rmStatus} ;;`,
|
|
351
|
+
' *) exit 1 ;;',
|
|
352
|
+
'esac',
|
|
353
|
+
'',
|
|
354
|
+
].join('\n'),
|
|
355
|
+
'utf8'
|
|
356
|
+
);
|
|
357
|
+
fs.chmodSync(path.join(binDir, 'docker'), 0o755);
|
|
358
|
+
const store = new ExecutionStore({ appFolder, useLinks: false });
|
|
359
|
+
const record = keptContainerRecord({
|
|
360
|
+
status: ExecutionStatus.EXECUTING,
|
|
361
|
+
exitCode: null,
|
|
362
|
+
endTime: null,
|
|
363
|
+
endTimeSource: undefined,
|
|
364
|
+
logPath,
|
|
365
|
+
});
|
|
366
|
+
store.save(record);
|
|
367
|
+
const script = buildDetachedDockerCompletionScript(
|
|
368
|
+
'status-removal-demo',
|
|
369
|
+
policy,
|
|
370
|
+
logPath,
|
|
371
|
+
record.uuid
|
|
372
|
+
);
|
|
373
|
+
const result = spawnSync('/bin/sh', ['-c', script], {
|
|
374
|
+
encoding: 'utf8',
|
|
375
|
+
env: {
|
|
376
|
+
...process.env,
|
|
377
|
+
PATH: `${binDir}${path.delimiter}${process.env.PATH}`,
|
|
378
|
+
START_APP_FOLDER: appFolder,
|
|
379
|
+
START_DISABLE_TRACKING: '',
|
|
380
|
+
},
|
|
381
|
+
timeout: 20000,
|
|
382
|
+
});
|
|
383
|
+
expect(result.status).toBe(0);
|
|
384
|
+
return new ExecutionStore({ appFolder, useLinks: false }).get(record.uuid);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
it('the watcher marks the record when its docker rm succeeded', () => {
|
|
388
|
+
if (needsPosixShell()) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const record = runWatcher(DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS, 0, 0);
|
|
392
|
+
expect(record.status).toBe('executed');
|
|
393
|
+
expect(record.containerState).toBe('removed');
|
|
394
|
+
expect(record.containerRemovedObservedAt).toBeDefined();
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('the watcher does not mark a kept container', () => {
|
|
398
|
+
if (needsPosixShell()) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const record = runWatcher(DOCKER_CONTAINER_CLEANUP_POLICY.DEFAULT, 1, 0);
|
|
402
|
+
expect(record.status).toBe('executed');
|
|
403
|
+
expect(record.exitCode).toBe(1);
|
|
404
|
+
expect(record.containerState).toBeUndefined();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('the watcher does not mark a container whose docker rm failed', () => {
|
|
408
|
+
if (needsPosixShell()) {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const record = runWatcher(DOCKER_CONTAINER_CLEANUP_POLICY.ALWAYS, 0, 1);
|
|
412
|
+
expect(record.status).toBe('executed');
|
|
413
|
+
expect(record.containerState).toBeUndefined();
|
|
414
|
+
});
|
|
415
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Issue #226: `$ --status` printed nested option values (launchOwner,
|
|
2
|
+
// exitEvidence, resolvedLimits, resourceLimitSpecs) as "[object Object]".
|
|
3
|
+
const { describe, it, expect } = require('bun:test');
|
|
4
|
+
const { ExecutionRecord } = require('../src/lib/execution-store');
|
|
5
|
+
const {
|
|
6
|
+
formatRecordAsLinksNotation,
|
|
7
|
+
formatRecordAsText,
|
|
8
|
+
formatRecordList,
|
|
9
|
+
} = require('../src/lib/status-formatter');
|
|
10
|
+
|
|
11
|
+
function recordWithNestedOptions() {
|
|
12
|
+
return new ExecutionRecord({
|
|
13
|
+
uuid: 'obj-demo-uuid',
|
|
14
|
+
command: "sh -c 'exit 1'",
|
|
15
|
+
status: 'executed',
|
|
16
|
+
exitCode: 1,
|
|
17
|
+
startTime: '2026-10-19T00:00:00.000Z',
|
|
18
|
+
endTime: '2026-10-19T00:00:10.000Z',
|
|
19
|
+
logPath: '/tmp/obj-demo.log',
|
|
20
|
+
options: {
|
|
21
|
+
isolated: 'docker',
|
|
22
|
+
isolationMode: 'detached',
|
|
23
|
+
sessionName: 'obj-demo',
|
|
24
|
+
launchOwner: { pid: 522913, hostname: '70a2c2990824' },
|
|
25
|
+
exitEvidence: { daemonRestart: false, mainOom: false },
|
|
26
|
+
resolvedLimits: { cpus: 2, memory: '4g' },
|
|
27
|
+
resourceLimitSpecs: { memory: { value: '4g', source: 'flag' } },
|
|
28
|
+
ports: ['8080:80', '9090:90'],
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('issue #226: nested option values in --status', () => {
|
|
34
|
+
it('links notation renders nested option values instead of [object Object]', () => {
|
|
35
|
+
const output = formatRecordAsLinksNotation(recordWithNestedOptions());
|
|
36
|
+
expect(output).not.toContain('[object Object]');
|
|
37
|
+
const lines = output.split('\n');
|
|
38
|
+
const at = (line) => lines.indexOf(line);
|
|
39
|
+
expect(at(' launchOwner')).toBeGreaterThan(at(' options'));
|
|
40
|
+
expect(lines[at(' launchOwner') + 1]).toBe(' pid 522913');
|
|
41
|
+
expect(lines[at(' launchOwner') + 2]).toBe(
|
|
42
|
+
' hostname 70a2c2990824'
|
|
43
|
+
);
|
|
44
|
+
expect(lines[at(' exitEvidence') + 1]).toBe(
|
|
45
|
+
' daemonRestart false'
|
|
46
|
+
);
|
|
47
|
+
expect(lines[at(' exitEvidence') + 2]).toBe(' mainOom false');
|
|
48
|
+
expect(lines[at(' resolvedLimits') + 1]).toBe(' cpus 2');
|
|
49
|
+
expect(lines[at(' resolvedLimits') + 2]).toBe(' memory 4g');
|
|
50
|
+
expect(lines[at(' resourceLimitSpecs') + 1]).toBe(' memory');
|
|
51
|
+
expect(lines[at(' resourceLimitSpecs') + 2]).toBe(
|
|
52
|
+
' value 4g'
|
|
53
|
+
);
|
|
54
|
+
expect(lines.slice(at(' ports'), at(' ports') + 4)).toEqual([
|
|
55
|
+
' ports',
|
|
56
|
+
' (',
|
|
57
|
+
' "8080:80"',
|
|
58
|
+
' "9090:90"',
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('text format renders nested option values as JSON', () => {
|
|
63
|
+
const output = formatRecordAsText(recordWithNestedOptions());
|
|
64
|
+
expect(output).not.toContain('[object Object]');
|
|
65
|
+
expect(output).toContain(
|
|
66
|
+
' launchOwner: {"pid":522913,"hostname":"70a2c2990824"}'
|
|
67
|
+
);
|
|
68
|
+
expect(output).toContain(
|
|
69
|
+
' exitEvidence: {"daemonRestart":false,"mainOom":false}'
|
|
70
|
+
);
|
|
71
|
+
expect(output).toContain(' ports: ["8080:80","9090:90"]');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('--list output in every format keeps the nested values', () => {
|
|
75
|
+
for (const format of ['links-notation', 'text', 'json']) {
|
|
76
|
+
const output = formatRecordList([recordWithNestedOptions()], format);
|
|
77
|
+
expect(output).not.toContain('[object Object]');
|
|
78
|
+
expect(output).toContain('522913');
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|