feat(smartshell): Add secure spawn APIs, PTY support, interactive/streaming control, timeouts and buffer limits; update README and tests

This commit is contained in:
2025-08-17 15:20:26 +00:00
parent f8e431f41e
commit 529b33fda1
10 changed files with 1748 additions and 615 deletions

222
test/test.errorHandling.ts Normal file
View File

@@ -0,0 +1,222 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as smartshell from '../ts/index.js';
tap.test('should handle EPIPE errors gracefully', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
const streaming = await testSmartshell.execStreamingInteractiveControl('head -n 1');
// Send more data after head exits (will cause EPIPE)
await streaming.sendLine('Line 1');
// This should not throw even though head has exited
let errorThrown = false;
try {
await streaming.sendLine('Line 2');
await streaming.sendLine('Line 3');
} catch (error) {
errorThrown = true;
// EPIPE or destroyed stdin is expected
}
const result = await streaming.finalPromise;
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('Line 1');
});
tap.test('should handle strict mode with non-zero exit codes', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
let errorThrown = false;
let errorMessage = '';
try {
await testSmartshell.execStrict('exit 42');
} catch (error) {
errorThrown = true;
errorMessage = error.message;
}
expect(errorThrown).toBeTrue();
expect(errorMessage).toContain('exited with code 42');
});
tap.test('should handle strict mode with signal termination', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
let errorThrown = false;
let errorMessage = '';
try {
// Use execSpawn with strict mode and kill it
const result = testSmartshell.execSpawn('sleep', ['10'], {
strict: true,
timeout: 100 // Will cause SIGTERM
});
await result;
} catch (error) {
errorThrown = true;
errorMessage = error.message;
}
expect(errorThrown).toBeTrue();
expect(errorMessage).toContain('terminated by signal');
});
tap.test('execAndWaitForLine with timeout should reject properly', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
let errorThrown = false;
let errorMessage = '';
try {
await testSmartshell.execAndWaitForLine(
'sleep 5 && echo "Too late"',
/Too late/,
false,
{ timeout: 100 }
);
} catch (error) {
errorThrown = true;
errorMessage = error.message;
}
expect(errorThrown).toBeTrue();
expect(errorMessage).toContain('Timeout waiting for pattern');
});
tap.test('execAndWaitForLine with terminateOnMatch should stop process', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
const start = Date.now();
await testSmartshell.execAndWaitForLine(
'echo "Match this" && sleep 5',
/Match this/,
false,
{ terminateOnMatch: true }
);
const duration = Date.now() - start;
// Should terminate immediately after match, not wait for sleep
expect(duration).toBeLessThan(2000);
});
tap.test('should handle process ending without matching pattern', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
let errorThrown = false;
let errorMessage = '';
try {
await testSmartshell.execAndWaitForLine(
'echo "Wrong text"',
/Never appears/,
false
);
} catch (error) {
errorThrown = true;
errorMessage = error.message;
}
expect(errorThrown).toBeTrue();
expect(errorMessage).toContain('Process ended without matching pattern');
});
tap.test('passthrough unpipe should handle destroyed stdin gracefully', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// This should complete without throwing even if stdin operations fail
const result = await testSmartshell.execPassthrough('echo "Test passthrough unpipe"');
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('Test passthrough unpipe');
});
tap.test('should handle write after stream destroyed', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
const interactive = await testSmartshell.execInteractiveControl('true'); // Exits immediately
// Wait for process to exit
await interactive.finalPromise;
// Try to write after process has exited
let errorThrown = false;
try {
await interactive.sendLine('This should fail');
} catch (error) {
errorThrown = true;
expect(error.message).toContain('destroyed');
}
expect(errorThrown).toBeTrue();
});
tap.test('debug mode should log additional information', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// Capture console.log output
const originalLog = console.log;
let debugOutput = '';
console.log = (msg: string) => {
debugOutput += msg + '\n';
};
try {
const streaming = await testSmartshell.execSpawnStreaming('echo', ['Debug test'], {
debug: true
});
await streaming.terminate();
await streaming.finalPromise;
} finally {
console.log = originalLog;
}
// Should have logged debug messages
expect(debugOutput).toContain('[smartshell]');
});
tap.test('custom environment variables should be passed correctly', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
const result = await testSmartshell.execSpawn('bash', ['-c', 'echo $CUSTOM_VAR'], {
env: {
...process.env,
CUSTOM_VAR: 'test_value_123'
}
});
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('test_value_123');
});
export default tap.start();

View File

@@ -0,0 +1,84 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as smartshell from '../ts/index.js';
tap.test('should handle programmatic input control with simple commands', async (tools) => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// Use cat which works well with pipe mode
const interactive = await testSmartshell.execInteractiveControl('cat');
// Send input programmatically
await interactive.sendLine('TestUser');
interactive.endInput();
// Wait for completion
const result = await interactive.finalPromise;
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('TestUser');
});
tap.test('should handle streaming interactive control with cat', async (tools) => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// Use cat for reliable pipe mode operation
const streaming = await testSmartshell.execStreamingInteractiveControl('cat');
// Send multiple inputs
await streaming.sendLine('One');
await streaming.sendLine('Two');
streaming.endInput();
const result = await streaming.finalPromise;
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('One');
expect(result.stdout).toContain('Two');
});
tap.test('should handle sendInput without newline', async (tools) => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// Use cat for testing input without newline
const interactive = await testSmartshell.execInteractiveControl('cat');
// Send characters without newline, then newline, then EOF
await interactive.sendInput('ABC');
await interactive.sendInput('DEF');
await interactive.sendInput('\n');
interactive.endInput();
const result = await interactive.finalPromise;
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('ABCDEF');
});
tap.test('should mix passthrough and interactive control modes', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// Test that passthrough still works
const passthroughResult = await testSmartshell.execPassthrough('echo "Passthrough works"');
expect(passthroughResult.exitCode).toEqual(0);
expect(passthroughResult.stdout).toContain('Passthrough works');
// Test that interactive control works
const interactiveResult = await testSmartshell.execInteractiveControl('echo "Interactive control works"');
const finalResult = await interactiveResult.finalPromise;
expect(finalResult.exitCode).toEqual(0);
expect(finalResult.stdout).toContain('Interactive control works');
});
// Note: Tests requiring bash read with prompts should use PTY mode
// See test.pty.ts for examples of testing commands that require terminal features
export default tap.start();

146
test/test.pty.ts Normal file
View File

@@ -0,0 +1,146 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as smartshell from '../ts/index.js';
// Helper to check if node-pty is available
const isPtyAvailable = async (): Promise<boolean> => {
try {
await import('node-pty');
return true;
} catch {
return false;
}
};
tap.test('PTY: should handle bash read with prompts correctly', async (tools) => {
const ptyAvailable = await isPtyAvailable();
if (!ptyAvailable) {
console.log('Skipping PTY test - node-pty not installed');
return;
}
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// This test should work with PTY (bash read with prompt)
const interactive = await testSmartshell.execInteractiveControlPty("bash -c 'read -p \"Enter name: \" name && echo \"Hello, $name\"'");
// Send input programmatically
await interactive.sendLine('TestUser');
// Wait for completion
const result = await interactive.finalPromise;
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('Enter name:'); // Prompt should be visible with PTY
expect(result.stdout).toContain('Hello, TestUser');
});
tap.test('PTY: should handle terminal colors and escape sequences', async (tools) => {
const ptyAvailable = await isPtyAvailable();
if (!ptyAvailable) {
console.log('Skipping PTY test - node-pty not installed');
return;
}
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// ls --color=auto should produce colors in PTY mode
const result = await testSmartshell.execInteractiveControlPty('ls --color=always /tmp');
const finalResult = await result.finalPromise;
expect(finalResult.exitCode).toEqual(0);
// Check for ANSI escape sequences (colors) in output
const hasColors = /\x1b\[[0-9;]*m/.test(finalResult.stdout);
expect(hasColors).toEqual(true);
});
tap.test('PTY: should handle interactive password prompt simulation', async (tools) => {
const ptyAvailable = await isPtyAvailable();
if (!ptyAvailable) {
console.log('Skipping PTY test - node-pty not installed');
return;
}
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// Simulate a password prompt scenario
const interactive = await testSmartshell.execStreamingInteractiveControlPty(
"bash -c 'read -s -p \"Password: \" pass && echo && echo \"Got password of length ${#pass}\"'"
);
await tools.delayFor(100);
await interactive.sendLine('secretpass');
const result = await interactive.finalPromise;
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('Password:');
expect(result.stdout).toContain('Got password of length 10');
});
tap.test('PTY: should handle terminal size options', async (tools) => {
const ptyAvailable = await isPtyAvailable();
if (!ptyAvailable) {
console.log('Skipping PTY test - node-pty not installed');
return;
}
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// Check terminal size using stty
const result = await testSmartshell.execInteractiveControlPty('stty size');
const finalResult = await result.finalPromise;
expect(finalResult.exitCode).toEqual(0);
// Default size should be 30 rows x 120 cols as set in _execCommandPty
expect(finalResult.stdout).toContain('30 120');
});
tap.test('PTY: should handle Ctrl+C (SIGINT) properly', async (tools) => {
const ptyAvailable = await isPtyAvailable();
if (!ptyAvailable) {
console.log('Skipping PTY test - node-pty not installed');
return;
}
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// Start a long-running process
const streaming = await testSmartshell.execStreamingInteractiveControlPty('sleep 10');
// Send interrupt after a short delay
await tools.delayFor(100);
await streaming.keyboardInterrupt();
const result = await streaming.finalPromise;
// Process should exit with non-zero code due to interrupt
expect(result.exitCode).not.toEqual(0);
});
tap.test('Regular pipe mode should still work alongside PTY', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// Regular mode should work without PTY
const interactive = await testSmartshell.execInteractiveControl('echo "Pipe mode works"');
const result = await interactive.finalPromise;
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('Pipe mode works');
});
export default tap.start();

View File

@@ -0,0 +1,54 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as smartshell from '../ts/index.js';
tap.test('should send input to cat command', async (tools) => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// Use cat which simply echoes what it receives
const interactive = await testSmartshell.execInteractiveControl('cat');
// Send some text and close stdin
await interactive.sendLine('Hello World');
interactive.endInput(); // Close stdin properly
const result = await interactive.finalPromise;
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('Hello World');
});
tap.test('should work with simple echo', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// This should work without any input
const interactive = await testSmartshell.execInteractiveControl('echo "Direct test"');
const result = await interactive.finalPromise;
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('Direct test');
});
tap.test('should handle streaming with input control', async (tools) => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// Test with streaming and cat
const streaming = await testSmartshell.execStreamingInteractiveControl('cat');
await streaming.sendLine('Line 1');
await streaming.sendLine('Line 2');
streaming.endInput(); // Close stdin
const result = await streaming.finalPromise;
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('Line 1');
expect(result.stdout).toContain('Line 2');
});
export default tap.start();

150
test/test.spawn.ts Normal file
View File

@@ -0,0 +1,150 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as smartshell from '../ts/index.js';
tap.test('execSpawn should execute commands with args array (shell:false)', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// Test basic command with args
const result = await testSmartshell.execSpawn('echo', ['Hello', 'World']);
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('Hello World');
});
tap.test('execSpawn should handle command not found errors', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
let errorThrown = false;
try {
await testSmartshell.execSpawn('nonexistentcommand123', ['arg1']);
} catch (error) {
errorThrown = true;
expect(error.code).toEqual('ENOENT');
}
expect(errorThrown).toBeTrue();
});
tap.test('execSpawn should properly escape arguments', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// Test that shell metacharacters are treated as literals
const result = await testSmartshell.execSpawn('echo', ['$HOME', '&&', 'ls']);
expect(result.exitCode).toEqual(0);
// Should output literal strings, not expanded/executed
expect(result.stdout).toContain('$HOME && ls');
});
tap.test('execSpawn streaming should work', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
const streaming = await testSmartshell.execSpawnStreaming('echo', ['Streaming test']);
const result = await streaming.finalPromise;
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('Streaming test');
});
tap.test('execSpawn interactive control should work', async (tools) => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
const interactive = await testSmartshell.execSpawnInteractiveControl('cat', []);
await interactive.sendLine('Input line');
interactive.endInput();
const result = await interactive.finalPromise;
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('Input line');
});
tap.test('execSpawn should capture stderr', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// ls on non-existent directory should produce stderr
const result = await testSmartshell.execSpawn('ls', ['/nonexistent/directory/path']);
expect(result.exitCode).not.toEqual(0);
expect(result.stderr).toBeTruthy();
expect(result.stderr).toContain('No such file or directory');
});
tap.test('execSpawn with timeout should terminate process', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
const start = Date.now();
const result = await testSmartshell.execSpawn('sleep', ['10'], { timeout: 100 });
const duration = Date.now() - start;
// Process should be terminated by timeout
expect(duration).toBeLessThan(500);
expect(result.exitCode).not.toEqual(0);
expect(result.signal).toBeTruthy(); // Should have been killed by signal
});
tap.test('execSpawn with maxBuffer should truncate output', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
// Generate large output
const result = await testSmartshell.execSpawn('bash', ['-c', 'for i in {1..1000}; do echo "Line $i with some padding text to make it longer"; done'], {
maxBuffer: 1024, // Very small buffer
});
expect(result.exitCode).toEqual(0);
expect(result.stdout).toContain('[Output truncated - exceeded maxBuffer]');
});
tap.test('execSpawn with onData callback should stream data', async (tools) => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
let dataReceived = '';
const result = await testSmartshell.execSpawn('echo', ['Test data'], {
onData: (chunk) => {
dataReceived += chunk.toString();
}
});
expect(result.exitCode).toEqual(0);
expect(dataReceived).toContain('Test data');
});
tap.test('execSpawn with signal should report signal in result', async () => {
const testSmartshell = new smartshell.Smartshell({
executor: 'bash',
sourceFilePaths: [],
});
const streaming = await testSmartshell.execSpawnStreaming('sleep', ['10']);
// Send SIGTERM after a short delay
setTimeout(() => streaming.terminate(), 100);
const result = await streaming.finalPromise;
expect(result.exitCode).not.toEqual(0);
expect(result.signal).toEqual('SIGTERM');
});
export default tap.start();