fix(review round 1): daemon PAT-header auth, job pruning, session-split batches, streaming tars, seed cleanup, ping/pong, byte caps, branch switch, slug --, ctx cancel, Init:true, pagination, latest/before events; web newest-window history + load-older, offline busy gate, staleness guards, memo store, auth probe, scroll key, focus-visible, draft restore, poll dedup; 106+181 tests, coverage 96.2%/95.1%+
This commit is contained in:
+446
-299
@@ -1,336 +1,483 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ChatMessage, ToolState } from "./derive";
|
||||
import ChatStream, { Bubble, TypingIndicator } from "./ChatStream";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Repo, SessionListItem } from "./protocol";
|
||||
import { Route as ApiRoute } from "./protocol";
|
||||
import { fetchJson } from "./api";
|
||||
import SpawnView from "./SpawnView";
|
||||
import type { SessionsStore } from "./store";
|
||||
import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup";
|
||||
|
||||
function msg(partial: Partial<ChatMessage>): ChatMessage {
|
||||
const repo = (path: string, branch = "main"): Repo => ({
|
||||
path,
|
||||
name: path.split("/")[1] ?? path,
|
||||
namespace: path.split("/")[0] ?? "g",
|
||||
lastActivityAt: "2024-05-01T00:00:00Z",
|
||||
webUrl: `https://gl/${path}`,
|
||||
defaultBranch: branch,
|
||||
});
|
||||
|
||||
const PUSH_TOAST = vi.fn();
|
||||
|
||||
function makeStore(over: Partial<SessionsStore> = {}): SessionsStore {
|
||||
return {
|
||||
key: `k-${Math.random()}`,
|
||||
role: "assistant",
|
||||
text: "",
|
||||
thinking: null,
|
||||
toolCalls: [],
|
||||
toolCallId: null,
|
||||
streaming: false,
|
||||
...partial,
|
||||
sessions: [],
|
||||
state: "open",
|
||||
spawnJobs: [],
|
||||
refresh: async (): Promise<SessionListItem[]> =>
|
||||
fetchJson<SessionListItem[]>(ApiRoute.Sessions),
|
||||
subscribe: (): (() => void) => () => undefined,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const tool = (p: Partial<ToolState>): ToolState => ({
|
||||
id: "c1",
|
||||
name: "bash",
|
||||
args: "",
|
||||
running: false,
|
||||
isError: false,
|
||||
preview: "",
|
||||
...p,
|
||||
function tree(store: SessionsStore): React.ReactElement {
|
||||
return (
|
||||
<MemoryRouter initialEntries={["/new"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/new"
|
||||
element={<SpawnView store={store} pushToast={PUSH_TOAST} />}
|
||||
/>
|
||||
<Route path="/s/:id" element={<div data-testid="chat-route" />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
/** Flush pending microtasks + React effects (works under fake timers). */
|
||||
async function flush(ticks = 4): Promise<void> {
|
||||
for (let i = 0; i < ticks; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
PUSH_TOAST.mockClear();
|
||||
seedSettings();
|
||||
});
|
||||
|
||||
describe("Bubble", () => {
|
||||
it("renders plain text per role class", () => {
|
||||
const { container } = render(
|
||||
<Bubble
|
||||
msg={msg({ role: "user", text: "hi there" })}
|
||||
tools={new Map()}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector(".bubble-row.user")).not.toBeNull();
|
||||
expect(container.textContent).toContain("hi there");
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("SpawnView status", () => {
|
||||
it("shows checking state, then error when gitlab status fails", async () => {
|
||||
mockFetchJson((url) => {
|
||||
if (url.includes("/api/gitlab/status"))
|
||||
return jsonResponse({ error: "down" }, 500);
|
||||
return [];
|
||||
});
|
||||
render(tree(makeStore()));
|
||||
expect(screen.getByText("checking gitlab…")).toBeInTheDocument();
|
||||
await flush();
|
||||
expect(screen.getByText(/gitlab status failed: down/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SpawnView connect flow", () => {
|
||||
it("requires a token", async () => {
|
||||
mockFetchJson((url) => {
|
||||
if (url.endsWith("/api/gitlab/status"))
|
||||
return { connected: false, baseUrl: "https://gl" };
|
||||
return [];
|
||||
});
|
||||
render(tree(makeStore()));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||
await flush();
|
||||
expect(screen.getByText("token required")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders empty assistant text as nothing but shows nothing when empty", () => {
|
||||
const { container } = render(
|
||||
<Bubble msg={msg({ role: "assistant", text: "" })} tools={new Map()} />,
|
||||
);
|
||||
expect(container.querySelector(".bubble")?.children).toHaveLength(0);
|
||||
it("connects, clears the PAT, loads repos and shows the picker", async () => {
|
||||
let connected = false;
|
||||
mockFetchJson((url, init) => {
|
||||
if (url.endsWith("/api/gitlab/status"))
|
||||
return {
|
||||
connected,
|
||||
baseUrl: "https://gl",
|
||||
username: connected ? "alice" : undefined,
|
||||
};
|
||||
if (url.endsWith("/api/gitlab/connect") && init?.method === "POST") {
|
||||
connected = true;
|
||||
return { username: "alice" };
|
||||
}
|
||||
if (url.endsWith("/api/gitlab/repos"))
|
||||
return [repo("g/one"), repo("g/two")];
|
||||
return [];
|
||||
});
|
||||
render(tree(makeStore()));
|
||||
await flush();
|
||||
expect(screen.getByText("Connect GitLab")).toBeInTheDocument();
|
||||
|
||||
const pat = screen.getByLabelText(
|
||||
"GitLab personal access token",
|
||||
) as HTMLInputElement;
|
||||
fireEvent.input(pat, { target: { value: "glpat-x" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||
await flush();
|
||||
|
||||
expect(screen.getByText("g/one")).toBeInTheDocument();
|
||||
expect(screen.getByText("g/two")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("GitLab personal access token")).toBeNull();
|
||||
expect(screen.getByText("Repository")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toolResult collapses to a one-line preview, expands to full text", async () => {
|
||||
const long = `${"x".repeat(200)}`;
|
||||
render(
|
||||
<Bubble
|
||||
msg={msg({ role: "toolResult", text: long })}
|
||||
tools={new Map()}
|
||||
/>,
|
||||
);
|
||||
const details = screen
|
||||
.getByText("result")
|
||||
.closest("details") as HTMLDetailsElement;
|
||||
expect(details.open).toBe(false);
|
||||
expect(details.textContent).toContain("…");
|
||||
await userEvent.click(screen.getByText("result"));
|
||||
expect(details.open).toBe(true);
|
||||
expect(details.textContent).toContain(long);
|
||||
it("connect failure shows the error and keeps the gate", async () => {
|
||||
mockFetchJson((url, init) => {
|
||||
if (url.endsWith("/api/gitlab/status"))
|
||||
return { connected: false, baseUrl: "https://gl" };
|
||||
if (url.endsWith("/api/gitlab/connect") && init?.method === "POST")
|
||||
return jsonResponse({ error: "bad pat" }, 401);
|
||||
return [];
|
||||
});
|
||||
render(tree(makeStore()));
|
||||
await flush();
|
||||
fireEvent.input(screen.getByLabelText("GitLab personal access token"), {
|
||||
target: { value: "glpat-bad" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||
await flush();
|
||||
expect(screen.getByText("bad pat")).toBeInTheDocument();
|
||||
expect(screen.getByText("Connect GitLab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toolResult with short text keeps full one-line preview", () => {
|
||||
render(
|
||||
<Bubble
|
||||
msg={msg({ role: "toolResult", text: "short out" })}
|
||||
tools={new Map()}
|
||||
/>,
|
||||
);
|
||||
const details = screen
|
||||
.getByText("result")
|
||||
.closest("details") as HTMLDetailsElement;
|
||||
expect(details.textContent).toContain("short out");
|
||||
expect(details.textContent).not.toContain("…");
|
||||
it("connected on load fetches repos immediately", async () => {
|
||||
mockFetchJson((url) => {
|
||||
if (url.endsWith("/api/gitlab/status"))
|
||||
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/quick")];
|
||||
return [];
|
||||
});
|
||||
render(tree(makeStore()));
|
||||
await flush();
|
||||
expect(screen.getByText("g/quick")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SpawnView repo picker", () => {
|
||||
function connectedMock(): void {
|
||||
mockFetchJson((url) => {
|
||||
if (url.endsWith("/api/gitlab/status"))
|
||||
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||
if (url.endsWith("/api/gitlab/repos"))
|
||||
return [repo("g/alpha"), repo("g/beta", "dev")];
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
it("filters repos by query, keyboard selects, empty filter message", async () => {
|
||||
connectedMock();
|
||||
render(tree(makeStore()));
|
||||
await flush();
|
||||
expect(screen.getByText("g/alpha")).toBeInTheDocument();
|
||||
|
||||
const filter = screen.getByLabelText("Filter repositories");
|
||||
fireEvent.input(filter, { target: { value: "beta" } });
|
||||
expect(screen.queryByText("g/alpha")).toBeNull();
|
||||
expect(screen.getByText("g/beta")).toBeInTheDocument();
|
||||
|
||||
const item = screen
|
||||
.getByText("g/beta")
|
||||
.closest(".repo-item") as HTMLElement;
|
||||
fireEvent.keyDown(item, { key: "Enter" });
|
||||
expect(screen.getByLabelText("Branch")).toHaveValue("dev");
|
||||
|
||||
fireEvent.input(filter, { target: { value: "zzz" } });
|
||||
expect(screen.getByText("no matching repos")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("flattens whitespace in previews", () => {
|
||||
render(
|
||||
<Bubble
|
||||
msg={msg({ role: "toolResult", text: "a\n\n b c" })}
|
||||
tools={new Map()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getAllByText("a b c").length).toBeGreaterThan(0);
|
||||
it("shows loading repos while the list is pending", async () => {
|
||||
const gate = { resolve: null as ((v: unknown) => void) | null };
|
||||
mockFetchJson((url) => {
|
||||
if (url.endsWith("/api/gitlab/status"))
|
||||
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||
if (url.endsWith("/api/gitlab/repos"))
|
||||
return new Promise((res) => {
|
||||
gate.resolve = res;
|
||||
});
|
||||
return [];
|
||||
});
|
||||
render(tree(makeStore()));
|
||||
await flush();
|
||||
expect(screen.getByText("loading repos…")).toBeInTheDocument();
|
||||
gate.resolve?.([repo("g/late")]);
|
||||
await flush();
|
||||
expect(await screen.findByText("g/late")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("assistant tool calls attach tool cards", async () => {
|
||||
const tools = new Map<string, ToolState>([
|
||||
[
|
||||
"c1",
|
||||
tool({
|
||||
id: "c1",
|
||||
name: "bash",
|
||||
args: "ls -la",
|
||||
running: false,
|
||||
isError: false,
|
||||
preview: "file",
|
||||
}),
|
||||
it("spawn without selection shows pick-a-repo error", async () => {
|
||||
connectedMock();
|
||||
render(tree(makeStore()));
|
||||
await flush();
|
||||
expect(screen.getByLabelText("Spawn container")).toBeDisabled();
|
||||
expect(screen.getByText("select a repo above")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SpawnView spawn+poll", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it("spawns, polls until online, then navigates to the chat", async () => {
|
||||
const posts: Array<[string, RequestInit | undefined]> = [];
|
||||
let sessionsOnline = false; // flipped after the first poll tick
|
||||
const fetchMock = mockFetchJson((url, init) => {
|
||||
if (init?.method === "POST" && url.endsWith("/api/spawn")) {
|
||||
posts.push([url, init]);
|
||||
return { sessionId: "new-1", containerId: "abc123def456" };
|
||||
}
|
||||
if (url.endsWith("/api/sessions"))
|
||||
return sessionsOnline
|
||||
? [
|
||||
{
|
||||
id: "new-1",
|
||||
name: "spawned",
|
||||
cwd: "/w",
|
||||
model: "m",
|
||||
provider: "p",
|
||||
agent: true,
|
||||
repo: "g/proj",
|
||||
startedAt: 1,
|
||||
online: true,
|
||||
lastEventAt: 1,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
if (url.endsWith("/api/spawn/status")) return [];
|
||||
if (url.endsWith("/api/gitlab/status"))
|
||||
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/proj")];
|
||||
return [];
|
||||
});
|
||||
const store = makeStore();
|
||||
const { unmount } = render(tree(store));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("g/proj"));
|
||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||
await flush();
|
||||
|
||||
expect(posts).toHaveLength(1);
|
||||
expect((posts[0]?.[1] as RequestInit).body).toBe(
|
||||
JSON.stringify({ repo: "g/proj", branch: "main" }),
|
||||
);
|
||||
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
||||
expect(screen.getByText(/container abc123def456/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("waiting for session to come online…"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// first tick: session not online yet
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
});
|
||||
await flush();
|
||||
expect(screen.queryByTestId("chat-route")).toBeNull();
|
||||
|
||||
// each poll tick issues exactly one sessions fetch (refresh reuse, S7)
|
||||
const sessionsFetches = fetchMock.mock.calls.filter(([u]) =>
|
||||
String(u).endsWith("/api/sessions"),
|
||||
).length;
|
||||
expect(sessionsFetches).toBe(1);
|
||||
|
||||
// session comes online -> next tick navigates
|
||||
sessionsOnline = true;
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
});
|
||||
await flush();
|
||||
expect(screen.getByTestId("chat-route")).toBeInTheDocument();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("spawning view shows job line from status and spawn error text", async () => {
|
||||
mockFetchJson((url, init) => {
|
||||
if (init?.method === "POST" && url.endsWith("/api/spawn"))
|
||||
return { sessionId: "sx", containerId: "" };
|
||||
if (url.endsWith("/api/gitlab/status"))
|
||||
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
|
||||
return [];
|
||||
});
|
||||
const store = makeStore({
|
||||
spawnJobs: [
|
||||
{ sessionId: "sx", repo: "g/p", state: "cloning", containerId: "" },
|
||||
],
|
||||
]);
|
||||
render(
|
||||
<Bubble
|
||||
msg={msg({
|
||||
role: "assistant",
|
||||
text: "finished",
|
||||
toolCalls: [{ id: "c1", name: "bash", argsJson: "{}" }],
|
||||
})}
|
||||
tools={tools}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("🛠 bash")).toBeInTheDocument();
|
||||
expect(screen.getByText("finished")).toBeInTheDocument();
|
||||
|
||||
const summary = screen
|
||||
.getByText("🛠 bash")
|
||||
.closest("summary") as HTMLElement;
|
||||
const card = summary.closest("details") as HTMLDetailsElement;
|
||||
expect(card.open).toBe(false);
|
||||
await userEvent.click(summary);
|
||||
expect(card.open).toBe(true);
|
||||
expect(card.textContent).toContain("ls -la");
|
||||
expect(card.textContent).toContain("file");
|
||||
});
|
||||
render(tree(store));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("g/p"));
|
||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||
await flush();
|
||||
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
||||
expect(screen.getByText("g/p: cloning")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("tool card status variants: running, error, done", () => {
|
||||
const tools = new Map<string, ToolState>([
|
||||
["c1", tool({ id: "c1", running: true })],
|
||||
["c2", tool({ id: "c2", running: false, isError: true })],
|
||||
["c3", tool({ id: "c3", running: false, isError: false })],
|
||||
]);
|
||||
render(
|
||||
<Bubble
|
||||
msg={msg({
|
||||
role: "assistant",
|
||||
toolCalls: ["c1", "c2", "c3"].map((id) => ({
|
||||
id,
|
||||
name: `t-${id}`,
|
||||
argsJson: "{}",
|
||||
})),
|
||||
})}
|
||||
tools={tools}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("working…")).toBeInTheDocument();
|
||||
expect(screen.getByText("error")).toBeInTheDocument();
|
||||
expect(screen.getByText("done")).toBeInTheDocument();
|
||||
it("branch left blank sends repo only; poll refresh failure toasts", async () => {
|
||||
const bodies: string[] = [];
|
||||
mockFetchJson((url, init) => {
|
||||
if (init?.method === "POST" && url.endsWith("/api/spawn")) {
|
||||
bodies.push(String(init.body));
|
||||
return { sessionId: "new-2", containerId: "cccccccccccc" };
|
||||
}
|
||||
if (url.endsWith("/api/spawn/status")) return [];
|
||||
if (url.endsWith("/api/gitlab/status"))
|
||||
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/blank")];
|
||||
return [];
|
||||
});
|
||||
const failingRefresh = makeStore({
|
||||
refresh: (): Promise<SessionListItem[]> =>
|
||||
Promise.reject(new Error("boom")),
|
||||
});
|
||||
render(tree(failingRefresh));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("g/blank"));
|
||||
fireEvent.change(screen.getByLabelText("Branch"), {
|
||||
target: { value: "" },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||
await flush();
|
||||
expect(bodies).toEqual([JSON.stringify({ repo: "g/blank" })]);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
});
|
||||
await flush();
|
||||
expect(PUSH_TOAST).toHaveBeenCalledWith("boom");
|
||||
});
|
||||
|
||||
it("tool call with no matching state renders no card", () => {
|
||||
const { container } = render(
|
||||
<Bubble
|
||||
msg={msg({
|
||||
role: "assistant",
|
||||
toolCalls: [{ id: "ghost", name: "x", argsJson: "{}" }],
|
||||
})}
|
||||
tools={new Map()}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelectorAll(".tool-card")).toHaveLength(0);
|
||||
it("spawn POST failure shows the error", async () => {
|
||||
mockFetchJson((url, init) => {
|
||||
if (init?.method === "POST" && url.endsWith("/api/spawn"))
|
||||
return jsonResponse({ error: "no docker" }, 500);
|
||||
if (url.endsWith("/api/gitlab/status"))
|
||||
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/x")];
|
||||
return [];
|
||||
});
|
||||
render(tree(makeStore()));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("g/x"));
|
||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||
await flush();
|
||||
expect(screen.getByText("no docker")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("thinking block only for non-empty thinking", async () => {
|
||||
render(<Bubble msg={msg({ thinking: "because" })} tools={new Map()} />);
|
||||
const details = screen
|
||||
.getByText("thinking")
|
||||
.closest("details") as HTMLDetailsElement;
|
||||
await userEvent.click(screen.getByText("thinking"));
|
||||
expect(details.open).toBe(true);
|
||||
expect(details.textContent).toContain("because");
|
||||
it("polling gives up after the tick cap and stays on the page", async () => {
|
||||
let statusCalls = 0;
|
||||
mockFetchJson((url) => {
|
||||
if (url.endsWith("/api/spawn/status")) {
|
||||
statusCalls += 1;
|
||||
return [];
|
||||
}
|
||||
if (url.endsWith("/api/gitlab/status"))
|
||||
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/slow")];
|
||||
if (url.endsWith("/api/spawn"))
|
||||
return { sessionId: "slow-1", containerId: "d" };
|
||||
return [];
|
||||
});
|
||||
const { unmount } = render(tree(makeStore()));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("g/slow"));
|
||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||
await flush();
|
||||
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
||||
|
||||
const { container } = render(
|
||||
<Bubble msg={msg({ thinking: null })} tools={new Map()} />,
|
||||
);
|
||||
expect(container.querySelector(".thinking")).toBeNull();
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1500 * 402);
|
||||
});
|
||||
await flush();
|
||||
const afterCap = statusCalls;
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1500 * 10);
|
||||
});
|
||||
await flush();
|
||||
expect(statusCalls).toBe(afterCap);
|
||||
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("streaming bubble shows the caret", () => {
|
||||
const { container } = render(
|
||||
<Bubble msg={msg({ text: "par", streaming: true })} tools={new Map()} />,
|
||||
);
|
||||
expect(container.querySelector(".stream-caret")).not.toBeNull();
|
||||
it("spawn job line renders from store.spawnJobs", async () => {
|
||||
mockFetchJson((url, init) => {
|
||||
if (init?.method === "POST" && url.endsWith("/api/spawn"))
|
||||
return { sessionId: "sj-1", containerId: "cid" };
|
||||
if (url.endsWith("/api/spawn/status")) return [];
|
||||
if (url.endsWith("/api/gitlab/status"))
|
||||
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
|
||||
return [];
|
||||
});
|
||||
const store = makeStore();
|
||||
const { rerender } = render(tree(store));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("g/p"));
|
||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||
await flush();
|
||||
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
||||
|
||||
store.spawnJobs = [{ repo: "g/p", state: "cloning", sessionId: "sj-1" }];
|
||||
act(() => {
|
||||
rerender(tree(store));
|
||||
});
|
||||
expect(screen.getByText("g/p: cloning")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TypingIndicator", () => {
|
||||
it("renders three dots with aria-live", () => {
|
||||
const { container } = render(<TypingIndicator />);
|
||||
expect(container.querySelector('[aria-live="polite"]')).not.toBeNull();
|
||||
expect(container.querySelectorAll(".dot")).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatStream", () => {
|
||||
it("renders messages and typing indicator while busy with no open stream", () => {
|
||||
const { container, rerender } = render(
|
||||
<ChatStream
|
||||
messages={[msg({ key: "a", text: "one" })]}
|
||||
tools={new Map()}
|
||||
busy
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector(".typing")).not.toBeNull();
|
||||
|
||||
rerender(
|
||||
<ChatStream
|
||||
messages={[
|
||||
msg({ key: "a", text: "one" }),
|
||||
msg({ key: "b", streaming: true }),
|
||||
]}
|
||||
tools={new Map()}
|
||||
busy
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector(".typing")).toBeNull();
|
||||
rerender(
|
||||
<ChatStream
|
||||
messages={[msg({ key: "a", text: "one" })]}
|
||||
tools={new Map()}
|
||||
busy={false}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector(".typing")).toBeNull();
|
||||
});
|
||||
|
||||
it("sticks to bottom on new messages, un-pins on user scroll up, re-pins near bottom", () => {
|
||||
const { container, rerender } = render(
|
||||
<ChatStream
|
||||
messages={[msg({ key: "a" })]}
|
||||
tools={new Map()}
|
||||
busy={false}
|
||||
/>,
|
||||
);
|
||||
const scroller = container.querySelector(".chat-scroll") as HTMLElement;
|
||||
Object.defineProperty(scroller, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: 1000,
|
||||
});
|
||||
Object.defineProperty(scroller, "clientHeight", {
|
||||
configurable: true,
|
||||
value: 300,
|
||||
describe("SpawnSteps", () => {
|
||||
it("renders progress steps matching job state", async () => {
|
||||
mockFetchJson((url, init) => {
|
||||
if (init?.method === "POST" && url.endsWith("/api/spawn"))
|
||||
return { sessionId: "sp1", containerId: "" };
|
||||
if (url.endsWith("/api/gitlab/status"))
|
||||
return { connected: true, baseUrl: "https://gl", username: "a" };
|
||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
|
||||
return [];
|
||||
});
|
||||
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
// pinned: scrollTop at bottom
|
||||
scroller.scrollTop = 700;
|
||||
Object.defineProperty(scroller, "scrollTop", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 700,
|
||||
const store = makeStore({
|
||||
spawnJobs: [
|
||||
{ sessionId: "sp1", repo: "g/p", state: "building", containerId: "" },
|
||||
],
|
||||
});
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
rerender(
|
||||
<ChatStream
|
||||
messages={[msg({ key: "a" }), msg({ key: "b" })]}
|
||||
tools={new Map()}
|
||||
busy={false}
|
||||
/>,
|
||||
);
|
||||
expect(scroller.scrollTop).toBe(1000);
|
||||
render(tree(store));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("g/p"));
|
||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||
await flush();
|
||||
const steps = document.querySelectorAll(".spawn-progress .step");
|
||||
expect(steps).toHaveLength(4);
|
||||
expect(steps[0]?.className).toContain("done");
|
||||
expect(steps[1]?.className).toContain("current");
|
||||
expect(steps[2]?.className).toBe("step");
|
||||
});
|
||||
|
||||
// scroll far up -> unpin
|
||||
Object.defineProperty(scroller, "scrollTop", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 0,
|
||||
it("progress element carries progressbar semantics for the current step", async () => {
|
||||
mockFetchJson((url, init) => {
|
||||
if (init?.method === "POST" && url.endsWith("/api/spawn"))
|
||||
return { sessionId: "sp2", containerId: "" };
|
||||
if (url.endsWith("/api/gitlab/status"))
|
||||
return { connected: true, baseUrl: "https://gl", username: "a" };
|
||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
|
||||
return [];
|
||||
});
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
rerender(
|
||||
<ChatStream
|
||||
messages={[msg({ key: "a" }), msg({ key: "b" }), msg({ key: "c" })]}
|
||||
tools={new Map()}
|
||||
busy={false}
|
||||
/>,
|
||||
);
|
||||
expect(scroller.scrollTop).toBe(0);
|
||||
|
||||
// scroll near bottom (within 80px) -> pinned again
|
||||
Object.defineProperty(scroller, "scrollTop", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 940,
|
||||
const store = makeStore({
|
||||
spawnJobs: [
|
||||
{ sessionId: "sp2", repo: "g/p", state: "building", containerId: "" },
|
||||
],
|
||||
});
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
rerender(
|
||||
<ChatStream
|
||||
messages={[
|
||||
msg({ key: "a" }),
|
||||
msg({ key: "b" }),
|
||||
msg({ key: "c" }),
|
||||
msg({ key: "d" }),
|
||||
]}
|
||||
tools={new Map()}
|
||||
busy={false}
|
||||
/>,
|
||||
);
|
||||
expect(scroller.scrollTop).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("copy button", () => {
|
||||
it("copy button writes message text and flashes copied", async () => {
|
||||
const writeText = vi.fn(() => Promise.resolve());
|
||||
Object.assign(navigator, { clipboard: { writeText } });
|
||||
render(
|
||||
<Bubble
|
||||
msg={{ key: "k", role: "assistant", text: "copy me", thinking: null, toolCalls: [], toolCallId: null, streaming: false }}
|
||||
tools={new Map()}
|
||||
/>,
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: "Copy message" });
|
||||
fireEvent.click(btn);
|
||||
expect(writeText).toHaveBeenCalledWith("copy me");
|
||||
await waitFor(() => expect(screen.getByText("copied")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("user bubbles get a copy button, toolResults do not", () => {
|
||||
const { rerender } = render(
|
||||
<Bubble
|
||||
msg={{ key: "u", role: "user", text: "hi", thinking: null, toolCalls: [], toolCallId: null, streaming: false }}
|
||||
tools={new Map()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Copy message" })).toBeInTheDocument();
|
||||
rerender(
|
||||
<Bubble
|
||||
msg={{ key: "t", role: "toolResult", text: "r", thinking: null, toolCalls: [], toolCallId: "c1", streaming: false }}
|
||||
tools={new Map()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "Copy message" })).toBeNull();
|
||||
render(tree(store));
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("g/p"));
|
||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||
await flush();
|
||||
const bar = screen.getByRole("progressbar");
|
||||
expect(bar).toHaveAttribute("aria-valuemin", "1");
|
||||
expect(bar).toHaveAttribute("aria-valuemax", "4");
|
||||
expect(bar).toHaveAttribute("aria-valuenow", "2"); // building = step 2
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user