57dc91585d
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
110 lines
4.2 KiB
JavaScript
110 lines
4.2 KiB
JavaScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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)`
|
|
);
|
|
});
|
|
});
|