Ports events.ts's CatalogStore and connections.ts's ConnectionPool to Zustand stores (src/stores/catalogStore.ts, connectionStore.ts) built via factory functions so tests can inject fake sockets/schedulers, same as the originals. Reconnect/backoff schedule, the 6-max-parked LRU, and the #140 view-reconciliation race handling all carry over unchanged. The vanilla events.ts/connections.ts stay in place for the current app; this is prep for the React port (#241). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
207 lines
5.9 KiB
TypeScript
207 lines
5.9 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import {
|
|
createCatalogStore,
|
|
INITIAL_BACKOFF_MS,
|
|
MAX_BACKOFF_MS,
|
|
type CatalogStore,
|
|
type Scheduler,
|
|
} from "./catalogStore.js";
|
|
import type { CatalogEvent, Session } from "../types.js";
|
|
|
|
function session(id: string): Session {
|
|
return {
|
|
id,
|
|
cwd: "/home/n",
|
|
command: "bash",
|
|
env: {},
|
|
metadata: {},
|
|
status: "Running",
|
|
created_at: "2026-08-22T00:00:00Z",
|
|
} as Session;
|
|
}
|
|
|
|
/** Just enough of a WebSocket for the store to drive, plus a way to push frames. */
|
|
class FakeSocket {
|
|
onmessage: ((ev: { data: string }) => void) | null = null;
|
|
onopen: (() => void) | null = null;
|
|
onclose: (() => void) | null = null;
|
|
onerror: (() => void) | null = null;
|
|
closed = false;
|
|
|
|
close(): void {
|
|
this.closed = true;
|
|
}
|
|
|
|
open(): void {
|
|
this.onopen?.();
|
|
}
|
|
|
|
emit(event: CatalogEvent): void {
|
|
this.onmessage?.({ data: JSON.stringify(event) });
|
|
}
|
|
|
|
drop(): void {
|
|
this.onclose?.();
|
|
}
|
|
}
|
|
|
|
/** Scheduler that records delays instead of waiting them out. */
|
|
function fakeScheduler(): Scheduler & { delays: number[]; runNext(): void } {
|
|
const queued: Array<() => void> = [];
|
|
return {
|
|
delays: [] as number[],
|
|
setTimer(fn, ms) {
|
|
this.delays.push(ms);
|
|
queued.push(fn);
|
|
return queued.length;
|
|
},
|
|
clearTimer() {},
|
|
runNext() {
|
|
queued.shift()?.();
|
|
},
|
|
};
|
|
}
|
|
|
|
function storeWith(sockets: FakeSocket[], refetch?: () => Promise<Session[]>) {
|
|
let i = 0;
|
|
const scheduler = fakeScheduler();
|
|
const store = createCatalogStore({
|
|
socket: () => sockets[i++] as unknown as WebSocket,
|
|
scheduler,
|
|
refetch,
|
|
});
|
|
return { store, scheduler };
|
|
}
|
|
|
|
describe("catalogStore reducer", () => {
|
|
it("replaces state on Snapshot and applies deltas", () => {
|
|
const ws = new FakeSocket();
|
|
const { store } = storeWith([ws]);
|
|
store.getState().start();
|
|
|
|
ws.emit({ type: "Snapshot", sessions: [session("a")] } as CatalogEvent);
|
|
expect([...store.getState().sessions.keys()]).toEqual(["a"]);
|
|
|
|
ws.emit({ type: "SessionAdded", session: session("b") } as CatalogEvent);
|
|
expect([...store.getState().sessions.keys()].sort()).toEqual(["a", "b"]);
|
|
|
|
ws.emit({ type: "SessionRemoved", id: "a" } as CatalogEvent);
|
|
expect([...store.getState().sessions.keys()]).toEqual(["b"]);
|
|
});
|
|
|
|
it("ignores a frame that is not JSON", () => {
|
|
const ws = new FakeSocket();
|
|
const { store } = storeWith([ws]);
|
|
store.getState().start();
|
|
ws.emit({ type: "Snapshot", sessions: [session("a")] } as CatalogEvent);
|
|
|
|
ws.onmessage?.({ data: "not json" });
|
|
|
|
expect([...store.getState().sessions.keys()]).toEqual(["a"]);
|
|
});
|
|
|
|
it("refetches over REST when the server reports dropped frames", async () => {
|
|
const ws = new FakeSocket();
|
|
const refetch = vi.fn(async () => [session("fresh")]);
|
|
const { store } = storeWith([ws], refetch);
|
|
store.getState().start();
|
|
ws.emit({ type: "Snapshot", sessions: [session("stale")] } as CatalogEvent);
|
|
|
|
ws.emit({ type: "Lagged", skipped: 4 } as CatalogEvent);
|
|
await vi.waitFor(() => expect(refetch).toHaveBeenCalledOnce());
|
|
|
|
expect([...store.getState().sessions.keys()]).toEqual(["fresh"]);
|
|
});
|
|
|
|
it("hands each subscriber a fresh sessions map it can diff against the previous one", () => {
|
|
const ws = new FakeSocket();
|
|
const { store } = storeWith([ws]);
|
|
const seen: CatalogStore[] = [];
|
|
store.subscribe((s) => seen.push(s));
|
|
store.getState().start();
|
|
|
|
ws.emit({ type: "Snapshot", sessions: [session("a")] } as CatalogEvent);
|
|
ws.emit({ type: "SessionAdded", session: session("b") } as CatalogEvent);
|
|
|
|
const [afterSnapshot, afterAdd] = seen;
|
|
expect(afterSnapshot.sessions).not.toBe(afterAdd.sessions);
|
|
expect(afterSnapshot.sessions.size).toBe(1);
|
|
expect(afterAdd.sessions.size).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("catalogStore reconnect", () => {
|
|
it("replaces a suspended socket immediately", () => {
|
|
const first = new FakeSocket();
|
|
const second = new FakeSocket();
|
|
const { store } = storeWith([first, second]);
|
|
store.getState().start();
|
|
|
|
store.getState().reconnect();
|
|
|
|
expect(first.closed).toBe(true);
|
|
second.open();
|
|
// A late close from the old socket is deliberately ignored.
|
|
first.drop();
|
|
expect(store.getState().backoff()).toBe(INITIAL_BACKOFF_MS);
|
|
});
|
|
|
|
it("doubles the delay per attempt and caps it", () => {
|
|
const sockets = Array.from({ length: 8 }, () => new FakeSocket());
|
|
const { store, scheduler } = storeWith(sockets);
|
|
store.getState().start();
|
|
|
|
for (let i = 0; i < 7; i++) {
|
|
sockets[i].drop();
|
|
scheduler.runNext();
|
|
}
|
|
|
|
expect(scheduler.delays[0]).toBe(INITIAL_BACKOFF_MS);
|
|
expect(scheduler.delays[1]).toBe(INITIAL_BACKOFF_MS * 2);
|
|
expect(scheduler.delays.at(-1)).toBe(MAX_BACKOFF_MS);
|
|
});
|
|
|
|
it("resets the delay once a connection opens", () => {
|
|
const sockets = [new FakeSocket(), new FakeSocket(), new FakeSocket()];
|
|
const { store, scheduler } = storeWith(sockets);
|
|
store.getState().start();
|
|
|
|
sockets[0].drop();
|
|
scheduler.runNext();
|
|
expect(store.getState().backoff()).toBe(INITIAL_BACKOFF_MS * 2);
|
|
|
|
sockets[1].open();
|
|
expect(store.getState().backoff()).toBe(INITIAL_BACKOFF_MS);
|
|
});
|
|
|
|
it("stop() tears the socket down without scheduling a reconnect", () => {
|
|
const ws = new FakeSocket();
|
|
const { store, scheduler } = storeWith([ws]);
|
|
store.getState().start();
|
|
|
|
store.getState().stop();
|
|
ws.drop();
|
|
|
|
expect(ws.closed).toBe(true);
|
|
expect(scheduler.delays).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("catalogStore connected state", () => {
|
|
it("reports the socket opening and dropping", () => {
|
|
const sock = new FakeSocket();
|
|
const store = createCatalogStore({
|
|
socket: () => sock as unknown as WebSocket,
|
|
scheduler: fakeScheduler(),
|
|
});
|
|
const seen: boolean[] = [];
|
|
store.subscribe((s) => seen.push(s.connected));
|
|
store.getState().start();
|
|
sock.open();
|
|
sock.drop();
|
|
|
|
expect(seen).toEqual([true, false]);
|
|
});
|
|
});
|