start-command 0.16.0 → 0.17.1

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,25 @@
1
1
  # start-command
2
2
 
3
+ ## 0.17.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 82a5297: fix: Improve output uniformity and ensure echo hi works in all modes
8
+ - Fixed truncation of log paths, session IDs, and result messages in output blocks
9
+ - Added consistent empty line formatting before/after command output
10
+ - Ensured proper output display in screen isolation mode
11
+ - Added integration tests for echo command across all isolation modes
12
+
13
+ ## 0.17.0
14
+
15
+ ### Minor Changes
16
+
17
+ - 1da275c: feat: Improve output block uniformity and add OS-based Docker image detection
18
+ - Move isolation info lines into start block instead of printing them separately
19
+ - Move exit/result messages into finish block instead of printing them separately
20
+ - Add getDefaultDockerImage() to detect host OS and select matching Docker image
21
+ - Default Docker images: ubuntu, debian, archlinux, fedora, centos based on host OS
22
+
3
23
  ## 0.16.0
4
24
 
5
25
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-command",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
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
@@ -22,6 +22,7 @@ const {
22
22
  createLogFooter,
23
23
  writeLogFile,
24
24
  createLogPath,
25
+ getDefaultDockerImage,
25
26
  } = require('../lib/isolation');
26
27
  const {
27
28
  createIsolatedUser,
@@ -286,6 +287,12 @@ async function runWithIsolation(
286
287
  const startTime = getTimestamp();
287
288
  const startTimeMs = Date.now();
288
289
 
290
+ // Use default Docker image if docker isolation is selected but no image specified
291
+ let effectiveImage = options.image;
292
+ if (environment === 'docker' && !effectiveImage) {
293
+ effectiveImage = getDefaultDockerImage();
294
+ }
295
+
289
296
  // Create log file path
290
297
  const logFilePath = createLogPath(environment || 'direct');
291
298
 
@@ -298,38 +305,8 @@ async function runWithIsolation(
298
305
  const isWindows = process.platform === 'win32';
299
306
  const shell = isWindows ? 'powershell.exe' : process.env.SHELL || '/bin/sh';
300
307
 
301
- // Create execution record for tracking with provided session ID
302
- let executionRecord = null;
303
- const store = getExecutionStore();
304
- if (store) {
305
- executionRecord = new ExecutionRecord({
306
- uuid: sessionId, // Use the provided session ID
307
- command: cmd,
308
- logPath: logFilePath,
309
- shell,
310
- workingDirectory: process.cwd(),
311
- options: {
312
- isolated: environment,
313
- isolationMode: mode,
314
- sessionName,
315
- image: options.image,
316
- endpoint: options.endpoint,
317
- user: options.user,
318
- keepAlive: options.keepAlive,
319
- useCommandStream,
320
- },
321
- });
322
- }
323
-
324
- // Print start block with session ID
325
- console.log(
326
- createStartBlock({
327
- sessionId,
328
- timestamp: startTime,
329
- command: cmd,
330
- })
331
- );
332
- console.log('');
308
+ // Collect isolation info lines for start block
309
+ const extraLines = [];
333
310
 
334
311
  // Handle --isolated-user option: create a new user with same permissions
335
312
  let createdUser = null;
@@ -352,9 +329,9 @@ async function runWithIsolation(
352
329
  currentGroups.includes(g)
353
330
  );
354
331
 
355
- console.log(`[User Isolation] Creating new user with same permissions...`);
332
+ extraLines.push('[User Isolation] Creating new user...');
356
333
  if (importantGroups.length > 0) {
357
- console.log(
334
+ extraLines.push(
358
335
  `[User Isolation] Inheriting groups: ${importantGroups.join(', ')}`
359
336
  );
360
337
  }
@@ -369,37 +346,69 @@ async function runWithIsolation(
369
346
  }
370
347
 
371
348
  createdUser = userResult.username;
372
- console.log(`[User Isolation] Created user: ${createdUser}`);
349
+ extraLines.push(`[User Isolation] Created user: ${createdUser}`);
373
350
  if (userResult.groups && userResult.groups.length > 0) {
374
- console.log(
351
+ extraLines.push(
375
352
  `[User Isolation] User groups: ${userResult.groups.join(', ')}`
376
353
  );
377
354
  }
378
355
  if (options.keepUser) {
379
- console.log(`[User Isolation] User will be kept after command completes`);
356
+ extraLines.push('[User Isolation] User will be kept after completion');
380
357
  }
381
- console.log('');
382
358
  }
383
359
 
384
- // Log isolation info
360
+ // Add isolation info to extra lines
385
361
  if (environment) {
386
- console.log(`[Isolation] Environment: ${environment}, Mode: ${mode}`);
362
+ extraLines.push(`[Isolation] Environment: ${environment}, Mode: ${mode}`);
387
363
  }
388
364
  if (options.session) {
389
- console.log(`[Isolation] Session: ${options.session}`);
365
+ extraLines.push(`[Isolation] Session: ${options.session}`);
390
366
  }
391
- if (options.image) {
392
- console.log(`[Isolation] Image: ${options.image}`);
367
+ if (effectiveImage) {
368
+ extraLines.push(`[Isolation] Image: ${effectiveImage}`);
393
369
  }
394
370
  if (options.endpoint) {
395
- console.log(`[Isolation] Endpoint: ${options.endpoint}`);
371
+ extraLines.push(`[Isolation] Endpoint: ${options.endpoint}`);
396
372
  }
397
373
  if (createdUser) {
398
- console.log(`[Isolation] User: ${createdUser} (isolated)`);
374
+ extraLines.push(`[Isolation] User: ${createdUser} (isolated)`);
399
375
  }
400
376
  if (useCommandStream) {
401
- console.log(`[Isolation] Using command-stream library`);
377
+ extraLines.push('[Isolation] Using command-stream library');
402
378
  }
379
+
380
+ // Create execution record for tracking with provided session ID
381
+ let executionRecord = null;
382
+ const store = getExecutionStore();
383
+ if (store) {
384
+ executionRecord = new ExecutionRecord({
385
+ uuid: sessionId, // Use the provided session ID
386
+ command: cmd,
387
+ logPath: logFilePath,
388
+ shell,
389
+ workingDirectory: process.cwd(),
390
+ options: {
391
+ isolated: environment,
392
+ isolationMode: mode,
393
+ sessionName,
394
+ image: effectiveImage,
395
+ endpoint: options.endpoint,
396
+ user: options.user,
397
+ keepAlive: options.keepAlive,
398
+ useCommandStream,
399
+ },
400
+ });
401
+ }
402
+
403
+ // Print start block with session ID and isolation info
404
+ console.log(
405
+ createStartBlock({
406
+ sessionId,
407
+ timestamp: startTime,
408
+ command: cmd,
409
+ extraLines,
410
+ })
411
+ );
403
412
  console.log('');
404
413
 
405
414
  // Save initial execution record
@@ -421,7 +430,7 @@ async function runWithIsolation(
421
430
  environment: environment || 'direct',
422
431
  mode,
423
432
  sessionName,
424
- image: options.image,
433
+ image: effectiveImage,
425
434
  user: createdUser,
426
435
  startTime,
427
436
  });
@@ -442,7 +451,7 @@ async function runWithIsolation(
442
451
  // Future: Add command-stream support with raw() function for multiplexers
443
452
  result = await runIsolated(environment, cmd, {
444
453
  session: options.session,
445
- image: options.image,
454
+ image: effectiveImage,
446
455
  endpoint: options.endpoint,
447
456
  detached: mode === 'detached',
448
457
  user: createdUser,
@@ -483,30 +492,28 @@ async function runWithIsolation(
483
492
  }
484
493
  }
485
494
 
486
- // Print result
487
- console.log('');
488
- console.log(result.message);
489
-
490
495
  // Cleanup: delete the created user if we created one (unless --keep-user)
496
+ // This output goes to stdout but NOT inside the boxes - it's operational info
491
497
  if (createdUser && !options.keepUser) {
492
- console.log('');
493
498
  console.log(`[User Isolation] Cleaning up user: ${createdUser}`);
494
499
  const deleteResult = deleteUser(createdUser, { removeHome: true });
495
500
  if (deleteResult.success) {
496
- console.log(`[User Isolation] User deleted successfully`);
501
+ console.log('[User Isolation] User deleted successfully');
497
502
  } else {
498
503
  console.log(`[User Isolation] Warning: ${deleteResult.message}`);
499
504
  }
500
- } else if (createdUser && options.keepUser) {
501
505
  console.log('');
506
+ } else if (createdUser && options.keepUser) {
502
507
  console.log(
503
508
  `[User Isolation] Keeping user: ${createdUser} (use 'sudo userdel -r ${createdUser}' to delete)`
504
509
  );
510
+ console.log('');
505
511
  }
506
512
 
507
- // Print finish block
508
- const durationMs = Date.now() - startTimeMs;
513
+ // Print finish block with result message inside
514
+ // Add empty line before finish block for visual separation
509
515
  console.log('');
516
+ const durationMs = Date.now() - startTimeMs;
510
517
  console.log(
511
518
  createFinishBlock({
512
519
  sessionId,
@@ -514,6 +521,7 @@ async function runWithIsolation(
514
521
  exitCode,
515
522
  logPath: logFilePath,
516
523
  durationMs,
524
+ resultMessage: result.message,
517
525
  })
518
526
  );
519
527
 
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Docker utilities for the start-command CLI
3
+ *
4
+ * Provides Docker-related helper functions like detecting the default
5
+ * Docker image based on the host operating system.
6
+ */
7
+
8
+ const fs = require('fs');
9
+
10
+ /**
11
+ * Get the default Docker image based on the host operating system
12
+ * Returns an image that matches the current OS as closely as possible:
13
+ * - macOS: Uses alpine (since macOS cannot run in Docker)
14
+ * - Ubuntu/Debian: Uses ubuntu:latest
15
+ * - Arch Linux: Uses archlinux:latest
16
+ * - Other Linux: Uses the detected distro or alpine as fallback
17
+ * - Windows: Uses alpine (Windows containers have limited support)
18
+ * @returns {string} Docker image name
19
+ */
20
+ function getDefaultDockerImage() {
21
+ const platform = process.platform;
22
+
23
+ if (platform === 'darwin') {
24
+ // macOS cannot run in Docker containers, use alpine as lightweight alternative
25
+ return 'alpine:latest';
26
+ }
27
+
28
+ if (platform === 'win32') {
29
+ // Windows containers have limited support, use alpine for Linux containers
30
+ return 'alpine:latest';
31
+ }
32
+
33
+ if (platform === 'linux') {
34
+ // Try to detect the Linux distribution
35
+ try {
36
+ const osRelease = fs.readFileSync('/etc/os-release', 'utf8');
37
+
38
+ // Check for Ubuntu
39
+ if (
40
+ osRelease.includes('ID=ubuntu') ||
41
+ osRelease.includes('ID_LIKE=ubuntu') ||
42
+ osRelease.includes('ID_LIKE=debian ubuntu')
43
+ ) {
44
+ return 'ubuntu:latest';
45
+ }
46
+
47
+ // Check for Debian
48
+ if (
49
+ osRelease.includes('ID=debian') ||
50
+ osRelease.includes('ID_LIKE=debian')
51
+ ) {
52
+ return 'debian:latest';
53
+ }
54
+
55
+ // Check for Arch Linux
56
+ if (osRelease.includes('ID=arch') || osRelease.includes('ID_LIKE=arch')) {
57
+ return 'archlinux:latest';
58
+ }
59
+
60
+ // Check for Fedora
61
+ if (osRelease.includes('ID=fedora')) {
62
+ return 'fedora:latest';
63
+ }
64
+
65
+ // Check for CentOS/RHEL
66
+ if (
67
+ osRelease.includes('ID=centos') ||
68
+ osRelease.includes('ID=rhel') ||
69
+ osRelease.includes('ID_LIKE=rhel')
70
+ ) {
71
+ return 'centos:latest';
72
+ }
73
+
74
+ // Check for Alpine
75
+ if (osRelease.includes('ID=alpine')) {
76
+ return 'alpine:latest';
77
+ }
78
+ } catch {
79
+ // Cannot read /etc/os-release, fall through to default
80
+ }
81
+ }
82
+
83
+ // Default fallback: use alpine as a lightweight, universal option
84
+ return 'alpine:latest';
85
+ }
86
+
87
+ module.exports = {
88
+ getDefaultDockerImage,
89
+ };
@@ -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
@@ -964,6 +965,9 @@ function canRunLinuxDockerImages() {
964
965
  }
965
966
  }
966
967
 
968
+ // Re-export getDefaultDockerImage from docker-utils for backwards compatibility
969
+ const { getDefaultDockerImage } = require('./docker-utils');
970
+
967
971
  module.exports = {
968
972
  isCommandAvailable,
969
973
  hasTTY,
@@ -985,4 +989,5 @@ module.exports = {
985
989
  supportsLogfileOption,
986
990
  resetScreenVersionCache,
987
991
  canRunLinuxDockerImages,
992
+ getDefaultDockerImage,
988
993
  };
@@ -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;
@@ -144,6 +154,7 @@ function createBottomBorder(width, style) {
144
154
  * @param {string} options.sessionId - Session UUID
145
155
  * @param {string} options.timestamp - Timestamp string
146
156
  * @param {string} options.command - Command being executed
157
+ * @param {string[]} [options.extraLines] - Additional lines to show after the command line
147
158
  * @param {string} [options.style] - Box style name
148
159
  * @param {number} [options.width] - Box width
149
160
  * @returns {string} Formatted start block
@@ -153,6 +164,7 @@ function createStartBlock(options) {
153
164
  sessionId,
154
165
  timestamp,
155
166
  command,
167
+ extraLines = [],
156
168
  style: styleName = DEFAULT_STYLE,
157
169
  width = DEFAULT_WIDTH,
158
170
  } = options;
@@ -165,6 +177,12 @@ function createStartBlock(options) {
165
177
  lines.push(
166
178
  createBorderedLine(`Starting at ${timestamp}: ${command}`, width, style)
167
179
  );
180
+
181
+ // Add extra lines (e.g., isolation info, docker image, etc.)
182
+ for (const line of extraLines) {
183
+ lines.push(createBorderedLine(line, width, style));
184
+ }
185
+
168
186
  lines.push(createBottomBorder(width, style));
169
187
 
170
188
  return lines.join('\n');
@@ -197,6 +215,7 @@ function formatDuration(durationMs) {
197
215
  * @param {number} options.exitCode - Exit code
198
216
  * @param {string} options.logPath - Path to log file
199
217
  * @param {number} [options.durationMs] - Duration in milliseconds
218
+ * @param {string} [options.resultMessage] - Result message (e.g., "Screen session exited...")
200
219
  * @param {string} [options.style] - Box style name
201
220
  * @param {number} [options.width] - Box width
202
221
  * @returns {string} Formatted finish block
@@ -208,6 +227,7 @@ function createFinishBlock(options) {
208
227
  exitCode,
209
228
  logPath,
210
229
  durationMs,
230
+ resultMessage,
211
231
  style: styleName = DEFAULT_STYLE,
212
232
  width = DEFAULT_WIDTH,
213
233
  } = options;
@@ -222,10 +242,20 @@ function createFinishBlock(options) {
222
242
  }
223
243
 
224
244
  lines.push(createTopBorder(width, style));
245
+
246
+ // Add result message first if provided (e.g., "Docker container exited...")
247
+ // Allow overflow so the full message is visible and copyable
248
+ if (resultMessage) {
249
+ lines.push(createBorderedLine(resultMessage, width, style, true));
250
+ }
251
+
225
252
  lines.push(createBorderedLine(finishedMsg, width, style));
226
253
  lines.push(createBorderedLine(`Exit code: ${exitCode}`, width, style));
227
- lines.push(createBorderedLine(`Log: ${logPath}`, width, style));
228
- 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
+ );
229
259
  lines.push(createBottomBorder(width, style));
230
260
 
231
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('');
@@ -13,6 +13,7 @@ const {
13
13
  getScreenVersion,
14
14
  supportsLogfileOption,
15
15
  resetScreenVersionCache,
16
+ getDefaultDockerImage,
16
17
  } = require('../src/lib/isolation');
17
18
 
18
19
  describe('Isolation Module', () => {
@@ -723,3 +724,34 @@ describe('Isolation Runner with Available Backends', () => {
723
724
  });
724
725
  });
725
726
  });
727
+
728
+ describe('Default Docker Image Detection', () => {
729
+ describe('getDefaultDockerImage', () => {
730
+ it('should return a valid Docker image string', () => {
731
+ const image = getDefaultDockerImage();
732
+ assert.ok(image, 'Should return a non-empty string');
733
+ assert.ok(typeof image === 'string', 'Should return a string');
734
+ });
735
+
736
+ it('should return an image with a tag', () => {
737
+ const image = getDefaultDockerImage();
738
+ assert.ok(image.includes(':'), 'Image should have a tag (e.g., :latest)');
739
+ });
740
+
741
+ it('should return a known base image', () => {
742
+ const image = getDefaultDockerImage();
743
+ const knownImages = [
744
+ 'alpine:latest',
745
+ 'ubuntu:latest',
746
+ 'debian:latest',
747
+ 'archlinux:latest',
748
+ 'fedora:latest',
749
+ 'centos:latest',
750
+ ];
751
+ assert.ok(
752
+ knownImages.includes(image),
753
+ `Image '${image}' should be one of the known base images: ${knownImages.join(', ')}`
754
+ );
755
+ });
756
+ });
757
+ });
@@ -77,6 +77,26 @@ describe('output-blocks module', () => {
77
77
  expect(block).toContain('+');
78
78
  expect(block).toContain('-');
79
79
  });
80
+
81
+ it('should include extra lines when provided', () => {
82
+ const block = createStartBlock({
83
+ sessionId: 'test-uuid',
84
+ timestamp: '2025-01-01 00:00:00',
85
+ command: 'echo hello',
86
+ extraLines: [
87
+ '[Isolation] Environment: screen, Mode: attached',
88
+ '[Isolation] Session: my-session',
89
+ ],
90
+ });
91
+
92
+ expect(block).toContain('╭');
93
+ expect(block).toContain('╰');
94
+ expect(block).toContain('Session ID: test-uuid');
95
+ expect(block).toContain(
96
+ '[Isolation] Environment: screen, Mode: attached'
97
+ );
98
+ expect(block).toContain('[Isolation] Session: my-session');
99
+ });
80
100
  });
81
101
 
82
102
  describe('createFinishBlock', () => {
@@ -110,6 +130,26 @@ describe('output-blocks module', () => {
110
130
  expect(block).toContain('Finished at 2025-01-01 00:00:01');
111
131
  expect(block).not.toContain('seconds');
112
132
  });
133
+
134
+ it('should include result message when provided', () => {
135
+ const block = createFinishBlock({
136
+ sessionId: 'test-uuid-1234',
137
+ timestamp: '2025-01-01 00:00:01',
138
+ exitCode: 0,
139
+ logPath: '/tmp/test.log',
140
+ durationMs: 17,
141
+ resultMessage: 'Screen session "my-session" exited with code 0',
142
+ });
143
+
144
+ expect(block).toContain('╭');
145
+ expect(block).toContain('╰');
146
+ expect(block).toContain('Screen session');
147
+ expect(block).toContain('exited with code 0');
148
+ expect(block).toContain('Session ID: test-uuid-1234');
149
+ expect(block).toContain(
150
+ 'Finished at 2025-01-01 00:00:01 in 0.017 seconds'
151
+ );
152
+ });
113
153
  });
114
154
 
115
155
  describe('formatDuration', () => {