// @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { emptyLayout, leaves, type Layout } from "./layout.js"; import { splitLeaf } from "./layout.js"; import { createMultiView, type MultiView } from "./multiview.js"; import type { ConnectionPool } from "./connections.js"; import type { SidebarHandle } from "./catalog.js"; function stubFetch(): void { vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL) => { const url = String(input); const body = url.includes("/api/info") ? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] } : url.includes("/api/projects") ? [] : url.includes("/api/names") ? { names: ["allegro-cello"] } : undefined; return new Response(JSON.stringify(body), { status: 200 }); }), ); } function fakePool(): ConnectionPool { return { setVisible() {}, setFocus() {}, } as unknown as ConnectionPool; } function fakeSidebar(): SidebarHandle { return { el: document.createElement("aside"), setActive() {}, setHighlights() {}, dispose() {}, }; } function makeMv(layout: Layout): MultiView { return createMultiView({ pool: fakePool(), layout, sidebar: fakeSidebar(), infoFor: () => ({ name: "sess", command: "bash" }), onDisconnect: () => {}, renderWelcome: () => document.createElement("div"), }); } /** Drain the microtask queue so the draft form's async seed application lands. */ async function flush(): Promise { for (let i = 0; i < 20; i++) await Promise.resolve(); } describe("multiview openDraft (#62)", () => { beforeEach(() => { stubFetch(); document.body.replaceChildren(); }); afterEach(() => { vi.unstubAllGlobals(); }); it("turns an empty tab into a draft pane", async () => { const layout = emptyLayout(); const mv = makeMv(layout); document.body.appendChild(mv.el); mv.openDraft(); await flush(); const ls = leaves(layout.tabs[0].root); expect(ls).toHaveLength(1); expect(ls[0].sessionId).toBeNull(); expect(ls[0].draft).not.toBeNull(); expect(mv.el.querySelector(".mv-draft")).toBeTruthy(); }); it("splits the focused session pane into a fresh draft", async () => { const layout = emptyLayout(); const tab = layout.tabs[0]; const first = splitLeaf(tab, "", "s1", "row", true); tab.focusedLeafId = first; const mv = makeMv(layout); document.body.appendChild(mv.el); mv.render(); mv.openDraft(); await flush(); const ls = leaves(tab.root); expect(ls).toHaveLength(2); expect(ls[0].sessionId).toBe("s1"); const draftLeaf = ls.find((l) => l.sessionId === null)!; expect(draftLeaf.draft).not.toBeNull(); expect(tab.focusedLeafId).toBe(draftLeaf.id); expect(mv.el.querySelectorAll(".mv-pane")).toHaveLength(2); }); it("reuses the focused pane when it is already empty", async () => { const layout = emptyLayout(); const tab = layout.tabs[0]; const first = splitLeaf(tab, "", "s1", "row", true); const empty = splitLeaf(tab, first, null, "row", true); tab.focusedLeafId = empty; const mv = makeMv(layout); document.body.appendChild(mv.el); mv.render(); mv.openDraft(); await flush(); const ls = leaves(tab.root); expect(ls).toHaveLength(2); const target = ls.find((l) => l.id === empty)!; expect(target.draft).not.toBeNull(); expect(tab.focusedLeafId).toBe(empty); }); it("keeps the draft form instance when an unrelated session closes (#68)", async () => { const layout = emptyLayout(); const tab = layout.tabs[0]; const s1 = splitLeaf(tab, "", "s1", "row", true); const draftLeaf = splitLeaf(tab, s1, null, "row", true); tab.focusedLeafId = draftLeaf; const mv = makeMv(layout); document.body.appendChild(mv.el); mv.render(); await flush(); const nameInput = mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement; nameInput.value = "wip"; nameInput.dispatchEvent(new Event("input")); await flush(); expect(leaves(tab.root).find((l) => l.id === draftLeaf)?.draft?.name).toBe("wip"); const formBefore = mv.el.querySelector(".mv-draft"); mv.closeSession("s1"); await flush(); expect(mv.el.querySelector(".mv-draft")).toBe(formBefore); expect((mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement).value).toBe("wip"); }); it("reuses an already-synced empty pane instead of splitting (#72)", async () => { const layout = emptyLayout(); const tab = layout.tabs[0]; const first = splitLeaf(tab, "", "s1", "row", true); const empty = splitLeaf(tab, first, null, "row", true); tab.focusedLeafId = empty; const mv = makeMv(layout); document.body.appendChild(mv.el); mv.render(); // Let the mounted form's loadProjects() settle and auto-sync its picked // defaults into leaf.draft before openDraft runs (#72's dead-branch window). await flush(); expect(leaves(tab.root).find((l) => l.id === empty)?.draft).not.toBeNull(); mv.openDraft(); await flush(); expect(leaves(tab.root)).toHaveLength(2); expect(tab.focusedLeafId).toBe(empty); }); it("keeps a seed after reusing an already-synced empty pane (#72)", async () => { const layout = emptyLayout(); const tab = layout.tabs[0]; const first = splitLeaf(tab, "", "s1", "row", true); const empty = splitLeaf(tab, first, null, "row", true); tab.focusedLeafId = empty; const mv = makeMv(layout); document.body.appendChild(mv.el); mv.render(); await flush(); mv.openDraft({ cwd: "/home/user/proj", command: "claude" }); await flush(); const leaf = leaves(tab.root).find((l) => l.id === empty)!; expect(leaf.draft?.cwd).toBe("/home/user/proj"); expect(leaf.draft?.command).toBe("claude"); const cwdRaw = mv.el.querySelector(".mv-draft .cwd-raw") as HTMLInputElement; expect(cwdRaw.value).toBe("/home/user/proj"); }); it("seeds a clone's cwd and command into the draft", async () => { const layout = emptyLayout(); const mv = makeMv(layout); document.body.appendChild(mv.el); mv.openDraft({ cwd: "/home/user/proj", command: "claude" }); await flush(); const leaf = leaves(layout.tabs[0].root)[0]; expect(leaf.draft?.cwd).toBe("/home/user/proj"); expect(leaf.draft?.command).toBe("claude"); }); }); describe("multiview pane-drag swap (#73)", () => { beforeEach(() => { stubFetch(); document.body.replaceChildren(); }); afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); /** Drag `sourcePane`'s title onto `targetPane`'s center, as the real pane * drag handler does — modelled after the pointer sequence in runDrag()/ * updateDock() in multiview.ts, since neither is exposed on MultiView. */ async function dragPaneOntoCenter(sourcePane: HTMLElement, targetPane: HTMLElement): Promise { // The real drag-start gate requires the pointerdown target to sit inside // an `.attach-header` (attach.ts's session title bar). Draft panes mount // no such element, so stand one in for the gate check itself. const marker = document.createElement("div"); marker.className = "attach-header"; sourcePane.appendChild(marker); vi.spyOn(targetPane, "getBoundingClientRect").mockReturnValue({ left: 100, top: 0, width: 100, height: 100, right: 200, bottom: 100, x: 100, y: 0, toJSON() {}, } as DOMRect); (document as unknown as { elementsFromPoint: (x: number, y: number) => Element[] }).elementsFromPoint = () => [ targetPane, ]; marker.dispatchEvent( new PointerEvent("pointerdown", { clientX: 0, clientY: 0, bubbles: true, cancelable: true }), ); window.dispatchEvent( new PointerEvent("pointermove", { clientX: 150, clientY: 50, bubbles: true, cancelable: true }), ); window.dispatchEvent(new PointerEvent("pointerup", { clientX: 150, clientY: 50, bubbles: true, cancelable: true })); await flush(); } it("moves each draft's form along with its data when two draft panes are swapped", async () => { const layout = emptyLayout(); const tab = layout.tabs[0]; const leaf1 = splitLeaf(tab, "", null, "row", true); const leaf2 = splitLeaf(tab, leaf1, null, "row", true); const mv = makeMv(layout); document.body.appendChild(mv.el); mv.render(); await flush(); const paneOf = (leafId: string) => mv.el.querySelector(`[data-leaf-id="${leafId}"]`) as HTMLElement; const nameInputOf = (leafId: string) => paneOf(leafId).querySelector("input[name=name]") as HTMLInputElement; nameInputOf(leaf1).value = "west"; nameInputOf(leaf1).dispatchEvent(new Event("input")); nameInputOf(leaf2).value = "east"; nameInputOf(leaf2).dispatchEvent(new Event("input")); await flush(); expect(leaves(tab.root).find((l) => l.id === leaf1)?.draft?.name).toBe("west"); expect(leaves(tab.root).find((l) => l.id === leaf2)?.draft?.name).toBe("east"); await dragPaneOntoCenter(paneOf(leaf1), paneOf(leaf2)); // The leaf data swaps (already correct pre-fix)... expect(leaves(tab.root).find((l) => l.id === leaf1)?.draft?.name).toBe("east"); expect(leaves(tab.root).find((l) => l.id === leaf2)?.draft?.name).toBe("west"); // ...and the mounted forms must follow, not stay behind showing the old // pane's values (#73). expect(nameInputOf(leaf1).value).toBe("east"); expect(nameInputOf(leaf2).value).toBe("west"); }); });