1import { sha256 } from "@noble/hashes/sha2";
2import JSZip from "jszip";
3import { afterEach, describe, expect, it, vi } from "vite-plus/test";
4
5import {
6 importOpenVsxThemeExtension,
7 searchOpenVsxThemes,
8 type OpenVsxThemeExtension,
9} from "./openVsxThemes";
10import { getThemeColorsForMode, themeColorToHex } from "./themePalette";
11
12const ASSET_ROOT = "https://open-vsx.org/api/demo/theme/1.0.0/file";
13
14function extensionDetail(overrides: Record<string, unknown> = {}) {
15 return {
16 namespace: "demo",
17 name: "theme",
18 displayName: "Demo Theme",
19 description: "A nice theme",
20 version: "1.0.0",
21 downloadCount: 123456,
22 license: "MIT",
23 repository: "https://github.com/demo/theme",
24 files: {
25 icon: `${ASSET_ROOT}/icon.png`,
26 manifest: `${ASSET_ROOT}/package.json`,
27 sha256: `${ASSET_ROOT}/demo.theme-1.0.0.sha256`,
28 download: `${ASSET_ROOT}/demo.theme-1.0.0.vsix`,
29 },
30 ...overrides,
31 };
32}
33
34afterEach(() => {
35 vi.useRealTimers();
36 vi.unstubAllGlobals();
37});
38
39describe("Open VSX themes", () => {
40 it("searches theme extensions and keeps only supported open-source licenses", async () => {
41 const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
42 const url = String(input);
43 if (url.includes("/-/search?")) {
44 return new Response(
45 JSON.stringify({
46 extensions: [
47 { namespace: "demo", name: "theme" },
48 { namespace: "icons", name: "theme" },
49 { namespace: "oversized", name: "theme" },
50 { namespace: "unlicensed", name: "theme" },
51 { namespace: "unavailable", name: "theme" },
52 { namespace: "closed", name: "theme" },
53 { namespace: "huge", name: "theme" },
54 ],
55 }),
56 { status: 200 },
57 );
58 }
59 if (url.endsWith("/icons/theme")) {
60 return new Response(
61 JSON.stringify(
62 extensionDetail({
63 namespace: "icons",
64 files: {
65 ...extensionDetail().files,
66 manifest: `${ASSET_ROOT.replace("demo/theme", "icons/theme")}/package.json`,
67 },
68 }),
69 ),
70 { status: 200 },
71 );
72 }
73 if (url.endsWith("/oversized/theme")) {
74 return new Response(
75 JSON.stringify(
76 extensionDetail({
77 namespace: "oversized",
78 files: {
79 ...extensionDetail().files,
80 download: `${ASSET_ROOT.replace("demo/theme", "oversized/theme")}/oversized.vsix`,
81 },
82 }),
83 ),
84 { status: 200 },
85 );
86 }
87 if (url.endsWith("/unlicensed/theme")) {
88 return new Response(
89 JSON.stringify(
90 extensionDetail({
91 namespace: "unlicensed",
92 files: {
93 ...extensionDetail().files,
94 manifest: `${ASSET_ROOT.replace("demo/theme", "unlicensed/theme")}/package.json`,
95 },
96 }),
97 ),
98 { status: 200 },
99 );
100 }
101 if (url.endsWith("/unavailable/theme")) {
102 return new Response(
103 JSON.stringify(
104 extensionDetail({
105 namespace: "unavailable",
106 files: {
107 ...extensionDetail().files,
108 download: `${ASSET_ROOT.replace("demo/theme", "unavailable/theme")}/unavailable.vsix`,
109 manifest: `${ASSET_ROOT.replace("demo/theme", "unavailable/theme")}/package.json`,
110 },
111 }),
112 ),
113 { status: 200 },
114 );
115 }
116 if (url.endsWith("/closed/theme")) {
117 return new Response(
118 JSON.stringify(extensionDetail({ namespace: "closed", license: "All Rights Reserved" })),
119 { status: 200 },
120 );
121 }
122 if (url.endsWith("/huge/theme")) {
123 return new Response(JSON.stringify({ padding: "x".repeat(256 * 1024) }), { status: 200 });
124 }
125 if (url.endsWith("/oversized/theme/1.0.0/file/oversized.vsix")) {
126 return new Response(null, {
127 headers: { "content-length": String(20 * 1024 * 1024 + 1) },
128 status: 200,
129 });
130 }
131 if (url.endsWith("/unavailable/theme/1.0.0/file/unavailable.vsix")) {
132 return new Response(null, { status: 404 });
133 }
134 if (url.endsWith(".vsix")) {
135 return new Response(null, { headers: { "content-length": "1024" }, status: 200 });
136 }
137 if (url.endsWith("/icons/theme/1.0.0/file/package.json")) {
138 return new Response(JSON.stringify({ contributes: { iconThemes: [{}] } }), { status: 200 });
139 }
140 if (url.endsWith("/unlicensed/theme/1.0.0/file/package.json")) {
141 return new Response(
142 JSON.stringify({ contributes: { themes: [{ path: "./theme.json" }] } }),
143 { status: 200 },
144 );
145 }
146 if (url.endsWith("/unavailable/theme/1.0.0/file/package.json")) {
147 return new Response(
148 JSON.stringify({ license: "MIT", contributes: { themes: [{ path: "./theme.json" }] } }),
149 { status: 200 },
150 );
151 }
152 if (url.endsWith("/demo/theme/1.0.0/file/package.json")) {
153 return new Response(
154 JSON.stringify({ license: "MIT", contributes: { themes: [{ path: "./theme.json" }] } }),
155 { status: 200 },
156 );
157 }
158 return new Response(JSON.stringify(extensionDetail()), { status: 200 });
159 });
160 vi.stubGlobal("fetch", fetchMock);
161
162 const results = await searchOpenVsxThemes(" dracula ");
163
164 expect(results).toEqual([
165 expect.objectContaining({
166 id: "demo.theme",
167 name: "Demo Theme",
168 publisher: "demo",
169 downloadCount: 123456,
170 iconUrl: `${ASSET_ROOT}/icon.png`,
171 sourceUrl: "https://github.com/demo/theme",
172 license: "MIT",
173 }),
174 ]);
175 const searchUrl = new URL(String(fetchMock.mock.calls[0]![0]));
176 expect(searchUrl.searchParams.get("query")).toBe("dracula");
177 expect(searchUrl.searchParams.get("category")).toBe("Themes");
178 expect(searchUrl.searchParams.get("sortBy")).toBe("downloadCount");
179 expect(searchUrl.searchParams.get("sortOrder")).toBe("desc");
180 expect(searchUrl.searchParams.get("size")).toBe("16");
181 });
182
183 it("supports sorting theme searches", async () => {
184 const fetchMock = vi.fn(
185 async (..._args: unknown[]) =>
186 new Response(JSON.stringify({ extensions: [] }), { status: 200 }),
187 );
188 vi.stubGlobal("fetch", fetchMock);
189
190 await searchOpenVsxThemes("nord", { sortBy: "rating" });
191
192 const searchUrl = new URL(String(fetchMock.mock.calls[0]![0]));
193 expect(searchUrl.searchParams.get("sortBy")).toBe("rating");
194 expect(searchUrl.searchParams.get("sortOrder")).toBe("desc");
195 });
196
197 it("rejects malformed search responses", async () => {
198 vi.stubGlobal(
199 "fetch",
200 vi.fn(async () => new Response(JSON.stringify({ error: "unavailable" }), { status: 200 })),
201 );
202
203 await expect(searchOpenVsxThemes("nord")).rejects.toThrow(
204 "Open VSX returned an unreadable search response.",
205 );
206 });
207
208 it("reports an unavailable search when every detail request fails", async () => {
209 vi.stubGlobal(
210 "fetch",
211 vi.fn(async (input: RequestInfo | URL) => {
212 if (String(input).includes("/-/search?")) {
213 return new Response(
214 JSON.stringify({ extensions: [{ namespace: "demo", name: "theme" }] }),
215 { status: 200 },
216 );
217 }
218 return new Response(null, { status: 429 });
219 }),
220 );
221
222 await expect(searchOpenVsxThemes("dracula")).rejects.toThrow(
223 "Open VSX theme details are unavailable",
224 );
225 });
226
227 it("reports an unavailable search when every detail response is malformed", async () => {
228 vi.stubGlobal(
229 "fetch",
230 vi.fn(async (input: RequestInfo | URL) => {
231 if (String(input).includes("/-/search?")) {
232 return new Response(
233 JSON.stringify({ extensions: [{ namespace: "demo", name: "theme" }] }),
234 { status: 200 },
235 );
236 }
237 return new Response("{}", { status: 200 });
238 }),
239 );
240
241 await expect(searchOpenVsxThemes("dracula")).rejects.toThrow(
242 "Open VSX theme details are unavailable",
243 );
244 });
245
246 it("returns no results when every theme package is unavailable", async () => {
247 vi.stubGlobal(
248 "fetch",
249 vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
250 const url = String(input);
251 if (url.includes("/-/search?")) {
252 return new Response(
253 JSON.stringify({ extensions: [{ namespace: "demo", name: "theme" }] }),
254 { status: 200 },
255 );
256 }
257 if (url.endsWith("/demo/theme")) {
258 return new Response(JSON.stringify(extensionDetail()), { status: 200 });
259 }
260 if (init?.method === "HEAD") return new Response(null, { status: 404 });
261 return new Response(
262 JSON.stringify({ license: "MIT", contributes: { themes: [{ path: "./theme.json" }] } }),
263 { status: 200 },
264 );
265 }),
266 );
267
268 await expect(searchOpenVsxThemes("dracula")).resolves.toEqual([]);
269 });
270
271 it("times out a stalled search request", async () => {
272 vi.useFakeTimers();
273 vi.stubGlobal(
274 "fetch",
275 vi.fn(
276 (_input: RequestInfo | URL, init?: RequestInit) =>
277 new Promise<Response>((_resolve, reject) => {
278 init?.signal?.addEventListener("abort", () => {
279 reject(new DOMException("The operation was aborted.", "AbortError"));
280 });
281 }),
282 ),
283 );
284
285 const search = expect(searchOpenVsxThemes("dracula")).rejects.toThrow(
286 "Open VSX took too long to respond",
287 );
288 await vi.advanceTimersByTimeAsync(10_000);
289
290 await search;
291 });
292
293 it("downloads a verified VSIX, reads JSONC includes, and pairs contributed variants", async () => {
294 const zip = new JSZip();
295 zip.file("extension/.gitkeep", "");
296 // Theme extensions sometimes publish their development dependencies too.
297 // Those unused files should not prevent importing the small theme payload.
298 for (let index = 0; index < 3_000; index += 1) {
299 zip.file(`extension/node_modules/package-${index}.js`, "");
300 }
301 zip.file(
302 "extension/themes/base.jsonc",
303 `{
304 // inherited workbench colors
305 "colors": {
306 "editor.foreground": "#eeeeee",
307 "focusBorder": "#8b5cf6",
308 "editor.findMatchBackground": "#663399",
309 "editor.findMatchForeground": "#ffffff",
310 "editor.findMatchHighlightBackground": "#445566",
311 "editor.findMatchHighlightForeground": "#ffffff",
312 },
313 }`,
314 );
315 zip.file(
316 "extension/themes/demo-dark.json",
317 `{
318 "include": "./base.jsonc",
319 "colors": {
320 "editor.background": "#111111",
321 "editor.findMatchHighlightBackground": "#ffffff20"
322 }
323 }`,
324 );
325 zip.file(
326 "extension/themes/demo-light.json",
327 `{
328 "colors": {
329 "editor.background": "#fafafa",
330 "editor.foreground": "#222222",
331 "focusBorder": "#8b5cf6",
332 "editor.findMatchBackground": "#335577",
333 "editor.findMatchForeground": "#ffffff",
334 "editor.findMatchHighlightBackground": "#bbddff",
335 "editor.findMatchHighlightForeground": "#112233"
336 }
337 }`,
338 );
339 zip.file(
340 "extension/themes/demo.json",
341 `{
342 "colors": {
343 "editor.background": "#181818",
344 "editor.foreground": "#eeeeee"
345 }
346 }`,
347 );
348 const packagedManifest = {
349 publisher: "demo",
350 name: "theme",
351 version: "1.0.0",
352 license: "MIT",
353 contributes: {
354 themes: [
355 { label: "Demo Dark", uiTheme: "vs-dark", path: "./themes/demo-dark.json" },
356 { label: "Demo Light", uiTheme: "vs", path: "./themes/demo-light.json" },
357 { label: "Demo", uiTheme: "vs-dark", path: "./themes/demo.json" },
358 ],
359 },
360 };
361 let packageBytes = new ArrayBuffer(0);
362 let checksum = "";
363 const rebuildPackage = async () => {
364 zip.file("extension/package.json", JSON.stringify(packagedManifest));
365 packageBytes = await zip.generateAsync({
366 type: "arraybuffer",
367 comment: `PK\u0005\u0006${"x".repeat(26)}`,
368 });
369 checksum = [...sha256(new Uint8Array(packageBytes))]
370 .map((byte) => byte.toString(16).padStart(2, "0"))
371 .join("");
372 };
373 await rebuildPackage();
374 // The public manifest is only a preflight hint. Imports must use the manifest
375 // inside the checksummed VSIX rather than this inconsistent contribution.
376 const manifest = {
377 contributes: { themes: [{ label: "Wrong", path: "./themes/missing.json" }] },
378 };
379 const extension: OpenVsxThemeExtension = {
380 collectionId: "open-vsx:demo.theme",
381 id: "demo.theme",
382 name: "Demo Theme",
383 publisher: "demo",
384 description: "",
385 downloadCount: 1,
386 iconUrl: null,
387 sourceUrl: null,
388 license: "MIT",
389 manifestUrl: `${ASSET_ROOT}/package.json`,
390 sha256Url: `${ASSET_ROOT}/demo.theme-1.0.0.sha256`,
391 version: "1.0.0",
392 vsixUrl: `${ASSET_ROOT}/demo.theme-1.0.0.vsix`,
393 };
394 vi.stubGlobal(
395 "fetch",
396 vi.fn(async (input: string | URL | Request) => {
397 const url = String(input);
398 if (url === extension.manifestUrl) {
399 return new Response(JSON.stringify(manifest), { status: 200 });
400 }
401 if (url === extension.sha256Url) return new Response(`${checksum} demo.vsix`);
402 return new Response(packageBytes, {
403 status: 200,
404 headers: { "Content-Length": String(packageBytes.byteLength) },
405 });
406 }),
407 );
408
409 const themes = await importOpenVsxThemeExtension(extension);
410
411 expect(themes).toHaveLength(2);
412 expect(new Set(themes.map((theme) => theme.id)).size).toBe(2);
413 expect(themes.every((theme) => /^ovx-[a-z0-9-]+-[0-9a-f]{12}$/.test(theme.id))).toBe(true);
414 expect(themes.every((theme) => theme.collection?.id === "open-vsx:demo.theme")).toBe(true);
415 const paired = themes.find(
416 (theme) =>
417 getThemeColorsForMode(theme, "light") !== null &&
418 getThemeColorsForMode(theme, "dark") !== null,
419 )!;
420 expect(paired.label).toBe("Demo");
421 expect(themeColorToHex(paired.colors.canvas)).toBe("#fafafa");
422 expect(themeColorToHex(getThemeColorsForMode(paired, "dark")!.canvas)).toBe("#111111");
423 expect(themeColorToHex(getThemeColorsForMode(paired, "dark")!.text)).toBe("#eeeeee");
424 const darkColors = getThemeColorsForMode(paired, "dark")!;
425 expect(themeColorToHex(darkColors.searchMatchActiveBackground)).toBe("#663399");
426 expect(themeColorToHex(darkColors.searchMatchActiveForeground)).toBe("#ffffff");
427 expect(themeColorToHex(darkColors.searchMatchBackground)).toBe("#2f2f2f");
428 expect(themeColorToHex(darkColors.searchMatchForeground)).toBe("#ffffff");
429 expect(themeColorToHex(paired.colors.searchMatchActiveBackground)).toBe("#335577");
430 expect(themeColorToHex(paired.colors.searchMatchActiveForeground)).toBe("#ffffff");
431 expect(themeColorToHex(paired.colors.searchMatchBackground)).toBe("#bbddff");
432 expect(themeColorToHex(paired.colors.searchMatchForeground)).toBe("#112233");
433
434 packagedManifest.contributes.themes[0]!.label = "Renamed Dark";
435 packagedManifest.contributes.themes[1]!.label = "Renamed Light";
436 packagedManifest.contributes.themes[2]!.label = "Renamed Solo";
437 await rebuildPackage();
438 const renamedThemes = await importOpenVsxThemeExtension(extension);
439 expect(renamedThemes.map((theme) => theme.id)).toEqual(themes.map((theme) => theme.id));
440
441 packagedManifest.contributes.themes.push({
442 label: "Duplicate Demo",
443 uiTheme: "vs-dark",
444 path: "./themes/demo.json",
445 });
446 await rebuildPackage();
447 const duplicatePathThemes = await importOpenVsxThemeExtension(extension);
448 expect(new Set(duplicatePathThemes.map((theme) => theme.id)).size).toBe(
449 duplicatePathThemes.length,
450 );
451 expect(themes.every(({ id }) => duplicatePathThemes.some((theme) => theme.id === id))).toBe(
452 true,
453 );
454 packagedManifest.contributes.themes.pop();
455
456 packagedManifest.contributes.themes[2]!.path = "./themes/missing.json";
457 await rebuildPackage();
458 await expect(importOpenVsxThemeExtension(extension)).rejects.toThrow(
459 "could not be imported safely",
460 );
461 packagedManifest.contributes.themes[2]!.path = "./themes/demo.json";
462
463 packagedManifest.license = "Proprietary";
464 await rebuildPackage();
465 await expect(importOpenVsxThemeExtension(extension)).rejects.toThrow(
466 "does not match its advertised license",
467 );
468
469 Reflect.deleteProperty(packagedManifest, "license");
470 await rebuildPackage();
471 await expect(importOpenVsxThemeExtension(extension)).rejects.toThrow(
472 "does not match its advertised license",
473 );
474 });
475
476 it("stops import work when the request is cancelled", async () => {
477 const packageBytes = new Uint8Array([1, 2, 3]);
478 const checksum = [...sha256(packageBytes)]
479 .map((byte) => byte.toString(16).padStart(2, "0"))
480 .join("");
481 const controller = new AbortController();
482 const extension: OpenVsxThemeExtension = {
483 collectionId: "open-vsx:demo.theme",
484 id: "demo.theme",
485 name: "Demo Theme",
486 publisher: "demo",
487 description: "",
488 downloadCount: 1,
489 iconUrl: null,
490 sourceUrl: null,
491 license: "MIT",
492 manifestUrl: `${ASSET_ROOT}/package.json`,
493 sha256Url: `${ASSET_ROOT}/demo.theme-1.0.0.sha256`,
494 version: "1.0.0",
495 vsixUrl: `${ASSET_ROOT}/demo.theme-1.0.0.vsix`,
496 };
497 vi.stubGlobal(
498 "fetch",
499 vi.fn(async (input: string | URL | Request) => {
500 const url = String(input);
501 if (url === extension.manifestUrl) {
502 return new Response(
503 JSON.stringify({ contributes: { themes: [{ path: "./theme.json" }] } }),
504 );
505 }
506 if (url === extension.sha256Url) {
507 controller.abort();
508 return new Response(checksum);
509 }
510 return new Response(packageBytes);
511 }),
512 );
513
514 await expect(importOpenVsxThemeExtension(extension, controller.signal)).rejects.toMatchObject({
515 name: "AbortError",
516 });
517 });
518
519 it("rejects a package whose Open VSX checksum does not match", async () => {
520 const extension: OpenVsxThemeExtension = {
521 collectionId: "open-vsx:demo.theme",
522 id: "demo.theme",
523 name: "Demo Theme",
524 publisher: "demo",
525 description: "",
526 downloadCount: 1,
527 iconUrl: null,
528 sourceUrl: null,
529 license: "MIT",
530 manifestUrl: `${ASSET_ROOT}/package.json`,
531 sha256Url: `${ASSET_ROOT}/demo.theme-1.0.0.sha256`,
532 version: "1.0.0",
533 vsixUrl: `${ASSET_ROOT}/demo.theme-1.0.0.vsix`,
534 };
535 vi.stubGlobal(
536 "fetch",
537 vi.fn(async (input: string | URL | Request) => {
538 const url = String(input);
539 if (url === extension.manifestUrl) {
540 return new Response(
541 JSON.stringify({ contributes: { themes: [{ path: "./theme.json" }] } }),
542 );
543 }
544 if (url === extension.sha256Url) return new Response("0".repeat(64));
545 return new Response(new Uint8Array([1, 2, 3]));
546 }),
547 );
548
549 await expect(importOpenVsxThemeExtension(extension)).rejects.toThrow("integrity check");
550 });
551});