diff --git a/apps/shared/src/json-rpc-gateway.ts b/apps/shared/src/json-rpc-gateway.ts index b083d8e0e1a0..f296856d16be 100644 --- a/apps/shared/src/json-rpc-gateway.ts +++ b/apps/shared/src/json-rpc-gateway.ts @@ -53,6 +53,8 @@ export interface GatewayClientOptions { connectErrorMessage?: string connectTimeoutMs?: number createRequestId?: (nextId: number) => GatewayRequestId + /** Return true to intercept the default closed-state transition. */ + onSocketClose?: (event: CloseEvent) => boolean | void requestIdPrefix?: string requestTimeoutMs?: number socketFactory?: (url: string) => WebSocketLike @@ -83,6 +85,7 @@ export class JsonRpcGatewayClient { connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS, createRequestId: options.createRequestId ?? ((nextId: number) => `${options.requestIdPrefix ?? 'r'}${nextId}`), notConnectedErrorMessage: options.notConnectedErrorMessage ?? 'gateway not connected', + onSocketClose: options.onSocketClose ?? (() => false), requestIdPrefix: options.requestIdPrefix ?? 'r', requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, socketFactory: options.socketFactory @@ -111,11 +114,15 @@ export class JsonRpcGatewayClient { this.handleMessage(message.data) }) - socket.addEventListener('close', () => { + socket.addEventListener('close', event => { if (this.socket !== socket) { return } + if (this.options.onSocketClose(event)) { + return + } + this.socket = null this.setState('closed') this.rejectAllPending(new Error(this.options.closedErrorMessage)) diff --git a/web/src/components/ChatSidebar.test.tsx b/web/src/components/ChatSidebar.test.tsx new file mode 100644 index 000000000000..8a05ee6a6a90 --- /dev/null +++ b/web/src/components/ChatSidebar.test.tsx @@ -0,0 +1,133 @@ +// @vitest-environment jsdom +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const apiMocks = vi.hoisted(() => ({ + buildWsUrl: vi.fn(async () => "ws://localhost/api/events?channel=chat-1"), + getModelInfo: vi.fn(async () => ({ + capabilities: { supports_reasoning: false }, + model: "test/model", + })), +})); + +const gatewayMocks = vi.hoisted(() => ({ + close: vi.fn(), + connect: vi.fn(async () => undefined), + on: vi.fn(() => () => undefined), + onState: vi.fn((handler: (state: string) => void) => { + handler("open"); + return () => undefined; + }), + request: vi.fn(async () => ({ session_id: "sidecar-1" })), +})); + +const reloadMocks = vi.hoisted(() => ({ + maybeReloadForLoopbackWsAuthFailure: vi.fn(() => true), +})); + +vi.mock("@/lib/api", () => ({ + api: { getModelInfo: apiMocks.getModelInfo }, + buildWsUrl: apiMocks.buildWsUrl, +})); +vi.mock("@/lib/dashboard-auth-reload", () => ({ + maybeReloadForLoopbackWsAuthFailure: + reloadMocks.maybeReloadForLoopbackWsAuthFailure, +})); +vi.mock("@/lib/gatewayClient", () => ({ + GatewayClient: class { + close = gatewayMocks.close; + connect = gatewayMocks.connect; + on = gatewayMocks.on; + onState = gatewayMocks.onState; + request = gatewayMocks.request; + }, +})); +vi.mock("@/components/ModelPickerDialog", () => ({ + ModelPickerDialog: () => null, +})); +vi.mock("@/components/ModelReloadConfirm", () => ({ + ModelReloadConfirm: () => null, +})); +vi.mock("@/components/ReasoningPicker", () => ({ + ReasoningPicker: () => null, +})); +vi.mock("@nous-research/ui/ui/components/button", () => ({ + Button: ({ children }: { children?: ReactNode }) => , +})); +vi.mock("@nous-research/ui/ui/components/badge", () => ({ + Badge: ({ children }: { children?: ReactNode }) => {children}, +})); +vi.mock("@nous-research/ui/ui/components/card", () => ({ + Card: ({ children }: { children?: ReactNode }) =>
{children}
, +})); + +type EventLike = { code?: number; data?: string }; + +class FakeWebSocket { + static instances: FakeWebSocket[] = []; + + private listeners = new Map void>>(); + readonly url: string; + + constructor(url: string) { + this.url = url; + FakeWebSocket.instances.push(this); + } + + addEventListener(type: string, listener: (event: EventLike) => void) { + const listeners = this.listeners.get(type) ?? []; + listeners.push(listener); + this.listeners.set(type, listeners); + } + + close() {} + + emit(type: string, event: EventLike) { + for (const listener of this.listeners.get(type) ?? []) { + listener(event); + } + } +} + +let container: HTMLDivElement; +let root: Root; + +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 = []; + vi.clearAllMocks(); + reloadMocks.maybeReloadForLoopbackWsAuthFailure.mockReturnValue(true); + vi.stubGlobal("WebSocket", FakeWebSocket); +}); + +afterEach(async () => { + await act(async () => root?.unmount()); + container?.remove(); + vi.unstubAllGlobals(); +}); + +describe("ChatSidebar event socket", () => { + it("routes loopback 4401 closes through stale-token recovery", async () => { + const { ChatSidebar } = await import("./ChatSidebar"); + + await render(); + + await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); + expect(apiMocks.buildWsUrl).toHaveBeenCalledWith("/api/events", { + channel: "chat-1", + }); + + FakeWebSocket.instances[0].emit("close", { code: 4401 }); + + expect( + reloadMocks.maybeReloadForLoopbackWsAuthFailure, + ).toHaveBeenCalledWith(4401); + }); +}); diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index 57236e782bc8..fd4df2edcded 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -32,6 +32,7 @@ import { ModelReloadConfirm } from "@/components/ModelReloadConfirm"; import { ReasoningPicker } from "@/components/ReasoningPicker"; import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient"; import { api, buildWsUrl } from "@/lib/api"; +import { maybeReloadForLoopbackWsAuthFailure } from "@/lib/dashboard-auth-reload"; import { titleFromSessionInfoPayload } from "@/lib/chat-title"; import { cn } from "@/lib/utils"; @@ -258,6 +259,9 @@ export function ChatSidebar({ ws.addEventListener("error", () => surface(DISCONNECTED)); ws.addEventListener("close", (ev) => { + if (maybeReloadForLoopbackWsAuthFailure(ev.code)) { + return; + } if (ev.code === 4401 || ev.code === 4403) { surface(`events feed rejected (${ev.code}) — reload the page`); } else if (ev.code !== 1000) { diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts index 4d63d51a01a3..5868f2bd5d31 100644 --- a/web/src/lib/api.test.ts +++ b/web/src/lib/api.test.ts @@ -1,9 +1,37 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { api } from "./api"; +import { api, fetchJSON } from "./api"; + +const reloadMocks = vi.hoisted(() => ({ + attemptDashboardTokenReloadOnce: vi.fn(() => false), + clearDashboardTokenReloadAttempt: vi.fn(), +})); + +vi.mock("./dashboard-auth-reload", () => ({ + attemptDashboardTokenReloadOnce: reloadMocks.attemptDashboardTokenReloadOnce, + clearDashboardTokenReloadAttempt: reloadMocks.clearDashboardTokenReloadAttempt, +})); const SESSION_HEADER = "X-Hermes-Session-Token"; +beforeEach(() => { + reloadMocks.attemptDashboardTokenReloadOnce.mockReset(); + reloadMocks.attemptDashboardTokenReloadOnce.mockReturnValue(false); + reloadMocks.clearDashboardTokenReloadAttempt.mockReset(); + + 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, + }); +}); + afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); @@ -19,6 +47,47 @@ function jsonFetchMock(body: unknown = { ok: true }) { ); } +describe("fetchJSON", () => { + it("tries the one-shot reload path for loopback 401s", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + clone: () => ({ + json: async () => ({}), + }), + ok: false, + status: 401, + statusText: "Unauthorized", + text: async () => "Unauthorized", + })), + ); + reloadMocks.attemptDashboardTokenReloadOnce.mockReturnValue(true); + + const pending = fetchJSON("/api/status"); + await expect(Promise.race([pending, Promise.resolve("pending")])).resolves.toBe( + "pending", + ); + + expect(reloadMocks.attemptDashboardTokenReloadOnce).toHaveBeenCalledTimes(1); + expect(reloadMocks.clearDashboardTokenReloadAttempt).not.toHaveBeenCalled(); + }); + + it("clears the reload latch after a successful response", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + json: async () => ({ ok: true }), + ok: true, + status: 200, + })), + ); + + await expect(fetchJSON("/api/status")).resolves.toEqual({ ok: true }); + + expect(reloadMocks.clearDashboardTokenReloadAttempt).toHaveBeenCalledTimes(1); + }); +}); + describe("api.getModelOptions", () => { it("requests a live model refresh when asked", async () => { vi.stubGlobal("window", {}); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 114b2c89576d..bcc2ed5e2463 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -20,6 +20,10 @@ export const HERMES_BASE_PATH = readBasePath(); const BASE = HERMES_BASE_PATH; import type { DashboardTheme } from "@/themes/types"; +import { + attemptDashboardTokenReloadOnce, + clearDashboardTokenReloadAttempt, +} from "@/lib/dashboard-auth-reload"; // Ephemeral session token for protected endpoints. // Injected into index.html by the server — never fetched via API. @@ -156,20 +160,7 @@ export async function fetchJSON( // handled above, so reaching here in gated mode means a real // middleware failure that should not reload-loop. if (!window.__HERMES_AUTH_REQUIRED__ && !options?.allowUnauthorized) { - let alreadyReloaded = false; - try { - alreadyReloaded = - sessionStorage.getItem("hermes.tokenReloadAttempted") === "1"; - } catch { - /* SSR / privacy mode — fall through to throw */ - } - if (!alreadyReloaded) { - try { - sessionStorage.setItem("hermes.tokenReloadAttempted", "1"); - } catch { - /* SSR / privacy mode — best effort */ - } - window.location.reload(); + if (attemptDashboardTokenReloadOnce()) { return new Promise(() => {}); } } @@ -178,11 +169,7 @@ export async function fetchJSON( // Clear the stale-token reload guard: a successful 2xx proves the // current ``window.__HERMES_SESSION_TOKEN__`` is valid, so the next // 401 — if any — should be allowed to trigger its own reload cycle. - try { - sessionStorage.removeItem("hermes.tokenReloadAttempted"); - } catch { - /* SSR / privacy mode — ignore */ - } + clearDashboardTokenReloadAttempt(); } if (!res.ok) { const text = await res.text().catch(() => res.statusText); diff --git a/web/src/lib/dashboard-auth-reload.test.ts b/web/src/lib/dashboard-auth-reload.test.ts new file mode 100644 index 000000000000..3bce0d979692 --- /dev/null +++ b/web/src/lib/dashboard-auth-reload.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + attemptDashboardTokenReloadOnce, + clearDashboardTokenReloadAttempt, + maybeReloadForLoopbackWsAuthFailure, +} from "./dashboard-auth-reload"; + +function makeStorage() { + const values = new Map(); + return { + getItem(key: string) { + return values.get(key) ?? null; + }, + removeItem(key: string) { + values.delete(key); + }, + setItem(key: string, value: string) { + values.set(key, value); + }, + }; +} + +describe("attemptDashboardTokenReloadOnce", () => { + it("reloads once and latches the attempt", () => { + const storage = makeStorage(); + const reload = vi.fn(); + + expect(attemptDashboardTokenReloadOnce(storage, reload)).toBe(true); + expect(reload).toHaveBeenCalledTimes(1); + + expect(attemptDashboardTokenReloadOnce(storage, reload)).toBe(false); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("clears the latch when asked", () => { + const storage = makeStorage(); + const reload = vi.fn(); + + expect(attemptDashboardTokenReloadOnce(storage, reload)).toBe(true); + clearDashboardTokenReloadAttempt(storage); + expect(attemptDashboardTokenReloadOnce(storage, reload)).toBe(true); + expect(reload).toHaveBeenCalledTimes(2); + }); +}); + +describe("maybeReloadForLoopbackWsAuthFailure", () => { + it("reloads once for loopback 4401 closes", () => { + const storage = makeStorage(); + const reload = vi.fn(); + + expect( + maybeReloadForLoopbackWsAuthFailure(4401, false, storage, reload), + ).toBe(true); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("does not reload in gated mode or for other close codes", () => { + const storage = makeStorage(); + const reload = vi.fn(); + + expect( + maybeReloadForLoopbackWsAuthFailure(4401, true, storage, reload), + ).toBe(false); + expect( + maybeReloadForLoopbackWsAuthFailure(4403, false, storage, reload), + ).toBe(false); + expect(reload).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/lib/dashboard-auth-reload.ts b/web/src/lib/dashboard-auth-reload.ts new file mode 100644 index 000000000000..69c50836cd31 --- /dev/null +++ b/web/src/lib/dashboard-auth-reload.ts @@ -0,0 +1,69 @@ +type StorageLike = Pick; + +const TOKEN_RELOAD_STORAGE_KEY = "hermes.tokenReloadAttempted"; + +function dashboardAuthRequired(): boolean { + return typeof window !== "undefined" && !!window.__HERMES_AUTH_REQUIRED__; +} + +function reloadDashboardWindow(): void { + if (typeof window !== "undefined") { + window.location.reload(); + } +} + +function dashboardSessionStorage(): StorageLike | null { + if (typeof window === "undefined") return null; + try { + return window.sessionStorage; + } catch { + return null; + } +} + +export function clearDashboardTokenReloadAttempt( + storage: StorageLike | null = dashboardSessionStorage(), +): void { + try { + storage?.removeItem(TOKEN_RELOAD_STORAGE_KEY); + } catch { + /* privacy mode / blocked storage — ignore */ + } +} + +export function attemptDashboardTokenReloadOnce( + storage: StorageLike | null = dashboardSessionStorage(), + reload: () => void = reloadDashboardWindow, +): boolean { + let alreadyReloaded = false; + try { + alreadyReloaded = + storage?.getItem(TOKEN_RELOAD_STORAGE_KEY) === "1"; + } catch { + /* privacy mode / blocked storage — fall through */ + } + if (alreadyReloaded) { + return false; + } + + try { + storage?.setItem(TOKEN_RELOAD_STORAGE_KEY, "1"); + } catch { + /* privacy mode / blocked storage — best effort */ + } + + reload(); + return true; +} + +export function maybeReloadForLoopbackWsAuthFailure( + code: number, + authRequired = dashboardAuthRequired(), + storage: StorageLike | null = dashboardSessionStorage(), + reload: () => void = reloadDashboardWindow, +): boolean { + if (authRequired || code !== 4401) { + return false; + } + return attemptDashboardTokenReloadOnce(storage, reload); +} diff --git a/web/src/lib/gatewayClient.test.ts b/web/src/lib/gatewayClient.test.ts new file mode 100644 index 000000000000..5ed55cac5848 --- /dev/null +++ b/web/src/lib/gatewayClient.test.ts @@ -0,0 +1,96 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { GatewayClient } from "./gatewayClient"; + +const reloadMocks = vi.hoisted(() => ({ + maybeReloadForLoopbackWsAuthFailure: vi.fn(() => false), +})); + +vi.mock("./dashboard-auth-reload", () => ({ + maybeReloadForLoopbackWsAuthFailure: + reloadMocks.maybeReloadForLoopbackWsAuthFailure, +})); + +class FakeWebSocket { + static instances: FakeWebSocket[] = []; + static OPEN = 1; + + listeners = new Map void>>(); + readyState = 0; + url: string; + + constructor(url: string) { + this.url = url; + FakeWebSocket.instances.push(this); + } + + addEventListener(type: string, cb: (event: EventLike) => void) { + const list = this.listeners.get(type) ?? []; + list.push(cb); + this.listeners.set(type, list); + } + + close() {} + + emit(type: string, event: EventLike) { + for (const cb of this.listeners.get(type) ?? []) { + cb(event); + } + } + + removeEventListener(type: string, cb: (event: EventLike) => void) { + const list = this.listeners.get(type) ?? []; + this.listeners.set( + type, + list.filter((item) => item !== cb), + ); + } + + send() {} +} + +type EventLike = { + code?: number; +}; + +beforeEach(() => { + FakeWebSocket.instances = []; + reloadMocks.maybeReloadForLoopbackWsAuthFailure.mockClear(); + vi.stubGlobal("WebSocket", FakeWebSocket); + 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, + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("GatewayClient", () => { + it("treats loopback 4401 closes as stale-token reload candidates", async () => { + reloadMocks.maybeReloadForLoopbackWsAuthFailure.mockReturnValue(true); + const gw = new GatewayClient(); + const connectPromise = gw.connect(); + + await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); + const socket = FakeWebSocket.instances[0]; + socket.readyState = 1; + socket.emit("open", {}); + await connectPromise; + + socket.emit("close", { code: 4401 }); + + expect( + reloadMocks.maybeReloadForLoopbackWsAuthFailure, + ).toHaveBeenCalledWith(4401); + expect(gw.connectionState).toBe("open"); + }); +}); diff --git a/web/src/lib/gatewayClient.ts b/web/src/lib/gatewayClient.ts index d5ef547ab774..29fd891a27bc 100644 --- a/web/src/lib/gatewayClient.ts +++ b/web/src/lib/gatewayClient.ts @@ -22,6 +22,7 @@ import { } from "@hermes/shared"; import { HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api"; +import { maybeReloadForLoopbackWsAuthFailure } from "@/lib/dashboard-auth-reload"; export type { ConnectionState, GatewayEvent, GatewayEventName }; @@ -31,6 +32,7 @@ export class GatewayClient extends JsonRpcGatewayClient { closedErrorMessage: "WebSocket closed", connectErrorMessage: "WebSocket connection failed", notConnectedErrorMessage: "gateway not connected", + onSocketClose: (event) => maybeReloadForLoopbackWsAuthFailure(event.code), requestIdPrefix: "w", }); } diff --git a/web/src/pages/ChatPage.test.tsx b/web/src/pages/ChatPage.test.tsx new file mode 100644 index 000000000000..912debe62b4b --- /dev/null +++ b/web/src/pages/ChatPage.test.tsx @@ -0,0 +1,228 @@ +// @vitest-environment jsdom +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +class FakeFitAddon { + fit() {} +} + +class FakeWebglAddon { + onContextLoss() { + return { dispose() {} }; + } +} + +class FakeTerminal { + options: Record; + rows = 24; + cols = 80; + parser = { + registerOscHandler: vi.fn(), + }; + unicode = { activeVersion: "" }; + + constructor(options: Record) { + this.options = options; + } + + attachCustomKeyEventHandler() { + return true; + } + + attachCustomWheelEventHandler() { + return true; + } + + clearSelection() {} + + dispose() {} + + focus() {} + + getSelection() { + return ""; + } + + loadAddon() {} + + onData() { + return { dispose() {} }; + } + + onResize() { + return { dispose() {} }; + } + + open() {} + + paste() {} + + refresh() {} + + write() {} +} + +const maybeReloadForLoopbackWsAuthFailure = vi.fn(() => false); + +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, +})); + +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() {} +} + +type CloseEventLike = { + code: number; + reason: string; + wasClean: boolean; +}; + +let container: HTMLDivElement; +let root: Root; + +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 = []; + maybeReloadForLoopbackWsAuthFailure.mockClear(); + 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(); +}); + +afterEach(async () => { + await act(async () => root?.unmount()); + container?.remove(); + vi.unstubAllGlobals(); +}); + +describe("ChatPage", () => { + it("treats loopback 4401 closes as stale-token reload candidates", async () => { + const { default: ChatPage } = await import("./ChatPage"); + + await render( + + + , + ); + + await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); + + FakeWebSocket.instances[0].onclose?.({ + code: 4401, + reason: "auth: token_mismatch", + wasClean: true, + }); + + expect(maybeReloadForLoopbackWsAuthFailure).toHaveBeenCalledWith(4401); + }); +}); diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index f7432d267cc8..fabd8d6339c5 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -55,6 +55,7 @@ import { transferMayContainImage, uploadChatImage, } from "@/lib/chatImagePaste"; +import { maybeReloadForLoopbackWsAuthFailure } from "@/lib/dashboard-auth-reload"; import { PluginSlot } from "@/plugins"; import { useTheme } from "@/themes"; import { useProfileScope } from "@/contexts/useProfileScope"; @@ -1013,6 +1014,9 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { console.warn(`[chat] PTY WebSocket closed code=${ev.code}${why}`); setLastCloseCode(ev.code); if (ev.code === 4401) { + if (maybeReloadForLoopbackWsAuthFailure(ev.code)) { + return; + } setPtyState("closed"); setBanner( ev.reason