// @vitest-environment jsdom import type { InboxItem } from "@rakazo/contracts"; import type { ReactNode } from "react"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; const api = vi.hoisted(() => ({ list: vi.fn(), answer: vi.fn(), markRead: vi.fn() })); vi.mock("../lib/rpc", () => ({ rpc: { inbox: { list: api.list }, threads: { answer: api.answer, markRead: api.markRead } }, })); vi.mock("@lingui/react/macro", () => { const t = (parts: TemplateStringsArray) => parts.join(""); return { useLingui: () => ({ t }), Trans: ({ children }: { children: ReactNode }) => children }; }); vi.mock("@rakazo/ui-web", () => ({ BotAvatar: () => , parseBotAvatar: (color: string) => ({ color }), resolvePersonaColorDef: (_id: string, color: string) => ({ light: color }), })); vi.mock("./AskCard", () => ({ AskCard: ({ block, onAnswer, }: { block: { text: string }; onAnswer: (text: string) => Promise; }) => ( // Like the real card, show a failed answer instead of rejecting. ), })); import { MainInbox } from "./MainInbox"; const scout = { id: "bot-scout", name: "Scout", color: "#3B82F6" }; const writer = { id: "bot-writer", name: "Writer", color: "#D9508A" }; const createdAt = "2026-10-02T09:00:00.000Z"; const askInOwnThread: InboxItem = { kind: "ask", bot: scout, target: { botId: scout.id }, runId: "run-ask", messageId: "msg-ask", ask: { kind: "ask", text: "Which city should I use?", status: "pending" }, createdAt, }; const approvalInGroup: InboxItem = { kind: "approval", bot: writer, target: { groupId: "group-ops" }, runId: "run-approval", messageId: "msg-approval", ask: { kind: "ask", text: "Review before writing to notes", status: "pending" }, createdAt, }; const takeover: InboxItem = { kind: "takeover", bot: writer, target: { botId: writer.id }, runId: "run-takeover", messageId: "msg-takeover", text: "Sign in to continue.", createdAt, }; const unread: InboxItem = { kind: "unread", bot: scout, target: { botId: scout.id }, messageId: "msg-unread", text: "Draft ready for review", createdAt, }; const idleBots = [ { id: scout.id, status: "idle", unread: false, preview: "" }, { id: writer.id, status: "idle", unread: false, preview: "" }, ]; let container: HTMLDivElement; let root: Root; const onOpenBot = vi.fn(); const onOpenGroup = vi.fn(); function render(bots = idleBots) { return act(async () => root.render(), ); } function itemOf(kind: InboxItem["kind"]) { const found = container.querySelector( `[data-testid='main-inbox-item'][data-kind='${kind}']`, ); if (!found) throw new Error(`Missing ${kind} item`); return found; } beforeEach(() => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); api.list.mockReset(); api.answer.mockReset(); api.markRead.mockReset().mockResolvedValue({ ok: true }); onOpenBot.mockReset(); onOpenGroup.mockReset(); container = document.createElement("div"); document.body.append(container); root = createRoot(container); }); afterEach(async () => { await act(async () => root.unmount()); container.remove(); vi.unstubAllGlobals(); }); it("renders nothing until a bot needs the user", async () => { api.list.mockResolvedValue({ items: [] }); await render(); expect(api.list).toHaveBeenCalledOnce(); expect(container.querySelector("[data-testid='main-inbox']")).toBeNull(); }); it("shows each item with its bot as the sender", async () => { api.list.mockResolvedValue({ items: [askInOwnThread, approvalInGroup, takeover, unread] }); await render(); const inbox = container.querySelector("[data-testid='main-inbox']"); expect(inbox?.getAttribute("aria-label")).toBe("Needs you"); expect(itemOf("ask").textContent).toContain("Scout"); expect(itemOf("ask").textContent).toContain("Which city should I use?"); expect(itemOf("approval").textContent).toContain("Writer"); expect(itemOf("takeover").textContent).toContain("Sign in to continue."); expect(itemOf("unread").textContent).toContain("Draft ready for review"); }); it("answers through the thread the ask came from and drops only that item", async () => { api.list .mockResolvedValueOnce({ items: [askInOwnThread, approvalInGroup] }) .mockReturnValue(new Promise(() => undefined)); api.answer.mockResolvedValue({ ok: true }); await render(); await act(async () => itemOf("approval").querySelector("[data-answer]")?.click(), ); expect(api.answer).toHaveBeenCalledWith({ groupId: "group-ops", runId: "run-approval", messageId: "msg-approval", answer: "Paris", }); expect(api.markRead).not.toHaveBeenCalled(); expect(api.list).toHaveBeenCalledTimes(2); expect(container.querySelectorAll("[data-testid='main-inbox-item']")).toHaveLength(1); expect(itemOf("ask")).toBeTruthy(); }); it("marks the bot's own thread read before answering, so the question does not return as unread", async () => { api.list.mockResolvedValueOnce({ items: [askInOwnThread] }).mockResolvedValue({ items: [] }); api.answer.mockResolvedValue({ ok: true }); await render(); await act(async () => itemOf("ask").querySelector("[data-answer]")?.click()); expect(api.markRead).toHaveBeenCalledWith({ botId: scout.id }); expect(api.markRead.mock.invocationCallOrder[0]).toBeLessThan( api.answer.mock.invocationCallOrder[0]!, ); expect(container.querySelector("[data-testid='main-inbox']")).toBeNull(); }); it("refreshes after a failed answer so a stale ask disappears", async () => { api.list.mockResolvedValueOnce({ items: [askInOwnThread] }).mockResolvedValue({ items: [] }); api.answer.mockRejectedValue(new Error("This prompt is no longer awaiting an answer")); await render(); await act(async () => itemOf("ask").querySelector("[data-answer]")?.click()); expect(api.answer).toHaveBeenCalledWith({ botId: scout.id, runId: "run-ask", messageId: "msg-ask", answer: "Paris", }); expect(container.querySelector("[data-testid='main-inbox']")).toBeNull(); }); it("opens the bot's thread for takeovers and unread threads, and the source thread for asks", async () => { api.list.mockResolvedValue({ items: [approvalInGroup, takeover, unread] }); await render(); await act(async () => itemOf("takeover").querySelector("button")?.click()); await act(async () => itemOf("unread").querySelector("button")?.click()); await act(async () => itemOf("approval").querySelector("button")?.click()); expect(onOpenBot.mock.calls).toEqual([[writer.id], [scout.id]]); expect(onOpenGroup.mock.calls).toEqual([["group-ops"]]); }); it("refreshes when a bot's state changes, not on every render", async () => { api.list.mockResolvedValue({ items: [] }); await render(); await render([...idleBots]); expect(api.list).toHaveBeenCalledOnce(); await render([{ ...idleBots[0]!, status: "waiting_input" }, idleBots[1]!]); expect(api.list).toHaveBeenCalledTimes(2); });