@testmuai/rook 0.1.0

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,456 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ mkdirSync,
4
+ rmSync,
5
+ writeFileSync,
6
+ chmodSync,
7
+ existsSync,
8
+ readFileSync,
9
+ } from "node:fs";
10
+ import { join, dirname } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { spawnSync, spawn } from "node:child_process";
13
+ import { EventEmitter } from "node:events";
14
+
15
+ const BIN_DIR = dirname(fileURLToPath(import.meta.url));
16
+
17
+ function fakePkgRoot(tag) {
18
+ return join(BIN_DIR, "node_modules", "@testmuai", `rook-node-${tag}`);
19
+ }
20
+
21
+ function writeFakePackage(tag, binName) {
22
+ const root = fakePkgRoot(tag);
23
+ mkdirSync(join(root, "bin"), { recursive: true });
24
+ writeFileSync(
25
+ join(root, "package.json"),
26
+ JSON.stringify({ name: `@testmuai/rook-node-${tag}` }),
27
+ );
28
+ writeFileSync(join(root, "bin", binName), "#!/bin/sh\n");
29
+ return join(root, "bin", binName);
30
+ }
31
+
32
+ describe("rook.cjs bundled runtime resolution", () => {
33
+ const originalPlatform = process.platform;
34
+ const originalArch = process.arch;
35
+
36
+ beforeEach(() => {
37
+ delete process.env.ROOK_SYSTEM_NODE;
38
+ // findBundledNode()'s require.resolve() caches a SUCCESSFUL resolution
39
+ // even after the resolved file is later deleted (verified directly:
40
+ // Node's Module._pathCache does not invalidate on filesystem changes).
41
+ // vi.resetModules() forces rook.cjs to be genuinely re-evaluated on the
42
+ // next import() (verified: without it, a plain `?t=` query on the
43
+ // import specifier does NOT force re-evaluation — the module runs once
44
+ // for the whole file) — but a stale-resolved SPECIFIER can still leak
45
+ // across tests within the same process even so, which is why "no
46
+ // platform package resolves" below deliberately probes a tag no other
47
+ // test in this file ever creates a real package for, rather than
48
+ // relying on re-evaluation alone to avoid the collision.
49
+ vi.resetModules();
50
+ });
51
+
52
+ afterEach(() => {
53
+ Object.defineProperty(process, "platform", { value: originalPlatform });
54
+ Object.defineProperty(process, "arch", { value: originalArch });
55
+ rmSync(join(BIN_DIR, "node_modules"), { recursive: true, force: true });
56
+ });
57
+
58
+ it("resolves the bundled node path when the platform package is installed (darwin-arm64)", async () => {
59
+ const expected = writeFakePackage("darwin-arm64", "node");
60
+ Object.defineProperty(process, "platform", { value: "darwin" });
61
+ Object.defineProperty(process, "arch", { value: "arm64" });
62
+
63
+ const { findBundledNode } = await import("./rook.cjs?t=" + Date.now());
64
+ expect(findBundledNode()).toBe(expected);
65
+ });
66
+
67
+ it("resolves the bundled node path on darwin-x64", async () => {
68
+ const expected = writeFakePackage("darwin-x64", "node");
69
+ Object.defineProperty(process, "platform", { value: "darwin" });
70
+ Object.defineProperty(process, "arch", { value: "x64" });
71
+
72
+ const { findBundledNode } = await import("./rook.cjs?t=" + Date.now());
73
+ expect(findBundledNode()).toBe(expected);
74
+ });
75
+
76
+ it("resolves the bundled node path on linux-arm64", async () => {
77
+ const expected = writeFakePackage("linux-arm64", "node");
78
+ Object.defineProperty(process, "platform", { value: "linux" });
79
+ Object.defineProperty(process, "arch", { value: "arm64" });
80
+
81
+ const { findBundledNode } = await import("./rook.cjs?t=" + Date.now());
82
+ expect(findBundledNode()).toBe(expected);
83
+ });
84
+
85
+ it("resolves node.exe on win32/x64", async () => {
86
+ const expected = writeFakePackage("win-x64", "node.exe");
87
+ Object.defineProperty(process, "platform", { value: "win32" });
88
+ Object.defineProperty(process, "arch", { value: "x64" });
89
+
90
+ const { findBundledNode } = await import("./rook.cjs?t=" + Date.now());
91
+ expect(findBundledNode()).toBe(expected);
92
+ });
93
+
94
+ it("returns null when no platform package resolves (linux-x64)", async () => {
95
+ // Deliberately linux-x64, not darwin-arm64: no other test in this file
96
+ // ever creates a real @testmuai/rook-node-linux-x64 package, so this
97
+ // is the only specifier require.resolve() ever sees for that exact
98
+ // string — guaranteeing a genuine, uncached MODULE_NOT_FOUND rather
99
+ // than accidentally observing a stale successful resolution left by an
100
+ // earlier test that happened to install the same-tagged package.
101
+ Object.defineProperty(process, "platform", { value: "linux" });
102
+ Object.defineProperty(process, "arch", { value: "x64" });
103
+
104
+ const { findBundledNode } = await import("./rook.cjs?t=" + Date.now());
105
+ expect(findBundledNode()).toBeNull();
106
+ });
107
+
108
+ it("returns null when ROOK_SYSTEM_NODE=1, even though the platform package resolves", async () => {
109
+ writeFakePackage("darwin-arm64", "node");
110
+ process.env.ROOK_SYSTEM_NODE = "1";
111
+ Object.defineProperty(process, "platform", { value: "darwin" });
112
+ Object.defineProperty(process, "arch", { value: "arm64" });
113
+
114
+ const { findBundledNode } = await import("./rook.cjs?t=" + Date.now());
115
+ expect(findBundledNode()).toBeNull();
116
+ });
117
+
118
+ it("returns null on an unsupported platform, even if a same-named package would resolve", async () => {
119
+ writeFakePackage("darwin-arm64", "node");
120
+ Object.defineProperty(process, "platform", { value: "aix" });
121
+
122
+ const { findBundledNode } = await import("./rook.cjs?t=" + Date.now());
123
+ expect(findBundledNode()).toBeNull();
124
+ });
125
+ });
126
+
127
+ // shouldForward is a pure function of its (signal, platform) arguments
128
+ // (unlike findBundledNode, which reads process.platform/arch directly), so
129
+ // no Object.defineProperty override or module reset is needed to exercise
130
+ // every branch.
131
+ describe("rook.cjs shouldForward", () => {
132
+ it("does not forward SIGINT or SIGHUP on win32 — child.kill() there is always an unconditional hard kill (SIGINT would duplicate a shutdown the child already gets natively) or throws outright (SIGHUP isn't in libuv's supported set on Windows)", async () => {
133
+ const { shouldForward } = await import("./rook.cjs");
134
+ expect(shouldForward("SIGINT", "win32")).toBe(false);
135
+ expect(shouldForward("SIGHUP", "win32")).toBe(false);
136
+ });
137
+
138
+ it("still forwards SIGTERM on win32 — the one signal with no native Windows delivery path to the child at all", async () => {
139
+ const { shouldForward } = await import("./rook.cjs");
140
+ expect(shouldForward("SIGTERM", "win32")).toBe(true);
141
+ });
142
+
143
+ it("forwards SIGINT, SIGTERM, and SIGHUP on POSIX platforms, unchanged from before the win32 fix", async () => {
144
+ const { shouldForward } = await import("./rook.cjs");
145
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
146
+ expect(shouldForward(signal, "darwin")).toBe(true);
147
+ expect(shouldForward(signal, "linux")).toBe(true);
148
+ }
149
+ });
150
+ });
151
+
152
+ // wireSignalForwarding is the actual behavioral fix — shouldForward alone
153
+ // only proves which signals are eligible, not that the surrounding
154
+ // signaled/spawned/killFailed control flow reacts to them correctly. A
155
+ // fake child (plain EventEmitter + a stubbed kill()) and a fake process
156
+ // object (records what it's told rather than doing it) let these run
157
+ // fully deterministically and in-process: driving the real `process`
158
+ // through this code would register real signal listeners on the test
159
+ // runner and could call its real process.exit()/process.kill(), which is
160
+ // exactly why the OS-level tests further down spawn a genuine subprocess
161
+ // instead. Here we don't need real OS signal delivery or real process
162
+ // lifecycle — only that the function reacts to the events correctly.
163
+ describe("rook.cjs wireSignalForwarding", () => {
164
+ function fakeChild() {
165
+ const child = new EventEmitter();
166
+ child.killCalls = [];
167
+ child.kill = vi.fn((signal) => {
168
+ child.killCalls.push(signal);
169
+ });
170
+ return child;
171
+ }
172
+
173
+ function fakeProcess() {
174
+ const listeners = new Map();
175
+ const events = [];
176
+ return {
177
+ events,
178
+ pid: 4242,
179
+ on(signal, fn) {
180
+ if (!listeners.has(signal)) listeners.set(signal, new Set());
181
+ listeners.get(signal).add(fn);
182
+ },
183
+ removeListener(signal, fn) {
184
+ listeners.get(signal)?.delete(fn);
185
+ },
186
+ emit(signal) {
187
+ for (const fn of listeners.get(signal) ?? []) fn(signal);
188
+ },
189
+ exit(code) {
190
+ events.push({ type: "exit", code });
191
+ },
192
+ kill(pid, signal) {
193
+ events.push({ type: "kill", pid, signal });
194
+ },
195
+ stderr: {
196
+ write(text) {
197
+ events.push({ type: "stderr", text });
198
+ },
199
+ },
200
+ };
201
+ }
202
+
203
+ it("falls back to the system Node path even when a signal was forwarded just before the spawn failure is detected — the race a naive signaled-only check would hang on", async () => {
204
+ const { wireSignalForwarding } = await import("./rook.cjs");
205
+ const child = fakeChild();
206
+ const proc = fakeProcess();
207
+ const onSpawnFailure = vi.fn();
208
+
209
+ wireSignalForwarding(child, {
210
+ bundledNode: "/fake/node",
211
+ entry: "/fake/entry.js",
212
+ platform: "linux",
213
+ proc,
214
+ onSpawnFailure,
215
+ });
216
+
217
+ // A signal arrives and is forwarded before the child has ever
218
+ // confirmed it actually spawned.
219
+ proc.emit("SIGTERM");
220
+ expect(child.killCalls).toEqual(["SIGTERM"]);
221
+
222
+ // The async spawn failure is now detected — no 'spawn' event was ever
223
+ // emitted, so this can only be a genuine spawn failure, not a failed
224
+ // kill, regardless of the signal that already landed.
225
+ child.emit("error", new Error("spawn ENOENT"));
226
+
227
+ expect(onSpawnFailure).toHaveBeenCalledTimes(1);
228
+ expect(onSpawnFailure).toHaveBeenCalledWith("/fake/entry.js");
229
+ });
230
+
231
+ it("does not launch a second CLI instance when a signal delivery fails after the child is confirmed alive — waits for the real exit instead", async () => {
232
+ const { wireSignalForwarding } = await import("./rook.cjs");
233
+ const child = fakeChild();
234
+ const proc = fakeProcess();
235
+ const onSpawnFailure = vi.fn();
236
+
237
+ wireSignalForwarding(child, {
238
+ bundledNode: "/fake/node",
239
+ entry: "/fake/entry.js",
240
+ platform: "linux",
241
+ proc,
242
+ onSpawnFailure,
243
+ });
244
+
245
+ child.emit("spawn");
246
+ proc.emit("SIGTERM");
247
+ expect(child.killCalls).toEqual(["SIGTERM"]);
248
+
249
+ // The kill attempt itself failed (delivered as 'error', not a throw) —
250
+ // the child may still be alive.
251
+ child.emit("error", new Error("kill EPERM"));
252
+ expect(onSpawnFailure).not.toHaveBeenCalled();
253
+
254
+ // The child genuinely exits later (the forwarded signal landing after
255
+ // all, or the child dying on its own) — normal cleanup must still run,
256
+ // proving settled was never latched by the branch above.
257
+ child.emit("exit", null, "SIGTERM");
258
+ expect(proc.events).toContainEqual({
259
+ type: "kill",
260
+ pid: proc.pid,
261
+ signal: "SIGTERM",
262
+ });
263
+ });
264
+
265
+ it("force-exits on the next signal after a kill attempt has already failed, instead of swallowing every later signal forever", async () => {
266
+ const { wireSignalForwarding } = await import("./rook.cjs");
267
+ const child = fakeChild();
268
+ child.kill = vi.fn(() => {
269
+ throw new Error("unsupported signal");
270
+ });
271
+ const proc = fakeProcess();
272
+
273
+ wireSignalForwarding(child, {
274
+ bundledNode: "/fake/node",
275
+ entry: "/fake/entry.js",
276
+ platform: "win32",
277
+ proc,
278
+ onSpawnFailure: vi.fn(),
279
+ });
280
+
281
+ proc.emit("SIGTERM"); // shouldForward("SIGTERM", "win32") is true; kill() throws
282
+ expect(proc.events.some((e) => e.type === "exit")).toBe(false);
283
+
284
+ proc.emit("SIGTERM"); // killFailed already true — give up and force-exit
285
+ expect(proc.events).toContainEqual({ type: "exit", code: 1 });
286
+ });
287
+
288
+ it("listens for SIGINT on win32 but never calls child.kill() for it — the observe-without-forwarding path", async () => {
289
+ const { wireSignalForwarding } = await import("./rook.cjs");
290
+ const child = fakeChild();
291
+ const proc = fakeProcess();
292
+
293
+ wireSignalForwarding(child, {
294
+ bundledNode: "/fake/node",
295
+ entry: "/fake/entry.js",
296
+ platform: "win32",
297
+ proc,
298
+ onSpawnFailure: vi.fn(),
299
+ });
300
+
301
+ proc.emit("SIGINT");
302
+ expect(child.killCalls).toEqual([]);
303
+ });
304
+ });
305
+
306
+ // Spawns rook.cjs as a genuine subprocess (via the real system Node, not
307
+ // import()) rather than calling main() in-process — main() calls
308
+ // process.exit()/process.kill() on every path, which would kill the test
309
+ // runner itself. This is also the only way to observe what a real caller
310
+ // sees: Node's own spawnSync result.signal, not a shell's collapsed exit
311
+ // code, which cannot tell "exited 143" and "killed by SIGTERM" apart.
312
+ describe("rook.cjs signal propagation (real subprocess)", () => {
313
+ const originalPlatform = process.platform;
314
+ const originalArch = process.arch;
315
+
316
+ beforeEach(() => {
317
+ delete process.env.ROOK_SYSTEM_NODE;
318
+ });
319
+
320
+ afterEach(() => {
321
+ Object.defineProperty(process, "platform", { value: originalPlatform });
322
+ Object.defineProperty(process, "arch", { value: originalArch });
323
+ rmSync(join(BIN_DIR, "node_modules"), { recursive: true, force: true });
324
+ rmSync(join(BIN_DIR, "child.pid"), { force: true });
325
+ });
326
+
327
+ // Both fixtures below need this file's own platform/arch (spawnSync's
328
+ // child inherits the real host, unlike the Object.defineProperty
329
+ // overrides used above, which only affect the current in-process
330
+ // module). Only darwin/linux are covered — there's no POSIX self-kill
331
+ // shell fixture for a win32 host, and this is exercising main()'s
332
+ // signal-handling logic, not per-OS behavior.
333
+ function hostTag() {
334
+ if (process.platform === "darwin" && process.arch === "arm64")
335
+ return "darwin-arm64";
336
+ if (process.platform === "darwin" && process.arch === "x64")
337
+ return "darwin-x64";
338
+ if (process.platform === "linux" && process.arch === "arm64")
339
+ return "linux-arm64";
340
+ if (process.platform === "linux" && process.arch === "x64")
341
+ return "linux-x64";
342
+ return null;
343
+ }
344
+
345
+ function writeExecutableFixture(tag, script) {
346
+ const root = fakePkgRoot(tag);
347
+ mkdirSync(join(root, "bin"), { recursive: true });
348
+ writeFileSync(
349
+ join(root, "package.json"),
350
+ JSON.stringify({ name: `@testmuai/rook-node-${tag}` }),
351
+ );
352
+ const binPath = join(root, "bin", "node");
353
+ writeFileSync(binPath, script);
354
+ chmodSync(binPath, 0o755);
355
+ return binPath;
356
+ }
357
+
358
+ it("propagates a bundled runtime's signal death instead of collapsing to exit 1", () => {
359
+ const tag = hostTag();
360
+ if (!tag) return; // no POSIX fixture for this host — see hostTag()
361
+ writeExecutableFixture(tag, "#!/bin/sh\nkill -TERM $$\n");
362
+
363
+ const result = spawnSync(process.execPath, [join(BIN_DIR, "rook.cjs")], {
364
+ cwd: BIN_DIR,
365
+ });
366
+
367
+ expect(result.signal).toBe("SIGTERM");
368
+ expect(result.status).toBeNull();
369
+ });
370
+
371
+ // Positive control for the test above: proves the fixture/spawn harness
372
+ // itself works and that a normal (non-signal) exit still passes its real
373
+ // code straight through unchanged — so the signal test failing "for
374
+ // real" (status stays null, no SIGTERM observed) can't be mistaken for a
375
+ // broken fixture rather than a broken fix.
376
+ it("exits with the bundled runtime's real exit code on a normal exit", () => {
377
+ const tag = hostTag();
378
+ if (!tag) return;
379
+ writeExecutableFixture(tag, "#!/bin/sh\nexit 7\n");
380
+
381
+ const result = spawnSync(process.execPath, [join(BIN_DIR, "rook.cjs")], {
382
+ cwd: BIN_DIR,
383
+ });
384
+
385
+ expect(result.signal).toBeNull();
386
+ expect(result.status).toBe(7);
387
+ });
388
+
389
+ // The two tests above cover a signal death starting IN the child and
390
+ // propagating UP to the trampoline. This one covers the other direction:
391
+ // a signal sent to the trampoline's own PID (docker stop, systemd
392
+ // KillMode=process, timeout(1), a supervisor targeting the advertised
393
+ // PID) has to be forwarded DOWN to the bundled child, or the child is
394
+ // left running, orphaned, with inherited stdio — reproduced directly
395
+ // before the fix (kill -TERM to just the trampoline's PID left the
396
+ // fixture child alive afterward). Needs async spawn(), not spawnSync():
397
+ // the trampoline must still be running when the signal arrives so there
398
+ // is something to send it to.
399
+ it("forwards a signal sent to just the trampoline's PID down to the bundled child, instead of orphaning it", async () => {
400
+ const tag = hostTag();
401
+ if (!tag) return;
402
+ const childPidFile = join(BIN_DIR, "child.pid");
403
+ rmSync(childPidFile, { force: true });
404
+ writeExecutableFixture(
405
+ tag,
406
+ `#!/bin/sh\necho $$ > ${JSON.stringify(childPidFile)}\nsleep 30\n`,
407
+ );
408
+
409
+ const trampoline = spawn(process.execPath, [join(BIN_DIR, "rook.cjs")], {
410
+ cwd: BIN_DIR,
411
+ stdio: "ignore",
412
+ });
413
+
414
+ const childPid = await waitForFileContent(childPidFile, 5000);
415
+ expect(childPid).toBeTruthy();
416
+
417
+ const exited = new Promise((resolve) => trampoline.on("exit", resolve));
418
+ // kill(pid) (no leading dash) always targets exactly that one
419
+ // process, never its process group — unlike an interactive
420
+ // terminal's Ctrl-C, which reaches the whole group and would mask
421
+ // this bug even without the fix.
422
+ process.kill(trampoline.pid, "SIGTERM");
423
+ await exited;
424
+ // Give the forwarded signal a moment to actually land before checking.
425
+ await new Promise((resolve) => setTimeout(resolve, 300));
426
+
427
+ expect(isAlive(Number(childPid))).toBe(false);
428
+ });
429
+ });
430
+
431
+ function waitForFileContent(path, timeoutMs) {
432
+ const deadline = Date.now() + timeoutMs;
433
+ return new Promise((resolve, reject) => {
434
+ const poll = () => {
435
+ if (existsSync(path)) {
436
+ resolve(readFileSync(path, "utf8").trim());
437
+ return;
438
+ }
439
+ if (Date.now() > deadline) {
440
+ reject(new Error(`timed out waiting for ${path}`));
441
+ return;
442
+ }
443
+ setTimeout(poll, 20);
444
+ };
445
+ poll();
446
+ });
447
+ }
448
+
449
+ function isAlive(pid) {
450
+ try {
451
+ process.kill(pid, 0);
452
+ return true;
453
+ } catch {
454
+ return false;
455
+ }
456
+ }