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