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 +21 -0
- package/package.json +1 -1
- package/src/bin/cli.js +7 -2
- package/src/lib/isolation.js +10 -9
- package/src/lib/output-blocks.js +20 -6
- package/test/echo-integration.test.js +767 -0
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
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
|
-
|
|
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
|
|
package/src/lib/isolation.js
CHANGED
|
@@ -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
|
package/src/lib/output-blocks.js
CHANGED
|
@@ -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
|
-
|
|
244
|
-
lines.push(createBorderedLine(`
|
|
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('');
|