401 lines
10 KiB
TypeScript
401 lines
10 KiB
TypeScript
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();
|
|
});
|
|
});
|