| 1 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | import { EnvironmentId } from "@t3tools/contracts"; |
| 3 | |
| 4 | import { |
| 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 | |
| 15 | const target = { environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }; |
| 16 | const url = "https://remote.test/api/assets/token-a/vabc-icon.svg"; |
| 17 | const image = "data:image/png;base64,aWNvbg=="; |
| 18 | const replacement = "data:image/png;base64,bmV3"; |
| 19 | const signal = () => new AbortController().signal; |
| 20 | |
| 21 | function 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 | |
| 29 | function 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 | |
| 49 | describe("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 | |
| 252 | describe("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 | }); |