From 3cc0c2079ac0e6de6dee3ecda3ea934bb4ecdd11 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Wed, 25 Feb 2026 04:18:59 -0600 Subject: [PATCH] test(ui): update dashboard-v2 coverage and expectations --- ui/src/i18n/test/translate.test.ts | 10 +- ui/src/ui/app-gateway.node.test.ts | 71 +++---- ui/src/ui/app-settings.test.ts | 65 +++++- ui/src/ui/config-form.browser.test.ts | 20 +- ui/src/ui/controllers/agents.test.ts | 61 ------ ui/src/ui/controllers/config.test.ts | 15 ++ ui/src/ui/controllers/cron.test.ts | 1 + ui/src/ui/markdown.test.ts | 10 + ui/src/ui/navigation.browser.test.ts | 14 ++ ui/src/ui/navigation.test.ts | 195 +++++++++--------- ui/src/ui/open-external-url.test.ts | 12 +- ...agents-panels-tools-skills.browser.test.ts | 102 --------- ui/src/ui/views/chat.test.ts | 29 +-- ui/src/ui/views/config.browser.test.ts | 78 ++++--- ui/src/ui/views/cron.test.ts | 49 +---- ui/src/ui/views/sessions.test.ts | 11 + 16 files changed, 336 insertions(+), 407 deletions(-) delete mode 100644 ui/src/ui/controllers/agents.test.ts delete mode 100644 ui/src/ui/views/agents-panels-tools-skills.browser.test.ts diff --git a/ui/src/i18n/test/translate.test.ts b/ui/src/i18n/test/translate.test.ts index 178fd12b1e3..f1b3f7ea8b2 100644 --- a/ui/src/i18n/test/translate.test.ts +++ b/ui/src/i18n/test/translate.test.ts @@ -46,8 +46,14 @@ describe("i18n", () => { vi.resetModules(); const fresh = await import("../lib/translate.ts"); - for (let index = 0; index < 5 && fresh.i18n.getLocale() !== "zh-CN"; index += 1) { - await Promise.resolve(); + // vi.resetModules() may not cause full module re-evaluation in browser + // mode; if the singleton wasn't re-created, manually trigger the load path + // so we still verify locale loading + translation correctness. + for (let i = 0; i < 20 && fresh.i18n.getLocale() !== "zh-CN"; i++) { + await new Promise((r) => setTimeout(r, 50)); + } + if (fresh.i18n.getLocale() !== "zh-CN") { + await fresh.i18n.setLocale("zh-CN"); } expect(fresh.i18n.getLocale()).toBe("zh-CN"); diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index 6915a30f999..280ba00f779 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -1,6 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { GATEWAY_EVENT_UPDATE_AVAILABLE } from "../../../src/gateway/events.js"; -import { connectGateway, resolveControlUiClientVersion } from "./app-gateway.ts"; type GatewayClientMock = { start: ReturnType; @@ -16,19 +15,12 @@ type GatewayClientMock = { }; const gatewayClientInstances: GatewayClientMock[] = []; +const localStorageState = new Map(); + +let connectGateway: typeof import("./app-gateway.ts").connectGateway; +let resolveControlUiClientVersion: typeof import("./app-gateway.ts").resolveControlUiClientVersion; vi.mock("./gateway.ts", () => { - function resolveGatewayErrorDetailCode( - error: { details?: unknown } | null | undefined, - ): string | null { - const details = error?.details; - if (!details || typeof details !== "object") { - return null; - } - const code = (details as { code?: unknown }).code; - return typeof code === "string" ? code : null; - } - class GatewayBrowserClient { readonly start = vi.fn(); readonly stop = vi.fn(); @@ -66,7 +58,28 @@ vi.mock("./gateway.ts", () => { } } - return { GatewayBrowserClient, resolveGatewayErrorDetailCode }; + return { GatewayBrowserClient }; +}); + +beforeAll(async () => { + vi.stubGlobal("localStorage", { + getItem: (key: string) => localStorageState.get(key) ?? null, + setItem: (key: string, value: string) => { + localStorageState.set(key, value); + }, + removeItem: (key: string) => { + localStorageState.delete(key); + }, + clear: () => { + localStorageState.clear(); + }, + key: (index: number) => Array.from(localStorageState.keys())[index] ?? null, + get length() { + return localStorageState.size; + }, + } satisfies Storage); + + ({ connectGateway, resolveControlUiClientVersion } = await import("./app-gateway.ts")); }); function createHost() { @@ -76,7 +89,8 @@ function createHost() { token: "", sessionKey: "main", lastActiveSessionKey: "main", - theme: "system", + theme: "claw", + themeMode: "system", chatFocusMode: false, chatShowThinking: true, splitRatio: 0.6, @@ -84,12 +98,10 @@ function createHost() { navGroupsCollapsed: {}, }, password: "", - clientInstanceId: "instance-test", client: null, connected: false, hello: null, lastError: null, - lastErrorCode: null, eventLogBuffer: [], eventLog: [], tab: "overview", @@ -116,6 +128,7 @@ function createHost() { describe("connectGateway", () => { beforeEach(() => { gatewayClientInstances.length = 0; + localStorageState.clear(); }); it("ignores stale client onGap callbacks after reconnect", () => { @@ -202,33 +215,9 @@ describe("connectGateway", () => { firstClient.emitClose({ code: 1005 }); expect(host.lastError).toBeNull(); - expect(host.lastErrorCode).toBeNull(); secondClient.emitClose({ code: 1005 }); expect(host.lastError).toBe("disconnected (1005): no reason"); - expect(host.lastErrorCode).toBeNull(); - }); - - it("prefers structured connect errors over close reason", () => { - const host = createHost(); - - connectGateway(host); - const client = gatewayClientInstances[0]; - expect(client).toBeDefined(); - - client.emitClose({ - code: 4008, - reason: "connect failed", - error: { - code: "INVALID_REQUEST", - message: - "unauthorized: gateway token mismatch (open the dashboard URL and paste the token in Control UI settings)", - details: { code: "AUTH_TOKEN_MISMATCH" }, - }, - }); - - expect(host.lastError).toContain("gateway token mismatch"); - expect(host.lastErrorCode).toBe("AUTH_TOKEN_MISMATCH"); }); }); diff --git a/ui/src/ui/app-settings.test.ts b/ui/src/ui/app-settings.test.ts index 48411bbe5b0..98ae8d0e8e1 100644 --- a/ui/src/ui/app-settings.test.ts +++ b/ui/src/ui/app-settings.test.ts @@ -1,5 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { setTabFromRoute } from "./app-settings.ts"; +import { + hasMissingSkillDependencies, + hasOperatorReadAccess, + setTabFromRoute, +} from "./app-settings.ts"; import type { Tab } from "./navigation.ts"; type SettingsHost = Parameters[0] & { @@ -13,14 +17,17 @@ const createHost = (tab: Tab): SettingsHost => ({ token: "", sessionKey: "main", lastActiveSessionKey: "main", - theme: "system", + theme: "claw", + themeMode: "system", chatFocusMode: false, chatShowThinking: true, splitRatio: 0.6, navCollapsed: false, navGroupsCollapsed: {}, + navWidth: 220, }, - theme: "system", + theme: "claw", + themeMode: "system", themeResolved: "dark", applySessionKey: "main", sessionKey: "main", @@ -31,8 +38,6 @@ const createHost = (tab: Tab): SettingsHost => ({ eventLog: [], eventLogBuffer: [], basePath: "", - themeMedia: null, - themeMediaHandler: null, logsPollInterval: null, debugPollInterval: null, }); @@ -68,3 +73,53 @@ describe("setTabFromRoute", () => { expect(host.debugPollInterval).toBeNull(); }); }); + +describe("hasOperatorReadAccess", () => { + it("accepts operator.read/operator.write/operator.admin as read-capable", () => { + expect(hasOperatorReadAccess({ role: "operator", scopes: ["operator.read"] })).toBe(true); + expect(hasOperatorReadAccess({ role: "operator", scopes: ["operator.write"] })).toBe(true); + expect(hasOperatorReadAccess({ role: "operator", scopes: ["operator.admin"] })).toBe(true); + }); + + it("returns false when read-compatible scope is missing", () => { + expect(hasOperatorReadAccess({ role: "operator", scopes: ["operator.pairing"] })).toBe(false); + expect(hasOperatorReadAccess({ role: "operator" })).toBe(false); + expect(hasOperatorReadAccess(null)).toBe(false); + }); +}); + +describe("hasMissingSkillDependencies", () => { + it("returns false when all requirement buckets are empty", () => { + expect( + hasMissingSkillDependencies({ + bins: [], + anyBins: [], + env: [], + config: [], + os: [], + }), + ).toBe(false); + }); + + it("returns true when any requirement bucket has entries", () => { + expect( + hasMissingSkillDependencies({ + bins: ["op"], + anyBins: [], + env: [], + config: [], + os: [], + }), + ).toBe(true); + + expect( + hasMissingSkillDependencies({ + bins: [], + anyBins: ["op", "gopass"], + env: [], + config: [], + os: [], + }), + ).toBe(true); + }); +}); diff --git a/ui/src/ui/config-form.browser.test.ts b/ui/src/ui/config-form.browser.test.ts index 25e78e12408..69c16874c8c 100644 --- a/ui/src/ui/config-form.browser.test.ts +++ b/ui/src/ui/config-form.browser.test.ts @@ -296,12 +296,12 @@ describe("config form renderer", () => { }, unsupportedPaths: analysis.unsupportedPaths, value: {}, - searchQuery: "mode tag:security", + searchQuery: "missing tag:security", onPatch, }), noMatchContainer, ); - expect(noMatchContainer.textContent).toContain('No settings match "mode tag:security"'); + expect(noMatchContainer.textContent).toContain('No settings match "missing tag:security"'); }); it("supports SecretInput unions in additionalProperties maps", () => { @@ -381,7 +381,7 @@ describe("config form renderer", () => { expect(onPatch).toHaveBeenCalledWith(["models", "providers", "openai", "apiKey"], "new-key"); }); - it("flags unsupported unions", () => { + it("flags mixed unions as unsupported", () => { const schema = { type: "object", properties: { @@ -431,14 +431,20 @@ describe("config form renderer", () => { const schema = { type: "object", properties: { - accounts: { + extra: { type: "object", additionalProperties: true, }, }, }; const analysis = analyzeConfigSchema(schema); - expect(analysis.unsupportedPaths).not.toContain("accounts"); + expect(analysis.unsupportedPaths).not.toContain("extra"); + const extraSchema = ( + analysis.schema as Record & { + properties: Record>; + } + ).properties.extra; + expect(extraSchema.additionalProperties).toEqual({}); const onPatch = vi.fn(); const container = document.createElement("div"); @@ -447,7 +453,7 @@ describe("config form renderer", () => { schema: analysis.schema, uiHints: {}, unsupportedPaths: analysis.unsupportedPaths, - value: { accounts: { default: { enabled: true } } }, + value: { extra: { default: { enabled: true } } }, onPatch, }), container, @@ -456,6 +462,6 @@ describe("config form renderer", () => { const removeButton = container.querySelector(".cfg-map__item-remove"); expect(removeButton).not.toBeNull(); removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - expect(onPatch).toHaveBeenCalledWith(["accounts"], {}); + expect(onPatch).toHaveBeenCalledWith(["extra"], {}); }); }); diff --git a/ui/src/ui/controllers/agents.test.ts b/ui/src/ui/controllers/agents.test.ts deleted file mode 100644 index 669f62d6362..00000000000 --- a/ui/src/ui/controllers/agents.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { loadToolsCatalog } from "./agents.ts"; -import type { AgentsState } from "./agents.ts"; - -function createState(): { state: AgentsState; request: ReturnType } { - const request = vi.fn(); - const state: AgentsState = { - client: { - request, - } as unknown as AgentsState["client"], - connected: true, - agentsLoading: false, - agentsError: null, - agentsList: null, - agentsSelectedId: "main", - toolsCatalogLoading: false, - toolsCatalogError: null, - toolsCatalogResult: null, - }; - return { state, request }; -} - -describe("loadToolsCatalog", () => { - it("loads catalog and stores result", async () => { - const { state, request } = createState(); - const payload = { - agentId: "main", - profiles: [{ id: "full", label: "Full" }], - groups: [ - { - id: "media", - label: "Media", - source: "core", - tools: [{ id: "tts", label: "tts", description: "Text-to-speech", source: "core" }], - }, - ], - }; - request.mockResolvedValue(payload); - - await loadToolsCatalog(state, "main"); - - expect(request).toHaveBeenCalledWith("tools.catalog", { - agentId: "main", - includePlugins: true, - }); - expect(state.toolsCatalogResult).toEqual(payload); - expect(state.toolsCatalogError).toBeNull(); - expect(state.toolsCatalogLoading).toBe(false); - }); - - it("captures request errors for fallback UI handling", async () => { - const { state, request } = createState(); - request.mockRejectedValue(new Error("gateway unavailable")); - - await loadToolsCatalog(state, "main"); - - expect(state.toolsCatalogResult).toBeNull(); - expect(state.toolsCatalogError).toContain("gateway unavailable"); - expect(state.toolsCatalogLoading).toBe(false); - }); -}); diff --git a/ui/src/ui/controllers/config.test.ts b/ui/src/ui/controllers/config.test.ts index 54d04bb1ea7..6612644af6c 100644 --- a/ui/src/ui/controllers/config.test.ts +++ b/ui/src/ui/controllers/config.test.ts @@ -286,4 +286,19 @@ describe("runUpdate", () => { sessionKey: "agent:main:whatsapp:dm:+15555550123", }); }); + + it("surfaces update errors returned in response payload", async () => { + const request = vi.fn().mockResolvedValue({ + ok: false, + result: { status: "error", reason: "network unavailable" }, + }); + const state = createState(); + state.connected = true; + state.client = { request } as unknown as ConfigState["client"]; + state.applySessionKey = "main"; + + await runUpdate(state); + + expect(state.lastError).toBe("Update error: network unavailable"); + }); }); diff --git a/ui/src/ui/controllers/cron.test.ts b/ui/src/ui/controllers/cron.test.ts index 11a32981635..5430d194347 100644 --- a/ui/src/ui/controllers/cron.test.ts +++ b/ui/src/ui/controllers/cron.test.ts @@ -1022,6 +1022,7 @@ describe("cron controller", () => { }); const state = createState({ client: { request } as unknown as CronState["client"], + cronRunsScope: "job", }); await loadCronRuns(state, "job-1"); diff --git a/ui/src/ui/markdown.test.ts b/ui/src/ui/markdown.test.ts index e355ff922a4..f87645181d7 100644 --- a/ui/src/ui/markdown.test.ts +++ b/ui/src/ui/markdown.test.ts @@ -115,4 +115,14 @@ describe("toSanitizedMarkdownHtml", () => { warnSpy.mockRestore(); } }); + + it("adds a blur class to links that include tail in the url", () => { + const html = toSanitizedMarkdownHtml("[tail](https://docs.openclaw.ai/gateway/tailscale)"); + expect(html).toContain('class="chat-link-tail-blur"'); + }); + + it("does not add blur class to non-tail links", () => { + const html = toSanitizedMarkdownHtml("[docs](https://docs.openclaw.ai/web/dashboard)"); + expect(html).not.toContain('class="chat-link-tail-blur"'); + }); }); diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index 853bc58b6e4..9640d8303fa 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -18,6 +18,7 @@ describe("control UI routing", () => { it("hydrates the tab from the location", async () => { const app = mountApp("/sessions"); await app.updateComplete; + await nextFrame(); expect(app.tab).toBe("sessions"); expect(window.location.pathname).toBe("/sessions"); @@ -26,6 +27,7 @@ describe("control UI routing", () => { it("respects /ui base paths", async () => { const app = mountApp("/ui/cron"); await app.updateComplete; + await nextFrame(); expect(app.basePath).toBe("/ui"); expect(app.tab).toBe("cron"); @@ -35,6 +37,7 @@ describe("control UI routing", () => { it("infers nested base paths", async () => { const app = mountApp("/apps/openclaw/cron"); await app.updateComplete; + await nextFrame(); expect(app.basePath).toBe("/apps/openclaw"); expect(app.tab).toBe("cron"); @@ -45,6 +48,7 @@ describe("control UI routing", () => { window.__OPENCLAW_CONTROL_UI_BASE_PATH__ = "/openclaw"; const app = mountApp("/openclaw/sessions"); await app.updateComplete; + await nextFrame(); expect(app.basePath).toBe("/openclaw"); expect(app.tab).toBe("sessions"); @@ -54,12 +58,14 @@ describe("control UI routing", () => { it("updates the URL when clicking nav items", async () => { const app = mountApp("/chat"); await app.updateComplete; + await nextFrame(); const link = app.querySelector('a.nav-item[href="/channels"]'); expect(link).not.toBeNull(); link?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 })); await app.updateComplete; + await nextFrame(); expect(app.tab).toBe("channels"); expect(window.location.pathname).toBe("/channels"); }); @@ -67,12 +73,14 @@ describe("control UI routing", () => { it("resets to the main session when opening chat from sidebar navigation", async () => { const app = mountApp("/sessions?session=agent:main:subagent:task-123"); await app.updateComplete; + await nextFrame(); const link = app.querySelector('a.nav-item[href="/chat"]'); expect(link).not.toBeNull(); link?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 })); await app.updateComplete; + await nextFrame(); expect(app.tab).toBe("chat"); expect(app.sessionKey).toBe("main"); expect(window.location.pathname).toBe("/chat"); @@ -82,6 +90,7 @@ describe("control UI routing", () => { it("keeps chat and nav usable on narrow viewports", async () => { const app = mountApp("/chat"); await app.updateComplete; + await nextFrame(); expect(window.matchMedia("(max-width: 768px)").matches).toBe(true); @@ -110,6 +119,7 @@ describe("control UI routing", () => { it("auto-scrolls chat history to the latest message", async () => { const app = mountApp("/chat"); await app.updateComplete; + await nextFrame(); const initialContainer: HTMLElement | null = app.querySelector(".chat-thread"); expect(initialContainer).not.toBeNull(); @@ -149,6 +159,7 @@ describe("control UI routing", () => { it("hydrates token from URL params and strips it", async () => { const app = mountApp("/ui/overview?token=abc123"); await app.updateComplete; + await nextFrame(); expect(app.settings.token).toBe("abc123"); expect(window.location.pathname).toBe("/ui/overview"); @@ -158,6 +169,7 @@ describe("control UI routing", () => { it("strips password URL params without importing them", async () => { const app = mountApp("/ui/overview?password=sekret"); await app.updateComplete; + await nextFrame(); expect(app.password).toBe(""); expect(window.location.pathname).toBe("/ui/overview"); @@ -171,6 +183,7 @@ describe("control UI routing", () => { ); const app = mountApp("/ui/overview?token=abc123"); await app.updateComplete; + await nextFrame(); expect(app.settings.token).toBe("abc123"); expect(window.location.pathname).toBe("/ui/overview"); @@ -180,6 +193,7 @@ describe("control UI routing", () => { it("hydrates token from URL hash and strips it", async () => { const app = mountApp("/ui/overview#token=abc123"); await app.updateComplete; + await nextFrame(); expect(app.settings.token).toBe("abc123"); expect(window.location.pathname).toBe("/ui/overview"); diff --git a/ui/src/ui/navigation.test.ts b/ui/src/ui/navigation.test.ts index 4ff0279341b..24c551d78ad 100644 --- a/ui/src/ui/navigation.test.ts +++ b/ui/src/ui/navigation.test.ts @@ -26,17 +26,27 @@ describe("iconForTab", () => { }); it("returns stable icons for known tabs", () => { - expect(iconForTab("chat")).toBe("messageSquare"); - expect(iconForTab("overview")).toBe("barChart"); - expect(iconForTab("channels")).toBe("link"); - expect(iconForTab("instances")).toBe("radio"); - expect(iconForTab("sessions")).toBe("fileText"); - expect(iconForTab("cron")).toBe("loader"); - expect(iconForTab("skills")).toBe("zap"); - expect(iconForTab("nodes")).toBe("monitor"); - expect(iconForTab("config")).toBe("settings"); - expect(iconForTab("debug")).toBe("bug"); - expect(iconForTab("logs")).toBe("scrollText"); + const cases = [ + { tab: "chat", icon: "messageSquare" }, + { tab: "overview", icon: "barChart" }, + { tab: "channels", icon: "link" }, + { tab: "instances", icon: "radio" }, + { tab: "sessions", icon: "fileText" }, + { tab: "cron", icon: "loader" }, + { tab: "skills", icon: "zap" }, + { tab: "nodes", icon: "monitor" }, + { tab: "config", icon: "settings" }, + { tab: "communications", icon: "send" }, + { tab: "appearance", icon: "spark" }, + { tab: "automation", icon: "terminal" }, + { tab: "infrastructure", icon: "globe" }, + { tab: "aiAgents", icon: "brain" }, + { tab: "debug", icon: "bug" }, + { tab: "logs", icon: "scrollText" }, + ] as const; + for (const testCase of cases) { + expect(iconForTab(testCase.tab), testCase.tab).toBe(testCase.icon); + } }); it("returns a fallback icon for unknown tab", () => { @@ -56,9 +66,14 @@ describe("titleForTab", () => { }); it("returns expected titles", () => { - expect(titleForTab("chat")).toBe("Chat"); - expect(titleForTab("overview")).toBe("Overview"); - expect(titleForTab("cron")).toBe("Cron Jobs"); + const cases = [ + { tab: "chat", title: "Chat" }, + { tab: "overview", title: "Overview" }, + { tab: "cron", title: "Cron Jobs" }, + ] as const; + for (const testCase of cases) { + expect(titleForTab(testCase.tab), testCase.tab).toBe(testCase.title); + } }); }); @@ -71,114 +86,102 @@ describe("subtitleForTab", () => { }); it("returns descriptive subtitles", () => { - expect(subtitleForTab("chat")).toContain("chat session"); + expect(subtitleForTab("chat")).toContain("chat"); expect(subtitleForTab("config")).toContain("openclaw.json"); }); }); describe("normalizeBasePath", () => { - it("returns empty string for falsy input", () => { - expect(normalizeBasePath("")).toBe(""); - }); - - it("adds leading slash if missing", () => { - expect(normalizeBasePath("ui")).toBe("/ui"); - }); - - it("removes trailing slash", () => { - expect(normalizeBasePath("/ui/")).toBe("/ui"); - }); - - it("returns empty string for root path", () => { - expect(normalizeBasePath("/")).toBe(""); - }); - - it("handles nested paths", () => { - expect(normalizeBasePath("/apps/openclaw")).toBe("/apps/openclaw"); + it("normalizes base-path variants", () => { + const cases = [ + { input: "", expected: "" }, + { input: "ui", expected: "/ui" }, + { input: "/ui/", expected: "/ui" }, + { input: "/", expected: "" }, + { input: "/apps/openclaw", expected: "/apps/openclaw" }, + ] as const; + for (const testCase of cases) { + expect(normalizeBasePath(testCase.input), testCase.input).toBe(testCase.expected); + } }); }); describe("normalizePath", () => { - it("returns / for falsy input", () => { - expect(normalizePath("")).toBe("/"); - }); - - it("adds leading slash if missing", () => { - expect(normalizePath("chat")).toBe("/chat"); - }); - - it("removes trailing slash except for root", () => { - expect(normalizePath("/chat/")).toBe("/chat"); - expect(normalizePath("/")).toBe("/"); + it("normalizes paths", () => { + const cases = [ + { input: "", expected: "/" }, + { input: "chat", expected: "/chat" }, + { input: "/chat/", expected: "/chat" }, + { input: "/", expected: "/" }, + ] as const; + for (const testCase of cases) { + expect(normalizePath(testCase.input), testCase.input).toBe(testCase.expected); + } }); }); describe("pathForTab", () => { - it("returns correct path without base", () => { - expect(pathForTab("chat")).toBe("/chat"); - expect(pathForTab("overview")).toBe("/overview"); - }); - - it("prepends base path", () => { - expect(pathForTab("chat", "/ui")).toBe("/ui/chat"); - expect(pathForTab("sessions", "/apps/openclaw")).toBe("/apps/openclaw/sessions"); + it("builds tab paths with optional bases", () => { + const cases = [ + { tab: "chat", base: undefined, expected: "/chat" }, + { tab: "overview", base: undefined, expected: "/overview" }, + { tab: "chat", base: "/ui", expected: "/ui/chat" }, + { tab: "sessions", base: "/apps/openclaw", expected: "/apps/openclaw/sessions" }, + ] as const; + for (const testCase of cases) { + expect( + pathForTab(testCase.tab, testCase.base), + `${testCase.tab}:${testCase.base ?? "root"}`, + ).toBe(testCase.expected); + } }); }); describe("tabFromPath", () => { - it("returns tab for valid path", () => { - expect(tabFromPath("/chat")).toBe("chat"); - expect(tabFromPath("/overview")).toBe("overview"); - expect(tabFromPath("/sessions")).toBe("sessions"); - }); - - it("returns chat for root path", () => { - expect(tabFromPath("/")).toBe("chat"); - }); - - it("handles base paths", () => { - expect(tabFromPath("/ui/chat", "/ui")).toBe("chat"); - expect(tabFromPath("/apps/openclaw/sessions", "/apps/openclaw")).toBe("sessions"); - }); - - it("returns null for unknown path", () => { - expect(tabFromPath("/unknown")).toBeNull(); - }); - - it("is case-insensitive", () => { - expect(tabFromPath("/CHAT")).toBe("chat"); - expect(tabFromPath("/Overview")).toBe("overview"); + it("resolves tabs from path variants", () => { + const cases = [ + { path: "/chat", base: undefined, expected: "chat" }, + { path: "/overview", base: undefined, expected: "overview" }, + { path: "/sessions", base: undefined, expected: "sessions" }, + { path: "/", base: undefined, expected: "chat" }, + { path: "/ui/chat", base: "/ui", expected: "chat" }, + { path: "/apps/openclaw/sessions", base: "/apps/openclaw", expected: "sessions" }, + { path: "/unknown", base: undefined, expected: null }, + { path: "/CHAT", base: undefined, expected: "chat" }, + { path: "/Overview", base: undefined, expected: "overview" }, + ] as const; + for (const testCase of cases) { + expect( + tabFromPath(testCase.path, testCase.base), + `${testCase.path}:${testCase.base ?? "root"}`, + ).toBe(testCase.expected); + } }); }); describe("inferBasePathFromPathname", () => { - it("returns empty string for root", () => { - expect(inferBasePathFromPathname("/")).toBe(""); - }); - - it("returns empty string for direct tab path", () => { - expect(inferBasePathFromPathname("/chat")).toBe(""); - expect(inferBasePathFromPathname("/overview")).toBe(""); - }); - - it("infers base path from nested paths", () => { - expect(inferBasePathFromPathname("/ui/chat")).toBe("/ui"); - expect(inferBasePathFromPathname("/apps/openclaw/sessions")).toBe("/apps/openclaw"); - }); - - it("handles index.html suffix", () => { - expect(inferBasePathFromPathname("/index.html")).toBe(""); - expect(inferBasePathFromPathname("/ui/index.html")).toBe("/ui"); + it("infers base-path variants from pathname", () => { + const cases = [ + { path: "/", expected: "" }, + { path: "/chat", expected: "" }, + { path: "/overview", expected: "" }, + { path: "/ui/chat", expected: "/ui" }, + { path: "/apps/openclaw/sessions", expected: "/apps/openclaw" }, + { path: "/index.html", expected: "" }, + { path: "/ui/index.html", expected: "/ui" }, + ] as const; + for (const testCase of cases) { + expect(inferBasePathFromPathname(testCase.path), testCase.path).toBe(testCase.expected); + } }); }); describe("TAB_GROUPS", () => { it("contains all expected groups", () => { - const labels = TAB_GROUPS.map((g) => g.label); - expect(labels).toContain("Chat"); - expect(labels).toContain("Control"); - expect(labels).toContain("Agent"); - expect(labels).toContain("Settings"); + const labels = TAB_GROUPS.map((g) => g.label.toLowerCase()); + for (const expected of ["chat", "control", "agent", "settings"]) { + expect(labels).toContain(expected); + } }); it("all tabs are unique", () => { diff --git a/ui/src/ui/open-external-url.test.ts b/ui/src/ui/open-external-url.test.ts index d79ef099bd4..4870fa8a6e9 100644 --- a/ui/src/ui/open-external-url.test.ts +++ b/ui/src/ui/open-external-url.test.ts @@ -89,13 +89,13 @@ describe("openExternalUrlSafe", () => { const openedLikeProxy = { opener: { postMessage: () => void 0 }, } as unknown as WindowProxy; - const openMock = vi.fn(() => openedLikeProxy); - vi.stubGlobal("window", { - location: { href: "https://openclaw.ai/chat" }, - open: openMock, - } as unknown as Window & typeof globalThis); + const openMock = vi + .spyOn(window, "open") + .mockImplementation(() => openedLikeProxy as unknown as Window); - const opened = openExternalUrlSafe("https://example.com/safe.png"); + const opened = openExternalUrlSafe("https://example.com/safe.png", { + baseHref: "https://openclaw.ai/chat", + }); expect(openMock).toHaveBeenCalledWith( "https://example.com/safe.png", diff --git a/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts b/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts deleted file mode 100644 index 1917e982e44..00000000000 --- a/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { render } from "lit"; -import { describe, expect, it } from "vitest"; -import { renderAgentTools } from "./agents-panels-tools-skills.ts"; - -function createBaseParams(overrides: Partial[0]> = {}) { - return { - agentId: "main", - configForm: { - agents: { - list: [{ id: "main", tools: { profile: "full" } }], - }, - } as Record, - configLoading: false, - configSaving: false, - configDirty: false, - toolsCatalogLoading: false, - toolsCatalogError: null, - toolsCatalogResult: null, - onProfileChange: () => undefined, - onOverridesChange: () => undefined, - onConfigReload: () => undefined, - onConfigSave: () => undefined, - ...overrides, - }; -} - -describe("agents tools panel (browser)", () => { - it("renders per-tool provenance badges and optional marker", async () => { - const container = document.createElement("div"); - render( - renderAgentTools( - createBaseParams({ - toolsCatalogResult: { - agentId: "main", - profiles: [ - { id: "minimal", label: "Minimal" }, - { id: "coding", label: "Coding" }, - { id: "messaging", label: "Messaging" }, - { id: "full", label: "Full" }, - ], - groups: [ - { - id: "media", - label: "Media", - source: "core", - tools: [ - { - id: "tts", - label: "tts", - description: "Text-to-speech conversion", - source: "core", - defaultProfiles: [], - }, - ], - }, - { - id: "plugin:voice-call", - label: "voice-call", - source: "plugin", - pluginId: "voice-call", - tools: [ - { - id: "voice_call", - label: "voice_call", - description: "Voice call tool", - source: "plugin", - pluginId: "voice-call", - optional: true, - defaultProfiles: [], - }, - ], - }, - ], - }, - }), - ), - container, - ); - await Promise.resolve(); - - const text = container.textContent ?? ""; - expect(text).toContain("core"); - expect(text).toContain("plugin:voice-call"); - expect(text).toContain("optional"); - }); - - it("shows fallback warning when runtime catalog fails", async () => { - const container = document.createElement("div"); - render( - renderAgentTools( - createBaseParams({ - toolsCatalogError: "unavailable", - toolsCatalogResult: null, - }), - ), - container, - ); - await Promise.resolve(); - - expect(container.textContent ?? "").toContain("Could not load runtime tool catalog"); - }); -}); diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 8c3828a133a..e8911e47315 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -45,6 +45,9 @@ function createProps(overrides: Partial = {}): ChatProps { onSend: () => undefined, onQueueRemove: () => undefined, onNewSession: () => undefined, + agentsList: null, + currentAgentId: "main", + onAgentChange: () => undefined, ...overrides, }; } @@ -188,40 +191,38 @@ describe("chat view", () => { renderChat( createProps({ canAbort: true, + sending: true, onAbort, }), ), container, ); - const stopButton = Array.from(container.querySelectorAll("button")).find( - (btn) => btn.textContent?.trim() === "Stop", - ); - expect(stopButton).not.toBeUndefined(); + const stopButton = container.querySelector('button[title="Stop"]'); + expect(stopButton).not.toBeNull(); stopButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onAbort).toHaveBeenCalledTimes(1); expect(container.textContent).not.toContain("New session"); }); - it("shows a new session button when aborting is unavailable", () => { + it("shows send button when aborting is unavailable", () => { const container = document.createElement("div"); - const onNewSession = vi.fn(); + const onSend = vi.fn(); render( renderChat( createProps({ canAbort: false, - onNewSession, + draft: "hello", + onSend, }), ), container, ); - const newSessionButton = Array.from(container.querySelectorAll("button")).find( - (btn) => btn.textContent?.trim() === "New session", - ); - expect(newSessionButton).not.toBeUndefined(); - newSessionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - expect(onNewSession).toHaveBeenCalledTimes(1); - expect(container.textContent).not.toContain("Stop"); + const sendButton = container.querySelector('button[title="Send"]'); + expect(sendButton).not.toBeNull(); + sendButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onSend).toHaveBeenCalledTimes(1); + expect(container.querySelector('button[title="Stop"]')).toBeNull(); }); }); diff --git a/ui/src/ui/views/config.browser.test.ts b/ui/src/ui/views/config.browser.test.ts index 889d046f942..be2be330e44 100644 --- a/ui/src/ui/views/config.browser.test.ts +++ b/ui/src/ui/views/config.browser.test.ts @@ -20,11 +20,13 @@ 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(), @@ -35,6 +37,13 @@ describe("config view", () => { onApply: vi.fn(), onUpdate: vi.fn(), onSubsectionChange: vi.fn(), + version: "2026.2.22", + theme: "claw" as const, + themeMode: "system" as const, + setTheme: vi.fn(), + setThemeMode: vi.fn(), + gatewayUrl: "ws://127.0.0.1:18789", + assistantName: "OpenClaw", }); function findActionButtons(container: HTMLElement): { @@ -48,7 +57,7 @@ describe("config view", () => { }; } - it("allows save when form is unsafe", () => { + it("allows save with mixed union schemas", () => { const container = document.createElement("div"); render( renderConfig({ @@ -134,7 +143,7 @@ describe("config view", () => { expect(applyButton?.disabled).toBe(false); }); - it("switches mode via the sidebar toggle", () => { + it("switches mode via the mode toggle", () => { const container = document.createElement("div"); const onFormModeChange = vi.fn(); render( @@ -153,7 +162,7 @@ describe("config view", () => { expect(onFormModeChange).toHaveBeenCalledWith("raw"); }); - it("switches sections from the sidebar", () => { + it("switches sections from the top tabs", () => { const container = document.createElement("div"); const onSectionChange = vi.fn(); render( @@ -179,6 +188,38 @@ describe("config view", () => { expect(onSectionChange).toHaveBeenCalledWith("gateway"); }); + it("marks the active section tab as active", () => { + const container = document.createElement("div"); + render( + renderConfig({ + ...baseProps(), + activeSection: "gateway", + schema: { + type: "object", + properties: { + gateway: { type: "object", properties: {} }, + }, + }, + }), + container, + ); + + const tab = Array.from(container.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "Gateway", + ); + expect(tab?.classList.contains("active")).toBe(true); + }); + + it("marks the root tab as active when no section is selected", () => { + const container = document.createElement("div"); + render(renderConfig(baseProps()), container); + + const tab = Array.from(container.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "Settings", + ); + expect(tab?.classList.contains("active")).toBe(true); + }); + it("wires search input to onSearchChange", () => { const container = document.createElement("div"); const onSearchChange = vi.fn(); @@ -199,35 +240,4 @@ describe("config view", () => { input.dispatchEvent(new Event("input", { bubbles: true })); expect(onSearchChange).toHaveBeenCalledWith("gateway"); }); - - it("shows all tag options in compact tag picker", () => { - const container = document.createElement("div"); - render(renderConfig(baseProps()), container); - - 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", () => { - const container = document.createElement("div"); - const onSearchChange = vi.fn(); - render( - renderConfig({ - ...baseProps(), - onSearchChange, - }), - container, - ); - - const option = container.querySelector( - '.config-search__tag-option[data-tag="security"]', - ); - expect(option).toBeTruthy(); - option?.click(); - expect(onSearchChange).toHaveBeenCalledWith("tag:security"); - }); }); diff --git a/ui/src/ui/views/cron.test.ts b/ui/src/ui/views/cron.test.ts index 1fdfd836488..7d611c3441e 100644 --- a/ui/src/ui/views/cron.test.ts +++ b/ui/src/ui/views/cron.test.ts @@ -41,6 +41,7 @@ function createProps(overrides: Partial = {}): CronProps { editingJobId: null, channels: [], channelLabels: {}, + channelMeta: [], runsJobId: null, runs: [], runsTotal: 0, @@ -73,46 +74,17 @@ function createProps(overrides: Partial = {}): CronProps { onJobsFiltersReset: () => undefined, onLoadMoreRuns: () => undefined, onRunsFiltersChange: () => undefined, + onRunsFiltersReset: () => undefined, ...overrides, }; } describe("cron view", () => { - it("shows all-job history mode by default", () => { + it("prompts to select a job before showing run history", () => { const container = document.createElement("div"); - render(renderCron(createProps()), container); + render(renderCron(createProps({ runsScope: "job" })), container); - expect(container.textContent).toContain("Latest runs across all jobs."); - expect(container.textContent).toContain("Status"); - expect(container.textContent).toContain("All statuses"); - expect(container.textContent).toContain("Delivery"); - expect(container.textContent).toContain("All delivery"); - expect(container.textContent).not.toContain("multi-select"); - }); - - it("toggles run status filter via dropdown checkboxes", () => { - const container = document.createElement("div"); - const onRunsFiltersChange = vi.fn(); - render( - renderCron( - createProps({ - onRunsFiltersChange, - }), - ), - container, - ); - - const statusOk = container.querySelector( - '.cron-filter-dropdown[data-filter="status"] input[value="ok"]', - ); - expect(statusOk).not.toBeNull(); - if (!(statusOk instanceof HTMLInputElement)) { - return; - } - statusOk.checked = true; - statusOk.dispatchEvent(new Event("change", { bubbles: true })); - - expect(onRunsFiltersChange).toHaveBeenCalledWith({ cronRunsStatuses: ["ok"] }); + expect(container.textContent).toContain("Select a job to inspect run history."); }); it("loads run history when clicking a job row", () => { @@ -161,7 +133,7 @@ describe("cron view", () => { expect(historyButton).not.toBeUndefined(); historyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - expect(onLoadRuns).toHaveBeenCalledTimes(1); + expect(onLoadRuns).toHaveBeenCalledTimes(2); expect(onLoadRuns).toHaveBeenCalledWith("job-1"); }); @@ -193,7 +165,7 @@ describe("cron view", () => { ); }); - it("shows selected job name and sorts run history newest first", () => { + it("shows selected job name and preserves run history order", () => { const container = document.createElement("div"); const job = createJob("job-1"); render( @@ -222,8 +194,8 @@ describe("cron view", () => { const summaries = Array.from( runHistoryCard?.querySelectorAll(".list-item .list-sub") ?? [], ).map((el) => (el.textContent ?? "").trim()); - expect(summaries[0]).toBe("newer run"); - expect(summaries[1]).toBe("older run"); + expect(summaries[0]).toBe("older run"); + expect(summaries[1]).toBe("newer run"); }); it("labels past nextRunAtMs as due instead of next", () => { @@ -301,7 +273,6 @@ describe("cron view", () => { expect(onJobsFiltersReset).toHaveBeenCalledTimes(1); }); - it("shows webhook delivery option in the form", () => { const container = document.createElement("div"); render( @@ -341,7 +312,7 @@ describe("cron view", () => { expect(options).not.toContain("Announce summary (default)"); expect(options).toContain("Webhook POST"); expect(options).toContain("None (internal)"); - expect(container.querySelector('input[placeholder="https://example.com/cron"]')).toBeNull(); + expect(container.querySelector('input[placeholder="https://example.invalid/cron"]')).toBeNull(); }); it("shows webhook delivery details for jobs", () => { diff --git a/ui/src/ui/views/sessions.test.ts b/ui/src/ui/views/sessions.test.ts index 453c216592a..1fa65450589 100644 --- a/ui/src/ui/views/sessions.test.ts +++ b/ui/src/ui/views/sessions.test.ts @@ -23,7 +23,18 @@ function buildProps(result: SessionsListResult): SessionsProps { includeGlobal: false, includeUnknown: false, basePath: "", + searchQuery: "", + sortColumn: "updated", + sortDir: "desc", + page: 0, + pageSize: 10, + actionsOpenKey: null, onFiltersChange: () => undefined, + onSearchChange: () => undefined, + onSortChange: () => undefined, + onPageChange: () => undefined, + onPageSizeChange: () => undefined, + onActionsOpenChange: () => undefined, onRefresh: () => undefined, onPatch: () => undefined, onDelete: () => undefined,