diff --git a/packages/workbench-cli/src/theme/__tests__/detection.test.ts b/packages/workbench-cli/src/theme/__tests__/detection.test.ts index c5b4265..df938be 100644 --- a/packages/workbench-cli/src/theme/__tests__/detection.test.ts +++ b/packages/workbench-cli/src/theme/__tests__/detection.test.ts @@ -1,9 +1,165 @@ -import { describe, it, expect } from "bun:test" +import { vi } from "bun:test" +import { describe, it, expect, afterEach, beforeEach } from "bun:test" import { detectTerminalMode } from "../detection" +// Shared mutable mock state — vi.mock factory captures by reference +const mockState = { + detectOSCSupport: async () => true, + detectColors: { defaultBackground: "#ffffff" }, + cleanup: () => {}, +} + +// vi.mock is hoisted — factory runs before imports resolve +// but `vi` is imported above so it's in scope when this runs +vi.mock("@opentui/core", () => ({ + TerminalPalette: function (stdin: unknown, stdout: unknown) { + return { + detectOSCSupport: mockState.detectOSCSupport, + detect: async () => mockState.detectColors, + cleanup: mockState.cleanup, + } + }, +})) + describe("terminal detection", () => { - it("returns 'dark' when OSC is not supported (CI environment)", async () => { - const mode = await detectTerminalMode() - expect(mode).toBe("dark") + const origStdout = process.stdout + const origStdin = process.stdin + + // Create mock stream objects with configurable isTTY + const createMockStream = (isTTYValue: boolean) => ({ + isTTY: isTTYValue, + write: vi.fn(), + on: vi.fn(), + end: vi.fn(), + }) + + // Enable TTY mode for tests that need to bypass the TTY guard + const enableTTY = () => { + Object.defineProperty(process, "stdout", { + value: createMockStream(true), + configurable: true, + }) + Object.defineProperty(process, "stdin", { + value: createMockStream(true), + configurable: true, + }) + } + + // Disable TTY mode (for non-TTY short-circuit tests) + const disableTTY = () => { + Object.defineProperty(process, "stdout", { + value: createMockStream(false), + configurable: true, + }) + Object.defineProperty(process, "stdin", { + value: createMockStream(false), + configurable: true, + }) + } + + beforeEach(() => { + // Default: enable TTY so tests can focus on their specific behavior + enableTTY() + // Reset mock state + mockState.detectOSCSupport = async () => true + mockState.detectColors = { defaultBackground: "#ffffff" } + mockState.cleanup = () => {} + }) + + afterEach(() => { + Object.defineProperty(process, "stdout", { value: origStdout, configurable: true }) + Object.defineProperty(process, "stdin", { value: origStdin, configurable: true }) + }) + + // ─────────────────────────────────────────────────────────────────────────── + // Test: non-TTY short-circuit + // ─────────────────────────────────────────────────────────────────────────── + describe("non-TTY short-circuit", () => { + it("returns 'dark' immediately when stdout is not a TTY", async () => { + disableTTY() + const mode = await detectTerminalMode() + expect(mode).toBe("dark") + }) + + it("returns 'dark' immediately when stdin is not a TTY", async () => { + disableTTY() + const mode = await detectTerminalMode() + expect(mode).toBe("dark") + }) + }) + + // ─────────────────────────────────────────────────────────────────────────── + // Test: timeout / unsupported OSC fallback + // ─────────────────────────────────────────────────────────────────────────── + describe("OSC support detection", () => { + it("returns 'dark' when detectOSCSupport returns false", async () => { + mockState.detectOSCSupport = async () => false + const mode = await detectTerminalMode() + expect(mode).toBe("dark") + }) + }) + + // ─────────────────────────────────────────────────────────────────────────── + // Test: malformed colour fallback + // ─────────────────────────────────────────────────────────────────────────── + describe("malformed colour fallback", () => { + it("returns 'dark' for short hex #rgb", async () => { + mockState.detectOSCSupport = async () => true + mockState.detectColors = { defaultBackground: "#fff" } + expect(await detectTerminalMode()).toBe("dark") + }) + + it("returns 'dark' for 8-char hex with alpha #rrggbbaa", async () => { + mockState.detectOSCSupport = async () => true + mockState.detectColors = { defaultBackground: "#ffffffff" } + expect(await detectTerminalMode()).toBe("dark") + }) + + it("returns 'dark' for rgb() format", async () => { + mockState.detectOSCSupport = async () => true + mockState.detectColors = { defaultBackground: "rgb(255, 255, 255)" } + expect(await detectTerminalMode()).toBe("dark") + }) + + it("returns 'dark' for null defaultBackground", async () => { + mockState.detectOSCSupport = async () => true + mockState.detectColors = { defaultBackground: null } + expect(await detectTerminalMode()).toBe("dark") + }) + + it("returns 'dark' for undefined defaultBackground", async () => { + mockState.detectOSCSupport = async () => true + mockState.detectColors = { defaultBackground: undefined } + expect(await detectTerminalMode()).toBe("dark") + }) + }) + + // ─────────────────────────────────────────────────────────────────────────── + // Test: valid colours (sanity checks) + // ─────────────────────────────────────────────────────────────────────────── + describe("valid colour detection", () => { + it("returns 'light' for bright valid hex #ffffff", async () => { + mockState.detectOSCSupport = async () => true + mockState.detectColors = { defaultBackground: "#ffffff" } + expect(await detectTerminalMode()).toBe("light") + }) + + it("returns 'dark' for dark valid hex #000000", async () => { + mockState.detectOSCSupport = async () => true + mockState.detectColors = { defaultBackground: "#000000" } + expect(await detectTerminalMode()).toBe("dark") + }) + + it("returns 'light' for medium gray #c0c0c0 (luminance=0.527)", async () => { + mockState.detectOSCSupport = async () => true + mockState.detectColors = { defaultBackground: "#c0c0c0" } + expect(await detectTerminalMode()).toBe("light") + }) + + it("returns 'dark' for dark gray #404040 (luminance=0.051)", async () => { + mockState.detectOSCSupport = async () => true + mockState.detectColors = { defaultBackground: "#404040" } + expect(await detectTerminalMode()).toBe("dark") + }) }) }) diff --git a/packages/workbench-cli/src/theme/detection.ts b/packages/workbench-cli/src/theme/detection.ts index f39e932..5cf909f 100644 --- a/packages/workbench-cli/src/theme/detection.ts +++ b/packages/workbench-cli/src/theme/detection.ts @@ -1,5 +1,7 @@ import { TerminalPalette } from "@opentui/core" +const OSC_TIMEOUT_MS = 300 + /** * Calculate relative luminance of a hex colour. * Returns 0.0 (black) to 1.0 (white). @@ -20,14 +22,14 @@ function luminance(hex: string): number { * Falls back to "dark" on any failure (timeout, unsupported terminal, etc.). */ export async function detectTerminalMode(): Promise<"light" | "dark"> { + if (!process.stdout.isTTY || !process.stdin.isTTY) return "dark" const detector = new TerminalPalette(process.stdin, process.stdout) try { - const supported = await detector.detectOSCSupport(150) + const supported = await detector.detectOSCSupport(OSC_TIMEOUT_MS) if (!supported) return "dark" - - const colors = await detector.detect({ timeout: 150 }) + const colors = await detector.detect({ timeout: OSC_TIMEOUT_MS }) const bg = colors.defaultBackground - if (!bg) return "dark" + if (!bg || !/^#[0-9a-fA-F]{6}$/.test(bg)) return "dark" return luminance(bg) > 0.5 ? "light" : "dark" } catch { return "dark"