/** * @file Sagi.test.tsx * @description Render tests for the Sagi companion component — mounting, mood rendering, the ⌘B panel toggle, and accessibility attributes. * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { render, renderHook, screen, fireEvent, act, cleanup, within, } from "@testing-library/react"; import { useSagiPosition } from "../useSagiPosition"; import { MemoryRouter } from "react-router-dom"; import { Sagi } from "../Sagi"; import { eventBus } from "../../../lib/eventBus"; import type { WSMessage, Session } from "../../../lib/types"; function renderSagi() { return render( ); } const sessionMsg = (id: string, status: Session["status"]): WSMessage => ({ type: "session_updated", data: { id, status } as Session, timestamp: "t", }); beforeEach(() => { localStorage.clear(); eventBus.setConnected(true); // Freeze timers so the brain's 1s heartbeat tick can't fire a state update // outside act() mid-assertion. We never advance them in these tests. vi.useFakeTimers(); }); afterEach(() => { cleanup(); vi.useRealTimers(); }); describe("Sagi widget", () => { it("renders the avatar button by default", () => { renderSagi(); expect(screen.getByRole("button", { name: /open sagi companion/i })).toBeInTheDocument(); expect(screen.getByRole("img", { name: /sagi/i })).toBeInTheDocument(); }); it("opens the panel on click and answers a local status question", () => { renderSagi(); fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i })); const panel = screen.getByRole("dialog", { name: /sagi companion/i }); expect(panel).toBeInTheDocument(); const input = within(panel).getByLabelText(/ask sagi/i); fireEvent.change(input, { target: { value: "status" } }); fireEvent.submit(input.closest("form")!); expect(within(panel).getByText(/live ·/i)).toBeInTheDocument(); }); it("toggles open/closed with Cmd/Ctrl+B and closes with Esc", () => { renderSagi(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); act(() => { fireEvent.keyDown(window, { key: "b", metaKey: true }); }); expect(screen.getByRole("dialog")).toBeInTheDocument(); act(() => { fireEvent.keyDown(window, { key: "Escape" }); }); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); it("shows an error badge when a session errors", () => { renderSagi(); act(() => { eventBus.publish(sessionMsg("a", "error")); }); const btn = screen.getByRole("button", { name: /open sagi companion/i }); expect(within(btn).getByText("1")).toBeInTheDocument(); }); it("names the errored session in its bubble and in the panel", () => { renderSagi(); act(() => { eventBus.publish({ type: "session_updated", data: { id: "a", status: "error", name: "Fix login", cwd: "/w/app", lane_id: 3 } as Session, timestamp: "t", }); }); const bubble = screen.getByRole("status"); expect(within(bubble).getByText("Fix login")).toBeInTheDocument(); expect(within(bubble).getByRole("button", { name: "Open" })).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i })); const panel = screen.getByRole("dialog", { name: /sagi companion/i }); expect(within(panel).getByText("Needs you")).toBeInTheDocument(); expect(within(panel).getByRole("button", { name: /Fix login/ })).toBeInTheDocument(); }); it("stays quiet about a cleared error when that session updates again", () => { renderSagi(); act(() => { eventBus.publish(sessionMsg("a", "error")); }); fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i })); fireEvent.click(screen.getByRole("button", { name: "Clear alerts" })); act(() => { eventBus.publish(sessionMsg("a", "error")); }); const btn = screen.getByRole("button", { name: /close sagi/i, expanded: true }); expect(within(btn).queryByText("1")).not.toBeInTheDocument(); expect(JSON.parse(localStorage.getItem("agenthub.pet.dismissedErrors")!)).toEqual(["a"]); }); it("renames the pet from the panel header", () => { renderSagi(); fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i })); fireEvent.click(screen.getByRole("button", { name: /rename sagi/i })); const input = screen.getByLabelText("Pet name"); fireEvent.change(input, { target: { value: "Mochi" } }); fireEvent.keyDown(input, { key: "Enter" }); expect(screen.getByRole("dialog", { name: /mochi companion/i })).toBeInTheDocument(); expect(screen.getByLabelText(/ask mochi/i)).toBeInTheDocument(); }); it("hands a question it cannot answer to the chat window", () => { localStorage.setItem("agenthub.defaultTool", "claude"); renderSagi(); fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i })); const input = screen.getByLabelText(/ask sagi/i); fireEvent.change(input, { target: { value: "write me a haiku" } }); fireEvent.submit(input.closest("form")!); const chat = screen.getByRole("dialog", { name: /chat with sagi/i }); expect(within(chat).getByText("write me a haiku")).toBeInTheDocument(); expect(screen.queryByRole("dialog", { name: /sagi companion/i })).not.toBeInTheDocument(); }); it("has no Chat button — Ask opens the chat — and Back returns to the panel", () => { localStorage.setItem("agenthub.defaultTool", "claude"); renderSagi(); fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i })); expect(screen.queryByRole("button", { name: /chat with sagi/i })).not.toBeInTheDocument(); const input = screen.getByLabelText(/ask sagi/i); fireEvent.change(input, { target: { value: "write me a haiku" } }); fireEvent.submit(input.closest("form")!); expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Back" })); expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument(); }); it("a pat shows its reaction on the pet even while the panel is open", () => { renderSagi(); fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i })); fireEvent.click(screen.getByRole("button", { name: /pat sagi/i })); const pet = screen.getByRole("button", { name: /close sagi/i, expanded: true }); expect(pet.querySelector(".sagi-reaction-text")?.textContent).toBeTruthy(); }); it("stroking the pet back and forth pats it", () => { renderSagi(); const pet = screen.getByRole("button", { name: /open sagi companion/i }); const xs = [100, 130, 100, 130, 100, 130, 100]; act(() => { for (const x of xs) fireEvent.pointerMove(pet, { clientX: x, clientY: 50, buttons: 0 }); }); expect(pet.querySelector(".sagi-reaction-text")).not.toBeNull(); }); it("reflects the live count in the panel status", () => { renderSagi(); act(() => { eventBus.publish(sessionMsg("a", "active")); eventBus.publish(sessionMsg("b", "active")); }); fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i })); const panel = screen.getByRole("dialog", { name: /sagi companion/i }); // The "live" stat chip shows value 2 next to its label. const liveChip = within(panel).getByText("live").closest("div")!; expect(within(liveChip).getByText("2")).toBeInTheDocument(); }); it("respects the enabled preference", () => { localStorage.setItem("agent-dashboard-sagi-enabled", "false"); renderSagi(); expect(screen.queryByRole("button", { name: /open sagi companion/i })).not.toBeInTheDocument(); }); it("a tap (no movement) still opens the panel", () => { renderSagi(); const btn = screen.getByRole("button", { name: /open sagi companion/i }); act(() => { fireEvent.pointerDown(btn, { clientX: 990, clientY: 700, button: 0 }); fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 }); }); fireEvent.click(btn); expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument(); }); it("dragging snaps to an edge, persists position, and does not open the panel", () => { renderSagi(); const btn = screen.getByRole("button", { name: /open sagi companion/i }); // Default dock is bottom-right. Drag far to the left past the threshold. act(() => { fireEvent.pointerDown(btn, { clientX: 990, clientY: 700, button: 0 }); fireEvent.pointerMove(btn, { clientX: 80, clientY: 300 }); fireEvent.pointerUp(btn, { clientX: 80, clientY: 300 }); }); // The synthetic click that follows a drag must be swallowed. fireEvent.click(btn); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); const saved = JSON.parse(localStorage.getItem("agent-dashboard-sagi-pos") || "{}"); expect(saved.side).toBe("left"); expect(typeof saved.y).toBe("number"); }); it("a sub-threshold pointer move is treated as a tap, not a drag", () => { renderSagi(); const btn = screen.getByRole("button", { name: /open sagi companion/i }); act(() => { fireEvent.pointerDown(btn, { clientX: 990, clientY: 700, button: 0 }); fireEvent.pointerMove(btn, { clientX: 992, clientY: 701 }); // < 5px threshold fireEvent.pointerUp(btn, { clientX: 992, clientY: 701 }); }); fireEvent.click(btn); expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument(); // No position was persisted because no real drag happened. expect(localStorage.getItem("agent-dashboard-sagi-pos")).toBeNull(); }); it("restores a persisted left-edge position on mount", () => { localStorage.setItem("agent-dashboard-sagi-pos", JSON.stringify({ side: "left", y: 0.2 })); renderSagi(); const btn = screen.getByRole("button", { name: /open sagi companion/i }) as HTMLElement; // Left-docked → inline left equals the edge margin (16px). expect(btn.style.left).toBe("16px"); }); it("Ctrl/Cmd+B is ignored while typing in a field", () => { renderSagi(); const ta = document.createElement("textarea"); document.body.appendChild(ta); ta.focus(); act(() => { fireEvent.keyDown(ta, { key: "b", ctrlKey: true }); }); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); ta.remove(); }); it("Ctrl/Cmd+B is left to the browser while the mascot is hidden", () => { localStorage.setItem("agent-dashboard-sagi-enabled", "false"); renderSagi(); const ev = new KeyboardEvent("keydown", { key: "b", ctrlKey: true, cancelable: true }); window.dispatchEvent(ev); expect(ev.defaultPrevented).toBe(false); }); it("docks bottom-right above the status bar by default", () => { renderSagi(); const btn = screen.getByRole("button", { name: /open sagi companion/i }) as HTMLElement; const top = parseFloat(btn.style.top); const size = parseFloat(btn.style.width); // Bottom edge sits above the 24px status bar plus the edge margin. expect(top + size).toBe(window.innerHeight - 16 - 24); }); it("arrow keys move the focused avatar and persist it", () => { renderSagi(); const btn = screen.getByRole("button", { name: /open sagi companion/i }) as HTMLElement; const before = parseFloat(btn.style.top); act(() => { fireEvent.keyDown(btn, { key: "ArrowUp" }); }); expect(parseFloat(btn.style.top)).toBeLessThan(before); act(() => { fireEvent.keyDown(btn, { key: "ArrowLeft" }); }); expect(btn.style.left).toBe("16px"); expect(JSON.parse(localStorage.getItem("agent-dashboard-sagi-pos") || "{}").side).toBe("left"); }); }); describe("useSagiPosition moving", () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); it("names the drag's direction and settles once the pointer rests", () => { const { result } = renderHook(() => useSagiPosition(68)); const target = { setPointerCapture() {}, releasePointerCapture() {} }; const ev = (x: number) => ({ button: 0, pointerId: 1, clientX: x, clientY: 300, currentTarget: target }) as never; act(() => result.current.onPointerDown(ev(500))); act(() => result.current.onPointerMove(ev(480))); expect(result.current.moving).toBe("left"); act(() => result.current.onPointerMove(ev(520))); expect(result.current.moving).toBe("right"); act(() => vi.advanceTimersByTime(400)); expect(result.current.moving).toBeNull(); }); });