mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 01:31:08 +00:00
chore: remove test files to reduce PR scope
This commit is contained in:
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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", {});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user