2025-08-17 15:20:26 +00:00
|
|
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
|
|
|
|
import * as smartshell from '../ts/index.js';
|
2026-05-09 13:48:16 +00:00
|
|
|
import * as fs from 'fs';
|
|
|
|
|
import * as os from 'os';
|
|
|
|
|
import * as path from 'path';
|
2025-08-17 15:20:26 +00:00
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-09 13:48:16 +00:00
|
|
|
tap.test('execSpawn should run in the configured cwd', async () => {
|
|
|
|
|
const testSmartshell = new smartshell.Smartshell({
|
|
|
|
|
executor: 'bash',
|
|
|
|
|
sourceFilePaths: [],
|
|
|
|
|
});
|
|
|
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'smartshell-cwd-'));
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const result = await testSmartshell.execSpawn('node', ['-e', 'console.log(process.cwd())'], {
|
|
|
|
|
cwd: tmpDir,
|
|
|
|
|
silent: true,
|
|
|
|
|
});
|
|
|
|
|
expect(result.exitCode).toEqual(0);
|
|
|
|
|
expect(result.stdout.trim()).toEqual(tmpDir);
|
|
|
|
|
} finally {
|
|
|
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tap.test('execSilent should run shell commands in the configured cwd', async () => {
|
|
|
|
|
const testSmartshell = new smartshell.Smartshell({
|
|
|
|
|
executor: 'bash',
|
|
|
|
|
sourceFilePaths: [],
|
|
|
|
|
});
|
|
|
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'smartshell-shell-cwd-'));
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const result = await testSmartshell.execSilent('pwd', { cwd: tmpDir });
|
|
|
|
|
expect(result.exitCode).toEqual(0);
|
|
|
|
|
expect(result.stdout.trim()).toEqual(tmpDir);
|
|
|
|
|
} finally {
|
|
|
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-08-17 15:20:26 +00:00
|
|
|
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;
|
2026-05-09 13:48:16 +00:00
|
|
|
expect((error as NodeJS.ErrnoException).code).toEqual('ENOENT');
|
2025-08-17 15:20:26 +00:00
|
|
|
}
|
|
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-10 14:32:07 +00:00
|
|
|
tap.test('execSpawn should support inherited stdio for interactive CLIs', async () => {
|
|
|
|
|
const testSmartshell = new smartshell.Smartshell({
|
|
|
|
|
executor: 'bash',
|
|
|
|
|
sourceFilePaths: [],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = await testSmartshell.execSpawn('node', ['-e', 'console.log("inherited stdio works")'], {
|
|
|
|
|
stdio: 'inherit',
|
|
|
|
|
});
|
|
|
|
|
expect(result.exitCode).toEqual(0);
|
|
|
|
|
expect(result.stdout).toEqual('');
|
|
|
|
|
expect(result.stderr).toEqual('');
|
|
|
|
|
});
|
|
|
|
|
|
2025-08-17 15:20:26 +00:00
|
|
|
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
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-09 13:48:16 +00:00
|
|
|
tap.test('execSpawn timeout should terminate the spawned process tree', async () => {
|
|
|
|
|
const testSmartshell = new smartshell.Smartshell({
|
|
|
|
|
executor: 'bash',
|
|
|
|
|
sourceFilePaths: [],
|
|
|
|
|
});
|
|
|
|
|
const markerPath = path.join(os.tmpdir(), `smartshell-spawn-timeout-${process.pid}-${Date.now()}`);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const result = await testSmartshell.execSpawn(
|
|
|
|
|
'bash',
|
|
|
|
|
['-c', `(sleep 0.6; touch "${markerPath}") & wait`],
|
|
|
|
|
{ timeout: 100, silent: true },
|
|
|
|
|
);
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
|
|
|
|
|
|
|
|
expect(result.exitCode).not.toEqual(0);
|
|
|
|
|
expect(fs.existsSync(markerPath)).toBeFalse();
|
|
|
|
|
} finally {
|
|
|
|
|
fs.rmSync(markerPath, { force: true });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
tap.test('exec timeout should terminate the shell process tree', async () => {
|
|
|
|
|
const testSmartshell = new smartshell.Smartshell({
|
|
|
|
|
executor: 'bash',
|
|
|
|
|
sourceFilePaths: [],
|
|
|
|
|
});
|
|
|
|
|
const markerPath = path.join(os.tmpdir(), `smartshell-shell-timeout-${process.pid}-${Date.now()}`);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const result = await testSmartshell.execSilent(`(sleep 0.6; touch "${markerPath}") & wait`, {
|
|
|
|
|
timeout: 100,
|
|
|
|
|
});
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
|
|
|
|
|
|
|
|
expect(result.exitCode).not.toEqual(0);
|
|
|
|
|
expect(fs.existsSync(markerPath)).toBeFalse();
|
|
|
|
|
} finally {
|
|
|
|
|
fs.rmSync(markerPath, { force: true });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-08-17 15:20:26 +00:00
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-09 13:48:16 +00:00
|
|
|
export default tap.start();
|