test(ui): update dashboard-v2 coverage and expectations

This commit is contained in:
Val Alexander
2026-02-25 04:18:59 -06:00
parent 9dcdb8b4d3
commit 3cc0c2079a
16 changed files with 336 additions and 407 deletions

View File

@@ -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");

View File

@@ -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<typeof vi.fn>;
@@ -16,19 +15,12 @@ type GatewayClientMock = {
};
const gatewayClientInstances: GatewayClientMock[] = [];
const localStorageState = new Map<string, string>();
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");
});
});

View File

@@ -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<typeof setTabFromRoute>[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);
});
});

View File

@@ -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<string, unknown> & {
properties: Record<string, Record<string, unknown>>;
}
).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"], {});
});
});

View File

@@ -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<typeof vi.fn> } {
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);
});
});

View File

@@ -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");
});
});

View File

@@ -1022,6 +1022,7 @@ describe("cron controller", () => {
});
const state = createState({
client: { request } as unknown as CronState["client"],
cronRunsScope: "job",
});
await loadCronRuns(state, "job-1");

View File

@@ -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"');
});
});

View File

@@ -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<HTMLAnchorElement>('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<HTMLAnchorElement>('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");

View File

@@ -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", () => {

View File

@@ -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",

View File

@@ -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<Parameters<typeof renderAgentTools>[0]> = {}) {
return {
agentId: "main",
configForm: {
agents: {
list: [{ id: "main", tools: { profile: "full" } }],
},
} as Record<string, unknown>,
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");
});
});

View File

@@ -45,6 +45,9 @@ function createProps(overrides: Partial<ChatProps> = {}): 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<HTMLButtonElement>('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<HTMLButtonElement>('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();
});
});

View File

@@ -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<HTMLButtonElement>(
'.config-search__tag-option[data-tag="security"]',
);
expect(option).toBeTruthy();
option?.click();
expect(onSearchChange).toHaveBeenCalledWith("tag:security");
});
});

View File

@@ -41,6 +41,7 @@ function createProps(overrides: Partial<CronProps> = {}): CronProps {
editingJobId: null,
channels: [],
channelLabels: {},
channelMeta: [],
runsJobId: null,
runs: [],
runsTotal: 0,
@@ -73,46 +74,17 @@ function createProps(overrides: Partial<CronProps> = {}): 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", () => {

View File

@@ -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,