test: ≥95% coverage — daemon 96.9% (go), web 95.5-99% (vitest 142 tests); store.ts hooks-order crash fix
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { EventFrame } from "./protocol";
|
||||
import ChatView from "./ChatView";
|
||||
import type { SessionsStore } from "./store";
|
||||
import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup";
|
||||
import type { SessionListItem } from "./protocol";
|
||||
|
||||
let seq: number = 0;
|
||||
function ev(type: string, extra: Partial<EventFrame> = {}): EventFrame {
|
||||
seq += 1;
|
||||
return { v: 1, sessionId: "s1", seq, ts: 0, type, ...extra } as EventFrame;
|
||||
}
|
||||
|
||||
const sessions: SessionListItem[] = [
|
||||
{
|
||||
id: "s1",
|
||||
name: "worker",
|
||||
cwd: "/w",
|
||||
model: "glm-5.3",
|
||||
provider: "zai-renaud",
|
||||
agent: false,
|
||||
repo: null,
|
||||
startedAt: 0,
|
||||
online: true,
|
||||
lastEventAt: 1,
|
||||
},
|
||||
];
|
||||
|
||||
function makeStore(over: Partial<SessionsStore> = {}): SessionsStore {
|
||||
return {
|
||||
sessions,
|
||||
state: "open",
|
||||
spawnJobs: [],
|
||||
refresh: async () => undefined,
|
||||
subscribe: (sessionId: string, onEvents: (events: EventFrame[]) => void): (() => void) => {
|
||||
currentSub = { sessionId, onEvents };
|
||||
return () => {
|
||||
if (currentSub !== null && currentSub.sessionId === sessionId) currentSub = null;
|
||||
};
|
||||
},
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
interface ActiveSub {
|
||||
sessionId: string;
|
||||
onEvents: (events: EventFrame[]) => void;
|
||||
}
|
||||
let currentSub: ActiveSub | null = null;
|
||||
|
||||
function push(events: EventFrame[]): void {
|
||||
act(() => currentSub?.onEvents(events));
|
||||
}
|
||||
|
||||
function renderChat(store: SessionsStore, path = "/s/s1"): ReturnType<typeof render> {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route path="/s/:id" element={<ChatView store={store} pushToast={pushToast} />} />
|
||||
<Route path="*" element={<div>OTHER</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
const pushToast = vi.fn();
|
||||
|
||||
function historyEvents(): EventFrame[] {
|
||||
seq = 0;
|
||||
return [
|
||||
ev("message_end", { message: { role: "user", id: "u1", text: "hello there", thinking: null, toolCalls: [], toolCallId: null } }),
|
||||
ev("agent_start"),
|
||||
ev("message_end", { message: { role: "assistant", id: "a1", text: "hi!", thinking: "hmm", toolCalls: [], toolCallId: null } }),
|
||||
ev("agent_settled"),
|
||||
];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
seq = 0;
|
||||
currentSub = null;
|
||||
pushToast.mockClear();
|
||||
seedSettings();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("ChatView", () => {
|
||||
it("renders history from REST on mount and subscribes to the WS stream", async () => {
|
||||
const fetchMock = mockFetchJson((url) => {
|
||||
if (url.startsWith("http://srv/api/sessions/s1/events")) return historyEvents();
|
||||
return [];
|
||||
});
|
||||
const store = makeStore();
|
||||
const { rerender } = renderChat(store);
|
||||
|
||||
await screen.findByText("hello there");
|
||||
expect(screen.getByText("hi!")).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => expect(currentSub).not.toBeNull());
|
||||
expect(currentSub?.sessionId).toBe("s1");
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
|
||||
// header shows session name + model + online dot
|
||||
expect(screen.getByText("worker")).toBeInTheDocument();
|
||||
expect(screen.getByText("glm-5.3")).toBeInTheDocument();
|
||||
expect(screen.getByTitle("online")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<MemoryRouter initialEntries={["/s/s1"]}>
|
||||
<Routes>
|
||||
<Route path="/s/:id" element={<ChatView store={store} pushToast={pushToast} />} />
|
||||
<Route path="*" element={<div>OTHER</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
});
|
||||
|
||||
it("live delta streaming appends into a streaming bubble and ends it", async () => {
|
||||
mockFetchJson(() => []);
|
||||
renderChat(makeStore());
|
||||
await waitFor(() => expect(currentSub).not.toBeNull());
|
||||
|
||||
push([
|
||||
ev("message_start", { message: { role: "assistant", id: "a9", text: "", thinking: null, toolCalls: [], toolCallId: null } }),
|
||||
]);
|
||||
push([ev("message_update", { delta: "Hel" })]);
|
||||
push([ev("message_update", { delta: "lo" })]);
|
||||
expect(screen.getByText("Hello")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Send message")).toBeNull();
|
||||
expect(screen.getByLabelText("Abort current run")).toBeInTheDocument();
|
||||
|
||||
push([
|
||||
ev("message_end", { message: { role: "assistant", id: "a9", text: "Hello world", thinking: null, toolCalls: [], toolCallId: null } }),
|
||||
ev("agent_settled"),
|
||||
]);
|
||||
expect(screen.getByText("Hello world")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Send message")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("typing indicator shows while busy with no open stream", async () => {
|
||||
mockFetchJson(() => []);
|
||||
const { container } = renderChat(makeStore());
|
||||
await waitFor(() => expect(currentSub).not.toBeNull());
|
||||
|
||||
push([ev("agent_start")]);
|
||||
expect(container.querySelector(".typing")).not.toBeNull();
|
||||
|
||||
push([ev("agent_settled")]);
|
||||
expect(container.querySelector(".typing")).toBeNull();
|
||||
});
|
||||
|
||||
it("409 on send toasts 'session offline'", async () => {
|
||||
let n = 0;
|
||||
mockFetchJson((_url, init) => {
|
||||
if (init?.method === "POST") {
|
||||
n += 1;
|
||||
return jsonResponse({ error: "session offline" }, n === 1 ? 409 : 200);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
renderChat(makeStore());
|
||||
const ta = screen.getByLabelText("Message");
|
||||
await userEvent.type(ta, "go");
|
||||
await userEvent.click(screen.getByLabelText("Send message"));
|
||||
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("session offline"));
|
||||
});
|
||||
|
||||
it("non-409 send failure toasts the error message", async () => {
|
||||
mockFetchJson((_url, init) => {
|
||||
if (init?.method === "POST") return jsonResponse({ error: "nope" }, 500);
|
||||
return [];
|
||||
});
|
||||
renderChat(makeStore());
|
||||
await userEvent.type(screen.getByLabelText("Message"), "go");
|
||||
await userEvent.click(screen.getByLabelText("Send message"));
|
||||
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("nope"));
|
||||
});
|
||||
|
||||
it("Enter sends, Shift+Enter adds a newline, send disabled while empty or sending", async () => {
|
||||
const fetchMock = mockFetchJson((_url, init) => (init?.method === "POST" ? { ok: true } : []));
|
||||
renderChat(makeStore());
|
||||
const ta = screen.getByLabelText("Message") as HTMLTextAreaElement;
|
||||
const send = screen.getByLabelText("Send message") as HTMLButtonElement;
|
||||
expect(send).toBeDisabled();
|
||||
|
||||
await userEvent.type(ta, "hello");
|
||||
expect(send).not.toBeDisabled();
|
||||
|
||||
fireEvent.keyDown(ta, { key: "Enter", shiftKey: true });
|
||||
fireEvent.click(send);
|
||||
await userEvent.clear(ta);
|
||||
fireEvent.keyDown(ta, { key: "Enter" }); // empty draft: send() early-returns
|
||||
await vi.waitFor(() => {
|
||||
const post = fetchMock.mock.calls.find((c) => (c[1] as RequestInit | undefined)?.method === "POST");
|
||||
expect(post).toBeDefined();
|
||||
expect((post?.[1] as RequestInit).body).toBe(JSON.stringify({ message: "hello" }));
|
||||
});
|
||||
expect(ta.value).toBe("");
|
||||
});
|
||||
|
||||
it("abort posts to the abort route", async () => {
|
||||
const fetchMock = mockFetchJson((_url, init) => (init?.method === "POST" ? { ok: true } : []));
|
||||
renderChat(makeStore());
|
||||
push([ev("agent_start")]);
|
||||
await userEvent.click(screen.getByLabelText("Abort current run"));
|
||||
await vi.waitFor(() => {
|
||||
const abortCall = fetchMock.mock.calls.find(
|
||||
(c) => (c[1] as RequestInit | undefined)?.method === "POST" && String(c[0]).endsWith("/abort")
|
||||
);
|
||||
expect(abortCall).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("abort failure toasts", async () => {
|
||||
mockFetchJson((url, init) => {
|
||||
if (init?.method === "POST" && url.endsWith("/abort")) return jsonResponse({ error: "abort failed" }, 500);
|
||||
return [];
|
||||
});
|
||||
renderChat(makeStore());
|
||||
push([ev("agent_start")]);
|
||||
await userEvent.click(screen.getByLabelText("Abort current run"));
|
||||
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("abort failed"));
|
||||
});
|
||||
|
||||
it("history load failure shows the error page with a back link", async () => {
|
||||
mockFetchJson((url) => {
|
||||
if (url.includes("/events")) return jsonResponse({ error: "db gone" }, 500);
|
||||
return [];
|
||||
});
|
||||
renderChat(makeStore());
|
||||
expect(await screen.findByText(/Failed to load history: db gone/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Back to sessions")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refetches missed persisted events when the ws (re)opens", async () => {
|
||||
let after = "";
|
||||
mockFetchJson((url) => {
|
||||
const m = /[?&]after=(\d+)/.exec(url);
|
||||
if (m !== null) after = m[1] ?? "";
|
||||
if (url.includes("/events")) return [ev("message_end", { message: { role: "user", id: "u2", text: "caught up", thinking: null, toolCalls: [], toolCallId: null } })];
|
||||
return [];
|
||||
});
|
||||
const store = makeStore({ state: "closed" });
|
||||
const { rerender } = render(
|
||||
<MemoryRouter initialEntries={["/s/s1"]}>
|
||||
<Routes>
|
||||
<Route path="/s/:id" element={<ChatView store={store} pushToast={pushToast} />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
await screen.findByText("caught up");
|
||||
expect(after).toBe("0");
|
||||
|
||||
// reconnect: state closed -> open triggers the after=N refetch
|
||||
act(() => {
|
||||
rerender(
|
||||
<MemoryRouter initialEntries={["/s/s1"]}>
|
||||
<Routes>
|
||||
<Route path="/s/:id" element={<ChatView store={makeStore({ state: "open" })} pushToast={pushToast} />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
});
|
||||
await vi.waitFor(() => expect(after).toBe("1"));
|
||||
});
|
||||
|
||||
it("task panel toggle and tool render in both the aside and mobile view", async () => {
|
||||
mockFetchJson((url) => (url.includes("/events") ? historyEvents() : []));
|
||||
const { container } = renderChat(makeStore());
|
||||
|
||||
// running tool start with no end shows in the Working section
|
||||
await screen.findByText("hello there");
|
||||
const toggle = screen.getByLabelText("Toggle task panel");
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
await userEvent.click(toggle);
|
||||
expect(screen.getByLabelText("Toggle task panel")).toHaveAttribute("aria-expanded", "true");
|
||||
expect(container.querySelectorAll(".task-panel")).toHaveLength(2); // aside + mobile
|
||||
expect(screen.getAllByText("No tasks yet.")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("no session id param renders the empty page", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/s/"]}>
|
||||
<Routes>
|
||||
<Route path="/s/" element={<ChatView store={makeStore()} pushToast={pushToast} />} />
|
||||
<Route path="/s/:id" element={<div>CHAT</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.getByText("No session selected.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user