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