start-command 0.17.0 → 0.17.2

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 CHANGED
@@ -1,5 +1,26 @@
1
1
  # start-command
2
2
 
3
+ ## 0.17.2
4
+
5
+ ### Patch Changes
6
+
7
+ - d38a67f: fix: Use 'close' event instead of 'exit' for reliable stdout capture on macOS
8
+
9
+ The 'exit' event fires when the process terminates, but stdio streams may still have buffered data. On macOS, fast-executing commands like 'echo hi' could exit before stdout data events fired, causing no output to be displayed and no finish block shown.
10
+ - Changed from 'exit' to 'close' event in JavaScript for reliable output capture
11
+ - Updated Rust to use piped stdout/stderr with threads for real-time display and capture
12
+ - Added case study documentation for Issue #57 root cause analysis
13
+
14
+ ## 0.17.1
15
+
16
+ ### Patch Changes
17
+
18
+ - 82a5297: fix: Improve output uniformity and ensure echo hi works in all modes
19
+ - Fixed truncation of log paths, session IDs, and result messages in output blocks
20
+ - Added consistent empty line formatting before/after command output
21
+ - Ensured proper output display in screen isolation mode
22
+ - Added integration tests for echo command across all isolation modes
23
+
3
24
  ## 0.17.0
4
25
 
5
26
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.17.0",
3
+ "version": "0.17.2",
4
4
  "description": "Gamification of coding, execute any command with ability to auto-report issues on GitHub",
5
5
  "main": "src/bin/cli.js",
6
6
  "exports": {
package/src/bin/cli.js CHANGED
@@ -511,6 +511,8 @@ async function runWithIsolation(
511
511
  }
512
512
 
513
513
  // Print finish block with result message inside
514
+ // Add empty line before finish block for visual separation
515
+ console.log('');
514
516
  const durationMs = Date.now() - startTimeMs;
515
517
  console.log(
516
518
  createFinishBlock({
@@ -638,8 +640,11 @@ function runDirect(cmd, sessionId) {
638
640
  logContent += text;
639
641
  });
640
642
 
641
- // Handle process exit
642
- child.on('exit', (code) => {
643
+ // Handle process close (not 'exit' - we need to wait for all stdio to be closed)
644
+ // The 'close' event fires after all stdio streams have been closed, ensuring
645
+ // all stdout/stderr data has been received. The 'exit' event can fire before
646
+ // buffered data is received, causing output loss on macOS (Issue #57).
647
+ child.on('close', (code) => {
643
648
  const exitCode = code || 0;
644
649
  const endTime = getTimestamp();
645
650
 
@@ -1,11 +1,4 @@
1
- /**
2
- * Isolation Runners for start-command
3
- *
4
- * Provides execution of commands in various isolated environments:
5
- * - screen: GNU Screen terminal multiplexer
6
- * - tmux: tmux terminal multiplexer
7
- * - docker: Docker containers
8
- */
1
+ /** Isolation Runners for start-command (screen, tmux, docker, ssh) */
9
2
 
10
3
  const { execSync, spawn, spawnSync } = require('child_process');
11
4
  const fs = require('fs');
@@ -237,9 +230,13 @@ function runScreenWithLogCapture(command, sessionName, shellInfo, user = null) {
237
230
  let output = '';
238
231
  try {
239
232
  output = fs.readFileSync(logFile, 'utf8');
240
- // Display the output
233
+ // Display the output with surrounding empty lines for consistency
241
234
  if (output.trim()) {
242
235
  process.stdout.write(output);
236
+ // Add trailing newline if output doesn't end with one
237
+ if (!output.endsWith('\n')) {
238
+ process.stdout.write('\n');
239
+ }
243
240
  }
244
241
  } catch {
245
242
  // Log file might not exist if command was very quick
@@ -281,6 +278,10 @@ function runScreenWithLogCapture(command, sessionName, shellInfo, user = null) {
281
278
  output = fs.readFileSync(logFile, 'utf8');
282
279
  if (output.trim()) {
283
280
  process.stdout.write(output);
281
+ // Add trailing newline if output doesn't end with one
282
+ if (!output.endsWith('\n')) {
283
+ process.stdout.write('\n');
284
+ }
284
285
  }
285
286
  } catch {
286
287
  // Ignore
@@ -85,10 +85,15 @@ function createHorizontalLine(width, style) {
85
85
  * Pad or truncate text to fit a specific width
86
86
  * @param {string} text - Text to pad
87
87
  * @param {number} width - Target width
88
+ * @param {boolean} [allowOverflow=false] - If true, don't truncate long text
88
89
  * @returns {string} Padded text
89
90
  */
90
- function padText(text, width) {
91
+ function padText(text, width, allowOverflow = false) {
91
92
  if (text.length >= width) {
93
+ // If overflow is allowed, return text as-is (for copyable content like paths)
94
+ if (allowOverflow) {
95
+ return text;
96
+ }
92
97
  return text.substring(0, width);
93
98
  }
94
99
  return text + ' '.repeat(width - text.length);
@@ -99,12 +104,17 @@ function padText(text, width) {
99
104
  * @param {string} text - Text content
100
105
  * @param {number} width - Total width (including borders)
101
106
  * @param {object} style - Box style
107
+ * @param {boolean} [allowOverflow=false] - If true, allow text to overflow (for copyable content)
102
108
  * @returns {string} Bordered line
103
109
  */
104
- function createBorderedLine(text, width, style) {
110
+ function createBorderedLine(text, width, style, allowOverflow = false) {
105
111
  if (style.vertical) {
106
112
  const innerWidth = width - 4; // 2 for borders, 2 for padding
107
- const paddedText = padText(text, innerWidth);
113
+ const paddedText = padText(text, innerWidth, allowOverflow);
114
+ // If text overflows, extend the right border position
115
+ if (allowOverflow && text.length > innerWidth) {
116
+ return `${style.vertical} ${paddedText} ${style.vertical}`;
117
+ }
108
118
  return `${style.vertical} ${paddedText} ${style.vertical}`;
109
119
  }
110
120
  return text;
@@ -234,14 +244,18 @@ function createFinishBlock(options) {
234
244
  lines.push(createTopBorder(width, style));
235
245
 
236
246
  // Add result message first if provided (e.g., "Docker container exited...")
247
+ // Allow overflow so the full message is visible and copyable
237
248
  if (resultMessage) {
238
- lines.push(createBorderedLine(resultMessage, width, style));
249
+ lines.push(createBorderedLine(resultMessage, width, style, true));
239
250
  }
240
251
 
241
252
  lines.push(createBorderedLine(finishedMsg, width, style));
242
253
  lines.push(createBorderedLine(`Exit code: ${exitCode}`, width, style));
243
- lines.push(createBorderedLine(`Log: ${logPath}`, width, style));
244
- lines.push(createBorderedLine(`Session ID: ${sessionId}`, width, style));
254
+ // Allow overflow for log path and session ID so they can be copied completely
255
+ lines.push(createBorderedLine(`Log: ${logPath}`, width, style, true));
256
+ lines.push(
257
+ createBorderedLine(`Session ID: ${sessionId}`, width, style, true)
258
+ );
245
259
  lines.push(createBottomBorder(width, style));
246
260
 
247
261
  return lines.join('\n');
@@ -0,0 +1,767 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Integration tests for echo command across all isolation modes
4
+ *
5
+ * Issue #55: Ensure `echo "hi"` works reliably in all modes with proper output
6
+ *
7
+ * These tests verify for ALL isolation modes (attached + detached):
8
+ * 1. Command output is captured and displayed
9
+ * 2. Start and finish blocks are properly formatted
10
+ * 3. Empty lines exist before and after command output
11
+ * 4. Log paths and session IDs are not truncated (fully copyable)
12
+ *
13
+ * Test coverage:
14
+ * - No isolation mode (direct execution)
15
+ * - Screen isolation: attached + detached
16
+ * - Tmux isolation: attached + detached
17
+ * - Docker isolation: attached + detached
18
+ */
19
+
20
+ const { describe, it } = require('node:test');
21
+ const assert = require('assert');
22
+ const { execSync } = require('child_process');
23
+ const path = require('path');
24
+ const {
25
+ isCommandAvailable,
26
+ canRunLinuxDockerImages,
27
+ } = require('../src/lib/isolation');
28
+
29
+ // Path to the CLI
30
+ const CLI_PATH = path.join(__dirname, '..', 'src', 'bin', 'cli.js');
31
+
32
+ // Helper function to run the CLI and capture output
33
+ function runCli(args, options = {}) {
34
+ const timeout = options.timeout || 30000;
35
+ try {
36
+ const result = execSync(`bun run ${CLI_PATH} ${args}`, {
37
+ encoding: 'utf8',
38
+ timeout,
39
+ env: {
40
+ ...process.env,
41
+ START_DISABLE_AUTO_ISSUE: '1',
42
+ START_DISABLE_TRACKING: '1',
43
+ },
44
+ maxBuffer: 1024 * 1024, // 1MB
45
+ });
46
+ return { success: true, output: result };
47
+ } catch (err) {
48
+ return {
49
+ success: false,
50
+ output: err.stdout || '',
51
+ stderr: err.stderr || '',
52
+ exitCode: err.status,
53
+ };
54
+ }
55
+ }
56
+
57
+ // Verify output contains expected structure for attached modes (shows finish block)
58
+ function verifyAttachedModeOutput(output, expectedOutputText = 'hi') {
59
+ // Should contain start block
60
+ assert.ok(
61
+ output.includes('╭'),
62
+ 'Output should contain start block top border'
63
+ );
64
+ assert.ok(output.includes('╰'), 'Output should contain block bottom border');
65
+ assert.ok(output.includes('Session ID:'), 'Output should contain Session ID');
66
+ assert.ok(
67
+ output.includes('Starting at'),
68
+ 'Output should contain Starting at timestamp'
69
+ );
70
+
71
+ // Should contain command output
72
+ assert.ok(
73
+ output.includes(expectedOutputText),
74
+ `Output should contain the "${expectedOutputText}" command output`
75
+ );
76
+
77
+ // Should contain finish block (for attached modes)
78
+ assert.ok(
79
+ output.includes('Finished at'),
80
+ 'Output should contain Finished at timestamp'
81
+ );
82
+ assert.ok(output.includes('Exit code:'), 'Output should contain Exit code');
83
+ assert.ok(output.includes('Log:'), 'Output should contain Log path');
84
+
85
+ // Verify there are empty lines around output (structure check)
86
+ const lines = output.split('\n');
87
+ const outputIndex = lines.findIndex((l) => l.trim() === expectedOutputText);
88
+
89
+ if (outputIndex > 0) {
90
+ // Check for empty line before output
91
+ const lineBefore = lines[outputIndex - 1];
92
+ // Line before should be empty or end of start block
93
+ assert.ok(
94
+ lineBefore.trim() === '' || lineBefore.includes('╰'),
95
+ `Expected empty line or block end before output, got: "${lineBefore}"`
96
+ );
97
+ }
98
+
99
+ if (outputIndex >= 0 && outputIndex < lines.length - 1) {
100
+ // Check for empty line after output
101
+ const lineAfter = lines[outputIndex + 1];
102
+ // Line after should be empty or start of finish block
103
+ assert.ok(
104
+ lineAfter.trim() === '' || lineAfter.includes('╭'),
105
+ `Expected empty line or block start after output, got: "${lineAfter}"`
106
+ );
107
+ }
108
+ }
109
+
110
+ // Verify output for detached modes (only start block, no finish block)
111
+ function verifyDetachedModeOutput(output) {
112
+ // Should contain start block
113
+ assert.ok(
114
+ output.includes('╭'),
115
+ 'Output should contain start block top border'
116
+ );
117
+ assert.ok(output.includes('╰'), 'Output should contain block bottom border');
118
+ assert.ok(output.includes('Session ID:'), 'Output should contain Session ID');
119
+ assert.ok(
120
+ output.includes('Starting at'),
121
+ 'Output should contain Starting at timestamp'
122
+ );
123
+
124
+ // Should show detached mode info
125
+ assert.ok(
126
+ output.includes('Mode: detached') || output.includes('Reattach with'),
127
+ 'Output should indicate detached mode or show reattach instructions'
128
+ );
129
+ }
130
+
131
+ // Verify log path is not truncated
132
+ function verifyLogPathNotTruncated(output) {
133
+ const logMatch = output.match(/Log: (.+)/);
134
+ assert.ok(logMatch, 'Should have Log line');
135
+ const logPath = logMatch[1].trim();
136
+ // Remove trailing box border character if present
137
+ const cleanPath = logPath.replace(/\s*│\s*$/, '').trim();
138
+
139
+ // Log path should end with .log extension
140
+ assert.ok(
141
+ cleanPath.endsWith('.log'),
142
+ `Log path should end with .log extension, got: "${cleanPath}"`
143
+ );
144
+ }
145
+
146
+ // Verify session ID is a valid UUID
147
+ function verifySessionId(output) {
148
+ const sessionMatches = output.match(/Session ID: ([a-f0-9-]+)/g);
149
+ assert.ok(
150
+ sessionMatches && sessionMatches.length >= 1,
151
+ 'Should have Session ID'
152
+ );
153
+
154
+ // Extract UUID from first match
155
+ const uuidMatch = sessionMatches[0].match(
156
+ /([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/
157
+ );
158
+ assert.ok(uuidMatch, 'Session ID should be a valid UUID format');
159
+ }
160
+
161
+ describe('Echo Integration Tests - Issue #55', () => {
162
+ // ============================================
163
+ // NO ISOLATION MODE (Direct Execution)
164
+ // ============================================
165
+ describe('No Isolation Mode (Direct Execution)', () => {
166
+ it('should execute echo hi and show output with proper formatting', () => {
167
+ const result = runCli('echo hi');
168
+
169
+ assert.ok(result.success, 'Command should succeed');
170
+ verifyAttachedModeOutput(result.output);
171
+ verifyLogPathNotTruncated(result.output);
172
+ verifySessionId(result.output);
173
+
174
+ console.log(' ✓ No isolation mode: echo hi works correctly');
175
+ });
176
+
177
+ it('should execute echo with single quotes', () => {
178
+ const result = runCli("'echo hi'");
179
+
180
+ assert.ok(result.success, 'Command should succeed');
181
+ assert.ok(result.output.includes('hi'), 'Output should contain "hi"');
182
+
183
+ console.log(' ✓ No isolation mode: echo with single quotes works');
184
+ });
185
+
186
+ it('should execute echo with double quotes', () => {
187
+ const result = runCli('\'echo "hi"\'');
188
+
189
+ assert.ok(result.success, 'Command should succeed');
190
+ assert.ok(result.output.includes('hi'), 'Output should contain "hi"');
191
+
192
+ console.log(' ✓ No isolation mode: echo with double quotes works');
193
+ });
194
+
195
+ it('should have consistent empty line formatting', () => {
196
+ const result = runCli('echo hi');
197
+
198
+ assert.ok(result.success, 'Command should succeed');
199
+
200
+ // The pattern should be:
201
+ // [start block]
202
+ // [empty line]
203
+ // hi
204
+ // [empty line]
205
+ // [finish block]
206
+
207
+ const lines = result.output.split('\n');
208
+ let foundHi = false;
209
+ let hiIndex = -1;
210
+
211
+ for (let i = 0; i < lines.length; i++) {
212
+ if (lines[i].trim() === 'hi') {
213
+ foundHi = true;
214
+ hiIndex = i;
215
+ break;
216
+ }
217
+ }
218
+
219
+ assert.ok(foundHi, 'Should find "hi" output on its own line');
220
+
221
+ // Check line before hi
222
+ if (hiIndex > 0) {
223
+ const prevLine = lines[hiIndex - 1].trim();
224
+ assert.ok(
225
+ prevLine === '' || prevLine.startsWith('╰'),
226
+ `Line before "hi" should be empty or end of start block`
227
+ );
228
+ }
229
+
230
+ // Check line after hi
231
+ if (hiIndex < lines.length - 1) {
232
+ const nextLine = lines[hiIndex + 1].trim();
233
+ assert.ok(
234
+ nextLine === '' || nextLine.startsWith('╭'),
235
+ `Line after "hi" should be empty or start of finish block`
236
+ );
237
+ }
238
+
239
+ console.log(' ✓ Empty line formatting is consistent');
240
+ });
241
+ });
242
+
243
+ // ============================================
244
+ // SCREEN ISOLATION MODE (Attached + Detached)
245
+ // ============================================
246
+ describe('Screen Isolation Mode', () => {
247
+ const screenAvailable = isCommandAvailable('screen');
248
+
249
+ if (!screenAvailable) {
250
+ it('should skip screen tests when screen is not installed', () => {
251
+ console.log(' ⚠ screen not installed, skipping screen tests');
252
+ assert.ok(true);
253
+ });
254
+ return;
255
+ }
256
+
257
+ describe('Attached Mode', () => {
258
+ it('should execute echo hi in attached screen mode with proper formatting', () => {
259
+ const result = runCli('--isolated screen -- echo hi', {
260
+ timeout: 30000,
261
+ });
262
+
263
+ assert.ok(
264
+ result.success,
265
+ `Command should succeed. Output: ${result.output || result.stderr}`
266
+ );
267
+ verifyAttachedModeOutput(result.output);
268
+ verifyLogPathNotTruncated(result.output);
269
+ verifySessionId(result.output);
270
+
271
+ // Should show isolation info
272
+ assert.ok(
273
+ result.output.includes('[Isolation] Environment: screen'),
274
+ 'Should show screen isolation info'
275
+ );
276
+ assert.ok(
277
+ result.output.includes('Mode: attached'),
278
+ 'Should show attached mode'
279
+ );
280
+
281
+ console.log(' ✓ Screen isolation (attached): echo hi works correctly');
282
+ });
283
+
284
+ it('should execute echo with quotes in attached screen mode', () => {
285
+ const result = runCli('--isolated screen -- echo "hello world"', {
286
+ timeout: 30000,
287
+ });
288
+
289
+ assert.ok(
290
+ result.success,
291
+ `Command should succeed. Output: ${result.output || result.stderr}`
292
+ );
293
+ assert.ok(
294
+ result.output.includes('hello world'),
295
+ 'Output should contain "hello world"'
296
+ );
297
+
298
+ console.log(' ✓ Screen isolation (attached): echo with quotes works');
299
+ });
300
+
301
+ it('should show exit code and finish block in attached screen mode', () => {
302
+ const result = runCli('--isolated screen -- echo hi', {
303
+ timeout: 30000,
304
+ });
305
+
306
+ assert.ok(result.success, 'Command should succeed');
307
+ assert.ok(
308
+ result.output.includes('Exit code: 0'),
309
+ 'Should show exit code 0'
310
+ );
311
+ assert.ok(
312
+ result.output.includes('exited with code 0') ||
313
+ result.output.includes('Finished at'),
314
+ 'Should show completion info'
315
+ );
316
+
317
+ console.log(
318
+ ' ✓ Screen isolation (attached): finish block displays correctly'
319
+ );
320
+ });
321
+ });
322
+
323
+ describe('Detached Mode', () => {
324
+ it('should execute echo hi in detached screen mode', () => {
325
+ const sessionName = `test-screen-detached-${Date.now()}`;
326
+ const result = runCli(
327
+ `--isolated screen -d --session ${sessionName} -- echo hi`,
328
+ { timeout: 10000 }
329
+ );
330
+
331
+ // Detached mode should succeed (command starts in background)
332
+ assert.ok(
333
+ result.success,
334
+ `Command should succeed. Output: ${result.output || result.stderr}`
335
+ );
336
+ verifyDetachedModeOutput(result.output);
337
+ verifySessionId(result.output);
338
+
339
+ // Should show screen isolation info with detached mode
340
+ assert.ok(
341
+ result.output.includes('[Isolation] Environment: screen'),
342
+ 'Should show screen isolation info'
343
+ );
344
+ assert.ok(
345
+ result.output.includes('Mode: detached'),
346
+ 'Should show detached mode'
347
+ );
348
+
349
+ // Cleanup: kill the screen session
350
+ try {
351
+ execSync(`screen -S ${sessionName} -X quit`, { stdio: 'ignore' });
352
+ } catch {
353
+ // Session may have already exited
354
+ }
355
+
356
+ console.log(
357
+ ' ✓ Screen isolation (detached): echo hi starts correctly'
358
+ );
359
+ });
360
+
361
+ it('should provide reattach instructions in detached screen mode', () => {
362
+ const sessionName = `test-screen-reattach-${Date.now()}`;
363
+ const result = runCli(
364
+ `--isolated screen -d --session ${sessionName} -- echo hi`,
365
+ { timeout: 10000 }
366
+ );
367
+
368
+ assert.ok(result.success, 'Command should succeed');
369
+ assert.ok(
370
+ result.output.includes('Reattach with') ||
371
+ result.output.includes('screen -r'),
372
+ 'Should show reattach instructions'
373
+ );
374
+
375
+ // Cleanup
376
+ try {
377
+ execSync(`screen -S ${sessionName} -X quit`, { stdio: 'ignore' });
378
+ } catch {
379
+ // Ignore
380
+ }
381
+
382
+ console.log(
383
+ ' ✓ Screen isolation (detached): reattach instructions displayed'
384
+ );
385
+ });
386
+ });
387
+ });
388
+
389
+ // ============================================
390
+ // TMUX ISOLATION MODE (Attached + Detached)
391
+ // ============================================
392
+ describe('Tmux Isolation Mode', () => {
393
+ const tmuxAvailable = isCommandAvailable('tmux');
394
+
395
+ if (!tmuxAvailable) {
396
+ it('should skip tmux tests when tmux is not installed', () => {
397
+ console.log(' ⚠ tmux not installed, skipping tmux tests');
398
+ assert.ok(true);
399
+ });
400
+ return;
401
+ }
402
+
403
+ describe('Attached Mode', () => {
404
+ // Note: Attached tmux mode requires a TTY, which is not available in CI
405
+ // We test that it properly handles no-TTY scenario
406
+ it('should handle attached tmux mode (may require TTY)', () => {
407
+ const result = runCli('--isolated tmux -- echo hi', { timeout: 30000 });
408
+
409
+ // Either succeeds or fails due to no TTY - both are valid
410
+ if (result.success) {
411
+ assert.ok(result.output.includes('hi'), 'Output should contain "hi"');
412
+ assert.ok(
413
+ result.output.includes('[Isolation] Environment: tmux'),
414
+ 'Should show tmux isolation info'
415
+ );
416
+ console.log(' ✓ Tmux isolation (attached): echo hi works correctly');
417
+ } else {
418
+ // May fail due to no TTY in CI
419
+ console.log(
420
+ ' ⚠ Tmux isolation (attached): skipped (no TTY available)'
421
+ );
422
+ assert.ok(true);
423
+ }
424
+ });
425
+ });
426
+
427
+ describe('Detached Mode', () => {
428
+ it('should execute echo hi in detached tmux mode', () => {
429
+ const sessionName = `test-tmux-detached-${Date.now()}`;
430
+ const result = runCli(
431
+ `--isolated tmux -d --session ${sessionName} -- echo hi`,
432
+ { timeout: 10000 }
433
+ );
434
+
435
+ // Detached mode should succeed
436
+ assert.ok(
437
+ result.success,
438
+ `Command should succeed. Output: ${result.output || result.stderr}`
439
+ );
440
+ verifyDetachedModeOutput(result.output);
441
+ verifySessionId(result.output);
442
+
443
+ // Should show tmux isolation info
444
+ assert.ok(
445
+ result.output.includes('[Isolation] Environment: tmux'),
446
+ 'Should show tmux isolation info'
447
+ );
448
+ assert.ok(
449
+ result.output.includes('Mode: detached'),
450
+ 'Should show detached mode'
451
+ );
452
+
453
+ // Cleanup: kill the tmux session
454
+ try {
455
+ execSync(`tmux kill-session -t ${sessionName}`, { stdio: 'ignore' });
456
+ } catch {
457
+ // Session may have already exited
458
+ }
459
+
460
+ console.log(' ✓ Tmux isolation (detached): echo hi starts correctly');
461
+ });
462
+
463
+ it('should provide reattach instructions in detached tmux mode', () => {
464
+ const sessionName = `test-tmux-reattach-${Date.now()}`;
465
+ const result = runCli(
466
+ `--isolated tmux -d --session ${sessionName} -- echo hi`,
467
+ { timeout: 10000 }
468
+ );
469
+
470
+ assert.ok(result.success, 'Command should succeed');
471
+ assert.ok(
472
+ result.output.includes('Reattach with') ||
473
+ result.output.includes('tmux attach'),
474
+ 'Should show reattach instructions'
475
+ );
476
+
477
+ // Cleanup
478
+ try {
479
+ execSync(`tmux kill-session -t ${sessionName}`, { stdio: 'ignore' });
480
+ } catch {
481
+ // Ignore
482
+ }
483
+
484
+ console.log(
485
+ ' ✓ Tmux isolation (detached): reattach instructions displayed'
486
+ );
487
+ });
488
+
489
+ it('should execute echo with quotes in detached tmux mode', () => {
490
+ const sessionName = `test-tmux-quotes-${Date.now()}`;
491
+ const result = runCli(
492
+ `--isolated tmux -d --session ${sessionName} -- echo "hello world"`,
493
+ { timeout: 10000 }
494
+ );
495
+
496
+ assert.ok(
497
+ result.success,
498
+ `Command should succeed. Output: ${result.output || result.stderr}`
499
+ );
500
+
501
+ // Cleanup
502
+ try {
503
+ execSync(`tmux kill-session -t ${sessionName}`, { stdio: 'ignore' });
504
+ } catch {
505
+ // Ignore
506
+ }
507
+
508
+ console.log(' ✓ Tmux isolation (detached): echo with quotes works');
509
+ });
510
+ });
511
+ });
512
+
513
+ // ============================================
514
+ // DOCKER ISOLATION MODE (Attached + Detached)
515
+ // ============================================
516
+ describe('Docker Isolation Mode', () => {
517
+ const dockerAvailable = canRunLinuxDockerImages();
518
+
519
+ if (!dockerAvailable) {
520
+ it('should skip docker tests when docker is not available or cannot run Linux containers', () => {
521
+ console.log(
522
+ ' ⚠ docker not available or cannot run Linux containers, skipping docker tests'
523
+ );
524
+ assert.ok(true);
525
+ });
526
+ return;
527
+ }
528
+
529
+ describe('Attached Mode', () => {
530
+ it('should execute echo hi in attached docker mode with proper formatting', () => {
531
+ const containerName = `test-docker-attached-${Date.now()}`;
532
+ const result = runCli(
533
+ `--isolated docker --image alpine:latest --session ${containerName} -- echo hi`,
534
+ { timeout: 60000 }
535
+ );
536
+
537
+ assert.ok(
538
+ result.success,
539
+ `Command should succeed. Output: ${result.output || result.stderr}`
540
+ );
541
+ verifyAttachedModeOutput(result.output);
542
+ verifyLogPathNotTruncated(result.output);
543
+ verifySessionId(result.output);
544
+
545
+ // Should show docker isolation info
546
+ assert.ok(
547
+ result.output.includes('[Isolation] Environment: docker'),
548
+ 'Should show docker isolation info'
549
+ );
550
+ assert.ok(
551
+ result.output.includes('[Isolation] Image: alpine:latest'),
552
+ 'Should show docker image info'
553
+ );
554
+
555
+ console.log(' ✓ Docker isolation (attached): echo hi works correctly');
556
+ });
557
+
558
+ it('should execute echo with quotes in attached docker mode', () => {
559
+ const containerName = `test-docker-quotes-${Date.now()}`;
560
+ const result = runCli(
561
+ `--isolated docker --image alpine:latest --session ${containerName} -- echo "hello world"`,
562
+ { timeout: 60000 }
563
+ );
564
+
565
+ assert.ok(
566
+ result.success,
567
+ `Command should succeed. Output: ${result.output || result.stderr}`
568
+ );
569
+ assert.ok(
570
+ result.output.includes('hello world'),
571
+ 'Output should contain "hello world"'
572
+ );
573
+
574
+ console.log(' ✓ Docker isolation (attached): echo with quotes works');
575
+ });
576
+
577
+ it('should show exit code and finish block in attached docker mode', () => {
578
+ const containerName = `test-docker-finish-${Date.now()}`;
579
+ const result = runCli(
580
+ `--isolated docker --image alpine:latest --session ${containerName} -- echo hi`,
581
+ { timeout: 60000 }
582
+ );
583
+
584
+ assert.ok(result.success, 'Command should succeed');
585
+ assert.ok(
586
+ result.output.includes('Exit code: 0'),
587
+ 'Should show exit code 0'
588
+ );
589
+ assert.ok(
590
+ result.output.includes('exited with code 0') ||
591
+ result.output.includes('Finished at'),
592
+ 'Should show completion info'
593
+ );
594
+
595
+ console.log(
596
+ ' ✓ Docker isolation (attached): finish block displays correctly'
597
+ );
598
+ });
599
+ });
600
+
601
+ describe('Detached Mode', () => {
602
+ it('should execute echo hi in detached docker mode', () => {
603
+ const containerName = `test-docker-detached-${Date.now()}`;
604
+ const result = runCli(
605
+ `--isolated docker -d --image alpine:latest --session ${containerName} -- echo hi`,
606
+ { timeout: 60000 }
607
+ );
608
+
609
+ // Detached mode should succeed
610
+ assert.ok(
611
+ result.success,
612
+ `Command should succeed. Output: ${result.output || result.stderr}`
613
+ );
614
+ verifyDetachedModeOutput(result.output);
615
+ verifySessionId(result.output);
616
+
617
+ // Should show docker isolation info with detached mode
618
+ assert.ok(
619
+ result.output.includes('[Isolation] Environment: docker'),
620
+ 'Should show docker isolation info'
621
+ );
622
+ assert.ok(
623
+ result.output.includes('Mode: detached'),
624
+ 'Should show detached mode'
625
+ );
626
+
627
+ // Cleanup: remove the docker container
628
+ try {
629
+ execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
630
+ } catch {
631
+ // Container may have already exited
632
+ }
633
+
634
+ console.log(
635
+ ' ✓ Docker isolation (detached): echo hi starts correctly'
636
+ );
637
+ });
638
+
639
+ it('should provide reattach instructions in detached docker mode', () => {
640
+ const containerName = `test-docker-reattach-${Date.now()}`;
641
+ const result = runCli(
642
+ `--isolated docker -d --image alpine:latest --session ${containerName} -- echo hi`,
643
+ { timeout: 60000 }
644
+ );
645
+
646
+ assert.ok(result.success, 'Command should succeed');
647
+ assert.ok(
648
+ result.output.includes('Reattach with') ||
649
+ result.output.includes('docker attach') ||
650
+ result.output.includes('docker logs'),
651
+ 'Should show reattach instructions'
652
+ );
653
+
654
+ // Cleanup
655
+ try {
656
+ execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
657
+ } catch {
658
+ // Ignore
659
+ }
660
+
661
+ console.log(
662
+ ' ✓ Docker isolation (detached): reattach instructions displayed'
663
+ );
664
+ });
665
+
666
+ it('should execute echo with quotes in detached docker mode', () => {
667
+ const containerName = `test-docker-quotes-detached-${Date.now()}`;
668
+ const result = runCli(
669
+ `--isolated docker -d --image alpine:latest --session ${containerName} -- echo "hello world"`,
670
+ { timeout: 60000 }
671
+ );
672
+
673
+ assert.ok(
674
+ result.success,
675
+ `Command should succeed. Output: ${result.output || result.stderr}`
676
+ );
677
+
678
+ // Cleanup
679
+ try {
680
+ execSync(`docker rm -f ${containerName}`, { stdio: 'ignore' });
681
+ } catch {
682
+ // Ignore
683
+ }
684
+
685
+ console.log(' ✓ Docker isolation (detached): echo with quotes works');
686
+ });
687
+ });
688
+ });
689
+
690
+ // ============================================
691
+ // OUTPUT BLOCK FORMATTING (Cross-mode tests)
692
+ // ============================================
693
+ describe('Output Block Formatting', () => {
694
+ it('should not truncate long log paths', () => {
695
+ const result = runCli('echo hi');
696
+
697
+ assert.ok(result.success, 'Command should succeed');
698
+
699
+ // Get the log path line
700
+ const logMatch = result.output.match(/Log: (.+)/);
701
+ assert.ok(logMatch, 'Should have Log line');
702
+
703
+ const logLine = logMatch[0];
704
+ // Log line should contain full path ending in .log
705
+ assert.ok(
706
+ logLine.includes('.log'),
707
+ 'Log path should be complete and not truncated'
708
+ );
709
+
710
+ console.log(' ✓ Log paths are not truncated');
711
+ });
712
+
713
+ it('should show full session ID in both start and finish blocks', () => {
714
+ const result = runCli('echo hi');
715
+
716
+ assert.ok(result.success, 'Command should succeed');
717
+
718
+ // Get session IDs from output (should appear twice: start and finish block)
719
+ const sessionMatches = result.output.match(/Session ID: ([a-f0-9-]+)/g);
720
+ assert.ok(
721
+ sessionMatches && sessionMatches.length >= 2,
722
+ 'Should have Session ID in both blocks'
723
+ );
724
+
725
+ // Extract UUID from first match
726
+ const uuidMatch = sessionMatches[0].match(
727
+ /([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})/
728
+ );
729
+ assert.ok(uuidMatch, 'Session ID should be a valid UUID format');
730
+
731
+ console.log(' ✓ Session IDs are complete UUIDs in both blocks');
732
+ });
733
+
734
+ it('should have consistent exit code formatting', () => {
735
+ const result = runCli('echo hi');
736
+
737
+ assert.ok(result.success, 'Command should succeed');
738
+ assert.ok(
739
+ result.output.includes('Exit code: 0'),
740
+ 'Should show "Exit code: 0" for successful command'
741
+ );
742
+
743
+ console.log(' ✓ Exit code formatting is consistent');
744
+ });
745
+
746
+ it('should include timing information in finish block', () => {
747
+ const result = runCli('echo hi');
748
+
749
+ assert.ok(result.success, 'Command should succeed');
750
+ assert.ok(
751
+ result.output.includes('seconds') || result.output.includes('in 0.'),
752
+ 'Should include timing information'
753
+ );
754
+
755
+ console.log(' ✓ Timing information is present in finish block');
756
+ });
757
+ });
758
+ });
759
+
760
+ console.log('=== Echo Integration Tests - Issue #55 ===');
761
+ console.log(
762
+ 'Testing that "echo hi" works correctly across all isolation modes'
763
+ );
764
+ console.log(
765
+ 'Coverage: No isolation, Screen (attached/detached), Tmux (attached/detached), Docker (attached/detached)'
766
+ );
767
+ console.log('');