openclaw/src/tui/theme/theme.test.ts

303 lines
10 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const cliHighlightMocks = vi.hoisted(() => ({
highlight: vi.fn((code: string) => code),
supportsLanguage: vi.fn((_lang: string) => true),
}));
vi.mock("cli-highlight", () => cliHighlightMocks);
const { markdownTheme, searchableSelectListTheme, selectListTheme, theme } =
await import("./theme.js");
const stripAnsi = (str: string) =>
str.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"), "");
function relativeLuminance(hex: string): number {
const channels = hex
.replace("#", "")
.match(/.{2}/g)
?.map((part) => Number.parseInt(part, 16) / 255)
.map((channel) => (channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4));
if (!channels || channels.length !== 3) {
throw new Error(`invalid color: ${hex}`);
}
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2];
}
function contrastRatio(foreground: string, background: string): number {
const [lighter, darker] = [relativeLuminance(foreground), relativeLuminance(background)].toSorted(
(a, b) => b - a,
);
return (lighter + 0.05) / (darker + 0.05);
}
describe("markdownTheme", () => {
describe("highlightCode", () => {
beforeEach(() => {
cliHighlightMocks.highlight.mockClear();
cliHighlightMocks.supportsLanguage.mockClear();
cliHighlightMocks.highlight.mockImplementation((code: string) => code);
cliHighlightMocks.supportsLanguage.mockReturnValue(true);
});
it("passes supported language through to the highlighter", () => {
markdownTheme.highlightCode!("const x = 42;", "javascript");
expect(cliHighlightMocks.supportsLanguage).toHaveBeenCalledWith("javascript");
expect(cliHighlightMocks.highlight).toHaveBeenCalledWith(
"const x = 42;",
expect.objectContaining({ language: "javascript" }),
);
});
it("falls back to auto-detect for unknown language and preserves lines", () => {
cliHighlightMocks.supportsLanguage.mockReturnValue(false);
cliHighlightMocks.highlight.mockImplementation((code: string) => `${code}\nline-2`);
const result = markdownTheme.highlightCode!(`echo "hello"`, "not-a-real-language");
expect(cliHighlightMocks.highlight).toHaveBeenCalledWith(
`echo "hello"`,
expect.objectContaining({ language: undefined }),
);
expect(stripAnsi(result[0] ?? "")).toContain("echo");
expect(stripAnsi(result[1] ?? "")).toBe("line-2");
});
it("returns plain highlighted lines when highlighting throws", () => {
cliHighlightMocks.highlight.mockImplementation(() => {
throw new Error("boom");
});
const result = markdownTheme.highlightCode!("echo hello", "javascript");
expect(result).toHaveLength(1);
expect(stripAnsi(result[0] ?? "")).toBe("echo hello");
});
});
});
describe("theme", () => {
it("keeps assistant text in terminal default foreground", () => {
expect(theme.assistantText("hello")).toBe("hello");
expect(stripAnsi(theme.assistantText("hello"))).toBe("hello");
});
});
describe("light background detection", () => {
const originalEnv = { ...process.env };
afterEach(() => {
process.env = { ...originalEnv };
vi.resetModules();
});
async function importThemeWithEnv(env: Record<string, string | undefined>) {
vi.resetModules();
for (const [key, value] of Object.entries(env)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
return import("./theme.js");
}
it("uses dark palette by default", async () => {
const mod = await importThemeWithEnv({
OPENCLAW_THEME: undefined,
COLORFGBG: undefined,
});
expect(mod.lightMode).toBe(false);
});
it("selects light palette when OPENCLAW_THEME=light", async () => {
const mod = await importThemeWithEnv({ OPENCLAW_THEME: "light" });
expect(mod.lightMode).toBe(true);
});
it("selects dark palette when OPENCLAW_THEME=dark", async () => {
const mod = await importThemeWithEnv({ OPENCLAW_THEME: "dark" });
expect(mod.lightMode).toBe(false);
});
it("treats OPENCLAW_THEME case-insensitively", async () => {
const mod = await importThemeWithEnv({ OPENCLAW_THEME: "LiGhT" });
expect(mod.lightMode).toBe(true);
});
it("detects light background from COLORFGBG", async () => {
const mod = await importThemeWithEnv({
OPENCLAW_THEME: undefined,
COLORFGBG: "0;15",
});
expect(mod.lightMode).toBe(true);
});
it("treats COLORFGBG bg=7 (silver) as light", async () => {
const mod = await importThemeWithEnv({
OPENCLAW_THEME: undefined,
COLORFGBG: "0;7",
});
expect(mod.lightMode).toBe(true);
});
it("treats COLORFGBG bg=8 (bright black / dark gray) as dark", async () => {
const mod = await importThemeWithEnv({
OPENCLAW_THEME: undefined,
COLORFGBG: "15;8",
});
expect(mod.lightMode).toBe(false);
});
it("treats COLORFGBG bg < 7 as dark", async () => {
const mod = await importThemeWithEnv({
OPENCLAW_THEME: undefined,
COLORFGBG: "15;0",
});
expect(mod.lightMode).toBe(false);
});
it("treats 256-color COLORFGBG bg=232 (near-black greyscale) as dark", async () => {
const mod = await importThemeWithEnv({
OPENCLAW_THEME: undefined,
COLORFGBG: "15;232",
});
expect(mod.lightMode).toBe(false);
});
it("treats 256-color COLORFGBG bg=255 (near-white greyscale) as light", async () => {
const mod = await importThemeWithEnv({
OPENCLAW_THEME: undefined,
COLORFGBG: "0;255",
});
expect(mod.lightMode).toBe(true);
});
it("treats 256-color COLORFGBG bg=231 (white cube entry) as light", async () => {
const mod = await importThemeWithEnv({
OPENCLAW_THEME: undefined,
COLORFGBG: "0;231",
});
expect(mod.lightMode).toBe(true);
});
it("treats 256-color COLORFGBG bg=16 (black cube entry) as dark", async () => {
const mod = await importThemeWithEnv({
OPENCLAW_THEME: undefined,
COLORFGBG: "15;16",
});
expect(mod.lightMode).toBe(false);
});
it("treats bright 256-color green backgrounds as light when dark text contrasts better", async () => {
const mod = await importThemeWithEnv({
OPENCLAW_THEME: undefined,
COLORFGBG: "15;34",
});
expect(mod.lightMode).toBe(true);
});
it("treats bright 256-color cyan backgrounds as light when dark text contrasts better", async () => {
const mod = await importThemeWithEnv({
OPENCLAW_THEME: undefined,
COLORFGBG: "15;39",
});
expect(mod.lightMode).toBe(true);
});
it("falls back to dark mode for invalid COLORFGBG values", async () => {
const mod = await importThemeWithEnv({
OPENCLAW_THEME: undefined,
COLORFGBG: "garbage",
});
expect(mod.lightMode).toBe(false);
});
it("ignores pathological COLORFGBG values", async () => {
const mod = await importThemeWithEnv({
OPENCLAW_THEME: undefined,
COLORFGBG: "0;".repeat(40),
});
expect(mod.lightMode).toBe(false);
});
it("OPENCLAW_THEME overrides COLORFGBG", async () => {
const mod = await importThemeWithEnv({
OPENCLAW_THEME: "dark",
COLORFGBG: "0;15",
});
expect(mod.lightMode).toBe(false);
});
it("keeps assistantText as identity in both modes", async () => {
const lightMod = await importThemeWithEnv({ OPENCLAW_THEME: "light" });
const darkMod = await importThemeWithEnv({ OPENCLAW_THEME: "dark" });
expect(lightMod.theme.assistantText("hello")).toBe("hello");
expect(darkMod.theme.assistantText("hello")).toBe("hello");
});
});
describe("light palette accessibility", () => {
it("keeps light theme text colors at WCAG AA contrast or better", async () => {
vi.resetModules();
process.env.OPENCLAW_THEME = "light";
const mod = await import("./theme.js");
const backgrounds = {
page: "#FFFFFF",
user: mod.lightPalette.userBg,
pending: mod.lightPalette.toolPendingBg,
success: mod.lightPalette.toolSuccessBg,
error: mod.lightPalette.toolErrorBg,
code: mod.lightPalette.codeBlock,
};
const textPairs = [
[mod.lightPalette.text, backgrounds.page],
[mod.lightPalette.dim, backgrounds.page],
[mod.lightPalette.accent, backgrounds.page],
[mod.lightPalette.accentSoft, backgrounds.page],
[mod.lightPalette.systemText, backgrounds.page],
[mod.lightPalette.link, backgrounds.page],
[mod.lightPalette.quote, backgrounds.page],
[mod.lightPalette.error, backgrounds.page],
[mod.lightPalette.success, backgrounds.page],
[mod.lightPalette.userText, backgrounds.user],
[mod.lightPalette.dim, backgrounds.pending],
[mod.lightPalette.dim, backgrounds.success],
[mod.lightPalette.dim, backgrounds.error],
[mod.lightPalette.toolTitle, backgrounds.pending],
[mod.lightPalette.toolTitle, backgrounds.success],
[mod.lightPalette.toolTitle, backgrounds.error],
[mod.lightPalette.toolOutput, backgrounds.pending],
[mod.lightPalette.toolOutput, backgrounds.success],
[mod.lightPalette.toolOutput, backgrounds.error],
[mod.lightPalette.code, backgrounds.code],
[mod.lightPalette.border, backgrounds.page],
[mod.lightPalette.quoteBorder, backgrounds.page],
[mod.lightPalette.codeBorder, backgrounds.page],
] as const;
for (const [foreground, background] of textPairs) {
expect(contrastRatio(foreground, background)).toBeGreaterThanOrEqual(4.5);
}
});
});
describe("list themes", () => {
it("reuses shared select-list styles in searchable list theme", () => {
expect(searchableSelectListTheme.selectedPrefix(">")).toBe(selectListTheme.selectedPrefix(">"));
expect(searchableSelectListTheme.selectedText("entry")).toBe(
selectListTheme.selectedText("entry"),
);
expect(searchableSelectListTheme.description("desc")).toBe(selectListTheme.description("desc"));
expect(searchableSelectListTheme.scrollInfo("scroll")).toBe(
selectListTheme.scrollInfo("scroll"),
);
expect(searchableSelectListTheme.noMatch("none")).toBe(selectListTheme.noMatch("none"));
});
it("keeps searchable list specific renderers readable", () => {
expect(stripAnsi(searchableSelectListTheme.searchPrompt("Search:"))).toBe("Search:");
expect(stripAnsi(searchableSelectListTheme.searchInput("query"))).toBe("query");
expect(stripAnsi(searchableSelectListTheme.matchHighlight("match"))).toBe("match");
});
});