chore: remove test files to reduce PR scope

This commit is contained in:
Val Alexander
2026-03-12 06:15:47 -05:00
parent 3a12361fed
commit d98628ae6a
25 changed files with 625 additions and 1574 deletions

View File

@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import { OpenClawSchema } from "./zod-schema.js";
import { TelegramConfigSchema } from "./zod-schema.providers-core.js";
describe("telegram custom commands schema", () => {
it("normalizes custom commands", () => {
@@ -40,22 +39,4 @@ describe("telegram custom commands schema", () => {
{ command: "bad_name", description: "Override status" },
]);
});
it("emits string-typed custom command fields in JSON schema", () => {
const jsonSchema = TelegramConfigSchema.toJSONSchema({
target: "draft-07",
unrepresentable: "any",
}) as {
properties?: {
customCommands?: {
items?: { properties?: Record<string, { type?: string }> };
};
};
};
expect(jsonSchema.properties?.customCommands?.items?.properties?.command?.type).toBe("string");
expect(jsonSchema.properties?.customCommands?.items?.properties?.description?.type).toBe(
"string",
);
});
});

View File

@@ -1,359 +0,0 @@
import { parseHTML } from "linkedom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatHost } from "../ui/src/ui/app-chat.ts";
import {
CHAT_ATTACHMENT_ACCEPT,
isSupportedChatAttachmentMimeType,
} from "../ui/src/ui/chat/attachment-support.ts";
import { DeletedMessages } from "../ui/src/ui/chat/deleted-messages.ts";
import { buildChatMarkdown } from "../ui/src/ui/chat/export.ts";
import { getPinnedMessageSummary } from "../ui/src/ui/chat/pinned-summary.ts";
import { messageMatchesSearchQuery } from "../ui/src/ui/chat/search-match.ts";
import {
MAX_CACHED_CHAT_SESSIONS,
getOrCreateSessionCacheValue,
} from "../ui/src/ui/chat/session-cache.ts";
import type { GatewayBrowserClient } from "../ui/src/ui/gateway.ts";
function createStorageMock(): Storage {
const store = new Map<string, string>();
return {
get length() {
return store.size;
},
clear() {
store.clear();
},
getItem(key: string) {
return store.get(key) ?? null;
},
key(index: number) {
return Array.from(store.keys())[index] ?? null;
},
removeItem(key: string) {
store.delete(key);
},
setItem(key: string, value: string) {
store.set(key, String(value));
},
};
}
function createHost(overrides: Partial<ChatHost> = {}): ChatHost & Record<string, unknown> {
return {
client: {
request: vi.fn(),
} as unknown as GatewayBrowserClient,
chatMessages: [{ role: "assistant", content: "existing", timestamp: 1 }],
chatStream: "streaming",
connected: true,
chatMessage: "",
chatAttachments: [],
chatQueue: [],
chatRunId: "run-1",
chatSending: false,
lastError: null,
sessionKey: "main",
basePath: "",
hello: null,
chatAvatarUrl: null,
refreshSessionsAfterChat: new Set<string>(),
updateComplete: Promise.resolve(),
querySelector: () => null,
style: { setProperty: () => undefined } as CSSStyleDeclaration,
chatScrollFrame: null,
chatScrollTimeout: null,
chatHasAutoScrolled: false,
chatUserNearBottom: true,
chatNewMessagesBelow: false,
logsScrollFrame: null,
logsAtBottom: true,
topbarObserver: null,
...overrides,
};
}
function createSettingsHost() {
return {
settings: {
gatewayUrl: "",
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
},
theme: "claw",
themeMode: "system",
themeResolved: "dark",
applySessionKey: "main",
sessionKey: "main",
tab: "chat",
connected: false,
chatHasAutoScrolled: false,
logsAtBottom: false,
eventLog: [],
eventLogBuffer: [],
basePath: "",
systemThemeCleanup: null,
} as Record<string, unknown>;
}
beforeEach(() => {
vi.resetModules();
const { window, document } = parseHTML("<html><body></body></html>");
vi.stubGlobal("localStorage", createStorageMock());
vi.stubGlobal("sessionStorage", createStorageMock());
vi.stubGlobal("window", window as unknown as Window & typeof globalThis);
vi.stubGlobal("document", document as unknown as Document);
vi.stubGlobal("location", {
protocol: "https:",
host: "gateway.example:8443",
pathname: "/",
} as Location);
vi.stubGlobal("customElements", window.customElements);
vi.stubGlobal("HTMLElement", window.HTMLElement);
vi.stubGlobal("Element", window.Element);
vi.stubGlobal("Node", window.Node);
vi.stubGlobal("DocumentFragment", window.DocumentFragment);
vi.stubGlobal("navigator", { language: "en-US" } as Navigator);
Object.defineProperty(window, "matchMedia", {
value: () => ({ matches: false }),
configurable: true,
});
vi.stubGlobal("requestAnimationFrame", ((cb: FrameRequestCallback) => {
cb(0);
return 1;
}) as typeof requestAnimationFrame);
vi.stubGlobal("cancelAnimationFrame", (() => undefined) as typeof cancelAnimationFrame);
vi.stubGlobal("getComputedStyle", (() => ({ overflowY: "auto" })) as typeof getComputedStyle);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("chat regressions", () => {
it("keeps the picker image-only", () => {
expect(CHAT_ATTACHMENT_ACCEPT).toBe("image/*");
expect(isSupportedChatAttachmentMimeType("image/png")).toBe(true);
expect(isSupportedChatAttachmentMimeType("application/pdf")).toBe(false);
expect(isSupportedChatAttachmentMimeType("text/plain")).toBe(false);
});
it("summarizes pinned messages from structured content blocks", () => {
expect(
getPinnedMessageSummary({
role: "assistant",
content: [{ type: "text", text: "hello from structured content" }],
}),
).toBe("hello from structured content");
});
it("degrades gracefully when deleted-message persistence cannot write", () => {
const failingStorage = createStorageMock();
vi.spyOn(failingStorage, "setItem").mockImplementation(() => {
throw new Error("quota exceeded");
});
vi.stubGlobal("localStorage", failingStorage);
const deleted = new DeletedMessages("main");
expect(() => deleted.delete("msg-1")).not.toThrow();
expect(() => deleted.restore("msg-1")).not.toThrow();
expect(() => deleted.clear()).not.toThrow();
});
it("exports structured message content instead of blank blocks", () => {
const markdown = buildChatMarkdown(
[
{
role: "user",
content: [{ type: "text", text: "hello there" }],
timestamp: Date.UTC(2026, 2, 10, 12, 0, 0),
},
{
role: "assistant",
content: [{ type: "text", text: "general kenobi" }],
timestamp: Date.UTC(2026, 2, 10, 12, 0, 5),
},
],
"OpenClaw",
);
expect(markdown).toContain("hello there");
expect(markdown).toContain("general kenobi");
});
it("matches chat search against extracted structured message text", () => {
expect(
messageMatchesSearchQuery(
{
role: "assistant",
content: [{ type: "text", text: "Structured search target" }],
},
"search target",
),
).toBe(true);
expect(
messageMatchesSearchQuery(
{
role: "assistant",
content: [{ type: "text", text: "Structured search target" }],
},
"missing",
),
).toBe(false);
});
it("bounds cached per-session chat state", () => {
const cache = new Map<string, number>();
for (let i = 0; i < MAX_CACHED_CHAT_SESSIONS; i++) {
getOrCreateSessionCacheValue(cache, `session-${i}`, () => i);
}
expect(cache.size).toBe(MAX_CACHED_CHAT_SESSIONS);
expect(getOrCreateSessionCacheValue(cache, "session-0", () => -1)).toBe(0);
getOrCreateSessionCacheValue(cache, `session-${MAX_CACHED_CHAT_SESSIONS}`, () => 99);
expect(cache.size).toBe(MAX_CACHED_CHAT_SESSIONS);
expect(cache.has("session-0")).toBe(true);
expect(cache.has("session-1")).toBe(false);
});
it("keeps the command palette in sync with slash commands", async () => {
const { getPaletteItems } = await import("../ui/src/ui/views/command-palette.ts");
const labels = getPaletteItems().map((item) => item.label);
expect(labels).toContain("/agents");
expect(labels).toContain("/clear");
expect(labels).toContain("/kill");
expect(labels).toContain("/skill");
expect(labels).toContain("/steer");
});
it("falls back to addListener/removeListener for system theme changes", async () => {
const { attachThemeListener, detachThemeListener } =
await import("../ui/src/ui/app-settings.ts");
const host = createSettingsHost();
const addListener = vi.fn();
const removeListener = vi.fn();
vi.stubGlobal(
"matchMedia",
vi.fn(() => ({
matches: false,
addListener,
removeListener,
})),
);
attachThemeListener(host);
expect(addListener).toHaveBeenCalledTimes(1);
detachThemeListener(host);
expect(removeListener).toHaveBeenCalledTimes(1);
});
it("queues local slash commands that would mutate session state during an active run", async () => {
const { handleSendChat } = await import("../ui/src/ui/app-chat.ts");
const request = vi.fn();
const host = createHost({
client: { request } as unknown as GatewayBrowserClient,
chatMessage: "/new",
chatRunId: "run-1",
chatSending: false,
});
await handleSendChat(host);
expect(request).not.toHaveBeenCalled();
expect(host.chatMessage).toBe("");
expect(host.chatQueue).toHaveLength(1);
expect(host.chatQueue[0]?.text).toBe("/new");
expect(host.chatQueue[0]?.refreshSessions).toBe(true);
});
it("replays queued local slash commands through the local dispatch path", async () => {
const { flushChatQueueForEvent, handleSendChat } = await import("../ui/src/ui/app-chat.ts");
const request = vi.fn(async (method: string, payload?: unknown) => {
if (method === "sessions.reset") {
expect(payload).toEqual({ key: "main" });
return { ok: true };
}
if (method === "chat.history") {
expect(payload).toEqual({ sessionKey: "main", limit: 200 });
return { messages: [], thinkingLevel: null };
}
throw new Error(`unexpected method: ${method}`);
});
const host = createHost({
client: { request } as unknown as GatewayBrowserClient,
chatMessage: "/clear",
chatRunId: "run-1",
chatSending: false,
});
await handleSendChat(host);
expect(host.chatQueue).toHaveLength(1);
host.chatRunId = null;
await flushChatQueueForEvent(host);
expect(request).toHaveBeenNthCalledWith(1, "sessions.reset", { key: "main" });
expect(request).toHaveBeenNthCalledWith(2, "chat.history", {
sessionKey: "main",
limit: 200,
});
expect(host.chatQueue).toEqual([]);
expect(host.chatMessages).toEqual([]);
});
it("resets persisted history for /clear", async () => {
const { handleSendChat } = await import("../ui/src/ui/app-chat.ts");
const request = vi.fn(async (method: string, payload?: unknown) => {
if (method === "sessions.reset") {
expect(payload).toEqual({ key: "main" });
return { ok: true };
}
if (method === "chat.history") {
expect(payload).toEqual({ sessionKey: "main", limit: 200 });
return { messages: [], thinkingLevel: null };
}
throw new Error(`unexpected method: ${method}`);
});
const host = createHost({
client: { request } as unknown as GatewayBrowserClient,
chatMessage: "/clear",
chatRunId: null,
});
await handleSendChat(host);
expect(request).toHaveBeenNthCalledWith(1, "sessions.reset", { key: "main" });
expect(request).toHaveBeenNthCalledWith(2, "chat.history", {
sessionKey: "main",
limit: 200,
});
expect(host.chatMessage).toBe("");
expect(host.chatMessages).toEqual([]);
expect(host.chatRunId).toBeNull();
expect(host.chatStream).toBeNull();
});
it("initializes the app with a generated client instance id", async () => {
const { OpenClawApp } = await import("../ui/src/ui/app.ts");
const app = new OpenClawApp();
expect(typeof app.clientInstanceId).toBe("string");
expect(app.clientInstanceId.length).toBeGreaterThan(0);
});
});

View File

@@ -1,52 +0,0 @@
import "../styles.css";
import { render } from "lit";
import { describe, expect, it } from "vitest";
import { renderAgentFiles } from "./views/agents-panels-status-files.ts";
describe("agent files editor styling", () => {
it("uses a taller editor and blurs file contents by default", () => {
const container = document.createElement("div");
document.body.append(container);
render(
renderAgentFiles({
agentId: "agent-1",
agentFilesList: {
agentId: "agent-1",
workspace: "/tmp/agent-1",
files: [
{
name: "AGENTS.md",
path: "/tmp/agent-1/AGENTS.md",
size: 128,
updatedAtMs: Date.now(),
missing: false,
},
],
},
agentFilesLoading: false,
agentFilesError: null,
agentFileActive: "AGENTS.md",
agentFileContents: { "AGENTS.md": "# AGENTS.md" },
agentFileDrafts: { "AGENTS.md": "# AGENTS.md" },
agentFileSaving: false,
onLoadFiles: () => {},
onSelectFile: () => {},
onFileDraftChange: () => {},
onFileReset: () => {},
onFileSave: () => {},
}),
container,
);
const field = container.querySelector<HTMLElement>(".agent-file-field");
const textarea = container.querySelector<HTMLTextAreaElement>(".agent-file-textarea");
const fieldMinHeight = Number.parseFloat(getComputedStyle(field!).minHeight);
const textareaMinHeight = Number.parseFloat(getComputedStyle(textarea!).minHeight);
expect(field).not.toBeNull();
expect(textarea).not.toBeNull();
expect(fieldMinHeight).toBeGreaterThanOrEqual(320);
expect(textareaMinHeight).toBeGreaterThanOrEqual(320);
expect(getComputedStyle(textarea!).filter).toContain("blur");
});
});

View File

@@ -1,29 +1,9 @@
import { describe, expect, it, vi } from "vitest";
vi.hoisted(() => {
const storage = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
clear: () => storage.clear(),
},
});
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: { language: "en-US" },
});
return {};
});
import { describe, expect, it } from "vitest";
import {
isCronSessionKey,
parseSessionKey,
resolveSessionOptionGroups,
resolveSessionDisplayName,
} from "./app-render.helpers.ts";
import type { AppViewState } from "./app-view-state.ts";
import type { SessionsListResult } from "./types.ts";
type SessionRow = SessionsListResult["sessions"][number];
@@ -32,14 +12,6 @@ function row(overrides: Partial<SessionRow> & { key: string }): SessionRow {
return { kind: "direct", updatedAt: 0, ...overrides };
}
function testState(overrides: Partial<AppViewState> = {}): AppViewState {
return {
agentsList: null,
sessionsHideCron: true,
...overrides,
} as AppViewState;
}
/* ================================================================
* parseSessionKey low-level key → type / fallback mapping
* ================================================================ */
@@ -312,44 +284,3 @@ describe("isCronSessionKey", () => {
expect(isCronSessionKey("agent:main:slack:cron:job:run:uuid")).toBe(false);
});
});
describe("resolveSessionOptionGroups", () => {
const sessions: SessionsListResult = {
ts: 0,
path: "",
count: 3,
defaults: { model: null, contextTokens: null },
sessions: [
row({ key: "agent:main:main" }),
row({ key: "agent:main:cron:daily" }),
row({ key: "agent:main:discord:direct:user-1" }),
],
};
it("filters cron sessions from options when the hide toggle is enabled", () => {
const groups = resolveSessionOptionGroups(
testState({ sessionsHideCron: true }),
"agent:main:main",
sessions,
);
expect(groups.flatMap((group) => group.options.map((option) => option.key))).toEqual([
"agent:main:main",
"agent:main:discord:direct:user-1",
]);
});
it("retains the active cron session even when cron sessions are hidden", () => {
const groups = resolveSessionOptionGroups(
testState({ sessionsHideCron: true }),
"agent:main:cron:daily",
sessions,
);
expect(groups.flatMap((group) => group.options.map((option) => option.key))).toEqual([
"agent:main:main",
"agent:main:cron:daily",
"agent:main:discord:direct:user-1",
]);
});
});

View File

@@ -1,12 +1,85 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { attachThemeListener, detachThemeListener, setTabFromRoute } from "./app-settings.ts";
import type { Tab } from "./navigation.ts";
import type { ThemeMode, ThemeName } from "./theme.ts";
type SettingsHost = Parameters<typeof setTabFromRoute>[0] & {
type Tab =
| "agents"
| "overview"
| "channels"
| "instances"
| "sessions"
| "usage"
| "cron"
| "skills"
| "nodes"
| "chat"
| "config"
| "communications"
| "appearance"
| "automation"
| "infrastructure"
| "aiAgents"
| "debug"
| "logs";
type AppSettingsModule = typeof import("./app-settings.ts");
type SettingsHost = {
settings: {
gatewayUrl: string;
token: string;
sessionKey: string;
lastActiveSessionKey: string;
theme: ThemeName;
themeMode: ThemeMode;
chatFocusMode: boolean;
chatShowThinking: boolean;
splitRatio: number;
navCollapsed: boolean;
navWidth: number;
navGroupsCollapsed: Record<string, boolean>;
};
theme: ThemeName & ThemeMode;
themeMode: ThemeMode;
themeResolved: import("./theme.ts").ResolvedTheme;
applySessionKey: string;
sessionKey: string;
tab: Tab;
connected: boolean;
chatHasAutoScrolled: boolean;
logsAtBottom: boolean;
eventLog: unknown[];
eventLogBuffer: unknown[];
basePath: string;
themeMedia: MediaQueryList | null;
themeMediaHandler: ((event: MediaQueryListEvent) => void) | null;
logsPollInterval: number | null;
debugPollInterval: number | null;
};
function createStorageMock(): Storage {
const store = new Map<string, string>();
return {
get length() {
return store.size;
},
clear() {
store.clear();
},
getItem(key: string) {
return store.get(key) ?? null;
},
key(index: number) {
return Array.from(store.keys())[index] ?? null;
},
removeItem(key: string) {
store.delete(key);
},
setItem(key: string, value: string) {
store.set(key, String(value));
},
};
}
const createHost = (tab: Tab): SettingsHost => ({
settings: {
gatewayUrl: "",
@@ -22,7 +95,7 @@ const createHost = (tab: Tab): SettingsHost => ({
navWidth: 220,
navGroupsCollapsed: {},
},
theme: "claw",
theme: "claw" as unknown as ThemeName & ThemeMode,
themeMode: "system",
themeResolved: "dark",
applySessionKey: "main",
@@ -34,59 +107,129 @@ const createHost = (tab: Tab): SettingsHost => ({
eventLog: [],
eventLogBuffer: [],
basePath: "",
themeMedia: null,
themeMediaHandler: null,
logsPollInterval: null,
debugPollInterval: null,
systemThemeCleanup: null,
});
describe("setTabFromRoute", () => {
let appSettings: AppSettingsModule;
beforeEach(() => {
vi.useFakeTimers();
vi.resetModules();
vi.stubGlobal("localStorage", createStorageMock());
vi.stubGlobal("navigator", { language: "en-US" } as Navigator);
vi.stubGlobal("window", {
setInterval,
clearInterval,
} as unknown as Window & typeof globalThis);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("starts and stops log polling based on the tab", () => {
it("starts and stops log polling based on the tab", async () => {
appSettings ??= await import("./app-settings.ts");
const host = createHost("chat");
setTabFromRoute(host, "logs");
appSettings.setTabFromRoute(host, "logs");
expect(host.logsPollInterval).not.toBeNull();
expect(host.debugPollInterval).toBeNull();
setTabFromRoute(host, "chat");
appSettings.setTabFromRoute(host, "chat");
expect(host.logsPollInterval).toBeNull();
});
it("starts and stops debug polling based on the tab", () => {
it("starts and stops debug polling based on the tab", async () => {
appSettings ??= await import("./app-settings.ts");
const host = createHost("chat");
setTabFromRoute(host, "debug");
appSettings.setTabFromRoute(host, "debug");
expect(host.debugPollInterval).not.toBeNull();
expect(host.logsPollInterval).toBeNull();
setTabFromRoute(host, "chat");
appSettings.setTabFromRoute(host, "chat");
expect(host.debugPollInterval).toBeNull();
});
it("falls back to addListener/removeListener for older MediaQueryList implementations", () => {
it("re-resolves the active palette when only themeMode changes", async () => {
appSettings ??= await import("./app-settings.ts");
const host = createHost("chat");
const addListener = vi.fn();
const removeListener = vi.fn();
vi.stubGlobal(
"matchMedia",
vi.fn(() => ({
matches: false,
addListener,
removeListener,
})),
);
host.settings.theme = "knot";
host.settings.themeMode = "dark";
host.theme = "knot" as unknown as ThemeName & ThemeMode;
host.themeMode = "dark";
host.themeResolved = "openknot";
attachThemeListener(host);
expect(addListener).toHaveBeenCalledTimes(1);
appSettings.applySettings(host, {
...host.settings,
themeMode: "light",
});
detachThemeListener(host);
expect(removeListener).toHaveBeenCalledTimes(1);
expect(host.theme).toBe("knot");
expect(host.themeMode).toBe("light");
expect(host.themeResolved).toBe("openknot-light");
});
it("syncs both theme family and mode from persisted settings", async () => {
appSettings ??= await import("./app-settings.ts");
const host = createHost("chat");
host.settings.theme = "dash";
host.settings.themeMode = "light";
appSettings.syncThemeWithSettings(host);
expect(host.theme).toBe("dash");
expect(host.themeMode).toBe("light");
expect(host.themeResolved).toBe("dash-light");
});
it("applies named system themes on OS preference changes", async () => {
appSettings ??= await import("./app-settings.ts");
const listeners: Array<(event: MediaQueryListEvent) => void> = [];
const matchMedia = vi.fn().mockReturnValue({
matches: false,
addEventListener: (_name: string, handler: (event: MediaQueryListEvent) => void) => {
listeners.push(handler);
},
removeEventListener: vi.fn(),
});
vi.stubGlobal("matchMedia", matchMedia);
vi.stubGlobal("window", {
setInterval,
clearInterval,
matchMedia,
} as unknown as Window & typeof globalThis);
const host = createHost("chat");
host.theme = "knot" as unknown as ThemeName & ThemeMode;
host.themeMode = "system";
appSettings.attachThemeListener(host);
listeners[0]?.({ matches: true } as MediaQueryListEvent);
expect(host.themeResolved).toBe("openknot");
listeners[0]?.({ matches: false } as MediaQueryListEvent);
expect(host.themeResolved).toBe("openknot-light");
});
it("normalizes light family themes to the shared light CSS token", async () => {
appSettings ??= await import("./app-settings.ts");
const root = {
dataset: {} as DOMStringMap,
style: { colorScheme: "" } as CSSStyleDeclaration & { colorScheme: string },
};
vi.stubGlobal("document", { documentElement: root } as Document);
const host = createHost("chat");
appSettings.applyResolvedTheme(host, "dash-light");
expect(host.themeResolved).toBe("dash-light");
expect(root.dataset.theme).toBe("light");
expect(root.style.colorScheme).toBe("light");
});
});

View File

@@ -1,9 +0,0 @@
import { describe, expect, it } from "vitest";
describe("OpenClawApp", () => {
it("constructs with a generated client instance id", async () => {
const { OpenClawApp } = await import("./app.ts");
const app = new OpenClawApp();
expect(app.clientInstanceId).toMatch(/^[0-9a-f-]{36}$/i);
});
});

View File

@@ -1,62 +0,0 @@
import "../styles.css";
import { render } from "lit";
import { describe, expect, it } from "vitest";
import { renderChat, type ChatProps } from "./views/chat.ts";
function makeProps(): ChatProps {
return {
sessionKey: "main:test",
onSessionKeyChange: () => {},
thinkingLevel: null,
showThinking: false,
loading: false,
sending: false,
messages: [],
toolMessages: [],
streamSegments: [],
stream: null,
streamStartedAt: null,
draft: "",
queue: [],
connected: true,
canSend: true,
disabledReason: null,
error: null,
sessions: null,
focusMode: false,
assistantName: "Nova",
assistantAvatar: null,
onRefresh: () => {},
onToggleFocusMode: () => {},
onDraftChange: () => {},
onSend: () => {},
onQueueRemove: () => {},
onNewSession: () => {},
agentsList: null,
currentAgentId: "default",
onAgentChange: () => {},
};
}
describe("chat composer styling", () => {
it("hides the native file input and renders the unified composer shell", () => {
const container = document.createElement("div");
document.body.append(container);
render(renderChat(makeProps()), container);
const composer = container.querySelector<HTMLElement>(".agent-chat__input");
const fileInput = container.querySelector<HTMLInputElement>(".agent-chat__file-input");
const textarea = container.querySelector<HTMLTextAreaElement>(".agent-chat__input > textarea");
const sendButton = container.querySelector<HTMLElement>(".chat-send-btn");
expect(composer).not.toBeNull();
expect(fileInput).not.toBeNull();
expect(textarea).not.toBeNull();
expect(sendButton).not.toBeNull();
expect(getComputedStyle(composer!).display).toBe("flex");
expect(getComputedStyle(composer!).flexDirection).toBe("column");
expect(getComputedStyle(fileInput!).display).toBe("none");
expect(Number.parseFloat(getComputedStyle(textarea!).minHeight)).toBeGreaterThanOrEqual(40);
expect(getComputedStyle(sendButton!).width).toBe("32px");
});
});

View File

@@ -3,11 +3,13 @@ import type { GatewayBrowserClient } from "../gateway.ts";
import type { GatewaySessionRow } from "../types.ts";
import { executeSlashCommand } from "./slash-command-executor.ts";
function row(key: string): GatewaySessionRow {
function row(key: string, overrides?: Partial<GatewaySessionRow>): GatewaySessionRow {
return {
key,
spawnedBy: overrides?.spawnedBy,
kind: "direct",
updatedAt: null,
...overrides,
};
}
@@ -18,8 +20,11 @@ describe("executeSlashCommand /kill", () => {
return {
sessions: [
row("main"),
row("agent:main:subagent:one"),
row("agent:main:subagent:parent:subagent:child"),
row("agent:main:subagent:one", { spawnedBy: "main" }),
row("agent:main:subagent:parent", { spawnedBy: "main" }),
row("agent:main:subagent:parent:subagent:child", {
spawnedBy: "agent:main:subagent:parent",
}),
row("agent:other:main"),
],
};
@@ -37,12 +42,15 @@ describe("executeSlashCommand /kill", () => {
"all",
);
expect(result.content).toBe("Aborted 2 sub-agent sessions.");
expect(result.content).toBe("Aborted 3 sub-agent sessions.");
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
expect(request).toHaveBeenNthCalledWith(2, "chat.abort", {
sessionKey: "agent:main:subagent:one",
});
expect(request).toHaveBeenNthCalledWith(3, "chat.abort", {
sessionKey: "agent:main:subagent:parent",
});
expect(request).toHaveBeenNthCalledWith(4, "chat.abort", {
sessionKey: "agent:main:subagent:parent:subagent:child",
});
});
@@ -52,9 +60,9 @@ describe("executeSlashCommand /kill", () => {
if (method === "sessions.list") {
return {
sessions: [
row("agent:main:subagent:one"),
row("agent:main:subagent:two"),
row("agent:other:subagent:three"),
row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }),
row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }),
row("agent:other:subagent:three", { spawnedBy: "agent:other:main" }),
],
};
}
@@ -80,4 +88,294 @@ describe("executeSlashCommand /kill", () => {
sessionKey: "agent:main:subagent:two",
});
});
it("does not exact-match a session key outside the current subagent subtree", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("agent:main:subagent:parent", { spawnedBy: "agent:main:main" }),
row("agent:main:subagent:parent:subagent:child", {
spawnedBy: "agent:main:subagent:parent",
}),
row("agent:main:subagent:sibling", { spawnedBy: "agent:main:main" }),
],
};
}
if (method === "chat.abort") {
return { ok: true, aborted: true };
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:subagent:parent",
"kill",
"agent:main:subagent:sibling",
);
expect(result.content).toBe(
"No matching sub-agent sessions found for `agent:main:subagent:sibling`.",
);
expect(request).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
});
it("returns a no-op summary when matching sessions have no active runs", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }),
row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }),
],
};
}
if (method === "chat.abort") {
return { ok: true, aborted: false };
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"kill",
"all",
);
expect(result.content).toBe("No active sub-agent runs to abort.");
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
expect(request).toHaveBeenNthCalledWith(2, "chat.abort", {
sessionKey: "agent:main:subagent:one",
});
expect(request).toHaveBeenNthCalledWith(3, "chat.abort", {
sessionKey: "agent:main:subagent:two",
});
});
it("treats the legacy main session key as the default agent scope", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("main"),
row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }),
row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }),
row("agent:other:subagent:three", { spawnedBy: "agent:other:main" }),
],
};
}
if (method === "chat.abort") {
return { ok: true, aborted: true };
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"main",
"kill",
"all",
);
expect(result.content).toBe("Aborted 2 sub-agent sessions.");
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
expect(request).toHaveBeenNthCalledWith(2, "chat.abort", {
sessionKey: "agent:main:subagent:one",
});
expect(request).toHaveBeenNthCalledWith(3, "chat.abort", {
sessionKey: "agent:main:subagent:two",
});
});
it("does not abort unrelated same-agent subagents from another root session", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("agent:main:main"),
row("agent:main:subagent:mine", { spawnedBy: "agent:main:main" }),
row("agent:main:subagent:mine:subagent:child", {
spawnedBy: "agent:main:subagent:mine",
}),
row("agent:main:subagent:other-root", {
spawnedBy: "agent:main:discord:dm:alice",
}),
],
};
}
if (method === "chat.abort") {
return { ok: true, aborted: true };
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"kill",
"all",
);
expect(result.content).toBe("Aborted 2 sub-agent sessions.");
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
expect(request).toHaveBeenNthCalledWith(2, "chat.abort", {
sessionKey: "agent:main:subagent:mine",
});
expect(request).toHaveBeenNthCalledWith(3, "chat.abort", {
sessionKey: "agent:main:subagent:mine:subagent:child",
});
});
});
describe("executeSlashCommand directives", () => {
it("resolves the legacy main alias for bare /model", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
defaults: { model: "default-model" },
sessions: [
row("agent:main:main", {
model: "gpt-4.1-mini",
}),
],
};
}
if (method === "models.list") {
return {
models: [{ id: "gpt-4.1-mini" }, { id: "gpt-4.1" }],
};
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"main",
"model",
"",
);
expect(result.content).toBe(
"**Current model:** `gpt-4.1-mini`\n**Available:** `gpt-4.1-mini`, `gpt-4.1`",
);
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
expect(request).toHaveBeenNthCalledWith(2, "models.list", {});
});
it("resolves the legacy main alias for /usage", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("agent:main:main", {
model: "gpt-4.1-mini",
inputTokens: 1200,
outputTokens: 300,
totalTokens: 1500,
contextTokens: 4000,
}),
],
};
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"main",
"usage",
"",
);
expect(result.content).toBe(
"**Session Usage**\nInput: **1.2k** tokens\nOutput: **300** tokens\nTotal: **1.5k** tokens\nContext: **30%** of 4k\nModel: `gpt-4.1-mini`",
);
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
});
it("reports the current thinking level for bare /think", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("agent:main:main", {
modelProvider: "openai",
model: "gpt-4.1-mini",
}),
],
};
}
if (method === "models.list") {
return {
models: [{ id: "gpt-4.1-mini", provider: "openai", reasoning: true }],
};
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"think",
"",
);
expect(result.content).toBe(
"Current thinking level: low.\nOptions: off, minimal, low, medium, high, adaptive.",
);
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
expect(request).toHaveBeenNthCalledWith(2, "models.list", {});
});
it("accepts minimal and xhigh thinking levels", async () => {
const request = vi.fn().mockResolvedValueOnce({ ok: true }).mockResolvedValueOnce({ ok: true });
const minimal = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"think",
"minimal",
);
const xhigh = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"think",
"xhigh",
);
expect(minimal.content).toBe("Thinking level set to **minimal**.");
expect(xhigh.content).toBe("Thinking level set to **xhigh**.");
expect(request).toHaveBeenNthCalledWith(1, "sessions.patch", {
key: "agent:main:main",
thinkingLevel: "minimal",
});
expect(request).toHaveBeenNthCalledWith(2, "sessions.patch", {
key: "agent:main:main",
thinkingLevel: "xhigh",
});
});
it("reports the current verbose level for bare /verbose", async () => {
const request = vi.fn(async (method: string, _payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [row("agent:main:main", { verboseLevel: "full" })],
};
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
{ request } as unknown as GatewayBrowserClient,
"agent:main:main",
"verbose",
"",
);
expect(result.content).toBe("Current verbose level: full.\nOptions: on, full, off.");
expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {});
});
});

View File

@@ -1,37 +0,0 @@
import "../styles.css";
import { render } from "lit";
import { describe, expect, it } from "vitest";
import { renderCommandPalette } from "./views/command-palette.ts";
describe("command palette styling", () => {
it("renders the overlay, input, and result items with palette layout styles", () => {
const rendered = renderCommandPalette({
open: true,
query: "",
activeIndex: 0,
onToggle: () => {},
onQueryChange: () => {},
onActiveIndexChange: () => {},
onNavigate: () => {},
onSlashCommand: () => {},
});
const host = document.createElement("div");
document.body.append(host);
render(rendered, host);
const overlay = host.querySelector<HTMLElement>(".cmd-palette-overlay");
const palette = host.querySelector<HTMLElement>(".cmd-palette");
const input = host.querySelector<HTMLElement>(".cmd-palette__input");
const item = host.querySelector<HTMLElement>(".cmd-palette__item");
expect(overlay).not.toBeNull();
expect(palette).not.toBeNull();
expect(input).not.toBeNull();
expect(item).not.toBeNull();
expect(getComputedStyle(overlay!).position).toBe("fixed");
expect(getComputedStyle(overlay!).display).toBe("flex");
expect(getComputedStyle(palette!).width).not.toBe("0px");
expect(getComputedStyle(input!).borderBottomStyle).toBe("solid");
expect(getComputedStyle(item!).display).toBe("flex");
});
});

View File

@@ -1,6 +1,5 @@
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { TelegramConfigSchema } from "../../../src/config/zod-schema.providers-core.ts";
import { analyzeConfigSchema, renderConfigForm } from "./views/config-form.ts";
const rootSchema = {
@@ -52,7 +51,7 @@ describe("config form renderer", () => {
container,
);
const tokenInput: HTMLInputElement | null = container.querySelector(".cfg-input");
const tokenInput: HTMLInputElement | null = container.querySelector("input[type='password']");
expect(tokenInput).not.toBeNull();
if (!tokenInput) {
return;
@@ -78,81 +77,6 @@ describe("config form renderer", () => {
expect(onPatch).toHaveBeenCalledWith(["enabled"], true);
});
it("keeps sensitive values out of hidden form inputs until revealed", () => {
const onPatch = vi.fn();
const container = document.createElement("div");
const analysis = analyzeConfigSchema(rootSchema);
const revealed = new Set<string>();
const props = {
schema: analysis.schema,
uiHints: {
"gateway.auth.token": { label: "Gateway Token", sensitive: true },
},
unsupportedPaths: analysis.unsupportedPaths,
value: { gateway: { auth: { token: "secret-123" } } },
streamMode: false,
isSensitivePathRevealed: (path: Array<string | number>) =>
revealed.has(
path.filter((segment): segment is string => typeof segment === "string").join("."),
),
onToggleSensitivePath: (path: Array<string | number>) => {
const key = path
.filter((segment): segment is string => typeof segment === "string")
.join(".");
if (revealed.has(key)) {
revealed.delete(key);
} else {
revealed.add(key);
}
},
onPatch,
};
render(renderConfigForm(props), container);
const hiddenInput = container.querySelector<HTMLInputElement>(".cfg-input");
expect(hiddenInput).not.toBeNull();
expect(hiddenInput?.value).toBe("");
expect(hiddenInput?.placeholder).toContain("redacted");
const toggle = container.querySelector<HTMLButtonElement>('button[aria-label="Reveal value"]');
expect(toggle?.disabled).toBe(false);
toggle?.click();
render(renderConfigForm(props), container);
const revealedInput = container.querySelector<HTMLInputElement>(".cfg-input");
expect(revealedInput?.value).toBe("secret-123");
expect(revealedInput?.type).toBe("text");
});
it("blocks sensitive field reveal while stream mode is enabled", () => {
const onPatch = vi.fn();
const container = document.createElement("div");
const analysis = analyzeConfigSchema(rootSchema);
render(
renderConfigForm({
schema: analysis.schema,
uiHints: {
"gateway.auth.token": { label: "Gateway Token", sensitive: true },
},
unsupportedPaths: analysis.unsupportedPaths,
value: { gateway: { auth: { token: "secret-123" } } },
streamMode: true,
isSensitivePathRevealed: () => false,
onToggleSensitivePath: vi.fn(),
onPatch,
}),
container,
);
const input = container.querySelector<HTMLInputElement>(".cfg-input");
expect(input?.value).toBe("");
const toggle = container.querySelector<HTMLButtonElement>(
'button[aria-label="Disable stream mode to reveal value"]',
);
expect(toggle?.disabled).toBe(true);
});
it("adds and removes array entries", () => {
const onPatch = vi.fn();
const container = document.createElement("div");
@@ -377,7 +301,7 @@ describe("config form renderer", () => {
}),
noMatchContainer,
);
expect(noMatchContainer.textContent).not.toContain("Token");
expect(noMatchContainer.textContent).toContain('No settings match "mode tag:security"');
});
it("supports SecretInput unions in additionalProperties maps", () => {
@@ -447,9 +371,7 @@ describe("config form renderer", () => {
container,
);
const apiKeyInput: HTMLInputElement | null = container.querySelector(
".cfg-input:not(.cfg-input--sm)",
);
const apiKeyInput: HTMLInputElement | null = container.querySelector("input[type='password']");
expect(apiKeyInput).not.toBeNull();
if (!apiKeyInput) {
return;
@@ -536,27 +458,4 @@ describe("config form renderer", () => {
removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(["accounts"], {});
});
it("accepts the real Telegram channel schema without forcing raw mode", () => {
const schema = {
type: "object",
properties: {
channels: {
type: "object",
properties: {
telegram: TelegramConfigSchema.toJSONSchema({
target: "draft-07",
unrepresentable: "any",
}),
},
},
},
};
const analysis = analyzeConfigSchema(schema);
expect(analysis.unsupportedPaths).not.toContain("channels.telegram");
expect(analysis.unsupportedPaths).not.toContain("channels.telegram.capabilities");
expect(analysis.unsupportedPaths).not.toContain("channels.telegram.customCommands");
expect(analysis.unsupportedPaths).not.toContain("channels.telegram.accounts");
});
});

View File

@@ -1,132 +0,0 @@
import "../styles.css";
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { renderConfig, resetConfigViewStateForTests } from "./views/config.ts";
function baseProps() {
resetConfigViewStateForTests();
return {
raw: "{\n}\n",
originalRaw: "{\n}\n",
valid: true,
issues: [],
loading: false,
saving: false,
applying: false,
updating: false,
connected: true,
schema: {
type: "object",
properties: {
gateway: { type: "object", properties: {} },
communication: {
type: "object",
properties: {
webhookBaseUrl: {
type: "string",
title: "Webhook Base URL",
},
},
},
},
},
schemaLoading: false,
uiHints: {},
formMode: "form" as const,
showModeToggle: true,
formValue: {},
originalValue: {},
searchQuery: "",
activeSection: "communication",
activeSubsection: null,
streamMode: false,
onRawChange: vi.fn(),
onFormModeChange: vi.fn(),
onFormPatch: vi.fn(),
onSearchChange: vi.fn(),
onSectionChange: vi.fn(),
onReload: vi.fn(),
onSave: vi.fn(),
onApply: vi.fn(),
onUpdate: vi.fn(),
onSubsectionChange: vi.fn(),
version: "",
theme: "claw" as const,
themeMode: "system" as const,
setTheme: vi.fn(),
setThemeMode: vi.fn(),
gatewayUrl: "",
assistantName: "",
};
}
describe("config layout width", () => {
it("lets the main config pane span the available width instead of collapsing to a dead sidebar track", () => {
const host = document.createElement("div");
host.style.width = "1200px";
document.body.append(host);
render(renderConfig(baseProps()), host);
const layout = host.querySelector<HTMLElement>(".config-layout");
const main = host.querySelector<HTMLElement>(".config-main");
const card = host.querySelector<HTMLElement>(".config-section-card");
expect(layout).not.toBeNull();
expect(main).not.toBeNull();
expect(card).not.toBeNull();
expect(getComputedStyle(layout!).display).toBe("grid");
expect(main!.getBoundingClientRect().width).toBeGreaterThan(800);
expect(card!.getBoundingClientRect().width).toBeGreaterThan(800);
});
it("lays out the search, tabs, and mode toggle as a real full-width top rail", () => {
const host = document.createElement("div");
host.style.width = "1200px";
document.body.append(host);
render(renderConfig(baseProps()), host);
const topTabs = host.querySelector<HTMLElement>(".config-top-tabs");
const scroller = host.querySelector<HTMLElement>(".config-top-tabs__scroller");
expect(topTabs).not.toBeNull();
expect(scroller).not.toBeNull();
expect(getComputedStyle(topTabs!).display).toBe("flex");
expect(getComputedStyle(scroller!).display).toBe("flex");
});
it("renders the appearance theme picker as styled cards instead of default buttons", () => {
const host = document.createElement("div");
host.style.width = "1200px";
document.body.append(host);
render(
renderConfig({
...baseProps(),
schema: {
type: "object",
properties: {
ui: { type: "object", properties: {} },
wizard: { type: "object", properties: {} },
},
},
activeSection: "__appearance__",
includeVirtualSections: true,
}),
host,
);
const grid = host.querySelector<HTMLElement>(".settings-theme-grid");
const card = host.querySelector<HTMLElement>(".settings-theme-card");
const activeCard = host.querySelector<HTMLElement>(".settings-theme-card--active");
expect(grid).not.toBeNull();
expect(card).not.toBeNull();
expect(activeCard).not.toBeNull();
expect(getComputedStyle(grid!).display).toBe("grid");
expect(getComputedStyle(card!).display).toBe("grid");
expect(getComputedStyle(card!).borderRadius).not.toBe("0px");
expect(getComputedStyle(activeCard!).borderColor).not.toBe("buttonborder");
});
});

View File

@@ -14,7 +14,6 @@ function createState(): { state: AgentsState; request: ReturnType<typeof vi.fn>
agentsList: null,
agentsSelectedId: "main",
toolsCatalogLoading: false,
toolsCatalogLoadingAgentId: null,
toolsCatalogError: null,
toolsCatalogResult: null,
};
@@ -150,71 +149,6 @@ describe("loadToolsCatalog", () => {
expect(state.toolsCatalogError).toContain("gateway unavailable");
expect(state.toolsCatalogLoading).toBe(false);
});
it("allows a new agent request to replace a stale in-flight load", async () => {
const { state, request } = createState();
let resolveMain:
| ((value: {
agentId: string;
profiles: { id: string; label: string }[];
groups: {
id: string;
label: string;
source: string;
tools: { id: string; label: string; description: string; source: string }[];
}[];
}) => void)
| null = null;
const mainRequest = new Promise<{
agentId: string;
profiles: { id: string; label: string }[];
groups: {
id: string;
label: string;
source: string;
tools: { id: string; label: string; description: string; source: string }[];
}[];
}>((resolve) => {
resolveMain = resolve;
});
const replacementPayload = {
agentId: "other",
profiles: [{ id: "full", label: "Full" }],
groups: [],
};
request.mockImplementationOnce(() => mainRequest).mockResolvedValueOnce(replacementPayload);
const initialLoad = loadToolsCatalog(state, "main");
await Promise.resolve();
state.agentsSelectedId = "other";
await loadToolsCatalog(state, "other");
expect(request).toHaveBeenNthCalledWith(1, "tools.catalog", {
agentId: "main",
includePlugins: true,
});
expect(request).toHaveBeenNthCalledWith(2, "tools.catalog", {
agentId: "other",
includePlugins: true,
});
expect(state.toolsCatalogResult).toEqual(replacementPayload);
expect(state.toolsCatalogLoading).toBe(false);
expect(resolveMain).not.toBeNull();
resolveMain!({
agentId: "main",
profiles: [{ id: "full", label: "Full" }],
groups: [],
});
await initialLoad;
expect(state.toolsCatalogResult).toEqual(replacementPayload);
expect(state.toolsCatalogLoading).toBe(false);
});
});
describe("saveAgentsConfig", () => {

View File

@@ -294,50 +294,6 @@ describe("applyConfig", () => {
expect(params.baseHash).toBe("hash-apply-1");
expect(params.sessionKey).toBe("agent:main:web:dm:test");
});
it("preserves sensitive form values in serialized raw state for apply", async () => {
const request = createRequestWithConfigGet();
const state = createState();
state.connected = true;
state.client = { request } as unknown as ConfigState["client"];
state.applySessionKey = "agent:main:web:dm:secret";
state.configFormMode = "form";
state.configForm = {
gateway: {
auth: {
token: "secret-123",
},
},
};
state.configSchema = {
type: "object",
properties: {
gateway: {
type: "object",
properties: {
auth: {
type: "object",
properties: {
token: { type: "string" },
},
},
},
},
},
};
state.configSnapshot = { hash: "hash-apply-secret" };
await applyConfig(state);
expect(request.mock.calls[0]?.[0]).toBe("config.apply");
const params = request.mock.calls[0]?.[1] as {
raw: string;
baseHash: string;
sessionKey: string;
};
expect(params.raw).toContain("secret-123");
expect(params.baseHash).toBe("hash-apply-secret");
});
});
describe("saveConfig", () => {

View File

@@ -1,67 +0,0 @@
import "../styles.css";
import { html, render } from "lit";
import { describe, expect, it } from "vitest";
import { icons } from "./icons.ts";
describe("icon layout styling", () => {
it("styles nav group chevrons as compact inline SVGs", () => {
const container = document.createElement("div");
document.body.append(container);
render(
html`<button class="nav-group__label">
<span class="nav-group__label-text">Control</span>
<span class="nav-group__chevron">${icons.chevronDown}</span>
</button>`,
container,
);
const button = container.querySelector<HTMLElement>(".nav-group__label");
const svg = container.querySelector<SVGElement>(".nav-group__chevron svg");
expect(button).not.toBeNull();
expect(svg).not.toBeNull();
expect(getComputedStyle(button!).display).toBe("flex");
expect(getComputedStyle(svg!).width).toBe("12px");
expect(getComputedStyle(svg!).height).toBe("12px");
});
it("styles tool summary icons without default SVG fallback sizing", () => {
const container = document.createElement("div");
document.body.append(container);
render(
html`<details class="chat-tool-msg-collapse">
<summary class="chat-tool-msg-summary">
<span class="chat-tool-msg-summary__icon">${icons.zap}</span>
<span class="chat-tool-msg-summary__label">Tool output</span>
</summary>
</details>`,
container,
);
const summary = container.querySelector<HTMLElement>(".chat-tool-msg-summary");
const svg = container.querySelector<SVGElement>(".chat-tool-msg-summary__icon svg");
expect(summary).not.toBeNull();
expect(svg).not.toBeNull();
expect(getComputedStyle(summary!).display).toBe("flex");
expect(getComputedStyle(svg!).width).toBe("14px");
expect(getComputedStyle(svg!).height).toBe("14px");
});
it("renders the shared nav collapse trigger with the compact hamburger icon", () => {
const container = document.createElement("div");
document.body.append(container);
render(
html`<button class="nav-collapse-toggle" type="button">
<span class="nav-collapse-toggle__icon">${icons.menu}</span>
</button>`,
container,
);
const button = container.querySelector<HTMLElement>(".nav-collapse-toggle");
const svg = container.querySelector<SVGElement>(".nav-collapse-toggle__icon svg");
expect(button).not.toBeNull();
expect(svg).not.toBeNull();
expect(getComputedStyle(button!).display).toBe("flex");
expect(getComputedStyle(svg!).width).toBe("16px");
expect(getComputedStyle(svg!).height).toBe("16px");
});
});

View File

@@ -1,40 +0,0 @@
import "../styles.css";
import { render } from "lit";
import { describe, expect, it } from "vitest";
import { renderLoginGate } from "./views/login-gate.ts";
describe("login gate layout", () => {
it("applies the disconnected view shell styling", () => {
const container = document.createElement("div");
document.body.append(container);
render(
renderLoginGate({
basePath: "",
settings: {
gatewayUrl: "ws://127.0.0.1:18789",
token: "",
},
loginShowGatewayToken: false,
loginShowGatewayPassword: false,
password: "",
lastError: null,
applySettings: () => {},
connect: () => {},
} as unknown as Parameters<typeof renderLoginGate>[0]),
container,
);
const gate = container.querySelector<HTMLElement>(".login-gate");
const card = container.querySelector<HTMLElement>(".login-gate__card");
const logo = container.querySelector<HTMLElement>(".login-gate__logo");
expect(gate).not.toBeNull();
expect(card).not.toBeNull();
expect(logo).not.toBeNull();
expect(getComputedStyle(gate!).display).toBe("flex");
expect(getComputedStyle(gate!).paddingTop).toBe("24px");
expect(getComputedStyle(card!).paddingLeft).toBe("32px");
expect(getComputedStyle(card!).backgroundColor).not.toBe("rgba(0, 0, 0, 0)");
expect(getComputedStyle(logo!).width).toBe("48px");
});
});

View File

@@ -1,60 +0,0 @@
import "../styles.css";
import { describe, expect, it } from "vitest";
import { mountApp, registerAppMountHooks } from "./test-helpers/app-mount.ts";
registerAppMountHooks();
describe("collapsed nav rail", () => {
it("collapses into a horizontal mobile rail instead of a vertical side strip", async () => {
localStorage.setItem(
"openclaw.control.settings.v1",
JSON.stringify({
gatewayUrl: "ws://127.0.0.1:18789",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: true,
navWidth: 220,
navGroupsCollapsed: {},
}),
);
const app = mountApp("/chat");
await app.updateComplete;
const shell = app.querySelector<HTMLElement>(".shell");
const shellNav = app.querySelector<HTMLElement>(".shell-nav");
const rail = app.querySelector<HTMLElement>(".sidebar");
const nav = app.querySelector<HTMLElement>(".sidebar-nav");
const toggle = app.querySelector<HTMLElement>(".nav-collapse-toggle");
const navItem = app.querySelector<HTMLAnchorElement>('a.nav-item[href="/chat"]');
const footer = app.querySelector<HTMLElement>(".sidebar-footer");
expect(shell).not.toBeNull();
expect(shellNav).not.toBeNull();
expect(rail).not.toBeNull();
expect(nav).not.toBeNull();
expect(toggle).not.toBeNull();
expect(navItem).not.toBeNull();
expect(footer).not.toBeNull();
expect(window.matchMedia("(max-width: 768px)").matches).toBe(true);
expect(shell!.classList.contains("shell--nav-collapsed")).toBe(true);
expect(getComputedStyle(nav!).display).toBe("flex");
expect(getComputedStyle(nav!).flexDirection).toBe("row");
expect(getComputedStyle(shellNav!).width).toBe(`${window.innerWidth}px`);
expect(getComputedStyle(navItem!).justifyContent).toBe("center");
expect(toggle!.getBoundingClientRect().width).toBeGreaterThan(40);
expect(toggle!.getBoundingClientRect().width).toBeLessThan(48);
expect(navItem!.getBoundingClientRect().width).toBeGreaterThan(40);
expect(navItem!.getBoundingClientRect().width).toBeLessThan(48);
expect(navItem!.getBoundingClientRect().left).toBeGreaterThan(
toggle!.getBoundingClientRect().left,
);
expect(getComputedStyle(footer!).display).toBe("none");
expect(app.querySelector(".nav-item__text")).toBeNull();
});
});

View File

@@ -85,18 +85,6 @@ describe("control UI routing", () => {
expect(window.matchMedia("(max-width: 768px)").matches).toBe(true);
const shellNav = app.querySelector<HTMLElement>(".shell-nav");
const sidebarNav = app.querySelector<HTMLElement>(".sidebar-nav");
expect(shellNav).not.toBeNull();
expect(sidebarNav).not.toBeNull();
if (shellNav) {
expect(getComputedStyle(shellNav).width).toBe(`${window.innerWidth}px`);
}
if (sidebarNav) {
expect(getComputedStyle(sidebarNav).display).toBe("flex");
expect(getComputedStyle(sidebarNav).flexDirection).toBe("row");
}
const split = app.querySelector(".chat-split-container");
expect(split).not.toBeNull();
if (split) {
@@ -142,8 +130,6 @@ describe("control UI routing", () => {
await nextFrame();
}
expect(app.tab).toBe("chat");
expect(app.connected).toBe(true);
const container = app.querySelector(".chat-thread");
expect(container).not.toBeNull();
if (!container) {

View File

@@ -1,5 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { UiSettings } from "./storage.ts";
function createStorageMock(): Storage {
const store = new Map<string, string>();
@@ -58,56 +57,11 @@ function setControlUiBasePath(value: string | undefined) {
});
}
function setViteDevScript(enabled: boolean) {
if (
typeof document === "undefined" ||
typeof (document as Partial<Document>).querySelectorAll !== "function" ||
typeof (document as Partial<Document>).createElement !== "function"
) {
vi.stubGlobal("document", {
querySelector: (selector: string) =>
enabled && selector.includes("/@vite/client") ? ({} as Element) : null,
querySelectorAll: () => [],
createElement: () => ({ setAttribute() {}, remove() {} }) as unknown as HTMLScriptElement,
head: { append() {} },
} as Document);
return;
}
document
.querySelectorAll('script[data-test-vite-client="true"]')
.forEach((node) => node.remove());
if (!enabled) {
return;
}
const script = document.createElement("script");
script.setAttribute("data-test-vite-client", "true");
script.setAttribute("src", "/@vite/client");
document.head.append(script);
}
function expectedGatewayUrl(basePath: string): string {
const proto = location.protocol === "https:" ? "wss" : "ws";
return `${proto}://${location.host}${basePath}`;
}
function createSettings(overrides: Partial<UiSettings> = {}): UiSettings {
return {
gatewayUrl: "wss://gateway.example:8443/openclaw",
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
...overrides,
};
}
describe("loadSettings default gateway URL derivation", () => {
beforeEach(() => {
vi.resetModules();
@@ -117,13 +71,11 @@ describe("loadSettings default gateway URL derivation", () => {
localStorage.clear();
sessionStorage.clear();
setControlUiBasePath(undefined);
setViteDevScript(false);
});
afterEach(() => {
vi.restoreAllMocks();
setControlUiBasePath(undefined);
setViteDevScript(false);
vi.unstubAllGlobals();
});
@@ -150,44 +102,6 @@ describe("loadSettings default gateway URL derivation", () => {
expect(loadSettings().gatewayUrl).toBe(expectedGatewayUrl("/apps/openclaw"));
});
it("defaults vite dev pages to the local gateway port", async () => {
setTestLocation({
protocol: "http:",
host: "127.0.0.1:5174",
pathname: "/chat",
});
setViteDevScript(true);
const { loadSettings } = await import("./storage.ts");
expect(loadSettings().gatewayUrl).toBe("ws://127.0.0.1:18789");
});
it("migrates persisted vite dev gateway URLs to the local gateway port", async () => {
setTestLocation({
protocol: "http:",
host: "127.0.0.1:5174",
pathname: "/chat",
});
setViteDevScript(true);
localStorage.setItem(
"openclaw.control.settings.v1",
JSON.stringify({
gatewayUrl: "ws://127.0.0.1:5174",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navGroupsCollapsed: {},
}),
);
const { loadSettings } = await import("./storage.ts");
expect(loadSettings().gatewayUrl).toBe("ws://127.0.0.1:18789");
});
it("ignores and scrubs legacy persisted tokens", async () => {
setTestLocation({
protocol: "https:",
@@ -234,7 +148,20 @@ describe("loadSettings default gateway URL derivation", () => {
});
const { loadSettings, saveSettings } = await import("./storage.ts");
saveSettings(createSettings({ token: "session-token" }));
saveSettings({
gatewayUrl: "wss://gateway.example:8443/openclaw",
token: "session-token",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
});
expect(loadSettings()).toMatchObject({
gatewayUrl: "wss://gateway.example:8443/openclaw",
@@ -250,7 +177,20 @@ describe("loadSettings default gateway URL derivation", () => {
});
const { loadSettings, saveSettings } = await import("./storage.ts");
saveSettings(createSettings({ token: "gateway-a-token" }));
saveSettings({
gatewayUrl: "wss://gateway.example:8443/openclaw",
token: "gateway-a-token",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
});
localStorage.setItem(
"openclaw.control.settings.v1",
@@ -283,7 +223,20 @@ describe("loadSettings default gateway URL derivation", () => {
});
const { loadSettings, saveSettings } = await import("./storage.ts");
saveSettings(createSettings({ token: "memory-only-token" }));
saveSettings({
gatewayUrl: "wss://gateway.example:8443/openclaw",
token: "memory-only-token",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
});
expect(loadSettings()).toMatchObject({
gatewayUrl: "wss://gateway.example:8443/openclaw",
token: "memory-only-token",
@@ -305,29 +258,6 @@ describe("loadSettings default gateway URL derivation", () => {
expect(sessionStorage.length).toBe(1);
});
it("persists theme mode and nav width", async () => {
setTestLocation({
protocol: "https:",
host: "gateway.example:8443",
pathname: "/",
});
const { loadSettings, saveSettings } = await import("./storage.ts");
saveSettings(
createSettings({
theme: "dash",
themeMode: "light",
navWidth: 360,
}),
);
expect(loadSettings()).toMatchObject({
theme: "dash",
themeMode: "light",
navWidth: 360,
});
});
it("clears the current-tab token when saving an empty token", async () => {
setTestLocation({
protocol: "https:",
@@ -336,10 +266,66 @@ describe("loadSettings default gateway URL derivation", () => {
});
const { loadSettings, saveSettings } = await import("./storage.ts");
saveSettings(createSettings({ token: "stale-token" }));
saveSettings(createSettings());
saveSettings({
gatewayUrl: "wss://gateway.example:8443/openclaw",
token: "stale-token",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
});
saveSettings({
gatewayUrl: "wss://gateway.example:8443/openclaw",
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
});
expect(loadSettings().token).toBe("");
expect(sessionStorage.length).toBe(0);
});
it("persists themeMode and navWidth alongside the selected theme", async () => {
setTestLocation({
protocol: "https:",
host: "gateway.example:8443",
pathname: "/",
});
const { saveSettings } = await import("./storage.ts");
saveSettings({
gatewayUrl: "wss://gateway.example:8443/openclaw",
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "dash",
themeMode: "light",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 320,
navGroupsCollapsed: {},
});
expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toMatchObject({
theme: "dash",
themeMode: "light",
navWidth: 320,
});
});
});

View File

@@ -1,14 +1,6 @@
import { afterEach, beforeEach } from "vitest";
import "../app.ts";
import type { OpenClawApp } from "../app.ts";
import type { GatewayHelloOk } from "../gateway.ts";
type MountHarnessApp = OpenClawApp & {
client?: { stop: () => void } | null;
connected?: boolean;
hello?: GatewayHelloOk | null;
lastError?: string | null;
};
export function mountApp(pathname: string) {
window.history.replaceState({}, "", pathname);
@@ -17,14 +9,6 @@ export function mountApp(pathname: string) {
// no-op: avoid real gateway WS connections in browser tests
};
document.body.append(app);
const mounted = app as MountHarnessApp;
// Browser tests exercise rendered UI behavior, not live gateway transport.
// Force a connected shell and neutralize any background client started by lifecycle hooks.
mounted.client?.stop();
mounted.client = null;
mounted.connected = true;
mounted.lastError = null;
mounted.hello = mounted.hello ?? null;
return app;
}

View File

@@ -1,27 +0,0 @@
import "../styles.css";
import { render } from "lit";
import { describe, expect, it } from "vitest";
import { renderThemeToggle } from "./app-render.helpers.ts";
describe("theme orb styling", () => {
it("renders the theme trigger as a compact round control", () => {
const container = document.createElement("div");
document.body.append(container);
render(
renderThemeToggle({
theme: "claw",
setTheme: () => {},
} as unknown as Parameters<typeof renderThemeToggle>[0]),
container,
);
const orb = container.querySelector<HTMLElement>(".theme-orb");
const trigger = container.querySelector<HTMLElement>(".theme-orb__trigger");
expect(orb).not.toBeNull();
expect(trigger).not.toBeNull();
expect(getComputedStyle(orb!).display).toBe("inline-flex");
expect(getComputedStyle(trigger!).width).toBe("28px");
expect(getComputedStyle(trigger!).borderRadius).toBe("9999px");
});
});

View File

@@ -1,30 +0,0 @@
import "../styles.css";
import { render } from "lit";
import { describe, expect, it } from "vitest";
import { renderTopbarThemeModeToggle } from "./app-render.helpers.ts";
describe("topbar theme mode styling", () => {
it("renders icon-sized mode buttons instead of unstyled fallback boxes", () => {
const container = document.createElement("div");
document.body.append(container);
render(
renderTopbarThemeModeToggle({
themeMode: "system",
setThemeMode: () => {},
} as never),
container,
);
const group = container.querySelector<HTMLElement>(".topbar-theme-mode");
const button = container.querySelector<HTMLElement>(".topbar-theme-mode__btn");
const svg = container.querySelector<SVGElement>(".topbar-theme-mode__btn svg");
expect(group).not.toBeNull();
expect(button).not.toBeNull();
expect(svg).not.toBeNull();
expect(getComputedStyle(group!).display).toBe("inline-flex");
expect(getComputedStyle(button!).display).toBe("flex");
expect(getComputedStyle(svg!).width).toBe("14px");
expect(getComputedStyle(svg!).height).toBe("14px");
});
});

View File

@@ -40,15 +40,12 @@ function createProps(overrides: Partial<ChatProps> = {}): ChatProps {
focusMode: false,
assistantName: "OpenClaw",
assistantAvatar: null,
agentsList: null,
currentAgentId: "main",
onRefresh: () => undefined,
onToggleFocusMode: () => undefined,
onDraftChange: () => undefined,
onSend: () => undefined,
onQueueRemove: () => undefined,
onNewSession: () => undefined,
onAgentChange: () => undefined,
...overrides,
};
}
@@ -193,17 +190,18 @@ describe("chat view", () => {
createProps({
canAbort: true,
onAbort,
stream: "in-flight",
}),
),
container,
);
const stopButton = container.querySelector<HTMLButtonElement>('button[title="Stop"]');
expect(stopButton).not.toBeNull();
const stopButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Stop",
);
expect(stopButton).not.toBeUndefined();
stopButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onAbort).toHaveBeenCalledTimes(1);
expect(container.querySelector('button[title="New session"]')).toBeNull();
expect(container.textContent).not.toContain("New session");
});
it("shows a new session button when aborting is unavailable", () => {
@@ -219,13 +217,13 @@ describe("chat view", () => {
container,
);
const newSessionButton = container.querySelector<HTMLButtonElement>(
'button[title="New session"]',
const newSessionButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "New session",
);
expect(newSessionButton).not.toBeNull();
expect(newSessionButton).not.toBeUndefined();
newSessionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onNewSession).toHaveBeenCalledTimes(1);
expect(container.querySelector('button[title="Stop"]')).toBeNull();
expect(container.textContent).not.toContain("Stop");
});
it("shows sender labels from sanitized gateway messages instead of generic You", () => {

View File

@@ -1,12 +1,8 @@
import { render } from "lit";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderConfig, resetConfigViewStateForTests } from "./config.ts";
import { describe, expect, it, vi } from "vitest";
import { renderConfig } from "./config.ts";
describe("config view", () => {
beforeEach(() => {
resetConfigViewStateForTests();
});
const baseProps = () => ({
raw: "{\n}\n",
originalRaw: "{\n}\n",
@@ -24,13 +20,11 @@ describe("config view", () => {
schemaLoading: false,
uiHints: {},
formMode: "form" as const,
showModeToggle: true,
formValue: {},
originalValue: {},
searchQuery: "",
activeSection: null,
activeSubsection: null,
streamMode: false,
onRawChange: vi.fn(),
onFormModeChange: vi.fn(),
onFormPatch: vi.fn(),
@@ -41,13 +35,6 @@ describe("config view", () => {
onApply: vi.fn(),
onUpdate: vi.fn(),
onSubsectionChange: vi.fn(),
version: "",
theme: "claw" as const,
themeMode: "system" as const,
setTheme: vi.fn(),
setThemeMode: vi.fn(),
gatewayUrl: "",
assistantName: "",
});
function findActionButtons(container: HTMLElement): {
@@ -147,102 +134,6 @@ describe("config view", () => {
expect(applyButton?.disabled).toBe(false);
});
it("keeps raw secrets out of the DOM while stream mode is enabled", () => {
const container = document.createElement("div");
render(
renderConfig({
...baseProps(),
formMode: "raw",
streamMode: true,
raw: '{\n gateway: { auth: { token: "secret-123" } }\n}\n',
originalRaw: "{\n}\n",
formValue: { gateway: { auth: { token: "secret-123" } } },
uiHints: {
"gateway.auth.token": { sensitive: true },
},
}),
container,
);
const textarea = container.querySelector("textarea");
expect(textarea).not.toBeNull();
expect(textarea?.value).toBe("");
expect(textarea?.getAttribute("placeholder")).toContain("redacted");
const toggle = container.querySelector<HTMLButtonElement>(
'button[aria-label="Toggle raw config redaction"]',
);
expect(toggle?.disabled).toBe(true);
});
it("reveals raw secrets only after explicit toggle when stream mode is off", () => {
const container = document.createElement("div");
const props = {
...baseProps(),
formMode: "raw" as const,
streamMode: false,
raw: '{\n gateway: { auth: { token: "secret-123" } }\n}\n',
originalRaw: "{\n}\n",
formValue: { gateway: { auth: { token: "secret-123" } } },
uiHints: {
"gateway.auth.token": { sensitive: true },
},
};
render(renderConfig(props), container);
const initialTextarea = container.querySelector("textarea");
expect(initialTextarea?.value).toBe("");
const toggle = container.querySelector<HTMLButtonElement>(
'button[aria-label="Toggle raw config redaction"]',
);
expect(toggle?.disabled).toBe(false);
toggle?.click();
render(renderConfig(props), container);
const revealedTextarea = container.querySelector("textarea");
expect(revealedTextarea?.value).toContain("secret-123");
});
it("reveals env values through the peek control instead of CSS-only masking", () => {
const container = document.createElement("div");
const props = {
...baseProps(),
activeSection: "env" as const,
formMode: "form" as const,
streamMode: false,
schema: {
type: "object",
properties: {
env: {
type: "object",
additionalProperties: { type: "string" },
},
},
},
formValue: {
env: {
OPENAI_API_KEY: "secret-123",
},
},
};
render(renderConfig(props), container);
const hiddenInput = container.querySelector<HTMLInputElement>(".cfg-input:not(.cfg-input--sm)");
expect(hiddenInput?.value).toBe("");
const peekButton = Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
(button) => button.textContent?.includes("Peek"),
);
peekButton?.click();
render(renderConfig(props), container);
const revealedInput = container.querySelector<HTMLInputElement>(
".cfg-input:not(.cfg-input--sm)",
);
expect(revealedInput?.value).toBe("secret-123");
});
it("switches mode via the sidebar toggle", () => {
const container = document.createElement("div");
const onFormModeChange = vi.fn();
@@ -313,7 +204,12 @@ describe("config view", () => {
const container = document.createElement("div");
render(renderConfig(baseProps()), container);
expect(container.querySelectorAll(".config-search__tag-option")).toHaveLength(0);
const options = Array.from(container.querySelectorAll(".config-search__tag-option")).map(
(option) => option.textContent?.trim(),
);
expect(options).toContain("tag:security");
expect(options).toContain("tag:advanced");
expect(options).toHaveLength(15);
});
it("updates search query when toggling a tag option", () => {
@@ -330,7 +226,8 @@ describe("config view", () => {
const option = container.querySelector<HTMLButtonElement>(
'.config-search__tag-option[data-tag="security"]',
);
expect(option).toBeNull();
expect(onSearchChange).not.toHaveBeenCalled();
expect(option).toBeTruthy();
option?.click();
expect(onSearchChange).toHaveBeenCalledWith("tag:security");
});
});

View File

@@ -1,95 +1,39 @@
import { describe, expect, it } from "vitest";
import { ConnectErrorDetailCodes } from "../../../../src/gateway/protocol/connect-error-details.js";
import {
shouldShowAuthHint,
shouldShowAuthRequiredHint,
shouldShowInsecureContextHint,
shouldShowPairingHint,
} from "./overview-hints.ts";
import { shouldShowPairingHint } from "./overview-hints.ts";
describe("overview hints", () => {
describe("shouldShowPairingHint", () => {
it("returns true for 'pairing required' close reason", () => {
expect(shouldShowPairingHint(false, "disconnected (1008): pairing required")).toBe(true);
});
it("matches case-insensitively", () => {
expect(shouldShowPairingHint(false, "Pairing Required")).toBe(true);
});
it("returns false when connected", () => {
expect(shouldShowPairingHint(true, "disconnected (1008): pairing required")).toBe(false);
});
it("returns false when lastError is null", () => {
expect(shouldShowPairingHint(false, null)).toBe(false);
});
it("returns false for unrelated errors", () => {
expect(shouldShowPairingHint(false, "disconnected (1006): no reason")).toBe(false);
});
it("returns false for auth errors", () => {
expect(shouldShowPairingHint(false, "disconnected (4008): unauthorized")).toBe(false);
});
it("returns true for structured pairing code", () => {
expect(
shouldShowPairingHint(
false,
"disconnected (4008): connect failed",
ConnectErrorDetailCodes.PAIRING_REQUIRED,
),
).toBe(true);
});
describe("shouldShowPairingHint", () => {
it("returns true for 'pairing required' close reason", () => {
expect(shouldShowPairingHint(false, "disconnected (1008): pairing required")).toBe(true);
});
describe("shouldShowAuthHint", () => {
it("returns true for structured auth failures", () => {
expect(
shouldShowAuthHint(
false,
"disconnected (4008): connect failed",
ConnectErrorDetailCodes.AUTH_TAILSCALE_IDENTITY_MISMATCH,
),
).toBe(true);
});
it("falls back to legacy close text when no detail code is present", () => {
expect(shouldShowAuthHint(false, "disconnected (4008): unauthorized")).toBe(true);
});
it("returns false for non-auth errors", () => {
expect(shouldShowAuthHint(false, "disconnected (1006): no reason")).toBe(false);
});
it("matches case-insensitively", () => {
expect(shouldShowPairingHint(false, "Pairing Required")).toBe(true);
});
describe("shouldShowAuthRequiredHint", () => {
it("returns true for structured auth-required codes", () => {
expect(
shouldShowAuthRequiredHint(true, true, ConnectErrorDetailCodes.AUTH_TOKEN_MISSING),
).toBe(true);
});
it("falls back to missing credentials when detail code is absent", () => {
expect(shouldShowAuthRequiredHint(false, false, null)).toBe(true);
expect(shouldShowAuthRequiredHint(true, false, null)).toBe(false);
});
it("returns false when connected", () => {
expect(shouldShowPairingHint(true, "disconnected (1008): pairing required")).toBe(false);
});
describe("shouldShowInsecureContextHint", () => {
it("returns true for structured device identity errors", () => {
expect(
shouldShowInsecureContextHint(
false,
"disconnected (4008): connect failed",
ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
),
).toBe(true);
});
it("returns false when lastError is null", () => {
expect(shouldShowPairingHint(false, null)).toBe(false);
});
it("falls back to legacy close text when detail code is absent", () => {
expect(shouldShowInsecureContextHint(false, "device identity required")).toBe(true);
});
it("returns false for unrelated errors", () => {
expect(shouldShowPairingHint(false, "disconnected (1006): no reason")).toBe(false);
});
it("returns false for auth errors", () => {
expect(shouldShowPairingHint(false, "disconnected (4008): unauthorized")).toBe(false);
});
it("returns true for structured pairing code", () => {
expect(
shouldShowPairingHint(
false,
"disconnected (4008): connect failed",
ConnectErrorDetailCodes.PAIRING_REQUIRED,
),
).toBe(true);
});
});

View File

@@ -23,18 +23,7 @@ function buildProps(result: SessionsListResult): SessionsProps {
includeGlobal: false,
includeUnknown: false,
basePath: "",
searchQuery: "",
sortColumn: "updated",
sortDir: "desc",
page: 0,
pageSize: 25,
actionsOpenKey: null,
onFiltersChange: () => undefined,
onSearchChange: () => undefined,
onSortChange: () => undefined,
onPageChange: () => undefined,
onPageSizeChange: () => undefined,
onActionsOpenChange: () => undefined,
onRefresh: () => undefined,
onPatch: () => undefined,
onDelete: () => undefined,