diff --git a/ui/src/ui/app-settings.test.ts b/ui/src/ui/app-settings.test.ts index dd4403e65a2..c7b20510795 100644 --- a/ui/src/ui/app-settings.test.ts +++ b/ui/src/ui/app-settings.test.ts @@ -1,19 +1,85 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - applySettings, - attachThemeListener, - setTabFromRoute, - syncThemeWithSettings, -} from "./app-settings.ts"; -import type { Tab } from "./navigation.ts"; import type { ThemeMode, ThemeName } from "./theme.ts"; -type SettingsHost = Parameters[0] & { +type Tab = + | "agents" + | "overview" + | "channels" + | "instances" + | "sessions" + | "usage" + | "cron" + | "skills" + | "nodes" + | "chat" + | "config" + | "communications" + | "appearance" + | "automation" + | "infrastructure" + | "aiAgents" + | "debug" + | "logs"; + +type AppSettingsModule = typeof import("./app-settings.ts"); + +type SettingsHost = { + settings: { + gatewayUrl: string; + token: string; + sessionKey: string; + lastActiveSessionKey: string; + theme: ThemeName; + themeMode: ThemeMode; + chatFocusMode: boolean; + chatShowThinking: boolean; + splitRatio: number; + navCollapsed: boolean; + navWidth: number; + navGroupsCollapsed: Record; + }; + theme: ThemeName & ThemeMode; themeMode: ThemeMode; + themeResolved: import("./theme.ts").ResolvedTheme; + applySessionKey: string; + sessionKey: string; + tab: Tab; + connected: boolean; + chatHasAutoScrolled: boolean; + logsAtBottom: boolean; + eventLog: unknown[]; + eventLogBuffer: unknown[]; + basePath: string; + themeMedia: MediaQueryList | null; + themeMediaHandler: ((event: MediaQueryListEvent) => void) | null; logsPollInterval: number | null; debugPollInterval: number | null; }; +function createStorageMock(): Storage { + const store = new Map(); + return { + get length() { + return store.size; + }, + clear() { + store.clear(); + }, + getItem(key: string) { + return store.get(key) ?? null; + }, + key(index: number) { + return Array.from(store.keys())[index] ?? null; + }, + removeItem(key: string) { + store.delete(key); + }, + setItem(key: string, value: string) { + store.set(key, String(value)); + }, + }; +} + const createHost = (tab: Tab): SettingsHost => ({ settings: { gatewayUrl: "", @@ -48,37 +114,50 @@ const createHost = (tab: Tab): SettingsHost => ({ }); describe("setTabFromRoute", () => { + let appSettings: AppSettingsModule; + beforeEach(() => { vi.useFakeTimers(); + vi.resetModules(); + vi.stubGlobal("localStorage", createStorageMock()); + vi.stubGlobal("navigator", { language: "en-US" } as Navigator); + vi.stubGlobal("window", { + setInterval, + clearInterval, + } as Window & typeof globalThis); }); afterEach(() => { vi.useRealTimers(); + vi.unstubAllGlobals(); }); - it("starts and stops log polling based on the tab", () => { + it("starts and stops log polling based on the tab", async () => { + appSettings ??= await import("./app-settings.ts"); const host = createHost("chat"); - setTabFromRoute(host, "logs"); + appSettings.setTabFromRoute(host, "logs"); expect(host.logsPollInterval).not.toBeNull(); expect(host.debugPollInterval).toBeNull(); - setTabFromRoute(host, "chat"); + appSettings.setTabFromRoute(host, "chat"); expect(host.logsPollInterval).toBeNull(); }); - it("starts and stops debug polling based on the tab", () => { + it("starts and stops debug polling based on the tab", async () => { + appSettings ??= await import("./app-settings.ts"); const host = createHost("chat"); - setTabFromRoute(host, "debug"); + appSettings.setTabFromRoute(host, "debug"); expect(host.debugPollInterval).not.toBeNull(); expect(host.logsPollInterval).toBeNull(); - setTabFromRoute(host, "chat"); + appSettings.setTabFromRoute(host, "chat"); expect(host.debugPollInterval).toBeNull(); }); - it("re-resolves the active palette when only themeMode changes", () => { + it("re-resolves the active palette when only themeMode changes", async () => { + appSettings ??= await import("./app-settings.ts"); const host = createHost("chat"); host.settings.theme = "knot"; host.settings.themeMode = "dark"; @@ -86,7 +165,7 @@ describe("setTabFromRoute", () => { host.themeMode = "dark"; host.themeResolved = "openknot"; - applySettings(host, { + appSettings.applySettings(host, { ...host.settings, themeMode: "light", }); @@ -96,19 +175,21 @@ describe("setTabFromRoute", () => { expect(host.themeResolved).toBe("openknot-light"); }); - it("syncs both theme family and mode from persisted settings", () => { + it("syncs both theme family and mode from persisted settings", async () => { + appSettings ??= await import("./app-settings.ts"); const host = createHost("chat"); host.settings.theme = "dash"; host.settings.themeMode = "light"; - syncThemeWithSettings(host); + appSettings.syncThemeWithSettings(host); expect(host.theme).toBe("dash"); expect(host.themeMode).toBe("light"); expect(host.themeResolved).toBe("dash-light"); }); - it("applies named system themes on OS preference changes", () => { + it("applies named system themes on OS preference changes", async () => { + appSettings ??= await import("./app-settings.ts"); const listeners: Array<(event: MediaQueryListEvent) => void> = []; const matchMedia = vi.fn().mockReturnValue({ matches: false, @@ -118,12 +199,17 @@ describe("setTabFromRoute", () => { removeEventListener: vi.fn(), }); vi.stubGlobal("matchMedia", matchMedia); + vi.stubGlobal("window", { + setInterval, + clearInterval, + matchMedia, + } as Window & typeof globalThis); const host = createHost("chat"); host.theme = "knot" as unknown as ThemeName & ThemeMode; host.themeMode = "system"; - attachThemeListener(host); + appSettings.attachThemeListener(host); listeners[0]?.({ matches: true } as MediaQueryListEvent); expect(host.themeResolved).toBe("openknot"); diff --git a/ui/src/ui/navigation-groups.test.ts b/ui/src/ui/navigation-groups.test.ts index 2e2bb58e58d..43ee2db66a4 100644 --- a/ui/src/ui/navigation-groups.test.ts +++ b/ui/src/ui/navigation-groups.test.ts @@ -1,9 +1,56 @@ -import { describe, expect, it } from "vitest"; -import { TAB_GROUPS } from "./navigation.ts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type NavigationModule = typeof import("./navigation.ts"); + +function createStorageMock(): Storage { + const store = new Map(); + return { + get length() { + return store.size; + }, + clear() { + store.clear(); + }, + getItem(key: string) { + return store.get(key) ?? null; + }, + key(index: number) { + return Array.from(store.keys())[index] ?? null; + }, + removeItem(key: string) { + store.delete(key); + }, + setItem(key: string, value: string) { + store.set(key, String(value)); + }, + }; +} describe("TAB_GROUPS", () => { + let navigation: NavigationModule; + + beforeEach(async () => { + vi.resetModules(); + vi.stubGlobal("localStorage", createStorageMock()); + vi.stubGlobal("navigator", { language: "en-US" } as Navigator); + navigation = await import("./navigation.ts"); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + it("does not expose unfinished settings slices in the sidebar", () => { - const settings = TAB_GROUPS.find((group) => group.label === "settings"); + const settings = navigation.TAB_GROUPS.find((group) => group.label === "settings"); expect(settings?.tabs).toEqual(["config", "debug", "logs"]); }); + + it("does not route directly into unfinished settings slices", () => { + expect(navigation.tabFromPath("/communications")).toBeNull(); + expect(navigation.tabFromPath("/appearance")).toBeNull(); + expect(navigation.tabFromPath("/automation")).toBeNull(); + expect(navigation.tabFromPath("/infrastructure")).toBeNull(); + expect(navigation.tabFromPath("/ai-agents")).toBeNull(); + expect(navigation.tabFromPath("/config")).toBe("config"); + }); }); diff --git a/ui/src/ui/navigation.ts b/ui/src/ui/navigation.ts index 2945560af62..a721982954e 100644 --- a/ui/src/ui/navigation.ts +++ b/ui/src/ui/navigation.ts @@ -55,7 +55,19 @@ const TAB_PATHS: Record = { logs: "/logs", }; -const PATH_TO_TAB = new Map(Object.entries(TAB_PATHS).map(([tab, path]) => [path, tab as Tab])); +const HIDDEN_SETTINGS_TABS = new Set([ + "communications", + "appearance", + "automation", + "infrastructure", + "aiAgents", +]); + +const PATH_TO_TAB = new Map( + Object.entries(TAB_PATHS) + .filter(([tab]) => !HIDDEN_SETTINGS_TABS.has(tab as Tab)) + .map(([tab, path]) => [path, tab as Tab]), +); export function normalizeBasePath(basePath: string): string { if (!basePath) {