// @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);
});