kimi-code/packages/kaos/test/shell.test.ts

241 lines
8.3 KiB
TypeScript

import { mkdtemp, rm, stat as fsStat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { Kaos } from '#/kaos';
import { LocalKaos } from '#/local';
import type { KaosProcess } from '#/process';
/**
* Helper to run a shell command via /bin/sh -c and collect stdout/stderr/exitCode.
* Since the new Kaos.exec(...args) doesn't take options, timeout is implemented
* by killing the process after the given duration.
*/
async function runSh(
kaos: Kaos,
command: string,
options?: { timeout?: number; stdinData?: string },
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const proc: KaosProcess = await kaos.exec('/bin/sh', '-c', command);
// Set up timeout if requested
let timedOut = false;
let timer: ReturnType<typeof setTimeout> | undefined;
if (options?.timeout !== undefined) {
timer = setTimeout(() => {
timedOut = true;
void proc.kill('SIGKILL');
}, options.timeout);
}
// If stdinData is provided, write it and close stdin
if (options?.stdinData !== undefined) {
proc.stdin.write(options.stdinData);
proc.stdin.end();
} else {
proc.stdin.end();
}
// Collect stdout and stderr concurrently with waiting for process exit
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
const stdoutDone = new Promise<void>((resolve) => {
proc.stdout.on('data', (chunk: Buffer) => {
stdoutChunks.push(chunk);
});
proc.stdout.on('end', () => {
resolve();
});
});
const stderrDone = new Promise<void>((resolve) => {
proc.stderr.on('data', (chunk: Buffer) => {
stderrChunks.push(chunk);
});
proc.stderr.on('end', () => {
resolve();
});
});
const exitCode = await proc.wait();
await stdoutDone;
await stderrDone;
if (timer !== undefined) {
clearTimeout(timer);
}
return {
stdout: Buffer.concat(stdoutChunks).toString('utf-8'),
stderr: Buffer.concat(stderrChunks).toString('utf-8'),
exitCode: timedOut ? -1 : exitCode,
};
}
describe.skipIf(process.platform === 'win32')('LocalKaos shell operations', () => {
let kaos: Kaos;
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'kaos-shell-'));
kaos = await LocalKaos.create();
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
// NOTE: These tests mirror Python test_local_kaos_sh.py one-for-one.
// Python pins stderr to '' on every non-error case and uses inline_snapshot
// for exact stdout comparisons — the TS side now matches that strength so
// any future drift (e.g. a rogue newline or a leaked warning) is caught.
it('should run a simple command', async () => {
const result = await runSh(kaos, "echo 'Hello World'");
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe('Hello World\n');
expect(result.stderr).toBe('');
});
it('should handle command with error', async () => {
const result = await runSh(kaos, 'ls /nonexistent/directory');
expect(result.exitCode).not.toBe(0);
expect(result.stdout).toBe('');
expect(result.stderr).toContain('No such file or directory');
});
it('should support command chaining with &&', async () => {
const result = await runSh(kaos, "echo 'First' && echo 'Second'");
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe('First\nSecond\n');
expect(result.stderr).toBe('');
});
it('should support command pipe', async () => {
const result = await runSh(kaos, "echo 'Hello World' | wc -w");
expect(result.exitCode).toBe(0);
expect(result.stdout.trim()).toBe('2');
expect(result.stderr).toBe('');
});
it('should handle command with timeout (completes before timeout)', async () => {
// Python asserts stdout='' for `sleep 0.1` so pin that exactly — if the
// helper ever introduces its own chatter we want to hear about it.
const result = await runSh(kaos, 'sleep 0.1', { timeout: 5000 });
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe('');
expect(result.stderr).toBe('');
});
it('should handle timeout expiration', async () => {
// Python raises TimeoutError from its helper; the TS helper surfaces the
// same condition as exitCode === -1 after force-killing the process.
// The contract pinned here is "super-short timeout kills a long sleep".
const result = await runSh(kaos, 'sleep 60', { timeout: 100 });
expect(result.exitCode).toBe(-1);
});
it('should pass environment variables to shell', async () => {
const result = await runSh(kaos, 'TEST_VAR=\'test_value\'; export TEST_VAR; echo "$TEST_VAR"');
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe('test_value\n');
expect(result.stderr).toBe('');
});
it('should perform file operations', async () => {
// Mirror Python test_file_operations: two separate kaos.exec calls so
// that the "file lands on disk between calls" invariant is actually
// exercised, plus explicit stat() check.
const filePath = join(tmpDir, 'test_file.txt');
const write = await runSh(kaos, `echo 'Test content' > "${filePath}"`);
expect(write.exitCode).toBe(0);
expect(write.stdout).toBe('');
expect(write.stderr).toBe('');
const statInfo = await fsStat(filePath);
expect(statInfo.isFile()).toBe(true);
const read = await runSh(kaos, `cat "${filePath}"`);
expect(read.exitCode).toBe(0);
expect(read.stdout).toBe('Test content\n');
expect(read.stderr).toBe('');
});
it('should handle stdin data', async () => {
// Mirror Python test_command_reads_stdin: use the shell `read` builtin,
// which requires a newline-terminated input. Previously the TS version
// was a trivial `cat` passthrough that did not exercise `read`.
const result = await runSh(kaos, 'read value; printf \'%s\\n\' "$value"', {
stdinData: 'from stdin\n',
});
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe('from stdin\n');
expect(result.stderr).toBe('');
});
it('should execute commands sequentially with ;', async () => {
const result = await runSh(kaos, "echo 'One'; echo 'Two'");
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe('One\nTwo\n');
expect(result.stderr).toBe('');
});
it('should support conditional execution with ||', async () => {
const result = await runSh(kaos, "false || echo 'Success'");
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe('Success\n');
expect(result.stderr).toBe('');
});
it('should support multiple pipes', async () => {
const result = await runSh(kaos, "printf '1\\n2\\n3\\n' | grep '2' | wc -l");
expect(result.exitCode).toBe(0);
expect(result.stdout.trim()).toBe('1');
expect(result.stderr).toBe('');
});
it('should handle text processing with sed', async () => {
const result = await runSh(kaos, "echo 'apple banana cherry' | sed 's/banana/orange/'");
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe('apple orange cherry\n');
expect(result.stderr).toBe('');
});
it('should support command substitution', async () => {
const result = await runSh(kaos, 'echo "Result: $(echo hello)"');
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe('Result: hello\n');
expect(result.stderr).toBe('');
});
it('should support arithmetic substitution', async () => {
const result = await runSh(kaos, 'echo "Answer: $((2 + 2))"');
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe('Answer: 4\n');
expect(result.stderr).toBe('');
});
it('should handle very long output', async () => {
const result = await runSh(kaos, 'seq 1 100 | head -50');
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('1');
expect(result.stdout).toContain('50');
expect(result.stdout).not.toContain('51');
expect(result.stderr).toBe('');
});
it('should read multiple lines from stdin', async () => {
const result = await runSh(
kaos,
'count=0; while IFS= read -r _; do count=$((count+1)); done; printf \'%s\\n\' "$count"',
{ stdinData: 'alpha\nbeta\ngamma\n' },
);
expect(result.exitCode).toBe(0);
expect(result.stdout.trim()).toBe('3');
expect(result.stderr).toBe('');
});
});