b9eb6ae058
* refactor(web): remove legacy settings migration * fix(web): hydrate portable settings from boot state * fix(web): preserve sidebar sync contracts * fix(web): retain portable settings filters * fix(web): retry sidebar settings writes * fix(web): retain sidebar boot settings * fix(websocket): avoid shutdown teardown leaks * fix(boot): normalize sidebar task preferences * fix(settings): preserve saved views during hydration * fix(jira): avoid duplicate saved-view syncs * fix(jira): preserve saved views on fetch failure * fix(jira): retry saved-view hydration failures * refactor(jira): simplify hydration request cleanup * fix(websocket): close late clients after hub shutdown
288 lines
8.8 KiB
TypeScript
288 lines
8.8 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { produce } from "immer";
|
|
import type { Draft } from "immer";
|
|
import { hydrateState, hydrateUI } from "./hydrator";
|
|
import { defaultUIState } from "@/lib/state/slices/ui/ui-slice";
|
|
import { defaultState, mergeInitialState } from "@/lib/state/default-state";
|
|
import type { AppState } from "@/lib/state/store";
|
|
|
|
function makeDraft(): AppState {
|
|
// hydrateUI only touches UI-slice fields; an empty object cast satisfies
|
|
// the rest without dragging the full AppState shape into this test.
|
|
return { ...defaultUIState } as unknown as AppState;
|
|
}
|
|
|
|
function makeAppDraft(): AppState {
|
|
return structuredClone(defaultState) as AppState;
|
|
}
|
|
|
|
describe("hydrateUI — quick chat name overlay", () => {
|
|
beforeEach(() => {
|
|
window.localStorage.clear();
|
|
});
|
|
|
|
it("overlays a locally-renamed name onto the SSR-provided session name", () => {
|
|
window.localStorage.setItem(
|
|
"kandev.quickChat.names",
|
|
JSON.stringify({ "sess-1": "My custom name" }),
|
|
);
|
|
|
|
const result = produce(makeDraft(), (draft: Draft<AppState>) => {
|
|
hydrateUI(draft, {
|
|
quickChat: {
|
|
isOpen: false,
|
|
activeSessionId: null,
|
|
sessions: [{ sessionId: "sess-1", workspaceId: "ws-1", name: "Agent A - Chat 1" }],
|
|
},
|
|
});
|
|
});
|
|
|
|
expect(result.quickChat.sessions[0].name).toBe("My custom name");
|
|
});
|
|
|
|
it("keeps the SSR-provided name when no local rename exists", () => {
|
|
const result = produce(makeDraft(), (draft: Draft<AppState>) => {
|
|
hydrateUI(draft, {
|
|
quickChat: {
|
|
isOpen: false,
|
|
activeSessionId: null,
|
|
sessions: [{ sessionId: "sess-2", workspaceId: "ws-1", name: "Agent A - Chat 1" }],
|
|
},
|
|
});
|
|
});
|
|
|
|
expect(result.quickChat.sessions[0].name).toBe("Agent A - Chat 1");
|
|
});
|
|
|
|
it("only overlays sessions that have a stored rename, leaving siblings untouched", () => {
|
|
window.localStorage.setItem(
|
|
"kandev.quickChat.names",
|
|
JSON.stringify({ "sess-a": "Renamed A" }),
|
|
);
|
|
|
|
const result = produce(makeDraft(), (draft: Draft<AppState>) => {
|
|
hydrateUI(draft, {
|
|
quickChat: {
|
|
isOpen: false,
|
|
activeSessionId: null,
|
|
sessions: [
|
|
{ sessionId: "sess-a", workspaceId: "ws-1", name: "Original A" },
|
|
{ sessionId: "sess-b", workspaceId: "ws-1", name: "Original B" },
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
expect(result.quickChat.sessions.map((s) => s.name)).toEqual(["Renamed A", "Original B"]);
|
|
});
|
|
|
|
it("clears stale quick chat sessions when the backend returns none", () => {
|
|
const result = produce(makeDraft(), (draft: Draft<AppState>) => {
|
|
draft.quickChat = {
|
|
isOpen: true,
|
|
activeSessionId: "stale-session",
|
|
sessions: [{ sessionId: "stale-session", workspaceId: "ws-1", name: "Stale" }],
|
|
};
|
|
hydrateUI(draft, {
|
|
quickChat: {
|
|
isOpen: false,
|
|
activeSessionId: null,
|
|
sessions: [],
|
|
},
|
|
});
|
|
});
|
|
|
|
expect(result.quickChat.sessions).toEqual([]);
|
|
expect(result.quickChat.isOpen).toBe(false);
|
|
expect(result.quickChat.activeSessionId).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("mergeInitialState — quick chat name overlay", () => {
|
|
beforeEach(() => {
|
|
window.localStorage.clear();
|
|
});
|
|
|
|
it("overlays locally-renamed names during boot payload merge", () => {
|
|
window.localStorage.setItem(
|
|
"kandev.quickChat.names",
|
|
JSON.stringify({ "sess-boot": "Local boot name" }),
|
|
);
|
|
|
|
const result = mergeInitialState({
|
|
quickChat: {
|
|
isOpen: false,
|
|
activeSessionId: null,
|
|
sessions: [
|
|
{ sessionId: "sess-boot", workspaceId: "ws-1", name: "Backend task title" },
|
|
{ sessionId: "sess-other", workspaceId: "ws-1", name: "Other title" },
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(result.quickChat.sessions.map((s) => s.name)).toEqual([
|
|
"Local boot name",
|
|
"Other title",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("mergeInitialState — sidebar views from boot settings", () => {
|
|
it("bridges backend sidebar settings into the UI slice before the store is created", () => {
|
|
const result = mergeInitialState({
|
|
userSettings: {
|
|
sidebarViews: [
|
|
{
|
|
id: "server",
|
|
name: "Server",
|
|
filters: [],
|
|
sort: { key: "state", direction: "asc" },
|
|
group: "none",
|
|
collapsedGroups: [],
|
|
},
|
|
],
|
|
sidebarActiveViewId: "server",
|
|
sidebarDraft: {
|
|
baseViewId: "server",
|
|
filters: [],
|
|
sort: { key: "updatedAt", direction: "desc" },
|
|
group: "workflow",
|
|
},
|
|
loaded: true,
|
|
},
|
|
} as unknown as Partial<AppState>);
|
|
|
|
expect(result.sidebarViews).toMatchObject({
|
|
views: [{ id: "server", name: "Server" }],
|
|
activeViewId: "server",
|
|
draft: { baseViewId: "server", group: "workflow" },
|
|
});
|
|
});
|
|
|
|
it("bridges backend task preferences into the UI slice before the store is created", () => {
|
|
const result = mergeInitialState({
|
|
userSettings: {
|
|
sidebarTaskPrefs: {
|
|
pinnedTaskIds: ["task-1"],
|
|
orderedTaskIds: ["task-2", "task-1"],
|
|
subtaskOrderByParentId: { "task-1": ["subtask-1"] },
|
|
},
|
|
loaded: true,
|
|
},
|
|
} as unknown as Partial<AppState>);
|
|
|
|
expect(result.sidebarTaskPrefs).toMatchObject({
|
|
pinnedTaskIds: ["task-1"],
|
|
orderedTaskIds: ["task-2", "task-1"],
|
|
subtaskOrderByParentId: { "task-1": ["subtask-1"] },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("hydrateState — sidebar views from user settings", () => {
|
|
it("hydrates active view and draft from backend user settings", () => {
|
|
const result = produce(makeAppDraft(), (draft: Draft<AppState>) => {
|
|
draft.sidebarViews.activeViewId = "local";
|
|
hydrateState(draft, {
|
|
userSettings: {
|
|
sidebarViews: [
|
|
{
|
|
id: "server",
|
|
name: "Server",
|
|
filters: [],
|
|
sort: { key: "state", direction: "asc" },
|
|
group: "none",
|
|
collapsedGroups: [],
|
|
},
|
|
],
|
|
sidebarActiveViewId: "server",
|
|
sidebarDraft: {
|
|
baseViewId: "server",
|
|
filters: [],
|
|
sort: { key: "updatedAt", direction: "desc" },
|
|
group: "workflow",
|
|
},
|
|
},
|
|
} as unknown as Partial<AppState>);
|
|
});
|
|
|
|
expect(result.sidebarViews.activeViewId).toBe("server");
|
|
expect(result.sidebarViews.draft).toEqual({
|
|
baseViewId: "server",
|
|
filters: [],
|
|
sort: { key: "updatedAt", direction: "desc" },
|
|
group: "workflow",
|
|
});
|
|
});
|
|
|
|
it("clears stale local draft when backend draft is null", () => {
|
|
const result = produce(makeAppDraft(), (draft: Draft<AppState>) => {
|
|
draft.sidebarViews.draft = {
|
|
baseViewId: "local",
|
|
filters: [],
|
|
sort: { key: "state", direction: "asc" },
|
|
group: "state",
|
|
};
|
|
hydrateState(draft, {
|
|
userSettings: {
|
|
sidebarDraft: null,
|
|
},
|
|
} as unknown as Partial<AppState>);
|
|
});
|
|
|
|
expect(result.sidebarViews.draft).toBeNull();
|
|
});
|
|
|
|
it("hydrates sidebar task prefs from backend, including explicit clears", () => {
|
|
const result = produce(makeAppDraft(), (draft: Draft<AppState>) => {
|
|
draft.sidebarTaskPrefs = {
|
|
pinnedTaskIds: ["local-pin"],
|
|
orderedTaskIds: ["local-order"],
|
|
subtaskOrderByParentId: { parent: ["child"] },
|
|
};
|
|
hydrateState(draft, {
|
|
userSettings: {
|
|
sidebarTaskPrefs: {
|
|
pinnedTaskIds: [],
|
|
orderedTaskIds: [],
|
|
subtaskOrderByParentId: {},
|
|
},
|
|
},
|
|
} as unknown as Partial<AppState>);
|
|
});
|
|
|
|
expect(result.sidebarTaskPrefs).toEqual({
|
|
pinnedTaskIds: [],
|
|
orderedTaskIds: [],
|
|
subtaskOrderByParentId: {},
|
|
});
|
|
});
|
|
|
|
it("uses backend sidebar task prefs as the authoritative hydrated value", () => {
|
|
const result = produce(makeAppDraft(), (draft: Draft<AppState>) => {
|
|
draft.sidebarTaskPrefs = {
|
|
pinnedTaskIds: ["local-pin"],
|
|
orderedTaskIds: ["local-order"],
|
|
subtaskOrderByParentId: { shared: ["local-child"], localOnly: ["child"] },
|
|
syncError: "retry",
|
|
};
|
|
hydrateState(draft, {
|
|
userSettings: {
|
|
sidebarTaskPrefs: {
|
|
pinnedTaskIds: ["server-pin"],
|
|
orderedTaskIds: ["server-order"],
|
|
subtaskOrderByParentId: { shared: ["server-child"], serverOnly: ["child"] },
|
|
},
|
|
},
|
|
} as unknown as Partial<AppState>);
|
|
});
|
|
|
|
expect(result.sidebarTaskPrefs).toEqual({
|
|
pinnedTaskIds: ["server-pin"],
|
|
orderedTaskIds: ["server-order"],
|
|
subtaskOrderByParentId: { shared: ["server-child"], serverOnly: ["child"] },
|
|
syncError: "retry",
|
|
});
|
|
});
|
|
});
|