@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.
- package/LICENSE +22 -0
- package/dist/__tests__/index.test.d.ts +2 -0
- package/dist/__tests__/index.test.d.ts.map +1 -0
- package/dist/__tests__/index.test.js +674 -0
- package/dist/__tests__/index.test.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +489 -0
- package/dist/index.js.map +1 -0
- package/dist/pty-client.d.ts +55 -0
- package/dist/pty-client.d.ts.map +1 -0
- package/dist/pty-client.js +248 -0
- package/dist/pty-client.js.map +1 -0
- package/dist/pty-host.d.ts +51 -0
- package/dist/pty-host.d.ts.map +1 -0
- package/dist/pty-host.js +354 -0
- package/dist/pty-host.js.map +1 -0
- package/package.json +49 -0
|
@@ -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
|