Skip to content
Source
Sign inSign up
Open History to see the last commit

vscodeThemeImport.test.ts

506 lines18.0 KB

1import { describe, expect, it } from "vite-plus/test";
2​
3import {
4 getThemeColorsForMode,
5 parseThemeFile,
6 themeColorToHex,
7 THEME_FILE_VERSION,
8 toCanonicalThemeColor,
9} from "./themePalette";
10import {
11 isVsCodeThemeFile,
12 pairVsCodeThemes,
13 parseThemeImport,
14 parseVsCodeThemeFile,
15 resolveThemeLabelCollisions,
16} from "./vscodeThemeImport";
17​
18function asHex(value: string): string {
19 const hex = themeColorToHex(value);
20 if (!hex) throw new Error(`Expected a theme color, received ${value}`);
21 return hex;
23​
24function contrastRatio(first: string, second: string): number {
25 const toChannels = (value: string) => {
26 const hex = asHex(value).slice(1);
27 return [0, 1, 2].map(
28 (channel) => Number.parseInt(hex.slice(channel * 2, channel * 2 + 2), 16) / 255,
29 );
30 };
31 const luminance = (value: string) =>
32 toChannels(value)
33 .map((channel) => (channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4))
34 .reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index]!, 0);
35 const lighter = Math.max(luminance(first), luminance(second));
36 const darker = Math.min(luminance(first), luminance(second));
37 return (lighter + 0.05) / (darker + 0.05);
39​
40function oklchParts(value: string): { chroma: number; hue: number } {
41 const [, chroma, hue] = /^oklch\(\S+ (\S+) (\S+)\)$/.exec(value)!;
42 return { chroma: Number(chroma), hue: Number(hue) };
44​
45const VSCODE_DARK = {
46 name: "pierre-dark-soft",
47 type: "dark",
48 colors: {
49 "editor.background": "#171717",
50 "editor.foreground": "#d4d4d4",
51 foreground: "#d4d4d4",
52 "sideBar.background": "#101010",
53 "sideBar.foreground": "#8a8a8a",
54 "sideBar.border": "#1d1d1d",
55 focusBorder: "#69b1ff",
56 "button.background": "#69b1ff",
57 "button.foreground": "#171717",
58 "input.border": "#2c2c2c",
59 "input.placeholderForeground": "#525252",
60 "terminal.background": "#101010",
61 "terminal.foreground": "#8a8a8a",
62 "list.hoverBackground": "#1f3e5e59",
63 "list.activeSelectionBackground": "#1f3e5e99",
64 },
65 tokenColors: [],
66};
67​
68describe("theme import text", () => {
69 it("preserves conversion of ordinary VS Code JSON", () => {
70 expect(parseThemeImport(JSON.stringify(VSCODE_DARK))).toEqual(
71 parseVsCodeThemeFile(VSCODE_DARK),
72 );
73 });
74​
75 it.each([
76 '{ // line comment\n "name": "Test", "colors": { "editor.background": "#002b36" } }',
77 '{ /* block comment */ "name": "Test", "colors": { "editor.background": "#002b36" } }',
78 '{ "name": "Test", "colors": { "editor.background": "#002b36", }, "tokenColors": [ { "scope": ["comment",], "settings": {}, }, ], }',
79 ])("imports VS Code JSONC without changing conversion: %s", (source) => {
80 const theme = parseThemeImport(source);
81 expect(theme.label).toBe("Test");
82 expect(theme.appearance).toBe("dark");
83 expect(asHex(theme.colors.canvas)).toBe("#002b36");
84 });
85​
86 it.each([
87 "",
88 '{ "name": "Test", "colors": { "editor.background": "#002b36" }',
89 '{ "name": "Test" "colors": { "editor.background": "#002b36" } }',
90 '{ "name": "Test", "colors": { "editor.background": "#002b36" } } /* unterminated',
91 '{ "name": "Test", "colors": { "editor.background": "#002b36" } } extra',
92 ])("rejects malformed input rather than using parser recovery: %s", (source) => {
93 expect(() => parseThemeImport(source)).toThrow("That theme file is not valid JSON.");
94 });
95​
96 it("preserves native T3 theme parsing and strict JSON syntax", () => {
97 const nativeTheme = {
98 version: THEME_FILE_VERSION,
99 name: "Native theme",
100 appearance: "dark",
101 colors: { canvas: "#002b36" },
102 };
103 const source = JSON.stringify(nativeTheme);
104 expect(parseThemeImport(source)).toEqual(parseThemeFile(nativeTheme));
105 expect(() => parseThemeImport(source.replace("{", "{ /* native theme */"))).toThrow();
106 expect(() => parseThemeImport(source.slice(0, -1) + ",}")).toThrow();
107 });
108​
109 it("still validates theme structure after parsing JSONC", () => {
110 expect(() =>
111 parseThemeImport('{ /* not a theme */ "colors": { "editor.foreground": "#fff" } }'),
112 ).toThrow('no "editor.background"');
113 expect(() =>
114 parseThemeImport(
115 '{ "version": 1, "name": "Bad native", "appearance": "dark", "colors": { "unknown": "#fff" } }',
116 ),
117 ).toThrow('"unknown" is not a supported theme color role.');
118 });
119});
120​
121describe("VS Code theme import", () => {
122 it("recognises workbench themes and rejects our own files", () => {
123 expect(isVsCodeThemeFile(VSCODE_DARK)).toBe(true);
124 expect(isVsCodeThemeFile({ type: "dark", tokenColors: [] })).toBe(true);
125 expect(
126 isVsCodeThemeFile({
127 version: THEME_FILE_VERSION,
128 name: "Aurora",
129 appearance: "light",
130 colors: { canvas: "#ffffff" },
131 }),
132 ).toBe(false);
133 expect(isVsCodeThemeFile("nope")).toBe(false);
134 });
135​
136 it("carries the editor surfaces and accent across", () => {
137 const theme = parseVsCodeThemeFile(VSCODE_DARK);
138 expect(theme.label).toBe("Pierre Dark Soft");
139 expect(theme.appearance).toBe("dark");
140 expect(asHex(theme.colors.canvas)).toBe("#171717");
141 expect(asHex(theme.colors.text)).toBe("#d4d4d4");
142 expect(asHex(theme.colors.accent)).toBe("#69b1ff");
143 expect(asHex(theme.colors.sidebar)).toBe("#101010");
144 expect(asHex(theme.colors.terminalBackground)).toBe("#101010");
145 });
146​
147 it("keeps a fallback placeholder dimmer than entered text", () => {
148 const theme = parseVsCodeThemeFile({
149 name: "Dark placeholder fallback",
150 type: "dark",
151 colors: {
152 "editor.background": "#1e1e2e",
153 "editor.foreground": "#cdd6f4",
154 "input.placeholderForeground": "#cdd6f473",
155 },
156 });
157​
158 expect(
159 contrastRatio(theme.colors.placeholder, theme.colors.surfaceRaised),
160 ).toBeGreaterThanOrEqual(4.5);
161 expect(contrastRatio(theme.colors.placeholder, theme.colors.canvas)).toBeGreaterThanOrEqual(
162 4.5,
163 );
164 expect(contrastRatio(theme.colors.placeholder, theme.colors.canvas)).toBeLessThan(
165 contrastRatio(theme.colors.text, theme.colors.canvas),
166 );
167 });
168​
169 it("moves reserved VS Code names to a non-reserved id", () => {
170 const darkPlus = parseVsCodeThemeFile({
171 name: "Dark+",
172 type: "dark",
173 colors: { "editor.background": "#1e1e1e" },
174 });
175 expect(darkPlus.label).toBe("Dark+");
176 expect(darkPlus.id).not.toBe("dark");
177 expect(darkPlus.id).toBe("dark-vscode");
178 });
179​
180 it("keeps Dark+ and Light+ as separate themes instead of pairing them as +", () => {
181 const themes = pairVsCodeThemes([
182 parseVsCodeThemeFile({
183 name: "Light+",
184 type: "light",
185 colors: { "editor.background": "#ffffff" },
186 }),
187 parseVsCodeThemeFile({
188 name: "Dark+",
189 type: "dark",
190 colors: { "editor.background": "#1e1e1e" },
191 }),
192 ]);
193 expect(themes.map((theme) => [theme.id, theme.label])).toEqual([
194 ["light-vscode", "Light+"],
195 ["dark-vscode", "Dark+"],
196 ]);
197 });
198​
199 it("prefers a visible input background over a transparent border", () => {
200 const catppuccin = parseVsCodeThemeFile({
201 name: "Catppuccin Mocha",
202 type: "dark",
203 colors: {
204 "editor.background": "#1e1e2e",
205 "input.border": "#00000000",
206 "input.background": "#313244",
207 },
208 });
209 expect(asHex(catppuccin.colors.input)).toBe("#313244");
210 });
211​
212 it("keeps unchecked inputs distinct from the checked action color", () => {
213 const gruvbox = parseVsCodeThemeFile({
214 name: "Gruvbox Light Soft",
215 type: "light",
216 colors: {
217 "editor.background": "#f2e5bc",
218 "button.background": "#45858880",
219 "input.background": "#45858880",
220 "input.border": "#928374",
221 },
222 });
223 expect(
224 contrastRatio(gruvbox.colors.input, gruvbox.colors.messageAction),
225 ).toBeGreaterThanOrEqual(1.1);
226 });
227​
228 it("skips a transparent focus border for a visible accent key", () => {
229 const vitesse = parseVsCodeThemeFile({
230 name: "Vitesse Dark",
231 type: "dark",
232 colors: {
233 "editor.background": "#121212",
234 focusBorder: "#00000000",
235 "button.background": "#4d9375",
236 },
237 });
238 expect(asHex(vitesse.colors.accent)).toBe("#4d9375");
239 expect(asHex(vitesse.colors.focus)).toBe("#4d9375");
240 expect(contrastRatio(vitesse.colors.focus, vitesse.colors.canvas)).toBeGreaterThanOrEqual(1.1);
241 });
242​
243 it("keeps focus visible against an explicit raised surface", () => {
244 const theme = parseVsCodeThemeFile({
245 name: "Raised focus",
246 type: "dark",
247 colors: {
248 "editor.background": "#000000",
249 focusBorder: "#111111",
250 "editorWidget.background": "#111111",
251 "button.background": "#4d9375",
252 },
253 });
254 expect(contrastRatio(theme.colors.focus, theme.colors.surfaceRaised)).toBeGreaterThanOrEqual(
255 1.1,
256 );
257 expect(asHex(theme.colors.focus)).toBe("#4d9375");
258 });
259​
260 it("skips a transparent button background for the action color", () => {
261 const theme = parseVsCodeThemeFile({
262 name: "Transparent button",
263 type: "dark",
264 colors: {
265 "editor.background": "#121212",
266 focusBorder: "#4d9375",
267 "button.background": "#00000000",
268 },
269 });
270 expect(asHex(theme.colors.messageAction)).toBe("#4d9375");
271 });
272 it("composites find backgrounds onto the canvas and foregrounds onto their match", () => {
273 const { colors } = parseVsCodeThemeFile({
274 type: "dark",
275 colors: {
276 "editor.background": "#202020",
277 "editor.findMatchBackground": "#33669980",
278 "editor.findMatchForeground": "#ffffffcc",
279 "editor.findMatchHighlightBackground": "#ffcc0040",
280 "editor.findMatchHighlightForeground": "#ffffff",
281 },
282 });
283​
284 expect(asHex(colors.searchMatchActiveBackground)).toBe("#2a435d");
285 expect(asHex(colors.searchMatchActiveForeground)).toBe("#d4d9df");
286 expect(asHex(colors.searchMatchBackground)).toBe("#584b18");
287 expect(asHex(colors.searchMatchForeground)).toBe("#ffffff");
288 });
289​
290 it.each(["light", "dark"])(
291 "keeps missing or invalid find colours readable in %s themes",
292 (type) => {
293 const base = {
294 "editor.background": type === "dark" ? "#101010" : "#fafafa",
295 };
296 const missing = parseVsCodeThemeFile({ type, colors: base }).colors;
297 const invalid = parseVsCodeThemeFile({
298 type,
299 colors: {
300 ...base,
301 "editor.findMatchBackground": "not-a-colour",
302 "editor.findMatchForeground": 42,
303 "editor.findMatchHighlightBackground": null,
304 "editor.findMatchHighlightForeground": "invalid",
305 },
306 }).colors;
307 const unreadable = parseVsCodeThemeFile({
308 type,
309 colors: {
310 ...base,
311 "editor.findMatchBackground": "#ffffcc",
312 "editor.findMatchForeground": "#ffffff",
313 "editor.findMatchHighlightBackground": "#101020",
314 "editor.findMatchHighlightForeground": "#111111",
315 },
316 }).colors;
317​
318 for (const colors of [missing, invalid, unreadable]) {
319 expect(
320 contrastRatio(colors.searchMatchForeground, colors.searchMatchBackground),
321 ).toBeGreaterThanOrEqual(4.5);
322 expect(
323 contrastRatio(colors.searchMatchActiveForeground, colors.searchMatchActiveBackground),
324 ).toBeGreaterThanOrEqual(4.5);
325 }
326 expect(invalid.searchMatchBackground).toBe(missing.searchMatchBackground);
327 expect(invalid.searchMatchActiveBackground).toBe(missing.searchMatchActiveBackground);
328 expect(missing.searchMatchBackground).not.toBe(missing.searchMatchActiveBackground);
329 expect(missing.searchMatchBackground).not.toBe(missing.canvas);
330 expect(missing.searchMatchActiveBackground).not.toBe(missing.canvas);
331 },
332 );
333​
334 it("fills every role the file omits with a readable derived value", () => {
335 const theme = parseVsCodeThemeFile(VSCODE_DARK);
336 const colors = getThemeColorsForMode(theme, "dark")!;
337 for (const value of Object.values(colors)) {
338 expect(value).toMatch(/^oklch\(/);
339 }
340 expect(contrastRatio(colors.text, colors.canvas)).toBeGreaterThanOrEqual(4.5);
341 expect(contrastRatio(colors.sidebarForeground, colors.sidebar)).toBeGreaterThanOrEqual(4.5);
342 expect(
343 contrastRatio(colors.messageActionForeground, colors.messageAction),
344 ).toBeGreaterThanOrEqual(4.5);
345 });
346​
347 it("falls back to the editor background when the type is missing or odd", () => {
348 const untyped = parseVsCodeThemeFile({
349 name: "Untyped",
350 colors: { "editor.background": "#fdfdfd", "editor.foreground": "#202020" },
351 });
352 expect(untyped.appearance).toBe("light");
353 const hc = parseVsCodeThemeFile({
354 name: "High contrast",
355 type: "hc-light",
356 colors: { "editor.background": "#ffffff" },
357 });
358 expect(hc.appearance).toBe("light");
359 });
360​
361 it.each([
362 ["dark", "#282828"],
363 ["light", "#ffffff"],
364 ] as const)(
365 "keeps a dim status color's hue on a %s canvas instead of replacing it",
366 (type, canvas) => {
367 const theme = parseVsCodeThemeFile({
368 name: "Gruvbox",
369 type,
370 colors: {
371 "editor.background": canvas,
372 "editorError.foreground": "#fb4934",
373 "editorWarning.foreground": "#b57614",
374 },
375 });
376 for (const [role, source] of [
377 ["error", "#fb4934"],
378 ["warning", "#b57614"],
379 ] as const) {
380 const imported = oklchParts(theme.colors[role]);
381 expect(contrastRatio(theme.colors[role], theme.colors.canvas)).toBeGreaterThanOrEqual(4.5);
382 expect(imported.chroma).toBeGreaterThan(0.1);
383 // 8-bit sRGB can only approximate the source hue at the new lightness.
384 expect(
385 Math.abs(imported.hue - oklchParts(toCanonicalThemeColor(source)!).hue),
386 ).toBeLessThan(2);
387 }
388 },
389 );
390​
391 it("keeps the derived status color when the file hides its own", () => {
392 const theme = parseVsCodeThemeFile({
393 name: "Hidden squiggles",
394 type: "dark",
395 colors: { "editor.background": "#101010", "editorError.foreground": "#ff000000" },
396 });
397 expect(oklchParts(theme.colors.error).chroma).toBeGreaterThan(0.1);
398 });
399​
400 it("stays readable when the file replaces a surface but not its foreground", () => {
401 const theme = parseVsCodeThemeFile({
402 name: "Split",
403 type: "dark",
404 colors: {
405 "editor.background": "#101010",
406 "editor.foreground": "#f5f5f5",
407 "sideBar.background": "#fafafa",
408 "terminal.background": "#fbfbfb",
409 },
410 });
411 expect(asHex(theme.colors.sidebar)).toBe("#fafafa");
412 expect(
413 contrastRatio(theme.colors.sidebarForeground, theme.colors.sidebar),
414 ).toBeGreaterThanOrEqual(4.5);
415 expect(
416 contrastRatio(theme.colors.terminalForeground, theme.colors.terminalBackground),
417 ).toBeGreaterThanOrEqual(4.5);
418 });
419​
420 it("reads wide-gamut color() notation", () => {
421 const theme = parseVsCodeThemeFile({
422 name: "Vibrant",
423 type: "dark",
424 colors: {
425 "editor.background": "color(display-p3 0.039216 0.039216 0.039216)",
426 "editor.foreground": "color(display-p3 0.980392 0.980392 0.980392)",
427 focusBorder: "color(display-p3 0.308664 0.645271 1.000000)",
428 "editor.selectionBackground": "color(display-p3 0.308664 0.645271 1.000000 / 0.300000)",
429 },
430 });
431 expect(asHex(theme.colors.canvas)).toMatch(/^#0[89ab]/);
432 expect(asHex(theme.colors.text)).toMatch(/^#f[a-f0-9]/);
433 const accent = asHex(theme.colors.accent);
434 const [red, green, blue] = [1, 3, 5].map((index) =>
435 Number.parseInt(accent.slice(index, index + 2), 16),
436 ) as [number, number, number];
437 expect(blue).toBeGreaterThan(200);
438 expect(blue).toBeGreaterThan(red);
439 expect(green).toBeGreaterThan(red);
440 });
441​
442 it("pairs light and dark files from one family into dual-mode themes", () => {
443 const make = (name: string, type: "light" | "dark") =>
444 parseVsCodeThemeFile({
445 name,
446 type,
447 colors: {
448 "editor.background": type === "dark" ? "#101014" : "#fdfdfd",
449 "editor.foreground": type === "dark" ? "#e6e6e6" : "#1f1f1f",
450 focusBorder: "#69b1ff",
451 },
452 });
453 const themes = pairVsCodeThemes([
454 make("github-dark", "dark"),
455 make("github-light", "light"),
456 make("github-dark-colorblind", "dark"),
457 make("github-light-colorblind", "light"),
458 make("github-dark-dimmed", "dark"),
459 ]);
460​
461 const labels = themes.map((theme) => theme.label);
462 expect(labels).toEqual(["Github", "Github Colorblind", "Github Dark Dimmed"]);
463 const github = themes[0]!;
464 expect(github.appearance).toBe("light");
465 expect(getThemeColorsForMode(github, "dark")).not.toBeNull();
466 expect(asHex(getThemeColorsForMode(github, "dark")!.canvas)).toBe("#101014");
467 expect(asHex(github.colors.canvas)).toBe("#fdfdfd");
468 expect(getThemeColorsForMode(themes[2]!, "light")).toBeNull();
469 });
470​
471 it("does not guess when a family is ambiguous", () => {
472 const make = (name: string, type: "light" | "dark") =>
473 parseVsCodeThemeFile({
474 name,
475 type,
476 colors: { "editor.background": type === "dark" ? "#101014" : "#fdfdfd" },
477 });
478 const themes = pairVsCodeThemes([
479 make("solar-dark", "dark"),
480 make("solar-dark-soft", "dark"),
481 make("solar-light", "light"),
482 ]);
483 expect(themes.map((theme) => theme.label).sort()).toEqual(["Solar", "Solar Dark Soft"]);
484 });
485​
486 it("tells same-named variants apart by their file names", () => {
487 // Both Dracula theme files declare the same internal name.
488 const dracula = (bg: string) =>
489 parseVsCodeThemeFile({
490 name: "Dracula",
491 type: "dark",
492 colors: { "editor.background": bg, "editor.foreground": "#f8f8f2" },
493 });
494 const themes = resolveThemeLabelCollisions([
495 { theme: dracula("#282a36"), sourceName: "dracula.json" },
496 { theme: dracula("#22232e"), sourceName: "dracula-soft.json" },
497 ]);
498 expect(themes.map((theme) => theme.label)).toEqual(["Dracula", "Dracula Soft"]);
499 expect(themes.map((theme) => theme.id)).toEqual(["dracula", "dracula-soft"]);
500 const numbered = resolveThemeLabelCollisions([
501 { theme: dracula("#282a36") },
502 { theme: dracula("#22232e") },
503 ]);
504 expect(numbered.map((theme) => theme.label)).toEqual(["Dracula", "Dracula 2"]);
505 });
506});