@made-by-moonlight/athene-plugin-runtime-process 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,674 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { EventEmitter } from "node:events";
3
+ // ---------------------------------------------------------------------------
4
+ // Hoisted mock — must be set up before import
5
+ // ---------------------------------------------------------------------------
6
+ const { mockSpawn, mockIsWindows, mockKillProcessTree, mockGetShell, mockGetPipePath, mockPtyHostSendMessage, mockPtyHostGetOutput, mockPtyHostIsAlive, mockPtyHostKill, } = vi.hoisted(() => ({
7
+ mockSpawn: vi.fn(),
8
+ mockIsWindows: vi.fn(() => false),
9
+ mockKillProcessTree: vi.fn().mockResolvedValue(undefined),
10
+ mockGetShell: vi.fn(() => ({ cmd: "sh", args: (c) => ["-c", c] })),
11
+ mockGetPipePath: vi.fn((id) => `\\\\.\\pipe\\ao-pty-${id}`),
12
+ mockPtyHostSendMessage: vi.fn().mockResolvedValue(undefined),
13
+ mockPtyHostGetOutput: vi.fn().mockResolvedValue(""),
14
+ mockPtyHostIsAlive: vi.fn().mockResolvedValue(true),
15
+ mockPtyHostKill: vi.fn().mockResolvedValue(undefined),
16
+ }));
17
+ vi.mock("node:child_process", async (importOriginal) => {
18
+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports
19
+ const actual = await importOriginal();
20
+ return {
21
+ ...actual,
22
+ spawn: mockSpawn,
23
+ };
24
+ });
25
+ vi.mock("@made-by-moonlight/athene-core", async (importOriginal) => {
26
+ // eslint-disable-next-line @typescript-eslint/consistent-type-imports
27
+ const actual = await importOriginal();
28
+ return {
29
+ ...actual,
30
+ getShell: mockGetShell,
31
+ isWindows: mockIsWindows,
32
+ killProcessTree: mockKillProcessTree,
33
+ };
34
+ });
35
+ vi.mock("../pty-client.js", () => ({
36
+ getPipePath: mockGetPipePath,
37
+ ptyHostSendMessage: mockPtyHostSendMessage,
38
+ ptyHostGetOutput: mockPtyHostGetOutput,
39
+ ptyHostIsAlive: mockPtyHostIsAlive,
40
+ ptyHostKill: mockPtyHostKill,
41
+ }));
42
+ import { create, manifest, default as defaultExport } from "../index.js";
43
+ // ---------------------------------------------------------------------------
44
+ // Mock ChildProcess
45
+ // ---------------------------------------------------------------------------
46
+ class MockChildProcess extends EventEmitter {
47
+ pid = 12345;
48
+ exitCode = null;
49
+ signalCode = null;
50
+ stdin = {
51
+ writable: true,
52
+ write: vi.fn((_data, cb) => {
53
+ cb(null);
54
+ }),
55
+ on: vi.fn(),
56
+ removeListener: vi.fn(),
57
+ };
58
+ stdout = Object.assign(new EventEmitter(), { destroy: vi.fn() });
59
+ stderr = Object.assign(new EventEmitter(), { destroy: vi.fn() });
60
+ kill = vi.fn();
61
+ unref = vi.fn();
62
+ }
63
+ function createMockChild(autoSpawn = true) {
64
+ const child = new MockChildProcess();
65
+ if (autoSpawn) {
66
+ // Emit "spawn" on next tick so the await in create() resolves
67
+ process.nextTick(() => child.emit("spawn"));
68
+ }
69
+ return child;
70
+ }
71
+ /**
72
+ * Creates a mock child process that emits READY:<pid> on stdout, simulating
73
+ * the PTY host startup handshake used on Windows.
74
+ */
75
+ function createWindowsMockChild(pid = 12345) {
76
+ const child = new MockChildProcess();
77
+ child.pid = pid;
78
+ // Emit READY signal on next tick so the Windows create() branch resolves
79
+ process.nextTick(() => {
80
+ child.stdout.emit("data", Buffer.from(`READY:${pid}\n`));
81
+ });
82
+ return child;
83
+ }
84
+ // ---------------------------------------------------------------------------
85
+ // Helpers
86
+ // ---------------------------------------------------------------------------
87
+ function makeHandle(id = "test-session") {
88
+ return { id, runtimeName: "process", data: { pid: 12345 } };
89
+ }
90
+ function defaultConfig(overrides = {}) {
91
+ return {
92
+ sessionId: "test-session",
93
+ launchCommand: "echo hello",
94
+ workspacePath: "/tmp/workspace",
95
+ environment: { FOO: "bar" },
96
+ ...overrides,
97
+ };
98
+ }
99
+ // ---------------------------------------------------------------------------
100
+ // Tests
101
+ // ---------------------------------------------------------------------------
102
+ beforeEach(() => {
103
+ vi.clearAllMocks();
104
+ vi.restoreAllMocks();
105
+ mockIsWindows.mockReturnValue(false);
106
+ mockKillProcessTree.mockResolvedValue(undefined);
107
+ mockSpawn.mockReturnValue(createMockChild());
108
+ });
109
+ // =========================================================================
110
+ // Manifest & exports
111
+ // =========================================================================
112
+ describe("manifest & exports", () => {
113
+ it("has correct manifest fields", () => {
114
+ expect(manifest).toEqual({
115
+ name: "process",
116
+ slot: "runtime",
117
+ description: "Runtime plugin: child processes",
118
+ version: "0.1.0",
119
+ });
120
+ });
121
+ it("default export is a valid PluginModule", () => {
122
+ expect(defaultExport.manifest).toBe(manifest);
123
+ expect(typeof defaultExport.create).toBe("function");
124
+ });
125
+ it("create() returns a runtime with name 'process'", () => {
126
+ const runtime = create();
127
+ expect(runtime.name).toBe("process");
128
+ });
129
+ });
130
+ // =========================================================================
131
+ // runtime.create()
132
+ // =========================================================================
133
+ describe("create()", () => {
134
+ it("spawns process with platform shell, detached:!isWindows(), correct cwd and env", async () => {
135
+ const child = createMockChild();
136
+ mockSpawn.mockReturnValue(child);
137
+ const runtime = create();
138
+ await runtime.create(defaultConfig());
139
+ // spawn is called as: spawn(shellCmd, shellArgs, options)
140
+ // shellCmd is the shell binary (a non-empty string), shellArgs is an array
141
+ // containing the launchCommand, options holds cwd/env/detached/stdio.
142
+ const [spawnCmd, spawnShellArgs, spawnOpts] = mockSpawn.mock.calls[0];
143
+ expect(typeof spawnCmd).toBe("string");
144
+ expect(spawnCmd.length).toBeGreaterThan(0);
145
+ expect(spawnShellArgs).toContain("echo hello");
146
+ // detached mirrors !isWindows() — use the mock's return value, not process.platform
147
+ const expectedDetached = !mockIsWindows();
148
+ expect(spawnOpts).toMatchObject({
149
+ cwd: "/tmp/workspace",
150
+ detached: expectedDetached,
151
+ stdio: ["pipe", "pipe", "pipe"],
152
+ });
153
+ // Check the env includes the config environment merged with process.env
154
+ expect(spawnOpts.env.FOO).toBe("bar");
155
+ });
156
+ it("returns handle with correct id, runtimeName, and pid in data", async () => {
157
+ const child = createMockChild();
158
+ child.pid = 99999;
159
+ mockSpawn.mockReturnValue(child);
160
+ const runtime = create();
161
+ const handle = await runtime.create(defaultConfig({ sessionId: "my-session" }));
162
+ expect(handle.id).toBe("my-session");
163
+ expect(handle.runtimeName).toBe("process");
164
+ expect(handle.data).toEqual(expect.objectContaining({ pid: 99999 }));
165
+ });
166
+ it("rejects invalid session IDs with special characters", async () => {
167
+ const runtime = create();
168
+ await expect(runtime.create(defaultConfig({ sessionId: "bad session!!" }))).rejects.toThrow(/Invalid session ID/);
169
+ });
170
+ it("rejects session ID with dots", async () => {
171
+ const runtime = create();
172
+ await expect(runtime.create(defaultConfig({ sessionId: "bad.session" }))).rejects.toThrow(/Invalid session ID/);
173
+ });
174
+ it("rejects session ID with spaces", async () => {
175
+ const runtime = create();
176
+ await expect(runtime.create(defaultConfig({ sessionId: "bad session" }))).rejects.toThrow(/Invalid session ID/);
177
+ });
178
+ it("accepts valid session IDs with alphanumeric, hyphens, underscores", async () => {
179
+ const child = createMockChild();
180
+ mockSpawn.mockReturnValue(child);
181
+ const runtime = create();
182
+ const handle = await runtime.create(defaultConfig({ sessionId: "my-session_01" }));
183
+ expect(handle.id).toBe("my-session_01");
184
+ });
185
+ it("rejects duplicate session IDs", async () => {
186
+ const child1 = createMockChild();
187
+ mockSpawn.mockReturnValue(child1);
188
+ const runtime = create();
189
+ await runtime.create(defaultConfig({ sessionId: "dup-session" }));
190
+ // Second call with same ID should throw
191
+ const child2 = createMockChild();
192
+ mockSpawn.mockReturnValue(child2);
193
+ await expect(runtime.create(defaultConfig({ sessionId: "dup-session" }))).rejects.toThrow(/already exists/);
194
+ });
195
+ it("cleans up on spawn error", async () => {
196
+ const child = createMockChild(false);
197
+ mockSpawn.mockReturnValue(child);
198
+ const runtime = create();
199
+ const createPromise = runtime.create(defaultConfig({ sessionId: "fail-session" }));
200
+ // Emit error on next tick
201
+ process.nextTick(() => child.emit("error", new Error("ENOENT")));
202
+ await expect(createPromise).rejects.toThrow(/Failed to spawn/);
203
+ // After the error, the session ID should be cleaned up from internal map.
204
+ // We can verify by trying to create with the same ID again (should succeed).
205
+ const child2 = createMockChild();
206
+ mockSpawn.mockReturnValue(child2);
207
+ const handle = await runtime.create(defaultConfig({ sessionId: "fail-session" }));
208
+ expect(handle.id).toBe("fail-session");
209
+ });
210
+ it("cleans up when spawn() itself throws synchronously", async () => {
211
+ mockSpawn.mockImplementation(() => {
212
+ throw new Error("spawn EACCES");
213
+ });
214
+ const runtime = create();
215
+ await expect(runtime.create(defaultConfig({ sessionId: "sync-fail" }))).rejects.toThrow(/Failed to spawn/);
216
+ // Slot should be freed — re-create should work
217
+ const child = createMockChild();
218
+ mockSpawn.mockReturnValue(child);
219
+ const handle = await runtime.create(defaultConfig({ sessionId: "sync-fail" }));
220
+ expect(handle.id).toBe("sync-fail");
221
+ });
222
+ });
223
+ // =========================================================================
224
+ // destroy()
225
+ // =========================================================================
226
+ describe("destroy()", () => {
227
+ it("kills the process and resolves after exit", async () => {
228
+ const child = createMockChild();
229
+ mockSpawn.mockReturnValue(child);
230
+ const runtime = create();
231
+ const handle = await runtime.create(defaultConfig());
232
+ // When destroy is called, it sends SIGTERM then waits for exit.
233
+ // We need to emit exit when the process receives the signal.
234
+ const destroyPromise = runtime.destroy(handle);
235
+ // Small delay before emitting exit to simulate real async process teardown
236
+ await new Promise((r) => setTimeout(r, 10));
237
+ child.exitCode = 0;
238
+ child.emit("exit", 0, null);
239
+ await destroyPromise;
240
+ // killProcessTree should have been called with pid and SIGTERM
241
+ expect(mockKillProcessTree).toHaveBeenCalledWith(12345, "SIGTERM");
242
+ });
243
+ it("does not throw for unknown handle (no-op)", async () => {
244
+ const runtime = create();
245
+ await expect(runtime.destroy(makeHandle("nonexistent"))).resolves.toBeUndefined();
246
+ });
247
+ it("does not attempt kill if process already exited", async () => {
248
+ const child = createMockChild();
249
+ mockSpawn.mockReturnValue(child);
250
+ const runtime = create();
251
+ const handle = await runtime.create(defaultConfig());
252
+ // Simulate the process having exited already
253
+ child.exitCode = 0;
254
+ await runtime.destroy(handle);
255
+ // Should NOT have called killProcessTree since process already exited
256
+ expect(mockKillProcessTree).not.toHaveBeenCalled();
257
+ expect(child.kill).not.toHaveBeenCalled();
258
+ });
259
+ it("escalates to SIGKILL after 5 second timeout", async () => {
260
+ vi.useFakeTimers();
261
+ const child = createMockChild();
262
+ mockSpawn.mockReturnValue(child);
263
+ const runtime = create();
264
+ // We need to emit "spawn" manually with fake timers
265
+ const createPromise = runtime.create(defaultConfig({ sessionId: "kill-timeout" }));
266
+ await vi.runAllTimersAsync();
267
+ // "spawn" was scheduled via nextTick in createMockChild, which ran
268
+ const handle = await createPromise;
269
+ const destroyPromise = runtime.destroy(handle);
270
+ // Advance past the 5-second timeout — process never exits
271
+ await vi.advanceTimersByTimeAsync(5100);
272
+ await destroyPromise;
273
+ // Should have called SIGTERM first, then SIGKILL via killProcessTree
274
+ expect(mockKillProcessTree).toHaveBeenCalledWith(12345, "SIGTERM");
275
+ expect(mockKillProcessTree).toHaveBeenCalledWith(12345, "SIGKILL");
276
+ vi.useRealTimers();
277
+ });
278
+ it("uses killProcessTree (not direct process.kill) on Windows and Unix", async () => {
279
+ // On Unix: verify killProcessTree is used (not process.kill(-pid))
280
+ mockIsWindows.mockReturnValue(false);
281
+ const child = createMockChild();
282
+ mockSpawn.mockReturnValue(child);
283
+ const runtime = create();
284
+ const handle = await runtime.create(defaultConfig());
285
+ const processKillSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
286
+ const destroyPromise = runtime.destroy(handle);
287
+ await new Promise((r) => setTimeout(r, 10));
288
+ child.exitCode = 0;
289
+ child.emit("exit", 0, null);
290
+ await destroyPromise;
291
+ // destroy() always delegates to killProcessTree — never calls process.kill directly
292
+ expect(mockKillProcessTree).toHaveBeenCalledWith(12345, "SIGTERM");
293
+ expect(processKillSpy).not.toHaveBeenCalledWith(-12345, expect.anything());
294
+ processKillSpy.mockRestore();
295
+ });
296
+ it("resolves promptly when process exits during async killProcessTree (no 5s delay)", async () => {
297
+ // Regression test: exit listener must be registered BEFORE await killProcessTree
298
+ // so that if the process dies during the async kill, destroy() resolves immediately
299
+ // instead of waiting for the 5-second timeout.
300
+ const child = createMockChild();
301
+ mockSpawn.mockReturnValue(child);
302
+ // Make killProcessTree emit exit synchronously mid-await to simulate the race
303
+ mockKillProcessTree.mockImplementation(async () => {
304
+ child.exitCode = 0;
305
+ child.emit("exit", 0, null);
306
+ });
307
+ const runtime = create();
308
+ const handle = await runtime.create(defaultConfig());
309
+ const start = Date.now();
310
+ await runtime.destroy(handle);
311
+ const elapsed = Date.now() - start;
312
+ // Should resolve well under 5 seconds — exit was caught before the timeout
313
+ expect(elapsed).toBeLessThan(1000);
314
+ expect(mockKillProcessTree).toHaveBeenCalledWith(12345, "SIGTERM");
315
+ });
316
+ it("falls back to child.kill when pid is undefined", async () => {
317
+ const child = createMockChild();
318
+ child.pid = undefined; // simulate missing PID
319
+ mockSpawn.mockReturnValue(child);
320
+ const runtime = create();
321
+ const handle = await runtime.create(defaultConfig());
322
+ const destroyPromise = runtime.destroy(handle);
323
+ await new Promise((r) => setTimeout(r, 10));
324
+ child.exitCode = 0;
325
+ child.emit("exit", 0, null);
326
+ await destroyPromise;
327
+ // pid was undefined, so child.kill should have been called as fallback
328
+ expect(child.kill).toHaveBeenCalledWith("SIGTERM");
329
+ // killProcessTree should NOT have been called since there's no pid
330
+ expect(mockKillProcessTree).not.toHaveBeenCalled();
331
+ });
332
+ });
333
+ // =========================================================================
334
+ // sendMessage()
335
+ // =========================================================================
336
+ describe("sendMessage()", () => {
337
+ it("writes message with trailing newline to stdin", async () => {
338
+ const child = createMockChild();
339
+ mockSpawn.mockReturnValue(child);
340
+ const runtime = create();
341
+ const handle = await runtime.create(defaultConfig());
342
+ await runtime.sendMessage(handle, "hello world");
343
+ expect(child.stdin.write).toHaveBeenCalledWith("hello world\n", expect.any(Function));
344
+ });
345
+ it("throws for unknown session", async () => {
346
+ const runtime = create();
347
+ await expect(runtime.sendMessage(makeHandle("nonexistent"), "hello")).rejects.toThrow(/No process found/);
348
+ });
349
+ it("throws when stdin is not writable", async () => {
350
+ const child = createMockChild();
351
+ child.stdin.writable = false;
352
+ mockSpawn.mockReturnValue(child);
353
+ const runtime = create();
354
+ const handle = await runtime.create(defaultConfig());
355
+ await expect(runtime.sendMessage(handle, "hello")).rejects.toThrow(/stdin not writable/);
356
+ });
357
+ it("rejects when stdin.write returns an error", async () => {
358
+ const child = createMockChild();
359
+ child.stdin.write = vi.fn((_data, cb) => {
360
+ cb(new Error("write EPIPE"));
361
+ });
362
+ mockSpawn.mockReturnValue(child);
363
+ const runtime = create();
364
+ const handle = await runtime.create(defaultConfig());
365
+ await expect(runtime.sendMessage(handle, "hello")).rejects.toThrow(/write EPIPE/);
366
+ });
367
+ });
368
+ // =========================================================================
369
+ // getOutput()
370
+ // =========================================================================
371
+ describe("getOutput()", () => {
372
+ it("returns buffered output lines", async () => {
373
+ const child = createMockChild();
374
+ mockSpawn.mockReturnValue(child);
375
+ const runtime = create();
376
+ await runtime.create(defaultConfig());
377
+ // Simulate stdout data — lines are newline-terminated
378
+ child.stdout.emit("data", Buffer.from("line1\nline2\nline3\n"));
379
+ const output = await runtime.getOutput(makeHandle(), 50);
380
+ expect(output).toBe("line1\nline2\nline3");
381
+ });
382
+ it("buffers partial lines across chunks", async () => {
383
+ const child = createMockChild();
384
+ mockSpawn.mockReturnValue(child);
385
+ const runtime = create();
386
+ await runtime.create(defaultConfig());
387
+ // Partial line split across two chunks
388
+ child.stdout.emit("data", Buffer.from("hel"));
389
+ child.stdout.emit("data", Buffer.from("lo\nworld\n"));
390
+ const output = await runtime.getOutput(makeHandle(), 50);
391
+ expect(output).toBe("hello\nworld");
392
+ });
393
+ it("returns only the requested number of lines", async () => {
394
+ const child = createMockChild();
395
+ mockSpawn.mockReturnValue(child);
396
+ const runtime = create();
397
+ await runtime.create(defaultConfig());
398
+ child.stdout.emit("data", Buffer.from("a\nb\nc\nd\ne\n"));
399
+ const output = await runtime.getOutput(makeHandle(), 2);
400
+ expect(output).toBe("d\ne");
401
+ });
402
+ it("returns empty string for unknown session", async () => {
403
+ const runtime = create();
404
+ const output = await runtime.getOutput(makeHandle("nonexistent"), 50);
405
+ expect(output).toBe("");
406
+ });
407
+ it("captures stderr in the output buffer too", async () => {
408
+ const child = createMockChild();
409
+ mockSpawn.mockReturnValue(child);
410
+ const runtime = create();
411
+ await runtime.create(defaultConfig());
412
+ child.stderr.emit("data", Buffer.from("error output\n"));
413
+ const output = await runtime.getOutput(makeHandle(), 50);
414
+ expect(output).toBe("error output");
415
+ });
416
+ it("interleaves stdout and stderr", async () => {
417
+ const child = createMockChild();
418
+ mockSpawn.mockReturnValue(child);
419
+ const runtime = create();
420
+ await runtime.create(defaultConfig());
421
+ child.stdout.emit("data", Buffer.from("out1\n"));
422
+ child.stderr.emit("data", Buffer.from("err1\n"));
423
+ child.stdout.emit("data", Buffer.from("out2\n"));
424
+ const output = await runtime.getOutput(makeHandle(), 50);
425
+ expect(output).toBe("out1\nerr1\nout2");
426
+ });
427
+ it("does not mix partial lines across stdout and stderr", async () => {
428
+ const child = createMockChild();
429
+ mockSpawn.mockReturnValue(child);
430
+ const runtime = create();
431
+ await runtime.create(defaultConfig());
432
+ // stdout emits a partial line, then stderr emits a full line,
433
+ // then stdout completes its line — they should NOT be concatenated
434
+ child.stdout.emit("data", Buffer.from("hel"));
435
+ child.stderr.emit("data", Buffer.from("error\n"));
436
+ child.stdout.emit("data", Buffer.from("lo\n"));
437
+ const output = await runtime.getOutput(makeHandle(), 50);
438
+ expect(output).toBe("error\nhello");
439
+ });
440
+ });
441
+ // =========================================================================
442
+ // isAlive()
443
+ // =========================================================================
444
+ describe("isAlive()", () => {
445
+ it("returns true when process is running (exitCode and signalCode null)", async () => {
446
+ const child = createMockChild();
447
+ mockSpawn.mockReturnValue(child);
448
+ const runtime = create();
449
+ await runtime.create(defaultConfig());
450
+ expect(await runtime.isAlive(makeHandle())).toBe(true);
451
+ });
452
+ it("returns false when process has exited", async () => {
453
+ const child = createMockChild();
454
+ mockSpawn.mockReturnValue(child);
455
+ const runtime = create();
456
+ await runtime.create(defaultConfig());
457
+ child.exitCode = 1;
458
+ expect(await runtime.isAlive(makeHandle())).toBe(false);
459
+ });
460
+ it("returns false when process was signalled", async () => {
461
+ const child = createMockChild();
462
+ mockSpawn.mockReturnValue(child);
463
+ const runtime = create();
464
+ await runtime.create(defaultConfig());
465
+ child.signalCode = "SIGTERM";
466
+ expect(await runtime.isAlive(makeHandle())).toBe(false);
467
+ });
468
+ it("returns false for unknown session", async () => {
469
+ const runtime = create();
470
+ expect(await runtime.isAlive(makeHandle("nonexistent"))).toBe(false);
471
+ });
472
+ });
473
+ // =========================================================================
474
+ // getMetrics()
475
+ // =========================================================================
476
+ describe("getMetrics()", () => {
477
+ it("returns uptimeMs for a running session", async () => {
478
+ const child = createMockChild();
479
+ mockSpawn.mockReturnValue(child);
480
+ const runtime = create();
481
+ await runtime.create(defaultConfig());
482
+ // Small delay to ensure uptime > 0
483
+ await new Promise((r) => setTimeout(r, 10));
484
+ const metrics = await runtime.getMetrics(makeHandle());
485
+ expect(metrics.uptimeMs).toBeGreaterThanOrEqual(0);
486
+ expect(metrics.uptimeMs).toBeLessThan(5000);
487
+ });
488
+ it("returns uptimeMs for unknown session (uses Date.now as fallback)", async () => {
489
+ const runtime = create();
490
+ const metrics = await runtime.getMetrics(makeHandle("nonexistent"));
491
+ // When entry is null, createdAt defaults to Date.now(), so uptimeMs is ~0
492
+ expect(metrics.uptimeMs).toBeGreaterThanOrEqual(0);
493
+ expect(metrics.uptimeMs).toBeLessThan(100);
494
+ });
495
+ });
496
+ // =========================================================================
497
+ // getAttachInfo()
498
+ // =========================================================================
499
+ describe("getAttachInfo()", () => {
500
+ it("returns PID as target when process is running", async () => {
501
+ const child = createMockChild();
502
+ child.pid = 54321;
503
+ mockSpawn.mockReturnValue(child);
504
+ const runtime = create();
505
+ await runtime.create(defaultConfig());
506
+ const info = await runtime.getAttachInfo(makeHandle());
507
+ expect(info.type).toBe("process");
508
+ expect(info.target).toBe("54321");
509
+ });
510
+ it("returns 'no longer running' command when process has exited", async () => {
511
+ const child = createMockChild();
512
+ mockSpawn.mockReturnValue(child);
513
+ const runtime = create();
514
+ await runtime.create(defaultConfig());
515
+ child.exitCode = 0;
516
+ const info = await runtime.getAttachInfo(makeHandle());
517
+ expect(info.type).toBe("process");
518
+ expect(info.target).toBe("");
519
+ expect(info.command).toContain("no longer running");
520
+ });
521
+ it("returns 'no longer running' for unknown session", async () => {
522
+ const runtime = create();
523
+ const info = await runtime.getAttachInfo(makeHandle("nonexistent"));
524
+ expect(info.type).toBe("process");
525
+ expect(info.target).toBe("");
526
+ expect(info.command).toContain("no longer running");
527
+ });
528
+ it("returns 'no longer running' when process was killed by signal", async () => {
529
+ const child = createMockChild();
530
+ mockSpawn.mockReturnValue(child);
531
+ const runtime = create();
532
+ await runtime.create(defaultConfig());
533
+ child.signalCode = "SIGKILL";
534
+ const info = await runtime.getAttachInfo(makeHandle());
535
+ expect(info.type).toBe("process");
536
+ expect(info.target).toBe("");
537
+ expect(info.command).toContain("no longer running");
538
+ });
539
+ });
540
+ // =========================================================================
541
+ // Exit handler cleans up internal map
542
+ // =========================================================================
543
+ describe("exit handler", () => {
544
+ it("removes session from internal map when process exits", async () => {
545
+ const child = createMockChild();
546
+ mockSpawn.mockReturnValue(child);
547
+ const runtime = create();
548
+ await runtime.create(defaultConfig());
549
+ // Process is alive
550
+ expect(await runtime.isAlive(makeHandle())).toBe(true);
551
+ // Simulate exit
552
+ child.exitCode = 0;
553
+ child.emit("exit", 0, null);
554
+ // After exit, session should be gone from the map
555
+ expect(await runtime.isAlive(makeHandle())).toBe(false);
556
+ });
557
+ it("allows re-creating a session after exit cleanup", async () => {
558
+ const child1 = createMockChild();
559
+ mockSpawn.mockReturnValue(child1);
560
+ const runtime = create();
561
+ await runtime.create(defaultConfig({ sessionId: "reuse-me" }));
562
+ // Simulate exit
563
+ child1.exitCode = 0;
564
+ child1.emit("exit", 0, null);
565
+ // Re-create with same ID should work
566
+ const child2 = createMockChild();
567
+ mockSpawn.mockReturnValue(child2);
568
+ const handle = await runtime.create(defaultConfig({ sessionId: "reuse-me" }));
569
+ expect(handle.id).toBe("reuse-me");
570
+ });
571
+ });
572
+ // =========================================================================
573
+ // Output buffer truncation
574
+ // =========================================================================
575
+ describe("output buffer truncation", () => {
576
+ it("truncates output buffer to MAX_OUTPUT_LINES (1000)", async () => {
577
+ const child = createMockChild();
578
+ mockSpawn.mockReturnValue(child);
579
+ const runtime = create();
580
+ await runtime.create(defaultConfig());
581
+ // Generate 1200 newline-terminated lines
582
+ const lines = Array.from({ length: 1200 }, (_, i) => `line-${i}`).join("\n") + "\n";
583
+ child.stdout.emit("data", Buffer.from(lines));
584
+ // Request all lines — should be capped at 1000
585
+ const output = await runtime.getOutput(makeHandle(), 2000);
586
+ const outputLines = output.split("\n");
587
+ expect(outputLines.length).toBeLessThanOrEqual(1000);
588
+ // Should contain the last line
589
+ expect(outputLines[outputLines.length - 1]).toBe("line-1199");
590
+ // Should NOT contain the first lines (they were truncated)
591
+ expect(output).not.toContain("line-0\n");
592
+ });
593
+ });
594
+ // =========================================================================
595
+ // Windows compatibility
596
+ // =========================================================================
597
+ describe("Windows compatibility", () => {
598
+ afterEach(() => {
599
+ mockIsWindows.mockReturnValue(false);
600
+ mockKillProcessTree.mockResolvedValue(undefined);
601
+ });
602
+ it("does not set detached:true on win32", async () => {
603
+ mockIsWindows.mockReturnValue(true);
604
+ const child = createWindowsMockChild();
605
+ mockSpawn.mockReturnValue(child);
606
+ const runtime = create();
607
+ await runtime.create(defaultConfig({ sessionId: "win-spawn-test" }));
608
+ // On Windows the PTY host is spawned with detached: true (must survive parent exit)
609
+ const [, , spawnOpts] = mockSpawn.mock.calls[0];
610
+ expect(spawnOpts.detached).toBe(true);
611
+ });
612
+ it("sets detached:true on non-Windows", async () => {
613
+ mockIsWindows.mockReturnValue(false);
614
+ const child = createMockChild();
615
+ mockSpawn.mockReturnValue(child);
616
+ const runtime = create();
617
+ await runtime.create(defaultConfig({ sessionId: "unix-spawn-test" }));
618
+ const [, , spawnOpts] = mockSpawn.mock.calls[0];
619
+ expect(spawnOpts.detached).toBe(true);
620
+ });
621
+ it("uses ptyHostKill + killProcessTree instead of process.kill(-pid) on win32", async () => {
622
+ mockIsWindows.mockReturnValue(true);
623
+ const child = createWindowsMockChild(12345);
624
+ mockSpawn.mockReturnValue(child);
625
+ const runtime = create();
626
+ const handle = await runtime.create(defaultConfig({ sessionId: "win-kill-test" }));
627
+ const processSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
628
+ await runtime.destroy(handle);
629
+ // On Windows destroy() calls ptyHostKill via the named pipe
630
+ expect(mockPtyHostKill).toHaveBeenCalledWith(expect.stringContaining("win-kill-test"));
631
+ // killProcessTree should be called with the ptyHostPid (child.pid)
632
+ expect(mockKillProcessTree).toHaveBeenCalledWith(12345, "SIGKILL");
633
+ // process.kill(-pid) should NOT have been called
634
+ expect(processSpy).not.toHaveBeenCalledWith(-12345, expect.anything());
635
+ processSpy.mockRestore();
636
+ });
637
+ it("calls ptyHostKill and killProcessTree(ptyHostPid) on win32 destroy when graceful shutdown times out", async () => {
638
+ mockIsWindows.mockReturnValue(true);
639
+ const child = createWindowsMockChild(12345);
640
+ mockSpawn.mockReturnValue(child);
641
+ // Simulate a pty-host that ignores MSG_KILL_REQ so destroy falls through
642
+ // to the SIGKILL path. Otherwise the probe (`process.kill(pid, 0)`) would
643
+ // see PID 12345 as already-gone and return early — which is the desired
644
+ // real-world behavior but defeats this test's intent.
645
+ const killSpy = vi.spyOn(process, "kill").mockReturnValue(true);
646
+ const runtime = create();
647
+ const handle = await runtime.create(defaultConfig({ sessionId: "win-sigkill-test" }));
648
+ await runtime.destroy(handle);
649
+ expect(mockPtyHostKill).toHaveBeenCalledWith(expect.stringContaining("win-sigkill-test"));
650
+ expect(mockKillProcessTree).toHaveBeenCalledWith(12345, "SIGKILL");
651
+ killSpy.mockRestore();
652
+ });
653
+ });
654
+ // =========================================================================
655
+ // Per-instance isolation
656
+ // =========================================================================
657
+ describe("per-instance isolation", () => {
658
+ it("each create() call gets its own isolated processes map", async () => {
659
+ const child1 = createMockChild();
660
+ child1.pid = 11111;
661
+ const runtime1 = create();
662
+ const runtime2 = create();
663
+ mockSpawn.mockReturnValue(child1);
664
+ await runtime1.create(defaultConfig({ sessionId: "session-a" }));
665
+ const child2 = createMockChild();
666
+ child2.pid = 99999;
667
+ mockSpawn.mockReturnValue(child2);
668
+ await runtime2.create(defaultConfig({ sessionId: "session-a" }));
669
+ // Both runtimes can have the same session ID independently
670
+ expect(await runtime1.isAlive(makeHandle("session-a"))).toBe(true);
671
+ expect(await runtime2.isAlive(makeHandle("session-a"))).toBe(true);
672
+ });
673
+ });
674
+ //# sourceMappingURL=index.test.js.map