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>
415 lines
14 KiB
TypeScript
415 lines
14 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { createConnectionStore } from "./connectionStore.js";
|
|
import { createCatalogStore } from "./catalogStore.js";
|
|
import type { Session } from "../types.js";
|
|
|
|
// The pane's own bytes-out path, stubbed: the escape hatch only needs to know
|
|
// the pool reaches the focused connection with them.
|
|
const sent: { id: string; data: string }[] = [];
|
|
const disposed: string[] = [];
|
|
const mounts: { id: string; focus: boolean | undefined }[] = [];
|
|
function fakeConn(kind: string, id: string) {
|
|
const el = document.createElement("div");
|
|
el.tabIndex = -1; // focusable via .focus(), like a real input/terminal
|
|
el.dataset.kind = kind;
|
|
el.dataset.session = id;
|
|
return {
|
|
sessionId: id,
|
|
kind,
|
|
el,
|
|
onMount(opts?: { focus?: boolean }) {
|
|
mounts.push({ id, focus: opts?.focus });
|
|
},
|
|
onUnmount() {},
|
|
focusTerminal() {},
|
|
sendInput(data: string) {
|
|
sent.push({ id, data });
|
|
},
|
|
updateInfo() {},
|
|
dispose() {
|
|
disposed.push(`${kind}:${id}`);
|
|
},
|
|
};
|
|
}
|
|
vi.mock("../attach.js", () => ({
|
|
createAttachConnection: (id: string) => fakeConn("attach", id),
|
|
}));
|
|
vi.mock("../structured.js", () => ({
|
|
createStructuredConnection: (id: string) => fakeConn("structured", id),
|
|
}));
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
sent.length = 0;
|
|
disposed.length = 0;
|
|
mounts.length = 0;
|
|
});
|
|
|
|
/** Minimal fixture for the sessions map a catalog store broadcasts. */
|
|
function session(id: string, view?: "Terminal" | "Structured"): Session {
|
|
return {
|
|
id,
|
|
cwd: "/repo",
|
|
command: "claude",
|
|
env: {},
|
|
metadata: {},
|
|
status: "Running",
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
view,
|
|
};
|
|
}
|
|
|
|
/** A catalog store an id can be pushed into on demand, mirroring
|
|
* catalogStore.test.ts's own injectable-socket pattern. */
|
|
class FakeCatalogSocket {
|
|
onmessage: ((ev: { data: string }) => void) | null = null;
|
|
onopen: (() => void) | null = null;
|
|
onclose: (() => void) | null = null;
|
|
onerror: (() => void) | null = null;
|
|
close(): void {}
|
|
push(sessions: Session[]): void {
|
|
this.onmessage?.({ data: JSON.stringify({ type: "Snapshot", sessions }) });
|
|
}
|
|
}
|
|
function fakeCatalog() {
|
|
const socket = new FakeCatalogSocket();
|
|
const store = createCatalogStore({ socket: () => socket as unknown as WebSocket });
|
|
store.getState().start();
|
|
return { store, socket };
|
|
}
|
|
|
|
const A = "aaaaaaaa-0000-0000-0000-0000000000ff";
|
|
const B = "bbbbbbbb-0000-0000-0000-0000000000ff";
|
|
|
|
function mockFetch() {
|
|
const fetchMock = vi.fn(() => Promise.resolve(new Response(null, { status: 204 })));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
return fetchMock;
|
|
}
|
|
|
|
describe("connectionStore setFocus", () => {
|
|
it("reports the new session focused, none unfocused, the first time", () => {
|
|
const fetchMock = mockFetch();
|
|
const pool = createConnectionStore();
|
|
|
|
pool.getState().setFocus(A);
|
|
|
|
expect(fetchMock).toHaveBeenCalledOnce();
|
|
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
|
expect(url).toBe(`/api/sessions/${A}/focus`);
|
|
expect(init.method).toBe("POST");
|
|
expect(JSON.parse(init.body as string)).toEqual({ focused: true });
|
|
expect(pool.getState().focusedId).toBe(A);
|
|
});
|
|
|
|
it("unfocuses the previous session and focuses the new one on a switch", () => {
|
|
const fetchMock = mockFetch();
|
|
const pool = createConnectionStore();
|
|
pool.getState().setFocus(A);
|
|
fetchMock.mockClear();
|
|
|
|
pool.getState().setFocus(B);
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
const bodies = fetchMock.mock.calls.map(([url, init]: unknown[]) => [
|
|
url,
|
|
JSON.parse((init as RequestInit).body as string),
|
|
]);
|
|
expect(bodies).toContainEqual([`/api/sessions/${A}/focus`, { focused: false }]);
|
|
expect(bodies).toContainEqual([`/api/sessions/${B}/focus`, { focused: true }]);
|
|
});
|
|
|
|
it("reports unfocused when focus clears to null", () => {
|
|
const fetchMock = mockFetch();
|
|
const pool = createConnectionStore();
|
|
pool.getState().setFocus(A);
|
|
fetchMock.mockClear();
|
|
|
|
pool.getState().setFocus(null);
|
|
|
|
expect(fetchMock).toHaveBeenCalledOnce();
|
|
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
|
expect(url).toBe(`/api/sessions/${A}/focus`);
|
|
expect(JSON.parse(init.body as string)).toEqual({ focused: false });
|
|
});
|
|
|
|
it("is a no-op when the focused session doesn't change", () => {
|
|
const fetchMock = mockFetch();
|
|
const pool = createConnectionStore();
|
|
pool.getState().setFocus(A);
|
|
fetchMock.mockClear();
|
|
|
|
pool.getState().setFocus(A);
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not let a fetch rejection break the pool", async () => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(() => Promise.reject(new Error("offline"))),
|
|
);
|
|
const pool = createConnectionStore();
|
|
|
|
expect(() => pool.getState().setFocus(A)).not.toThrow();
|
|
// Let the rejected promise's `.catch` settle before the test ends.
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
describe("connectionStore sendToFocused", () => {
|
|
it("types the bytes into the focused session's PTY", () => {
|
|
mockFetch();
|
|
sent.length = 0;
|
|
const pool = createConnectionStore();
|
|
const pane = document.createElement("div");
|
|
pool.getState().mount(A, pane);
|
|
pool.getState().setFocus(A);
|
|
|
|
expect(pool.getState().sendToFocused("\x0b")).toBe(true);
|
|
expect(sent).toEqual([{ id: A, data: "\x0b" }]);
|
|
});
|
|
|
|
it("is a no-op with nothing focused", () => {
|
|
mockFetch();
|
|
sent.length = 0;
|
|
const pool = createConnectionStore();
|
|
|
|
expect(pool.getState().sendToFocused("\x0b")).toBe(false);
|
|
expect(sent).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("connectionStore mount/unmount", () => {
|
|
it("tracks mounted and LRU order reactively", () => {
|
|
mockFetch();
|
|
const pool = createConnectionStore();
|
|
const paneA = document.createElement("div");
|
|
const paneB = document.createElement("div");
|
|
|
|
pool.getState().mount(A, paneA);
|
|
pool.getState().mount(B, paneB);
|
|
expect(pool.getState().cacheOrder).toEqual([A, B]);
|
|
expect([...pool.getState().mounted]).toEqual([A, B]);
|
|
|
|
pool.getState().unmount(A);
|
|
expect([...pool.getState().mounted]).toEqual([B]);
|
|
// Parked, not evicted — still in LRU order.
|
|
expect(pool.getState().cacheOrder).toEqual([A, B]);
|
|
|
|
pool.getState().close(A);
|
|
expect(pool.getState().cacheOrder).toEqual([B]);
|
|
expect(disposed).toEqual(["attach:" + A]);
|
|
});
|
|
|
|
it("evicts the least-recently-viewed parked connection beyond MAX_PARKED", () => {
|
|
mockFetch();
|
|
const pool = createConnectionStore();
|
|
const ids = Array.from({ length: 8 }, (_, i) => `id-${i}`);
|
|
for (const id of ids) {
|
|
pool.getState().mount(id, document.createElement("div"));
|
|
pool.getState().unmount(id); // park immediately
|
|
}
|
|
|
|
// Each mount only has itself visible (the rest already parked), so the
|
|
// cap-crossing eviction fires once, on the mount that pushes the parked
|
|
// count from 6 to 7: the single oldest (id-0) is dropped.
|
|
expect(disposed).toEqual(["attach:id-0"]);
|
|
expect(pool.getState().cacheOrder).toEqual(ids.slice(1));
|
|
});
|
|
});
|
|
|
|
// A deep link or restored layout can `mount()` a session before the catalog
|
|
// snapshot names its view — the daemon defaults an absent `view` to
|
|
// `"Terminal"`, but so does an *unknown* session, and only one of those is
|
|
// actually resolved (#140 review).
|
|
describe("connectionStore: view resolution races", () => {
|
|
it("replaces a wrongly-typed connection once the catalog resolves it, without waiting for another mount()", () => {
|
|
mockFetch();
|
|
const { store, socket } = fakeCatalog();
|
|
const pool = createConnectionStore({ store });
|
|
const pane = document.createElement("div");
|
|
|
|
pool.getState().mount(A, pane); // no info: deep-link-before-snapshot race
|
|
expect(disposed).toEqual([]);
|
|
|
|
socket.push([session(A, "Structured")]);
|
|
|
|
expect(disposed).toEqual(["attach:" + A]);
|
|
// The fresh connection landed in the same pane nothing re-mounted it into.
|
|
expect(pane.children).toHaveLength(1);
|
|
});
|
|
|
|
it("also repairs a wrongly-typed connection that is parked, not mounted, without evicting its other parked siblings", () => {
|
|
mockFetch();
|
|
const { store, socket } = fakeCatalog();
|
|
const pool = createConnectionStore({ store });
|
|
const hidden = document.body.lastElementChild as HTMLElement; // this pool's own cache
|
|
pool.getState().mount(A, document.createElement("div"));
|
|
pool.getState().mount(B, document.createElement("div"));
|
|
pool.getState().unmount(A);
|
|
pool.getState().unmount(B);
|
|
disposed.length = 0;
|
|
|
|
socket.push([session(A, "Structured")]);
|
|
|
|
expect(disposed).toEqual(["attach:" + A]);
|
|
// B's own element is still parked — A's repair didn't wipe its siblings
|
|
// out of the shared hidden cache.
|
|
expect(hidden.querySelector(`[data-session="${B}"]`)).not.toBeNull();
|
|
expect((hidden.querySelector(`[data-session="${A}"]`) as HTMLElement | null)?.dataset.kind).toBe("structured");
|
|
});
|
|
|
|
it("does not replace a connection whose resolved view already matches", () => {
|
|
mockFetch();
|
|
const { store, socket } = fakeCatalog();
|
|
const pool = createConnectionStore({ store });
|
|
const pane = document.createElement("div");
|
|
pool.getState().mount(A, pane, { name: "a", command: "claude", view: "Structured" });
|
|
disposed.length = 0;
|
|
|
|
socket.push([session(A, "Structured")]);
|
|
|
|
expect(disposed).toEqual([]);
|
|
});
|
|
|
|
it("does not downgrade an already-resolved connection when a later mount() call has no info", () => {
|
|
mockFetch();
|
|
const pool = createConnectionStore();
|
|
const pane = document.createElement("div");
|
|
pool.getState().mount(A, pane, { name: "a", command: "claude", view: "Structured" });
|
|
disposed.length = 0;
|
|
|
|
pool.getState().mount(A, pane); // e.g. a re-render before the catalog answers again
|
|
|
|
expect(disposed).toEqual([]);
|
|
});
|
|
|
|
it("does not treat a session missing from the catalog as Terminal and churn a live connection", () => {
|
|
mockFetch();
|
|
const { store, socket } = fakeCatalog();
|
|
const pool = createConnectionStore({ store });
|
|
const pane = document.createElement("div");
|
|
pool.getState().mount(A, pane, { name: "a", command: "claude", view: "Structured" });
|
|
disposed.length = 0;
|
|
|
|
// `A` is absent from this snapshot — the embedder's own subscription
|
|
// handles closing the pane; the pool must not race it by "fixing" the
|
|
// view first.
|
|
socket.push([session(B, "Terminal")]);
|
|
|
|
expect(disposed).toEqual([]);
|
|
});
|
|
|
|
it("re-focuses the fresh connection when the keyboard was actually inside the replaced pane", () => {
|
|
mockFetch();
|
|
const { store, socket } = fakeCatalog();
|
|
const pool = createConnectionStore({ store });
|
|
const pane = document.createElement("div");
|
|
document.body.appendChild(pane);
|
|
pool.getState().mount(A, pane);
|
|
pool.getState().setFocus(A);
|
|
(pane.firstElementChild as HTMLElement).focus();
|
|
expect(document.activeElement).toBe(pane.firstElementChild);
|
|
mounts.length = 0;
|
|
|
|
socket.push([session(A, "Structured")]);
|
|
|
|
expect(disposed).toEqual(["attach:" + A]);
|
|
expect(mounts).toEqual([{ id: A, focus: true }]);
|
|
pane.remove();
|
|
});
|
|
|
|
it("does not steal focus from a same-tab sidebar preview even though focusedId still names the previous pane", () => {
|
|
mockFetch();
|
|
const { store, socket } = fakeCatalog();
|
|
const pool = createConnectionStore({ store });
|
|
const pane = document.createElement("div");
|
|
document.body.appendChild(pane);
|
|
pool.getState().mount(A, pane);
|
|
pool.getState().setFocus(A);
|
|
(pane.firstElementChild as HTMLElement).focus();
|
|
|
|
const sidebarInput = document.createElement("input");
|
|
document.body.appendChild(sidebarInput);
|
|
sidebarInput.focus();
|
|
expect(document.activeElement).toBe(sidebarInput);
|
|
mounts.length = 0;
|
|
|
|
socket.push([session(A, "Structured")]);
|
|
|
|
expect(disposed).toEqual(["attach:" + A]);
|
|
expect(mounts).toEqual([{ id: A, focus: false }]);
|
|
pane.remove();
|
|
sidebarInput.remove();
|
|
});
|
|
|
|
it("does not focus a parked (unmounted) connection it repairs", () => {
|
|
mockFetch();
|
|
const { store, socket } = fakeCatalog();
|
|
const pool = createConnectionStore({ store });
|
|
const pane = document.createElement("div");
|
|
pool.getState().mount(A, pane);
|
|
pool.getState().setFocus(A);
|
|
pool.getState().unmount(A); // parked — no longer visible, `focusedId` still names it
|
|
mounts.length = 0;
|
|
|
|
socket.push([session(A, "Structured")]);
|
|
|
|
expect(disposed).toEqual(["attach:" + A]);
|
|
expect(mounts).toEqual([]); // parked repair calls onUnmount, never onMount
|
|
});
|
|
});
|
|
|
|
describe("connectionStore setVisible/activate/parkActive", () => {
|
|
it("parks sessions not in the next visible set and mounts the rest", () => {
|
|
mockFetch();
|
|
const pool = createConnectionStore();
|
|
const paneA = document.createElement("div");
|
|
const paneB = document.createElement("div");
|
|
pool.getState().setVisible([
|
|
{ id: A, pane: paneA },
|
|
{ id: B, pane: paneB },
|
|
]);
|
|
expect([...pool.getState().mounted].sort()).toEqual([A, B]);
|
|
|
|
pool.getState().setVisible([{ id: B, pane: paneB }]);
|
|
|
|
expect([...pool.getState().mounted]).toEqual([B]);
|
|
});
|
|
|
|
it("activate mounts+focuses alone, parkActive parks all and clears focus", () => {
|
|
mockFetch();
|
|
const pool = createConnectionStore();
|
|
const pane = document.createElement("div");
|
|
|
|
pool.getState().activate(A, pane);
|
|
expect(pool.getState().focusedId).toBe(A);
|
|
expect(pool.getState().isMounted(A)).toBe(true);
|
|
|
|
pool.getState().parkActive();
|
|
expect(pool.getState().focusedId).toBeNull();
|
|
expect(pool.getState().isMounted(A)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("connectionStore dispose", () => {
|
|
it("disposes every cached connection and resets state", () => {
|
|
mockFetch();
|
|
const pool = createConnectionStore();
|
|
pool.getState().mount(A, document.createElement("div"));
|
|
pool.getState().mount(B, document.createElement("div"));
|
|
disposed.length = 0;
|
|
|
|
pool.getState().dispose();
|
|
|
|
expect(disposed.sort()).toEqual(["attach:" + A, "attach:" + B]);
|
|
expect(pool.getState().mounted.size).toBe(0);
|
|
expect(pool.getState().cacheOrder).toEqual([]);
|
|
expect(pool.getState().focusedId).toBeNull();
|
|
});
|
|
});
|