start-command 0.24.7 → 0.24.9
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 +40 -0
- package/package.json +1 -1
- package/src/lib/isolation.js +14 -239
- package/src/lib/screen-isolation.js +309 -0
- package/test/echo-integration.test.js +9 -0
- package/test/failure-handler.test.js +103 -0
- package/test/isolation-log-utils.test.js +234 -0
- package/test/isolation.test.js +33 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
# start-command
|
|
2
2
|
|
|
3
|
+
## 0.24.9
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 48515a1: fix: capture output from quick-completing commands in screen isolation (issue #96)
|
|
8
|
+
|
|
9
|
+
When running a short-lived command like `agent --version` through screen isolation:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
$ --isolated screen -- agent --version
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
the version output was silently lost — the command exited cleanly (exit code 0)
|
|
16
|
+
but no output was displayed.
|
|
17
|
+
|
|
18
|
+
**Root cause:** GNU Screen's internal log buffer flushes every 10 seconds by default
|
|
19
|
+
(`log_flush = 10`). For commands that complete faster than this, the buffer may not
|
|
20
|
+
be flushed to the log file before the screen session terminates.
|
|
21
|
+
|
|
22
|
+
**Fix:** A temporary screenrc file with `logfile flush 0` is passed to screen via
|
|
23
|
+
the `-c` option. This forces screen to flush the log buffer after every write,
|
|
24
|
+
eliminating the 10-second flush delay for quick-completing commands.
|
|
25
|
+
|
|
26
|
+
A retry mechanism is also added for the tee fallback path (older screen < 4.5.1)
|
|
27
|
+
to handle the TOCTOU race where the log file appears empty when first read
|
|
28
|
+
immediately after session completion.
|
|
29
|
+
|
|
30
|
+
Both JavaScript (`isolation.js`) and Rust (`isolation.rs`) implementations are fixed
|
|
31
|
+
with equivalent test coverage added.
|
|
32
|
+
|
|
33
|
+
## 0.24.8
|
|
34
|
+
|
|
35
|
+
### Patch Changes
|
|
36
|
+
|
|
37
|
+
- 1195fc1: Add CI/CD coverage enforcement and Rust/JS test parity checks (issue #93)
|
|
38
|
+
- Add `scripts/check-test-parity.mjs` script to enforce Rust/JS test count within 10%
|
|
39
|
+
- Add coverage job to JavaScript CI/CD workflow (80% minimum threshold)
|
|
40
|
+
- Update `ARCHITECTURE.md` to document dual-language sync requirements
|
|
41
|
+
- Update `REQUIREMENTS.md` to document test coverage requirements and parity rules
|
|
42
|
+
|
|
3
43
|
## 0.24.7
|
|
4
44
|
|
|
5
45
|
### Patch Changes
|
package/package.json
CHANGED
package/src/lib/isolation.js
CHANGED
|
@@ -1,94 +1,20 @@
|
|
|
1
1
|
/** Isolation Runners for start-command (screen, tmux, docker, ssh) */
|
|
2
2
|
|
|
3
3
|
const { execSync, spawn, spawnSync } = require('child_process');
|
|
4
|
-
const fs = require('fs');
|
|
5
|
-
const os = require('os');
|
|
6
4
|
const path = require('path');
|
|
7
5
|
const { generateSessionName } = require('./args-parser');
|
|
8
6
|
const outputBlocks = require('./output-blocks');
|
|
9
7
|
|
|
10
|
-
const setTimeout = globalThis.setTimeout;
|
|
11
|
-
|
|
12
8
|
// Debug mode from environment
|
|
13
9
|
const DEBUG =
|
|
14
10
|
process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true';
|
|
15
11
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
* @returns {{major: number, minor: number, patch: number}|null} Version object or null if detection fails
|
|
23
|
-
*/
|
|
24
|
-
function getScreenVersion() {
|
|
25
|
-
if (screenVersionChecked) {
|
|
26
|
-
return cachedScreenVersion;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
screenVersionChecked = true;
|
|
30
|
-
|
|
31
|
-
try {
|
|
32
|
-
const output = execSync('screen --version', {
|
|
33
|
-
encoding: 'utf8',
|
|
34
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
35
|
-
});
|
|
36
|
-
// Match patterns like "4.09.01", "4.00.03", "4.5.1"
|
|
37
|
-
const match = output.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
38
|
-
if (match) {
|
|
39
|
-
cachedScreenVersion = {
|
|
40
|
-
major: parseInt(match[1], 10),
|
|
41
|
-
minor: parseInt(match[2], 10),
|
|
42
|
-
patch: parseInt(match[3], 10),
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
if (DEBUG) {
|
|
46
|
-
console.log(
|
|
47
|
-
`[DEBUG] Detected screen version: ${cachedScreenVersion.major}.${cachedScreenVersion.minor}.${cachedScreenVersion.patch}`
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
return cachedScreenVersion;
|
|
52
|
-
}
|
|
53
|
-
} catch {
|
|
54
|
-
if (DEBUG) {
|
|
55
|
-
console.log('[DEBUG] Could not detect screen version');
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Check if screen supports the -Logfile option
|
|
64
|
-
* The -Logfile option was introduced in GNU Screen 4.5.1
|
|
65
|
-
* @returns {boolean} True if -Logfile is supported
|
|
66
|
-
*/
|
|
67
|
-
function supportsLogfileOption() {
|
|
68
|
-
const version = getScreenVersion();
|
|
69
|
-
if (!version) {
|
|
70
|
-
// If we can't detect version, assume older version and use fallback
|
|
71
|
-
return false;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// -Logfile was added in 4.5.1
|
|
75
|
-
// Compare: version >= 4.5.1
|
|
76
|
-
if (version.major > 4) {
|
|
77
|
-
return true;
|
|
78
|
-
}
|
|
79
|
-
if (version.major < 4) {
|
|
80
|
-
return false;
|
|
81
|
-
}
|
|
82
|
-
// major === 4
|
|
83
|
-
if (version.minor > 5) {
|
|
84
|
-
return true;
|
|
85
|
-
}
|
|
86
|
-
if (version.minor < 5) {
|
|
87
|
-
return false;
|
|
88
|
-
}
|
|
89
|
-
// minor === 5
|
|
90
|
-
return version.patch >= 1;
|
|
91
|
-
}
|
|
12
|
+
const {
|
|
13
|
+
getScreenVersion,
|
|
14
|
+
supportsLogfileOption,
|
|
15
|
+
runScreenWithLogCapture: _runScreenWithLogCapture,
|
|
16
|
+
resetScreenVersionCache,
|
|
17
|
+
} = require('./screen-isolation');
|
|
92
18
|
|
|
93
19
|
/**
|
|
94
20
|
* Check if a command is available on the system
|
|
@@ -245,159 +171,14 @@ function wrapCommandWithUser(command, user) {
|
|
|
245
171
|
* @returns {Promise<{success: boolean, sessionName: string, message: string, output: string}>}
|
|
246
172
|
*/
|
|
247
173
|
function runScreenWithLogCapture(command, sessionName, shellInfo, user = null) {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
let screenArgs;
|
|
257
|
-
// Wrap command with user switch if specified
|
|
258
|
-
let effectiveCommand = wrapCommandWithUser(command, user);
|
|
259
|
-
|
|
260
|
-
if (useNativeLogging) {
|
|
261
|
-
// Modern screen (>= 4.5.1): Use -L -Logfile option for native log capture
|
|
262
|
-
// screen -dmS <session> -L -Logfile <logfile> <shell> -c '<command>'
|
|
263
|
-
const logArgs = ['-dmS', sessionName, '-L', '-Logfile', logFile];
|
|
264
|
-
screenArgs = isInteractiveShellCommand(command)
|
|
265
|
-
? [...logArgs, ...command.trim().split(/\s+/)]
|
|
266
|
-
: [...logArgs, shell, shellArg, effectiveCommand];
|
|
267
|
-
|
|
268
|
-
if (DEBUG) {
|
|
269
|
-
console.log(
|
|
270
|
-
`[DEBUG] Running screen with native log capture (-Logfile): screen ${screenArgs.join(' ')}`
|
|
271
|
-
);
|
|
272
|
-
}
|
|
273
|
-
} else {
|
|
274
|
-
// Older screen (< 4.5.1, e.g., macOS bundled 4.0.3): Use tee fallback
|
|
275
|
-
// The parentheses ensure proper grouping of the command and its stderr
|
|
276
|
-
const isBareShell = isInteractiveShellCommand(command);
|
|
277
|
-
if (!isBareShell) {
|
|
278
|
-
effectiveCommand = `(${effectiveCommand}) 2>&1 | tee "${logFile}"`;
|
|
279
|
-
}
|
|
280
|
-
screenArgs = isBareShell
|
|
281
|
-
? ['-dmS', sessionName, ...command.trim().split(/\s+/)]
|
|
282
|
-
: ['-dmS', sessionName, shell, shellArg, effectiveCommand];
|
|
283
|
-
|
|
284
|
-
if (DEBUG) {
|
|
285
|
-
console.log(
|
|
286
|
-
`[DEBUG] Running screen with tee fallback (older screen version): screen ${screenArgs.join(' ')}`
|
|
287
|
-
);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// Use spawnSync with array args (not execSync string) to avoid quoting issues (issue #25)
|
|
292
|
-
const result = spawnSync('screen', screenArgs, {
|
|
293
|
-
stdio: 'inherit',
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
if (result.error) {
|
|
297
|
-
throw result.error;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Poll for session completion
|
|
301
|
-
const checkInterval = 100; // ms
|
|
302
|
-
const maxWait = 300000; // 5 minutes max
|
|
303
|
-
let waited = 0;
|
|
304
|
-
|
|
305
|
-
const checkCompletion = () => {
|
|
306
|
-
try {
|
|
307
|
-
// Check if session still exists
|
|
308
|
-
const sessions = execSync('screen -ls', {
|
|
309
|
-
encoding: 'utf8',
|
|
310
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
if (!sessions.includes(sessionName)) {
|
|
314
|
-
// Session ended, read output
|
|
315
|
-
let output = '';
|
|
316
|
-
try {
|
|
317
|
-
output = fs.readFileSync(logFile, 'utf8');
|
|
318
|
-
// Display the output with surrounding empty lines for consistency
|
|
319
|
-
if (output.trim()) {
|
|
320
|
-
process.stdout.write(output);
|
|
321
|
-
// Add trailing newline if output doesn't end with one
|
|
322
|
-
if (!output.endsWith('\n')) {
|
|
323
|
-
process.stdout.write('\n');
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
} catch {
|
|
327
|
-
// Log file might not exist if command was very quick
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// Clean up log file
|
|
331
|
-
try {
|
|
332
|
-
fs.unlinkSync(logFile);
|
|
333
|
-
} catch {
|
|
334
|
-
// Ignore cleanup errors
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
resolve({
|
|
338
|
-
success: true,
|
|
339
|
-
sessionName,
|
|
340
|
-
message: `Screen session "${sessionName}" exited with code 0`,
|
|
341
|
-
exitCode: 0,
|
|
342
|
-
output,
|
|
343
|
-
});
|
|
344
|
-
return;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
waited += checkInterval;
|
|
348
|
-
if (waited >= maxWait) {
|
|
349
|
-
resolve({
|
|
350
|
-
success: false,
|
|
351
|
-
sessionName,
|
|
352
|
-
message: `Screen session "${sessionName}" timed out after ${maxWait / 1000} seconds`,
|
|
353
|
-
exitCode: 1,
|
|
354
|
-
});
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
setTimeout(checkCompletion, checkInterval);
|
|
359
|
-
} catch {
|
|
360
|
-
// screen -ls failed, session probably ended
|
|
361
|
-
let output = '';
|
|
362
|
-
try {
|
|
363
|
-
output = fs.readFileSync(logFile, 'utf8');
|
|
364
|
-
if (output.trim()) {
|
|
365
|
-
process.stdout.write(output);
|
|
366
|
-
// Add trailing newline if output doesn't end with one
|
|
367
|
-
if (!output.endsWith('\n')) {
|
|
368
|
-
process.stdout.write('\n');
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
} catch {
|
|
372
|
-
// Ignore
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
try {
|
|
376
|
-
fs.unlinkSync(logFile);
|
|
377
|
-
} catch {
|
|
378
|
-
// Ignore
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
resolve({
|
|
382
|
-
success: true,
|
|
383
|
-
sessionName,
|
|
384
|
-
message: `Screen session "${sessionName}" exited with code 0`,
|
|
385
|
-
exitCode: 0,
|
|
386
|
-
output,
|
|
387
|
-
});
|
|
388
|
-
}
|
|
389
|
-
};
|
|
390
|
-
|
|
391
|
-
// Start checking after a brief delay
|
|
392
|
-
setTimeout(checkCompletion, checkInterval);
|
|
393
|
-
} catch (err) {
|
|
394
|
-
resolve({
|
|
395
|
-
success: false,
|
|
396
|
-
sessionName,
|
|
397
|
-
message: `Failed to run in screen: ${err.message}`,
|
|
398
|
-
});
|
|
399
|
-
}
|
|
400
|
-
});
|
|
174
|
+
return _runScreenWithLogCapture(
|
|
175
|
+
command,
|
|
176
|
+
sessionName,
|
|
177
|
+
shellInfo,
|
|
178
|
+
user,
|
|
179
|
+
wrapCommandWithUser,
|
|
180
|
+
isInteractiveShellCommand
|
|
181
|
+
);
|
|
401
182
|
}
|
|
402
183
|
|
|
403
184
|
/**
|
|
@@ -934,12 +715,6 @@ function runIsolated(backend, command, options = {}) {
|
|
|
934
715
|
}
|
|
935
716
|
}
|
|
936
717
|
|
|
937
|
-
/** Reset screen version cache (useful for testing) */
|
|
938
|
-
function resetScreenVersionCache() {
|
|
939
|
-
cachedScreenVersion = null;
|
|
940
|
-
screenVersionChecked = false;
|
|
941
|
-
}
|
|
942
|
-
|
|
943
718
|
const {
|
|
944
719
|
getTimestamp,
|
|
945
720
|
generateLogFilename,
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/** Screen-specific isolation helpers extracted from isolation.js */
|
|
2
|
+
|
|
3
|
+
const { execSync, spawnSync } = require('child_process');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const setTimeout = globalThis.setTimeout;
|
|
9
|
+
|
|
10
|
+
// Debug mode from environment
|
|
11
|
+
const DEBUG =
|
|
12
|
+
process.env.START_DEBUG === '1' || process.env.START_DEBUG === 'true';
|
|
13
|
+
|
|
14
|
+
// Cache for screen version detection
|
|
15
|
+
let cachedScreenVersion = null;
|
|
16
|
+
let screenVersionChecked = false;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get the installed screen version
|
|
20
|
+
* @returns {{major: number, minor: number, patch: number}|null} Version object or null if detection fails
|
|
21
|
+
*/
|
|
22
|
+
function getScreenVersion() {
|
|
23
|
+
if (screenVersionChecked) {
|
|
24
|
+
return cachedScreenVersion;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
screenVersionChecked = true;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const output = execSync('screen --version', {
|
|
31
|
+
encoding: 'utf8',
|
|
32
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
33
|
+
});
|
|
34
|
+
// Match patterns like "4.09.01", "4.00.03", "4.5.1"
|
|
35
|
+
const match = output.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
36
|
+
if (match) {
|
|
37
|
+
cachedScreenVersion = {
|
|
38
|
+
major: parseInt(match[1], 10),
|
|
39
|
+
minor: parseInt(match[2], 10),
|
|
40
|
+
patch: parseInt(match[3], 10),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
if (DEBUG) {
|
|
44
|
+
console.log(
|
|
45
|
+
`[DEBUG] Detected screen version: ${cachedScreenVersion.major}.${cachedScreenVersion.minor}.${cachedScreenVersion.patch}`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return cachedScreenVersion;
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
if (DEBUG) {
|
|
53
|
+
console.log('[DEBUG] Could not detect screen version');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Check if screen supports the -Logfile option
|
|
62
|
+
* The -Logfile option was introduced in GNU Screen 4.5.1
|
|
63
|
+
* @returns {boolean} True if -Logfile is supported
|
|
64
|
+
*/
|
|
65
|
+
function supportsLogfileOption() {
|
|
66
|
+
const version = getScreenVersion();
|
|
67
|
+
if (!version) {
|
|
68
|
+
// If we can't detect version, assume older version and use fallback
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// -Logfile was added in 4.5.1
|
|
73
|
+
// Compare: version >= 4.5.1
|
|
74
|
+
if (version.major > 4) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
if (version.major < 4) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
// major === 4
|
|
81
|
+
if (version.minor > 5) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
if (version.minor < 5) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
// minor === 5
|
|
88
|
+
return version.patch >= 1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Run command in GNU Screen using detached mode with log capture.
|
|
93
|
+
* Supports screen >= 4.5.1 (native -Logfile) and older versions (tee fallback).
|
|
94
|
+
* @param {string} command - Command to execute
|
|
95
|
+
* @param {string} sessionName - Session name
|
|
96
|
+
* @param {object} shellInfo - Shell info from getShell()
|
|
97
|
+
* @param {string|null} user - Username to run command as (optional)
|
|
98
|
+
* @param {Function} wrapCommandWithUser - Function to wrap command with user
|
|
99
|
+
* @param {Function} isInteractiveShellCommand - Function to check if command is interactive shell
|
|
100
|
+
* @returns {Promise<{success: boolean, sessionName: string, message: string, output: string}>}
|
|
101
|
+
*/
|
|
102
|
+
function runScreenWithLogCapture(
|
|
103
|
+
command,
|
|
104
|
+
sessionName,
|
|
105
|
+
shellInfo,
|
|
106
|
+
user = null,
|
|
107
|
+
wrapCommandWithUser,
|
|
108
|
+
isInteractiveShellCommand
|
|
109
|
+
) {
|
|
110
|
+
const { shell, shellArg } = shellInfo;
|
|
111
|
+
const logFile = path.join(os.tmpdir(), `screen-output-${sessionName}.log`);
|
|
112
|
+
|
|
113
|
+
// Check if screen supports -Logfile option (added in 4.5.1)
|
|
114
|
+
const useNativeLogging = supportsLogfileOption();
|
|
115
|
+
|
|
116
|
+
return new Promise((resolve) => {
|
|
117
|
+
try {
|
|
118
|
+
let screenArgs;
|
|
119
|
+
// Wrap command with user switch if specified
|
|
120
|
+
let effectiveCommand = wrapCommandWithUser(command, user);
|
|
121
|
+
|
|
122
|
+
// Temporary screenrc file for native logging path (issue #96)
|
|
123
|
+
// Setting logfile flush 0 forces screen to flush its log buffer after every write,
|
|
124
|
+
// preventing output loss for quick-completing commands like `agent --version`.
|
|
125
|
+
// Without this, screen buffers log writes and flushes every 10 seconds by default.
|
|
126
|
+
let screenrcFile = null;
|
|
127
|
+
|
|
128
|
+
if (useNativeLogging) {
|
|
129
|
+
// Modern screen (>= 4.5.1): Use -L -Logfile option for native log capture
|
|
130
|
+
// Use a temporary screenrc with `logfile flush 0` to force immediate log flushing
|
|
131
|
+
// (issue #96: quick commands like `agent --version` lose output without this)
|
|
132
|
+
screenrcFile = path.join(os.tmpdir(), `screenrc-${sessionName}`);
|
|
133
|
+
try {
|
|
134
|
+
fs.writeFileSync(screenrcFile, 'logfile flush 0\n');
|
|
135
|
+
} catch {
|
|
136
|
+
// If we can't create the screenrc, proceed without it (best effort)
|
|
137
|
+
screenrcFile = null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// screen -dmS <session> -c <screenrc> -L -Logfile <logfile> <shell> -c '<command>'
|
|
141
|
+
const logArgs = screenrcFile
|
|
142
|
+
? ['-dmS', sessionName, '-c', screenrcFile, '-L', '-Logfile', logFile]
|
|
143
|
+
: ['-dmS', sessionName, '-L', '-Logfile', logFile];
|
|
144
|
+
screenArgs = isInteractiveShellCommand(command)
|
|
145
|
+
? [...logArgs, ...command.trim().split(/\s+/)]
|
|
146
|
+
: [...logArgs, shell, shellArg, effectiveCommand];
|
|
147
|
+
|
|
148
|
+
if (DEBUG) {
|
|
149
|
+
console.log(
|
|
150
|
+
`[DEBUG] Running screen with native log capture (-Logfile): screen ${screenArgs.join(' ')}`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
// Older screen (< 4.5.1, e.g., macOS bundled 4.0.3): Use tee fallback
|
|
155
|
+
// The parentheses ensure proper grouping of the command and its stderr
|
|
156
|
+
const isBareShell = isInteractiveShellCommand(command);
|
|
157
|
+
if (!isBareShell) {
|
|
158
|
+
effectiveCommand = `(${effectiveCommand}) 2>&1 | tee "${logFile}"`;
|
|
159
|
+
}
|
|
160
|
+
screenArgs = isBareShell
|
|
161
|
+
? ['-dmS', sessionName, ...command.trim().split(/\s+/)]
|
|
162
|
+
: ['-dmS', sessionName, shell, shellArg, effectiveCommand];
|
|
163
|
+
|
|
164
|
+
if (DEBUG) {
|
|
165
|
+
console.log(
|
|
166
|
+
`[DEBUG] Running screen with tee fallback (older screen version): screen ${screenArgs.join(' ')}`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Use spawnSync with array args (not execSync string) to avoid quoting issues (issue #25)
|
|
172
|
+
const result = spawnSync('screen', screenArgs, {
|
|
173
|
+
stdio: 'inherit',
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
if (result.error) {
|
|
177
|
+
throw result.error;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Helper to read log file output and write to stdout
|
|
181
|
+
// Includes a short retry for the tee fallback path to handle the TOCTOU race
|
|
182
|
+
// condition where the session appears gone but the log file isn't fully written yet
|
|
183
|
+
// (issue #96)
|
|
184
|
+
const readAndDisplayOutput = (retryCount = 0) => {
|
|
185
|
+
let output = '';
|
|
186
|
+
try {
|
|
187
|
+
output = fs.readFileSync(logFile, 'utf8');
|
|
188
|
+
} catch {
|
|
189
|
+
// Log file might not exist if command produced no output
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// If output is empty and we haven't retried yet, wait briefly and retry once.
|
|
193
|
+
// This handles the race where tee's write hasn't been flushed to disk yet
|
|
194
|
+
// when the screen session appears done in `screen -ls` (issue #96).
|
|
195
|
+
if (!output.trim() && retryCount === 0) {
|
|
196
|
+
return new Promise((resolveRetry) => {
|
|
197
|
+
setTimeout(() => {
|
|
198
|
+
resolveRetry(readAndDisplayOutput(1));
|
|
199
|
+
}, 50);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Display the output
|
|
204
|
+
if (output.trim()) {
|
|
205
|
+
process.stdout.write(output);
|
|
206
|
+
// Add trailing newline if output doesn't end with one
|
|
207
|
+
if (!output.endsWith('\n')) {
|
|
208
|
+
process.stdout.write('\n');
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return Promise.resolve(output);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// Clean up temp files
|
|
215
|
+
const cleanupTempFiles = () => {
|
|
216
|
+
try {
|
|
217
|
+
fs.unlinkSync(logFile);
|
|
218
|
+
} catch {
|
|
219
|
+
// Ignore cleanup errors
|
|
220
|
+
}
|
|
221
|
+
if (screenrcFile) {
|
|
222
|
+
try {
|
|
223
|
+
fs.unlinkSync(screenrcFile);
|
|
224
|
+
} catch {
|
|
225
|
+
// Ignore cleanup errors
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// Poll for session completion
|
|
231
|
+
const checkInterval = 100; // ms
|
|
232
|
+
const maxWait = 300000; // 5 minutes max
|
|
233
|
+
let waited = 0;
|
|
234
|
+
|
|
235
|
+
const checkCompletion = () => {
|
|
236
|
+
try {
|
|
237
|
+
// Check if session still exists
|
|
238
|
+
const sessions = execSync('screen -ls', {
|
|
239
|
+
encoding: 'utf8',
|
|
240
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
if (!sessions.includes(sessionName)) {
|
|
244
|
+
// Session ended, read output (with retry for tee path race condition)
|
|
245
|
+
readAndDisplayOutput().then((output) => {
|
|
246
|
+
cleanupTempFiles();
|
|
247
|
+
resolve({
|
|
248
|
+
success: true,
|
|
249
|
+
sessionName,
|
|
250
|
+
message: `Screen session "${sessionName}" exited with code 0`,
|
|
251
|
+
exitCode: 0,
|
|
252
|
+
output,
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
waited += checkInterval;
|
|
259
|
+
if (waited >= maxWait) {
|
|
260
|
+
cleanupTempFiles();
|
|
261
|
+
resolve({
|
|
262
|
+
success: false,
|
|
263
|
+
sessionName,
|
|
264
|
+
message: `Screen session "${sessionName}" timed out after ${maxWait / 1000} seconds`,
|
|
265
|
+
exitCode: 1,
|
|
266
|
+
});
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
setTimeout(checkCompletion, checkInterval);
|
|
271
|
+
} catch {
|
|
272
|
+
// screen -ls failed, session probably ended
|
|
273
|
+
readAndDisplayOutput().then((output) => {
|
|
274
|
+
cleanupTempFiles();
|
|
275
|
+
resolve({
|
|
276
|
+
success: true,
|
|
277
|
+
sessionName,
|
|
278
|
+
message: `Screen session "${sessionName}" exited with code 0`,
|
|
279
|
+
exitCode: 0,
|
|
280
|
+
output,
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// Start checking after a brief delay
|
|
287
|
+
setTimeout(checkCompletion, checkInterval);
|
|
288
|
+
} catch (err) {
|
|
289
|
+
resolve({
|
|
290
|
+
success: false,
|
|
291
|
+
sessionName,
|
|
292
|
+
message: `Failed to run in screen: ${err.message}`,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Reset screen version cache (useful for testing) */
|
|
299
|
+
function resetScreenVersionCache() {
|
|
300
|
+
cachedScreenVersion = null;
|
|
301
|
+
screenVersionChecked = false;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
module.exports = {
|
|
305
|
+
getScreenVersion,
|
|
306
|
+
supportsLogfileOption,
|
|
307
|
+
runScreenWithLogCapture,
|
|
308
|
+
resetScreenVersionCache,
|
|
309
|
+
};
|
|
@@ -24,6 +24,7 @@ const path = require('path');
|
|
|
24
24
|
const {
|
|
25
25
|
isCommandAvailable,
|
|
26
26
|
canRunLinuxDockerImages,
|
|
27
|
+
hasTTY,
|
|
27
28
|
} = require('../src/lib/isolation');
|
|
28
29
|
|
|
29
30
|
// Path to the CLI
|
|
@@ -537,6 +538,14 @@ describe('Echo Integration Tests - Issue #55', () => {
|
|
|
537
538
|
}
|
|
538
539
|
|
|
539
540
|
describe('Attached Mode', () => {
|
|
541
|
+
if (!hasTTY()) {
|
|
542
|
+
it('should skip attached docker tests when no TTY is available', () => {
|
|
543
|
+
console.log(' ⚠ no TTY available, skipping attached docker tests');
|
|
544
|
+
assert.ok(true);
|
|
545
|
+
});
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
540
549
|
it('should execute echo hi in attached docker mode with proper formatting', () => {
|
|
541
550
|
const containerName = `test-docker-attached-${Date.now()}`;
|
|
542
551
|
const result = runCli(
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Unit tests for failure-handler module
|
|
4
|
+
* Tests pure functions: parseGitUrl and handleFailure early-exit behavior
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { describe, it } = require('node:test');
|
|
8
|
+
const assert = require('assert');
|
|
9
|
+
const { parseGitUrl, handleFailure } = require('../src/lib/failure-handler');
|
|
10
|
+
|
|
11
|
+
describe('failure-handler', () => {
|
|
12
|
+
describe('parseGitUrl', () => {
|
|
13
|
+
it('should parse HTTPS GitHub URL', () => {
|
|
14
|
+
const result = parseGitUrl('https://github.com/owner/my-repo');
|
|
15
|
+
assert.ok(result !== null);
|
|
16
|
+
assert.strictEqual(result.owner, 'owner');
|
|
17
|
+
assert.strictEqual(result.repo, 'my-repo');
|
|
18
|
+
assert.strictEqual(result.url, 'https://github.com/owner/my-repo');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should parse HTTPS URL with .git suffix', () => {
|
|
22
|
+
const result = parseGitUrl('https://github.com/owner/my-repo.git');
|
|
23
|
+
assert.ok(result !== null);
|
|
24
|
+
assert.strictEqual(result.owner, 'owner');
|
|
25
|
+
assert.strictEqual(result.repo, 'my-repo');
|
|
26
|
+
assert.strictEqual(result.url, 'https://github.com/owner/my-repo');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should parse SSH git@ URL', () => {
|
|
30
|
+
const result = parseGitUrl('git@github.com:owner/my-repo.git');
|
|
31
|
+
assert.ok(result !== null);
|
|
32
|
+
assert.strictEqual(result.owner, 'owner');
|
|
33
|
+
assert.strictEqual(result.repo, 'my-repo');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should parse git+https URL format', () => {
|
|
37
|
+
const result = parseGitUrl('git+https://github.com/owner/repo.git');
|
|
38
|
+
assert.ok(result !== null);
|
|
39
|
+
assert.strictEqual(result.owner, 'owner');
|
|
40
|
+
assert.strictEqual(result.repo, 'repo');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should return null for empty string', () => {
|
|
44
|
+
const result = parseGitUrl('');
|
|
45
|
+
assert.strictEqual(result, null);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should return null for null/undefined input', () => {
|
|
49
|
+
assert.strictEqual(parseGitUrl(null), null);
|
|
50
|
+
assert.strictEqual(parseGitUrl(undefined), null);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should return null for non-github URL', () => {
|
|
54
|
+
const result = parseGitUrl('https://gitlab.com/owner/repo');
|
|
55
|
+
assert.strictEqual(result, null);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should return null for invalid/random string', () => {
|
|
59
|
+
const result = parseGitUrl('not-a-url-at-all');
|
|
60
|
+
assert.strictEqual(result, null);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should normalize URL to https://github.com format', () => {
|
|
64
|
+
const result = parseGitUrl('git@github.com:myorg/myrepo');
|
|
65
|
+
assert.ok(result !== null);
|
|
66
|
+
assert.ok(result.url.startsWith('https://github.com/'));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should handle URL with subdirectory (only owner/repo captured)', () => {
|
|
70
|
+
const result = parseGitUrl('https://github.com/myorg/myrepo/issues');
|
|
71
|
+
assert.ok(result !== null);
|
|
72
|
+
assert.strictEqual(result.owner, 'myorg');
|
|
73
|
+
assert.strictEqual(result.repo, 'myrepo');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should return object with owner, repo, url keys', () => {
|
|
77
|
+
const result = parseGitUrl('https://github.com/test/project');
|
|
78
|
+
assert.ok(result !== null);
|
|
79
|
+
assert.ok(Object.prototype.hasOwnProperty.call(result, 'owner'));
|
|
80
|
+
assert.ok(Object.prototype.hasOwnProperty.call(result, 'repo'));
|
|
81
|
+
assert.ok(Object.prototype.hasOwnProperty.call(result, 'url'));
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('handleFailure', () => {
|
|
86
|
+
it('should return early when disableAutoIssue is true', () => {
|
|
87
|
+
// This should not throw and should return without calling external processes
|
|
88
|
+
const config = { disableAutoIssue: true };
|
|
89
|
+
// If it tries to call external tools, it would either throw or hang;
|
|
90
|
+
// returning cleanly means the early-exit path was taken.
|
|
91
|
+
assert.doesNotThrow(() => {
|
|
92
|
+
handleFailure(config, 'someCmd', 'someCmd --flag', 1, '/tmp/fake.log');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should return early when disableAutoIssue is true (verbose mode)', () => {
|
|
97
|
+
const config = { disableAutoIssue: true, verbose: true };
|
|
98
|
+
assert.doesNotThrow(() => {
|
|
99
|
+
handleFailure(config, 'cmd', 'cmd arg', 2, '/tmp/fake.log');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Unit tests for isolation-log-utils module
|
|
4
|
+
* Tests pure utility functions for log file management
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { describe, it } = require('node:test');
|
|
8
|
+
const assert = require('assert');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const {
|
|
11
|
+
getTimestamp,
|
|
12
|
+
generateLogFilename,
|
|
13
|
+
createLogHeader,
|
|
14
|
+
createLogFooter,
|
|
15
|
+
getLogDir,
|
|
16
|
+
createLogPath,
|
|
17
|
+
} = require('../src/lib/isolation-log-utils');
|
|
18
|
+
|
|
19
|
+
describe('isolation-log-utils', () => {
|
|
20
|
+
describe('getTimestamp', () => {
|
|
21
|
+
it('should return a non-empty string', () => {
|
|
22
|
+
const ts = getTimestamp();
|
|
23
|
+
assert.strictEqual(typeof ts, 'string');
|
|
24
|
+
assert.ok(ts.length > 0);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should return a timestamp without T or Z (ISO-like but space-separated)', () => {
|
|
28
|
+
const ts = getTimestamp();
|
|
29
|
+
assert.ok(!ts.includes('T'), 'Should not contain ISO T separator');
|
|
30
|
+
assert.ok(!ts.endsWith('Z'), 'Should not end with Z');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should contain date-like content (numbers and dashes)', () => {
|
|
34
|
+
const ts = getTimestamp();
|
|
35
|
+
// Expect format like "2024-01-15 10:30:45.123"
|
|
36
|
+
assert.match(ts, /\d{4}-\d{2}-\d{2}/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should return different values on successive calls (or same within same ms)', () => {
|
|
40
|
+
const ts1 = getTimestamp();
|
|
41
|
+
assert.strictEqual(typeof ts1, 'string');
|
|
42
|
+
// Just verify it's callable multiple times without error
|
|
43
|
+
const ts2 = getTimestamp();
|
|
44
|
+
assert.strictEqual(typeof ts2, 'string');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('generateLogFilename', () => {
|
|
49
|
+
it('should return a string ending with .log', () => {
|
|
50
|
+
const filename = generateLogFilename('screen');
|
|
51
|
+
assert.ok(filename.endsWith('.log'));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should include the environment name in the filename', () => {
|
|
55
|
+
const filename = generateLogFilename('docker');
|
|
56
|
+
assert.ok(filename.includes('docker'));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should start with "start-command-"', () => {
|
|
60
|
+
const filename = generateLogFilename('tmux');
|
|
61
|
+
assert.ok(filename.startsWith('start-command-'));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should generate unique filenames on successive calls', () => {
|
|
65
|
+
const f1 = generateLogFilename('screen');
|
|
66
|
+
const f2 = generateLogFilename('screen');
|
|
67
|
+
// Due to random component, should be different
|
|
68
|
+
assert.notStrictEqual(f1, f2);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should handle different environment names', () => {
|
|
72
|
+
const environments = ['screen', 'tmux', 'docker', 'user', 'none'];
|
|
73
|
+
for (const env of environments) {
|
|
74
|
+
const filename = generateLogFilename(env);
|
|
75
|
+
assert.ok(
|
|
76
|
+
filename.includes(env),
|
|
77
|
+
`Filename should include environment "${env}"`
|
|
78
|
+
);
|
|
79
|
+
assert.ok(filename.endsWith('.log'));
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('createLogHeader', () => {
|
|
85
|
+
const baseParams = {
|
|
86
|
+
command: 'npm test',
|
|
87
|
+
environment: 'screen',
|
|
88
|
+
mode: 'attached',
|
|
89
|
+
sessionName: 'test-session-123',
|
|
90
|
+
startTime: '2024-01-15 10:30:00.000',
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
it('should return a non-empty string', () => {
|
|
94
|
+
const header = createLogHeader(baseParams);
|
|
95
|
+
assert.strictEqual(typeof header, 'string');
|
|
96
|
+
assert.ok(header.length > 0);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should include the command in the header', () => {
|
|
100
|
+
const header = createLogHeader(baseParams);
|
|
101
|
+
assert.ok(header.includes('npm test'));
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should include the environment in the header', () => {
|
|
105
|
+
const header = createLogHeader(baseParams);
|
|
106
|
+
assert.ok(header.includes('screen'));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should include the session name in the header', () => {
|
|
110
|
+
const header = createLogHeader(baseParams);
|
|
111
|
+
assert.ok(header.includes('test-session-123'));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should include the mode in the header', () => {
|
|
115
|
+
const header = createLogHeader(baseParams);
|
|
116
|
+
assert.ok(header.includes('attached'));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should include image field when provided', () => {
|
|
120
|
+
const params = { ...baseParams, image: 'node:20-alpine' };
|
|
121
|
+
const header = createLogHeader(params);
|
|
122
|
+
assert.ok(header.includes('node:20-alpine'));
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should include user field when provided', () => {
|
|
126
|
+
const params = { ...baseParams, user: 'isolateduser' };
|
|
127
|
+
const header = createLogHeader(params);
|
|
128
|
+
assert.ok(header.includes('isolateduser'));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should NOT include Image line when image is not provided', () => {
|
|
132
|
+
const header = createLogHeader(baseParams);
|
|
133
|
+
assert.ok(!header.includes('Image:'));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should contain separator line', () => {
|
|
137
|
+
const header = createLogHeader(baseParams);
|
|
138
|
+
assert.ok(header.includes('==='));
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('createLogFooter', () => {
|
|
143
|
+
it('should return a non-empty string', () => {
|
|
144
|
+
const footer = createLogFooter('2024-01-15 10:35:00.000', 0);
|
|
145
|
+
assert.strictEqual(typeof footer, 'string');
|
|
146
|
+
assert.ok(footer.length > 0);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should include the exit code', () => {
|
|
150
|
+
const footer = createLogFooter('2024-01-15 10:35:00.000', 42);
|
|
151
|
+
assert.ok(footer.includes('42'));
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should include exit code 0', () => {
|
|
155
|
+
const footer = createLogFooter('2024-01-15 10:35:00.000', 0);
|
|
156
|
+
assert.ok(footer.includes('0'));
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should include the end time', () => {
|
|
160
|
+
const endTime = '2024-01-15 10:35:00.000';
|
|
161
|
+
const footer = createLogFooter(endTime, 1);
|
|
162
|
+
assert.ok(footer.includes(endTime));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should contain separator line', () => {
|
|
166
|
+
const footer = createLogFooter('2024-01-15 10:35:00.000', 0);
|
|
167
|
+
assert.ok(footer.includes('='));
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('getLogDir', () => {
|
|
172
|
+
it('should return a string', () => {
|
|
173
|
+
const dir = getLogDir();
|
|
174
|
+
assert.strictEqual(typeof dir, 'string');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('should return a non-empty path', () => {
|
|
178
|
+
const dir = getLogDir();
|
|
179
|
+
assert.ok(dir.length > 0);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should use START_LOG_DIR env var when set', () => {
|
|
183
|
+
const original = process.env.START_LOG_DIR;
|
|
184
|
+
process.env.START_LOG_DIR = '/tmp/custom-log-dir';
|
|
185
|
+
try {
|
|
186
|
+
const dir = getLogDir();
|
|
187
|
+
assert.strictEqual(dir, '/tmp/custom-log-dir');
|
|
188
|
+
} finally {
|
|
189
|
+
if (original === undefined) {
|
|
190
|
+
delete process.env.START_LOG_DIR;
|
|
191
|
+
} else {
|
|
192
|
+
process.env.START_LOG_DIR = original;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should fall back to os.tmpdir() when START_LOG_DIR is not set', () => {
|
|
198
|
+
const os = require('os');
|
|
199
|
+
const original = process.env.START_LOG_DIR;
|
|
200
|
+
delete process.env.START_LOG_DIR;
|
|
201
|
+
try {
|
|
202
|
+
const dir = getLogDir();
|
|
203
|
+
assert.strictEqual(dir, os.tmpdir());
|
|
204
|
+
} finally {
|
|
205
|
+
if (original !== undefined) {
|
|
206
|
+
process.env.START_LOG_DIR = original;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe('createLogPath', () => {
|
|
213
|
+
it('should return a string ending with .log', () => {
|
|
214
|
+
const logPath = createLogPath('screen');
|
|
215
|
+
assert.ok(logPath.endsWith('.log'));
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('should return an absolute path', () => {
|
|
219
|
+
const logPath = createLogPath('tmux');
|
|
220
|
+
assert.ok(path.isAbsolute(logPath));
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('should include the environment name', () => {
|
|
224
|
+
const logPath = createLogPath('docker');
|
|
225
|
+
assert.ok(logPath.includes('docker'));
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should be under the log directory', () => {
|
|
229
|
+
const logDir = getLogDir();
|
|
230
|
+
const logPath = createLogPath('screen');
|
|
231
|
+
assert.ok(logPath.startsWith(logDir));
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
});
|
package/test/isolation.test.js
CHANGED
|
@@ -678,6 +678,39 @@ describe('Isolation Runner with Available Backends', () => {
|
|
|
678
678
|
);
|
|
679
679
|
console.log(` Verified output capture: "${result.output.trim()}"`);
|
|
680
680
|
});
|
|
681
|
+
|
|
682
|
+
it('should capture output from version-flag commands (issue #96)', async () => {
|
|
683
|
+
if (!isCommandAvailable('screen')) {
|
|
684
|
+
console.log(' Skipping: screen not installed');
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// This test verifies that screen isolation captures output from quick-completing
|
|
689
|
+
// commands like `agent --version` or `node --version`.
|
|
690
|
+
// Issue #96: output was silently lost because screen's internal log buffer was
|
|
691
|
+
// not flushed before the session terminated (default 10s flush interval).
|
|
692
|
+
// Fix: use a temporary screenrc with `logfile flush 0` to force immediate flushing.
|
|
693
|
+
const result = await runInScreen('node --version', {
|
|
694
|
+
session: `test-version-flag-${Date.now()}`,
|
|
695
|
+
detached: false,
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
assert.strictEqual(result.success, true, 'Command should succeed');
|
|
699
|
+
assert.ok(
|
|
700
|
+
result.output !== undefined,
|
|
701
|
+
'Attached mode should always return output property'
|
|
702
|
+
);
|
|
703
|
+
assert.ok(
|
|
704
|
+
result.output.trim().length > 0,
|
|
705
|
+
'Output should not be empty (issue #96: version output was silently lost)'
|
|
706
|
+
);
|
|
707
|
+
// node --version outputs something like "v20.0.0"
|
|
708
|
+
assert.ok(
|
|
709
|
+
result.output.includes('v') || /\d+\.\d+/.test(result.output),
|
|
710
|
+
'Output should contain version string'
|
|
711
|
+
);
|
|
712
|
+
console.log(` Captured version output: "${result.output.trim()}"`);
|
|
713
|
+
});
|
|
681
714
|
});
|
|
682
715
|
|
|
683
716
|
describe('runInTmux (if available)', () => {
|