Open History to see the last commit
1import { describe, expect, it, vi } from "vite-plus/test";
2import { EnvironmentId } from "@t3tools/contracts";
3
4import {
5 createProjectFaviconCache,
6 createProjectFaviconImageLoader,
7 PROJECT_FAVICON_CACHE_MAX_BYTES,
8 PROJECT_FAVICON_CACHE_MAX_ENTRIES,
9 PROJECT_FAVICON_MAX_DATA_URL_LENGTH,
10 PROJECT_FAVICON_MAX_SOURCE_BYTES,
11 type ProjectFaviconEntry,
12 type ProjectFaviconStorage,
13} from "./projectFaviconCache.ts";
14
15const target = { environmentId: EnvironmentId.make("remote"), cwd: "/workspace" };
16const url = "https://remote.test/api/assets/token-a/vabc-icon.svg";
17const image = "data:image/png;base64,aWNvbg==";
18const replacement = "data:image/png;base64,bmV3";
19const signal = () => new AbortController().signal;
20
21function deferred<A>() {
22 let resolve!: (value: A) => void;
23 const promise = new Promise<A>((done) => {
24 resolve = done;
25 });
26 return { promise, resolve };
27}
28
29function fixture() {
30 const records = new Map<string, ProjectFaviconEntry>();
31 const load = vi.fn(async () => image);
32 const storage: ProjectFaviconStorage = {
33 list: async () => [...records.values()],
34 put: async (key, entry) => {
35 records.set(key, entry);
36 },
37 remove: async (key) => {
38 records.delete(key);
39 },
40 };
41 return {
42 storage,
43 load,
44 records,
45 cache: createProjectFaviconCache({ storage, load }),
46 };
47}
48
49describe("persistent project favicon cache", () => {
50 it("isolates environments, workspaces, and icon selections", async () => {
51 const { cache } = fixture();
52 await cache.resolve(target, url, signal());
53 expect(cache.peek({ ...target, faviconPath: null })).toBe(image);
54 expect(cache.peek({ ...target, faviconPath: "brand.svg" })).toBeNull();
55 expect(cache.peek({ ...target, cwd: "/other" })).toBeNull();
56 expect(cache.peek({ ...target, environmentId: EnvironmentId.make("other") })).toBeNull();
57 });
58
59 it.each([
60 {
61 scope: "one environment",
62 clear: (cache: ReturnType<typeof fixture>["cache"]) =>
63 cache.clearEnvironment(target.environmentId),
64 remaining: 1,
65 },
66 {
67 scope: "every environment",
68 clear: (cache: ReturnType<typeof fixture>["cache"]) => cache.clearAll(),
69 remaining: 0,
70 },
71 ])(
72 "does not restore images for $scope removed during a download",
73 async ({ clear, remaining }) => {
74 const { cache, load, records } = fixture();
75 const other = { ...target, environmentId: EnvironmentId.make("other") };
76 await cache.resolve(other, url, signal());
77 const next = deferred<string>();
78 const started = deferred<void>();
79 load.mockImplementationOnce(() => {
80 started.resolve();
81 return next.promise;
82 });
83 const pending = cache.resolve(target, url, signal());
84 await started.promise;
85 await clear(cache);
86 next.resolve(image);
87 await pending;
88 await cache.flush();
89 expect(cache.peek(target)).toBeNull();
90 expect(records.size).toBe(remaining);
91 },
92 );
93
94 it("bounds individual images, total bytes, and entry count in storage", async () => {
95 const { cache, load, records } = fixture();
96 load.mockResolvedValueOnce(
97 `data:image/png;base64,${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH)}`,
98 );
99 expect(await cache.resolve(target, url, signal())).toBe(url);
100 expect(cache.peek(target)).toBeNull();
101 const large = `data:image/png;base64,${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH - 32)}`;
102 load.mockResolvedValue(large);
103 for (let i = 0; i < 40; i++) {
104 await cache.resolve({ ...target, cwd: `/large-${i}` }, url, signal());
105 }
106 await cache.flush();
107 expect(
108 [...records.values()].reduce((total, entry) => total + entry.dataUrl.length, 0),
109 ).toBeLessThanOrEqual(PROJECT_FAVICON_CACHE_MAX_BYTES);
110 expect(cache.peek({ ...target, cwd: "/large-0" })).toBeNull();
111 expect(cache.peek({ ...target, cwd: "/large-39" })).toBe(large);
112 load.mockResolvedValue(image);
113 for (let i = 0; i <= PROJECT_FAVICON_CACHE_MAX_ENTRIES; i++) {
114 await cache.resolve({ ...target, cwd: `/small-${i}` }, url, signal());
115 }
116 await cache.flush();
117 expect(records.size).toBe(PROJECT_FAVICON_CACHE_MAX_ENTRIES);
118 expect(cache.peek({ ...target, cwd: "/small-0" })).toBeNull();
119 });
120
121 it("skips corrupt records and tolerates unavailable storage", async () => {
122 const corrupt = createProjectFaviconCache({
123 storage: {
124 list: async () => [
125 { ...target, faviconPath: null, revision: "r", dataUrl: image },
126 { ...target, cwd: "/broken", faviconPath: null, revision: "r", dataUrl: "not-an-image" },
127 "garbage",
128 ],
129 put: async () => {},
130 remove: async () => {},
131 },
132 load: async () => replacement,
133 });
134 await corrupt.hydrate();
135 expect(corrupt.peek(target)).toBe(image);
136 expect(corrupt.peek({ ...target, cwd: "/broken" })).toBeNull();
137
138 const unavailable = createProjectFaviconCache({
139 storage: {
140 list: async () => {
141 throw new Error("storage unavailable");
142 },
143 put: async () => {
144 throw new Error("quota exceeded");
145 },
146 remove: async () => {
147 throw new Error("quota exceeded");
148 },
149 },
150 load: async () => image,
151 });
152 expect(await unavailable.resolve(target, url, signal())).toBe(image);
153 await unavailable.flush();
154 expect(unavailable.peek(target)).toBe(image);
155 });
156});
157
158describe("project favicon image loader", () => {
159 const svg =
160 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16"/></svg>';
161 const svgBase64 = btoa(svg);
162
163 function loader(response: Response, downscale = vi.fn(async () => replacement)) {
164 return {
165 downscale,
166 load: createProjectFaviconImageLoader({ fetch: async () => response, downscale }),
167 };
168 }
169
170 it("falls back to the file extension when the response has no image type", async () => {
171 const { load } = loader(new Response(svg, { headers: { "content-type": "text/plain" } }));
172 expect(await load(url, signal())).toBe(`data:image/svg+xml;base64,${svgBase64}`);
173 });
174
175 it("downscales large bitmaps and refuses large vector icons", async () => {
176 const bytes = new Uint8Array(PROJECT_FAVICON_MAX_DATA_URL_LENGTH);
177 const bitmap = loader(new Response(bytes, { headers: { "content-type": "image/png" } }));
178 expect(await bitmap.load("https://remote.test/api/assets/t/v1-icon.png", signal())).toBe(
179 replacement,
180 );
181 expect(bitmap.downscale).toHaveBeenCalledWith(
182 expect.objectContaining({ mimeType: "image/png", bytes }),
183 expect.any(AbortSignal),
184 );
185 const vector = loader(new Response(bytes, { headers: { "content-type": "image/svg+xml" } }));
186 await expect(vector.load(url, signal())).rejects.toThrow("exceeds the cache limit");
187 expect(vector.downscale).not.toHaveBeenCalled();
188 });
189
190 it("stops reading a response that exceeds the source limit", async () => {
191 let pulled = 0;
192 const chunk = new Uint8Array(1024 * 1024);
193 const stream = new ReadableStream<Uint8Array>({
194 pull(controller) {
195 pulled += 1;
196 controller.enqueue(chunk);
197 },
198 });
199 const { load, downscale } = loader(
200 new Response(stream, { headers: { "content-type": "image/png" } }),
201 );
202 await expect(load(url, signal())).rejects.toThrow("too large");
203 expect(pulled).toBeLessThan(PROJECT_FAVICON_MAX_SOURCE_BYTES / chunk.byteLength + 3);
204 expect(downscale).not.toHaveBeenCalled();
205 let cancelled = false;
206 const declaredStream = new ReadableStream<Uint8Array>({
207 cancel() {
208 cancelled = true;
209 },
210 });
211 const declared = loader(
212 new Response(declaredStream, {
213 headers: { "content-type": "image/png", "content-length": String(2 ** 40) },
214 }),
215 );
216 await expect(declared.load(url, signal())).rejects.toThrow("too large");
217 expect(cancelled).toBe(true);
218 expect(declared.downscale).not.toHaveBeenCalled();
219 });
220});