Files
kandev/apps/web/lib/state/dockview-preset-persistence.test.ts
T

311 lines
11 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import type { DockviewApi } from "dockview-react";
vi.mock("@/lib/local-storage", () => ({
setEnvLayout: vi.fn(),
getEnvLayout: vi.fn(() => null),
getEnvMaximizeState: vi.fn(() => null),
setEnvMaximizeState: vi.fn(),
removeEnvMaximizeState: vi.fn(),
getGlobalSidebarWidth: vi.fn(() => null),
setGlobalSidebarWidth: vi.fn(),
clearGlobalSidebarWidth: vi.fn(),
}));
vi.mock("@/lib/layout/panel-portal-manager", () => ({
panelPortalManager: { releaseByEnv: vi.fn(), reconcile: vi.fn() },
}));
vi.mock("./dockview-scroll-preserve", () => ({
preserveChatScrollDuringLayout: vi.fn(),
}));
vi.mock("./dockview-measure", () => ({
measureDockviewContainer: vi.fn(() => ({ width: 800, height: 600 })),
}));
vi.mock("./dockview-pinned-enforce", () => ({
enforcePinnedTargets: vi.fn(),
}));
vi.mock("./dockview-layout-builders", () => ({
applyLayoutFixups: vi.fn(() => ({
sidebarGroupId: "g-sidebar",
centerGroupId: "g-center",
rightTopGroupId: "g-right-top",
rightBottomGroupId: "g-right-bottom",
})),
focusOrAddPanel: vi.fn(),
}));
vi.mock("./layout-manager", () => ({
SIDEBAR_GROUP: "sidebar",
CENTER_GROUP: "center",
RIGHT_TOP_GROUP: "right-top",
RIGHT_BOTTOM_GROUP: "right-bottom",
TERMINAL_DEFAULT_ID: "terminal",
LAYOUT_SIDEBAR_RATIO: 0.2,
LAYOUT_RIGHT_RATIO: 0.25,
LAYOUT_PINNED_MIN_PX: 200,
computeSidebarMaxPx: vi.fn(() => 350),
computeRightMaxPx: vi.fn(() => 500),
getPresetLayout: vi.fn(() => ({ columns: [] })),
applyLayout: vi.fn(() => ({
sidebarGroupId: "g-sidebar",
centerGroupId: "g-center",
rightTopGroupId: "g-right-top",
rightBottomGroupId: "g-right-bottom",
})),
getPinnedWidth: vi.fn(() => 350),
getRootSplitview: vi.fn(() => null),
fromDockviewApi: vi.fn(() => ({ columns: [] })),
filterEphemeral: vi.fn((s: unknown) => s),
defaultLayout: vi.fn(() => ({ columns: [] })),
mergeCurrentPanelsIntoPreset: vi.fn((_api: unknown, preset: unknown) => preset),
toSerializedDockview: vi.fn((s: unknown) => s),
injectIntentPanels: vi.fn(),
applyActivePanelOverrides: vi.fn(),
resolveNamedIntent: vi.fn(),
setPinnedTarget: vi.fn(),
clearPinnedTarget: vi.fn(),
getPinnedTarget: vi.fn(() => undefined),
layoutStructuresMatch: vi.fn(() => false),
savedLayoutMatchesLive: vi.fn(() => false),
}));
import { setEnvLayout } from "@/lib/local-storage";
import { persistEnvLayoutNow, useDockviewStore } from "./dockview-store";
function makeApi(snapshot: object = { columns: [] }): DockviewApi {
return {
toJSON: vi.fn(() => snapshot),
} as unknown as DockviewApi;
}
function makeStoreApi(): DockviewApi {
return {
width: 800,
height: 600,
panels: [],
groups: [],
layout: vi.fn(),
fromJSON: vi.fn(),
toJSON: vi.fn(() => ({ columns: [{ id: "center" }] })),
getPanel: vi.fn(() => null),
addPanel: vi.fn(),
activeGroup: null,
hasMaximizedGroup: vi.fn(() => false),
exitMaximizedGroup: vi.fn(),
onDidActivePanelChange: vi.fn(() => ({ dispose: vi.fn() })),
} as unknown as DockviewApi;
}
function flushRaf(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
// Regression: applyBuiltInPreset and applyCustomLayout used to mutate the live
// layout in memory but never wrote it to env-keyed sessionStorage. The auto-save
// in setupLayoutPersistence is gated by isRestoringLayout=true (which both
// actions hold for the entire rAF window in which they emit layout-change
// events), so the debounced save never fired. A page refresh after a preset
// switch restored the pre-preset layout from sessionStorage, losing the user's
// intent. persistEnvLayoutNow runs after isRestoringLayout flips back to false
// at the end of those rAF callbacks.
describe("persistEnvLayoutNow", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("writes the live api.toJSON() to env storage when envId is set", () => {
const snapshot = { columns: [{ id: "center" }] };
const api = makeApi(snapshot);
persistEnvLayoutNow(api, "env-1", null);
expect(setEnvLayout).toHaveBeenCalledTimes(1);
expect(setEnvLayout).toHaveBeenCalledWith("env-1", snapshot);
});
it("is a no-op when envId is null (no env adopted yet)", () => {
const api = makeApi();
persistEnvLayoutNow(api, null, null);
expect(setEnvLayout).not.toHaveBeenCalled();
expect(api.toJSON).not.toHaveBeenCalled();
});
it("is a no-op while maximized (toJSON would be the 2-column overlay)", () => {
// saveOutgoingEnv owns the maximize slot. Writing the overlay as the env's
// regular layout would resurface a truncated layout on reload.
const api = makeApi();
const preMaximizeLayout = { columns: [] };
persistEnvLayoutNow(api, "env-1", preMaximizeLayout);
expect(setEnvLayout).not.toHaveBeenCalled();
expect(api.toJSON).not.toHaveBeenCalled();
});
it("swallows serialization/storage errors so callers do not crash mid-rAF", () => {
const api = {
toJSON: vi.fn(() => {
throw new Error("dockview serialize failed");
}),
} as unknown as DockviewApi;
expect(() => persistEnvLayoutNow(api, "env-1", null)).not.toThrow();
expect(setEnvLayout).not.toHaveBeenCalled();
});
});
// Integration coverage: assert the real preset/custom-layout actions invoke
// persistEnvLayoutNow at the end of their rAF callback. The helper-only tests
// above cover the contract, but the bug we fixed was at the call sites — they
// previously held isRestoringLayout=true for the whole rAF and never wrote.
function resetStoreForIntegration() {
vi.clearAllMocks();
useDockviewStore.setState({
api: null,
currentLayoutEnvId: null,
preMaximizeLayout: null,
maximizedGroupId: null,
isRestoringLayout: false,
});
}
const customLayout = {
id: "custom-1",
name: "custom",
layout: { columns: [{ id: "center", views: [], activeView: null }] },
};
type ApplyCustomLayoutArg = Parameters<
ReturnType<typeof useDockviewStore.getState>["applyCustomLayout"]
>[0];
describe("applyBuiltInPreset — persistence at call site", () => {
beforeEach(resetStoreForIntegration);
it("persists the env layout after isRestoringLayout clears", async () => {
const api = makeStoreApi();
useDockviewStore.setState({ api, currentLayoutEnvId: "env-preset" });
useDockviewStore.getState().applyBuiltInPreset("default");
expect(useDockviewStore.getState().isRestoringLayout).toBe(true);
await flushRaf();
expect(useDockviewStore.getState().isRestoringLayout).toBe(false);
expect(setEnvLayout).toHaveBeenCalledTimes(1);
expect(setEnvLayout).toHaveBeenCalledWith("env-preset", expect.any(Object));
});
it("does not persist when no env is adopted yet", async () => {
const api = makeStoreApi();
useDockviewStore.setState({ api, currentLayoutEnvId: null });
useDockviewStore.getState().applyBuiltInPreset("default");
await flushRaf();
expect(setEnvLayout).not.toHaveBeenCalled();
});
it("skips persistence while maximized to avoid stomping the regular layout", async () => {
const api = makeStoreApi();
type StoreState = ReturnType<typeof useDockviewStore.getState>;
useDockviewStore.setState({
api,
currentLayoutEnvId: "env-maxed",
preMaximizeLayout: { columns: [] } as unknown as StoreState["preMaximizeLayout"],
});
useDockviewStore.getState().applyBuiltInPreset("default");
await flushRaf();
expect(setEnvLayout).not.toHaveBeenCalled();
});
it("does not persist if the env changes between scheduling and rAF", async () => {
const api = makeStoreApi();
useDockviewStore.setState({ api, currentLayoutEnvId: "env-before" });
useDockviewStore.getState().applyBuiltInPreset("default");
// Simulate the user navigating to a different task in the ~16ms before
// the rAF callback fires. The rAF should detect the env switch and skip
// the write so the old layout is not stored under the new env's key.
useDockviewStore.setState({ currentLayoutEnvId: "env-after" });
await flushRaf();
expect(setEnvLayout).not.toHaveBeenCalled();
});
});
describe("applyCustomLayout — persistence at call site", () => {
beforeEach(resetStoreForIntegration);
it("persists the env layout after isRestoringLayout clears", async () => {
const api = makeStoreApi();
useDockviewStore.setState({ api, currentLayoutEnvId: "env-custom" });
useDockviewStore.getState().applyCustomLayout(customLayout as unknown as ApplyCustomLayoutArg);
await flushRaf();
expect(setEnvLayout).toHaveBeenCalledTimes(1);
expect(setEnvLayout).toHaveBeenCalledWith("env-custom", expect.any(Object));
});
it("does not persist when no env is adopted yet", async () => {
const api = makeStoreApi();
useDockviewStore.setState({ api, currentLayoutEnvId: null });
useDockviewStore.getState().applyCustomLayout(customLayout as unknown as ApplyCustomLayoutArg);
await flushRaf();
expect(setEnvLayout).not.toHaveBeenCalled();
});
it("skips persistence while maximized", async () => {
const api = makeStoreApi();
type StoreState = ReturnType<typeof useDockviewStore.getState>;
useDockviewStore.setState({
api,
currentLayoutEnvId: "env-maxed-custom",
preMaximizeLayout: { columns: [] } as unknown as StoreState["preMaximizeLayout"],
});
useDockviewStore.getState().applyCustomLayout(customLayout as unknown as ApplyCustomLayoutArg);
await flushRaf();
expect(setEnvLayout).not.toHaveBeenCalled();
});
it("does not persist if the env changes between scheduling and rAF", async () => {
const api = makeStoreApi();
useDockviewStore.setState({ api, currentLayoutEnvId: "env-before-custom" });
useDockviewStore.getState().applyCustomLayout(customLayout as unknown as ApplyCustomLayoutArg);
useDockviewStore.setState({ currentLayoutEnvId: "env-after-custom" });
await flushRaf();
expect(setEnvLayout).not.toHaveBeenCalled();
});
it("does not persist when legacy fromJSON restore throws", async () => {
const api = makeStoreApi();
// Force the old-format path by passing a layout without `columns`, and
// make fromJSON throw so the API may be in a partial state. Persisting
// that partial snapshot would propagate corruption to the next load.
(api.fromJSON as ReturnType<typeof vi.fn>).mockImplementation(() => {
throw new Error("dockview fromJSON failed");
});
useDockviewStore.setState({ api, currentLayoutEnvId: "env-legacy" });
const legacyLayout = { id: "legacy", name: "legacy", layout: { grid: {} } };
useDockviewStore.getState().applyCustomLayout(legacyLayout as unknown as ApplyCustomLayoutArg);
await flushRaf();
expect(setEnvLayout).not.toHaveBeenCalled();
});
});