Files
hermes-agent/web/src/lib/gatewayClient.test.ts
T
izumi0uu cab8673ea6 fix(dashboard): reload loopback tabs after stale session-token closes
Loopback dashboard tabs now share one one-shot stale-token recovery path across REST 401s, the PTY socket, the structured event socket, and the shared JSON-RPC gateway wrapper. The shared client exposes only an optional close-event interception hook; the dashboard remains responsible for deciding that loopback 4401 means reload.

Constraint: Current main delegates the web gateway to apps/shared JsonRpcGatewayClient, and #54022 review requires a shared-client-compatible close-code hook plus direct ChatSidebar event-socket coverage.
Rejected: Restore the dashboard's old direct WebSocket implementation | stale against the shared JSON-RPC client and would duplicate transport behavior.
Confidence: high
Scope-risk: moderate
Directive: Keep stale-token policy dashboard-specific; the shared JSON-RPC client should expose close events without learning dashboard auth semantics.
Tested: npm --workspace web test (21 files, 106 tests); focused stale-token tests (5 files, 14 tests); npm --workspace web run typecheck; npm --workspace @hermes/shared run lint; npm --workspace @hermes/shared run typecheck; focused web eslint; git diff --check.
Not-tested: Manual browser smoke test across a real dashboard restart.
2026-08-04 15:26:37 +05:30

97 lines
2.4 KiB
TypeScript

// @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<string, Array<(event: EventLike) => 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");
});
});