From d98628ae6a2297669ada538ac7e048d5d8db115e Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Thu, 12 Mar 2026 06:15:47 -0500 Subject: [PATCH] chore: remove test files to reduce PR scope --- .../config.telegram-custom-commands.test.ts | 19 - test/ui-chat-regressions.test.ts | 359 ------------------ ui/src/ui/agent-files.browser.test.ts | 52 --- ui/src/ui/app-render.helpers.node.test.ts | 71 +--- ui/src/ui/app-settings.test.ts | 195 ++++++++-- ui/src/ui/app.test.ts | 9 - ui/src/ui/chat-composer.browser.test.ts | 62 --- .../chat/slash-command-executor.node.test.ts | 312 ++++++++++++++- ui/src/ui/command-palette.browser.test.ts | 37 -- ui/src/ui/config-form.browser.test.ts | 107 +----- ui/src/ui/config-layout.browser.test.ts | 132 ------- ui/src/ui/controllers/agents.test.ts | 66 ---- ui/src/ui/controllers/config.test.ts | 44 --- ui/src/ui/icon-layout.browser.test.ts | 67 ---- ui/src/ui/login-gate.browser.test.ts | 40 -- ui/src/ui/nav-collapsed.browser.test.ts | 60 --- ui/src/ui/navigation.browser.test.ts | 14 - ui/src/ui/storage.node.test.ts | 214 +++++------ ui/src/ui/test-helpers/app-mount.ts | 16 - ui/src/ui/theme-orb.browser.test.ts | 27 -- ui/src/ui/topbar-theme-mode.browser.test.ts | 30 -- ui/src/ui/views/chat.test.ts | 20 +- ui/src/ui/views/config.browser.test.ts | 125 +----- ui/src/ui/views/overview.node.test.ts | 110 ++---- ui/src/ui/views/sessions.test.ts | 11 - 25 files changed, 625 insertions(+), 1574 deletions(-) delete mode 100644 test/ui-chat-regressions.test.ts delete mode 100644 ui/src/ui/agent-files.browser.test.ts delete mode 100644 ui/src/ui/app.test.ts delete mode 100644 ui/src/ui/chat-composer.browser.test.ts delete mode 100644 ui/src/ui/command-palette.browser.test.ts delete mode 100644 ui/src/ui/config-layout.browser.test.ts delete mode 100644 ui/src/ui/icon-layout.browser.test.ts delete mode 100644 ui/src/ui/login-gate.browser.test.ts delete mode 100644 ui/src/ui/nav-collapsed.browser.test.ts delete mode 100644 ui/src/ui/theme-orb.browser.test.ts delete mode 100644 ui/src/ui/topbar-theme-mode.browser.test.ts diff --git a/src/config/config.telegram-custom-commands.test.ts b/src/config/config.telegram-custom-commands.test.ts index e914f55fc24..27ff0450220 100644 --- a/src/config/config.telegram-custom-commands.test.ts +++ b/src/config/config.telegram-custom-commands.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; import { OpenClawSchema } from "./zod-schema.js"; -import { TelegramConfigSchema } from "./zod-schema.providers-core.js"; describe("telegram custom commands schema", () => { it("normalizes custom commands", () => { @@ -40,22 +39,4 @@ describe("telegram custom commands schema", () => { { command: "bad_name", description: "Override status" }, ]); }); - - it("emits string-typed custom command fields in JSON schema", () => { - const jsonSchema = TelegramConfigSchema.toJSONSchema({ - target: "draft-07", - unrepresentable: "any", - }) as { - properties?: { - customCommands?: { - items?: { properties?: Record }; - }; - }; - }; - - expect(jsonSchema.properties?.customCommands?.items?.properties?.command?.type).toBe("string"); - expect(jsonSchema.properties?.customCommands?.items?.properties?.description?.type).toBe( - "string", - ); - }); }); diff --git a/test/ui-chat-regressions.test.ts b/test/ui-chat-regressions.test.ts deleted file mode 100644 index 62376ecdf6a..00000000000 --- a/test/ui-chat-regressions.test.ts +++ /dev/null @@ -1,359 +0,0 @@ -import { parseHTML } from "linkedom"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ChatHost } from "../ui/src/ui/app-chat.ts"; -import { - CHAT_ATTACHMENT_ACCEPT, - isSupportedChatAttachmentMimeType, -} from "../ui/src/ui/chat/attachment-support.ts"; -import { DeletedMessages } from "../ui/src/ui/chat/deleted-messages.ts"; -import { buildChatMarkdown } from "../ui/src/ui/chat/export.ts"; -import { getPinnedMessageSummary } from "../ui/src/ui/chat/pinned-summary.ts"; -import { messageMatchesSearchQuery } from "../ui/src/ui/chat/search-match.ts"; -import { - MAX_CACHED_CHAT_SESSIONS, - getOrCreateSessionCacheValue, -} from "../ui/src/ui/chat/session-cache.ts"; -import type { GatewayBrowserClient } from "../ui/src/ui/gateway.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)); - }, - }; -} - -function createHost(overrides: Partial = {}): ChatHost & Record { - return { - client: { - request: vi.fn(), - } as unknown as GatewayBrowserClient, - chatMessages: [{ role: "assistant", content: "existing", timestamp: 1 }], - chatStream: "streaming", - connected: true, - chatMessage: "", - chatAttachments: [], - chatQueue: [], - chatRunId: "run-1", - chatSending: false, - lastError: null, - sessionKey: "main", - basePath: "", - hello: null, - chatAvatarUrl: null, - refreshSessionsAfterChat: new Set(), - updateComplete: Promise.resolve(), - querySelector: () => null, - style: { setProperty: () => undefined } as CSSStyleDeclaration, - chatScrollFrame: null, - chatScrollTimeout: null, - chatHasAutoScrolled: false, - chatUserNearBottom: true, - chatNewMessagesBelow: false, - logsScrollFrame: null, - logsAtBottom: true, - topbarObserver: null, - ...overrides, - }; -} - -function createSettingsHost() { - return { - settings: { - gatewayUrl: "", - token: "", - sessionKey: "main", - lastActiveSessionKey: "main", - theme: "claw", - themeMode: "system", - chatFocusMode: false, - chatShowThinking: true, - splitRatio: 0.6, - navCollapsed: false, - navWidth: 220, - navGroupsCollapsed: {}, - }, - theme: "claw", - themeMode: "system", - themeResolved: "dark", - applySessionKey: "main", - sessionKey: "main", - tab: "chat", - connected: false, - chatHasAutoScrolled: false, - logsAtBottom: false, - eventLog: [], - eventLogBuffer: [], - basePath: "", - systemThemeCleanup: null, - } as Record; -} - -beforeEach(() => { - vi.resetModules(); - const { window, document } = parseHTML(""); - vi.stubGlobal("localStorage", createStorageMock()); - vi.stubGlobal("sessionStorage", createStorageMock()); - vi.stubGlobal("window", window as unknown as Window & typeof globalThis); - vi.stubGlobal("document", document as unknown as Document); - vi.stubGlobal("location", { - protocol: "https:", - host: "gateway.example:8443", - pathname: "/", - } as Location); - vi.stubGlobal("customElements", window.customElements); - vi.stubGlobal("HTMLElement", window.HTMLElement); - vi.stubGlobal("Element", window.Element); - vi.stubGlobal("Node", window.Node); - vi.stubGlobal("DocumentFragment", window.DocumentFragment); - vi.stubGlobal("navigator", { language: "en-US" } as Navigator); - Object.defineProperty(window, "matchMedia", { - value: () => ({ matches: false }), - configurable: true, - }); - vi.stubGlobal("requestAnimationFrame", ((cb: FrameRequestCallback) => { - cb(0); - return 1; - }) as typeof requestAnimationFrame); - vi.stubGlobal("cancelAnimationFrame", (() => undefined) as typeof cancelAnimationFrame); - vi.stubGlobal("getComputedStyle", (() => ({ overflowY: "auto" })) as typeof getComputedStyle); -}); - -afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); - -describe("chat regressions", () => { - it("keeps the picker image-only", () => { - expect(CHAT_ATTACHMENT_ACCEPT).toBe("image/*"); - expect(isSupportedChatAttachmentMimeType("image/png")).toBe(true); - expect(isSupportedChatAttachmentMimeType("application/pdf")).toBe(false); - expect(isSupportedChatAttachmentMimeType("text/plain")).toBe(false); - }); - - it("summarizes pinned messages from structured content blocks", () => { - expect( - getPinnedMessageSummary({ - role: "assistant", - content: [{ type: "text", text: "hello from structured content" }], - }), - ).toBe("hello from structured content"); - }); - - it("degrades gracefully when deleted-message persistence cannot write", () => { - const failingStorage = createStorageMock(); - vi.spyOn(failingStorage, "setItem").mockImplementation(() => { - throw new Error("quota exceeded"); - }); - vi.stubGlobal("localStorage", failingStorage); - - const deleted = new DeletedMessages("main"); - expect(() => deleted.delete("msg-1")).not.toThrow(); - expect(() => deleted.restore("msg-1")).not.toThrow(); - expect(() => deleted.clear()).not.toThrow(); - }); - - it("exports structured message content instead of blank blocks", () => { - const markdown = buildChatMarkdown( - [ - { - role: "user", - content: [{ type: "text", text: "hello there" }], - timestamp: Date.UTC(2026, 2, 10, 12, 0, 0), - }, - { - role: "assistant", - content: [{ type: "text", text: "general kenobi" }], - timestamp: Date.UTC(2026, 2, 10, 12, 0, 5), - }, - ], - "OpenClaw", - ); - - expect(markdown).toContain("hello there"); - expect(markdown).toContain("general kenobi"); - }); - - it("matches chat search against extracted structured message text", () => { - expect( - messageMatchesSearchQuery( - { - role: "assistant", - content: [{ type: "text", text: "Structured search target" }], - }, - "search target", - ), - ).toBe(true); - expect( - messageMatchesSearchQuery( - { - role: "assistant", - content: [{ type: "text", text: "Structured search target" }], - }, - "missing", - ), - ).toBe(false); - }); - - it("bounds cached per-session chat state", () => { - const cache = new Map(); - for (let i = 0; i < MAX_CACHED_CHAT_SESSIONS; i++) { - getOrCreateSessionCacheValue(cache, `session-${i}`, () => i); - } - - expect(cache.size).toBe(MAX_CACHED_CHAT_SESSIONS); - expect(getOrCreateSessionCacheValue(cache, "session-0", () => -1)).toBe(0); - - getOrCreateSessionCacheValue(cache, `session-${MAX_CACHED_CHAT_SESSIONS}`, () => 99); - - expect(cache.size).toBe(MAX_CACHED_CHAT_SESSIONS); - expect(cache.has("session-0")).toBe(true); - expect(cache.has("session-1")).toBe(false); - }); - - it("keeps the command palette in sync with slash commands", async () => { - const { getPaletteItems } = await import("../ui/src/ui/views/command-palette.ts"); - const labels = getPaletteItems().map((item) => item.label); - - expect(labels).toContain("/agents"); - expect(labels).toContain("/clear"); - expect(labels).toContain("/kill"); - expect(labels).toContain("/skill"); - expect(labels).toContain("/steer"); - }); - - it("falls back to addListener/removeListener for system theme changes", async () => { - const { attachThemeListener, detachThemeListener } = - await import("../ui/src/ui/app-settings.ts"); - const host = createSettingsHost(); - const addListener = vi.fn(); - const removeListener = vi.fn(); - - vi.stubGlobal( - "matchMedia", - vi.fn(() => ({ - matches: false, - addListener, - removeListener, - })), - ); - - attachThemeListener(host); - expect(addListener).toHaveBeenCalledTimes(1); - - detachThemeListener(host); - expect(removeListener).toHaveBeenCalledTimes(1); - }); - - it("queues local slash commands that would mutate session state during an active run", async () => { - const { handleSendChat } = await import("../ui/src/ui/app-chat.ts"); - const request = vi.fn(); - const host = createHost({ - client: { request } as unknown as GatewayBrowserClient, - chatMessage: "/new", - chatRunId: "run-1", - chatSending: false, - }); - - await handleSendChat(host); - - expect(request).not.toHaveBeenCalled(); - expect(host.chatMessage).toBe(""); - expect(host.chatQueue).toHaveLength(1); - expect(host.chatQueue[0]?.text).toBe("/new"); - expect(host.chatQueue[0]?.refreshSessions).toBe(true); - }); - - it("replays queued local slash commands through the local dispatch path", async () => { - const { flushChatQueueForEvent, handleSendChat } = await import("../ui/src/ui/app-chat.ts"); - const request = vi.fn(async (method: string, payload?: unknown) => { - if (method === "sessions.reset") { - expect(payload).toEqual({ key: "main" }); - return { ok: true }; - } - if (method === "chat.history") { - expect(payload).toEqual({ sessionKey: "main", limit: 200 }); - return { messages: [], thinkingLevel: null }; - } - throw new Error(`unexpected method: ${method}`); - }); - const host = createHost({ - client: { request } as unknown as GatewayBrowserClient, - chatMessage: "/clear", - chatRunId: "run-1", - chatSending: false, - }); - - await handleSendChat(host); - expect(host.chatQueue).toHaveLength(1); - - host.chatRunId = null; - await flushChatQueueForEvent(host); - - expect(request).toHaveBeenNthCalledWith(1, "sessions.reset", { key: "main" }); - expect(request).toHaveBeenNthCalledWith(2, "chat.history", { - sessionKey: "main", - limit: 200, - }); - expect(host.chatQueue).toEqual([]); - expect(host.chatMessages).toEqual([]); - }); - - it("resets persisted history for /clear", async () => { - const { handleSendChat } = await import("../ui/src/ui/app-chat.ts"); - const request = vi.fn(async (method: string, payload?: unknown) => { - if (method === "sessions.reset") { - expect(payload).toEqual({ key: "main" }); - return { ok: true }; - } - if (method === "chat.history") { - expect(payload).toEqual({ sessionKey: "main", limit: 200 }); - return { messages: [], thinkingLevel: null }; - } - throw new Error(`unexpected method: ${method}`); - }); - const host = createHost({ - client: { request } as unknown as GatewayBrowserClient, - chatMessage: "/clear", - chatRunId: null, - }); - - await handleSendChat(host); - - expect(request).toHaveBeenNthCalledWith(1, "sessions.reset", { key: "main" }); - expect(request).toHaveBeenNthCalledWith(2, "chat.history", { - sessionKey: "main", - limit: 200, - }); - expect(host.chatMessage).toBe(""); - expect(host.chatMessages).toEqual([]); - expect(host.chatRunId).toBeNull(); - expect(host.chatStream).toBeNull(); - }); - - it("initializes the app with a generated client instance id", async () => { - const { OpenClawApp } = await import("../ui/src/ui/app.ts"); - - const app = new OpenClawApp(); - - expect(typeof app.clientInstanceId).toBe("string"); - expect(app.clientInstanceId.length).toBeGreaterThan(0); - }); -}); diff --git a/ui/src/ui/agent-files.browser.test.ts b/ui/src/ui/agent-files.browser.test.ts deleted file mode 100644 index 4307955d6e1..00000000000 --- a/ui/src/ui/agent-files.browser.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import "../styles.css"; -import { render } from "lit"; -import { describe, expect, it } from "vitest"; -import { renderAgentFiles } from "./views/agents-panels-status-files.ts"; - -describe("agent files editor styling", () => { - it("uses a taller editor and blurs file contents by default", () => { - const container = document.createElement("div"); - document.body.append(container); - render( - renderAgentFiles({ - agentId: "agent-1", - agentFilesList: { - agentId: "agent-1", - workspace: "/tmp/agent-1", - files: [ - { - name: "AGENTS.md", - path: "/tmp/agent-1/AGENTS.md", - size: 128, - updatedAtMs: Date.now(), - missing: false, - }, - ], - }, - agentFilesLoading: false, - agentFilesError: null, - agentFileActive: "AGENTS.md", - agentFileContents: { "AGENTS.md": "# AGENTS.md" }, - agentFileDrafts: { "AGENTS.md": "# AGENTS.md" }, - agentFileSaving: false, - onLoadFiles: () => {}, - onSelectFile: () => {}, - onFileDraftChange: () => {}, - onFileReset: () => {}, - onFileSave: () => {}, - }), - container, - ); - - const field = container.querySelector(".agent-file-field"); - const textarea = container.querySelector(".agent-file-textarea"); - const fieldMinHeight = Number.parseFloat(getComputedStyle(field!).minHeight); - const textareaMinHeight = Number.parseFloat(getComputedStyle(textarea!).minHeight); - - expect(field).not.toBeNull(); - expect(textarea).not.toBeNull(); - expect(fieldMinHeight).toBeGreaterThanOrEqual(320); - expect(textareaMinHeight).toBeGreaterThanOrEqual(320); - expect(getComputedStyle(textarea!).filter).toContain("blur"); - }); -}); diff --git a/ui/src/ui/app-render.helpers.node.test.ts b/ui/src/ui/app-render.helpers.node.test.ts index 49b74a5932f..72f39209be3 100644 --- a/ui/src/ui/app-render.helpers.node.test.ts +++ b/ui/src/ui/app-render.helpers.node.test.ts @@ -1,29 +1,9 @@ -import { describe, expect, it, vi } from "vitest"; - -vi.hoisted(() => { - const storage = new Map(); - Object.defineProperty(globalThis, "localStorage", { - configurable: true, - value: { - getItem: (key: string) => storage.get(key) ?? null, - setItem: (key: string, value: string) => storage.set(key, value), - removeItem: (key: string) => storage.delete(key), - clear: () => storage.clear(), - }, - }); - Object.defineProperty(globalThis, "navigator", { - configurable: true, - value: { language: "en-US" }, - }); - return {}; -}); +import { describe, expect, it } from "vitest"; import { isCronSessionKey, parseSessionKey, - resolveSessionOptionGroups, resolveSessionDisplayName, } from "./app-render.helpers.ts"; -import type { AppViewState } from "./app-view-state.ts"; import type { SessionsListResult } from "./types.ts"; type SessionRow = SessionsListResult["sessions"][number]; @@ -32,14 +12,6 @@ function row(overrides: Partial & { key: string }): SessionRow { return { kind: "direct", updatedAt: 0, ...overrides }; } -function testState(overrides: Partial = {}): AppViewState { - return { - agentsList: null, - sessionsHideCron: true, - ...overrides, - } as AppViewState; -} - /* ================================================================ * parseSessionKey – low-level key → type / fallback mapping * ================================================================ */ @@ -312,44 +284,3 @@ describe("isCronSessionKey", () => { expect(isCronSessionKey("agent:main:slack:cron:job:run:uuid")).toBe(false); }); }); - -describe("resolveSessionOptionGroups", () => { - const sessions: SessionsListResult = { - ts: 0, - path: "", - count: 3, - defaults: { model: null, contextTokens: null }, - sessions: [ - row({ key: "agent:main:main" }), - row({ key: "agent:main:cron:daily" }), - row({ key: "agent:main:discord:direct:user-1" }), - ], - }; - - it("filters cron sessions from options when the hide toggle is enabled", () => { - const groups = resolveSessionOptionGroups( - testState({ sessionsHideCron: true }), - "agent:main:main", - sessions, - ); - - expect(groups.flatMap((group) => group.options.map((option) => option.key))).toEqual([ - "agent:main:main", - "agent:main:discord:direct:user-1", - ]); - }); - - it("retains the active cron session even when cron sessions are hidden", () => { - const groups = resolveSessionOptionGroups( - testState({ sessionsHideCron: true }), - "agent:main:cron:daily", - sessions, - ); - - expect(groups.flatMap((group) => group.options.map((option) => option.key))).toEqual([ - "agent:main:main", - "agent:main:cron:daily", - "agent:main:discord:direct:user-1", - ]); - }); -}); diff --git a/ui/src/ui/app-settings.test.ts b/ui/src/ui/app-settings.test.ts index d1dcf7a9ab5..08c939403ea 100644 --- a/ui/src/ui/app-settings.test.ts +++ b/ui/src/ui/app-settings.test.ts @@ -1,12 +1,85 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { attachThemeListener, detachThemeListener, setTabFromRoute } 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: "", @@ -22,7 +95,7 @@ const createHost = (tab: Tab): SettingsHost => ({ navWidth: 220, navGroupsCollapsed: {}, }, - theme: "claw", + theme: "claw" as unknown as ThemeName & ThemeMode, themeMode: "system", themeResolved: "dark", applySessionKey: "main", @@ -34,59 +107,129 @@ const createHost = (tab: Tab): SettingsHost => ({ eventLog: [], eventLogBuffer: [], basePath: "", + themeMedia: null, + themeMediaHandler: null, logsPollInterval: null, debugPollInterval: null, - systemThemeCleanup: null, }); 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 unknown 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("falls back to addListener/removeListener for older MediaQueryList implementations", () => { + it("re-resolves the active palette when only themeMode changes", async () => { + appSettings ??= await import("./app-settings.ts"); const host = createHost("chat"); - const addListener = vi.fn(); - const removeListener = vi.fn(); - vi.stubGlobal( - "matchMedia", - vi.fn(() => ({ - matches: false, - addListener, - removeListener, - })), - ); + host.settings.theme = "knot"; + host.settings.themeMode = "dark"; + host.theme = "knot" as unknown as ThemeName & ThemeMode; + host.themeMode = "dark"; + host.themeResolved = "openknot"; - attachThemeListener(host); - expect(addListener).toHaveBeenCalledTimes(1); + appSettings.applySettings(host, { + ...host.settings, + themeMode: "light", + }); - detachThemeListener(host); - expect(removeListener).toHaveBeenCalledTimes(1); + expect(host.theme).toBe("knot"); + expect(host.themeMode).toBe("light"); + expect(host.themeResolved).toBe("openknot-light"); + }); + + 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"; + + 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", async () => { + appSettings ??= await import("./app-settings.ts"); + const listeners: Array<(event: MediaQueryListEvent) => void> = []; + const matchMedia = vi.fn().mockReturnValue({ + matches: false, + addEventListener: (_name: string, handler: (event: MediaQueryListEvent) => void) => { + listeners.push(handler); + }, + removeEventListener: vi.fn(), + }); + vi.stubGlobal("matchMedia", matchMedia); + vi.stubGlobal("window", { + setInterval, + clearInterval, + matchMedia, + } as unknown as Window & typeof globalThis); + + const host = createHost("chat"); + host.theme = "knot" as unknown as ThemeName & ThemeMode; + host.themeMode = "system"; + + appSettings.attachThemeListener(host); + listeners[0]?.({ matches: true } as MediaQueryListEvent); + expect(host.themeResolved).toBe("openknot"); + + listeners[0]?.({ matches: false } as MediaQueryListEvent); + expect(host.themeResolved).toBe("openknot-light"); + }); + + it("normalizes light family themes to the shared light CSS token", async () => { + appSettings ??= await import("./app-settings.ts"); + const root = { + dataset: {} as DOMStringMap, + style: { colorScheme: "" } as CSSStyleDeclaration & { colorScheme: string }, + }; + vi.stubGlobal("document", { documentElement: root } as Document); + + const host = createHost("chat"); + appSettings.applyResolvedTheme(host, "dash-light"); + + expect(host.themeResolved).toBe("dash-light"); + expect(root.dataset.theme).toBe("light"); + expect(root.style.colorScheme).toBe("light"); }); }); diff --git a/ui/src/ui/app.test.ts b/ui/src/ui/app.test.ts deleted file mode 100644 index 3be6a1fb653..00000000000 --- a/ui/src/ui/app.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { describe, expect, it } from "vitest"; - -describe("OpenClawApp", () => { - it("constructs with a generated client instance id", async () => { - const { OpenClawApp } = await import("./app.ts"); - const app = new OpenClawApp(); - expect(app.clientInstanceId).toMatch(/^[0-9a-f-]{36}$/i); - }); -}); diff --git a/ui/src/ui/chat-composer.browser.test.ts b/ui/src/ui/chat-composer.browser.test.ts deleted file mode 100644 index 8067204d8a9..00000000000 --- a/ui/src/ui/chat-composer.browser.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import "../styles.css"; -import { render } from "lit"; -import { describe, expect, it } from "vitest"; -import { renderChat, type ChatProps } from "./views/chat.ts"; - -function makeProps(): ChatProps { - return { - sessionKey: "main:test", - onSessionKeyChange: () => {}, - thinkingLevel: null, - showThinking: false, - loading: false, - sending: false, - messages: [], - toolMessages: [], - streamSegments: [], - stream: null, - streamStartedAt: null, - draft: "", - queue: [], - connected: true, - canSend: true, - disabledReason: null, - error: null, - sessions: null, - focusMode: false, - assistantName: "Nova", - assistantAvatar: null, - onRefresh: () => {}, - onToggleFocusMode: () => {}, - onDraftChange: () => {}, - onSend: () => {}, - onQueueRemove: () => {}, - onNewSession: () => {}, - agentsList: null, - currentAgentId: "default", - onAgentChange: () => {}, - }; -} - -describe("chat composer styling", () => { - it("hides the native file input and renders the unified composer shell", () => { - const container = document.createElement("div"); - document.body.append(container); - render(renderChat(makeProps()), container); - - const composer = container.querySelector(".agent-chat__input"); - const fileInput = container.querySelector(".agent-chat__file-input"); - const textarea = container.querySelector(".agent-chat__input > textarea"); - const sendButton = container.querySelector(".chat-send-btn"); - - expect(composer).not.toBeNull(); - expect(fileInput).not.toBeNull(); - expect(textarea).not.toBeNull(); - expect(sendButton).not.toBeNull(); - expect(getComputedStyle(composer!).display).toBe("flex"); - expect(getComputedStyle(composer!).flexDirection).toBe("column"); - expect(getComputedStyle(fileInput!).display).toBe("none"); - expect(Number.parseFloat(getComputedStyle(textarea!).minHeight)).toBeGreaterThanOrEqual(40); - expect(getComputedStyle(sendButton!).width).toBe("32px"); - }); -}); diff --git a/ui/src/ui/chat/slash-command-executor.node.test.ts b/ui/src/ui/chat/slash-command-executor.node.test.ts index 706bfed0c3c..ca30fdc54d5 100644 --- a/ui/src/ui/chat/slash-command-executor.node.test.ts +++ b/ui/src/ui/chat/slash-command-executor.node.test.ts @@ -3,11 +3,13 @@ import type { GatewayBrowserClient } from "../gateway.ts"; import type { GatewaySessionRow } from "../types.ts"; import { executeSlashCommand } from "./slash-command-executor.ts"; -function row(key: string): GatewaySessionRow { +function row(key: string, overrides?: Partial): GatewaySessionRow { return { key, + spawnedBy: overrides?.spawnedBy, kind: "direct", updatedAt: null, + ...overrides, }; } @@ -18,8 +20,11 @@ describe("executeSlashCommand /kill", () => { return { sessions: [ row("main"), - row("agent:main:subagent:one"), - row("agent:main:subagent:parent:subagent:child"), + row("agent:main:subagent:one", { spawnedBy: "main" }), + row("agent:main:subagent:parent", { spawnedBy: "main" }), + row("agent:main:subagent:parent:subagent:child", { + spawnedBy: "agent:main:subagent:parent", + }), row("agent:other:main"), ], }; @@ -37,12 +42,15 @@ describe("executeSlashCommand /kill", () => { "all", ); - expect(result.content).toBe("Aborted 2 sub-agent sessions."); + expect(result.content).toBe("Aborted 3 sub-agent sessions."); expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); expect(request).toHaveBeenNthCalledWith(2, "chat.abort", { sessionKey: "agent:main:subagent:one", }); expect(request).toHaveBeenNthCalledWith(3, "chat.abort", { + sessionKey: "agent:main:subagent:parent", + }); + expect(request).toHaveBeenNthCalledWith(4, "chat.abort", { sessionKey: "agent:main:subagent:parent:subagent:child", }); }); @@ -52,9 +60,9 @@ describe("executeSlashCommand /kill", () => { if (method === "sessions.list") { return { sessions: [ - row("agent:main:subagent:one"), - row("agent:main:subagent:two"), - row("agent:other:subagent:three"), + row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }), + row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }), + row("agent:other:subagent:three", { spawnedBy: "agent:other:main" }), ], }; } @@ -80,4 +88,294 @@ describe("executeSlashCommand /kill", () => { sessionKey: "agent:main:subagent:two", }); }); + + it("does not exact-match a session key outside the current subagent subtree", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("agent:main:subagent:parent", { spawnedBy: "agent:main:main" }), + row("agent:main:subagent:parent:subagent:child", { + spawnedBy: "agent:main:subagent:parent", + }), + row("agent:main:subagent:sibling", { spawnedBy: "agent:main:main" }), + ], + }; + } + if (method === "chat.abort") { + return { ok: true, aborted: true }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:subagent:parent", + "kill", + "agent:main:subagent:sibling", + ); + + expect(result.content).toBe( + "No matching sub-agent sessions found for `agent:main:subagent:sibling`.", + ); + expect(request).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); + }); + + it("returns a no-op summary when matching sessions have no active runs", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }), + row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }), + ], + }; + } + if (method === "chat.abort") { + return { ok: true, aborted: false }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "kill", + "all", + ); + + expect(result.content).toBe("No active sub-agent runs to abort."); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); + expect(request).toHaveBeenNthCalledWith(2, "chat.abort", { + sessionKey: "agent:main:subagent:one", + }); + expect(request).toHaveBeenNthCalledWith(3, "chat.abort", { + sessionKey: "agent:main:subagent:two", + }); + }); + + it("treats the legacy main session key as the default agent scope", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("main"), + row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }), + row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }), + row("agent:other:subagent:three", { spawnedBy: "agent:other:main" }), + ], + }; + } + if (method === "chat.abort") { + return { ok: true, aborted: true }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "main", + "kill", + "all", + ); + + expect(result.content).toBe("Aborted 2 sub-agent sessions."); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); + expect(request).toHaveBeenNthCalledWith(2, "chat.abort", { + sessionKey: "agent:main:subagent:one", + }); + expect(request).toHaveBeenNthCalledWith(3, "chat.abort", { + sessionKey: "agent:main:subagent:two", + }); + }); + + it("does not abort unrelated same-agent subagents from another root session", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("agent:main:main"), + row("agent:main:subagent:mine", { spawnedBy: "agent:main:main" }), + row("agent:main:subagent:mine:subagent:child", { + spawnedBy: "agent:main:subagent:mine", + }), + row("agent:main:subagent:other-root", { + spawnedBy: "agent:main:discord:dm:alice", + }), + ], + }; + } + if (method === "chat.abort") { + return { ok: true, aborted: true }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "kill", + "all", + ); + + expect(result.content).toBe("Aborted 2 sub-agent sessions."); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); + expect(request).toHaveBeenNthCalledWith(2, "chat.abort", { + sessionKey: "agent:main:subagent:mine", + }); + expect(request).toHaveBeenNthCalledWith(3, "chat.abort", { + sessionKey: "agent:main:subagent:mine:subagent:child", + }); + }); +}); + +describe("executeSlashCommand directives", () => { + it("resolves the legacy main alias for bare /model", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + defaults: { model: "default-model" }, + sessions: [ + row("agent:main:main", { + model: "gpt-4.1-mini", + }), + ], + }; + } + if (method === "models.list") { + return { + models: [{ id: "gpt-4.1-mini" }, { id: "gpt-4.1" }], + }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "main", + "model", + "", + ); + + expect(result.content).toBe( + "**Current model:** `gpt-4.1-mini`\n**Available:** `gpt-4.1-mini`, `gpt-4.1`", + ); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); + expect(request).toHaveBeenNthCalledWith(2, "models.list", {}); + }); + + it("resolves the legacy main alias for /usage", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("agent:main:main", { + model: "gpt-4.1-mini", + inputTokens: 1200, + outputTokens: 300, + totalTokens: 1500, + contextTokens: 4000, + }), + ], + }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "main", + "usage", + "", + ); + + expect(result.content).toBe( + "**Session Usage**\nInput: **1.2k** tokens\nOutput: **300** tokens\nTotal: **1.5k** tokens\nContext: **30%** of 4k\nModel: `gpt-4.1-mini`", + ); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); + }); + + it("reports the current thinking level for bare /think", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("agent:main:main", { + modelProvider: "openai", + model: "gpt-4.1-mini", + }), + ], + }; + } + if (method === "models.list") { + return { + models: [{ id: "gpt-4.1-mini", provider: "openai", reasoning: true }], + }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "think", + "", + ); + + expect(result.content).toBe( + "Current thinking level: low.\nOptions: off, minimal, low, medium, high, adaptive.", + ); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); + expect(request).toHaveBeenNthCalledWith(2, "models.list", {}); + }); + + it("accepts minimal and xhigh thinking levels", async () => { + const request = vi.fn().mockResolvedValueOnce({ ok: true }).mockResolvedValueOnce({ ok: true }); + + const minimal = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "think", + "minimal", + ); + const xhigh = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "think", + "xhigh", + ); + + expect(minimal.content).toBe("Thinking level set to **minimal**."); + expect(xhigh.content).toBe("Thinking level set to **xhigh**."); + expect(request).toHaveBeenNthCalledWith(1, "sessions.patch", { + key: "agent:main:main", + thinkingLevel: "minimal", + }); + expect(request).toHaveBeenNthCalledWith(2, "sessions.patch", { + key: "agent:main:main", + thinkingLevel: "xhigh", + }); + }); + + it("reports the current verbose level for bare /verbose", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [row("agent:main:main", { verboseLevel: "full" })], + }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "verbose", + "", + ); + + expect(result.content).toBe("Current verbose level: full.\nOptions: on, full, off."); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); + }); }); diff --git a/ui/src/ui/command-palette.browser.test.ts b/ui/src/ui/command-palette.browser.test.ts deleted file mode 100644 index 85e84485d1a..00000000000 --- a/ui/src/ui/command-palette.browser.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import "../styles.css"; -import { render } from "lit"; -import { describe, expect, it } from "vitest"; -import { renderCommandPalette } from "./views/command-palette.ts"; - -describe("command palette styling", () => { - it("renders the overlay, input, and result items with palette layout styles", () => { - const rendered = renderCommandPalette({ - open: true, - query: "", - activeIndex: 0, - onToggle: () => {}, - onQueryChange: () => {}, - onActiveIndexChange: () => {}, - onNavigate: () => {}, - onSlashCommand: () => {}, - }); - const host = document.createElement("div"); - document.body.append(host); - render(rendered, host); - - const overlay = host.querySelector(".cmd-palette-overlay"); - const palette = host.querySelector(".cmd-palette"); - const input = host.querySelector(".cmd-palette__input"); - const item = host.querySelector(".cmd-palette__item"); - - expect(overlay).not.toBeNull(); - expect(palette).not.toBeNull(); - expect(input).not.toBeNull(); - expect(item).not.toBeNull(); - expect(getComputedStyle(overlay!).position).toBe("fixed"); - expect(getComputedStyle(overlay!).display).toBe("flex"); - expect(getComputedStyle(palette!).width).not.toBe("0px"); - expect(getComputedStyle(input!).borderBottomStyle).toBe("solid"); - expect(getComputedStyle(item!).display).toBe("flex"); - }); -}); diff --git a/ui/src/ui/config-form.browser.test.ts b/ui/src/ui/config-form.browser.test.ts index 5648a70895d..393d13a8f97 100644 --- a/ui/src/ui/config-form.browser.test.ts +++ b/ui/src/ui/config-form.browser.test.ts @@ -1,6 +1,5 @@ import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; -import { TelegramConfigSchema } from "../../../src/config/zod-schema.providers-core.ts"; import { analyzeConfigSchema, renderConfigForm } from "./views/config-form.ts"; const rootSchema = { @@ -52,7 +51,7 @@ describe("config form renderer", () => { container, ); - const tokenInput: HTMLInputElement | null = container.querySelector(".cfg-input"); + const tokenInput: HTMLInputElement | null = container.querySelector("input[type='password']"); expect(tokenInput).not.toBeNull(); if (!tokenInput) { return; @@ -78,81 +77,6 @@ describe("config form renderer", () => { expect(onPatch).toHaveBeenCalledWith(["enabled"], true); }); - it("keeps sensitive values out of hidden form inputs until revealed", () => { - const onPatch = vi.fn(); - const container = document.createElement("div"); - const analysis = analyzeConfigSchema(rootSchema); - const revealed = new Set(); - const props = { - schema: analysis.schema, - uiHints: { - "gateway.auth.token": { label: "Gateway Token", sensitive: true }, - }, - unsupportedPaths: analysis.unsupportedPaths, - value: { gateway: { auth: { token: "secret-123" } } }, - streamMode: false, - isSensitivePathRevealed: (path: Array) => - revealed.has( - path.filter((segment): segment is string => typeof segment === "string").join("."), - ), - onToggleSensitivePath: (path: Array) => { - const key = path - .filter((segment): segment is string => typeof segment === "string") - .join("."); - if (revealed.has(key)) { - revealed.delete(key); - } else { - revealed.add(key); - } - }, - onPatch, - }; - - render(renderConfigForm(props), container); - const hiddenInput = container.querySelector(".cfg-input"); - expect(hiddenInput).not.toBeNull(); - expect(hiddenInput?.value).toBe(""); - expect(hiddenInput?.placeholder).toContain("redacted"); - - const toggle = container.querySelector('button[aria-label="Reveal value"]'); - expect(toggle?.disabled).toBe(false); - toggle?.click(); - - render(renderConfigForm(props), container); - const revealedInput = container.querySelector(".cfg-input"); - expect(revealedInput?.value).toBe("secret-123"); - expect(revealedInput?.type).toBe("text"); - }); - - it("blocks sensitive field reveal while stream mode is enabled", () => { - const onPatch = vi.fn(); - const container = document.createElement("div"); - const analysis = analyzeConfigSchema(rootSchema); - render( - renderConfigForm({ - schema: analysis.schema, - uiHints: { - "gateway.auth.token": { label: "Gateway Token", sensitive: true }, - }, - unsupportedPaths: analysis.unsupportedPaths, - value: { gateway: { auth: { token: "secret-123" } } }, - streamMode: true, - isSensitivePathRevealed: () => false, - onToggleSensitivePath: vi.fn(), - onPatch, - }), - container, - ); - - const input = container.querySelector(".cfg-input"); - expect(input?.value).toBe(""); - - const toggle = container.querySelector( - 'button[aria-label="Disable stream mode to reveal value"]', - ); - expect(toggle?.disabled).toBe(true); - }); - it("adds and removes array entries", () => { const onPatch = vi.fn(); const container = document.createElement("div"); @@ -377,7 +301,7 @@ describe("config form renderer", () => { }), noMatchContainer, ); - expect(noMatchContainer.textContent).not.toContain("Token"); + expect(noMatchContainer.textContent).toContain('No settings match "mode tag:security"'); }); it("supports SecretInput unions in additionalProperties maps", () => { @@ -447,9 +371,7 @@ describe("config form renderer", () => { container, ); - const apiKeyInput: HTMLInputElement | null = container.querySelector( - ".cfg-input:not(.cfg-input--sm)", - ); + const apiKeyInput: HTMLInputElement | null = container.querySelector("input[type='password']"); expect(apiKeyInput).not.toBeNull(); if (!apiKeyInput) { return; @@ -536,27 +458,4 @@ describe("config form renderer", () => { removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["accounts"], {}); }); - - it("accepts the real Telegram channel schema without forcing raw mode", () => { - const schema = { - type: "object", - properties: { - channels: { - type: "object", - properties: { - telegram: TelegramConfigSchema.toJSONSchema({ - target: "draft-07", - unrepresentable: "any", - }), - }, - }, - }, - }; - - const analysis = analyzeConfigSchema(schema); - expect(analysis.unsupportedPaths).not.toContain("channels.telegram"); - expect(analysis.unsupportedPaths).not.toContain("channels.telegram.capabilities"); - expect(analysis.unsupportedPaths).not.toContain("channels.telegram.customCommands"); - expect(analysis.unsupportedPaths).not.toContain("channels.telegram.accounts"); - }); }); diff --git a/ui/src/ui/config-layout.browser.test.ts b/ui/src/ui/config-layout.browser.test.ts deleted file mode 100644 index b4565ea743f..00000000000 --- a/ui/src/ui/config-layout.browser.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import "../styles.css"; -import { render } from "lit"; -import { describe, expect, it, vi } from "vitest"; -import { renderConfig, resetConfigViewStateForTests } from "./views/config.ts"; - -function baseProps() { - resetConfigViewStateForTests(); - return { - raw: "{\n}\n", - originalRaw: "{\n}\n", - valid: true, - issues: [], - loading: false, - saving: false, - applying: false, - updating: false, - connected: true, - schema: { - type: "object", - properties: { - gateway: { type: "object", properties: {} }, - communication: { - type: "object", - properties: { - webhookBaseUrl: { - type: "string", - title: "Webhook Base URL", - }, - }, - }, - }, - }, - schemaLoading: false, - uiHints: {}, - formMode: "form" as const, - showModeToggle: true, - formValue: {}, - originalValue: {}, - searchQuery: "", - activeSection: "communication", - activeSubsection: null, - streamMode: false, - onRawChange: vi.fn(), - onFormModeChange: vi.fn(), - onFormPatch: vi.fn(), - onSearchChange: vi.fn(), - onSectionChange: vi.fn(), - onReload: vi.fn(), - onSave: vi.fn(), - onApply: vi.fn(), - onUpdate: vi.fn(), - onSubsectionChange: vi.fn(), - version: "", - theme: "claw" as const, - themeMode: "system" as const, - setTheme: vi.fn(), - setThemeMode: vi.fn(), - gatewayUrl: "", - assistantName: "", - }; -} - -describe("config layout width", () => { - it("lets the main config pane span the available width instead of collapsing to a dead sidebar track", () => { - const host = document.createElement("div"); - host.style.width = "1200px"; - document.body.append(host); - - render(renderConfig(baseProps()), host); - - const layout = host.querySelector(".config-layout"); - const main = host.querySelector(".config-main"); - const card = host.querySelector(".config-section-card"); - - expect(layout).not.toBeNull(); - expect(main).not.toBeNull(); - expect(card).not.toBeNull(); - expect(getComputedStyle(layout!).display).toBe("grid"); - expect(main!.getBoundingClientRect().width).toBeGreaterThan(800); - expect(card!.getBoundingClientRect().width).toBeGreaterThan(800); - }); - - it("lays out the search, tabs, and mode toggle as a real full-width top rail", () => { - const host = document.createElement("div"); - host.style.width = "1200px"; - document.body.append(host); - - render(renderConfig(baseProps()), host); - - const topTabs = host.querySelector(".config-top-tabs"); - const scroller = host.querySelector(".config-top-tabs__scroller"); - - expect(topTabs).not.toBeNull(); - expect(scroller).not.toBeNull(); - expect(getComputedStyle(topTabs!).display).toBe("flex"); - expect(getComputedStyle(scroller!).display).toBe("flex"); - }); - - it("renders the appearance theme picker as styled cards instead of default buttons", () => { - const host = document.createElement("div"); - host.style.width = "1200px"; - document.body.append(host); - - render( - renderConfig({ - ...baseProps(), - schema: { - type: "object", - properties: { - ui: { type: "object", properties: {} }, - wizard: { type: "object", properties: {} }, - }, - }, - activeSection: "__appearance__", - includeVirtualSections: true, - }), - host, - ); - - const grid = host.querySelector(".settings-theme-grid"); - const card = host.querySelector(".settings-theme-card"); - const activeCard = host.querySelector(".settings-theme-card--active"); - - expect(grid).not.toBeNull(); - expect(card).not.toBeNull(); - expect(activeCard).not.toBeNull(); - expect(getComputedStyle(grid!).display).toBe("grid"); - expect(getComputedStyle(card!).display).toBe("grid"); - expect(getComputedStyle(card!).borderRadius).not.toBe("0px"); - expect(getComputedStyle(activeCard!).borderColor).not.toBe("buttonborder"); - }); -}); diff --git a/ui/src/ui/controllers/agents.test.ts b/ui/src/ui/controllers/agents.test.ts index 1be30ed6107..a026d447cf9 100644 --- a/ui/src/ui/controllers/agents.test.ts +++ b/ui/src/ui/controllers/agents.test.ts @@ -14,7 +14,6 @@ function createState(): { state: AgentsState; request: ReturnType agentsList: null, agentsSelectedId: "main", toolsCatalogLoading: false, - toolsCatalogLoadingAgentId: null, toolsCatalogError: null, toolsCatalogResult: null, }; @@ -150,71 +149,6 @@ describe("loadToolsCatalog", () => { expect(state.toolsCatalogError).toContain("gateway unavailable"); expect(state.toolsCatalogLoading).toBe(false); }); - - it("allows a new agent request to replace a stale in-flight load", async () => { - const { state, request } = createState(); - - let resolveMain: - | ((value: { - agentId: string; - profiles: { id: string; label: string }[]; - groups: { - id: string; - label: string; - source: string; - tools: { id: string; label: string; description: string; source: string }[]; - }[]; - }) => void) - | null = null; - const mainRequest = new Promise<{ - agentId: string; - profiles: { id: string; label: string }[]; - groups: { - id: string; - label: string; - source: string; - tools: { id: string; label: string; description: string; source: string }[]; - }[]; - }>((resolve) => { - resolveMain = resolve; - }); - - const replacementPayload = { - agentId: "other", - profiles: [{ id: "full", label: "Full" }], - groups: [], - }; - - request.mockImplementationOnce(() => mainRequest).mockResolvedValueOnce(replacementPayload); - - const initialLoad = loadToolsCatalog(state, "main"); - await Promise.resolve(); - - state.agentsSelectedId = "other"; - await loadToolsCatalog(state, "other"); - - expect(request).toHaveBeenNthCalledWith(1, "tools.catalog", { - agentId: "main", - includePlugins: true, - }); - expect(request).toHaveBeenNthCalledWith(2, "tools.catalog", { - agentId: "other", - includePlugins: true, - }); - expect(state.toolsCatalogResult).toEqual(replacementPayload); - expect(state.toolsCatalogLoading).toBe(false); - - expect(resolveMain).not.toBeNull(); - resolveMain!({ - agentId: "main", - profiles: [{ id: "full", label: "Full" }], - groups: [], - }); - await initialLoad; - - expect(state.toolsCatalogResult).toEqual(replacementPayload); - expect(state.toolsCatalogLoading).toBe(false); - }); }); describe("saveAgentsConfig", () => { diff --git a/ui/src/ui/controllers/config.test.ts b/ui/src/ui/controllers/config.test.ts index 3e23ca696b6..826030f884e 100644 --- a/ui/src/ui/controllers/config.test.ts +++ b/ui/src/ui/controllers/config.test.ts @@ -294,50 +294,6 @@ describe("applyConfig", () => { expect(params.baseHash).toBe("hash-apply-1"); expect(params.sessionKey).toBe("agent:main:web:dm:test"); }); - - it("preserves sensitive form values in serialized raw state for apply", async () => { - const request = createRequestWithConfigGet(); - const state = createState(); - state.connected = true; - state.client = { request } as unknown as ConfigState["client"]; - state.applySessionKey = "agent:main:web:dm:secret"; - state.configFormMode = "form"; - state.configForm = { - gateway: { - auth: { - token: "secret-123", - }, - }, - }; - state.configSchema = { - type: "object", - properties: { - gateway: { - type: "object", - properties: { - auth: { - type: "object", - properties: { - token: { type: "string" }, - }, - }, - }, - }, - }, - }; - state.configSnapshot = { hash: "hash-apply-secret" }; - - await applyConfig(state); - - expect(request.mock.calls[0]?.[0]).toBe("config.apply"); - const params = request.mock.calls[0]?.[1] as { - raw: string; - baseHash: string; - sessionKey: string; - }; - expect(params.raw).toContain("secret-123"); - expect(params.baseHash).toBe("hash-apply-secret"); - }); }); describe("saveConfig", () => { diff --git a/ui/src/ui/icon-layout.browser.test.ts b/ui/src/ui/icon-layout.browser.test.ts deleted file mode 100644 index 2b440718dfb..00000000000 --- a/ui/src/ui/icon-layout.browser.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import "../styles.css"; -import { html, render } from "lit"; -import { describe, expect, it } from "vitest"; -import { icons } from "./icons.ts"; - -describe("icon layout styling", () => { - it("styles nav group chevrons as compact inline SVGs", () => { - const container = document.createElement("div"); - document.body.append(container); - render( - html``, - container, - ); - - const button = container.querySelector(".nav-group__label"); - const svg = container.querySelector(".nav-group__chevron svg"); - expect(button).not.toBeNull(); - expect(svg).not.toBeNull(); - expect(getComputedStyle(button!).display).toBe("flex"); - expect(getComputedStyle(svg!).width).toBe("12px"); - expect(getComputedStyle(svg!).height).toBe("12px"); - }); - - it("styles tool summary icons without default SVG fallback sizing", () => { - const container = document.createElement("div"); - document.body.append(container); - render( - html`
- - ${icons.zap} - Tool output - -
`, - container, - ); - - const summary = container.querySelector(".chat-tool-msg-summary"); - const svg = container.querySelector(".chat-tool-msg-summary__icon svg"); - expect(summary).not.toBeNull(); - expect(svg).not.toBeNull(); - expect(getComputedStyle(summary!).display).toBe("flex"); - expect(getComputedStyle(svg!).width).toBe("14px"); - expect(getComputedStyle(svg!).height).toBe("14px"); - }); - - it("renders the shared nav collapse trigger with the compact hamburger icon", () => { - const container = document.createElement("div"); - document.body.append(container); - render( - html``, - container, - ); - - const button = container.querySelector(".nav-collapse-toggle"); - const svg = container.querySelector(".nav-collapse-toggle__icon svg"); - expect(button).not.toBeNull(); - expect(svg).not.toBeNull(); - expect(getComputedStyle(button!).display).toBe("flex"); - expect(getComputedStyle(svg!).width).toBe("16px"); - expect(getComputedStyle(svg!).height).toBe("16px"); - }); -}); diff --git a/ui/src/ui/login-gate.browser.test.ts b/ui/src/ui/login-gate.browser.test.ts deleted file mode 100644 index 9b2ed0309a9..00000000000 --- a/ui/src/ui/login-gate.browser.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import "../styles.css"; -import { render } from "lit"; -import { describe, expect, it } from "vitest"; -import { renderLoginGate } from "./views/login-gate.ts"; - -describe("login gate layout", () => { - it("applies the disconnected view shell styling", () => { - const container = document.createElement("div"); - document.body.append(container); - render( - renderLoginGate({ - basePath: "", - settings: { - gatewayUrl: "ws://127.0.0.1:18789", - token: "", - }, - loginShowGatewayToken: false, - loginShowGatewayPassword: false, - password: "", - lastError: null, - applySettings: () => {}, - connect: () => {}, - } as unknown as Parameters[0]), - container, - ); - - const gate = container.querySelector(".login-gate"); - const card = container.querySelector(".login-gate__card"); - const logo = container.querySelector(".login-gate__logo"); - - expect(gate).not.toBeNull(); - expect(card).not.toBeNull(); - expect(logo).not.toBeNull(); - expect(getComputedStyle(gate!).display).toBe("flex"); - expect(getComputedStyle(gate!).paddingTop).toBe("24px"); - expect(getComputedStyle(card!).paddingLeft).toBe("32px"); - expect(getComputedStyle(card!).backgroundColor).not.toBe("rgba(0, 0, 0, 0)"); - expect(getComputedStyle(logo!).width).toBe("48px"); - }); -}); diff --git a/ui/src/ui/nav-collapsed.browser.test.ts b/ui/src/ui/nav-collapsed.browser.test.ts deleted file mode 100644 index 1e48add0c72..00000000000 --- a/ui/src/ui/nav-collapsed.browser.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import "../styles.css"; -import { describe, expect, it } from "vitest"; -import { mountApp, registerAppMountHooks } from "./test-helpers/app-mount.ts"; - -registerAppMountHooks(); - -describe("collapsed nav rail", () => { - it("collapses into a horizontal mobile rail instead of a vertical side strip", async () => { - localStorage.setItem( - "openclaw.control.settings.v1", - JSON.stringify({ - gatewayUrl: "ws://127.0.0.1:18789", - sessionKey: "main", - lastActiveSessionKey: "main", - theme: "claw", - themeMode: "system", - chatFocusMode: false, - chatShowThinking: true, - splitRatio: 0.6, - navCollapsed: true, - navWidth: 220, - navGroupsCollapsed: {}, - }), - ); - - const app = mountApp("/chat"); - await app.updateComplete; - - const shell = app.querySelector(".shell"); - const shellNav = app.querySelector(".shell-nav"); - const rail = app.querySelector(".sidebar"); - const nav = app.querySelector(".sidebar-nav"); - const toggle = app.querySelector(".nav-collapse-toggle"); - const navItem = app.querySelector('a.nav-item[href="/chat"]'); - const footer = app.querySelector(".sidebar-footer"); - - expect(shell).not.toBeNull(); - expect(shellNav).not.toBeNull(); - expect(rail).not.toBeNull(); - expect(nav).not.toBeNull(); - expect(toggle).not.toBeNull(); - expect(navItem).not.toBeNull(); - expect(footer).not.toBeNull(); - expect(window.matchMedia("(max-width: 768px)").matches).toBe(true); - expect(shell!.classList.contains("shell--nav-collapsed")).toBe(true); - expect(getComputedStyle(nav!).display).toBe("flex"); - expect(getComputedStyle(nav!).flexDirection).toBe("row"); - expect(getComputedStyle(shellNav!).width).toBe(`${window.innerWidth}px`); - expect(getComputedStyle(navItem!).justifyContent).toBe("center"); - expect(toggle!.getBoundingClientRect().width).toBeGreaterThan(40); - expect(toggle!.getBoundingClientRect().width).toBeLessThan(48); - expect(navItem!.getBoundingClientRect().width).toBeGreaterThan(40); - expect(navItem!.getBoundingClientRect().width).toBeLessThan(48); - expect(navItem!.getBoundingClientRect().left).toBeGreaterThan( - toggle!.getBoundingClientRect().left, - ); - expect(getComputedStyle(footer!).display).toBe("none"); - expect(app.querySelector(".nav-item__text")).toBeNull(); - }); -}); diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index 207d6d13824..d9b5f3c7182 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -85,18 +85,6 @@ describe("control UI routing", () => { expect(window.matchMedia("(max-width: 768px)").matches).toBe(true); - const shellNav = app.querySelector(".shell-nav"); - const sidebarNav = app.querySelector(".sidebar-nav"); - expect(shellNav).not.toBeNull(); - expect(sidebarNav).not.toBeNull(); - if (shellNav) { - expect(getComputedStyle(shellNav).width).toBe(`${window.innerWidth}px`); - } - if (sidebarNav) { - expect(getComputedStyle(sidebarNav).display).toBe("flex"); - expect(getComputedStyle(sidebarNav).flexDirection).toBe("row"); - } - const split = app.querySelector(".chat-split-container"); expect(split).not.toBeNull(); if (split) { @@ -142,8 +130,6 @@ describe("control UI routing", () => { await nextFrame(); } - expect(app.tab).toBe("chat"); - expect(app.connected).toBe(true); const container = app.querySelector(".chat-thread"); expect(container).not.toBeNull(); if (!container) { diff --git a/ui/src/ui/storage.node.test.ts b/ui/src/ui/storage.node.test.ts index f6c601fd2b6..b3fc09f079d 100644 --- a/ui/src/ui/storage.node.test.ts +++ b/ui/src/ui/storage.node.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { UiSettings } from "./storage.ts"; function createStorageMock(): Storage { const store = new Map(); @@ -58,56 +57,11 @@ function setControlUiBasePath(value: string | undefined) { }); } -function setViteDevScript(enabled: boolean) { - if ( - typeof document === "undefined" || - typeof (document as Partial).querySelectorAll !== "function" || - typeof (document as Partial).createElement !== "function" - ) { - vi.stubGlobal("document", { - querySelector: (selector: string) => - enabled && selector.includes("/@vite/client") ? ({} as Element) : null, - querySelectorAll: () => [], - createElement: () => ({ setAttribute() {}, remove() {} }) as unknown as HTMLScriptElement, - head: { append() {} }, - } as Document); - return; - } - document - .querySelectorAll('script[data-test-vite-client="true"]') - .forEach((node) => node.remove()); - if (!enabled) { - return; - } - const script = document.createElement("script"); - script.setAttribute("data-test-vite-client", "true"); - script.setAttribute("src", "/@vite/client"); - document.head.append(script); -} - function expectedGatewayUrl(basePath: string): string { const proto = location.protocol === "https:" ? "wss" : "ws"; return `${proto}://${location.host}${basePath}`; } -function createSettings(overrides: Partial = {}): UiSettings { - return { - gatewayUrl: "wss://gateway.example:8443/openclaw", - token: "", - sessionKey: "main", - lastActiveSessionKey: "main", - theme: "claw", - themeMode: "system", - chatFocusMode: false, - chatShowThinking: true, - splitRatio: 0.6, - navCollapsed: false, - navWidth: 220, - navGroupsCollapsed: {}, - ...overrides, - }; -} - describe("loadSettings default gateway URL derivation", () => { beforeEach(() => { vi.resetModules(); @@ -117,13 +71,11 @@ describe("loadSettings default gateway URL derivation", () => { localStorage.clear(); sessionStorage.clear(); setControlUiBasePath(undefined); - setViteDevScript(false); }); afterEach(() => { vi.restoreAllMocks(); setControlUiBasePath(undefined); - setViteDevScript(false); vi.unstubAllGlobals(); }); @@ -150,44 +102,6 @@ describe("loadSettings default gateway URL derivation", () => { expect(loadSettings().gatewayUrl).toBe(expectedGatewayUrl("/apps/openclaw")); }); - it("defaults vite dev pages to the local gateway port", async () => { - setTestLocation({ - protocol: "http:", - host: "127.0.0.1:5174", - pathname: "/chat", - }); - setViteDevScript(true); - - const { loadSettings } = await import("./storage.ts"); - expect(loadSettings().gatewayUrl).toBe("ws://127.0.0.1:18789"); - }); - - it("migrates persisted vite dev gateway URLs to the local gateway port", async () => { - setTestLocation({ - protocol: "http:", - host: "127.0.0.1:5174", - pathname: "/chat", - }); - setViteDevScript(true); - localStorage.setItem( - "openclaw.control.settings.v1", - JSON.stringify({ - gatewayUrl: "ws://127.0.0.1:5174", - sessionKey: "main", - lastActiveSessionKey: "main", - theme: "system", - chatFocusMode: false, - chatShowThinking: true, - splitRatio: 0.6, - navCollapsed: false, - navGroupsCollapsed: {}, - }), - ); - - const { loadSettings } = await import("./storage.ts"); - expect(loadSettings().gatewayUrl).toBe("ws://127.0.0.1:18789"); - }); - it("ignores and scrubs legacy persisted tokens", async () => { setTestLocation({ protocol: "https:", @@ -234,7 +148,20 @@ describe("loadSettings default gateway URL derivation", () => { }); const { loadSettings, saveSettings } = await import("./storage.ts"); - saveSettings(createSettings({ token: "session-token" })); + saveSettings({ + gatewayUrl: "wss://gateway.example:8443/openclaw", + token: "session-token", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "claw", + themeMode: "system", + chatFocusMode: false, + chatShowThinking: true, + splitRatio: 0.6, + navCollapsed: false, + navWidth: 220, + navGroupsCollapsed: {}, + }); expect(loadSettings()).toMatchObject({ gatewayUrl: "wss://gateway.example:8443/openclaw", @@ -250,7 +177,20 @@ describe("loadSettings default gateway URL derivation", () => { }); const { loadSettings, saveSettings } = await import("./storage.ts"); - saveSettings(createSettings({ token: "gateway-a-token" })); + saveSettings({ + gatewayUrl: "wss://gateway.example:8443/openclaw", + token: "gateway-a-token", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "claw", + themeMode: "system", + chatFocusMode: false, + chatShowThinking: true, + splitRatio: 0.6, + navCollapsed: false, + navWidth: 220, + navGroupsCollapsed: {}, + }); localStorage.setItem( "openclaw.control.settings.v1", @@ -283,7 +223,20 @@ describe("loadSettings default gateway URL derivation", () => { }); const { loadSettings, saveSettings } = await import("./storage.ts"); - saveSettings(createSettings({ token: "memory-only-token" })); + saveSettings({ + gatewayUrl: "wss://gateway.example:8443/openclaw", + token: "memory-only-token", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "claw", + themeMode: "system", + chatFocusMode: false, + chatShowThinking: true, + splitRatio: 0.6, + navCollapsed: false, + navWidth: 220, + navGroupsCollapsed: {}, + }); expect(loadSettings()).toMatchObject({ gatewayUrl: "wss://gateway.example:8443/openclaw", token: "memory-only-token", @@ -305,29 +258,6 @@ describe("loadSettings default gateway URL derivation", () => { expect(sessionStorage.length).toBe(1); }); - it("persists theme mode and nav width", async () => { - setTestLocation({ - protocol: "https:", - host: "gateway.example:8443", - pathname: "/", - }); - - const { loadSettings, saveSettings } = await import("./storage.ts"); - saveSettings( - createSettings({ - theme: "dash", - themeMode: "light", - navWidth: 360, - }), - ); - - expect(loadSettings()).toMatchObject({ - theme: "dash", - themeMode: "light", - navWidth: 360, - }); - }); - it("clears the current-tab token when saving an empty token", async () => { setTestLocation({ protocol: "https:", @@ -336,10 +266,66 @@ describe("loadSettings default gateway URL derivation", () => { }); const { loadSettings, saveSettings } = await import("./storage.ts"); - saveSettings(createSettings({ token: "stale-token" })); - saveSettings(createSettings()); + saveSettings({ + gatewayUrl: "wss://gateway.example:8443/openclaw", + token: "stale-token", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "claw", + themeMode: "system", + chatFocusMode: false, + chatShowThinking: true, + splitRatio: 0.6, + navCollapsed: false, + navWidth: 220, + navGroupsCollapsed: {}, + }); + saveSettings({ + gatewayUrl: "wss://gateway.example:8443/openclaw", + token: "", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "claw", + themeMode: "system", + chatFocusMode: false, + chatShowThinking: true, + splitRatio: 0.6, + navCollapsed: false, + navWidth: 220, + navGroupsCollapsed: {}, + }); expect(loadSettings().token).toBe(""); expect(sessionStorage.length).toBe(0); }); + + it("persists themeMode and navWidth alongside the selected theme", async () => { + setTestLocation({ + protocol: "https:", + host: "gateway.example:8443", + pathname: "/", + }); + + const { saveSettings } = await import("./storage.ts"); + saveSettings({ + gatewayUrl: "wss://gateway.example:8443/openclaw", + token: "", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "dash", + themeMode: "light", + chatFocusMode: false, + chatShowThinking: true, + splitRatio: 0.6, + navCollapsed: false, + navWidth: 320, + navGroupsCollapsed: {}, + }); + + expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toMatchObject({ + theme: "dash", + themeMode: "light", + navWidth: 320, + }); + }); }); diff --git a/ui/src/ui/test-helpers/app-mount.ts b/ui/src/ui/test-helpers/app-mount.ts index d781d4e53f0..e078b186203 100644 --- a/ui/src/ui/test-helpers/app-mount.ts +++ b/ui/src/ui/test-helpers/app-mount.ts @@ -1,14 +1,6 @@ import { afterEach, beforeEach } from "vitest"; import "../app.ts"; import type { OpenClawApp } from "../app.ts"; -import type { GatewayHelloOk } from "../gateway.ts"; - -type MountHarnessApp = OpenClawApp & { - client?: { stop: () => void } | null; - connected?: boolean; - hello?: GatewayHelloOk | null; - lastError?: string | null; -}; export function mountApp(pathname: string) { window.history.replaceState({}, "", pathname); @@ -17,14 +9,6 @@ export function mountApp(pathname: string) { // no-op: avoid real gateway WS connections in browser tests }; document.body.append(app); - const mounted = app as MountHarnessApp; - // Browser tests exercise rendered UI behavior, not live gateway transport. - // Force a connected shell and neutralize any background client started by lifecycle hooks. - mounted.client?.stop(); - mounted.client = null; - mounted.connected = true; - mounted.lastError = null; - mounted.hello = mounted.hello ?? null; return app; } diff --git a/ui/src/ui/theme-orb.browser.test.ts b/ui/src/ui/theme-orb.browser.test.ts deleted file mode 100644 index cad1065e0af..00000000000 --- a/ui/src/ui/theme-orb.browser.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import "../styles.css"; -import { render } from "lit"; -import { describe, expect, it } from "vitest"; -import { renderThemeToggle } from "./app-render.helpers.ts"; - -describe("theme orb styling", () => { - it("renders the theme trigger as a compact round control", () => { - const container = document.createElement("div"); - document.body.append(container); - render( - renderThemeToggle({ - theme: "claw", - setTheme: () => {}, - } as unknown as Parameters[0]), - container, - ); - - const orb = container.querySelector(".theme-orb"); - const trigger = container.querySelector(".theme-orb__trigger"); - - expect(orb).not.toBeNull(); - expect(trigger).not.toBeNull(); - expect(getComputedStyle(orb!).display).toBe("inline-flex"); - expect(getComputedStyle(trigger!).width).toBe("28px"); - expect(getComputedStyle(trigger!).borderRadius).toBe("9999px"); - }); -}); diff --git a/ui/src/ui/topbar-theme-mode.browser.test.ts b/ui/src/ui/topbar-theme-mode.browser.test.ts deleted file mode 100644 index 361d9b359d7..00000000000 --- a/ui/src/ui/topbar-theme-mode.browser.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import "../styles.css"; -import { render } from "lit"; -import { describe, expect, it } from "vitest"; -import { renderTopbarThemeModeToggle } from "./app-render.helpers.ts"; - -describe("topbar theme mode styling", () => { - it("renders icon-sized mode buttons instead of unstyled fallback boxes", () => { - const container = document.createElement("div"); - document.body.append(container); - render( - renderTopbarThemeModeToggle({ - themeMode: "system", - setThemeMode: () => {}, - } as never), - container, - ); - - const group = container.querySelector(".topbar-theme-mode"); - const button = container.querySelector(".topbar-theme-mode__btn"); - const svg = container.querySelector(".topbar-theme-mode__btn svg"); - - expect(group).not.toBeNull(); - expect(button).not.toBeNull(); - expect(svg).not.toBeNull(); - expect(getComputedStyle(group!).display).toBe("inline-flex"); - expect(getComputedStyle(button!).display).toBe("flex"); - expect(getComputedStyle(svg!).width).toBe("14px"); - expect(getComputedStyle(svg!).height).toBe("14px"); - }); -}); diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 7aec524c111..d67acd77485 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -40,15 +40,12 @@ function createProps(overrides: Partial = {}): ChatProps { focusMode: false, assistantName: "OpenClaw", assistantAvatar: null, - agentsList: null, - currentAgentId: "main", onRefresh: () => undefined, onToggleFocusMode: () => undefined, onDraftChange: () => undefined, onSend: () => undefined, onQueueRemove: () => undefined, onNewSession: () => undefined, - onAgentChange: () => undefined, ...overrides, }; } @@ -193,17 +190,18 @@ describe("chat view", () => { createProps({ canAbort: true, onAbort, - stream: "in-flight", }), ), container, ); - const stopButton = container.querySelector('button[title="Stop"]'); - expect(stopButton).not.toBeNull(); + const stopButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Stop", + ); + expect(stopButton).not.toBeUndefined(); stopButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onAbort).toHaveBeenCalledTimes(1); - expect(container.querySelector('button[title="New session"]')).toBeNull(); + expect(container.textContent).not.toContain("New session"); }); it("shows a new session button when aborting is unavailable", () => { @@ -219,13 +217,13 @@ describe("chat view", () => { container, ); - const newSessionButton = container.querySelector( - 'button[title="New session"]', + const newSessionButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "New session", ); - expect(newSessionButton).not.toBeNull(); + expect(newSessionButton).not.toBeUndefined(); newSessionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onNewSession).toHaveBeenCalledTimes(1); - expect(container.querySelector('button[title="Stop"]')).toBeNull(); + expect(container.textContent).not.toContain("Stop"); }); it("shows sender labels from sanitized gateway messages instead of generic You", () => { diff --git a/ui/src/ui/views/config.browser.test.ts b/ui/src/ui/views/config.browser.test.ts index 26bc035d07f..889d046f942 100644 --- a/ui/src/ui/views/config.browser.test.ts +++ b/ui/src/ui/views/config.browser.test.ts @@ -1,12 +1,8 @@ import { render } from "lit"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { renderConfig, resetConfigViewStateForTests } from "./config.ts"; +import { describe, expect, it, vi } from "vitest"; +import { renderConfig } from "./config.ts"; describe("config view", () => { - beforeEach(() => { - resetConfigViewStateForTests(); - }); - const baseProps = () => ({ raw: "{\n}\n", originalRaw: "{\n}\n", @@ -24,13 +20,11 @@ describe("config view", () => { schemaLoading: false, uiHints: {}, formMode: "form" as const, - showModeToggle: true, formValue: {}, originalValue: {}, searchQuery: "", activeSection: null, activeSubsection: null, - streamMode: false, onRawChange: vi.fn(), onFormModeChange: vi.fn(), onFormPatch: vi.fn(), @@ -41,13 +35,6 @@ describe("config view", () => { onApply: vi.fn(), onUpdate: vi.fn(), onSubsectionChange: vi.fn(), - version: "", - theme: "claw" as const, - themeMode: "system" as const, - setTheme: vi.fn(), - setThemeMode: vi.fn(), - gatewayUrl: "", - assistantName: "", }); function findActionButtons(container: HTMLElement): { @@ -147,102 +134,6 @@ describe("config view", () => { expect(applyButton?.disabled).toBe(false); }); - it("keeps raw secrets out of the DOM while stream mode is enabled", () => { - const container = document.createElement("div"); - render( - renderConfig({ - ...baseProps(), - formMode: "raw", - streamMode: true, - raw: '{\n gateway: { auth: { token: "secret-123" } }\n}\n', - originalRaw: "{\n}\n", - formValue: { gateway: { auth: { token: "secret-123" } } }, - uiHints: { - "gateway.auth.token": { sensitive: true }, - }, - }), - container, - ); - - const textarea = container.querySelector("textarea"); - expect(textarea).not.toBeNull(); - expect(textarea?.value).toBe(""); - expect(textarea?.getAttribute("placeholder")).toContain("redacted"); - - const toggle = container.querySelector( - 'button[aria-label="Toggle raw config redaction"]', - ); - expect(toggle?.disabled).toBe(true); - }); - - it("reveals raw secrets only after explicit toggle when stream mode is off", () => { - const container = document.createElement("div"); - const props = { - ...baseProps(), - formMode: "raw" as const, - streamMode: false, - raw: '{\n gateway: { auth: { token: "secret-123" } }\n}\n', - originalRaw: "{\n}\n", - formValue: { gateway: { auth: { token: "secret-123" } } }, - uiHints: { - "gateway.auth.token": { sensitive: true }, - }, - }; - - render(renderConfig(props), container); - const initialTextarea = container.querySelector("textarea"); - expect(initialTextarea?.value).toBe(""); - - const toggle = container.querySelector( - 'button[aria-label="Toggle raw config redaction"]', - ); - expect(toggle?.disabled).toBe(false); - toggle?.click(); - - render(renderConfig(props), container); - const revealedTextarea = container.querySelector("textarea"); - expect(revealedTextarea?.value).toContain("secret-123"); - }); - - it("reveals env values through the peek control instead of CSS-only masking", () => { - const container = document.createElement("div"); - const props = { - ...baseProps(), - activeSection: "env" as const, - formMode: "form" as const, - streamMode: false, - schema: { - type: "object", - properties: { - env: { - type: "object", - additionalProperties: { type: "string" }, - }, - }, - }, - formValue: { - env: { - OPENAI_API_KEY: "secret-123", - }, - }, - }; - - render(renderConfig(props), container); - const hiddenInput = container.querySelector(".cfg-input:not(.cfg-input--sm)"); - expect(hiddenInput?.value).toBe(""); - - const peekButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("Peek"), - ); - peekButton?.click(); - - render(renderConfig(props), container); - const revealedInput = container.querySelector( - ".cfg-input:not(.cfg-input--sm)", - ); - expect(revealedInput?.value).toBe("secret-123"); - }); - it("switches mode via the sidebar toggle", () => { const container = document.createElement("div"); const onFormModeChange = vi.fn(); @@ -313,7 +204,12 @@ describe("config view", () => { const container = document.createElement("div"); render(renderConfig(baseProps()), container); - expect(container.querySelectorAll(".config-search__tag-option")).toHaveLength(0); + const options = Array.from(container.querySelectorAll(".config-search__tag-option")).map( + (option) => option.textContent?.trim(), + ); + expect(options).toContain("tag:security"); + expect(options).toContain("tag:advanced"); + expect(options).toHaveLength(15); }); it("updates search query when toggling a tag option", () => { @@ -330,7 +226,8 @@ describe("config view", () => { const option = container.querySelector( '.config-search__tag-option[data-tag="security"]', ); - expect(option).toBeNull(); - expect(onSearchChange).not.toHaveBeenCalled(); + expect(option).toBeTruthy(); + option?.click(); + expect(onSearchChange).toHaveBeenCalledWith("tag:security"); }); }); diff --git a/ui/src/ui/views/overview.node.test.ts b/ui/src/ui/views/overview.node.test.ts index a5657c8de8f..3fa65b93391 100644 --- a/ui/src/ui/views/overview.node.test.ts +++ b/ui/src/ui/views/overview.node.test.ts @@ -1,95 +1,39 @@ import { describe, expect, it } from "vitest"; import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js"; -import { - shouldShowAuthHint, - shouldShowAuthRequiredHint, - shouldShowInsecureContextHint, - shouldShowPairingHint, -} from "./overview-hints.ts"; +import { shouldShowPairingHint } from "./overview-hints.ts"; -describe("overview hints", () => { - describe("shouldShowPairingHint", () => { - it("returns true for 'pairing required' close reason", () => { - expect(shouldShowPairingHint(false, "disconnected (1008): pairing required")).toBe(true); - }); - - it("matches case-insensitively", () => { - expect(shouldShowPairingHint(false, "Pairing Required")).toBe(true); - }); - - it("returns false when connected", () => { - expect(shouldShowPairingHint(true, "disconnected (1008): pairing required")).toBe(false); - }); - - it("returns false when lastError is null", () => { - expect(shouldShowPairingHint(false, null)).toBe(false); - }); - - it("returns false for unrelated errors", () => { - expect(shouldShowPairingHint(false, "disconnected (1006): no reason")).toBe(false); - }); - - it("returns false for auth errors", () => { - expect(shouldShowPairingHint(false, "disconnected (4008): unauthorized")).toBe(false); - }); - - it("returns true for structured pairing code", () => { - expect( - shouldShowPairingHint( - false, - "disconnected (4008): connect failed", - ConnectErrorDetailCodes.PAIRING_REQUIRED, - ), - ).toBe(true); - }); +describe("shouldShowPairingHint", () => { + it("returns true for 'pairing required' close reason", () => { + expect(shouldShowPairingHint(false, "disconnected (1008): pairing required")).toBe(true); }); - describe("shouldShowAuthHint", () => { - it("returns true for structured auth failures", () => { - expect( - shouldShowAuthHint( - false, - "disconnected (4008): connect failed", - ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISMATCH, - ), - ).toBe(true); - }); - - it("falls back to legacy close text when no detail code is present", () => { - expect(shouldShowAuthHint(false, "disconnected (4008): unauthorized")).toBe(true); - }); - - it("returns false for non-auth errors", () => { - expect(shouldShowAuthHint(false, "disconnected (1006): no reason")).toBe(false); - }); + it("matches case-insensitively", () => { + expect(shouldShowPairingHint(false, "Pairing Required")).toBe(true); }); - describe("shouldShowAuthRequiredHint", () => { - it("returns true for structured auth-required codes", () => { - expect( - shouldShowAuthRequiredHint(true, true, ConnectErrorDetailCodes.AUTH_TOKEN_MISSING), - ).toBe(true); - }); - - it("falls back to missing credentials when detail code is absent", () => { - expect(shouldShowAuthRequiredHint(false, false, null)).toBe(true); - expect(shouldShowAuthRequiredHint(true, false, null)).toBe(false); - }); + it("returns false when connected", () => { + expect(shouldShowPairingHint(true, "disconnected (1008): pairing required")).toBe(false); }); - describe("shouldShowInsecureContextHint", () => { - it("returns true for structured device identity errors", () => { - expect( - shouldShowInsecureContextHint( - false, - "disconnected (4008): connect failed", - ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED, - ), - ).toBe(true); - }); + it("returns false when lastError is null", () => { + expect(shouldShowPairingHint(false, null)).toBe(false); + }); - it("falls back to legacy close text when detail code is absent", () => { - expect(shouldShowInsecureContextHint(false, "device identity required")).toBe(true); - }); + it("returns false for unrelated errors", () => { + expect(shouldShowPairingHint(false, "disconnected (1006): no reason")).toBe(false); + }); + + it("returns false for auth errors", () => { + expect(shouldShowPairingHint(false, "disconnected (4008): unauthorized")).toBe(false); + }); + + it("returns true for structured pairing code", () => { + expect( + shouldShowPairingHint( + false, + "disconnected (4008): connect failed", + ConnectErrorDetailCodes.PAIRING_REQUIRED, + ), + ).toBe(true); }); }); diff --git a/ui/src/ui/views/sessions.test.ts b/ui/src/ui/views/sessions.test.ts index 50b35cae883..453c216592a 100644 --- a/ui/src/ui/views/sessions.test.ts +++ b/ui/src/ui/views/sessions.test.ts @@ -23,18 +23,7 @@ function buildProps(result: SessionsListResult): SessionsProps { includeGlobal: false, includeUnknown: false, basePath: "", - searchQuery: "", - sortColumn: "updated", - sortDir: "desc", - page: 0, - pageSize: 25, - actionsOpenKey: null, onFiltersChange: () => undefined, - onSearchChange: () => undefined, - onSortChange: () => undefined, - onPageChange: () => undefined, - onPageSizeChange: () => undefined, - onActionsOpenChange: () => undefined, onRefresh: () => undefined, onPatch: () => undefined, onDelete: () => undefined,