fix(review round 2): daemon symlink tars, orphan-safe removes, unique seed names, git ctx, IsErrNotFound, collision-free slugs, branch validation; web first-run WS, draft reset, IME guard, churn fix, test swap repair; 106 daemon + 190 web tests green
This commit is contained in:
+22
-4
@@ -102,6 +102,24 @@ describe("App gate", () => {
|
||||
);
|
||||
await screen.findByLabelText("Sessions");
|
||||
});
|
||||
|
||||
it("first-run: saving settings starts the ws manager (A)", async () => {
|
||||
seedApi();
|
||||
renderApp();
|
||||
fireEvent.input(screen.getByLabelText("Bearer token"), {
|
||||
target: { value: "tok" },
|
||||
});
|
||||
fireEvent.submit(
|
||||
screen
|
||||
.getByRole("button", { name: "Connect" })
|
||||
.closest("form") as HTMLFormElement,
|
||||
);
|
||||
// before the fix the effect deps never changed after the gate save,
|
||||
// so no manager ever started and the app stayed on the connecting banner
|
||||
await screen.findByLabelText("Sessions");
|
||||
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
||||
await screen.findByText(/reconnecting/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("App shell", () => {
|
||||
@@ -113,9 +131,7 @@ describe("App shell", () => {
|
||||
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
||||
const sock = FakeWebSocket.last();
|
||||
act(() => sock.serverOpen());
|
||||
expect(
|
||||
screen.getByTitle("ws open"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTitle("ws open")).toBeInTheDocument();
|
||||
|
||||
const sidebar = screen.getByLabelText("Sessions");
|
||||
const links = Array.from(sidebar.querySelectorAll("a.session-link")).map(
|
||||
@@ -229,7 +245,9 @@ describe("ErrorBoundary direct", () => {
|
||||
function Bomb(): React.ReactNode {
|
||||
throw new Error("kaboom-ui");
|
||||
}
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
const consoleSpy = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => undefined);
|
||||
function Harness(): React.ReactNode {
|
||||
return (
|
||||
<MemoryRouter>
|
||||
|
||||
+1
-1
@@ -88,7 +88,7 @@ export default function App() {
|
||||
const [configured, setConfigured] = useState<boolean>(getSettings() !== null);
|
||||
const [sidebarOpen, setSidebarOpen] = useState<boolean>(false);
|
||||
const { toasts, push } = useToasts();
|
||||
const store = useSessions(push);
|
||||
const store = useSessions(push, configured);
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
+367
-440
@@ -1,483 +1,410 @@
|
||||
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";
|
||||
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";
|
||||
|
||||
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 {
|
||||
function msg(partial: Partial<ChatMessage>): ChatMessage {
|
||||
return {
|
||||
sessions: [],
|
||||
state: "open",
|
||||
spawnJobs: [],
|
||||
refresh: async (): Promise<SessionListItem[]> =>
|
||||
fetchJson<SessionListItem[]>(ApiRoute.Sessions),
|
||||
subscribe: (): (() => void) => () => undefined,
|
||||
...over,
|
||||
key: `k-${Math.random()}`,
|
||||
role: "assistant",
|
||||
text: "",
|
||||
thinking: null,
|
||||
toolCalls: [],
|
||||
toolCallId: null,
|
||||
streaming: false,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
const tool = (p: Partial<ToolState>): ToolState => ({
|
||||
id: "c1",
|
||||
name: "bash",
|
||||
args: "",
|
||||
running: false,
|
||||
isError: false,
|
||||
preview: "",
|
||||
...p,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
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");
|
||||
});
|
||||
|
||||
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("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);
|
||||
});
|
||||
|
||||
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();
|
||||
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("…");
|
||||
});
|
||||
});
|
||||
|
||||
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("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("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",
|
||||
}),
|
||||
],
|
||||
]);
|
||||
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();
|
||||
|
||||
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();
|
||||
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");
|
||||
});
|
||||
|
||||
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("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("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();
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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("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("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();
|
||||
const { container } = render(
|
||||
<Bubble msg={msg({ thinking: null })} tools={new Map()} />,
|
||||
);
|
||||
expect(container.querySelector(".thinking")).toBeNull();
|
||||
});
|
||||
|
||||
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("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 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("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", () => {
|
||||
function stream(p: {
|
||||
messages: ChatMessage[];
|
||||
busy: boolean;
|
||||
hasOlder?: boolean;
|
||||
loadingOlder?: boolean;
|
||||
onOlder?: () => void;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<ChatStream
|
||||
messages={p.messages}
|
||||
tools={new Map()}
|
||||
busy={p.busy}
|
||||
hasOlder={p.hasOlder ?? false}
|
||||
loadingOlder={p.loadingOlder ?? false}
|
||||
onLoadOlder={p.onOlder ?? (() => undefined)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
it("renders messages and typing indicator while busy with no open stream", () => {
|
||||
const { container, rerender } = render(
|
||||
stream({ messages: [msg({ key: "a", text: "one" })], busy: true }),
|
||||
);
|
||||
expect(container.querySelector(".typing")).not.toBeNull();
|
||||
|
||||
describe("SpawnView spawn+poll", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
rerender(
|
||||
stream({
|
||||
messages: [
|
||||
msg({ key: "a", text: "one" }),
|
||||
msg({ key: "b", streaming: true }),
|
||||
],
|
||||
busy: true,
|
||||
}),
|
||||
);
|
||||
expect(container.querySelector(".typing")).toBeNull();
|
||||
rerender(
|
||||
stream({ messages: [msg({ key: "a", text: "one" })], busy: false }),
|
||||
);
|
||||
expect(container.querySelector(".typing")).toBeNull();
|
||||
});
|
||||
|
||||
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 [];
|
||||
it("sticks to bottom on new messages, un-pins on user scroll up, re-pins near bottom", () => {
|
||||
const { container, rerender } = render(
|
||||
stream({ messages: [msg({ key: "a" })], busy: false }),
|
||||
);
|
||||
const scroller = container.querySelector(".chat-scroll") as HTMLElement;
|
||||
Object.defineProperty(scroller, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: 1000,
|
||||
});
|
||||
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" }),
|
||||
Object.defineProperty(scroller, "clientHeight", {
|
||||
configurable: true,
|
||||
value: 300,
|
||||
});
|
||||
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
// pinned: scrollTop at bottom
|
||||
scroller.scrollTop = 700;
|
||||
Object.defineProperty(scroller, "scrollTop", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 700,
|
||||
});
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
rerender(
|
||||
stream({
|
||||
messages: [msg({ key: "a" }), msg({ key: "b" })],
|
||||
busy: false,
|
||||
}),
|
||||
);
|
||||
expect(scroller.scrollTop).toBe(1000);
|
||||
|
||||
// scroll far up -> unpin
|
||||
Object.defineProperty(scroller, "scrollTop", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 0,
|
||||
});
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
rerender(
|
||||
stream({
|
||||
messages: [msg({ key: "a" }), msg({ key: "b" }), msg({ key: "c" })],
|
||||
busy: false,
|
||||
}),
|
||||
);
|
||||
expect(scroller.scrollTop).toBe(0);
|
||||
|
||||
// scroll near bottom (within 80px) -> pinned again
|
||||
Object.defineProperty(scroller, "scrollTop", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 940,
|
||||
});
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
rerender(
|
||||
stream({
|
||||
messages: [
|
||||
msg({ key: "a" }),
|
||||
msg({ key: "b" }),
|
||||
msg({ key: "c" }),
|
||||
msg({ key: "d" }),
|
||||
],
|
||||
busy: false,
|
||||
}),
|
||||
);
|
||||
expect(scroller.scrollTop).toBe(1000);
|
||||
});
|
||||
|
||||
it("load-older button renders only when an older page exists and fires on click", () => {
|
||||
const onOlder = vi.fn();
|
||||
const { rerender } = render(
|
||||
stream({ messages: [msg({ key: "a" })], busy: false }),
|
||||
);
|
||||
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
||||
expect(screen.getByText(/container abc123def456/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("waiting for session to come online…"),
|
||||
).toBeInTheDocument();
|
||||
screen.queryByRole("button", { name: "Load older messages" }),
|
||||
).toBeNull();
|
||||
|
||||
// first tick: session not online yet
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
});
|
||||
await flush();
|
||||
expect(screen.queryByTestId("chat-route")).toBeNull();
|
||||
rerender(
|
||||
stream({
|
||||
messages: [msg({ key: "a" })],
|
||||
busy: false,
|
||||
hasOlder: true,
|
||||
onOlder,
|
||||
}),
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: "Load older messages" });
|
||||
fireEvent.click(btn);
|
||||
expect(onOlder).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 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();
|
||||
rerender(
|
||||
stream({
|
||||
messages: [msg({ key: "a" })],
|
||||
busy: false,
|
||||
hasOlder: true,
|
||||
loadingOlder: true,
|
||||
onOlder,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Load older messages" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
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(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();
|
||||
});
|
||||
describe("copy button", () => {
|
||||
it("copy button writes message text and flashes copied", async () => {
|
||||
vi.useFakeTimers();
|
||||
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 vi.waitFor(() =>
|
||||
expect(screen.getByText("copied")).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("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("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();
|
||||
|
||||
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("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" }];
|
||||
// feedback clears after COPY_FEEDBACK_MS (H)
|
||||
act(() => {
|
||||
rerender(tree(store));
|
||||
vi.advanceTimersByTime(1200);
|
||||
});
|
||||
expect(screen.getByText("g/p: cloning")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
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 [];
|
||||
});
|
||||
const store = makeStore({
|
||||
spawnJobs: [
|
||||
{ sessionId: "sp1", repo: "g/p", state: "building", containerId: "" },
|
||||
],
|
||||
});
|
||||
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");
|
||||
expect(screen.getByText("copy")).toBeInTheDocument();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
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 [];
|
||||
});
|
||||
const store = makeStore({
|
||||
spawnJobs: [
|
||||
{ sessionId: "sp2", repo: "g/p", state: "building", containerId: "" },
|
||||
],
|
||||
});
|
||||
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
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useEffect, useRef, useState } from "react";
|
||||
import type { ChatMessage, ToolState } from "./derive";
|
||||
|
||||
const PREVIEW_LEN: number = 120;
|
||||
const COPY_FEEDBACK_MS: number = 1200;
|
||||
const PIN_THRESHOLD_PX: number = 80;
|
||||
|
||||
function oneLine(text: string): string {
|
||||
const flat = text.replace(/\s+/g, " ").trim();
|
||||
@@ -18,7 +20,7 @@ function CopyButton({ text }: { text: string }): React.ReactNode {
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1200);
|
||||
window.setTimeout(() => setCopied(false), COPY_FEEDBACK_MS);
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -156,7 +158,8 @@ export default function ChatStream({
|
||||
const onScroll = (): void => {
|
||||
const el = scrollRef.current;
|
||||
if (el === null) return;
|
||||
pinnedRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||
pinnedRef.current =
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight < PIN_THRESHOLD_PX;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -313,6 +313,25 @@ describe("ChatView", () => {
|
||||
expect(ta.value).toBe("");
|
||||
});
|
||||
|
||||
it("Enter during IME composition confirms candidates, not a send (E)", async () => {
|
||||
const fetchMock = mockFetchJson((_url, init) =>
|
||||
init?.method === "POST" ? { ok: true } : [],
|
||||
);
|
||||
renderChat(makeStore());
|
||||
const ta = screen.getByLabelText("Message") as HTMLTextAreaElement;
|
||||
await userEvent.type(ta, "こん");
|
||||
fireEvent.keyDown(ta, { key: "Enter", isComposing: true });
|
||||
const posts = (): number =>
|
||||
fetchMock.mock.calls.filter(
|
||||
(c) => (c[1] as RequestInit | undefined)?.method === "POST",
|
||||
).length;
|
||||
expect(posts()).toBe(0);
|
||||
expect(ta.value).toBe("こん"); // composition text survives the confirm
|
||||
|
||||
fireEvent.keyDown(ta, { key: "Enter" }); // composition finished: real send
|
||||
await vi.waitFor(() => expect(posts()).toBe(1));
|
||||
});
|
||||
|
||||
it("abort posts to the abort route", async () => {
|
||||
const fetchMock = mockFetchJson((_url, init) =>
|
||||
init?.method === "POST" ? { ok: true } : [],
|
||||
@@ -810,6 +829,92 @@ describe("ChatView stale async guards (S1/S4) and send draft (S6)", () => {
|
||||
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("nope"));
|
||||
expect(ta.value).toBe("precious draft");
|
||||
});
|
||||
|
||||
it("session switch clears the draft instead of leaking it (B)", async () => {
|
||||
mockFetchJson(() => []);
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/s/s1"]}>
|
||||
<Switcher state="open" />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
const ta = screen.getByLabelText("Message") as HTMLTextAreaElement;
|
||||
await userEvent.type(ta, "secret for s1");
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "switch session" }),
|
||||
);
|
||||
await screen.findByText("ghost");
|
||||
expect(ta.value).toBe(""); // s1's draft must not appear in ghost's composer
|
||||
});
|
||||
|
||||
it("session switch resets a stuck sending flag (B)", async () => {
|
||||
let releaseSend: ((v: unknown) => void) | null = null;
|
||||
mockFetchJson((_url, init) => {
|
||||
if (init?.method === "POST")
|
||||
return new Promise<unknown>((res) => {
|
||||
releaseSend = res;
|
||||
});
|
||||
return [];
|
||||
});
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/s/s1"]}>
|
||||
<Switcher state="open" />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
const ta = screen.getByLabelText("Message") as HTMLTextAreaElement;
|
||||
await userEvent.type(ta, "hangs");
|
||||
fireEvent.keyDown(ta, { key: "Enter" }); // send starts and never settles
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "switch session" }),
|
||||
);
|
||||
await screen.findByText("ghost");
|
||||
const ta2 = screen.getByLabelText("Message") as HTMLTextAreaElement;
|
||||
await userEvent.type(ta2, "fresh");
|
||||
// sending=true surviving the switch would keep the ghost button disabled
|
||||
expect(screen.getByLabelText("Send message")).toBeEnabled();
|
||||
await act(async () => {
|
||||
releaseSend?.([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("resubscribes only when the subscribe identity (manager) changes, not on store churn (G)", async () => {
|
||||
mockFetchJson(() => []);
|
||||
let subscribeCalls: number = 0;
|
||||
const makeSubscribe =
|
||||
(): SessionsStore["subscribe"] => (sessionId, onEvents) => {
|
||||
subscribeCalls += 1;
|
||||
currentSub = { sessionId, onEvents };
|
||||
return () => {
|
||||
if (currentSub !== null && currentSub.sessionId === sessionId)
|
||||
currentSub = null;
|
||||
};
|
||||
};
|
||||
const liveSubscribe = makeSubscribe();
|
||||
const { rerender } = renderChat({
|
||||
...makeStore(),
|
||||
subscribe: liveSubscribe,
|
||||
});
|
||||
await waitFor(() => expect(subscribeCalls).toBe(1));
|
||||
|
||||
// store identity changes with every session_list frame; the live
|
||||
// subscription must survive instead of unsubscribe/resubscribe churn
|
||||
const churned = makeStore({
|
||||
sessions: [{ ...sessions[0]!, online: false }],
|
||||
});
|
||||
rerenderChatAgain(rerender, { ...churned, subscribe: liveSubscribe });
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
expect(subscribeCalls).toBe(1);
|
||||
expect(currentSub).not.toBeNull();
|
||||
|
||||
// manager replaced -> new subscribe identity -> resubscribe happens
|
||||
rerenderChatAgain(rerender, { ...makeStore(), subscribe: makeSubscribe() });
|
||||
await waitFor(() => expect(subscribeCalls).toBe(2));
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatView unknown session", () => {
|
||||
|
||||
+10
-2
@@ -76,6 +76,10 @@ export default function ChatView({ store, pushToast }: Props) {
|
||||
setEvents([]);
|
||||
setHasOlder(false);
|
||||
lastSeqRef.current = 0;
|
||||
// a draft (or stuck sending state) from the previous session must not
|
||||
// leak into the new one
|
||||
setDraft("");
|
||||
setSending(false);
|
||||
let alive = true;
|
||||
void (async () => {
|
||||
try {
|
||||
@@ -96,11 +100,13 @@ export default function ChatView({ store, pushToast }: Props) {
|
||||
};
|
||||
}, [sessionId, applyEvents]);
|
||||
|
||||
// ws subscription
|
||||
// ws subscription: dep on store.subscribe (stable per manager) rather than
|
||||
// the whole store object — every sessions-list change would otherwise
|
||||
// churn unsubscribe/resubscribe and can drop events in the gap
|
||||
useEffect(() => {
|
||||
if (sessionId.length === 0 || store.state !== "open") return;
|
||||
return store.subscribe(sessionId, applyEvents);
|
||||
}, [sessionId, store.state, store, applyEvents]);
|
||||
}, [sessionId, store.state, store.subscribe, applyEvents]);
|
||||
|
||||
// missed events after reconnect (persisted only); a response for a
|
||||
// previous session must not merge here nor touch the cursor (S1)
|
||||
@@ -187,6 +193,8 @@ export default function ChatView({ store, pushToast }: Props) {
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>): void => {
|
||||
// IME composition: Enter confirms the candidate window, not a send
|
||||
if (e.nativeEvent.isComposing) return;
|
||||
if (e.key === SEND_KEY && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void send();
|
||||
|
||||
@@ -77,6 +77,12 @@ describe("SessionsView", () => {
|
||||
expect(skel).not.toBeNull();
|
||||
});
|
||||
|
||||
it("sessions arriving before the 600ms window end the skeleton immediately (F)", () => {
|
||||
renderView({ sessions: [session({ id: "s1", name: "live" })] });
|
||||
expect(document.querySelector(".skeleton")).toBeNull();
|
||||
expect(screen.getByText("live")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders cards sorted by last activity with fallbacks", () => {
|
||||
renderView({
|
||||
sessions: [
|
||||
|
||||
@@ -79,6 +79,12 @@ export default function SessionsView({
|
||||
return () => window.clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// real rows arriving end the skeleton window immediately: a later
|
||||
// transient empty list must not flash skeletons again
|
||||
if (sessions.length > 0) setLoaded(true);
|
||||
}, [sessions.length]);
|
||||
|
||||
const open = useCallback(
|
||||
(id: string): void => {
|
||||
navigate(`/s/${id}`);
|
||||
|
||||
+468
-365
@@ -1,400 +1,503 @@
|
||||
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();
|
||||
});
|
||||
|
||||
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("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("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("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("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",
|
||||
}),
|
||||
],
|
||||
]);
|
||||
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");
|
||||
});
|
||||
|
||||
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("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("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");
|
||||
|
||||
const { container } = render(
|
||||
<Bubble msg={msg({ thinking: null })} tools={new Map()} />,
|
||||
);
|
||||
expect(container.querySelector(".thinking")).toBeNull();
|
||||
});
|
||||
|
||||
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();
|
||||
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("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("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("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("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("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("ChatStream", () => {
|
||||
function stream(p: {
|
||||
messages: ChatMessage[];
|
||||
busy: boolean;
|
||||
hasOlder?: boolean;
|
||||
loadingOlder?: boolean;
|
||||
onOlder?: () => void;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<ChatStream
|
||||
messages={p.messages}
|
||||
tools={new Map()}
|
||||
busy={p.busy}
|
||||
hasOlder={p.hasOlder ?? false}
|
||||
loadingOlder={p.loadingOlder ?? false}
|
||||
onLoadOlder={p.onOlder ?? (() => undefined)}
|
||||
/>
|
||||
);
|
||||
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("renders messages and typing indicator while busy with no open stream", () => {
|
||||
const { container, rerender } = render(
|
||||
stream({ messages: [msg({ key: "a", text: "one" })], busy: true }),
|
||||
);
|
||||
expect(container.querySelector(".typing")).not.toBeNull();
|
||||
it("filters repos by query, keyboard selects, empty filter message", async () => {
|
||||
connectedMock();
|
||||
render(tree(makeStore()));
|
||||
await flush();
|
||||
expect(screen.getByText("g/alpha")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
stream({
|
||||
messages: [
|
||||
msg({ key: "a", text: "one" }),
|
||||
msg({ key: "b", streaming: true }),
|
||||
],
|
||||
busy: true,
|
||||
}),
|
||||
);
|
||||
expect(container.querySelector(".typing")).toBeNull();
|
||||
rerender(
|
||||
stream({ messages: [msg({ key: "a", text: "one" })], busy: false }),
|
||||
);
|
||||
expect(container.querySelector(".typing")).toBeNull();
|
||||
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("sticks to bottom on new messages, un-pins on user scroll up, re-pins near bottom", () => {
|
||||
const { container, rerender } = render(
|
||||
stream({ messages: [msg({ key: "a" })], 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,
|
||||
});
|
||||
it("keyboard Enter and Space on a repo item are default-prevented (C)", async () => {
|
||||
connectedMock();
|
||||
render(tree(makeStore()));
|
||||
await flush();
|
||||
const item = screen
|
||||
.getByText("g/alpha")
|
||||
.closest(".repo-item") as HTMLElement;
|
||||
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
// pinned: scrollTop at bottom
|
||||
scroller.scrollTop = 700;
|
||||
Object.defineProperty(scroller, "scrollTop", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 700,
|
||||
});
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
rerender(
|
||||
stream({
|
||||
messages: [msg({ key: "a" }), msg({ key: "b" })],
|
||||
busy: false,
|
||||
}),
|
||||
);
|
||||
expect(scroller.scrollTop).toBe(1000);
|
||||
const enter = fireEvent.keyDown(item, { key: "Enter" });
|
||||
expect(enter).toBe(false); // preventDefault consumed: no page scroll
|
||||
expect(screen.getByLabelText("Branch")).toHaveValue("main");
|
||||
|
||||
// scroll far up -> unpin
|
||||
Object.defineProperty(scroller, "scrollTop", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 0,
|
||||
});
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
rerender(
|
||||
stream({
|
||||
messages: [msg({ key: "a" }), msg({ key: "b" }), msg({ key: "c" })],
|
||||
busy: false,
|
||||
}),
|
||||
);
|
||||
expect(scroller.scrollTop).toBe(0);
|
||||
const space = fireEvent.keyDown(item, { key: " " });
|
||||
expect(space).toBe(false);
|
||||
expect(item.getAttribute("aria-pressed")).toBe("true"); // still picks
|
||||
|
||||
// scroll near bottom (within 80px) -> pinned again
|
||||
Object.defineProperty(scroller, "scrollTop", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 940,
|
||||
});
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
rerender(
|
||||
stream({
|
||||
messages: [
|
||||
msg({ key: "a" }),
|
||||
msg({ key: "b" }),
|
||||
msg({ key: "c" }),
|
||||
msg({ key: "d" }),
|
||||
],
|
||||
busy: false,
|
||||
}),
|
||||
);
|
||||
expect(scroller.scrollTop).toBe(1000);
|
||||
const tab = fireEvent.keyDown(item, { key: "Tab" });
|
||||
expect(tab).toBe(true); // other keys keep their default behavior
|
||||
});
|
||||
|
||||
it("load-older button renders only when an older page exists and fires on click", () => {
|
||||
const onOlder = vi.fn();
|
||||
const { rerender } = render(
|
||||
stream({ messages: [msg({ key: "a" })], busy: false }),
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Load older messages" }),
|
||||
).toBeNull();
|
||||
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();
|
||||
});
|
||||
|
||||
rerender(
|
||||
stream({
|
||||
messages: [msg({ key: "a" })],
|
||||
busy: false,
|
||||
hasOlder: true,
|
||||
onOlder,
|
||||
}),
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: "Load older messages" });
|
||||
fireEvent.click(btn);
|
||||
expect(onOlder).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(
|
||||
stream({
|
||||
messages: [msg({ key: "a" })],
|
||||
busy: false,
|
||||
hasOlder: true,
|
||||
loadingOlder: true,
|
||||
onOlder,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Load older messages" }),
|
||||
).toBeDisabled();
|
||||
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("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());
|
||||
describe("SpawnView spawn+poll", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
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()}
|
||||
/>,
|
||||
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.getByRole("button", { name: "Copy message" }),
|
||||
screen.getByText("waiting for session to come online…"),
|
||||
).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();
|
||||
|
||||
// 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(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("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("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("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();
|
||||
|
||||
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("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("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 [];
|
||||
});
|
||||
const store = makeStore({
|
||||
spawnJobs: [
|
||||
{ sessionId: "sp1", repo: "g/p", state: "building", containerId: "" },
|
||||
],
|
||||
});
|
||||
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");
|
||||
});
|
||||
|
||||
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 [];
|
||||
});
|
||||
const store = makeStore({
|
||||
spawnJobs: [
|
||||
{ sessionId: "sp2", repo: "g/p", state: "building", containerId: "" },
|
||||
],
|
||||
});
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -270,7 +270,11 @@ export default function SpawnView({ store, pushToast }: Props) {
|
||||
aria-pressed={selected?.path === r.path}
|
||||
onClick={() => pick(r)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") pick(r);
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
// role=button: keep Space from scrolling the page
|
||||
e.preventDefault();
|
||||
pick(r);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
|
||||
@@ -38,6 +38,22 @@ describe("TaskPanel", () => {
|
||||
expect(deleted.style.textDecoration).toContain("line-through");
|
||||
});
|
||||
|
||||
it("duplicate todo contents render as distinct rows (D)", () => {
|
||||
const tasks: TaskDerivation = {
|
||||
...base,
|
||||
todos: [
|
||||
{ content: "same text", status: "pending", deleted: false },
|
||||
{ content: "same text", status: "completed", deleted: false },
|
||||
],
|
||||
};
|
||||
const { container } = render(<TaskPanel tasks={tasks} />);
|
||||
// identical content would collide on key={content}; index-composite keys
|
||||
// keep both rows
|
||||
expect(container.querySelectorAll(".todo-item")).toHaveLength(2);
|
||||
expect(container.querySelectorAll(".todo-icon")).toHaveLength(2);
|
||||
expect(container.querySelector(".todo-icon.completed")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("subagent rows: running spinner, done check, failed cross", () => {
|
||||
const tasks: TaskDerivation = {
|
||||
...base,
|
||||
|
||||
+73
-64
@@ -2,78 +2,87 @@ import type { SubagentRun, TodoItem, TodoStatus } from "./derive";
|
||||
import type { TaskDerivation } from "./derive";
|
||||
|
||||
const STATUS_ICON: Record<TodoStatus, string> = {
|
||||
pending: "○",
|
||||
"in-progress": "◺",
|
||||
completed: "●",
|
||||
pending: "○",
|
||||
"in-progress": "◺",
|
||||
completed: "●",
|
||||
};
|
||||
|
||||
function TodoRow({ item }: { item: TodoItem }) {
|
||||
return (
|
||||
<div className={`todo-item ${item.status === "completed" ? "done" : ""} ${item.deleted ? "deleted" : ""}`}>
|
||||
<span className={`todo-icon ${item.status}`} aria-hidden="true">
|
||||
{STATUS_ICON[item.status]}
|
||||
</span>
|
||||
<span className="todo-text" style={item.deleted ? { textDecoration: "line-through", color: "var(--text-faint)" } : undefined}>
|
||||
{item.content}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={`todo-item ${item.status === "completed" ? "done" : ""} ${item.deleted ? "deleted" : ""}`}
|
||||
>
|
||||
<span className={`todo-icon ${item.status}`} aria-hidden="true">
|
||||
{STATUS_ICON[item.status]}
|
||||
</span>
|
||||
<span
|
||||
className="todo-text"
|
||||
style={
|
||||
item.deleted
|
||||
? { textDecoration: "line-through", color: "var(--text-faint)" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{item.content}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubagentRow({ run }: { run: SubagentRun }) {
|
||||
return (
|
||||
<div className="subagent-item">
|
||||
{run.running ? (
|
||||
<span className="spinner" role="status" aria-label="running" />
|
||||
) : (
|
||||
<span className="done-icon" aria-hidden="true">
|
||||
{run.isError ? "✕" : "✓"}
|
||||
</span>
|
||||
)}
|
||||
<span>{run.name}</span>
|
||||
<span style={{ color: "var(--text-faint)", fontSize: 11 }}>
|
||||
{run.running ? "running" : run.isError ? "failed" : "done"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="subagent-item">
|
||||
{run.running ? (
|
||||
<span className="spinner" role="status" aria-label="running" />
|
||||
) : (
|
||||
<span className="done-icon" aria-hidden="true">
|
||||
{run.isError ? "✕" : "✓"}
|
||||
</span>
|
||||
)}
|
||||
<span>{run.name}</span>
|
||||
<span style={{ color: "var(--text-faint)", fontSize: 11 }}>
|
||||
{run.running ? "running" : run.isError ? "failed" : "done"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TaskPanel({ tasks }: { tasks: TaskDerivation }) {
|
||||
const hasTodos: boolean = tasks.todos.length > 0;
|
||||
const hasSubagents: boolean = tasks.subagents.length > 0;
|
||||
const hasWorking: boolean = tasks.workingTools.length > 0;
|
||||
const empty: boolean = !hasTodos && !hasSubagents && !hasWorking;
|
||||
const hasTodos: boolean = tasks.todos.length > 0;
|
||||
const hasSubagents: boolean = tasks.subagents.length > 0;
|
||||
const hasWorking: boolean = tasks.workingTools.length > 0;
|
||||
const empty: boolean = !hasTodos && !hasSubagents && !hasWorking;
|
||||
|
||||
return (
|
||||
<div className="task-panel">
|
||||
{empty && <p className="empty">No tasks yet.</p>}
|
||||
{hasTodos && (
|
||||
<section className="task-section">
|
||||
<h2>Tasks</h2>
|
||||
{tasks.todos.map((t) => (
|
||||
<TodoRow key={t.content} item={t} />
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
{hasSubagents && (
|
||||
<section className="task-section">
|
||||
<h2>Subagents</h2>
|
||||
{tasks.subagents.map((s) => (
|
||||
<SubagentRow key={s.key} run={s} />
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
{hasWorking && (
|
||||
<section className="task-section">
|
||||
<h2>Working</h2>
|
||||
{tasks.workingTools.map((w) => (
|
||||
<div key={w.id} className="working-line">
|
||||
<span className="spinner" role="status" aria-label="working" />
|
||||
{w.name}…
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="task-panel">
|
||||
{empty && <p className="empty">No tasks yet.</p>}
|
||||
{hasTodos && (
|
||||
<section className="task-section">
|
||||
<h2>Tasks</h2>
|
||||
{tasks.todos.map((t, i) => (
|
||||
<TodoRow key={`${i}-${t.content}`} item={t} />
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
{hasSubagents && (
|
||||
<section className="task-section">
|
||||
<h2>Subagents</h2>
|
||||
{tasks.subagents.map((s) => (
|
||||
<SubagentRow key={s.key} run={s} />
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
{hasWorking && (
|
||||
<section className="task-section">
|
||||
<h2>Working</h2>
|
||||
{tasks.workingTools.map((w) => (
|
||||
<div key={w.id} className="working-line">
|
||||
<span className="spinner" role="status" aria-label="working" />
|
||||
{w.name}…
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+29
-11
@@ -69,10 +69,28 @@ function listEvent(seq: number): EventFrame {
|
||||
describe("useSessions", () => {
|
||||
it("returns null when unconfigured", () => {
|
||||
const push = vi.fn();
|
||||
const { result } = renderHook(() => useSessions(push));
|
||||
const { result } = renderHook(() => useSessions(push, true));
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
it("first-run: inactive -> active starts the ws manager (A)", async () => {
|
||||
seedSettings();
|
||||
mockFetchJson(() => []);
|
||||
const push = vi.fn();
|
||||
const { result, rerender } = renderHook(
|
||||
({ active }: { active: boolean }) => useSessions(push, active),
|
||||
{ initialProps: { active: false } },
|
||||
);
|
||||
// gate is up: settings exist but the hook must not dial yet
|
||||
expect(FakeWebSocket.instances.length).toBe(0);
|
||||
expect(result.current).not.toBeNull();
|
||||
|
||||
// gate saved: active flips true, the effect re-runs and starts the manager
|
||||
rerender({ active: true });
|
||||
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
||||
expect(result.current?.state).toBe("connecting");
|
||||
});
|
||||
|
||||
it("seeds via REST, updates from session_list and spawn_status frames, refresh works", async () => {
|
||||
seedSettings();
|
||||
const sessions: SessionListItem[] = [
|
||||
@@ -88,7 +106,7 @@ describe("useSessions", () => {
|
||||
return [];
|
||||
});
|
||||
const push = vi.fn();
|
||||
const { result } = renderHook(() => useSessions(push));
|
||||
const { result } = renderHook(() => useSessions(push, true));
|
||||
|
||||
expect(result.current?.state).toBe("connecting");
|
||||
await waitFor(() => expect(result.current?.sessions).toEqual(sessions));
|
||||
@@ -131,7 +149,7 @@ describe("useSessions", () => {
|
||||
throw new Error("network down");
|
||||
});
|
||||
const push = vi.fn();
|
||||
const { result } = renderHook(() => useSessions(push));
|
||||
const { result } = renderHook(() => useSessions(push, true));
|
||||
await waitFor(() =>
|
||||
expect(push).toHaveBeenCalledWith("sessions: network down"),
|
||||
);
|
||||
@@ -147,7 +165,7 @@ describe("useSessions", () => {
|
||||
seedSettings();
|
||||
mockFetchJson(() => []);
|
||||
const push = vi.fn();
|
||||
const { result } = renderHook(() => useSessions(push));
|
||||
const { result } = renderHook(() => useSessions(push, true));
|
||||
const sock = FakeWebSocket.last();
|
||||
act(() => sock.serverOpen());
|
||||
|
||||
@@ -198,7 +216,7 @@ describe("useSessions", () => {
|
||||
mockFetchJson(() => []);
|
||||
const loc = stubReload();
|
||||
const push = vi.fn();
|
||||
const { result } = renderHook(() => useSessions(push));
|
||||
const { result } = renderHook(() => useSessions(push, true));
|
||||
const sock = FakeWebSocket.last();
|
||||
act(() => sock.serverOpen());
|
||||
act(() => sock.serverClose(1008));
|
||||
@@ -219,7 +237,7 @@ describe("useSessions", () => {
|
||||
);
|
||||
const loc = stubReload();
|
||||
const push = vi.fn();
|
||||
renderHook(() => useSessions(push));
|
||||
renderHook(() => useSessions(push, true));
|
||||
const failConnect = (): void => {
|
||||
act(() => FakeWebSocket.last().serverClose(1006));
|
||||
};
|
||||
@@ -254,7 +272,7 @@ describe("useSessions", () => {
|
||||
});
|
||||
const loc = stubReload();
|
||||
const push = vi.fn();
|
||||
renderHook(() => useSessions(push));
|
||||
renderHook(() => useSessions(push, true));
|
||||
const failConnect = (): void => {
|
||||
act(() => FakeWebSocket.last().serverClose(1006));
|
||||
};
|
||||
@@ -293,7 +311,7 @@ describe("useSessions", () => {
|
||||
});
|
||||
const loc = stubReload();
|
||||
const push = vi.fn();
|
||||
renderHook(() => useSessions(push));
|
||||
renderHook(() => useSessions(push, true));
|
||||
const failConnect = (): void => {
|
||||
act(() => FakeWebSocket.last().serverClose(1006));
|
||||
};
|
||||
@@ -336,7 +354,7 @@ describe("useSessions", () => {
|
||||
seedSettings();
|
||||
mockFetchJson(() => []);
|
||||
const push = vi.fn();
|
||||
const { unmount } = renderHook(() => useSessions(push));
|
||||
const { unmount } = renderHook(() => useSessions(push, true));
|
||||
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
||||
const sock = FakeWebSocket.last();
|
||||
unmount();
|
||||
@@ -350,7 +368,7 @@ describe("useSessions", () => {
|
||||
const stable: SessionListItem[] = [];
|
||||
mockFetchJson(() => stable);
|
||||
const push = vi.fn();
|
||||
const { result, rerender } = renderHook(() => useSessions(push));
|
||||
const { result, rerender } = renderHook(() => useSessions(push, true));
|
||||
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
||||
// let the mount-time seed settle
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
@@ -370,7 +388,7 @@ describe("useSessions", () => {
|
||||
let list: SessionListItem[] = [];
|
||||
mockFetchJson(() => list);
|
||||
const push = vi.fn();
|
||||
const { result } = renderHook(() => useSessions(push));
|
||||
const { result } = renderHook(() => useSessions(push, true));
|
||||
await waitFor(() => expect(result.current).not.toBeNull());
|
||||
const first = result.current;
|
||||
const sock = FakeWebSocket.last();
|
||||
|
||||
+7
-1
@@ -71,8 +71,13 @@ export interface SessionsStore {
|
||||
) => () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* `active` tells the hook the settings gate has been passed; it must be a
|
||||
* dep below so saving settings (false -> true) starts the ws manager.
|
||||
*/
|
||||
export function useSessions(
|
||||
pushToast: (text: string) => void,
|
||||
active: boolean,
|
||||
): SessionsStore | null {
|
||||
const [sessions, setSessions] = useState<SessionListItem[]>([]);
|
||||
const [spawnJobs, setSpawnJobs] = useState<SpawnJob[]>([]);
|
||||
@@ -80,6 +85,7 @@ export function useSessions(
|
||||
const [manager, setManager] = useState<WsManager | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const settings = getSettings();
|
||||
if (settings === null) return;
|
||||
|
||||
@@ -136,7 +142,7 @@ export function useSessions(
|
||||
m.close();
|
||||
setManager(null);
|
||||
};
|
||||
}, [pushToast]);
|
||||
}, [pushToast, active]);
|
||||
|
||||
const refresh = useCallback(async (): Promise<SessionListItem[]> => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user