// @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { addTab, 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: ["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() {}, setPreview() {}, focusList() {}, hasListFocus() { return false; }, 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 any pending async work lands. */ async function flush(): Promise { for (let i = 0; i < 20; i++) await Promise.resolve(); } describe("multiview empty panes", () => { beforeEach(() => { stubFetch(); document.body.replaceChildren(); }); afterEach(() => { vi.unstubAllGlobals(); }); it("renders a placeholder, not a spawn form", async () => { const layout = emptyLayout(); const tab = layout.tabs[0]; const first = splitLeaf(tab, "", "s1", "row", true); tab.focusedLeafId = splitLeaf(tab, first, null, "row", true); const mv = makeMv(layout); document.body.appendChild(mv.el); mv.render(); await flush(); expect(mv.el.querySelectorAll(".mv-pane")).toHaveLength(2); expect(mv.el.querySelector(".mv-pane-empty")).toBeTruthy(); expect(mv.el.querySelector(".spawn-form")).toBeNull(); }); }); describe("multiview pane-drag swap", () => { 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), which the fake pool // never mounts; 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("swaps the two panes' sessions when one is dropped on the other's centre", async () => { const layout = emptyLayout(); const tab = layout.tabs[0]; const leaf1 = splitLeaf(tab, "", "s1", "row", true); const leaf2 = splitLeaf(tab, leaf1, "s2", "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; await dragPaneOntoCenter(paneOf(leaf1), paneOf(leaf2)); expect(leaves(tab.root).find((l) => l.id === leaf1)?.sessionId).toBe("s2"); expect(leaves(tab.root).find((l) => l.id === leaf2)?.sessionId).toBe("s1"); expect(tab.focusedLeafId).toBe(leaf2); }); }); describe("openSessionBeside newTab (#100)", () => { beforeEach(() => { stubFetch(); document.body.replaceChildren(); }); afterEach(() => { vi.unstubAllGlobals(); }); it("opens its own tab instead of splitting the focused session", 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.openSessionBeside("s2", { newTab: true }); await flush(); expect(layout.tabs).toHaveLength(2); // The tab you were reading is untouched: still one pane, still its session. const original = leaves(layout.tabs[0].root); expect(original).toHaveLength(1); expect(original[0].sessionId).toBe("s1"); // The new session is alone in the new, now-active tab. const fresh = leaves(layout.tabs[1].root); expect(fresh).toHaveLength(1); expect(fresh[0].sessionId).toBe("s2"); expect(layout.activeTabId).toBe(layout.tabs[1].id); }); it("still splits when newTab is not asked for", async () => { const layout = emptyLayout(); const tab = layout.tabs[0]; tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true); const mv = makeMv(layout); document.body.appendChild(mv.el); mv.render(); mv.openSessionBeside("s2"); await flush(); expect(layout.tabs).toHaveLength(1); expect(leaves(tab.root)).toHaveLength(2); }); }); describe("multiview closeSession tab cleanup (#137)", () => { beforeEach(() => { stubFetch(); document.body.replaceChildren(); }); afterEach(() => { vi.unstubAllGlobals(); }); it("closes the tab when its last session goes away", () => { const layout = emptyLayout(); const first = layout.tabs[0]; first.focusedLeafId = splitLeaf(first, "", "s1", "row", true); const second = addTab(layout); second.focusedLeafId = splitLeaf(second, "", "s2", "row", true); const mv = makeMv(layout); document.body.appendChild(mv.el); mv.render(); mv.closeSession("s2"); expect(layout.tabs).toHaveLength(1); expect(layout.tabs[0].id).toBe(first.id); expect(layout.activeTabId).toBe(first.id); }); it("closes a background tab without stealing focus from the active one", () => { const layout = emptyLayout(); const background = layout.tabs[0]; background.focusedLeafId = splitLeaf(background, "", "s1", "row", true); const active = addTab(layout); active.focusedLeafId = splitLeaf(active, "", "s2", "row", true); const mv = makeMv(layout); document.body.appendChild(mv.el); mv.render(); mv.closeSession("s1"); expect(layout.tabs).toHaveLength(1); expect(layout.tabs[0].id).toBe(active.id); expect(layout.activeTabId).toBe(active.id); }); it("keeps a tab that still holds another pane", () => { const layout = emptyLayout(); const tab = layout.tabs[0]; const first = splitLeaf(tab, "", "s1", "row", true); tab.focusedLeafId = splitLeaf(tab, first, "s2", "row", true); addTab(layout); layout.activeTabId = tab.id; const mv = makeMv(layout); document.body.appendChild(mv.el); mv.render(); mv.closeSession("s2"); expect(layout.tabs).toHaveLength(2); expect(leaves(tab.root)).toHaveLength(1); }); it("keeps a tab holding an empty pane", async () => { const layout = emptyLayout(); const tab = layout.tabs[0]; const s1 = splitLeaf(tab, "", "s1", "row", true); splitLeaf(tab, s1, null, "row", true); addTab(layout); layout.activeTabId = tab.id; const mv = makeMv(layout); document.body.appendChild(mv.el); mv.render(); await flush(); mv.closeSession("s1"); expect(layout.tabs).toHaveLength(2); expect(leaves(tab.root)).toHaveLength(1); }); it("never closes the only tab", () => { const layout = emptyLayout(); const tab = layout.tabs[0]; tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true); const mv = makeMv(layout); document.body.appendChild(mv.el); mv.render(); mv.closeSession("s1"); expect(layout.tabs).toHaveLength(1); expect(layout.tabs[0].root).toBeNull(); expect(layout.activeTabId).toBe(tab.id); }); }); describe("multiview mobile single pane", () => { /** Force the mobile media query on, whatever happy-dom's viewport says. */ function forceMobile(): void { vi.spyOn(window, "matchMedia").mockImplementation( (query: string) => ({ matches: query.includes("859"), media: query, onchange: null, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, dispatchEvent: () => false, }) as unknown as MediaQueryList, ); } beforeEach(() => { stubFetch(); forceMobile(); document.body.replaceChildren(); }); afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); it("replaces the focused pane instead of splitting beside it", () => { const layout = emptyLayout(); const tab = layout.tabs[0]; tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true); const mv = makeMv(layout); document.body.appendChild(mv.el); mv.render(); mv.openSessionBeside("s2"); const ls = leaves(tab.root); expect(ls).toHaveLength(1); expect(ls[0].sessionId).toBe("s2"); expect(layout.tabs).toHaveLength(1); }); it("keeps a spawned session in the current pane rather than a new tab", async () => { const layout = emptyLayout(); const tab = layout.tabs[0]; tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true); const mv = makeMv(layout); document.body.appendChild(mv.el); mv.render(); mv.openSessionBeside("s2", { newTab: true }); await flush(); expect(layout.tabs).toHaveLength(1); const ls = leaves(tab.root); expect(ls).toHaveLength(1); expect(ls[0].sessionId).toBe("s2"); }); it("renders one pane and no pane pills for a multi-leaf tab", () => { const layout = emptyLayout(); const tab = layout.tabs[0]; const first = splitLeaf(tab, "", "s1", "row", true); tab.focusedLeafId = splitLeaf(tab, first, "s2", "row", true); const mv = makeMv(layout); document.body.appendChild(mv.el); mv.render(); expect(mv.el.querySelectorAll(".mv-pane")).toHaveLength(1); expect(mv.el.querySelector(".mv-panebar")).toBeNull(); expect(mv.el.querySelector(".mv-pill")).toBeNull(); }); it("collapses the tab bar even with several tabs", () => { const layout = emptyLayout(); const first = layout.tabs[0]; first.focusedLeafId = splitLeaf(first, "", "s1", "row", true); const second = addTab(layout); second.focusedLeafId = splitLeaf(second, "", "s2", "row", true); const mv = makeMv(layout); document.body.appendChild(mv.el); mv.render(); expect(mv.el.classList.contains("mv-mobile-solo")).toBe(true); }); }); describe("multiview solo tab bar", () => { beforeEach(() => { stubFetch(); document.body.replaceChildren(); }); afterEach(() => { vi.unstubAllGlobals(); }); it("hides the tab bar while only one tab exists", () => { const layout = emptyLayout(); const tab = layout.tabs[0]; tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true); const mv = makeMv(layout); document.body.appendChild(mv.el); mv.render(); expect(mv.el.classList.contains("mv-solo-tab")).toBe(true); }); it("shows the tab bar again once a second tab appears", () => { const layout = emptyLayout(); const first = layout.tabs[0]; first.focusedLeafId = splitLeaf(first, "", "s1", "row", true); const second = addTab(layout); second.focusedLeafId = splitLeaf(second, "", "s2", "row", true); const mv = makeMv(layout); document.body.appendChild(mv.el); mv.render(); expect(mv.el.classList.contains("mv-solo-tab")).toBe(false); }); });