feat(smartshell): add cwd-aware execution options, structured strict-mode errors, and safer process tree termination
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartshell from '../ts/index.js';
|
||||
|
||||
const getErrorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error);
|
||||
|
||||
tap.test('should handle EPIPE errors gracefully', async () => {
|
||||
const testSmartshell = new smartshell.Smartshell({
|
||||
executor: 'bash',
|
||||
@@ -40,7 +42,7 @@ tap.test('should handle strict mode with non-zero exit codes', async () => {
|
||||
await testSmartshell.execStrict('exit 42');
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
errorMessage = error.message;
|
||||
errorMessage = getErrorMessage(error);
|
||||
}
|
||||
|
||||
expect(errorThrown).toBeTrue();
|
||||
@@ -65,13 +67,39 @@ tap.test('should handle strict mode with signal termination', async () => {
|
||||
await result;
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
errorMessage = error.message;
|
||||
errorMessage = getErrorMessage(error);
|
||||
}
|
||||
|
||||
expect(errorThrown).toBeTrue();
|
||||
expect(errorMessage).toContain('terminated by signal');
|
||||
});
|
||||
|
||||
tap.test('strict mode errors should expose command result details', async () => {
|
||||
const testSmartshell = new smartshell.Smartshell({
|
||||
executor: 'bash',
|
||||
sourceFilePaths: [],
|
||||
});
|
||||
|
||||
let caughtError: smartshell.SmartshellError | null = null;
|
||||
|
||||
try {
|
||||
await testSmartshell.execSpawn('bash', ['-c', 'echo stdout-value; echo stderr-value >&2; exit 7'], {
|
||||
strict: true,
|
||||
silent: true,
|
||||
});
|
||||
} catch (error) {
|
||||
caughtError = error as smartshell.SmartshellError;
|
||||
}
|
||||
|
||||
expect(caughtError).toBeInstanceOf(smartshell.SmartshellError);
|
||||
expect(caughtError!.command).toEqual('bash');
|
||||
expect(caughtError!.exitCode).toEqual(7);
|
||||
expect(caughtError!.stdout).toContain('stdout-value');
|
||||
expect(caughtError!.combinedOutput).toContain('stderr-value');
|
||||
expect(caughtError!.stderr).toContain('stderr-value');
|
||||
expect(caughtError!.result.exitCode).toEqual(7);
|
||||
});
|
||||
|
||||
tap.test('execAndWaitForLine with timeout should reject properly', async () => {
|
||||
const testSmartshell = new smartshell.Smartshell({
|
||||
executor: 'bash',
|
||||
@@ -90,7 +118,7 @@ tap.test('execAndWaitForLine with timeout should reject properly', async () => {
|
||||
);
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
errorMessage = error.message;
|
||||
errorMessage = getErrorMessage(error);
|
||||
}
|
||||
|
||||
expect(errorThrown).toBeTrue();
|
||||
@@ -133,7 +161,7 @@ tap.test('should handle process ending without matching pattern', async () => {
|
||||
);
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
errorMessage = error.message;
|
||||
errorMessage = getErrorMessage(error);
|
||||
}
|
||||
|
||||
expect(errorThrown).toBeTrue();
|
||||
@@ -169,7 +197,7 @@ tap.test('should handle write after stream destroyed', async () => {
|
||||
await interactive.sendLine('This should fail');
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
expect(error.message).toContain('destroyed');
|
||||
expect(getErrorMessage(error)).toContain('destroyed');
|
||||
}
|
||||
|
||||
expect(errorThrown).toBeTrue();
|
||||
@@ -219,4 +247,4 @@ tap.test('custom environment variables should be passed correctly', async () =>
|
||||
expect(result.stdout).toContain('test_value_123');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
export default tap.start();
|
||||
|
||||
+2
-1
@@ -4,6 +4,7 @@ import * as smartshell from '../ts/index.js';
|
||||
// Helper to check if node-pty is available
|
||||
const isPtyAvailable = async (): Promise<boolean> => {
|
||||
try {
|
||||
// @ts-ignore - node-pty is an optional runtime dependency.
|
||||
await import('node-pty');
|
||||
return true;
|
||||
} catch {
|
||||
@@ -143,4 +144,4 @@ tap.test('Regular pipe mode should still work alongside PTY', async () => {
|
||||
expect(result.stdout).toContain('Pipe mode works');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
export default tap.start();
|
||||
|
||||
+2
-2
@@ -62,7 +62,7 @@ tap.test('execStreamingSilent should capture streaming output without console di
|
||||
const streamingResult = await testSmartshell.execStreamingSilent('echo "Line 1" && sleep 0.1 && echo "Line 2"');
|
||||
|
||||
let capturedData = '';
|
||||
streamingResult.childProcess.stdout.on('data', (data) => {
|
||||
streamingResult.childProcess.stdout!.on('data', (data) => {
|
||||
capturedData += data.toString();
|
||||
});
|
||||
|
||||
@@ -101,4 +101,4 @@ tap.test('execSilent vs exec output comparison', async () => {
|
||||
|
||||
export default tap.start({
|
||||
throwOnError: true,
|
||||
});
|
||||
});
|
||||
|
||||
+82
-2
@@ -1,5 +1,8 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import * as smartshell from '../ts/index.js';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
tap.test('execSpawn should execute commands with args array (shell:false)', async () => {
|
||||
const testSmartshell = new smartshell.Smartshell({
|
||||
@@ -13,6 +16,41 @@ tap.test('execSpawn should execute commands with args array (shell:false)', asyn
|
||||
expect(result.stdout).toContain('Hello World');
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('execSpawn should handle command not found errors', async () => {
|
||||
const testSmartshell = new smartshell.Smartshell({
|
||||
executor: 'bash',
|
||||
@@ -24,7 +62,7 @@ tap.test('execSpawn should handle command not found errors', async () => {
|
||||
await testSmartshell.execSpawn('nonexistentcommand123', ['arg1']);
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
expect(error.code).toEqual('ENOENT');
|
||||
expect((error as NodeJS.ErrnoException).code).toEqual('ENOENT');
|
||||
}
|
||||
expect(errorThrown).toBeTrue();
|
||||
});
|
||||
@@ -99,6 +137,48 @@ tap.test('execSpawn with timeout should terminate process', async () => {
|
||||
expect(result.signal).toBeTruthy(); // Should have been killed by signal
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('execSpawn with maxBuffer should truncate output', async () => {
|
||||
const testSmartshell = new smartshell.Smartshell({
|
||||
executor: 'bash',
|
||||
@@ -147,4 +227,4 @@ tap.test('execSpawn with signal should report signal in result', async () => {
|
||||
expect(result.signal).toEqual('SIGTERM');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
export default tap.start();
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ tap.test('smartshell should run async and silent', async () => {
|
||||
tap.test('smartshell should stream a shell execution', async () => {
|
||||
let done = smartpromise.defer();
|
||||
let execStreamingResponse = await testSmartshell.execStreaming('npm -v');
|
||||
execStreamingResponse.childProcess.stdout.on('data', (data) => {
|
||||
execStreamingResponse.childProcess.stdout!.on('data', (data) => {
|
||||
done.resolve(data);
|
||||
});
|
||||
let data = await done.promise;
|
||||
|
||||
Reference in New Issue
Block a user