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("restores image bytes in a fresh client before any remote response", async () => {
51 const { cache, storage, load } = fixture();
52 expect(await cache.resolve(target, url, signal())).toBe(image);
53 await cache.flush();
54 const reloaded = createProjectFaviconCache({ storage, load });
55 await reloaded.hydrate();
56 expect(reloaded.peek(target)).toBe(image);
57 expect(await reloaded.resolve(target, null, signal())).toBe(image);
58 expect(load).toHaveBeenCalledTimes(1);
59 });
60
61 it("reuses the image when signed URLs or connection origins change", async () => {
62 const { cache, load } = fixture();
63 await cache.resolve(target, url, signal());
64 expect(
65 await cache.resolve(target, "https://new.test/api/assets/token-b/vabc-icon.svg", signal()),
66 ).toBe(image);
67 expect(load).toHaveBeenCalledTimes(1);
68 });
69
70 it("keeps the old image during refresh and failures, then persists its replacement", async () => {
71 const { cache, load, storage } = fixture();
72 await cache.resolve(target, url, signal());
73 const next = deferred<string>();
74 load.mockImplementationOnce(() => next.promise);
75 const refreshing = cache.resolve(target, url.replace("vabc", "vdef"), signal());
76 expect(cache.peek(target)).toBe(image);
77 next.resolve(replacement);
78 expect(await refreshing).toBe(replacement);
79 load.mockRejectedValueOnce(new Error("offline"));
80 expect(await cache.resolve(target, url, signal())).toBe(replacement);
81 await cache.flush();
82 expect(await createProjectFaviconCache({ storage, load }).resolve(target, null, signal())).toBe(
83 replacement,
84 );
85 });
86
87 it("persists confirmed removal and ignores an aborted older download", async () => {
88 const { cache, load, storage } = fixture();
89 await cache.resolve(target, url, signal());
90 const next = deferred<string>();
91 const started = deferred<void>();
92 load.mockImplementationOnce(() => {
93 started.resolve();
94 return next.promise;
95 });
96 const controller = new AbortController();
97 const pending = cache.resolve(target, url.replace("vabc", "vdef"), controller.signal);
98 await started.promise;
99 controller.abort();
100 expect(
101 await cache.resolve(
102 target,
103 "https://remote.test/api/assets/token/project-favicon-missing",
104 signal(),
105 ),
106 ).toBeNull();
107 next.resolve(replacement);
108 await pending;
109 await cache.flush();
110 expect(
111 await createProjectFaviconCache({ storage, load }).resolve(target, null, signal()),
112 ).toBeNull();
113 });
114
115 it("isolates environments, workspaces, and icon selections", async () => {
116 const { cache } = fixture();
117 await cache.resolve(target, url, signal());
118 expect(cache.peek({ ...target, faviconPath: null })).toBe(image);
119 expect(cache.peek({ ...target, faviconPath: "brand.svg" })).toBeNull();
120 expect(cache.peek({ ...target, cwd: "/other" })).toBeNull();
121 expect(cache.peek({ ...target, environmentId: EnvironmentId.make("other") })).toBeNull();
122 });
123
124 it.each([
125 {
126 scope: "one environment",
127 clear: (cache: ReturnType<typeof fixture>["cache"]) =>
128 cache.clearEnvironment(target.environmentId),
129 remaining: 1,
130 },
131 {
132 scope: "every environment",
133 clear: (cache: ReturnType<typeof fixture>["cache"]) => cache.clearAll(),
134 remaining: 0,
135 },
136 ])(
137 "does not restore images for $scope removed during a download",
138 async ({ clear, remaining }) => {
139 const { cache, load, records } = fixture();
140 const other = { ...target, environmentId: EnvironmentId.make("other") };
141 await cache.resolve(other, url, signal());
142 const next = deferred<string>();
143 const started = deferred<void>();
144 load.mockImplementationOnce(() => {
145 started.resolve();
146 return next.promise;
147 });
148 const pending = cache.resolve(target, url, signal());
149 await started.promise;
150 await clear(cache);
151 next.resolve(image);
152 await pending;
153 await cache.flush();
154 expect(cache.peek(target)).toBeNull();
155 expect(records.size).toBe(remaining);
156 },
157 );
158
159 it("discards a download that starts while the environment is being cleared", async () => {
160 const records = new Map<string, ProjectFaviconEntry>();
161 const removal = deferred<void>();
162 const load = vi.fn(async () => image);
163 const storage: ProjectFaviconStorage = {
164 list: async () => [...records.values()],
165 put: async (key, entry) => {
166 records.set(key, entry);
167 },
168 remove: async (key) => {
169 await removal.promise;
170 records.delete(key);
171 },
172 };
173 const cache = createProjectFaviconCache({ storage, load });
174 await cache.resolve(target, url, signal());
175 await cache.flush();
176 const clearing = cache.clearEnvironment(target.environmentId);
177 await Promise.resolve();
178 const late = cache.resolve(target, url.replace("vabc", "vdef"), signal());
179 removal.resolve();
180 await clearing;
181 expect(records.size).toBe(0);
182 expect(cache.peek(target)).toBeNull();
183 expect(load).toHaveBeenCalledTimes(1);
184 expect(await late).toBe(image);
185 expect(load).toHaveBeenCalledTimes(2);
186 });
187
188 it("bounds individual images, total bytes, and entry count in storage", async () => {
189 const { cache, load, records } = fixture();
190 load.mockResolvedValueOnce(
191 `data:image/png;base64,${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH)}`,
192 );
193 expect(await cache.resolve(target, url, signal())).toBe(url);
194 expect(cache.peek(target)).toBeNull();
195 const large = `data:image/png;base64,${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH - 32)}`;
196 load.mockResolvedValue(large);
197 for (let i = 0; i < 40; i++) {
198 await cache.resolve({ ...target, cwd: `/large-${i}` }, url, signal());
199 }
200 await cache.flush();
201 expect(
202 [...records.values()].reduce((total, entry) => total + entry.dataUrl.length, 0),
203 ).toBeLessThanOrEqual(PROJECT_FAVICON_CACHE_MAX_BYTES);
204 expect(cache.peek({ ...target, cwd: "/large-0" })).toBeNull();
205 expect(cache.peek({ ...target, cwd: "/large-39" })).toBe(large);
206 load.mockResolvedValue(image);
207 for (let i = 0; i <= PROJECT_FAVICON_CACHE_MAX_ENTRIES; i++) {
208 await cache.resolve({ ...target, cwd: `/small-${i}` }, url, signal());
209 }
210 await cache.flush();
211 expect(records.size).toBe(PROJECT_FAVICON_CACHE_MAX_ENTRIES);
212 expect(cache.peek({ ...target, cwd: "/small-0" })).toBeNull();
213 });
214
215 it("skips corrupt records and tolerates unavailable storage", async () => {
216 const corrupt = createProjectFaviconCache({
217 storage: {
218 list: async () => [
219 { ...target, faviconPath: null, revision: "r", dataUrl: image },
220 { ...target, cwd: "/broken", faviconPath: null, revision: "r", dataUrl: "not-an-image" },
221 "garbage",
222 ],
223 put: async () => {},
224 remove: async () => {},
225 },
226 load: async () => replacement,
227 });
228 await corrupt.hydrate();
229 expect(corrupt.peek(target)).toBe(image);
230 expect(corrupt.peek({ ...target, cwd: "/broken" })).toBeNull();
231
232 const unavailable = createProjectFaviconCache({
233 storage: {
234 list: async () => {
235 throw new Error("storage unavailable");
236 },
237 put: async () => {
238 throw new Error("quota exceeded");
239 },
240 remove: async () => {
241 throw new Error("quota exceeded");
242 },
243 },
244 load: async () => image,
245 });
246 expect(await unavailable.resolve(target, url, signal())).toBe(image);
247 await unavailable.flush();
248 expect(unavailable.peek(target)).toBe(image);
249 });
250});
251
252describe("project favicon image loader", () => {
253 const svg =
254 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16"/></svg>';
255 const svgBase64 = btoa(svg);
256
257 function loader(response: Response, downscale = vi.fn(async () => replacement)) {
258 return {
259 downscale,
260 load: createProjectFaviconImageLoader({ fetch: async () => response, downscale }),
261 };
262 }
263
264 it("inlines small icons exactly as served without rasterizing", async () => {
265 const { load, downscale } = loader(
266 new Response(svg, { headers: { "content-type": "image/svg+xml; charset=utf-8" } }),
267 );
268 expect(await load(url, signal())).toBe(`data:image/svg+xml;base64,${svgBase64}`);
269 expect(downscale).not.toHaveBeenCalled();
270 });
271
272 it("falls back to the file extension when the response has no image type", async () => {
273 const { load } = loader(new Response(svg, { headers: { "content-type": "text/plain" } }));
274 expect(await load(url, signal())).toBe(`data:image/svg+xml;base64,${svgBase64}`);
275 });
276
277 it("downscales large bitmaps and refuses large vector icons", async () => {
278 const bytes = new Uint8Array(PROJECT_FAVICON_MAX_DATA_URL_LENGTH);
279 const bitmap = loader(new Response(bytes, { headers: { "content-type": "image/png" } }));
280 expect(await bitmap.load("https://remote.test/api/assets/t/v1-icon.png", signal())).toBe(
281 replacement,
282 );
283 expect(bitmap.downscale).toHaveBeenCalledWith(
284 expect.objectContaining({ mimeType: "image/png", bytes }),
285 expect.any(AbortSignal),
286 );
287 const vector = loader(new Response(bytes, { headers: { "content-type": "image/svg+xml" } }));
288 await expect(vector.load(url, signal())).rejects.toThrow("exceeds the cache limit");
289 expect(vector.downscale).not.toHaveBeenCalled();
290 });
291
292 it("stops reading a response that exceeds the source limit", async () => {
293 let pulled = 0;
294 const chunk = new Uint8Array(1024 * 1024);
295 const stream = new ReadableStream<Uint8Array>({
296 pull(controller) {
297 pulled += 1;
298 controller.enqueue(chunk);
299 },
300 });
301 const { load, downscale } = loader(
302 new Response(stream, { headers: { "content-type": "image/png" } }),
303 );
304 await expect(load(url, signal())).rejects.toThrow("too large");
305 expect(pulled).toBeLessThan(PROJECT_FAVICON_MAX_SOURCE_BYTES / chunk.byteLength + 3);
306 expect(downscale).not.toHaveBeenCalled();
307 const declared = loader(
308 new Response("x", {
309 headers: { "content-type": "image/png", "content-length": String(2 ** 40) },
310 }),
311 );
312 await expect(declared.load(url, signal())).rejects.toThrow("too large");
313 });
314
315 it("rejects failed responses and non-image payloads", async () => {
316 const failed = loader(new Response("nope", { status: 404 }));
317 await expect(failed.load(url, signal())).rejects.toThrow("404");
318 const html = loader(new Response("<html/>", { headers: { "content-type": "text/html" } }));
319 await expect(
320 html.load("https://remote.test/api/assets/t/v1-favicon", signal()),
321 ).rejects.toThrow("no image type");
322 });
323});