File size: 11,612 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
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
import { mkdtemp, realpath, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { LocalKaos } from '#/local';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

// ── Helper ────────────────────────────────────────────────────────────

async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
  const chunks: Buffer[] = [];
  for await (const chunk of stream) {
    chunks.push(Buffer.from(chunk as Buffer));
  }
  return Buffer.concat(chunks);
}

// ── Tests ─────────────────────────────────────────────────────────────

describe('e2e: process lifecycle', () => {
  let kaos: LocalKaos;
  let tempDir: string;
  let originalCwd: string;

  beforeEach(async () => {
    kaos = await LocalKaos.create();
    originalCwd = process.cwd();
    tempDir = await realpath(await mkdtemp(join(tmpdir(), 'kaos-proc-')));
    process.chdir(tempDir);
  });

  afterEach(async () => {
    process.chdir(originalCwd);
    await rm(tempDir, { recursive: true, force: true });
  });

  describe('stdin β†’ stdout β†’ wait β†’ exitCode', () => {
    it('write to stdin, read from stdout, wait for exit', async () => {
      // Node script that reads stdin and echoes it to stdout
      const code = `
        let data = '';
        process.stdin.setEncoding('utf-8');
        process.stdin.on('data', (chunk) => { data += chunk; });
        process.stdin.on('end', () => {
          process.stdout.write('echo:' + data);
        });
      `;
      const proc = await kaos.exec('node', '-e', code);

      // Write to stdin
      proc.stdin.write('hello from test');
      proc.stdin.end();

      // Wait for process to complete
      const exitCode = await proc.wait();
      expect(exitCode).toBe(0);

      // Read stdout
      const stdout = await streamToBuffer(proc.stdout);
      expect(stdout.toString('utf-8')).toBe('echo:hello from test');
    });

    it('multiple stdin writes before end', async () => {
      const code = `
        let data = '';
        process.stdin.setEncoding('utf-8');
        process.stdin.on('data', (chunk) => { data += chunk; });
        process.stdin.on('end', () => {
          process.stdout.write(data.toUpperCase());
        });
      `;
      const proc = await kaos.exec('node', '-e', code);

      proc.stdin.write('hello ');
      proc.stdin.write('world ');
      proc.stdin.write('test');
      proc.stdin.end();

      const exitCode = await proc.wait();
      expect(exitCode).toBe(0);

      const stdout = await streamToBuffer(proc.stdout);
      expect(stdout.toString('utf-8')).toBe('HELLO WORLD TEST');
    });

    it('exitCode is null before wait, correct after wait', async () => {
      const proc = await kaos.exec('node', '-e', 'process.exit(42)');

      // Before wait, exitCode may be null
      // (it could also be set if process is very fast, so we just check after wait)
      const exitCode = await proc.wait();
      expect(exitCode).toBe(42);
      expect(proc.exitCode).toBe(42);
    });
  });

  describe('long-running process β†’ kill', () => {
    it.skipIf(process.platform === 'win32')('start β†’ verify running β†’ kill β†’ confirm exit', async () => {
      // Process that runs indefinitely
      const code = `
        process.stdout.write('started\\n');
        setInterval(() => {}, 1000);
      `;
      const proc = await kaos.exec('node', '-e', code);

      expect(proc.pid).toBeGreaterThan(0);

      // Kill it
      await proc.kill('SIGTERM');

      const exitCode = await proc.wait();
      // On SIGTERM, node typically exits with non-zero
      expect(typeof exitCode).toBe('number');
    });

    it('kill with SIGKILL β†’ immediate termination', async () => {
      const code = `
        // Trap SIGTERM to test that SIGKILL forces exit
        process.on('SIGTERM', () => { /* ignore */ });
        process.stdout.write('alive\\n');
        setInterval(() => {}, 1000);
      `;
      const proc = await kaos.exec('node', '-e', code);

      await proc.kill('SIGKILL');
      const exitCode = await proc.wait();

      // SIGKILL cannot be caught - process is terminated
      expect(exitCode).not.toBe(0);
    });

    it('multiple wait() calls return same exit code', async () => {
      const proc = await kaos.exec('node', '-e', 'process.exit(7)');

      const code1 = await proc.wait();
      const code2 = await proc.wait();
      const code3 = await proc.wait();

      expect(code1).toBe(7);
      expect(code2).toBe(7);
      expect(code3).toBe(7);
    });
  });

  describe('stdin close β†’ natural exit', () => {
    it('closing stdin causes stdin-reading process to exit naturally', async () => {
      // Process that exits when stdin closes
      const code = `
        process.stdin.resume();
        process.stdin.on('end', () => {
          process.stdout.write('stdin closed');
          process.exit(0);
        });
      `;
      const proc = await kaos.exec('node', '-e', code);

      // Close stdin immediately
      proc.stdin.end();

      const exitCode = await proc.wait();
      expect(exitCode).toBe(0);

      const stdout = await streamToBuffer(proc.stdout);
      expect(stdout.toString('utf-8')).toBe('stdin closed');
    });

    it('cat-like process exits when stdin is closed', async () => {
      const code = `
        process.stdin.setEncoding('utf-8');
        process.stdin.on('data', (chunk) => {
          process.stdout.write(chunk);
        });
        process.stdin.on('end', () => {
          process.exit(0);
        });
      `;
      const proc = await kaos.exec('node', '-e', code);

      proc.stdin.write('line1\n');
      proc.stdin.write('line2\n');
      proc.stdin.end();

      const exitCode = await proc.wait();
      expect(exitCode).toBe(0);

      const stdout = await streamToBuffer(proc.stdout);
      expect(stdout.toString('utf-8')).toBe('line1\nline2\n');
    });
  });

  describe('process.pid validity', () => {
    it('pid is a positive integer', async () => {
      const proc = await kaos.exec('node', '-e', 'process.exit(0)');
      expect(proc.pid).toBeGreaterThan(0);
      expect(Number.isInteger(proc.pid)).toBe(true);

      await proc.wait();
    });

    it('different processes have different pids', async () => {
      const proc1 = await kaos.exec('node', '-e', 'process.exit(0)');
      const proc2 = await kaos.exec('node', '-e', 'process.exit(0)');

      expect(proc1.pid).not.toBe(proc2.pid);

      await Promise.all([proc1.wait(), proc2.wait()]);
    });

    it('pid matches the actual child process pid', async () => {
      // Have the child process report its own pid
      const code = `process.stdout.write(String(process.pid))`;
      const proc = await kaos.exec('node', '-e', code);
      await proc.wait();

      const stdout = await streamToBuffer(proc.stdout);
      const reportedPid = Number(stdout.toString('utf-8'));

      expect(proc.pid).toBe(reportedPid);
    });
  });

  describe('exec failure (command not found)', () => {
    it('non-existent command β†’ error on wait()', async () => {
      // exec() itself may succeed (spawn returns) but wait() should
      // report the error or the process should fail
      try {
        const proc = await kaos.exec('this-command-absolutely-does-not-exist-xyz-123');
        // If exec resolves, wait should give a rejection
        await expect(proc.wait()).rejects.toThrow();
      } catch (error: unknown) {
        // If exec itself throws, that's also acceptable
        expect(error).toBeInstanceOf(Error);
      }
    });

    it('exec with empty arguments rejects', async () => {
      // exec() is now async, so validation errors (missing command) surface
      // as rejected promises rather than synchronous throws.
      await expect(kaos.exec()).rejects.toThrow(/at least one argument/);
    });
  });

  describe('execWithEnv', () => {
    it('passes custom environment variables to the child process', async () => {
      const code = `process.stdout.write(process.env.MY_VAR || 'undefined')`;
      const proc = await kaos.execWithEnv(['node', '-e', code], {
        ...(process.env as Record<string, string>),
        MY_VAR: 'test-value-123',
      });
      const exitCode = await proc.wait();
      expect(exitCode).toBe(0);

      const stdout = await streamToBuffer(proc.stdout);
      expect(stdout.toString('utf-8')).toBe('test-value-123');
    });

    it('custom env overrides specific variables', async () => {
      // Passing a custom env with PATH so node can still run,
      // but with a custom variable that overrides a default one.
      const code = `process.stdout.write(process.env.MY_CUSTOM || 'missing')`;
      const proc = await kaos.execWithEnv(['node', '-e', code], {
        PATH: process.env['PATH'] ?? '',
        MY_CUSTOM: 'overridden-value',
      });
      const exitCode = await proc.wait();
      expect(exitCode).toBe(0);

      const stdout = await streamToBuffer(proc.stdout);
      expect(stdout.toString('utf-8')).toBe('overridden-value');
    });
  });

  describe('process output ordering', () => {
    it('stdout preserves line order for sequential writes', async () => {
      const code = `
        for (let i = 0; i < 100; i++) {
          process.stdout.write(i + '\\n');
        }
      `;
      const proc = await kaos.exec('node', '-e', code);
      const exitCode = await proc.wait();
      expect(exitCode).toBe(0);

      const stdout = await streamToBuffer(proc.stdout);
      const lines = stdout.toString('utf-8').trimEnd().split('\n');

      expect(lines).toHaveLength(100);
      for (let i = 0; i < 100; i++) {
        expect(lines[i]).toBe(String(i));
      }
    });

    it('stderr preserves line order for sequential writes', async () => {
      const code = `
        for (let i = 0; i < 50; i++) {
          process.stderr.write('err-' + i + '\\n');
        }
      `;
      const proc = await kaos.exec('node', '-e', code);
      const exitCode = await proc.wait();
      expect(exitCode).toBe(0);

      const stderr = await streamToBuffer(proc.stderr);
      const lines = stderr.toString('utf-8').trimEnd().split('\n');

      expect(lines).toHaveLength(50);
      for (let i = 0; i < 50; i++) {
        expect(lines[i]).toBe(`err-${i}`);
      }
    });
  });

  describe('process exit codes', () => {
    it('exit code 0 for successful process', async () => {
      const proc = await kaos.exec('node', '-e', 'process.exit(0)');
      expect(await proc.wait()).toBe(0);
    });

    it('exit code 1 for generic failure', async () => {
      const proc = await kaos.exec('node', '-e', 'process.exit(1)');
      expect(await proc.wait()).toBe(1);
    });

    it('custom exit codes (2, 42, 127, 255)', async () => {
      for (const code of [2, 42, 127, 255]) {
        const proc = await kaos.exec('node', '-e', `process.exit(${code})`);
        expect(await proc.wait()).toBe(code);
      }
    });

    it('uncaught exception results in exit code 1', async () => {
      const code = `throw new Error('uncaught')`;
      const proc = await kaos.exec('node', '-e', code);
      const exitCode = await proc.wait();
      expect(exitCode).toBe(1);

      // stderr should contain the error message
      const stderr = await streamToBuffer(proc.stderr);
      expect(stderr.toString('utf-8')).toContain('uncaught');
    });
  });
});