Files
hermes-agent/web/src/pages/ChatPage.test.tsx
2026-09-15 18:38:16 -07:00

619 lines
18 KiB
TypeScript

// @vitest-environment jsdom
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { MemoryRouter } from "react-router";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
PTY_RECONNECT_MAX_ATTEMPTS,
PTY_RECONNECT_MAX_MS,
PTY_TICKET_TIMEOUT_MS,
} from "@/lib/pty-reconnect";
class FakeFitAddon {
fit() {}
}
class FakeWebglAddon {
constructor() {
webglAddonConstructed += 1;
}
onContextLoss() {
return { dispose() {} };
}
}
class FakeTerminal {
options: Record<string, unknown>;
rows = 24;
cols = 80;
parser = {
registerOscHandler: vi.fn(),
};
unicode = { activeVersion: "" };
constructor(options: Record<string, unknown>) {
this.options = options;
}
attachCustomKeyEventHandler() {
return true;
}
attachCustomWheelEventHandler() {
return true;
}
clearSelection() {}
dispose() {}
focus() {}
getSelection() {
return "";
}
loadAddon() {}
onData() {
return { dispose() {} };
}
onResize() {
return { dispose() {} };
}
onScroll() {
return { dispose() {} };
}
get buffer() {
// Minimal active-buffer surface for the resume follow-scroll pin
// (isViewportPinnedToBottom reads viewportY/baseY).
return { active: { baseY: 0, viewportY: 0 } };
}
scrollToBottom() {}
open() {}
paste() {}
refresh() {}
write() {}
}
const maybeReloadForLoopbackWsAuthFailure = vi.fn(() => false);
const apiMocks = vi.hoisted(() => ({
buildWsUrl: vi.fn(async () => "ws://localhost/api/pty?channel=chat-1"),
}));
vi.mock("@xterm/addon-fit", () => ({ FitAddon: FakeFitAddon }));
vi.mock("@xterm/addon-unicode11", () => ({ Unicode11Addon: class {} }));
vi.mock("@xterm/addon-web-links", () => ({ WebLinksAddon: class {} }));
vi.mock("@xterm/addon-webgl", () => ({ WebglAddon: FakeWebglAddon }));
vi.mock("@xterm/xterm", () => ({ Terminal: FakeTerminal }));
vi.mock("@/components/ChatSidebar", () => ({
ChatSidebar: () => null,
}));
vi.mock("@/components/ChatSessionList", () => ({
ChatSessionList: () => null,
}));
vi.mock("@/components/Backdrop", () => ({ Backdrop: () => null }));
vi.mock("@/plugins", () => ({
PluginSlot: () => null,
}));
vi.mock("@/contexts/usePageHeader", () => ({
usePageHeader: () => ({ setEnd: vi.fn(), setTitle: vi.fn() }),
}));
vi.mock("@/contexts/useProfileScope", () => ({
useProfileScope: () => ({ profile: "" }),
}));
vi.mock("@/themes", () => ({
useTheme: () => ({ theme: { terminalBackground: "#000000" } }),
}));
vi.mock("@/i18n", () => ({
useI18n: () => ({
t: {
app: {
closeModelTools: "Close model tools",
modelToolsSheetSubtitle: "Tools",
modelToolsSheetTitle: "Model",
},
},
}),
}));
vi.mock("@/lib/dashboard-auth-reload", () => ({
maybeReloadForLoopbackWsAuthFailure,
}));
vi.mock("@/lib/api", () => ({
api: apiMocks,
buildWsUrl: apiMocks.buildWsUrl,
}));
class FakeWebSocket {
static instances: FakeWebSocket[] = [];
static OPEN = 1;
binaryType = "blob";
onclose: ((event: CloseEventLike) => void) | null = null;
onmessage: ((event: { data: ArrayBuffer | string }) => void) | null = null;
onopen: (() => void) | null = null;
readyState = FakeWebSocket.OPEN;
url: string;
constructor(url: string) {
this.url = url;
FakeWebSocket.instances.push(this);
}
close() {
this.readyState = 3;
}
send = vi.fn();
}
type CloseEventLike = {
code: number;
reason: string;
wasClean: boolean;
};
let container: HTMLDivElement;
let root: Root;
let webglAddonConstructed = 0;
// jsdom runs without an origin here (per-file @vitest-environment jsdom on a
// node-default config), so localStorage is undefined. Stub it so components
// that persist UI state (side panel collapse) can be exercised.
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => {
store[key] = String(value);
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
store = {};
},
};
})();
// React only routes updates through act() when this flag is set; without it
// the isActive re-renders in the keyboard-inset gate test warn.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
async function render(ui: ReactNode) {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
await act(async () => root.render(ui));
}
beforeEach(() => {
FakeWebSocket.instances = [];
webglAddonConstructed = 0;
maybeReloadForLoopbackWsAuthFailure.mockClear();
apiMocks.buildWsUrl.mockReset();
apiMocks.buildWsUrl.mockResolvedValue("ws://localhost/api/pty?channel=chat-1");
vi.stubGlobal("WebSocket", FakeWebSocket);
vi.stubGlobal(
"ResizeObserver",
class {
disconnect() {}
observe() {}
unobserve() {}
},
);
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
cb(0);
return 1;
});
vi.stubGlobal("cancelAnimationFrame", () => {});
vi.stubGlobal("matchMedia", () => ({
addEventListener() {},
matches: false,
media: "",
removeEventListener() {},
}));
vi.stubGlobal("crypto", {
getRandomValues: (values: Uint8Array) => {
values.fill(7);
return values;
},
randomUUID: () => "chat-test-id",
});
Object.defineProperty(window, "visualViewport", {
configurable: true,
value: { addEventListener() {}, removeEventListener() {}, width: 1280 },
});
Object.defineProperty(window, "__HERMES_SESSION_TOKEN__", {
configurable: true,
value: "stale-token",
writable: true,
});
Object.defineProperty(window, "__HERMES_AUTH_REQUIRED__", {
configurable: true,
value: false,
writable: true,
});
Object.defineProperty(window.navigator, "clipboard", {
configurable: true,
value: {
readText: vi.fn(async () => ""),
writeText: vi.fn(async () => {}),
},
});
sessionStorage.clear();
vi.stubGlobal("localStorage", localStorageMock);
localStorageMock.clear();
});
afterEach(async () => {
await act(async () => root?.unmount());
container?.remove();
vi.unstubAllGlobals();
});
describe("ChatPage", () => {
it("uses the canvas renderer and sends a visible PTY keepalive", async () => {
vi.useFakeTimers();
try {
const { default: ChatPage } = await import("./ChatPage");
await render(
<MemoryRouter initialEntries={["/chat"]}>
<ChatPage isActive />
</MemoryRouter>,
);
await act(async () => {
await Promise.resolve();
});
expect(FakeWebSocket.instances).toHaveLength(1);
expect(webglAddonConstructed).toBe(0);
const socket = FakeWebSocket.instances[0];
await act(async () => socket.onopen?.());
socket.send.mockClear();
await act(async () => {
await vi.advanceTimersByTimeAsync(20_000);
});
expect(socket.send).toHaveBeenCalledWith("\x1b[RESIZE:80;24]");
} finally {
vi.useRealTimers();
}
});
it("defers a reconnect while the chat tab is inactive", async () => {
vi.useFakeTimers();
try {
const { default: ChatPage } = await import("./ChatPage");
await render(
<MemoryRouter initialEntries={["/chat"]}>
<ChatPage isActive />
</MemoryRouter>,
);
await act(async () => {
await Promise.resolve();
});
const socket = FakeWebSocket.instances[0];
await act(async () => {
socket.onclose?.({ code: 1001, reason: "", wasClean: true });
root.render(
<MemoryRouter initialEntries={["/chat"]}>
<ChatPage isActive={false} />
</MemoryRouter>,
);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(1_000);
});
expect(FakeWebSocket.instances).toHaveLength(1);
} finally {
vi.useRealTimers();
}
});
it("treats loopback 4401 closes as stale-token reload candidates", async () => {
const { default: ChatPage } = await import("./ChatPage");
await render(
<MemoryRouter initialEntries={["/chat"]}>
<ChatPage isActive />
</MemoryRouter>,
);
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1));
FakeWebSocket.instances[0].onclose?.({
code: 4401,
reason: "auth: token_mismatch",
wasClean: true,
});
expect(maybeReloadForLoopbackWsAuthFailure).toHaveBeenCalledWith(4401);
});
it("explains an expired login in plain words with a Reload button when auto-reload is spent", async () => {
const { default: ChatPage } = await import("./ChatPage");
await render(
<MemoryRouter initialEntries={["/chat"]}>
<ChatPage isActive />
</MemoryRouter>,
);
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1));
await act(async () => {
FakeWebSocket.instances[0].onclose?.({ code: 4401, reason: "auth: bad-token", wasClean: true });
});
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toMatch(/login expired/i);
expect(alert?.textContent).not.toMatch(/auth failed|bad-token|4401/i);
const labels = Array.from(container.querySelectorAll("button")).map((b) => b.textContent?.trim());
expect(labels).toContain("Reload page");
});
it("renders Start new session after the server could not start the chat (1011)", async () => {
const { default: ChatPage } = await import("./ChatPage");
await render(
<MemoryRouter initialEntries={["/chat"]}>
<ChatPage isActive />
</MemoryRouter>,
);
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1));
await act(async () => {
FakeWebSocket.instances[0].onclose?.({ code: 1011, reason: "", wasClean: true });
});
expect(container.textContent).toMatch(/Chat could not start/);
const labels = Array.from(container.querySelectorAll("button")).map((b) => b.textContent?.trim());
expect(labels).toContain("Start new session");
});
it("offers Open logs when the agent process ended, since a crash looks like /exit", async () => {
const { default: ChatPage } = await import("./ChatPage");
await render(
<MemoryRouter initialEntries={["/chat"]}>
<ChatPage isActive />
</MemoryRouter>,
);
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1));
await act(async () => {
FakeWebSocket.instances[0].onclose?.({ code: 4410, reason: "", wasClean: true });
});
expect(container.textContent).toMatch(/may have crashed/i);
const labels = Array.from(container.querySelectorAll("button")).map((b) => b.textContent?.trim());
expect(labels).toContain("Start new session");
expect(labels).toContain("Open logs");
});
it("stops retrying after the ladder is spent and offers Check server status", async () => {
vi.useFakeTimers();
try {
const { default: ChatPage } = await import("./ChatPage");
await render(
<MemoryRouter initialEntries={["/chat"]}>
<ChatPage isActive />
</MemoryRouter>,
);
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1));
// Drop the socket abnormally; walk every scheduled retry to failure.
for (let attempt = 0; attempt <= PTY_RECONNECT_MAX_ATTEMPTS; attempt += 1) {
const sockets = FakeWebSocket.instances.length;
await act(async () => {
FakeWebSocket.instances[sockets - 1].onclose?.({ code: 1006, reason: "", wasClean: false });
});
await act(async () => {
await vi.advanceTimersByTimeAsync(PTY_RECONNECT_MAX_MS + 100);
});
}
expect(container.textContent).not.toMatch(/code 1006/);
expect(container.textContent).toMatch(/Lost connection to the Hermes dashboard server/);
expect(container.textContent).toContain("hermes dashboard");
const labels = Array.from(container.querySelectorAll("button")).map((b) => b.textContent?.trim());
expect(labels).toContain("Reconnect now");
expect(labels).toContain("Check server status");
} finally {
vi.useRealTimers();
}
});
it("attaches visualViewport keyboard-inset listeners only while the chat tab is active", async () => {
// NS-434 follow-up: ChatPage stays mounted (hidden) on every dashboard
// route. The keyboard-inset/scroll-pin listeners must only be live while
// /chat is the active tab, or the scroll pin fires when a soft keyboard
// opens on Settings etc.
const addEventListener = vi.fn();
const removeEventListener = vi.fn();
Object.defineProperty(window, "visualViewport", {
configurable: true,
value: { addEventListener, removeEventListener, width: 1280 },
});
const { default: ChatPage } = await import("./ChatPage");
await render(
<MemoryRouter initialEntries={["/chat"]}>
<ChatPage isActive={false} />
</MemoryRouter>,
);
expect(addEventListener).not.toHaveBeenCalled();
await act(async () =>
root.render(
<MemoryRouter initialEntries={["/chat"]}>
<ChatPage isActive />
</MemoryRouter>,
),
);
expect(addEventListener.mock.calls.map((c) => c[0]).sort()).toEqual([
"resize",
"scroll",
]);
expect(removeEventListener).not.toHaveBeenCalled();
await act(async () =>
root.render(
<MemoryRouter initialEntries={["/chat"]}>
<ChatPage isActive={false} />
</MemoryRouter>,
),
);
expect(removeEventListener.mock.calls.map((c) => c[0]).sort()).toEqual([
"resize",
"scroll",
]);
});
});
describe("ChatPage side panel collapse", () => {
async function renderChat() {
const { default: ChatPage } = await import("./ChatPage");
await render(
<MemoryRouter initialEntries={["/chat"]}>
<ChatPage isActive />
</MemoryRouter>,
);
}
it("collapses the desktop side panel and persists the choice", async () => {
localStorage.clear();
await renderChat();
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1));
const collapseButton = container.querySelector(
'[aria-label="Collapse chat side panel"]',
);
expect(collapseButton).not.toBeNull();
await act(async () => {
collapseButton!.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
});
expect(localStorage.getItem("hermes-chat-panel-collapsed")).toBe("1");
expect(
container.querySelector('[aria-label="Collapse chat side panel"]'),
).toBeNull();
expect(
container.querySelector('[aria-label="Show chat side panel"]'),
).not.toBeNull();
// Reopening restores the panel and clears the persisted flag.
await act(async () => {
container
.querySelector('[aria-label="Show chat side panel"]')!
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(localStorage.getItem("hermes-chat-panel-collapsed")).toBe("0");
expect(
container.querySelector('[aria-label="Collapse chat side panel"]'),
).not.toBeNull();
});
});
// The gated-mode ticket request runs before any socket exists, so a rejection
// or a hang emits no `close` event and never arms PTY_CONNECTING_TIMEOUT_MS
// (that timer is set after `new WebSocket`). Without its own deadline the tab
// strands on "connecting" with no retry. Mirrors the ChatSidebar events-feed
// coverage in src/components/ChatSidebar.test.tsx.
describe("ChatPage PTY ticket connect deadline", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
async function renderChat() {
const { default: ChatPage } = await import("./ChatPage");
await render(
<MemoryRouter initialEntries={["/chat"]}>
<ChatPage isActive />
</MemoryRouter>,
);
}
/** Advance timers and flush the async connect that fires on the tick. */
async function advance(ms: number) {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms);
});
}
it("retries when the ticket request rejects", async () => {
apiMocks.buildWsUrl.mockRejectedValueOnce(
new Error("ticket endpoint unavailable"),
);
await renderChat();
await advance(0);
expect(FakeWebSocket.instances).toHaveLength(0);
// First backoff step is 250ms; the retry must mint a fresh ticket.
await advance(250);
expect(apiMocks.buildWsUrl).toHaveBeenCalledTimes(2);
expect(FakeWebSocket.instances).toHaveLength(1);
});
it("times out a stalled ticket request and retries", async () => {
let resolveStalledRequest!: (url: string) => void;
apiMocks.buildWsUrl.mockImplementationOnce(
() =>
new Promise<string>((resolve) => {
resolveStalledRequest = resolve;
}),
);
await renderChat();
await advance(0);
expect(FakeWebSocket.instances).toHaveLength(0);
await advance(PTY_TICKET_TIMEOUT_MS);
expect(FakeWebSocket.instances).toHaveLength(0);
// A late ticket from the timed-out attempt must not open a socket behind
// the replacement the deadline scheduled.
await act(async () => {
resolveStalledRequest("ws://localhost/api/pty?channel=stale");
await Promise.resolve();
});
expect(FakeWebSocket.instances).toHaveLength(0);
await advance(250);
expect(FakeWebSocket.instances).toHaveLength(1);
expect(FakeWebSocket.instances[0].url).not.toContain("channel=stale");
});
it("leaves a settled ticket's socket to the CONNECTING timer", async () => {
await renderChat();
await advance(0);
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1));
// NS-591 regression: once the socket exists the ticket deadline is
// disarmed, so PTY_CONNECTING_TIMEOUT_MS stays the only thing that may
// force-close a wedged handshake — the two must not both fire.
await advance(PTY_TICKET_TIMEOUT_MS);
expect(apiMocks.buildWsUrl).toHaveBeenCalledTimes(1);
});
});