| 1 | // Run: tsx src/__tests__/terminal-theme.test.ts |
| 2 | |
| 3 | import assert from "node:assert/strict"; |
| 4 | import { readFileSync } from "node:fs"; |
| 5 | import { dirname, resolve } from "node:path"; |
| 6 | import { fileURLToPath } from "node:url"; |
| 7 | import { JSDOM } from "jsdom"; |
| 8 | |
| 9 | const dom = new JSDOM("<!doctype html><html><head></head><body><div id='terminal'></div></body></html>"); |
| 10 | Object.assign(globalThis, { |
| 11 | window: dom.window, |
| 12 | document: dom.window.document, |
| 13 | MutationObserver: dom.window.MutationObserver, |
| 14 | getComputedStyle: dom.window.getComputedStyle.bind(dom.window), |
| 15 | }); |
| 16 | Object.defineProperty(dom.window, "matchMedia", { |
| 17 | configurable: true, |
| 18 | value: () => ({ |
| 19 | matches: false, |
| 20 | addEventListener() {}, |
| 21 | removeEventListener() {}, |
| 22 | }), |
| 23 | }); |
| 24 | |
| 25 | const { applyTheme } = await import("../lib/theme"); |
| 26 | const { |
| 27 | applyTerminalThemePreference, |
| 28 | createTerminalThemeSaveQueue, |
| 29 | getResolvedTerminalTheme, |
| 30 | normalizeTerminalThemePreference, |
| 31 | onTerminalThemePreferenceChange, |
| 32 | terminalThemeForElement, |
| 33 | } = await import("../lib/terminalTheme"); |
| 34 | |
| 35 | assert.equal(normalizeTerminalThemePreference("unknown"), "auto"); |
| 36 | assert.equal(normalizeTerminalThemePreference(undefined), "auto", "older settings payloads fall back safely"); |
| 37 | assert.equal(normalizeTerminalThemePreference("light"), "light"); |
| 38 | |
| 39 | applyTheme("light", "graphite"); |
| 40 | applyTerminalThemePreference("auto"); |
| 41 | assert.equal(getResolvedTerminalTheme(), "light", "follow-app resolves the current app theme"); |
| 42 | assert.equal(document.documentElement.hasAttribute("data-terminal-theme"), false); |
| 43 | |
| 44 | applyTerminalThemePreference("dark"); |
| 45 | assert.equal(getResolvedTerminalTheme(), "dark", "explicit terminal theme overrides the app theme"); |
| 46 | assert.equal(document.documentElement.getAttribute("data-terminal-theme"), "dark"); |
| 47 | |
| 48 | let notifications = 0; |
| 49 | const unsubscribe = onTerminalThemePreferenceChange(() => { notifications += 1; }); |
| 50 | applyTerminalThemePreference("light"); |
| 51 | unsubscribe(); |
| 52 | assert.equal(notifications, 1, "open terminals are notified when the preference changes"); |
| 53 | |
| 54 | const host = document.getElementById("terminal")!; |
| 55 | host.style.setProperty("--terminal-bg", "#fafafa"); |
| 56 | host.style.setProperty("--terminal-fg", "#202124"); |
| 57 | host.style.setProperty("--terminal-cursor", "#9a4f00"); |
| 58 | const xtermTheme = terminalThemeForElement(host); |
| 59 | assert.equal(xtermTheme.background, "#fafafa"); |
| 60 | assert.equal(xtermTheme.foreground, "#202124"); |
| 61 | assert.equal(xtermTheme.cursor, "#9a4f00"); |
| 62 | assert.equal(xtermTheme.black, "#25272a", "light terminal uses a contrast-safe ANSI palette"); |
| 63 | |
| 64 | function relativeLuminance(hex: string): number { |
| 65 | assert.match(hex, /^#[0-9a-f]{6}$/i); |
| 66 | const channels = [1, 3, 5].map((start) => Number.parseInt(hex.slice(start, start + 2), 16) / 255); |
| 67 | const linear = channels.map((channel) => channel <= 0.04045 |
| 68 | ? channel / 12.92 |
| 69 | : ((channel + 0.055) / 1.055) ** 2.4); |
| 70 | return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]; |
| 71 | } |
| 72 | |
| 73 | function contrastRatio(foreground: string, background: string): number { |
| 74 | const [lighter, darker] = [relativeLuminance(foreground), relativeLuminance(background)] |
| 75 | .sort((a, b) => b - a); |
| 76 | return (lighter + 0.05) / (darker + 0.05); |
| 77 | } |
| 78 | |
| 79 | const ansiKeys = [ |
| 80 | "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", |
| 81 | "brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue", |
| 82 | "brightMagenta", "brightCyan", "brightWhite", |
| 83 | ] as const; |
| 84 | for (const key of ansiKeys) { |
| 85 | const color = xtermTheme[key]; |
| 86 | assert.equal(typeof color, "string"); |
| 87 | assert.ok( |
| 88 | contrastRatio(color!, xtermTheme.background!) >= 4.5, |
| 89 | `${key} must remain readable against the light terminal background`, |
| 90 | ); |
| 91 | } |
| 92 | |
| 93 | let releaseFirstSave!: () => void; |
| 94 | const firstSaveGate = new Promise<void>((resolve) => { releaseFirstSave = resolve; }); |
| 95 | const saveOrder: string[] = []; |
| 96 | const saveTerminalTheme = createTerminalThemeSaveQueue(async (theme) => { |
| 97 | saveOrder.push(`start:${theme}`); |
| 98 | if (theme === "light") await firstSaveGate; |
| 99 | saveOrder.push(`end:${theme}`); |
| 100 | }); |
| 101 | const firstSave = saveTerminalTheme("light"); |
| 102 | await Promise.resolve(); |
| 103 | const secondSave = saveTerminalTheme("dark"); |
| 104 | await Promise.resolve(); |
| 105 | assert.deepEqual(saveOrder, ["start:light"], "newer intent waits for the active Wails save"); |
| 106 | releaseFirstSave(); |
| 107 | await Promise.all([firstSave, secondSave]); |
| 108 | assert.deepEqual(saveOrder, ["start:light", "end:light", "start:dark", "end:dark"]); |
| 109 | |
| 110 | const recoveryOrder: string[] = []; |
| 111 | const recoverAfterFailure = createTerminalThemeSaveQueue(async (theme) => { |
| 112 | recoveryOrder.push(theme); |
| 113 | if (theme === "light") throw new Error("simulated save failure"); |
| 114 | }); |
| 115 | const failedSave = recoverAfterFailure("light"); |
| 116 | const recoveredSave = recoverAfterFailure("dark"); |
| 117 | await assert.rejects(failedSave, /simulated save failure/); |
| 118 | await recoveredSave; |
| 119 | assert.deepEqual(recoveryOrder, ["light", "dark"], "a failed save does not poison newer intent"); |
| 120 | |
| 121 | const testDir = dirname(fileURLToPath(import.meta.url)); |
| 122 | const terminalSource = readFileSync(resolve(testDir, "../components/TerminalView.tsx"), "utf8"); |
| 123 | const settingsSource = readFileSync(resolve(testDir, "../components/SettingsPanel.tsx"), "utf8"); |
| 124 | const stylesSource = readFileSync(resolve(testDir, "../styles.css"), "utf8"); |
| 125 | |
| 126 | function cssRuleDeclarations(css: string, selector: string): Record<string, string> { |
| 127 | const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); |
| 128 | const match = css.match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`)); |
| 129 | assert.ok(match, `missing CSS rule for ${selector}`); |
| 130 | return Object.fromEntries( |
| 131 | match[1] |
| 132 | .split(";") |
| 133 | .map((declaration) => declaration.trim()) |
| 134 | .filter(Boolean) |
| 135 | .map((declaration) => { |
| 136 | const colon = declaration.indexOf(":"); |
| 137 | assert.ok(colon > 0, `invalid declaration in ${selector}: ${declaration}`); |
| 138 | return [declaration.slice(0, colon).trim(), declaration.slice(colon + 1).trim()]; |
| 139 | }), |
| 140 | ); |
| 141 | } |
| 142 | |
| 143 | function assertCssRule( |
| 144 | css: string, |
| 145 | selector: string, |
| 146 | expected: Record<string, string>, |
| 147 | ): void { |
| 148 | const actual = cssRuleDeclarations(css, selector); |
| 149 | for (const [property, value] of Object.entries(expected)) { |
| 150 | assert.equal(actual[property], value, `${selector} must set ${property} from terminal-owned tokens`); |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | const terminalStylesStart = stylesSource.indexOf("/* Integrated terminal */"); |
| 155 | const terminalStylesEnd = stylesSource.indexOf("@media (max-width: 760px)", terminalStylesStart); |
| 156 | assert.ok(terminalStylesStart >= 0 && terminalStylesEnd > terminalStylesStart, "integrated terminal CSS section must exist"); |
| 157 | const terminalStyles = stylesSource.slice(terminalStylesStart, terminalStylesEnd); |
| 158 | |
| 159 | assert.ok(terminalSource.includes("terminal.options.theme = terminalThemeForElement(host)")); |
| 160 | assert.ok(!terminalSource.includes('background: "#111315"')); |
| 161 | assert.ok(settingsSource.includes("createTerminalThemeSaveQueue")); |
| 162 | assert.ok(settingsSource.includes("if (!terminalThemeSavePending.current)")); |
| 163 | assert.ok(settingsSource.includes("onTerminalTheme={setTerminalThemePreference}")); |
| 164 | assert.ok(stylesSource.includes(':root[data-terminal-theme="light"]')); |
| 165 | assert.ok(stylesSource.includes(':root[data-terminal-theme="dark"]')); |
| 166 | assert.doesNotMatch(terminalStyles, /var\(--(?:bg-elev-1|fg-muted)\)/, "auto mode must use defined app tokens"); |
| 167 | |
| 168 | assertCssRule(terminalStyles, ":root", { |
| 169 | "--terminal-surface": "var(--bg-elev)", |
| 170 | "--terminal-muted": "var(--fg-dim)", |
| 171 | "--terminal-cursor": "var(--accent)", |
| 172 | }); |
| 173 | assertCssRule(terminalStyles, ':root[data-terminal-theme="dark"] .terminal-panel', { "color-scheme": "dark" }); |
| 174 | assertCssRule(terminalStyles, ':root[data-terminal-theme="light"] .terminal-panel', { "color-scheme": "light" }); |
| 175 | |
| 176 | const terminalChromeMatrix: Array<[string, Record<string, string>]> = [ |
| 177 | [".terminal-panel__header", { |
| 178 | "border-bottom": "1px solid var(--terminal-border)", |
| 179 | background: "var(--terminal-surface)", |
| 180 | }], |
| 181 | [".terminal-panel__identity span", { color: "var(--terminal-muted)" }], |
| 182 | [".terminal-shell-select", { |
| 183 | border: "1px solid var(--terminal-border)", |
| 184 | background: "var(--terminal-active)", |
| 185 | color: "var(--terminal-fg)", |
| 186 | }], |
| 187 | [".terminal-shell-select:hover, .terminal-shell-select:focus-visible", { |
| 188 | "border-color": "var(--terminal-cursor)", |
| 189 | }], |
| 190 | [".terminal-icon-button", { color: "var(--terminal-muted)" }], |
| 191 | [".terminal-icon-button:hover, .terminal-icon-button:focus-visible", { |
| 192 | "border-color": "var(--terminal-border)", |
| 193 | background: "var(--terminal-active)", |
| 194 | color: "var(--terminal-fg)", |
| 195 | }], |
| 196 | [".terminal-session--active", { |
| 197 | "border-bottom-color": "var(--terminal-cursor)", |
| 198 | background: "var(--terminal-active)", |
| 199 | }], |
| 200 | [".terminal-empty__spinner", { "border-top-color": "var(--terminal-cursor)" }], |
| 201 | ]; |
| 202 | for (const [selector, expected] of terminalChromeMatrix) { |
| 203 | assertCssRule(terminalStyles, selector, expected); |
| 204 | } |
| 205 | assertCssRule(stylesSource, ".layout--terminal-drawer-expanded .terminal-drawer", { |
| 206 | background: "var(--terminal-bg, #111315)", |
| 207 | "border-top": "1px solid var(--terminal-border)", |
| 208 | }); |
| 209 | assertCssRule(stylesSource, ".layout--terminal-drawer-open .terminal-drawer .terminal-panel__header", { |
| 210 | "border-bottom": "1px solid var(--terminal-border)", |
| 211 | }); |
| 212 | |
| 213 | console.log("terminal theme tests passed"); |
| 214 |