Files
AgentHub/client/src/components/Sagi/__tests__/Sagi.test.tsx
T
nntrivi2001 4aae61297f feat: analytics parity — tool cost, date ranges, budgets, query explorer, replay, snapshots (v2.4.0)
Analytics parity phases 2–7 (docs/plans/analytics-parity/), built on
138e706 and ported onto v2.3.1:
- Cost by AI tool (?tool= on cost/analytics/stats/workflows, by_tool,
  Codex fast/priority and flex tiers, agenthub_cost_usd_total{tool,model})
- Date ranges (from/to) on cost/analytics/workflows; Analytics range select
  with Today / This week (Monday) / This month (1st) / 7d / 30d / 90d / All /
  Custom; workflow filters carried into workflow-stats.js (read worker)
- Budgets and cost alerts as alert rule types (alert_rules rebuilt without
  its rule_type CHECK), GET /api/budgets/status, rule preview, spend pace,
  status-bar budget chip; rules stay editable in place
- Query Explorer (/query, allowlisted sort, CSV/JSON export) and scheduled
  reports (report_definitions/report_runs, Settings → Reports)
- Session Replay tab and immutable snapshots (session_snapshots, /snapshot/:token)
- Remote Codex sync, push ingest with a hashed token, privacy redaction and
  ignored folders (Settings → Data), Cursor sessions (no cost)
- Read-only MCP server, statusline, Grafana/Prometheus files

Also: resizable sidebar, pet run/wave animations, the workspace home keeps a
half-typed prompt, and the pre-commit hook runs only the related tests for a
small commit.
2026-10-06 17:12:28 +07:00

223 lines
8.3 KiB
TypeScript

/**
* @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ĩ <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 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 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("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();
});
});