// @vitest-environment happy-dom import { afterEach, describe, expect, it, vi } from "vitest"; import { ConnectionPool } from "./connections.js"; import { CatalogStore } from "./events.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 `CatalogState` a `CatalogStore` 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 `CatalogStore` an id can be pushed into on demand, mirroring * events.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 fakeStore(): { store: CatalogStore; socket: FakeCatalogSocket } { const socket = new FakeCatalogSocket(); const store = new CatalogStore({ socket: () => socket as unknown as WebSocket }); store.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("ConnectionPool.setFocus", () => { it("reports the new session focused, none unfocused, the first time", () => { const fetchMock = mockFetch(); const pool = new ConnectionPool(); pool.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 }); }); it("unfocuses the previous session and focuses the new one on a switch", () => { const fetchMock = mockFetch(); const pool = new ConnectionPool(); pool.setFocus(A); fetchMock.mockClear(); pool.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 = new ConnectionPool(); pool.setFocus(A); fetchMock.mockClear(); pool.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 = new ConnectionPool(); pool.setFocus(A); fetchMock.mockClear(); pool.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 = new ConnectionPool(); expect(() => pool.setFocus(A)).not.toThrow(); // Let the rejected promise's `.catch` settle before the test ends. await Promise.resolve(); await Promise.resolve(); }); }); describe("ConnectionPool.sendToFocused", () => { it("types the bytes into the focused session's PTY", () => { mockFetch(); sent.length = 0; const pool = new ConnectionPool(); const pane = document.createElement("div"); pool.mount(A, pane); pool.setFocus(A); expect(pool.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 = new ConnectionPool(); expect(pool.sendToFocused("\x0b")).toBe(false); expect(sent).toEqual([]); }); }); // 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("ConnectionPool: view resolution races", () => { it("replaces a wrongly-typed connection once the catalog resolves it, without waiting for another mount()", () => { mockFetch(); const { store, socket } = fakeStore(); const pool = new ConnectionPool({ store }); const pane = document.createElement("div"); pool.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 } = fakeStore(); const pool = new ConnectionPool({ store }); const hidden = document.body.lastElementChild as HTMLElement; // this pool's own cache pool.mount(A, document.createElement("div")); pool.mount(B, document.createElement("div")); pool.unmount(A); pool.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 } = fakeStore(); const pool = new ConnectionPool({ store }); const pane = document.createElement("div"); pool.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 = new ConnectionPool(); const pane = document.createElement("div"); pool.mount(A, pane, { name: "a", command: "claude", view: "Structured" }); disposed.length = 0; pool.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 } = fakeStore(); const pool = new ConnectionPool({ store }); const pane = document.createElement("div"); pool.mount(A, pane, { name: "a", command: "claude", view: "Structured" }); disposed.length = 0; // `A` is absent from this snapshot — main.ts'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 } = fakeStore(); const pool = new ConnectionPool({ store }); const pane = document.createElement("div"); document.body.appendChild(pane); pool.mount(A, pane); pool.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", () => { // docs/PREVIEW.md: previewing a session already open in the current tab // moves the keyboard into the sidebar without ever calling setFocus, so // `focusedId` keeps naming the pane the operator was in before — but the // DOM disagrees, and the DOM is what must decide. mockFetch(); const { store, socket } = fakeStore(); const pool = new ConnectionPool({ store }); const pane = document.createElement("div"); document.body.appendChild(pane); pool.mount(A, pane); pool.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("counts focus on a descendant of the pane's element, not just the element itself", () => { mockFetch(); const { store, socket } = fakeStore(); const pool = new ConnectionPool({ store }); const pane = document.createElement("div"); document.body.appendChild(pane); pool.mount(A, pane); pool.setFocus(A); const textarea = document.createElement("textarea"); (pane.firstElementChild as HTMLElement).appendChild(textarea); textarea.focus(); expect(document.activeElement).toBe(textarea); mounts.length = 0; socket.push([session(A, "Structured")]); expect(mounts).toEqual([{ id: A, focus: true }]); pane.remove(); }); it("treats document.body (nothing meaningfully focused) as no active descendant", () => { mockFetch(); const { store, socket } = fakeStore(); const pool = new ConnectionPool({ store }); const pane = document.createElement("div"); document.body.appendChild(pane); pool.mount(A, pane); pool.setFocus(A); (document.activeElement as HTMLElement | null)?.blur(); expect(document.activeElement).toBe(document.body); mounts.length = 0; socket.push([session(A, "Structured")]); expect(mounts).toEqual([{ id: A, focus: false }]); pane.remove(); }); it("does not steal focus for a merely-visible pane that was never actually focused", () => { mockFetch(); const { store, socket } = fakeStore(); const pool = new ConnectionPool({ store }); const paneA = document.createElement("div"); const paneB = document.createElement("div"); pool.mount(A, paneA); pool.mount(B, paneB); pool.setFocus(B); // B is the real keyboard focus; A is only visible mounts.length = 0; socket.push([session(A, "Structured")]); expect(disposed).toEqual(["attach:" + A]); expect(mounts).toEqual([{ id: A, focus: false }]); }); it("does not focus a parked (unmounted) connection it repairs", () => { mockFetch(); const { store, socket } = fakeStore(); const pool = new ConnectionPool({ store }); const pane = document.createElement("div"); pool.mount(A, pane); pool.setFocus(A); pool.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 }); });