mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
test(ui): update dashboard-v2 coverage and expectations
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"], {});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1022,6 +1022,7 @@ describe("cron controller", () => {
|
||||
});
|
||||
const state = createState({
|
||||
client: { request } as unknown as CronState["client"],
|
||||
cronRunsScope: "job",
|
||||
});
|
||||
|
||||
await loadCronRuns(state, "job-1");
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user