f460fff406
- Sagi: clicking the pet opens its chat window directly (PetChat + SagiInfo status strip, Needs you, Mute / Clear alerts); SagiPanel removed. - Chat view answers Claude's unnumbered folder-trust prompt (parseBareMenu). - pty-attach: resize/write after a Windows attach exited, and node-pty's pipe errors, no longer throw out of the WebSocket handler. - restart-helper: the server started by an update logs to data/agenthub-server.log instead of discarding its output.
322 lines
13 KiB
TypeScript
322 lines
13 KiB
TypeScript
/**
|
|
* @file Sagi.test.tsx
|
|
* @description Render tests for the Sagi companion component — mounting, mood rendering, the ⌘B chat toggle (the chat window is the pet's whole UI: status strip, local answers, New chat stopping the old session), and accessibility attributes.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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 { api } from "../../../lib/api";
|
|
import type { WSMessage, Session } from "../../../lib/types";
|
|
|
|
function renderSagi() {
|
|
return render(
|
|
<MemoryRouter>
|
|
<Sagi />
|
|
</MemoryRouter>
|
|
);
|
|
}
|
|
|
|
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 chat on click and answers a local status question there", () => {
|
|
renderSagi();
|
|
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
|
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
|
|
expect(panel).toBeInTheDocument();
|
|
|
|
const input = within(panel).getByLabelText(/ask sagi anything/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 chat", () => {
|
|
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: /chat with sagi/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 chat 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: /chat with mochi/i })).toBeInTheDocument();
|
|
expect(screen.getByLabelText(/ask mochi anything/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it("hands a question it cannot answer to the AI", () => {
|
|
localStorage.setItem("agenthub.defaultTool", "claude");
|
|
const ensure = vi.spyOn(api.lanes, "ensure").mockReturnValue(new Promise(() => {}));
|
|
renderSagi();
|
|
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
|
const chat = screen.getByRole("dialog", { name: /chat with sagi/i });
|
|
const input = within(chat).getByLabelText(/ask sagi anything/i);
|
|
fireEvent.change(input, { target: { value: "write me a haiku" } });
|
|
fireEvent.submit(input.closest("form")!);
|
|
expect(within(chat).getByText("write me a haiku")).toBeInTheDocument();
|
|
expect(ensure).toHaveBeenCalledWith({ title: "Sagi" });
|
|
ensure.mockRestore();
|
|
});
|
|
|
|
it("reopens the remembered chat, and New chat stops its session", async () => {
|
|
localStorage.setItem(
|
|
"agenthub.pet.chat",
|
|
JSON.stringify({ laneId: 7, runId: "agenthub-lane-7-1", tool: "claude" })
|
|
);
|
|
const get = vi
|
|
.spyOn(api.run, "get")
|
|
.mockReturnValue(new Promise(() => {}) as ReturnType<typeof api.run.get>);
|
|
const kill = vi.spyOn(api.run, "kill").mockResolvedValue({ ok: true });
|
|
renderSagi();
|
|
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
|
expect(screen.getByRole("button", { name: /open in workspace/i })).toBeInTheDocument();
|
|
await act(async () => {
|
|
fireEvent.click(screen.getByRole("button", { name: /new chat/i }));
|
|
});
|
|
expect(kill).toHaveBeenCalledWith("agenthub-lane-7-1", { archive: true });
|
|
expect(localStorage.getItem("agenthub.pet.chat")).toBeNull();
|
|
expect(screen.queryByRole("button", { name: /open in workspace/i })).not.toBeInTheDocument();
|
|
get.mockRestore();
|
|
kill.mockRestore();
|
|
});
|
|
|
|
it("a pat shows its reaction on the pet even while the chat 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 chat 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: /chat with sagi/i });
|
|
// The "live" stat chip shows value 2 next to its label.
|
|
const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement;
|
|
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 chat", () => {
|
|
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: /chat with sagi/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: /chat with sagi/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();
|
|
});
|
|
});
|