Six findings from reviewing the accumulated blitz/spawn-v2 diff: - loadRecents restored the seeded cwd but never emitted it, so the leaf's draft kept cwd:"" and the next render reseeded the form blank. - stripDrafts did not recurse, so every half-filled draft in a split tab was written to localStorage; the old test passed only because hydrate discards draft on the way back in. - showRawCwd and selectPreset grabbed focus from async init paths, so a draft pane stole the caret seconds after mounting. - .mv-draft inherited no layout once the form left .modal-body. - onSuccess mounted the new session onto a leaf that may have been closed while the POST was in flight, stranding it in no pane.
129 lines
4.4 KiB
TypeScript
129 lines
4.4 KiB
TypeScript
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");
|
|
});
|
|
});
|