start-command 0.7.5 → 0.9.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.
@@ -0,0 +1,377 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Resource cleanup tests for isolation module
4
+ * Tests that verify isolation environments release resources after command execution
5
+ */
6
+
7
+ const { describe, it } = require('node:test');
8
+ const assert = require('assert');
9
+ const { isCommandAvailable } = require('../src/lib/isolation');
10
+
11
+ describe('Isolation Resource Cleanup Verification', () => {
12
+ // These tests verify that isolation environments release resources after command execution
13
+ // This ensures uniform behavior across all backends where resources are freed by default
14
+
15
+ const {
16
+ runInScreen,
17
+ runInTmux,
18
+ runInDocker,
19
+ } = require('../src/lib/isolation');
20
+ const { execSync } = require('child_process');
21
+
22
+ // Helper to wait for a condition with timeout
23
+ async function waitFor(conditionFn, timeout = 5000, interval = 100) {
24
+ const startTime = Date.now();
25
+ while (Date.now() - startTime < timeout) {
26
+ if (conditionFn()) {
27
+ return true;
28
+ }
29
+ await new Promise((resolve) => setTimeout(resolve, interval));
30
+ }
31
+ return false;
32
+ }
33
+
34
+ describe('screen resource cleanup', () => {
35
+ it('should not list screen session after command completes (auto-exit by default)', async () => {
36
+ if (!isCommandAvailable('screen')) {
37
+ console.log(' Skipping: screen not installed');
38
+ return;
39
+ }
40
+
41
+ const sessionName = `test-cleanup-screen-${Date.now()}`;
42
+
43
+ // Run a quick command in detached mode
44
+ const result = await runInScreen('echo "test" && sleep 0.1', {
45
+ session: sessionName,
46
+ detached: true,
47
+ keepAlive: false,
48
+ });
49
+
50
+ assert.strictEqual(result.success, true);
51
+
52
+ // Wait for the session to exit naturally (should happen quickly)
53
+ const sessionGone = await waitFor(() => {
54
+ try {
55
+ const sessions = execSync('screen -ls', {
56
+ encoding: 'utf8',
57
+ stdio: ['pipe', 'pipe', 'pipe'],
58
+ });
59
+ return !sessions.includes(sessionName);
60
+ } catch {
61
+ // screen -ls returns non-zero when no sessions exist
62
+ return true;
63
+ }
64
+ }, 10000);
65
+
66
+ assert.ok(
67
+ sessionGone,
68
+ 'Screen session should not be in the list after command completes (auto-exit by default)'
69
+ );
70
+
71
+ // Double-check with screen -ls to verify no active session
72
+ try {
73
+ const sessions = execSync('screen -ls', {
74
+ encoding: 'utf8',
75
+ stdio: ['pipe', 'pipe', 'pipe'],
76
+ });
77
+ assert.ok(
78
+ !sessions.includes(sessionName),
79
+ 'Session should not appear in screen -ls output'
80
+ );
81
+ console.log(' ✓ Screen session auto-exited and resources released');
82
+ } catch {
83
+ // screen -ls returns non-zero when no sessions - this is expected
84
+ console.log(
85
+ ' ✓ Screen session auto-exited (no sessions found in screen -ls)'
86
+ );
87
+ }
88
+ });
89
+
90
+ it('should keep screen session alive when keepAlive is true', async () => {
91
+ if (!isCommandAvailable('screen')) {
92
+ console.log(' Skipping: screen not installed');
93
+ return;
94
+ }
95
+
96
+ const sessionName = `test-keepalive-screen-${Date.now()}`;
97
+
98
+ // Run command with keepAlive enabled
99
+ const result = await runInScreen('echo "test"', {
100
+ session: sessionName,
101
+ detached: true,
102
+ keepAlive: true,
103
+ });
104
+
105
+ assert.strictEqual(result.success, true);
106
+
107
+ // Wait a bit for the command to complete
108
+ await new Promise((resolve) => setTimeout(resolve, 1000));
109
+
110
+ // Session should still exist
111
+ try {
112
+ const sessions = execSync('screen -ls', {
113
+ encoding: 'utf8',
114
+ stdio: ['pipe', 'pipe', 'pipe'],
115
+ });
116
+ assert.ok(
117
+ sessions.includes(sessionName),
118
+ 'Session should still be alive with keepAlive=true'
119
+ );
120
+ console.log(
121
+ ' ✓ Screen session kept alive as expected with --keep-alive'
122
+ );
123
+ } catch {
124
+ assert.fail(
125
+ 'screen -ls should show the session when keepAlive is true'
126
+ );
127
+ }
128
+
129
+ // Clean up
130
+ try {
131
+ execSync(`screen -S ${sessionName} -X quit`, { stdio: 'ignore' });
132
+ } catch {
133
+ // Ignore cleanup errors
134
+ }
135
+ });
136
+ });
137
+
138
+ describe('tmux resource cleanup', () => {
139
+ it('should not list tmux session after command completes (auto-exit by default)', async () => {
140
+ if (!isCommandAvailable('tmux')) {
141
+ console.log(' Skipping: tmux not installed');
142
+ return;
143
+ }
144
+
145
+ const sessionName = `test-cleanup-tmux-${Date.now()}`;
146
+
147
+ // Run a quick command in detached mode
148
+ const result = await runInTmux('echo "test" && sleep 0.1', {
149
+ session: sessionName,
150
+ detached: true,
151
+ keepAlive: false,
152
+ });
153
+
154
+ assert.strictEqual(result.success, true);
155
+
156
+ // Wait for the session to exit naturally
157
+ const sessionGone = await waitFor(() => {
158
+ try {
159
+ const sessions = execSync('tmux ls', {
160
+ encoding: 'utf8',
161
+ stdio: ['pipe', 'pipe', 'pipe'],
162
+ });
163
+ return !sessions.includes(sessionName);
164
+ } catch {
165
+ // tmux ls returns non-zero when no sessions exist
166
+ return true;
167
+ }
168
+ }, 10000);
169
+
170
+ assert.ok(
171
+ sessionGone,
172
+ 'Tmux session should not be in the list after command completes (auto-exit by default)'
173
+ );
174
+
175
+ // Double-check with tmux ls
176
+ try {
177
+ const sessions = execSync('tmux ls', {
178
+ encoding: 'utf8',
179
+ stdio: ['pipe', 'pipe', 'pipe'],
180
+ });
181
+ assert.ok(
182
+ !sessions.includes(sessionName),
183
+ 'Session should not appear in tmux ls output'
184
+ );
185
+ console.log(' ✓ Tmux session auto-exited and resources released');
186
+ } catch {
187
+ // tmux ls returns non-zero when no sessions - this is expected
188
+ console.log(
189
+ ' ✓ Tmux session auto-exited (no sessions found in tmux ls)'
190
+ );
191
+ }
192
+ });
193
+
194
+ it('should keep tmux session alive when keepAlive is true', async () => {
195
+ if (!isCommandAvailable('tmux')) {
196
+ console.log(' Skipping: tmux not installed');
197
+ return;
198
+ }
199
+
200
+ const sessionName = `test-keepalive-tmux-${Date.now()}`;
201
+
202
+ // Run command with keepAlive enabled
203
+ const result = await runInTmux('echo "test"', {
204
+ session: sessionName,
205
+ detached: true,
206
+ keepAlive: true,
207
+ });
208
+
209
+ assert.strictEqual(result.success, true);
210
+
211
+ // Wait a bit for the command to complete
212
+ await new Promise((resolve) => setTimeout(resolve, 1000));
213
+
214
+ // Session should still exist
215
+ try {
216
+ const sessions = execSync('tmux ls', {
217
+ encoding: 'utf8',
218
+ stdio: ['pipe', 'pipe', 'pipe'],
219
+ });
220
+ assert.ok(
221
+ sessions.includes(sessionName),
222
+ 'Session should still be alive with keepAlive=true'
223
+ );
224
+ console.log(
225
+ ' ✓ Tmux session kept alive as expected with --keep-alive'
226
+ );
227
+ } catch {
228
+ assert.fail('tmux ls should show the session when keepAlive is true');
229
+ }
230
+
231
+ // Clean up
232
+ try {
233
+ execSync(`tmux kill-session -t ${sessionName}`, { stdio: 'ignore' });
234
+ } catch {
235
+ // Ignore cleanup errors
236
+ }
237
+ });
238
+ });
239
+
240
+ describe('docker resource cleanup', () => {
241
+ // Helper function to check if docker daemon is running
242
+ function isDockerRunning() {
243
+ if (!isCommandAvailable('docker')) {
244
+ return false;
245
+ }
246
+ try {
247
+ execSync('docker info', { stdio: 'ignore', timeout: 5000 });
248
+ return true;
249
+ } catch {
250
+ return false;
251
+ }
252
+ }
253
+
254
+ it('should show docker container as exited after command completes (auto-exit by default)', async () => {
255
+ if (!isDockerRunning()) {
256
+ console.log(' Skipping: docker not available or daemon not running');
257
+ return;
258
+ }
259
+
260
+ const containerName = `test-cleanup-docker-${Date.now()}`;
261
+
262
+ // Run a quick command in detached mode
263
+ const result = await runInDocker('echo "test" && sleep 0.1', {
264
+ image: 'alpine:latest',
265
+ session: containerName,
266
+ detached: true,
267
+ keepAlive: false,
268
+ });
269
+
270
+ assert.strictEqual(result.success, true);
271
+
272
+ // Wait for the container to exit
273
+ const containerExited = await waitFor(() => {
274
+ try {
275
+ const status = execSync(
276
+ `docker inspect -f '{{.State.Status}}' ${containerName}`,
277
+ {
278
+ encoding: 'utf8',
279
+ stdio: ['pipe', 'pipe', 'pipe'],
280
+ }
281
+ ).trim();
282
+ return status === 'exited';
283
+ } catch {
284
+ return false;
285
+ }
286
+ }, 10000);
287
+
288
+ assert.ok(
289
+ containerExited,
290
+ 'Docker container should be in exited state after command completes (auto-exit by default)'
291
+ );
292
+
293
+ // Verify with docker ps -a that container is exited (not running)
294
+ try {
295
+ const allContainers = execSync('docker ps -a', {
296
+ encoding: 'utf8',
297
+ stdio: ['pipe', 'pipe', 'pipe'],
298
+ });
299
+ assert.ok(
300
+ allContainers.includes(containerName),
301
+ 'Container should appear in docker ps -a'
302
+ );
303
+
304
+ const runningContainers = execSync('docker ps', {
305
+ encoding: 'utf8',
306
+ stdio: ['pipe', 'pipe', 'pipe'],
307
+ });
308
+ assert.ok(
309
+ !runningContainers.includes(containerName),
310
+ 'Container should NOT appear in docker ps (not running)'
311
+ );
312
+ console.log(
313
+ ' ✓ Docker container auto-exited and stopped (resources released, filesystem preserved)'
314
+ );
315
+ } catch (err) {
316
+ assert.fail(`Failed to verify container status: ${err.message}`);
317
+ }
318
+
319
+ // Clean up
320
+ try {
321
+ execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
322
+ } catch {
323
+ // Ignore cleanup errors
324
+ }
325
+ });
326
+
327
+ it('should keep docker container running when keepAlive is true', async () => {
328
+ if (!isDockerRunning()) {
329
+ console.log(' Skipping: docker not available or daemon not running');
330
+ return;
331
+ }
332
+
333
+ const containerName = `test-keepalive-docker-${Date.now()}`;
334
+
335
+ // Run command with keepAlive enabled
336
+ const result = await runInDocker('echo "test"', {
337
+ image: 'alpine:latest',
338
+ session: containerName,
339
+ detached: true,
340
+ keepAlive: true,
341
+ });
342
+
343
+ assert.strictEqual(result.success, true);
344
+
345
+ // Wait a bit for the command to complete
346
+ await new Promise((resolve) => setTimeout(resolve, 1000));
347
+
348
+ // Container should still be running
349
+ try {
350
+ const status = execSync(
351
+ `docker inspect -f '{{.State.Status}}' ${containerName}`,
352
+ {
353
+ encoding: 'utf8',
354
+ stdio: ['pipe', 'pipe', 'pipe'],
355
+ }
356
+ ).trim();
357
+ assert.strictEqual(
358
+ status,
359
+ 'running',
360
+ 'Container should still be running with keepAlive=true'
361
+ );
362
+ console.log(
363
+ ' ✓ Docker container kept running as expected with --keep-alive'
364
+ );
365
+ } catch (err) {
366
+ assert.fail(`Failed to verify container is running: ${err.message}`);
367
+ }
368
+
369
+ // Clean up
370
+ try {
371
+ execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
372
+ } catch {
373
+ // Ignore cleanup errors
374
+ }
375
+ });
376
+ });
377
+ });
@@ -243,6 +243,206 @@ describe('Isolation Runner Error Handling', () => {
243
243
  });
244
244
  });
245
245
 
246
+ describe('Isolation Keep-Alive Behavior', () => {
247
+ // Tests for the --keep-alive option behavior
248
+ // These test the message output and options handling
249
+
250
+ const {
251
+ runInScreen,
252
+ runInTmux,
253
+ runInDocker,
254
+ } = require('../src/lib/isolation');
255
+ const { execSync } = require('child_process');
256
+
257
+ describe('runInScreen keep-alive messages', () => {
258
+ it('should include auto-exit message by default in detached mode', async () => {
259
+ if (!isCommandAvailable('screen')) {
260
+ console.log(' Skipping: screen not installed');
261
+ return;
262
+ }
263
+
264
+ const result = await runInScreen('echo test', {
265
+ session: `test-autoexit-${Date.now()}`,
266
+ detached: true,
267
+ keepAlive: false,
268
+ });
269
+
270
+ assert.strictEqual(result.success, true);
271
+ assert.ok(
272
+ result.message.includes('exit automatically'),
273
+ 'Message should indicate auto-exit behavior'
274
+ );
275
+
276
+ // Clean up
277
+ try {
278
+ execSync(`screen -S ${result.sessionName} -X quit`, {
279
+ stdio: 'ignore',
280
+ });
281
+ } catch {
282
+ // Session may have already exited
283
+ }
284
+ });
285
+
286
+ it('should include keep-alive message when keepAlive is true', async () => {
287
+ if (!isCommandAvailable('screen')) {
288
+ console.log(' Skipping: screen not installed');
289
+ return;
290
+ }
291
+
292
+ const result = await runInScreen('echo test', {
293
+ session: `test-keepalive-${Date.now()}`,
294
+ detached: true,
295
+ keepAlive: true,
296
+ });
297
+
298
+ assert.strictEqual(result.success, true);
299
+ assert.ok(
300
+ result.message.includes('stay alive'),
301
+ 'Message should indicate keep-alive behavior'
302
+ );
303
+
304
+ // Clean up
305
+ try {
306
+ execSync(`screen -S ${result.sessionName} -X quit`, {
307
+ stdio: 'ignore',
308
+ });
309
+ } catch {
310
+ // Ignore cleanup errors
311
+ }
312
+ });
313
+ });
314
+
315
+ describe('runInTmux keep-alive messages', () => {
316
+ it('should include auto-exit message by default in detached mode', async () => {
317
+ if (!isCommandAvailable('tmux')) {
318
+ console.log(' Skipping: tmux not installed');
319
+ return;
320
+ }
321
+
322
+ const result = await runInTmux('echo test', {
323
+ session: `test-autoexit-${Date.now()}`,
324
+ detached: true,
325
+ keepAlive: false,
326
+ });
327
+
328
+ assert.strictEqual(result.success, true);
329
+ assert.ok(
330
+ result.message.includes('exit automatically'),
331
+ 'Message should indicate auto-exit behavior'
332
+ );
333
+
334
+ // Clean up
335
+ try {
336
+ execSync(`tmux kill-session -t ${result.sessionName}`, {
337
+ stdio: 'ignore',
338
+ });
339
+ } catch {
340
+ // Session may have already exited
341
+ }
342
+ });
343
+
344
+ it('should include keep-alive message when keepAlive is true', async () => {
345
+ if (!isCommandAvailable('tmux')) {
346
+ console.log(' Skipping: tmux not installed');
347
+ return;
348
+ }
349
+
350
+ const result = await runInTmux('echo test', {
351
+ session: `test-keepalive-${Date.now()}`,
352
+ detached: true,
353
+ keepAlive: true,
354
+ });
355
+
356
+ assert.strictEqual(result.success, true);
357
+ assert.ok(
358
+ result.message.includes('stay alive'),
359
+ 'Message should indicate keep-alive behavior'
360
+ );
361
+
362
+ // Clean up
363
+ try {
364
+ execSync(`tmux kill-session -t ${result.sessionName}`, {
365
+ stdio: 'ignore',
366
+ });
367
+ } catch {
368
+ // Ignore cleanup errors
369
+ }
370
+ });
371
+ });
372
+
373
+ describe('runInDocker keep-alive messages', () => {
374
+ // Helper function to check if docker daemon is running
375
+ function isDockerRunning() {
376
+ if (!isCommandAvailable('docker')) {
377
+ return false;
378
+ }
379
+ try {
380
+ // Try to ping the docker daemon
381
+ execSync('docker info', { stdio: 'ignore', timeout: 5000 });
382
+ return true;
383
+ } catch {
384
+ return false;
385
+ }
386
+ }
387
+
388
+ it('should include auto-exit message by default in detached mode', async () => {
389
+ if (!isDockerRunning()) {
390
+ console.log(' Skipping: docker not available or daemon not running');
391
+ return;
392
+ }
393
+
394
+ const containerName = `test-autoexit-${Date.now()}`;
395
+ const result = await runInDocker('echo test', {
396
+ image: 'alpine:latest',
397
+ session: containerName,
398
+ detached: true,
399
+ keepAlive: false,
400
+ });
401
+
402
+ assert.strictEqual(result.success, true);
403
+ assert.ok(
404
+ result.message.includes('exit automatically'),
405
+ 'Message should indicate auto-exit behavior'
406
+ );
407
+
408
+ // Clean up
409
+ try {
410
+ execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
411
+ } catch {
412
+ // Container may have already been removed
413
+ }
414
+ });
415
+
416
+ it('should include keep-alive message when keepAlive is true', async () => {
417
+ if (!isDockerRunning()) {
418
+ console.log(' Skipping: docker not available or daemon not running');
419
+ return;
420
+ }
421
+
422
+ const containerName = `test-keepalive-${Date.now()}`;
423
+ const result = await runInDocker('echo test', {
424
+ image: 'alpine:latest',
425
+ session: containerName,
426
+ detached: true,
427
+ keepAlive: true,
428
+ });
429
+
430
+ assert.strictEqual(result.success, true);
431
+ assert.ok(
432
+ result.message.includes('stay alive'),
433
+ 'Message should indicate keep-alive behavior'
434
+ );
435
+
436
+ // Clean up
437
+ try {
438
+ execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
439
+ } catch {
440
+ // Ignore cleanup errors
441
+ }
442
+ });
443
+ });
444
+ });
445
+
246
446
  describe('Isolation Runner with Available Backends', () => {
247
447
  // Integration-style tests that run if backends are available
248
448
  // These test actual execution in detached mode (quick and non-blocking)