503 lines
16 KiB
TypeScript
503 lines
16 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import type { SavedLayout } from "@/lib/types/http";
|
|
import { panel, type LayoutState } from "@/lib/state/layout-manager";
|
|
import {
|
|
BUILT_IN_LAYOUT_PROFILES,
|
|
createLayoutProfile,
|
|
createLayoutProfileId,
|
|
deleteLayoutProfile,
|
|
duplicateLayoutProfile,
|
|
getBuiltInLayoutOverride,
|
|
getBuiltInLayoutOverrideId,
|
|
getBuiltInLayoutProfile,
|
|
getLayoutProfileIdentity,
|
|
getLayoutProfileCompatibility,
|
|
isBuiltInLayoutOverride,
|
|
renameLayoutProfile,
|
|
resetBuiltInLayoutOverride,
|
|
resolveEffectiveDefaultLayout,
|
|
setDefaultLayoutProfile,
|
|
upsertBuiltInLayoutOverride,
|
|
validateReusableLayout,
|
|
} from "./layout-profiles";
|
|
|
|
const DEFAULT_LAYOUT_ID = "default";
|
|
const CREATED_AT = "2026-07-19T11:00:00.000Z";
|
|
|
|
const FIRST_LAYOUT_ID = "layout-one";
|
|
const SECOND_LAYOUT_ID = "layout-two";
|
|
const CENTER_COLUMN_ID = "center";
|
|
const LEGACY_LAYOUT_VALUE = "old-format";
|
|
const SESSION_PANEL_ID = "session:session-one";
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
function reusableLayout(panelIds: string[] = ["chat", "files", "changes"]): LayoutState {
|
|
return {
|
|
columns: [
|
|
{
|
|
id: CENTER_COLUMN_ID,
|
|
groups: [
|
|
{
|
|
id: "group-center",
|
|
panels: panelIds.map(panel),
|
|
activePanel: panelIds[0],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function savedLayout(overrides: Partial<SavedLayout> = {}): SavedLayout {
|
|
return {
|
|
id: FIRST_LAYOUT_ID,
|
|
name: "Layout one",
|
|
is_default: false,
|
|
layout: reusableLayout(),
|
|
created_at: "2026-07-19T10:00:00.000Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("built-in layout profiles", () => {
|
|
it("exposes the four reusable built-in templates", () => {
|
|
expect(BUILT_IN_LAYOUT_PROFILES.map(({ id, name }) => ({ id, name }))).toEqual([
|
|
{ id: DEFAULT_LAYOUT_ID, name: "Default" },
|
|
{ id: "plan", name: "Plan Mode" },
|
|
{ id: "preview", name: "Preview Mode" },
|
|
{ id: "vscode", name: "VS Code" },
|
|
]);
|
|
});
|
|
|
|
it("describes the current workbench without the retired embedded sidebar", () => {
|
|
expect(BUILT_IN_LAYOUT_PROFILES.map(({ description }) => description)).not.toContainEqual(
|
|
expect.stringContaining("Sidebar"),
|
|
);
|
|
});
|
|
|
|
it("returns a fresh template layout for each request", () => {
|
|
const first = getBuiltInLayoutProfile(DEFAULT_LAYOUT_ID);
|
|
first.layout.columns[0].groups[0].panels.length = 0;
|
|
|
|
expect(
|
|
getBuiltInLayoutProfile(DEFAULT_LAYOUT_ID).layout.columns[0].groups[0].panels.map(
|
|
(panel) => panel.id,
|
|
),
|
|
).toEqual(["chat"]);
|
|
});
|
|
});
|
|
|
|
describe("validateReusableLayout", () => {
|
|
it("accepts one Agent and unique reusable optional panels", () => {
|
|
const result = validateReusableLayout(reusableLayout());
|
|
|
|
expect(result).toMatchObject({ valid: true, issues: [] });
|
|
});
|
|
|
|
it("accepts canonical PR Details as reusable layout content", () => {
|
|
const result = validateReusableLayout(reusableLayout(["chat", "pr-detail"]));
|
|
|
|
expect(result).toMatchObject({ valid: true, issues: [] });
|
|
});
|
|
|
|
it("normalizes a saved session Agent without changing the input", () => {
|
|
const input = reusableLayout();
|
|
input.columns[0].groups[0].panels[0] = {
|
|
id: SESSION_PANEL_ID,
|
|
component: "chat",
|
|
title: "Agent",
|
|
tabComponent: "sessionTab",
|
|
params: { sessionId: "session-one" },
|
|
};
|
|
input.columns[0].groups[0].activePanel = SESSION_PANEL_ID;
|
|
const snapshot = structuredClone(input);
|
|
|
|
const result = validateReusableLayout(input);
|
|
|
|
expect(result.valid && result.layout.columns[0].groups[0]).toMatchObject({
|
|
activePanel: "chat",
|
|
panels: expect.arrayContaining([expect.objectContaining({ id: "chat", component: "chat" })]),
|
|
});
|
|
expect(input).toEqual(snapshot);
|
|
});
|
|
});
|
|
|
|
describe("validateReusableLayout failures", () => {
|
|
it.each([
|
|
{
|
|
name: "a missing Agent",
|
|
layout: reusableLayout(["files"]),
|
|
code: "missing-agent",
|
|
},
|
|
{
|
|
name: "a duplicate reusable panel",
|
|
layout: reusableLayout(["chat", "files", "files"]),
|
|
code: "duplicate-panel",
|
|
},
|
|
{
|
|
name: "an unsupported panel",
|
|
layout: {
|
|
columns: [
|
|
{
|
|
id: CENTER_COLUMN_ID,
|
|
groups: [
|
|
{
|
|
id: "group-center",
|
|
panels: [
|
|
panel("chat"),
|
|
{
|
|
id: "pr-detail|owner/repository/123",
|
|
component: "pr-detail",
|
|
title: "Pull Request",
|
|
},
|
|
],
|
|
activePanel: "chat",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
code: "unsupported-panel",
|
|
},
|
|
{
|
|
name: "an empty group",
|
|
layout: {
|
|
columns: [{ id: CENTER_COLUMN_ID, groups: [{ panels: [panel("chat")] }, { panels: [] }] }],
|
|
},
|
|
code: "empty-group",
|
|
},
|
|
{
|
|
name: "an active tab outside its group",
|
|
layout: {
|
|
columns: [
|
|
{
|
|
id: CENTER_COLUMN_ID,
|
|
groups: [{ panels: [panel("chat")], activePanel: "files" }],
|
|
},
|
|
],
|
|
},
|
|
code: "invalid-active-panel",
|
|
},
|
|
])("rejects $name", ({ layout, code }) => {
|
|
const result = validateReusableLayout(layout);
|
|
|
|
expect(result).toMatchObject({ valid: false, issues: [expect.objectContaining({ code })] });
|
|
});
|
|
|
|
it("identifies unreadable legacy JSON without modifying it", () => {
|
|
const input = {
|
|
columns: [{ id: CENTER_COLUMN_ID, groups: LEGACY_LAYOUT_VALUE }],
|
|
legacy: true,
|
|
};
|
|
const snapshot = structuredClone(input);
|
|
|
|
expect(validateReusableLayout(input)).toMatchObject({
|
|
valid: false,
|
|
issues: [expect.objectContaining({ code: "invalid-layout" })],
|
|
});
|
|
expect(input).toEqual(snapshot);
|
|
});
|
|
|
|
it("validates the authoritative tree groups when a layout has split metadata", () => {
|
|
const layout = reusableLayout();
|
|
layout.columns[0].tree = {
|
|
type: "leaf",
|
|
group: { panels: [panel("chat")], activePanel: "files" },
|
|
};
|
|
|
|
expect(validateReusableLayout(layout)).toMatchObject({
|
|
valid: false,
|
|
issues: [expect.objectContaining({ code: "invalid-active-panel" })],
|
|
});
|
|
});
|
|
|
|
it("reports an invalid active panel only once", () => {
|
|
const layout = reusableLayout();
|
|
layout.columns[0].groups[0].activePanel = "files-outside-group";
|
|
|
|
const result = validateReusableLayout(layout);
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.issues.filter(({ code }) => code === "invalid-active-panel")).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("layout profile defaults", () => {
|
|
it("marks an unreadable profile as legacy without rewriting its payload", () => {
|
|
const profile = savedLayout({ layout: { columns: LEGACY_LAYOUT_VALUE } });
|
|
const snapshot = structuredClone(profile);
|
|
|
|
expect(getLayoutProfileCompatibility(profile)).toMatchObject({
|
|
status: "legacy",
|
|
profile,
|
|
issues: [expect.objectContaining({ code: "invalid-layout" })],
|
|
});
|
|
expect(profile).toEqual(snapshot);
|
|
});
|
|
|
|
it("resolves a valid custom default and returns its normalized layout", () => {
|
|
const profile = savedLayout({ is_default: true });
|
|
|
|
expect(resolveEffectiveDefaultLayout([profile])).toMatchObject({
|
|
source: "custom",
|
|
profile,
|
|
layout: reusableLayout(),
|
|
});
|
|
});
|
|
|
|
it("resolves a valid reserved built-in override default", () => {
|
|
const profile = savedLayout({
|
|
id: getBuiltInLayoutOverrideId(DEFAULT_LAYOUT_ID),
|
|
name: "Default",
|
|
is_default: true,
|
|
});
|
|
|
|
expect(resolveEffectiveDefaultLayout([profile])).toMatchObject({
|
|
source: "custom",
|
|
profile,
|
|
layout: reusableLayout(),
|
|
});
|
|
});
|
|
|
|
it("falls back to the built-in Default when the selected profile is invalid", () => {
|
|
const invalid = savedLayout({ is_default: true, layout: reusableLayout(["files"]) });
|
|
|
|
const resolved = resolveEffectiveDefaultLayout([invalid]);
|
|
|
|
expect(resolved.source).toBe("built-in");
|
|
expect(resolved.profile.id).toBe(DEFAULT_LAYOUT_ID);
|
|
expect(validateReusableLayout(resolved.layout).valid).toBe(true);
|
|
});
|
|
|
|
it("applies a legacy default after removing its retired sidebar column", () => {
|
|
const layout = reusableLayout();
|
|
layout.columns.unshift({
|
|
id: "sidebar",
|
|
groups: [
|
|
{
|
|
id: "group-sidebar",
|
|
panels: [{ id: "sidebar", component: "sidebar", title: "Sidebar" }],
|
|
activePanel: "sidebar",
|
|
},
|
|
],
|
|
});
|
|
const profile = savedLayout({ is_default: true, layout });
|
|
|
|
const resolved = resolveEffectiveDefaultLayout([profile]);
|
|
|
|
expect(resolved.source).toBe("custom");
|
|
expect(resolved.layout.columns.map(({ id }) => id)).toEqual([CENTER_COLUMN_ID]);
|
|
});
|
|
});
|
|
|
|
describe("layout profile IDs", () => {
|
|
it("maps reserved overrides to their built-in identity", () => {
|
|
expect(getLayoutProfileIdentity({ id: getBuiltInLayoutOverrideId(DEFAULT_LAYOUT_ID) })).toEqual(
|
|
{ kind: "built-in", id: DEFAULT_LAYOUT_ID },
|
|
);
|
|
expect(getLayoutProfileIdentity({ id: "layout-copied-default" })).toEqual({
|
|
kind: "custom",
|
|
id: "layout-copied-default",
|
|
});
|
|
});
|
|
|
|
it("uses UUID-backed IDs", () => {
|
|
const first = createLayoutProfileId();
|
|
const second = createLayoutProfileId();
|
|
|
|
expect(first).toMatch(/^layout-[0-9a-f-]{36}$/);
|
|
expect(second).not.toBe(first);
|
|
});
|
|
|
|
it("uses the UUID fallback when crypto.randomUUID is unavailable", () => {
|
|
vi.stubGlobal("crypto", {});
|
|
|
|
const first = createLayoutProfileId();
|
|
const second = createLayoutProfileId();
|
|
|
|
expect(first).toMatch(/^layout-[0-9a-f-]{36}$/);
|
|
expect(second).toMatch(/^layout-[0-9a-f-]{36}$/);
|
|
expect(second).not.toBe(first);
|
|
});
|
|
});
|
|
|
|
describe("built-in layout overrides", () => {
|
|
it("creates one stable hidden override and updates it in place", () => {
|
|
const firstLayout = reusableLayout(["chat", "files"]);
|
|
const secondLayout = reusableLayout(["chat", "changes"]);
|
|
|
|
const created = upsertBuiltInLayoutOverride([], DEFAULT_LAYOUT_ID, firstLayout, {
|
|
createdAt: CREATED_AT,
|
|
isDefault: true,
|
|
});
|
|
const updated = upsertBuiltInLayoutOverride(created, DEFAULT_LAYOUT_ID, secondLayout, {
|
|
createdAt: "2026-07-19T12:00:00.000Z",
|
|
});
|
|
|
|
expect(updated).toHaveLength(1);
|
|
expect(updated[0]).toMatchObject({
|
|
id: getBuiltInLayoutOverrideId(DEFAULT_LAYOUT_ID),
|
|
name: "Default",
|
|
is_default: true,
|
|
layout: secondLayout,
|
|
created_at: CREATED_AT,
|
|
});
|
|
expect(isBuiltInLayoutOverride(updated[0])).toBe(true);
|
|
expect(getBuiltInLayoutOverride(updated, DEFAULT_LAYOUT_ID)).toBe(updated[0]);
|
|
});
|
|
|
|
it("rejects invalid layouts when creating or updating an override", () => {
|
|
const invalidLayout = reusableLayout(["files"]);
|
|
|
|
expect(() =>
|
|
upsertBuiltInLayoutOverride([], "plan", invalidLayout, { createdAt: CREATED_AT }),
|
|
).toThrow("valid reusable layout");
|
|
|
|
const existing = upsertBuiltInLayoutOverride([], "plan", reusableLayout(), {
|
|
createdAt: CREATED_AT,
|
|
});
|
|
expect(() =>
|
|
upsertBuiltInLayoutOverride(existing, "plan", invalidLayout, { createdAt: CREATED_AT }),
|
|
).toThrow("valid reusable layout");
|
|
});
|
|
|
|
it("persists the normalized layout when creating and updating an override", () => {
|
|
const sessionLayout = reusableLayout();
|
|
sessionLayout.columns[0].groups[0].panels[0] = {
|
|
id: SESSION_PANEL_ID,
|
|
component: "chat",
|
|
title: "Agent",
|
|
params: { sessionId: "session-one" },
|
|
};
|
|
sessionLayout.columns[0].groups[0].activePanel = SESSION_PANEL_ID;
|
|
|
|
const created = upsertBuiltInLayoutOverride([], "plan", sessionLayout, {
|
|
createdAt: CREATED_AT,
|
|
});
|
|
const updated = upsertBuiltInLayoutOverride(created, "plan", sessionLayout, {
|
|
createdAt: CREATED_AT,
|
|
});
|
|
|
|
for (const profiles of [created, updated]) {
|
|
const storedLayout = profiles[0].layout as unknown as LayoutState;
|
|
expect(storedLayout.columns[0].groups[0]).toMatchObject({
|
|
activePanel: "chat",
|
|
panels: expect.arrayContaining([expect.objectContaining({ id: "chat" })]),
|
|
});
|
|
}
|
|
});
|
|
|
|
it("resets only the selected built-in override", () => {
|
|
const defaultOverride = upsertBuiltInLayoutOverride([], DEFAULT_LAYOUT_ID, reusableLayout(), {
|
|
createdAt: CREATED_AT,
|
|
isDefault: true,
|
|
});
|
|
const withCustom = [...defaultOverride, savedLayout({ id: SECOND_LAYOUT_ID })];
|
|
|
|
expect(resetBuiltInLayoutOverride(withCustom, DEFAULT_LAYOUT_ID)).toEqual([
|
|
savedLayout({ id: SECOND_LAYOUT_ID }),
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("immutable layout profile mutations", () => {
|
|
it("creates a trimmed profile and clears an earlier default", () => {
|
|
const original = [savedLayout({ is_default: true })];
|
|
|
|
const next = createLayoutProfile(original, {
|
|
id: SECOND_LAYOUT_ID,
|
|
name: " Focused ",
|
|
layout: reusableLayout(["chat", "files"]),
|
|
createdAt: CREATED_AT,
|
|
isDefault: true,
|
|
});
|
|
|
|
expect(next).toEqual([
|
|
{ ...original[0], is_default: false },
|
|
savedLayout({
|
|
id: SECOND_LAYOUT_ID,
|
|
name: "Focused",
|
|
is_default: true,
|
|
layout: reusableLayout(["chat", "files"]),
|
|
created_at: CREATED_AT,
|
|
}),
|
|
]);
|
|
expect(original[0].is_default).toBe(true);
|
|
});
|
|
|
|
it("rejects duplicate IDs and blank names", () => {
|
|
const original = [savedLayout()];
|
|
|
|
expect(() =>
|
|
createLayoutProfile(original, {
|
|
id: FIRST_LAYOUT_ID,
|
|
name: "Duplicate",
|
|
layout: reusableLayout(),
|
|
createdAt: CREATED_AT,
|
|
}),
|
|
).toThrow("unique");
|
|
expect(() => renameLayoutProfile(original, FIRST_LAYOUT_ID, " ")).toThrow("name");
|
|
});
|
|
|
|
it("renames only the selected profile while preserving its creation time", () => {
|
|
const original = [savedLayout(), savedLayout({ id: SECOND_LAYOUT_ID, name: "Two" })];
|
|
|
|
const next = renameLayoutProfile(original, FIRST_LAYOUT_ID, " Renamed ");
|
|
|
|
expect(next[0]).toEqual({ ...original[0], name: "Renamed" });
|
|
expect(next[1]).toBe(original[1]);
|
|
});
|
|
|
|
it("duplicates a legacy profile as a non-default independent copy", () => {
|
|
const legacyLayout = { columns: LEGACY_LAYOUT_VALUE };
|
|
const original = [savedLayout({ is_default: true, layout: legacyLayout })];
|
|
|
|
const next = duplicateLayoutProfile(original, "layout-one", {
|
|
id: "layout-copy",
|
|
name: "Layout copy",
|
|
createdAt: "2026-07-19T12:00:00.000Z",
|
|
});
|
|
|
|
expect(next[1]).toEqual({
|
|
...original[0],
|
|
id: "layout-copy",
|
|
name: "Layout copy",
|
|
is_default: false,
|
|
created_at: "2026-07-19T12:00:00.000Z",
|
|
});
|
|
expect(next[1].layout).not.toBe(legacyLayout);
|
|
expect(original[0]).toEqual(savedLayout({ is_default: true, layout: legacyLayout }));
|
|
});
|
|
|
|
it("deletes a profile without changing the remaining objects", () => {
|
|
const retained = savedLayout({ id: SECOND_LAYOUT_ID, name: "Two" });
|
|
const original = [savedLayout(), retained];
|
|
|
|
const next = deleteLayoutProfile(original, FIRST_LAYOUT_ID);
|
|
|
|
expect(next).toEqual([retained]);
|
|
expect(next[0]).toBe(retained);
|
|
});
|
|
|
|
it("sets one valid profile as default and can clear the custom default", () => {
|
|
const first = savedLayout({ is_default: true });
|
|
const second = savedLayout({ id: SECOND_LAYOUT_ID, name: "Two" });
|
|
|
|
const selected = setDefaultLayoutProfile([first, second], SECOND_LAYOUT_ID);
|
|
const cleared = setDefaultLayoutProfile(selected, null);
|
|
|
|
expect(selected.map((profile) => profile.is_default)).toEqual([false, true]);
|
|
expect(cleared.map((profile) => profile.is_default)).toEqual([false, false]);
|
|
expect(first.is_default).toBe(true);
|
|
});
|
|
|
|
it("does not make an invalid legacy profile the default", () => {
|
|
const legacy = savedLayout({ layout: { columns: LEGACY_LAYOUT_VALUE } });
|
|
|
|
expect(() => setDefaultLayoutProfile([legacy], legacy.id)).toThrow("reusable");
|
|
});
|
|
});
|