cab8673ea6
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.
97 lines
2.4 KiB
TypeScript
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");
|
|
});
|
|
});
|