返回 DeepSeek-Reasonix
terminal-theme.test.ts
根目录 / desktop / frontend / src / __tests__ / terminal-theme.test.ts
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
214 lines TYPESCRIPT