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("reports an unavailable search when every detail request fails", async () => {
184 vi.stubGlobal(
185 "fetch",
186 vi.fn(async (input: RequestInfo | URL) => {
187 if (String(input).includes("/-/search?")) {
188 return new Response(
189 JSON.stringify({ extensions: [{ namespace: "demo", name: "theme" }] }),
190 { status: 200 },
191 );
192 }
193 return new Response(null, { status: 429 });
194 }),
195 );
196
197 await expect(searchOpenVsxThemes("dracula")).rejects.toThrow(
198 "Open VSX theme details are unavailable",
199 );
200 });
201
202 it("downloads a verified VSIX, reads JSONC includes, and pairs contributed variants", async () => {
203 const zip = new JSZip();
204 zip.file("extension/.gitkeep", "");
205 for (let index = 0; index < 3_000; index += 1) {
206 zip.file(`extension/node_modules/package-${index}.js`, "");
207 }
208 zip.file(
209 "extension/themes/base.jsonc",
210 `{
211 // inherited workbench colors
212 "colors": {
213 "editor.foreground": "#eeeeee",
214 "focusBorder": "#8b5cf6",
215 "editor.findMatchBackground": "#663399",
216 "editor.findMatchForeground": "#ffffff",
217 "editor.findMatchHighlightBackground": "#445566",
218 "editor.findMatchHighlightForeground": "#ffffff",
219 },
220 }`,
221 );
222 zip.file(
223 "extension/themes/demo-dark.json",
224 `{
225 "include": "./base.jsonc",
226 "colors": {
227 "editor.background": "#111111",
228 "editor.findMatchHighlightBackground": "#ffffff20"
229 }
230 }`,
231 );
232 zip.file(
233 "extension/themes/demo-light.json",
234 `{
235 "colors": {
236 "editor.background": "#fafafa",
237 "editor.foreground": "#222222",
238 "focusBorder": "#8b5cf6",
239 "editor.findMatchBackground": "#335577",
240 "editor.findMatchForeground": "#ffffff",
241 "editor.findMatchHighlightBackground": "#bbddff",
242 "editor.findMatchHighlightForeground": "#112233"
243 }
244 }`,
245 );
246 zip.file(
247 "extension/themes/demo.json",
248 `{
249 "colors": {
250 "editor.background": "#181818",
251 "editor.foreground": "#eeeeee"
252 }
253 }`,
254 );
255 const packagedManifest = {
256 publisher: "demo",
257 name: "theme",
258 version: "1.0.0",
259 license: "MIT",
260 contributes: {
261 themes: [
262 { label: "Demo Dark", uiTheme: "vs-dark", path: "./themes/demo-dark.json" },
263 { label: "Demo Light", uiTheme: "vs", path: "./themes/demo-light.json" },
264 { label: "Demo", uiTheme: "vs-dark", path: "./themes/demo.json" },
265 ],
266 },
267 };
268 let packageBytes = new ArrayBuffer(0);
269 let checksum = "";
270 const rebuildPackage = async () => {
271 zip.file("extension/package.json", JSON.stringify(packagedManifest));
272 packageBytes = await zip.generateAsync({
273 type: "arraybuffer",
274 comment: `PK\u0005\u0006${"x".repeat(26)}`,
275 });
276 checksum = [...sha256(new Uint8Array(packageBytes))]
277 .map((byte) => byte.toString(16).padStart(2, "0"))
278 .join("");
279 };
280 await rebuildPackage();
281 const manifest = {
282 contributes: { themes: [{ label: "Wrong", path: "./themes/missing.json" }] },
283 };
284 const extension: OpenVsxThemeExtension = {
285 collectionId: "open-vsx:demo.theme",
286 id: "demo.theme",
287 name: "Demo Theme",
288 publisher: "demo",
289 description: "",
290 downloadCount: 1,
291 iconUrl: null,
292 sourceUrl: null,
293 license: "MIT",
294 manifestUrl: `${ASSET_ROOT}/package.json`,
295 sha256Url: `${ASSET_ROOT}/demo.theme-1.0.0.sha256`,
296 version: "1.0.0",
297 vsixUrl: `${ASSET_ROOT}/demo.theme-1.0.0.vsix`,
298 };
299 vi.stubGlobal(
300 "fetch",
301 vi.fn(async (input: string | URL | Request) => {
302 const url = String(input);
303 if (url === extension.manifestUrl) {
304 return new Response(JSON.stringify(manifest), { status: 200 });
305 }
306 if (url === extension.sha256Url) return new Response(`${checksum} demo.vsix`);
307 return new Response(packageBytes, {
308 status: 200,
309 headers: { "Content-Length": String(packageBytes.byteLength) },
310 });
311 }),
312 );
313
314 const themes = await importOpenVsxThemeExtension(extension);
315
316 expect(themes).toHaveLength(2);
317 expect(new Set(themes.map((theme) => theme.id)).size).toBe(2);
318 expect(themes.every((theme) => /^ovx-[a-z0-9-]+-[0-9a-f]{12}$/.test(theme.id))).toBe(true);
319 expect(themes.every((theme) => theme.collection?.id === "open-vsx:demo.theme")).toBe(true);
320 const paired = themes.find(
321 (theme) =>
322 getThemeColorsForMode(theme, "light") !== null &&
323 getThemeColorsForMode(theme, "dark") !== null,
324 )!;
325 expect(paired.label).toBe("Demo");
326 expect(themeColorToHex(paired.colors.canvas)).toBe("#fafafa");
327 expect(themeColorToHex(getThemeColorsForMode(paired, "dark")!.canvas)).toBe("#111111");
328 expect(themeColorToHex(getThemeColorsForMode(paired, "dark")!.text)).toBe("#eeeeee");
329 const darkColors = getThemeColorsForMode(paired, "dark")!;
330 expect(themeColorToHex(darkColors.searchMatchActiveBackground)).toBe("#663399");
331 expect(themeColorToHex(darkColors.searchMatchActiveForeground)).toBe("#ffffff");
332 expect(themeColorToHex(darkColors.searchMatchBackground)).toBe("#2f2f2f");
333 expect(themeColorToHex(darkColors.searchMatchForeground)).toBe("#ffffff");
334 expect(themeColorToHex(paired.colors.searchMatchActiveBackground)).toBe("#335577");
335 expect(themeColorToHex(paired.colors.searchMatchActiveForeground)).toBe("#ffffff");
336 expect(themeColorToHex(paired.colors.searchMatchBackground)).toBe("#bbddff");
337 expect(themeColorToHex(paired.colors.searchMatchForeground)).toBe("#112233");
338
339 packagedManifest.contributes.themes[0]!.label = "Renamed Dark";
340 packagedManifest.contributes.themes[1]!.label = "Renamed Light";
341 packagedManifest.contributes.themes[2]!.label = "Renamed Solo";
342 await rebuildPackage();
343 const renamedThemes = await importOpenVsxThemeExtension(extension);
344 expect(renamedThemes.map((theme) => theme.id)).toEqual(themes.map((theme) => theme.id));
345
346 packagedManifest.contributes.themes.push({
347 label: "Duplicate Demo",
348 uiTheme: "vs-dark",
349 path: "./themes/demo.json",
350 });
351 await rebuildPackage();
352 const duplicatePathThemes = await importOpenVsxThemeExtension(extension);
353 expect(new Set(duplicatePathThemes.map((theme) => theme.id)).size).toBe(
354 duplicatePathThemes.length,
355 );
356 expect(themes.every(({ id }) => duplicatePathThemes.some((theme) => theme.id === id))).toBe(
357 true,
358 );
359 packagedManifest.contributes.themes.pop();
360
361 packagedManifest.contributes.themes[2]!.path = "./themes/missing.json";
362 await rebuildPackage();
363 await expect(importOpenVsxThemeExtension(extension)).rejects.toThrow(
364 "could not be imported safely",
365 );
366 packagedManifest.contributes.themes[2]!.path = "./themes/demo.json";
367
368 packagedManifest.license = "Proprietary";
369 await rebuildPackage();
370 await expect(importOpenVsxThemeExtension(extension)).rejects.toThrow(
371 "does not match its advertised license",
372 );
373
374 Reflect.deleteProperty(packagedManifest, "license");
375 await rebuildPackage();
376 await expect(importOpenVsxThemeExtension(extension)).rejects.toThrow(
377 "does not match its advertised license",
378 );
379 });
380
381 it("stops import work when the request is cancelled", async () => {
382 const packageBytes = new Uint8Array([1, 2, 3]);
383 const checksum = [...sha256(packageBytes)]
384 .map((byte) => byte.toString(16).padStart(2, "0"))
385 .join("");
386 const controller = new AbortController();
387 const extension: OpenVsxThemeExtension = {
388 collectionId: "open-vsx:demo.theme",
389 id: "demo.theme",
390 name: "Demo Theme",
391 publisher: "demo",
392 description: "",
393 downloadCount: 1,
394 iconUrl: null,
395 sourceUrl: null,
396 license: "MIT",
397 manifestUrl: `${ASSET_ROOT}/package.json`,
398 sha256Url: `${ASSET_ROOT}/demo.theme-1.0.0.sha256`,
399 version: "1.0.0",
400 vsixUrl: `${ASSET_ROOT}/demo.theme-1.0.0.vsix`,
401 };
402 vi.stubGlobal(
403 "fetch",
404 vi.fn(async (input: string | URL | Request) => {
405 const url = String(input);
406 if (url === extension.manifestUrl) {
407 return new Response(
408 JSON.stringify({ contributes: { themes: [{ path: "./theme.json" }] } }),
409 );
410 }
411 if (url === extension.sha256Url) {
412 controller.abort();
413 return new Response(checksum);
414 }
415 return new Response(packageBytes);
416 }),
417 );
418
419 await expect(importOpenVsxThemeExtension(extension, controller.signal)).rejects.toMatchObject({
420 name: "AbortError",
421 });
422 });
423
424 it("rejects a package whose Open VSX checksum does not match", async () => {
425 const extension: OpenVsxThemeExtension = {
426 collectionId: "open-vsx:demo.theme",
427 id: "demo.theme",
428 name: "Demo Theme",
429 publisher: "demo",
430 description: "",
431 downloadCount: 1,
432 iconUrl: null,
433 sourceUrl: null,
434 license: "MIT",
435 manifestUrl: `${ASSET_ROOT}/package.json`,
436 sha256Url: `${ASSET_ROOT}/demo.theme-1.0.0.sha256`,
437 version: "1.0.0",
438 vsixUrl: `${ASSET_ROOT}/demo.theme-1.0.0.vsix`,
439 };
440 vi.stubGlobal(
441 "fetch",
442 vi.fn(async (input: string | URL | Request) => {
443 const url = String(input);
444 if (url === extension.manifestUrl) {
445 return new Response(
446 JSON.stringify({ contributes: { themes: [{ path: "./theme.json" }] } }),
447 );
448 }
449 if (url === extension.sha256Url) return new Response("0".repeat(64));
450 return new Response(new Uint8Array([1, 2, 3]));
451 }),
452 );
453
454 await expect(importOpenVsxThemeExtension(extension)).rejects.toThrow("integrity check");
455 });
456});