303 lines
8.4 KiB
TypeScript
303 lines
8.4 KiB
TypeScript
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
import { MemoryRouter, Route, Routes, useParams } from "react-router-dom";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import type { SessionListItem } from "./protocol";
|
|
import SessionsView from "./SessionsView";
|
|
import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup";
|
|
|
|
function session(p: Partial<SessionListItem>): SessionListItem {
|
|
return {
|
|
id: "s1",
|
|
name: null,
|
|
cwd: "/w",
|
|
model: "glm-5.3",
|
|
provider: "zai-renaud",
|
|
agent: false,
|
|
repo: null,
|
|
startedAt: 100,
|
|
online: true,
|
|
lastEventAt: null,
|
|
...p,
|
|
};
|
|
}
|
|
|
|
function renderView(
|
|
props: Partial<Parameters<typeof SessionsView>[0]> = {},
|
|
): ReturnType<typeof render> {
|
|
return render(
|
|
<MemoryRouter>
|
|
<SessionsView
|
|
sessions={[]}
|
|
onChanged={() => undefined}
|
|
pushToast={() => undefined}
|
|
{...props}
|
|
/>
|
|
</MemoryRouter>,
|
|
);
|
|
}
|
|
|
|
function Probe({ onVisit }: { onVisit: (path: string) => void }): null {
|
|
const { id } = useParams();
|
|
onVisit(`/s/${id ?? ""}`);
|
|
return null;
|
|
}
|
|
|
|
describe("SessionsView", () => {
|
|
it("offline sessions appear under the Archive section, not in active", () => {
|
|
renderView({
|
|
sessions: [
|
|
session({ id: "on", name: "live" }),
|
|
session({ id: "off", name: "dead", online: false }),
|
|
],
|
|
});
|
|
const cards = screen.getAllByRole("button", { name: /^Open session/ });
|
|
expect(cards.map((c) => c.getAttribute("aria-label"))).toEqual([
|
|
"Open session live",
|
|
"Open session dead",
|
|
]);
|
|
expect(screen.getByText("Archive")).toBeInTheDocument();
|
|
});
|
|
|
|
it("empty state message (after load window)", () => {
|
|
vi.useFakeTimers();
|
|
renderView({ sessions: [] });
|
|
act(() => {
|
|
vi.advanceTimersByTime(700);
|
|
});
|
|
expect(screen.getByText(/No active sessions/i)).toBeInTheDocument();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("skeleton while first load pending", () => {
|
|
renderView({ sessions: [] });
|
|
expect(
|
|
screen.getByRole("heading", { name: "Active sessions" }),
|
|
).toBeInTheDocument();
|
|
const skel = document.querySelector(".skeleton");
|
|
expect(skel).not.toBeNull();
|
|
});
|
|
|
|
it("renders cards sorted by last activity with fallbacks", () => {
|
|
renderView({
|
|
sessions: [
|
|
session({
|
|
id: "a",
|
|
name: null,
|
|
repo: "g/p",
|
|
lastEventAt: 5,
|
|
startedAt: 1,
|
|
}),
|
|
session({ id: "b", name: "named", lastEventAt: null, startedAt: 10 }),
|
|
session({
|
|
id: "c",
|
|
name: null,
|
|
repo: null,
|
|
cwd: "/fallback",
|
|
startedAt: 100,
|
|
online: true,
|
|
}),
|
|
],
|
|
});
|
|
const cards = screen.getAllByRole("button", { name: /^Open session/ });
|
|
expect(cards.map((c) => c.getAttribute("aria-label"))).toEqual([
|
|
"Open session c",
|
|
"Open session named",
|
|
"Open session a",
|
|
]);
|
|
expect(cards[0]?.textContent).toContain("/fallback");
|
|
expect(screen.getAllByTitle("online")).toHaveLength(3);
|
|
expect(screen.queryAllByTitle("offline")).toHaveLength(0);
|
|
});
|
|
|
|
it("shows repo, model, relative time and agent badge", () => {
|
|
renderView({
|
|
sessions: [
|
|
session({
|
|
id: "s1",
|
|
name: "named",
|
|
repo: "g/p",
|
|
lastEventAt: Date.now() - 5000,
|
|
agent: true,
|
|
}),
|
|
],
|
|
});
|
|
expect(screen.getAllByText("g/p")).toHaveLength(1);
|
|
expect(screen.getByText("glm-5.3")).toBeInTheDocument();
|
|
expect(screen.getByText("just now")).toBeInTheDocument();
|
|
expect(screen.getByText("agent")).toBeInTheDocument();
|
|
expect(
|
|
screen.getByLabelText("Stop container for named"),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("no badge/stop for non-agent sessions", () => {
|
|
renderView({
|
|
sessions: [session({ id: "s1", name: "local", repo: "g/p" })],
|
|
});
|
|
expect(screen.queryByText("agent")).toBeNull();
|
|
expect(screen.queryByLabelText(/Stop container/)).toBeNull();
|
|
});
|
|
|
|
it("keyboard Enter and Space open the session; other keys ignored", () => {
|
|
const probe = vi.fn();
|
|
render(
|
|
<MemoryRouter initialEntries={["/"]}>
|
|
<Routes>
|
|
<Route
|
|
path="/"
|
|
element={
|
|
<SessionsView
|
|
sessions={[session({ id: "s9", name: "kb" })]}
|
|
onChanged={() => undefined}
|
|
pushToast={() => undefined}
|
|
/>
|
|
}
|
|
/>
|
|
<Route path="/s/:id" element={<Probe onVisit={probe} />} />
|
|
</Routes>
|
|
</MemoryRouter>,
|
|
);
|
|
const card = screen.getByRole("button", { name: "Open session kb" });
|
|
fireEvent.keyDown(card, { key: "Tab" });
|
|
expect(probe).not.toHaveBeenCalled();
|
|
fireEvent.keyDown(card, { key: "Enter" });
|
|
expect(probe).toHaveBeenCalledWith("/s/s9");
|
|
});
|
|
|
|
it("Space key opens the session", () => {
|
|
const probe = vi.fn();
|
|
render(
|
|
<MemoryRouter initialEntries={["/"]}>
|
|
<Routes>
|
|
<Route
|
|
path="/"
|
|
element={
|
|
<SessionsView
|
|
sessions={[session({ id: "s8" })]}
|
|
onChanged={() => undefined}
|
|
pushToast={() => undefined}
|
|
/>
|
|
}
|
|
/>
|
|
<Route path="/s/:id" element={<Probe onVisit={probe} />} />
|
|
</Routes>
|
|
</MemoryRouter>,
|
|
);
|
|
const ev = fireEvent.keyDown(
|
|
screen.getByRole("button", { name: "Open session s8" }),
|
|
{
|
|
key: " ",
|
|
},
|
|
);
|
|
expect(ev).toBe(false); // preventDefault consumed the default action
|
|
expect(probe).toHaveBeenCalledWith("/s/s8");
|
|
});
|
|
|
|
it("Space and Enter keydown are default-prevented so the page does not scroll (S8)", () => {
|
|
const probe = vi.fn();
|
|
const renderKb = (): HTMLElement => {
|
|
render(
|
|
<MemoryRouter initialEntries={["/"]}>
|
|
<Routes>
|
|
<Route
|
|
path="/"
|
|
element={
|
|
<SessionsView
|
|
sessions={[session({ id: "kb2" })]}
|
|
onChanged={() => undefined}
|
|
pushToast={() => undefined}
|
|
/>
|
|
}
|
|
/>
|
|
<Route path="/s/:id" element={<Probe onVisit={probe} />} />
|
|
</Routes>
|
|
</MemoryRouter>,
|
|
);
|
|
return screen.getByRole("button", { name: "Open session kb2" });
|
|
};
|
|
// document-level bubble listener observes the event AFTER React's
|
|
// delegated handler, so defaultPrevented reflects the component's call
|
|
const seen: boolean[] = [];
|
|
const rec = (e: Event): void => {
|
|
seen.push(e.defaultPrevented);
|
|
};
|
|
document.addEventListener("keydown", rec);
|
|
// Space: navigation unmounts the card, so each key gets a fresh render
|
|
fireEvent.keyDown(renderKb(), { key: " " });
|
|
fireEvent.keyDown(renderKb(), { key: "Enter" });
|
|
fireEvent.keyDown(renderKb(), { key: "Tab" });
|
|
document.removeEventListener("keydown", rec);
|
|
expect(seen).toEqual([true, true, false]);
|
|
expect(probe).toHaveBeenCalledWith("/s/kb2");
|
|
});
|
|
|
|
it("stop with unnamed session toasts the id", async () => {
|
|
seedSettings();
|
|
mockFetchJson(() => ({ ok: true }));
|
|
const pushToast = vi.fn();
|
|
renderView({
|
|
sessions: [session({ id: "s1", name: null, agent: true })],
|
|
pushToast,
|
|
onChanged: () => undefined,
|
|
});
|
|
fireEvent.click(screen.getByLabelText("Stop container for s1"));
|
|
await vi.waitFor(() =>
|
|
expect(pushToast).toHaveBeenCalledWith("stopped s1"),
|
|
);
|
|
});
|
|
|
|
it("stop button deletes container, toasts and refreshes", async () => {
|
|
seedSettings();
|
|
const fetchMock = mockFetchJson(() => ({ ok: true }));
|
|
const onChanged = vi.fn();
|
|
const pushToast = vi.fn();
|
|
renderView({
|
|
sessions: [session({ id: "s1", name: "worker", agent: true })],
|
|
onChanged,
|
|
pushToast,
|
|
});
|
|
fireEvent.click(screen.getByLabelText("Stop container for worker"));
|
|
await vi.waitFor(() => {
|
|
const call = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
|
expect(call[0]).toBe("http://srv/api/sessions/s1/container");
|
|
expect(call[1].method).toBe("DELETE");
|
|
});
|
|
await vi.waitFor(() =>
|
|
expect(pushToast).toHaveBeenCalledWith("stopped worker"),
|
|
);
|
|
expect(onChanged).toHaveBeenCalled();
|
|
});
|
|
|
|
it("stop failure toasts the error", async () => {
|
|
seedSettings();
|
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
jsonResponse({ error: "nope" }, 500),
|
|
);
|
|
const pushToast = vi.fn();
|
|
renderView({
|
|
sessions: [session({ id: "s1", name: "w", agent: true })],
|
|
pushToast,
|
|
onChanged: () => undefined,
|
|
});
|
|
fireEvent.click(screen.getByLabelText("Stop container for w"));
|
|
await vi.waitFor(() =>
|
|
expect(pushToast).toHaveBeenCalledWith("stop failed: nope"),
|
|
);
|
|
});
|
|
|
|
it("non-error stop failure path stringifies non-Error throws", async () => {
|
|
seedSettings();
|
|
vi.spyOn(globalThis, "fetch").mockRejectedValue("plain-string");
|
|
const pushToast = vi.fn();
|
|
renderView({
|
|
sessions: [session({ id: "s1", name: "w", agent: true })],
|
|
pushToast,
|
|
onChanged: () => undefined,
|
|
});
|
|
fireEvent.click(screen.getByLabelText("Stop container for w"));
|
|
await vi.waitFor(() =>
|
|
expect(pushToast).toHaveBeenCalledWith("stop failed: plain-string"),
|
|
);
|
|
});
|
|
});
|