This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/stores/connectionStore.test.ts
T
Miguel PalhasandClaude Sonnet 5 20d303278d feat(web): port CatalogStore and ConnectionPool to Zustand stores
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>
2026-09-09 00:12:09 +01:00

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();
});
});