# Conflicts: # crates/maestro-daemon/tests/worktree.rs # crates/maestro-e2e/tests/e2e.rs # web/src/attach.ts # web/src/connections.test.ts # web/src/connections.ts
384 lines
13 KiB
TypeScript
384 lines
13 KiB
TypeScript
// @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
|
|
});
|
|
});
|