/** * @file RunConsole.test.tsx * @description Pins the props-only boundary of `RunConsole` after its move out * of `pages/Run.tsx`: the envelope stream renders from the `envelopes` prop * (no stream subscription of its own), the token meter rolls up usage from * those same envelopes, the prompt editor's `/` autocomplete filters and fills * the prompt through `onFollowUpChange`, and `onSend` / `onStop` fire from the * send and stop controls. * * @author Nguyễn Ngọc Trí Vĩ */ import { useState } from "react"; import { MemoryRouter } from "react-router-dom"; import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { RunConsole, type SlashCommand } from "../RunConsole"; import type { Envelope } from "../../../hooks/useRunStream"; import type { RunHandle } from "../../../lib/api"; const HANDLE: RunHandle = { id: "run-1", pid: 4242, mode: "conversation", cwd: "/tmp/project", model: "claude-opus-5", permissionMode: "acceptEdits", effort: "", prompt: "hi", argv: [], resumeSessionId: null, status: "running", startedAt: 1, endedAt: null, exitCode: null, signal: null, error: null, sessionId: null, envelopeCount: 0, stdoutTail: "", stderrTail: "", }; const COMMANDS: SlashCommand[] = [ { name: "code-review", description: "Review the working diff", source: "project" }, { name: "compact", description: "Compact the conversation context", source: "builtin" }, { name: "logout", description: "Sign out", source: "builtin" }, ]; /** * Mount the console with the parent-owned follow-up state it expects, so the * autocomplete assertions exercise the real controlled-input round trip. */ function renderConsole( props: Partial> = {}, onFollowUp?: (s: string) => void ) { const seen = { followUp: "" }; function Harness() { const [followUp, setFollowUp] = useState(""); seen.followUp = followUp; return ( { setFollowUp(s); onFollowUp?.(s); }} busy={null} onSend={() => {}} onStop={() => {}} onNewRun={() => {}} slashCommands={COMMANDS} {...props} /> ); } render( ); return seen; } describe("RunConsole", () => { it("renders assistant text from the envelopes prop", () => { const envelopes: Envelope[] = [ { type: "user", message: { content: "explain this repo" } }, { type: "assistant", message: { content: [{ type: "text", text: "Here is the answer." }] } }, ] as Envelope[]; renderConsole({ envelopes }); expect(screen.getByText("explain this repo")).toBeInTheDocument(); expect(screen.getByText("Here is the answer.")).toBeInTheDocument(); }); it("shows the empty-stream placeholder when there are no envelopes", () => { renderConsole({ isLive: false }); expect(screen.getByText("Nothing yet")).toBeInTheDocument(); }); it("shows the token totals computed from the envelopes", () => { // Transcript-shaped assistant envelope (no `message.id`), which is the // branch computeTokens folds into the running totals. const envelopes: Envelope[] = [ { type: "assistant", message: { content: [{ type: "text", text: "done" }], usage: { input_tokens: 12_000, output_tokens: 2_500, cache_read_input_tokens: 8_000 }, }, }, ] as Envelope[]; renderConsole({ envelopes }); // Context gauge: (input + cache read) / default 200k window. // The CLI-style meter is one status line: context usage as a single label, // then output and cache-hit figures with terminal glyphs. Input is implied // by the context total rather than listed separately. expect(screen.getByText("20.0k / 200k (10%)")).toBeInTheDocument(); expect(screen.getByText("↑2.5k")).toBeInTheDocument(); // Output expect(screen.getByText("⚡8.0k")).toBeInTheDocument(); // Cache hit }); it("filters slash commands as the user types and fills the prompt on pick", () => { const seen = renderConsole(); const textarea = screen.getByRole("textbox"); fireEvent.change(textarea, { target: { value: "/co" } }); expect(screen.getByText("/code-review")).toBeInTheDocument(); expect(screen.getByText("/compact")).toBeInTheDocument(); expect(screen.queryByText("/logout")).not.toBeInTheDocument(); fireEvent.click(screen.getByText("/code-review")); expect(seen.followUp).toBe("/code-review"); expect(screen.queryByText("/compact")).not.toBeInTheDocument(); // dropdown closed }); it("fires onSend from the send button with the prompt the parent holds", () => { const onSend = vi.fn(); const seen = renderConsole({ onSend }); fireEvent.change(screen.getByRole("textbox"), { target: { value: "follow up please" } }); fireEvent.click(screen.getByRole("button", { name: /send/i })); expect(onSend).toHaveBeenCalledTimes(1); expect(seen.followUp).toBe("follow up please"); }); it("fires onStop from the stop control while live, and hides it when not", () => { const onStop = vi.fn(); renderConsole({ onStop }); fireEvent.click(screen.getByRole("button", { name: /stop/i })); expect(onStop).toHaveBeenCalledTimes(1); }); it("hides the stop control and the follow-up editor once the run is not live", () => { renderConsole({ isLive: false, hasFinished: true }); expect(screen.queryByRole("button", { name: /stop/i })).not.toBeInTheDocument(); expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); }); });