File size: 8,476 Bytes
4e23b01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
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('');
  });
});