import type { StepId } from "@dispatch/wire"; import { render, screen, within } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import type { RenderedChunk } from "../../core/chunks"; import type { TurnMetricsEntry } from "../../core/metrics"; import ChatView from "./ui/ChatView.svelte"; import Composer from "./ui/Composer.svelte"; import ModelSelector from "./ui/ModelSelector.svelte"; import ReasoningEffortSelector from "./ui/ReasoningEffortSelector.svelte"; describe("ChatView", () => { it("renders a message's text chunk", () => { const chunks: RenderedChunk[] = [ { seq: 1, role: "assistant", chunk: { type: "text", text: "Hello world" }, provisional: false, }, ]; render(ChatView, { props: { chunks } }); expect(screen.getByText("Hello world")).toBeInTheDocument(); }); it("renders multiple chunks", () => { const chunks: RenderedChunk[] = [ { seq: 1, role: "user", chunk: { type: "text", text: "Hi there" }, provisional: false }, { seq: 2, role: "assistant", chunk: { type: "text", text: "Hello!" }, provisional: false, }, ]; render(ChatView, { props: { chunks } }); expect(screen.getByText("Hi there")).toBeInTheDocument(); expect(screen.getByText("Hello!")).toBeInTheDocument(); }); it("shows the show-earlier button only when earlier history is unloaded, and pages it in", async () => { const chunks: RenderedChunk[] = [ { seq: 26, role: "user", chunk: { type: "text", text: "later" }, provisional: false }, ]; let resolveEarlier: (() => void) | undefined; const onShowEarlier = vi.fn( () => new Promise((resolve) => { resolveEarlier = resolve; }), ); render(ChatView, { props: { chunks, hasEarlier: true, onShowEarlier } }); const button = screen.getByRole("button", { name: /show earlier messages/i }); const user = userEvent.setup(); await user.click(button); expect(onShowEarlier).toHaveBeenCalledTimes(1); // While the page-in is awaited the button is disabled (no double-fire). expect(screen.getByRole("button", { name: /loading earlier messages/i })).toBeDisabled(); resolveEarlier?.(); await vi.waitFor(() => { expect(screen.getByRole("button", { name: /show earlier messages/i })).toBeEnabled(); }); }); it("hides the show-earlier button when nothing is unloaded", () => { const chunks: RenderedChunk[] = [ { seq: 1, role: "user", chunk: { type: "text", text: "all here" }, provisional: false }, ]; render(ChatView, { props: { chunks, hasEarlier: false, onShowEarlier: vi.fn() } }); expect(screen.queryByRole("button", { name: /show earlier/i })).not.toBeInTheDocument(); }); it("renders tool-call chunks", () => { const chunks: RenderedChunk[] = [ { seq: 1, role: "assistant", chunk: { type: "tool-call", toolCallId: "tc1", toolName: "read_file", input: { path: "/tmp/test.txt" }, }, provisional: false, }, ]; render(ChatView, { props: { chunks } }); expect(screen.getByText("read_file")).toBeInTheDocument(); const pre = screen.getByText((content, element) => { return element?.tagName === "PRE" && content.includes("/tmp/test.txt"); }); expect(pre).toBeInTheDocument(); }); it("renders tool-result chunks", () => { const chunks: RenderedChunk[] = [ { seq: 1, role: "tool", chunk: { type: "tool-result", toolCallId: "tc1", toolName: "read_file", content: "file contents here", isError: false, }, provisional: false, }, ]; render(ChatView, { props: { chunks } }); expect(screen.getByText("read_file")).toBeInTheDocument(); expect(screen.getByText("file contents here")).toBeInTheDocument(); }); it("renders error chunks with alert role", () => { const chunks: RenderedChunk[] = [ { seq: 1, role: "assistant", chunk: { type: "error", message: "Something failed" }, provisional: false, }, ]; render(ChatView, { props: { chunks } }); const alert = screen.getByRole("alert"); expect(alert).toHaveTextContent("Something failed"); }); it("renders error chunks with code", () => { const chunks: RenderedChunk[] = [ { seq: 1, role: "assistant", chunk: { type: "error", message: "Rate limited", code: "RATE_LIMIT" }, provisional: false, }, ]; render(ChatView, { props: { chunks } }); expect(screen.getByText("Rate limited")).toBeInTheDocument(); expect(screen.getByText("[RATE_LIMIT]")).toBeInTheDocument(); }); it("renders system chunks", () => { const chunks: RenderedChunk[] = [ { seq: 1, role: "system", chunk: { type: "system", text: "System context loaded" }, provisional: false, }, ]; render(ChatView, { props: { chunks } }); expect(screen.getByText("System context loaded")).toBeInTheDocument(); }); it("renders provisional (in-flight) chunks without any dimming", () => { const chunks: RenderedChunk[] = [ { seq: null, role: "assistant", chunk: { type: "text", text: "Streaming..." }, provisional: true, }, ]; render(ChatView, { props: { chunks } }); // In-flight chunks render at full opacity (no faded "disabled" look). const wrapper = screen.getByText("Streaming...").closest("div"); expect(wrapper).not.toHaveClass("opacity-50"); }); it("renders empty transcript", () => { render(ChatView, { props: { chunks: [] } }); const log = screen.getByRole("log"); expect(log).toBeInTheDocument(); expect(log.children).toHaveLength(0); }); it("groups batched tool calls (shared stepId) into one DaisyUI list", () => { const chunks: RenderedChunk[] = [ { seq: 1, role: "assistant", chunk: { type: "tool-call", toolCallId: "a", toolName: "read_file", input: { path: "/a" }, stepId: "t1#0" as StepId, }, provisional: false, }, { seq: 2, role: "assistant", chunk: { type: "tool-call", toolCallId: "b", toolName: "list_dir", input: { path: "/b" }, stepId: "t1#0" as StepId, }, provisional: false, }, { seq: 3, role: "tool", chunk: { type: "tool-result", toolCallId: "a", toolName: "read_file", content: "contents-of-a", isError: false, stepId: "t1#0" as StepId, }, provisional: false, }, ]; const { container } = render(ChatView, { props: { chunks } }); // One DaisyUI list with two rows (one per call), not separate cards. const lists = container.querySelectorAll("ul.list"); expect(lists).toHaveLength(1); expect(container.querySelectorAll("ul.list > li.list-row")).toHaveLength(2); // Both call names + the available result are shown; the result is absorbed // (no standalone tool-result card). expect(screen.getByText("read_file")).toBeInTheDocument(); expect(screen.getByText("list_dir")).toBeInTheDocument(); expect(screen.getByText("contents-of-a")).toBeInTheDocument(); }); it("thinking is a checkbox collapse (no arrow) inside a visible bubble", () => { const chunks: RenderedChunk[] = [ { seq: null, role: "assistant", chunk: { type: "thinking", text: "Let me think..." }, provisional: true, streaming: true, }, ]; const { container } = render(ChatView, { props: { chunks } }); const collapse = container.querySelector(".collapse"); expect(collapse).not.toBeNull(); expect(collapse).not.toHaveClass("collapse-arrow"); // no indicator icon expect(collapse).not.toHaveClass("collapse-plus"); // Visible bubble, like tool cards. expect(collapse).toHaveClass("bg-base-200"); expect(collapse).toHaveClass("rounded-box"); expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeInTheDocument(); }); it("title is 'Thinking' + dots while streaming, then 'Thoughts' with no dots once complete; open state persists", async () => { const streaming: RenderedChunk[] = [ { seq: null, role: "assistant", chunk: { type: "thinking", text: "hmm" }, provisional: true, streaming: true, }, ]; const { container, rerender } = render(ChatView, { props: { chunks: streaming } }); // Streaming: "Thinking" + loading dots. expect(screen.getByText("Thinking")).toBeInTheDocument(); expect(screen.queryByText("Thoughts")).toBeNull(); expect(container.querySelector(".loading")).not.toBeNull(); // Open it. const checkbox = screen.getByRole("checkbox", { name: "Toggle thoughts" }); await userEvent.click(checkbox); expect(checkbox).toBeChecked(); // Transition generating → completed/committed (seq assigned, no longer streaming). await rerender({ chunks: [ { seq: 1, role: "assistant", chunk: { type: "thinking", text: "hmm, all done" }, provisional: false, }, ], }); // Completed: "Thoughts", no dots — and the open state survived the transition. expect(screen.getByText("Thoughts")).toBeInTheDocument(); expect(screen.queryByText("Thinking")).toBeNull(); expect(container.querySelector(".loading")).toBeNull(); expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeChecked(); expect(container).toHaveTextContent("hmm, all done"); }); it("renders step and turn metrics as separate rows", () => { const chunks: RenderedChunk[] = [ { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, { seq: 2, role: "assistant", chunk: { type: "text", text: "Hello!" }, provisional: false, }, { seq: 3, role: "assistant", chunk: { type: "tool-call", toolCallId: "tc1", toolName: "test", input: {}, stepId: "t1#0" as StepId, }, provisional: false, }, ]; const turnMetrics: TurnMetricsEntry[] = [ { turnId: "t1", steps: [ { stepId: "t1#0" as StepId, usage: { inputTokens: 100, outputTokens: 50 }, genTotalMs: 800, }, ], total: { turnId: "t1", usage: { inputTokens: 100, outputTokens: 50 }, durationMs: 1200, steps: [ { stepId: "t1#0" as StepId, usage: { inputTokens: 100, outputTokens: 50 }, genTotalMs: 800, }, ], }, }, ]; render(ChatView, { props: { chunks, turnMetrics } }); expect(screen.getByText("Hi")).toBeInTheDocument(); expect(screen.getByText("Hello!")).toBeInTheDocument(); expect(screen.getByText(/step 1/)).toBeInTheDocument(); expect(screen.getAllByText(/150 tok/)).toHaveLength(2); expect(screen.getByText(/turn 1 · 150 tok \(100 in \/ 50 out\)/)).toBeInTheDocument(); expect(screen.getByText(/1\.2s/)).toBeInTheDocument(); }); it("renders cache hit-rate badges (Last turn + Chat Total) coloured by level", () => { const chunks: RenderedChunk[] = [ { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, { seq: 2, role: "assistant", chunk: { type: "text", text: "Hello!" }, provisional: false, }, ]; const turnMetrics: TurnMetricsEntry[] = [ { turnId: "t1", steps: [], total: { turnId: "t1", usage: { inputTokens: 100, outputTokens: 10, cacheReadTokens: 93 }, steps: [], }, }, ]; const { container } = render(ChatView, { props: { chunks, turnMetrics } }); expect(screen.getByText("Last turn:")).toBeInTheDocument(); expect(screen.getByText("Chat Total:")).toBeInTheDocument(); // single turn ⇒ both the turn rate and the cumulative are 93% ⇒ success badge const badges = container.querySelectorAll(".badge"); expect(badges).toHaveLength(2); for (const b of badges) { expect(b.textContent).toBe("93%"); expect(b.classList.contains("badge-success")).toBe(true); } }); it("renders step-metrics inline after tool group", () => { const chunks: RenderedChunk[] = [ { seq: 1, role: "user", chunk: { type: "text", text: "Run it" }, provisional: false }, { seq: 2, role: "assistant", chunk: { type: "tool-call", toolCallId: "tc1", toolName: "bash", input: { command: "ls" }, stepId: "t1#0" as StepId, }, provisional: false, }, { seq: 3, role: "tool", chunk: { type: "tool-result", toolCallId: "tc1", toolName: "bash", content: "file.txt", isError: false, stepId: "t1#0" as StepId, }, provisional: false, }, { seq: 4, role: "assistant", chunk: { type: "text", text: "Done!" }, provisional: false, }, ]; const turnMetrics: TurnMetricsEntry[] = [ { turnId: "t1", steps: [ { stepId: "t1#0" as StepId, usage: { inputTokens: 80, outputTokens: 20 }, genTotalMs: 300, }, ], total: { turnId: "t1", usage: { inputTokens: 80, outputTokens: 20 }, durationMs: 500, steps: [ { stepId: "t1#0" as StepId, usage: { inputTokens: 80, outputTokens: 20 }, genTotalMs: 300, }, ], }, }, ]; render(ChatView, { props: { chunks, turnMetrics } }); // Both step-metrics and turn-metrics render expect(screen.getByText(/step 1/)).toBeInTheDocument(); expect(screen.getByText(/turn 1 · 100 tok/)).toBeInTheDocument(); // They are in separate elements (different rows) const stepEl = screen.getByText(/step 1 · 100 tok/).closest("div"); const turnEl = screen.getByText(/turn 1 · 100 tok/).closest("div"); expect(stepEl).not.toBe(turnEl); }); it("renders no metrics bubble when turnMetrics is empty", () => { const chunks: RenderedChunk[] = [ { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, { seq: 2, role: "assistant", chunk: { type: "text", text: "Hello!" }, provisional: false, }, ]; render(ChatView, { props: { chunks, turnMetrics: [] } }); expect(screen.getByText("Hi")).toBeInTheDocument(); expect(screen.getByText("Hello!")).toBeInTheDocument(); expect(screen.queryByText(/step 1/)).toBeNull(); expect(screen.queryByText(/^turn/)).toBeNull(); }); it("omits null view values from metrics bubbles", () => { const chunks: RenderedChunk[] = [ { seq: 1, role: "user", chunk: { type: "text", text: "Test" }, provisional: false }, { seq: 2, role: "assistant", chunk: { type: "text", text: "Response" }, provisional: false, }, { seq: 3, role: "assistant", chunk: { type: "tool-call", toolCallId: "tc1", toolName: "test", input: {}, stepId: "t1#0" as StepId, }, provisional: false, }, ]; const turnMetrics: TurnMetricsEntry[] = [ { turnId: "t1", steps: [ { stepId: "t1#0" as StepId, usage: { inputTokens: 10, outputTokens: 5 }, }, ], total: { turnId: "t1", usage: { inputTokens: 10, outputTokens: 5 }, steps: [ { stepId: "t1#0" as StepId, usage: { inputTokens: 10, outputTokens: 5 }, }, ], }, }, ]; render(ChatView, { props: { chunks, turnMetrics } }); // Step metrics rendered expect(screen.getByText(/step 1/)).toBeInTheDocument(); expect(screen.getAllByText(/15 tok/)).toHaveLength(2); // Turn metrics rendered expect(screen.getByText(/turn 1 · 15 tok \(10 in \/ 5 out\)/)).toBeInTheDocument(); // No "null" or "undefined" in the DOM expect(screen.queryByText("null")).toBeNull(); expect(screen.queryByText("undefined")).toBeNull(); }); it("renders step text but no turn total for a progressive turn (total: null)", () => { const chunks: RenderedChunk[] = [ { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, { seq: 2, role: "assistant", chunk: { type: "text", text: "Hello!" }, provisional: false, }, { seq: 3, role: "assistant", chunk: { type: "tool-call", toolCallId: "tc1", toolName: "test", input: {}, stepId: "t1#0" as StepId, }, provisional: false, }, ]; const turnMetrics: TurnMetricsEntry[] = [ { turnId: "t1", steps: [ { stepId: "t1#0" as StepId, usage: { inputTokens: 100, outputTokens: 50 }, genTotalMs: 800, }, ], total: null, }, ]; render(ChatView, { props: { chunks, turnMetrics } }); // Step metrics should render expect(screen.getByText(/step 1/)).toBeInTheDocument(); expect(screen.getByText(/150 tok/)).toBeInTheDocument(); // Turn total should NOT render (total is null — turn still in progress) expect(screen.queryByText(/^turn/)).toBeNull(); }); }); describe("Composer", () => { it("calls onSend with the typed text and clears", async () => { const onSend = vi.fn(); const user = userEvent.setup(); render(Composer, { props: { onSend } }); const textarea = screen.getByRole("textbox", { name: "Message input" }); await user.type(textarea, "Hello world"); const sendButton = screen.getByRole("button", { name: "Send" }); await user.click(sendButton); expect(onSend).toHaveBeenCalledTimes(1); expect(onSend).toHaveBeenCalledWith("Hello world"); expect(textarea).toHaveValue(""); }); it("does not call onSend with empty text", async () => { const onSend = vi.fn(); const _user = userEvent.setup(); render(Composer, { props: { onSend } }); const sendButton = screen.getByRole("button", { name: "Send" }); expect(sendButton).toBeDisabled(); expect(onSend).not.toHaveBeenCalled(); }); it("trims whitespace before sending", async () => { const onSend = vi.fn(); const user = userEvent.setup(); render(Composer, { props: { onSend } }); const textarea = screen.getByRole("textbox", { name: "Message input" }); await user.type(textarea, " hello "); const sendButton = screen.getByRole("button", { name: "Send" }); await user.click(sendButton); expect(onSend).toHaveBeenCalledWith("hello"); }); it("sends on Enter key (without Shift)", async () => { const onSend = vi.fn(); const user = userEvent.setup(); render(Composer, { props: { onSend } }); const textarea = screen.getByRole("textbox", { name: "Message input" }); await user.type(textarea, "Test message{Enter}"); expect(onSend).toHaveBeenCalledWith("Test message"); }); it("does not send on Shift+Enter", async () => { const onSend = vi.fn(); const user = userEvent.setup(); render(Composer, { props: { onSend } }); const textarea = screen.getByRole("textbox", { name: "Message input" }); await user.type(textarea, "Line 1{Shift>}{Enter}{/Shift}Line 2"); expect(onSend).not.toHaveBeenCalled(); }); }); describe("ModelSelector", () => { const optionValues = (el: HTMLElement): string[] => within(el) .getAllByRole("option") .map((o) => (o as HTMLOptionElement).value); it("renders a key selector (distinct keys) and a model selector (models for the current key)", () => { const models = ["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3", "google/gemini"]; render(ModelSelector, { props: { models, selected: "anthropic/claude-3", onSelect: vi.fn() }, }); const keySelect = screen.getByRole("combobox", { name: "Key selector" }); const modelSelect = screen.getByRole("combobox", { name: "Model selector" }); expect(keySelect).toHaveValue("anthropic"); expect(modelSelect).toHaveValue("claude-3"); expect(optionValues(keySelect)).toEqual(["openai", "anthropic", "google"]); // only the models under the selected key expect(optionValues(modelSelect)).toEqual(["claude-3"]); }); it("selecting a key switches to the first model under it", async () => { const onSelect = vi.fn(); const user = userEvent.setup(); const models = ["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3"]; render(ModelSelector, { props: { models, selected: "openai/gpt-4o", onSelect }, }); await user.selectOptions(screen.getByRole("combobox", { name: "Key selector" }), "anthropic"); expect(onSelect).toHaveBeenCalledTimes(1); expect(onSelect).toHaveBeenCalledWith("anthropic/claude-3"); }); it("selecting a model keeps the current key", async () => { const onSelect = vi.fn(); const user = userEvent.setup(); const models = ["openai/gpt-4", "openai/gpt-4o"]; render(ModelSelector, { props: { models, selected: "openai/gpt-4", onSelect }, }); await user.selectOptions(screen.getByRole("combobox", { name: "Model selector" }), "gpt-4o"); expect(onSelect).toHaveBeenCalledTimes(1); expect(onSelect).toHaveBeenCalledWith("openai/gpt-4o"); }); }); describe("ReasoningEffortSelector", () => { it("renders null (never set) as the default level, marked '(default)'", () => { render(ReasoningEffortSelector, { props: { persisted: null, save: vi.fn() } }); const select = screen.getByRole("combobox", { name: "Reasoning effort" }); expect(select).toHaveValue("high"); expect(within(select).getByRole("option", { name: "high (default)" })).toBeInTheDocument(); // All five ladder levels are offered. expect(within(select).getAllByRole("option")).toHaveLength(5); }); it("renders a persisted level as selected", () => { render(ReasoningEffortSelector, { props: { persisted: "xhigh", save: vi.fn() } }); expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("xhigh"); }); it("selecting a level saves it via the injected port and confirms", async () => { const save = vi.fn(async (level: "low" | "medium" | "high" | "xhigh" | "max") => ({ ok: true as const, reasoningEffort: level, })); const user = userEvent.setup(); render(ReasoningEffortSelector, { props: { persisted: null, save } }); await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max"); expect(save).toHaveBeenCalledTimes(1); expect(save).toHaveBeenCalledWith("max"); await vi.waitFor(() => { expect(screen.getByText(/applies from the next turn/i)).toBeInTheDocument(); }); expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("max"); }); it("a failed save shows the error and reverts to the persisted value", async () => { const save = vi.fn(async () => ({ ok: false as const, error: "nope" })); const user = userEvent.setup(); render(ReasoningEffortSelector, { props: { persisted: "low", save } }); await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max"); await vi.waitFor(() => { expect(screen.getByText("nope")).toBeInTheDocument(); }); expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("low"); }); it("disables the select while a save is in flight (no double-fire)", async () => { let resolveSave: ((r: { ok: true; reasoningEffort: "max" }) => void) | undefined; const save = vi.fn( () => new Promise<{ ok: true; reasoningEffort: "max" }>((resolve) => { resolveSave = resolve; }), ); const user = userEvent.setup(); render(ReasoningEffortSelector, { props: { persisted: null, save } }); await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max"); expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeDisabled(); resolveSave?.({ ok: true, reasoningEffort: "max" }); await vi.waitFor(() => { expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeEnabled(); }); }); });