450 lines
18 KiB
TypeScript
450 lines
18 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import {
|
|
collectPhantomSessionIdsForEnv,
|
|
sanitizeLayout,
|
|
tryRestoreLayout,
|
|
} from "./dockview-layout-restore";
|
|
import * as localStorage from "@/lib/local-storage";
|
|
import { ENV_SCOPED_DOCKVIEW_COMPONENTS } from "@/lib/state/dockview-env-scoped-components";
|
|
|
|
const VALID_COMPONENTS = new Set<string>(["chat", "files", "shell", "git", "terminal"]);
|
|
const PHANTOM_PANEL_ID = "session:phantom";
|
|
const ALIVE_PANEL_ID = "session:alive-1";
|
|
const PREVIEW_FILE_EDITOR_PANEL_ID = "preview:file-editor";
|
|
const TERMINAL_DEFAULT_PANEL_ID = "terminal-default";
|
|
const ENV_SCOPED_PANEL_ID_BY_COMPONENT: Record<string, string> = {
|
|
browser: "browser:http://localhost:3000",
|
|
"commit-detail": "preview:commit-detail",
|
|
"diff-viewer": "preview:file-diff",
|
|
"file-editor": PREVIEW_FILE_EDITOR_PANEL_ID,
|
|
"pr-detail": "pr-detail",
|
|
vscode: "vscode",
|
|
};
|
|
|
|
/**
|
|
* Build a minimal valid SerializedDockview-shaped object — matches what
|
|
* dockview's api.toJSON() produces for a 3-column layout (sidebar | center | right).
|
|
*/
|
|
function buildLayout(opts?: { centerSize?: number; sidebarSize?: number; rightSize?: number }) {
|
|
return {
|
|
grid: {
|
|
root: {
|
|
type: "branch" as const,
|
|
size: 600,
|
|
data: [
|
|
{
|
|
type: "leaf" as const,
|
|
size: opts?.sidebarSize ?? 350,
|
|
data: { id: "g-sidebar", views: ["files"], activeView: "files" },
|
|
},
|
|
{
|
|
type: "leaf" as const,
|
|
size: opts?.centerSize ?? 800,
|
|
data: { id: "g-center", views: ["chat"], activeView: "chat" },
|
|
},
|
|
{
|
|
type: "leaf" as const,
|
|
size: opts?.rightSize ?? 450,
|
|
data: { id: "g-right", views: ["git", "shell"], activeView: "git" },
|
|
},
|
|
],
|
|
},
|
|
height: 600,
|
|
width: 1600,
|
|
orientation: "HORIZONTAL" as const,
|
|
},
|
|
panels: {
|
|
files: { id: "files", contentComponent: "files" },
|
|
chat: { id: "chat", contentComponent: "chat" },
|
|
git: { id: "git", contentComponent: "git" },
|
|
shell: { id: "shell", contentComponent: "shell" },
|
|
},
|
|
activeGroup: "g-center",
|
|
};
|
|
}
|
|
|
|
function makeFakeRestoreApi() {
|
|
return {
|
|
fromJSON: vi.fn(),
|
|
layout: vi.fn(),
|
|
width: 1600,
|
|
height: 600,
|
|
groups: [],
|
|
panels: [],
|
|
activeGroup: { id: "g-center" },
|
|
getPanel: vi.fn(() => null),
|
|
onDidActiveGroupChange: vi.fn(() => ({ dispose: vi.fn() })),
|
|
onDidLayoutChange: vi.fn(() => ({ dispose: vi.fn() })),
|
|
} as unknown as Parameters<typeof tryRestoreLayout>[0];
|
|
}
|
|
|
|
function restoreGlobalFallbackLayout(
|
|
layout: ReturnType<typeof buildLayout>,
|
|
components: Set<string>,
|
|
) {
|
|
window.localStorage.setItem("dockview-layout-v2", JSON.stringify(layout));
|
|
const api = makeFakeRestoreApi();
|
|
const restored = tryRestoreLayout(api, null, components);
|
|
|
|
expect(restored).toBe(true);
|
|
expect(api.fromJSON).toHaveBeenCalledOnce();
|
|
return (api.fromJSON as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
|
}
|
|
|
|
describe("sanitizeLayout - size validation", () => {
|
|
it("returns the layout unchanged when all sizes are positive", () => {
|
|
const layout = buildLayout();
|
|
const result = sanitizeLayout(layout, VALID_COMPONENTS);
|
|
expect(result).not.toBeNull();
|
|
expect(result.grid.root.data).toHaveLength(3);
|
|
});
|
|
|
|
it("returns null when a leaf node has size 0", () => {
|
|
const layout = buildLayout({ centerSize: 0 });
|
|
const result = sanitizeLayout(layout, VALID_COMPONENTS);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("returns null when a leaf node has negative size", () => {
|
|
const layout = buildLayout({ sidebarSize: -50 });
|
|
const result = sanitizeLayout(layout, VALID_COMPONENTS);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("returns null when a branch node has size 0", () => {
|
|
const layout = buildLayout();
|
|
layout.grid.root.size = 0;
|
|
const result = sanitizeLayout(layout, VALID_COMPONENTS);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("returns null when nested branch has invalid size", () => {
|
|
const layout = {
|
|
grid: {
|
|
root: {
|
|
type: "branch" as const,
|
|
size: 800,
|
|
data: [
|
|
{
|
|
type: "leaf" as const,
|
|
size: 350,
|
|
data: { id: "g-sidebar", views: ["files"], activeView: "files" },
|
|
},
|
|
{
|
|
type: "branch" as const,
|
|
size: 0,
|
|
data: [
|
|
{
|
|
type: "leaf" as const,
|
|
size: 800,
|
|
data: { id: "g-center", views: ["chat"], activeView: "chat" },
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
height: 600,
|
|
width: 1600,
|
|
orientation: "HORIZONTAL" as const,
|
|
},
|
|
panels: {
|
|
files: { id: "files", contentComponent: "files" },
|
|
chat: { id: "chat", contentComponent: "chat" },
|
|
},
|
|
activeGroup: "g-center",
|
|
};
|
|
const result = sanitizeLayout(layout, VALID_COMPONENTS);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("returns null when grid.width is 0", () => {
|
|
const layout = buildLayout();
|
|
layout.grid.width = 0;
|
|
const result = sanitizeLayout(layout, VALID_COMPONENTS);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("returns null when grid.height is 0", () => {
|
|
const layout = buildLayout();
|
|
layout.grid.height = 0;
|
|
const result = sanitizeLayout(layout, VALID_COMPONENTS);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("preserves existing component-validation behavior alongside size checks", () => {
|
|
const layout = buildLayout();
|
|
// Add an unknown panel — sanitizer should remove it but keep the rest.
|
|
layout.panels = {
|
|
...layout.panels,
|
|
// @ts-expect-error - injecting an extra unknown panel
|
|
unknown: { id: "unknown", contentComponent: "unknown-component" },
|
|
};
|
|
const result = sanitizeLayout(layout, VALID_COMPONENTS);
|
|
expect(result).not.toBeNull();
|
|
expect(Object.keys(result.panels)).not.toContain("unknown");
|
|
});
|
|
});
|
|
|
|
describe("sanitizeLayout - session panel handling", () => {
|
|
const SESSION_PANEL_ID = "session:abc-123";
|
|
|
|
function buildLayoutWithSession() {
|
|
const layout = buildLayout();
|
|
layout.grid.root.data[1].data.views = ["chat", SESSION_PANEL_ID];
|
|
layout.panels = {
|
|
...layout.panels,
|
|
// @ts-expect-error - injecting a session panel without a matching valid component
|
|
[SESSION_PANEL_ID]: { id: SESSION_PANEL_ID, contentComponent: "session-panel" },
|
|
};
|
|
return layout;
|
|
}
|
|
|
|
it("keeps session:* panels by default (per-env restore)", () => {
|
|
const result = sanitizeLayout(buildLayoutWithSession(), VALID_COMPONENTS);
|
|
expect(result).not.toBeNull();
|
|
expect(Object.keys(result.panels)).toContain(SESSION_PANEL_ID);
|
|
});
|
|
|
|
it("strips session:* panels when stripSessionPanels=true (global fallback)", () => {
|
|
const result = sanitizeLayout(buildLayoutWithSession(), VALID_COMPONENTS, {
|
|
stripSessionPanels: true,
|
|
});
|
|
expect(result).not.toBeNull();
|
|
expect(Object.keys(result.panels)).not.toContain(SESSION_PANEL_ID);
|
|
// The non-session panels stay so the rest of the layout is still usable.
|
|
expect(Object.keys(result.panels)).toContain("chat");
|
|
expect(Object.keys(result.panels)).toContain("files");
|
|
});
|
|
|
|
it("strips session:* panels even when contentComponent is a valid component (e.g. 'chat')", () => {
|
|
// dockview serializes panels added via api.addPanel({ component: "chat" })
|
|
// with contentComponent: "chat", which is in VALID_COMPONENTS. The strip
|
|
// logic must key off the panel id, not the component name.
|
|
const layout = buildLayout();
|
|
layout.grid.root.data[1].data.views = ["chat", SESSION_PANEL_ID];
|
|
layout.panels = {
|
|
...layout.panels,
|
|
// @ts-expect-error - injecting a session panel with the chat component
|
|
[SESSION_PANEL_ID]: { id: SESSION_PANEL_ID, contentComponent: "chat" },
|
|
};
|
|
const result = sanitizeLayout(layout, VALID_COMPONENTS, { stripSessionPanels: true });
|
|
expect(result).not.toBeNull();
|
|
expect(Object.keys(result.panels)).not.toContain(SESSION_PANEL_ID);
|
|
expect(Object.keys(result.panels)).toContain("chat");
|
|
});
|
|
|
|
describe("excludeSessionIds (per-env restore filter)", () => {
|
|
function buildLayoutWithTwoSessions(sidA: string, sidB: string) {
|
|
const layout = buildLayout();
|
|
layout.grid.root.data[1].data.views = [`session:${sidA}`, `session:${sidB}`];
|
|
layout.grid.root.data[1].data.activeView = `session:${sidA}`;
|
|
layout.panels = {
|
|
files: { id: "files", contentComponent: "files" },
|
|
chat: { id: "chat", contentComponent: "chat" },
|
|
git: { id: "git", contentComponent: "git" },
|
|
shell: { id: "shell", contentComponent: "shell" },
|
|
[`session:${sidA}`]: { id: `session:${sidA}`, contentComponent: "chat" },
|
|
[`session:${sidB}`]: { id: `session:${sidB}`, contentComponent: "chat" },
|
|
};
|
|
return layout;
|
|
}
|
|
|
|
it("strips session panels whose ids are in excludeSessionIds (phantom panels from deleted tasks)", () => {
|
|
// Regression for "phantom panels from local/session storage": a stale
|
|
// session id from a previously-deleted task can end up serialized in an
|
|
// env-layout (e.g. via a debounced save firing during a task switch).
|
|
// The env restore must drop session panels we know belong to another env.
|
|
const aliveId = "alive-1";
|
|
const phantomId = "phantom-deleted";
|
|
const layout = buildLayoutWithTwoSessions(aliveId, phantomId);
|
|
const result = sanitizeLayout(layout, VALID_COMPONENTS, {
|
|
excludeSessionIds: new Set([phantomId]),
|
|
});
|
|
expect(result).not.toBeNull();
|
|
const alivePanel = `session:${aliveId}`;
|
|
expect(Object.keys(result.panels)).toContain(alivePanel);
|
|
expect(Object.keys(result.panels)).not.toContain(`session:${phantomId}`);
|
|
// The leaf's views list also drops the phantom.
|
|
const centerLeaf = result.grid.root.data[1];
|
|
expect(centerLeaf.data.views).toEqual([alivePanel]);
|
|
expect(centerLeaf.data.activeView).toBe(alivePanel);
|
|
});
|
|
|
|
it("keeps unmapped session ids (preserves still-loading sessions across restore)", () => {
|
|
// A session id present in the saved layout that the store has not yet
|
|
// mapped (WS hasn't arrived) MUST be kept — otherwise the user loses
|
|
// their valid chat tab on load. Reconcile cleans it up later if stale.
|
|
const layout = buildLayoutWithTwoSessions("a", "b");
|
|
const result = sanitizeLayout(layout, VALID_COMPONENTS, {
|
|
excludeSessionIds: new Set<string>(),
|
|
});
|
|
expect(result).not.toBeNull();
|
|
expect(Object.keys(result.panels)).toContain("session:a");
|
|
expect(Object.keys(result.panels)).toContain("session:b");
|
|
});
|
|
|
|
it("ignores excludeSessionIds when undefined (preserves default per-env behavior)", () => {
|
|
// Without the option, behavior is unchanged: session panels are kept.
|
|
const layout = buildLayoutWithTwoSessions("a", "b");
|
|
const result = sanitizeLayout(layout, VALID_COMPONENTS);
|
|
expect(result).not.toBeNull();
|
|
expect(Object.keys(result.panels)).toContain("session:a");
|
|
expect(Object.keys(result.panels)).toContain("session:b");
|
|
});
|
|
|
|
it("rejects mixing stripSessionPanels with excludeSessionIds at the type level", () => {
|
|
// The two options are intent-mutually-exclusive; the type union enforces
|
|
// it so a caller can't silently get strip-wins behavior.
|
|
const layout = buildLayoutWithTwoSessions("a", "b");
|
|
const result = sanitizeLayout(layout, VALID_COMPONENTS, {
|
|
stripSessionPanels: true,
|
|
// @ts-expect-error - the discriminated union forbids passing both
|
|
excludeSessionIds: new Set(["a"]),
|
|
});
|
|
// Compile-time guard is the contract; this just asserts the call still
|
|
// returns a usable value so the test isn't a no-op at runtime.
|
|
expect(result).not.toBeNull();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("tryRestoreLayout - phantom session panel filtering", () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("restores the (stripped) layout when every session in the saved env-layout is a phantom", () => {
|
|
// Saved layout had a single session panel — a phantom from a previously-
|
|
// deleted task. After stripping, no session panels remain. We still
|
|
// restore the (truncated) layout; useAutoSessionTab will add the active
|
|
// session's chat panel afterwards.
|
|
const layout = buildLayout();
|
|
layout.grid.root.data[1].data.views = [PHANTOM_PANEL_ID];
|
|
layout.grid.root.data[1].data.activeView = PHANTOM_PANEL_ID;
|
|
Object.assign(layout.panels, {
|
|
[PHANTOM_PANEL_ID]: { id: PHANTOM_PANEL_ID, contentComponent: "chat" },
|
|
});
|
|
vi.spyOn(localStorage, "getEnvLayout").mockReturnValue(layout);
|
|
vi.spyOn(localStorage, "getEnvMaximizeState").mockReturnValue(null);
|
|
|
|
const api = makeFakeRestoreApi();
|
|
const restored = tryRestoreLayout(api, "env-new", VALID_COMPONENTS, new Set(["phantom"]));
|
|
expect(restored).toBe(true);
|
|
expect(api.fromJSON).toHaveBeenCalledOnce();
|
|
// The phantom was stripped from the layout passed to fromJSON.
|
|
const restoredLayout = (api.fromJSON as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
|
expect(Object.keys(restoredLayout.panels)).not.toContain(PHANTOM_PANEL_ID);
|
|
});
|
|
|
|
it("restores normally when at least one session panel survives the filter", () => {
|
|
const layout = buildLayout();
|
|
layout.grid.root.data[1].data.views = [ALIVE_PANEL_ID, PHANTOM_PANEL_ID];
|
|
layout.grid.root.data[1].data.activeView = ALIVE_PANEL_ID;
|
|
Object.assign(layout.panels, {
|
|
[ALIVE_PANEL_ID]: { id: ALIVE_PANEL_ID, contentComponent: "chat" },
|
|
[PHANTOM_PANEL_ID]: { id: PHANTOM_PANEL_ID, contentComponent: "chat" },
|
|
});
|
|
vi.spyOn(localStorage, "getEnvLayout").mockReturnValue(layout);
|
|
vi.spyOn(localStorage, "getEnvMaximizeState").mockReturnValue(null);
|
|
|
|
const api = makeFakeRestoreApi();
|
|
const restored = tryRestoreLayout(api, "env-X", VALID_COMPONENTS, new Set(["phantom"]));
|
|
expect(restored).toBe(true);
|
|
expect(api.fromJSON).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("restores normally when the saved layout has no session panels at all (legit non-session env layout)", () => {
|
|
// An env may persist a layout with only files/terminal/etc. — no session
|
|
// panels to begin with. The fallback guard must NOT discard this layout.
|
|
const layout = buildLayout(); // center has "chat" only, no session:* ids
|
|
vi.spyOn(localStorage, "getEnvLayout").mockReturnValue(layout);
|
|
vi.spyOn(localStorage, "getEnvMaximizeState").mockReturnValue(null);
|
|
|
|
const api = makeFakeRestoreApi();
|
|
const restored = tryRestoreLayout(api, "env-X", VALID_COMPONENTS, new Set());
|
|
expect(restored).toBe(true);
|
|
expect(api.fromJSON).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
|
|
describe("tryRestoreLayout - global fallback env-scoped panel filtering", () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
window.localStorage.clear();
|
|
});
|
|
|
|
it.each(
|
|
[...ENV_SCOPED_DOCKVIEW_COMPONENTS].map((component) => ({
|
|
component,
|
|
panelId: ENV_SCOPED_PANEL_ID_BY_COMPONENT[component] ?? `${component}:stale`,
|
|
})),
|
|
)("strips $component panels from the no-env global fallback layout", ({ component, panelId }) => {
|
|
const layout = buildLayout();
|
|
layout.grid.root.data[1].data.views = [PHANTOM_PANEL_ID, panelId];
|
|
layout.grid.root.data[1].data.activeView = panelId;
|
|
const panels = layout.panels as Record<string, { id?: string; contentComponent: string }>;
|
|
delete panels.chat;
|
|
Object.assign(panels, {
|
|
[PHANTOM_PANEL_ID]: { id: PHANTOM_PANEL_ID, contentComponent: "chat" },
|
|
[panelId]: { id: panelId, contentComponent: component },
|
|
});
|
|
|
|
const restoredLayout = restoreGlobalFallbackLayout(
|
|
layout,
|
|
new Set([...VALID_COMPONENTS, component]),
|
|
);
|
|
expect(Object.keys(restoredLayout.panels)).not.toContain(PHANTOM_PANEL_ID);
|
|
expect(Object.keys(restoredLayout.panels)).not.toContain(panelId);
|
|
});
|
|
|
|
it("keeps terminal panels in the no-env global fallback layout", () => {
|
|
const layout = buildLayout();
|
|
layout.grid.root.data[1].data.views = [TERMINAL_DEFAULT_PANEL_ID];
|
|
layout.grid.root.data[1].data.activeView = TERMINAL_DEFAULT_PANEL_ID;
|
|
const panels = layout.panels as Record<string, { id?: string; contentComponent: string }>;
|
|
delete panels.chat;
|
|
Object.assign(panels, {
|
|
[TERMINAL_DEFAULT_PANEL_ID]: {
|
|
id: TERMINAL_DEFAULT_PANEL_ID,
|
|
contentComponent: "terminal",
|
|
},
|
|
});
|
|
|
|
const restoredLayout = restoreGlobalFallbackLayout(layout, VALID_COMPONENTS);
|
|
expect(Object.keys(restoredLayout.panels)).toContain(TERMINAL_DEFAULT_PANEL_ID);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Regression: an env-layout could be restored with a session panel whose id
|
|
* referred to a previously-deleted task's session. The fix strips session
|
|
* panels we KNOW belong to a different env; sessions not yet mapped in the
|
|
* store are preserved (they may still be loading via WS).
|
|
*/
|
|
describe("collectPhantomSessionIdsForEnv", () => {
|
|
it("returns session ids whose mapping is a different env", () => {
|
|
const state = {
|
|
environmentIdBySessionId: {
|
|
"sess-1": "env-A",
|
|
"sess-2": "env-A",
|
|
"sess-3": "env-B",
|
|
},
|
|
};
|
|
expect(collectPhantomSessionIdsForEnv(state, "env-A")).toEqual(new Set(["sess-3"]));
|
|
expect(collectPhantomSessionIdsForEnv(state, "env-B")).toEqual(new Set(["sess-1", "sess-2"]));
|
|
});
|
|
|
|
it("returns every mapped session as phantom when the env has no own sessions yet", () => {
|
|
const state = { environmentIdBySessionId: { "sess-1": "env-A" } };
|
|
expect(collectPhantomSessionIdsForEnv(state, "env-new")).toEqual(new Set(["sess-1"]));
|
|
});
|
|
|
|
it("does NOT classify a session as a phantom when its mapping is absent (still loading via WS)", () => {
|
|
// A session id present in a saved layout but not in environmentIdBySessionId
|
|
// could be a not-yet-arrived session for this very env. Keep it; reconcile
|
|
// will clean it up later if it really is stale.
|
|
const state = { environmentIdBySessionId: {} };
|
|
expect(collectPhantomSessionIdsForEnv(state, "env-A")).toEqual(new Set());
|
|
});
|
|
});
|