/** * @file Regression tests for scripts/hook-handler.js delivery behavior. The * handler must never block Claude Code waiting for the dashboard's HTTP * response — it delivers the event (flushes the request) and exits, leaving the * local server to process the buffered request on its own schedule. These tests * lock in that non-blocking contract so a future refactor can't reintroduce the * "stuck running hooks" stall (handler waiting up to the per-request timeout for * a slow/busy/wedged dashboard to reply). * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it } = require("node:test"); const assert = require("node:assert/strict"); const path = require("path"); const http = require("http"); const { spawn } = require("child_process"); const HANDLER = path.resolve(__dirname, "../../scripts/hook-handler.js"); // A mock dashboard that fully RECEIVES the request (records the body) but can be // told to delay its HTTP response — emulating a busy/slow/wedged server. function startMockServer({ responseDelayMs }) { const received = []; const server = http.createServer((req, res) => { let body = ""; req.on("data", (c) => (body += c)); req.on("end", () => { received.push(body); const reply = () => { try { res.end('{"ok":true}'); } catch { /* client already gone — expected when the handler exits early */ } }; if (responseDelayMs > 0) setTimeout(reply, responseDelayMs); else reply(); }); }); return new Promise((resolve) => { server.listen(0, "127.0.0.1", () => { resolve({ server, port: server.address().port, received }); }); }); } // Spawn the real handler, pipe a hook payload to stdin, and time how long it // takes to exit. function runHandler({ port, hookType = "Stop", payload }) { return new Promise((resolve, reject) => { const start = process.hrtime.bigint(); const child = spawn(process.execPath, [HANDLER, hookType], { env: { ...process.env, CLAUDE_DASHBOARD_PORT: String(port) }, stdio: ["pipe", "ignore", "ignore"], }); child.on("error", reject); child.on("exit", (code) => { resolve({ code, ms: Number(process.hrtime.bigint() - start) / 1e6 }); }); child.stdin.write(JSON.stringify(payload)); child.stdin.end(); }); } describe("hook-handler non-blocking delivery", () => { it("exits without waiting for a slow dashboard response, yet still delivers the event", async () => { // Server takes 5s to respond — far longer than the handler's own safety net. const { server, port, received } = await startMockServer({ responseDelayMs: 5000 }); try { const { code, ms } = await runHandler({ port, payload: { session_id: "hh-slow", stop_reason: "end_turn" }, }); assert.equal(code, 0, "handler should exit cleanly"); // Must NOT have waited on the 5s response (and must beat its 2.5s safety // net): a healthy deliver-and-exit is well under a second. assert.ok( ms < 2000, `handler should exit fast (was ${ms.toFixed(0)}ms) despite the 5s server response` ); // Delivery is preserved even though we exited before the reply. await new Promise((r) => setTimeout(r, 200)); assert.equal(received.length, 1, "event should be delivered exactly once"); assert.match(received[0], /hh-slow/, "delivered payload should carry the session id"); assert.match(received[0], /"hook_type":"Stop"/, "payload should be wrapped with hook_type"); } finally { server.close(); } }); it("exits promptly when no dashboard is listening (connection refused)", async () => { // Grab a port then close it so nothing is listening there. const { server, port } = await startMockServer({ responseDelayMs: 0 }); await new Promise((r) => server.close(r)); const { code, ms } = await runHandler({ port, payload: { session_id: "hh-dead", stop_reason: "end_turn" }, }); assert.equal(code, 0, "handler should still exit cleanly with no listener"); assert.ok( ms < 2000, `handler should exit fast on a refused connection (was ${ms.toFixed(0)}ms)` ); }); });