import { describe, expect, it } from "vitest"; import { hydrate, serializeLayout } from "./persistence.js"; import { leaves, type Layout } from "./layout.js"; describe("hydrate", () => { it("rejects anything that is not a tab-bearing object", () => { expect(hydrate(null)).toBeNull(); expect(hydrate("nope")).toBeNull(); expect(hydrate({})).toBeNull(); expect(hydrate({ tabs: [] })).toBeNull(); expect(hydrate({ tabs: "no" })).toBeNull(); }); it("rebuilds tabs and re-issues ids rather than trusting stored ones", () => { const layout = hydrate({ tabs: [{ id: "old", title: "Work", root: { kind: "leaf", id: "l1", sessionId: "s1" } }], activeTabId: "old", }); expect(layout).not.toBeNull(); expect(layout!.tabs).toHaveLength(1); expect(layout!.tabs[0].title).toBe("Work"); expect(layout!.tabs[0].id).not.toBe("old"); expect(layout!.activeTabId).toBe(layout!.tabs[0].id); expect(leaves(layout!.tabs[0].root)[0].sessionId).toBe("s1"); }); it("carries the focused leaf across the id reissue", () => { const layout = hydrate({ tabs: [ { id: "t", root: { kind: "split", dir: "row", a: { kind: "leaf", id: "l1", sessionId: "s1" }, b: { kind: "leaf", id: "l2", sessionId: "s2" }, }, focusedLeafId: "l2", }, ], activeTabId: "t", }); const tab = layout!.tabs[0]; const focused = leaves(tab.root).find((l) => l.id === tab.focusedLeafId); expect(focused?.sessionId).toBe("s2"); }); it("falls back to a default title and the first tab as active", () => { const layout = hydrate({ tabs: [{ id: "a" }, { id: "b" }], activeTabId: "missing" }); expect(layout!.tabs[0].title).toBe("Tab 1"); expect(layout!.activeTabId).toBe(layout!.tabs[0].id); }); it("drops a malformed split branch instead of failing the whole layout", () => { const layout = hydrate({ tabs: [{ id: "t", root: { kind: "split", dir: "row", a: null, b: { kind: "leaf", id: "l", sessionId: "s" } } }], activeTabId: "t", }); expect(leaves(layout!.tabs[0].root).map((l) => l.sessionId)).toEqual(["s"]); }); it("never restores draft state, even if a stale blob carries it", () => { const layout = hydrate({ tabs: [ { id: "t", root: { kind: "leaf", id: "l1", sessionId: null, draft: { command: "zsh", cwd: "/x" } } }, ], activeTabId: "t", }); expect(leaves(layout!.tabs[0].root)[0]).not.toHaveProperty("draft"); }); }); describe("serializeLayout", () => { it("strips draft state so a reload gives back an empty form", () => { const layout = hydrate({ tabs: [ { id: "t", root: { kind: "split", dir: "row", a: { kind: "leaf", id: "l1", sessionId: "s1" }, b: { kind: "leaf", id: "l2", sessionId: null }, }, }, ], activeTabId: "t", }) as Layout; const draftLeaf = leaves(layout.tabs[0].root).find((l) => !l.sessionId)!; draftLeaf.draft = { command: "claude", cwd: "/home/user/project", name: "amber-fox" }; const restored = hydrate(JSON.parse(serializeLayout(layout)).layout); expect(restored).not.toBeNull(); for (const leaf of leaves(restored!.tabs[0].root)) { expect(leaf.draft ?? null).toBeNull(); } expect(leaves(restored!.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1", null]); }); it("keeps the draft out of the stored blob itself, not just out of hydrate", () => { // A draft pane only exists once a tab has split, so the strip has to walk // into split branches; asserting on the restored layout alone passes even // when the draft was written to localStorage, because hydrate drops it. const layout = hydrate({ tabs: [ { id: "t", root: { kind: "split", dir: "row", a: { kind: "leaf", id: "l1", sessionId: "s1" }, b: { kind: "leaf", id: "l2", sessionId: null }, }, }, ], activeTabId: "t", }) as Layout; const draftLeaf = leaves(layout.tabs[0].root).find((l) => !l.sessionId)!; draftLeaf.draft = { command: "claude", cwd: "/home/user/secret-project", name: "amber-fox" }; const raw = serializeLayout(layout); expect(raw).not.toContain("secret-project"); expect(raw).not.toContain("amber-fox"); }); });