Files
Claude-Code-Monitor/client/src/hooks/__tests__/useRunStream.test.tsx
T
nntrivi2001 57dc91585d feat: Claude Code Monitor — lanes, pipelines and a merged workspace
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.
2026-07-30 14:39:03 +07:00

175 lines
5.9 KiB
TypeScript

/**
* @file useRunStream.test.tsx
* @description Covers `useRunStream`, the hook that owns the Run page's live
* envelope state: it subscribes to the event bus and folds `run_stream`
* envelopes into an array, forwards `run_status` / `run_input_ack` for the
* subscribed run id to the caller's callbacks, fires the id-agnostic
* `onAnyStatus` for every `run_status`, and disposes its subscription on
* unmount. `eventBus` is exercised for real (it is a plain in-memory pub/sub)
* with only its `subscribe` spied on, so the disposer assertion pins the real
* lifecycle rather than a mock's.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { eventBus } from "../../lib/eventBus";
import type { WSMessage } from "../../lib/types";
import { useRunStream, type Envelope } from "../useRunStream";
/** `run_stream` frame carrying one envelope for `id`. */
function streamMsg(id: string, envelope: unknown): WSMessage {
return { type: "run_stream", data: { id, envelope } } as WSMessage;
}
function statusMsg(id: string, status: string): WSMessage {
return { type: "run_status", data: { id, status, at: 1 } } as WSMessage;
}
const noopOpts = { onStatus: () => {}, onInputAck: () => {}, onAnyStatus: () => {} };
afterEach(() => {
vi.restoreAllMocks();
});
describe("useRunStream", () => {
it("merges envelopes for the subscribed run id in arrival order", () => {
const { result } = renderHook(() => useRunStream("run-1", noopOpts));
act(() => {
eventBus.publish(streamMsg("run-1", { type: "system", subtype: "init" }));
eventBus.publish(streamMsg("run-1", { type: "result", subtype: "success" }));
});
expect(result.current.envelopes.map((e) => (e as { type: string }).type)).toEqual([
"system",
"result",
]);
});
it("ignores an envelope for a different run id", () => {
const { result } = renderHook(() => useRunStream("run-1", noopOpts));
act(() => {
eventBus.publish(streamMsg("run-2", { type: "result" }));
});
expect(result.current.envelopes).toEqual([]);
});
it("updates a streaming assistant envelope in place instead of appending", () => {
const { result } = renderHook(() => useRunStream("run-1", noopOpts));
act(() => {
// message_start seeds the kept envelope + a streaming placeholder.
eventBus.publish(
streamMsg("run-1", {
type: "stream_event",
event: { type: "message_start", message: { id: "m1" } },
})
);
});
expect(result.current.envelopes).toHaveLength(2);
act(() => {
eventBus.publish(
streamMsg("run-1", {
type: "stream_event",
event: {
type: "content_block_start",
index: 0,
message: { id: "m1" },
content_block: { type: "text", text: "" },
},
})
);
eventBus.publish(
streamMsg("run-1", {
type: "stream_event",
event: {
type: "content_block_delta",
index: 0,
message: { id: "m1" },
delta: { type: "text_delta", text: "hi" },
},
})
);
});
// Still 2 envelopes: the deltas mutated the placeholder, they did not append.
expect(result.current.envelopes).toHaveLength(2);
const placeholder = result.current.envelopes[1] as {
message: { content: { text?: string }[]; _streaming?: boolean };
};
expect(placeholder.message.content[0]?.text).toBe("hi");
expect(placeholder.message._streaming).toBe(true);
});
it("invokes onStatus only for the subscribed run id, onAnyStatus for every run_status", () => {
const onStatus = vi.fn();
const onAnyStatus = vi.fn();
renderHook(() => useRunStream("run-1", { ...noopOpts, onStatus, onAnyStatus }));
act(() => {
eventBus.publish(statusMsg("run-1", "completed"));
eventBus.publish(statusMsg("run-2", "completed"));
});
expect(onStatus).toHaveBeenCalledTimes(1);
expect(onStatus.mock.calls[0]?.[0]).toMatchObject({ id: "run-1", status: "completed" });
expect(onAnyStatus).toHaveBeenCalledTimes(2);
});
it("invokes onInputAck only for the subscribed run id", () => {
const onInputAck = vi.fn();
renderHook(() => useRunStream("run-1", { ...noopOpts, onInputAck }));
act(() => {
eventBus.publish({ type: "run_input_ack", data: { id: "run-2" } } as WSMessage);
eventBus.publish({ type: "run_input_ack", data: { id: "run-1" } } as WSMessage);
});
expect(onInputAck).toHaveBeenCalledTimes(1);
});
it("ignores every frame while the run id is null", () => {
const onAnyStatus = vi.fn();
const { result } = renderHook(() => useRunStream(null, { ...noopOpts, onAnyStatus }));
act(() => {
eventBus.publish(streamMsg("run-1", { type: "result" }));
eventBus.publish(statusMsg("run-1", "completed"));
});
expect(result.current.envelopes).toEqual([]);
expect(onAnyStatus).toHaveBeenCalledTimes(1); // id-agnostic by design
});
it("disposes the event bus subscription on unmount", () => {
const dispose = vi.fn();
const subscribe = vi.spyOn(eventBus, "subscribe").mockReturnValue(dispose);
const { unmount } = renderHook(() => useRunStream("run-1", noopOpts));
expect(subscribe).toHaveBeenCalledTimes(1);
expect(dispose).not.toHaveBeenCalled();
unmount();
expect(dispose).toHaveBeenCalledTimes(1);
});
it("exposes setEnvelopes so the page can seed and clear the list", () => {
const { result } = renderHook(() => useRunStream("run-1", noopOpts));
act(() => {
result.current.setEnvelopes([{ type: "user", message: { content: "hello" } } as Envelope]);
});
expect(result.current.envelopes).toHaveLength(1);
act(() => {
result.current.setEnvelopes([]);
});
expect(result.current.envelopes).toEqual([]);
});
});