| 1 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | |
| 3 | import { |
| 4 | MAX_FAVICON_CANDIDATES, |
| 5 | MAX_FAVICON_RESPONSE_BYTES, |
| 6 | captureFavicon, |
| 7 | selectFaviconCandidates, |
| 8 | sourceDimensions, |
| 9 | } from "./FaviconCapture.ts"; |
| 10 | |
| 11 | const PNG = "data:image/png;base64,cG5n"; |
| 12 | const SOURCE_PNG = Buffer.alloc(24); |
| 13 | Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(SOURCE_PNG); |
| 14 | SOURCE_PNG.writeUInt32BE(1, 16); |
| 15 | SOURCE_PNG.writeUInt32BE(1, 20); |
| 16 | const SOURCE_PNG_URL = `data:image/png;base64,${SOURCE_PNG.toString("base64")}`; |
| 17 | |
| 18 | function sourceGif( |
| 19 | width: number, |
| 20 | height: number, |
| 21 | frameWidth = width, |
| 22 | frameHeight = height, |
| 23 | additionalFrames: ReadonlyArray<{ |
| 24 | readonly left?: number; |
| 25 | readonly top?: number; |
| 26 | readonly width: number; |
| 27 | readonly height: number; |
| 28 | }> = [], |
| 29 | ): Buffer { |
| 30 | const frames = [{ width: frameWidth, height: frameHeight }, ...additionalFrames]; |
| 31 | const buffer = Buffer.alloc(13 + frames.length * 12 + 1); |
| 32 | buffer.write("GIF89a", 0, "ascii"); |
| 33 | buffer.writeUInt16LE(width, 6); |
| 34 | buffer.writeUInt16LE(height, 8); |
| 35 | let offset = 13; |
| 36 | for (const frame of frames) { |
| 37 | buffer[offset] = 0x2c; |
| 38 | buffer.writeUInt16LE(frame.left ?? 0, offset + 1); |
| 39 | buffer.writeUInt16LE(frame.top ?? 0, offset + 3); |
| 40 | buffer.writeUInt16LE(frame.width, offset + 5); |
| 41 | buffer.writeUInt16LE(frame.height, offset + 7); |
| 42 | offset += 10; |
| 43 | buffer[offset] = 2; |
| 44 | buffer[offset + 1] = 0; |
| 45 | offset += 2; |
| 46 | } |
| 47 | buffer[offset] = 0x3b; |
| 48 | return buffer; |
| 49 | } |
| 50 | |
| 51 | it.each([ |
| 52 | ["logical pixels", sourceGif(2048, 1024), { width: 2048, height: 1024 }], |
| 53 | [ |
| 54 | "cumulative frame pixels", |
| 55 | sourceGif(1024, 1024, 1024, 1024, [{ width: 1024, height: 1024 }]), |
| 56 | { width: 1024, height: 1024 }, |
| 57 | ], |
| 58 | ])("uses the caller's GIF limit for %s while keeping the favicon limit", (_, gif, dimensions) => { |
| 59 | expect(sourceDimensions(gif)).toBeNull(); |
| 60 | expect(sourceDimensions(gif, 8_388_608)).toEqual(dimensions); |
| 61 | }); |
| 62 | |
| 63 | it("rejects GIF frames exceeding the caller's cumulative pixel limit", () => { |
| 64 | const gif = sourceGif(2048, 2048, 2048, 2048, [ |
| 65 | { width: 2048, height: 2048 }, |
| 66 | { width: 2048, height: 2048 }, |
| 67 | ]); |
| 68 | expect(sourceDimensions(gif, 8_388_608)).toBeNull(); |
| 69 | }); |
| 70 | |
| 71 | function sourceJpeg( |
| 72 | width: number, |
| 73 | height: number, |
| 74 | orientations: number | ReadonlyArray<number> = [], |
| 75 | ): Buffer { |
| 76 | const frame = Buffer.from([ |
| 77 | 0xff, |
| 78 | 0xd8, |
| 79 | 0xff, |
| 80 | 0xc0, |
| 81 | 0x00, |
| 82 | 0x07, |
| 83 | 0x08, |
| 84 | height >>> 8, |
| 85 | height & 0xff, |
| 86 | width >>> 8, |
| 87 | width & 0xff, |
| 88 | ]); |
| 89 | const app1Segments = (typeof orientations === "number" ? [orientations] : orientations).map( |
| 90 | (orientation) => sourceJpegExifSegment([orientation]), |
| 91 | ); |
| 92 | return Buffer.concat([frame.subarray(0, 2), ...app1Segments, frame.subarray(2)]); |
| 93 | } |
| 94 | |
| 95 | function sourceJpegApp1Segment(payload: Buffer): Buffer { |
| 96 | const app1 = Buffer.alloc(4 + payload.byteLength); |
| 97 | app1[0] = 0xff; |
| 98 | app1[1] = 0xe1; |
| 99 | app1.writeUInt16BE(payload.byteLength + 2, 2); |
| 100 | payload.copy(app1, 4); |
| 101 | return app1; |
| 102 | } |
| 103 | |
| 104 | function sourceJpegExifSegment( |
| 105 | orientations: ReadonlyArray<number>, |
| 106 | options?: { |
| 107 | readonly byteOrder?: "II" | "MM"; |
| 108 | readonly magic?: number; |
| 109 | readonly padding?: number; |
| 110 | }, |
| 111 | ): Buffer { |
| 112 | const exif = Buffer.alloc(20 + orientations.length * 12); |
| 113 | exif.write("Exif\0\0", 0, "binary"); |
| 114 | exif[5] = options?.padding ?? 0; |
| 115 | const littleEndian = options?.byteOrder !== "MM"; |
| 116 | exif.write(littleEndian ? "II" : "MM", 6, "ascii"); |
| 117 | const writeUInt16 = (value: number, offset: number) => |
| 118 | littleEndian ? exif.writeUInt16LE(value, offset) : exif.writeUInt16BE(value, offset); |
| 119 | const writeUInt32 = (value: number, offset: number) => |
| 120 | littleEndian ? exif.writeUInt32LE(value, offset) : exif.writeUInt32BE(value, offset); |
| 121 | writeUInt16(options?.magic ?? 42, 8); |
| 122 | writeUInt32(8, 10); |
| 123 | writeUInt16(orientations.length, 14); |
| 124 | orientations.forEach((orientation, index) => { |
| 125 | const entryOffset = 16 + index * 12; |
| 126 | writeUInt16(0x0112, entryOffset); |
| 127 | writeUInt16(3, entryOffset + 2); |
| 128 | writeUInt32(1, entryOffset + 4); |
| 129 | writeUInt16(orientation, entryOffset + 8); |
| 130 | }); |
| 131 | return sourceJpegApp1Segment(exif); |
| 132 | } |
| 133 | |
| 134 | function sourceJpegWithApp1Segments( |
| 135 | width: number, |
| 136 | height: number, |
| 137 | segments: ReadonlyArray<Buffer>, |
| 138 | ): Buffer { |
| 139 | const frame = sourceJpeg(width, height); |
| 140 | return Buffer.concat([frame.subarray(0, 2), ...segments, frame.subarray(2)]); |
| 141 | } |
| 142 | |
| 143 | function sourceJpegWithOrientationEntries( |
| 144 | width: number, |
| 145 | height: number, |
| 146 | orientations: ReadonlyArray<number>, |
| 147 | ): Buffer { |
| 148 | return sourceJpegWithApp1Segments(width, height, [sourceJpegExifSegment(orientations)]); |
| 149 | } |
| 150 | |
| 151 | function sourceJpegWithEndianAlias(alias: number, byteOrder: "II" | "MM"): Buffer { |
| 152 | const exif = sourceJpegExifSegment([6], { byteOrder }); |
| 153 | exif[10] = alias; |
| 154 | exif[11] = alias; |
| 155 | return sourceJpegWithApp1Segments(64, 32, [exif]); |
| 156 | } |
| 157 | |
| 158 | function sourceJpegExifWithSubIfd(options: { |
| 159 | readonly rootOrientation?: number; |
| 160 | readonly subIfdFirst?: boolean; |
| 161 | readonly subIfdOrientation: number; |
| 162 | }): Buffer { |
| 163 | const rootEntries = options.rootOrientation === undefined ? 1 : 2; |
| 164 | const rootIfdOffset = 14; |
| 165 | const subIfdOffset = rootIfdOffset + 2 + rootEntries * 12 + 4; |
| 166 | const exif = Buffer.alloc(subIfdOffset + 2 + 12 + 4); |
| 167 | exif.write("Exif\0\0", 0, "binary"); |
| 168 | exif.write("II", 6, "ascii"); |
| 169 | exif.writeUInt16LE(42, 8); |
| 170 | exif.writeUInt32LE(8, 10); |
| 171 | exif.writeUInt16LE(rootEntries, rootIfdOffset); |
| 172 | |
| 173 | const writeOrientation = (offset: number, orientation: number) => { |
| 174 | exif.writeUInt16LE(0x0112, offset); |
| 175 | exif.writeUInt16LE(3, offset + 2); |
| 176 | exif.writeUInt32LE(1, offset + 4); |
| 177 | exif.writeUInt16LE(orientation, offset + 8); |
| 178 | }; |
| 179 | const writeSubIfdPointer = (offset: number) => { |
| 180 | exif.writeUInt16LE(0x8769, offset); |
| 181 | exif.writeUInt16LE(4, offset + 2); |
| 182 | exif.writeUInt32LE(1, offset + 4); |
| 183 | exif.writeUInt32LE(subIfdOffset - 6, offset + 8); |
| 184 | }; |
| 185 | |
| 186 | const firstRootEntryOffset = rootIfdOffset + 2; |
| 187 | if (options.rootOrientation === undefined) { |
| 188 | writeSubIfdPointer(firstRootEntryOffset); |
| 189 | } else if (options.subIfdFirst) { |
| 190 | writeSubIfdPointer(firstRootEntryOffset); |
| 191 | writeOrientation(firstRootEntryOffset + 12, options.rootOrientation); |
| 192 | } else { |
| 193 | writeOrientation(firstRootEntryOffset, options.rootOrientation); |
| 194 | writeSubIfdPointer(firstRootEntryOffset + 12); |
| 195 | } |
| 196 | |
| 197 | exif.writeUInt16LE(1, subIfdOffset); |
| 198 | writeOrientation(subIfdOffset + 2, options.subIfdOrientation); |
| 199 | return sourceJpegApp1Segment(exif); |
| 200 | } |
| 201 | |
| 202 | function sourceJpegExifWithSubIfdPointers(options: { |
| 203 | readonly pointerCount: number; |
| 204 | readonly subIfdEntries: number; |
| 205 | }): Buffer { |
| 206 | const { pointerCount, subIfdEntries } = options; |
| 207 | const rootIfdOffset = 14; |
| 208 | const rootEntries = pointerCount + 1; |
| 209 | const subIfdOffset = rootIfdOffset + 2 + rootEntries * 12 + 4; |
| 210 | const exif = Buffer.alloc(subIfdOffset + 2 + subIfdEntries * 12 + 4); |
| 211 | exif.write("Exif\0\0", 0, "binary"); |
| 212 | exif.write("II", 6, "ascii"); |
| 213 | exif.writeUInt16LE(42, 8); |
| 214 | exif.writeUInt32LE(8, 10); |
| 215 | exif.writeUInt16LE(rootEntries, rootIfdOffset); |
| 216 | for (let index = 0; index < pointerCount; index += 1) { |
| 217 | const entryOffset = rootIfdOffset + 2 + index * 12; |
| 218 | exif.writeUInt16LE(0x8769, entryOffset); |
| 219 | exif.writeUInt16LE(4, entryOffset + 2); |
| 220 | exif.writeUInt32LE(1, entryOffset + 4); |
| 221 | exif.writeUInt32LE(subIfdOffset - 6, entryOffset + 8); |
| 222 | } |
| 223 | const orientationOffset = rootIfdOffset + 2 + pointerCount * 12; |
| 224 | exif.writeUInt16LE(0x0112, orientationOffset); |
| 225 | exif.writeUInt16LE(3, orientationOffset + 2); |
| 226 | exif.writeUInt32LE(1, orientationOffset + 4); |
| 227 | exif.writeUInt16LE(6, orientationOffset + 8); |
| 228 | |
| 229 | exif.writeUInt16LE(subIfdEntries, subIfdOffset); |
| 230 | for (let index = 0; index < subIfdEntries; index += 1) { |
| 231 | const entryOffset = subIfdOffset + 2 + index * 12; |
| 232 | exif.writeUInt16LE(1, entryOffset); |
| 233 | exif.writeUInt16LE(3, entryOffset + 2); |
| 234 | exif.writeUInt32LE(1, entryOffset + 4); |
| 235 | } |
| 236 | return sourceJpegApp1Segment(exif); |
| 237 | } |
| 238 | |
| 239 | function sourceJpegExifWithOverlappingSubIfds(pointerCount: number, subIfdEntries: number): Buffer { |
| 240 | const rootIfdOffset = 14; |
| 241 | const rootEntries = pointerCount + 1; |
| 242 | const subIfdOffset = rootIfdOffset + 2 + rootEntries * 12 + 4; |
| 243 | const exif = Buffer.alloc(subIfdOffset + pointerCount * 2 + 2 + subIfdEntries * 12); |
| 244 | exif.write("Exif\0\0", 0, "binary"); |
| 245 | exif.write("II", 6, "ascii"); |
| 246 | exif.writeUInt32LE(8, 10); |
| 247 | exif.writeUInt16LE(rootEntries, rootIfdOffset); |
| 248 | for (let index = 0; index < pointerCount; index += 1) { |
| 249 | const entryOffset = rootIfdOffset + 2 + index * 12; |
| 250 | exif.writeUInt16LE(0x8769, entryOffset); |
| 251 | exif.writeUInt16LE(4, entryOffset + 2); |
| 252 | exif.writeUInt32LE(1, entryOffset + 4); |
| 253 | exif.writeUInt32LE(subIfdOffset + index * 2 - 6, entryOffset + 8); |
| 254 | exif.writeUInt16LE(subIfdEntries, subIfdOffset + index * 2); |
| 255 | } |
| 256 | const orientationOffset = rootIfdOffset + 2 + pointerCount * 12; |
| 257 | exif.writeUInt16LE(0x0112, orientationOffset); |
| 258 | exif.writeUInt16LE(3, orientationOffset + 2); |
| 259 | exif.writeUInt32LE(1, orientationOffset + 4); |
| 260 | exif.writeUInt16LE(6, orientationOffset + 8); |
| 261 | return sourceJpegApp1Segment(exif); |
| 262 | } |
| 263 | |
| 264 | function sourceWebp(width: number, height: number): Buffer { |
| 265 | const buffer = Buffer.alloc(30); |
| 266 | buffer.write("RIFF", 0, "ascii"); |
| 267 | buffer.write("WEBP", 8, "ascii"); |
| 268 | buffer.write("VP8X", 12, "ascii"); |
| 269 | buffer.writeUIntLE(width - 1, 24, 3); |
| 270 | buffer.writeUIntLE(height - 1, 27, 3); |
| 271 | return buffer; |
| 272 | } |
| 273 | |
| 274 | function sourceIco(embedded: Buffer): Buffer { |
| 275 | const buffer = Buffer.alloc(22 + embedded.byteLength); |
| 276 | buffer.writeUInt16LE(1, 2); |
| 277 | buffer.writeUInt16LE(1, 4); |
| 278 | buffer.writeUInt32LE(embedded.byteLength, 14); |
| 279 | buffer.writeUInt32LE(22, 18); |
| 280 | embedded.copy(buffer, 22); |
| 281 | return buffer; |
| 282 | } |
| 283 | |
| 284 | function makeUnsafePng(): Buffer { |
| 285 | const buffer = Buffer.from(SOURCE_PNG); |
| 286 | buffer.writeUInt32BE(4096, 16); |
| 287 | buffer.writeUInt32BE(4096, 20); |
| 288 | return buffer; |
| 289 | } |
| 290 | |
| 291 | function sourcePng(width: number, height: number): Buffer { |
| 292 | const buffer = Buffer.from(SOURCE_PNG); |
| 293 | buffer.writeUInt32BE(width, 16); |
| 294 | buffer.writeUInt32BE(height, 20); |
| 295 | return buffer; |
| 296 | } |
| 297 | |
| 298 | function makeUnsafeDib(): Buffer { |
| 299 | const buffer = Buffer.alloc(40); |
| 300 | buffer.writeUInt32LE(40, 0); |
| 301 | buffer.writeInt32LE(4096, 4); |
| 302 | buffer.writeInt32LE(4096, 8); |
| 303 | return buffer; |
| 304 | } |
| 305 | |
| 306 | function makeWebContents(options?: { |
| 307 | readonly fetch?: (url: string, init?: RequestInit) => Promise<Response>; |
| 308 | readonly rasterize?: (code: string) => Promise<unknown>; |
| 309 | }) { |
| 310 | const fetch = vi.fn( |
| 311 | options?.fetch ?? |
| 312 | (async () => |
| 313 | new Response(new Uint8Array(SOURCE_PNG), { |
| 314 | headers: { "content-type": "image/png" }, |
| 315 | })), |
| 316 | ); |
| 317 | const executeJavaScriptInIsolatedWorld = vi.fn( |
| 318 | async (_worldId: number, scripts: ReadonlyArray<{ readonly code: string }>) => |
| 319 | options?.rasterize ? options.rasterize(scripts[0]?.code ?? "") : PNG, |
| 320 | ); |
| 321 | return { |
| 322 | webContents: { |
| 323 | session: { fetch }, |
| 324 | executeJavaScriptInIsolatedWorld, |
| 325 | } as never, |
| 326 | executeJavaScriptInIsolatedWorld, |
| 327 | fetch, |
| 328 | }; |
| 329 | } |
| 330 | |
| 331 | const JPEG_LANDSCAPE_LAYOUT = { |
| 332 | draw: "context.drawImage(bitmap, 0, 8, 32, 16)", |
| 333 | resizeHeight: 16, |
| 334 | resizeWidth: 32, |
| 335 | } as const; |
| 336 | const JPEG_PORTRAIT_LAYOUT = { |
| 337 | draw: "context.drawImage(bitmap, 8, 0, 16, 32)", |
| 338 | resizeHeight: 32, |
| 339 | resizeWidth: 16, |
| 340 | } as const; |
| 341 | |
| 342 | async function expectJpegLayout( |
| 343 | source: Buffer, |
| 344 | layout: typeof JPEG_LANDSCAPE_LAYOUT | typeof JPEG_PORTRAIT_LAYOUT, |
| 345 | ): Promise<void> { |
| 346 | const { webContents } = makeWebContents({ |
| 347 | rasterize: async (code) => { |
| 348 | expect(code).toContain(`resizeWidth: ${layout.resizeWidth}`); |
| 349 | expect(code).toContain(`resizeHeight: ${layout.resizeHeight}`); |
| 350 | expect(code).toContain(layout.draw); |
| 351 | return PNG; |
| 352 | }, |
| 353 | }); |
| 354 | |
| 355 | expect( |
| 356 | await captureFavicon({ |
| 357 | webContents, |
| 358 | pageUrl: "https://example.com/page", |
| 359 | candidates: [`data:image/jpeg;base64,${source.toString("base64")}`], |
| 360 | signal: new AbortController().signal, |
| 361 | }), |
| 362 | ).toEqual({ kind: "captured", dataUrl: PNG }); |
| 363 | } |
| 364 | |
| 365 | describe("selectFaviconCandidates", () => { |
| 366 | it("filters and deduplicates before applying the candidate cap", () => { |
| 367 | const valid = Array.from( |
| 368 | { length: MAX_FAVICON_CANDIDATES + 2 }, |
| 369 | (_, index) => `https://example.com/favicon-${index}.png`, |
| 370 | ); |
| 371 | expect( |
| 372 | selectFaviconCandidates([ |
| 373 | ...Array.from({ length: 64 }, () => "javascript:alert(1)"), |
| 374 | valid[0]!, |
| 375 | valid[0]!, |
| 376 | ...valid.slice(1), |
| 377 | ]), |
| 378 | ).toEqual(valid.slice(0, MAX_FAVICON_CANDIDATES)); |
| 379 | }); |
| 380 | |
| 381 | it("bounds raw candidate scanning independently of the usable-candidate cap", () => { |
| 382 | const oversizedInvalid = `javascript:${"x".repeat(2_048)}`; |
| 383 | expect( |
| 384 | selectFaviconCandidates([ |
| 385 | ...Array.from({ length: 128 }, () => oversizedInvalid), |
| 386 | "https://example.com/too-late.png", |
| 387 | ]), |
| 388 | ).toEqual([]); |
| 389 | }); |
| 390 | }); |
| 391 | |
| 392 | describe("captureFavicon", () => { |
| 393 | it.each([ |
| 394 | { |
| 395 | label: "same-origin", |
| 396 | pageUrl: "https://example.com/page", |
| 397 | faviconUrl: "https://example.com/favicon.png", |
| 398 | credentials: "include", |
| 399 | }, |
| 400 | { |
| 401 | label: "cross-origin", |
| 402 | pageUrl: "https://example.com/page", |
| 403 | faviconUrl: "https://cdn.example.net/favicon.png", |
| 404 | credentials: "omit", |
| 405 | }, |
| 406 | ])("uses the explicit credential policy for $label requests", async (testCase) => { |
| 407 | const { webContents, fetch } = makeWebContents(); |
| 408 | const result = await captureFavicon({ |
| 409 | webContents, |
| 410 | pageUrl: testCase.pageUrl, |
| 411 | candidates: [testCase.faviconUrl], |
| 412 | signal: new AbortController().signal, |
| 413 | }); |
| 414 | |
| 415 | expect(result).toEqual({ kind: "captured", dataUrl: PNG }); |
| 416 | expect(fetch).toHaveBeenCalledWith( |
| 417 | testCase.faviconUrl, |
| 418 | expect.objectContaining({ credentials: testCase.credentials, redirect: "error" }), |
| 419 | ); |
| 420 | }); |
| 421 | |
| 422 | it("decodes base64 and percent-encoded inline images without fetching", async () => { |
| 423 | const { webContents, fetch, executeJavaScriptInIsolatedWorld } = makeWebContents(); |
| 424 | const percentEncodedPng = [...SOURCE_PNG] |
| 425 | .map((byte) => `%${byte.toString(16).padStart(2, "0")}`) |
| 426 | .join(""); |
| 427 | |
| 428 | for (const candidate of [SOURCE_PNG_URL, `data:image/png,${percentEncodedPng}`]) { |
| 429 | expect( |
| 430 | await captureFavicon({ |
| 431 | webContents, |
| 432 | pageUrl: "https://example.com/page", |
| 433 | candidates: [candidate], |
| 434 | signal: new AbortController().signal, |
| 435 | }), |
| 436 | ).toEqual({ kind: "captured", dataUrl: PNG }); |
| 437 | } |
| 438 | |
| 439 | expect(fetch).not.toHaveBeenCalled(); |
| 440 | expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(2); |
| 441 | }); |
| 442 | |
| 443 | it("tries the next candidate after an ordinary rejection", async () => { |
| 444 | const { webContents, fetch } = makeWebContents({ |
| 445 | fetch: async (url) => |
| 446 | url.endsWith("first.png") |
| 447 | ? new Response(null, { status: 404 }) |
| 448 | : new Response(new Uint8Array(SOURCE_PNG), { |
| 449 | headers: { "content-type": "image/png" }, |
| 450 | }), |
| 451 | }); |
| 452 | |
| 453 | expect( |
| 454 | await captureFavicon({ |
| 455 | webContents, |
| 456 | pageUrl: "https://example.com/page", |
| 457 | candidates: ["https://example.com/first.png", "https://example.com/second.png"], |
| 458 | signal: new AbortController().signal, |
| 459 | }), |
| 460 | ).toEqual({ kind: "captured", dataUrl: PNG }); |
| 461 | expect(fetch).toHaveBeenCalledTimes(2); |
| 462 | }); |
| 463 | |
| 464 | it("cancels a rejected response body before trying the next candidate", async () => { |
| 465 | const cancel = vi.fn(); |
| 466 | const body = new ReadableStream<Uint8Array>({ |
| 467 | start(controller) { |
| 468 | controller.enqueue(new Uint8Array(1)); |
| 469 | }, |
| 470 | cancel, |
| 471 | }); |
| 472 | const { webContents, fetch } = makeWebContents({ |
| 473 | fetch: async (url) => |
| 474 | url.endsWith("first.png") |
| 475 | ? new Response(body, { status: 404 }) |
| 476 | : new Response(new Uint8Array(SOURCE_PNG), { |
| 477 | headers: { "content-type": "image/png" }, |
| 478 | }), |
| 479 | }); |
| 480 | |
| 481 | expect( |
| 482 | await captureFavicon({ |
| 483 | webContents, |
| 484 | pageUrl: "https://example.com/page", |
| 485 | candidates: ["https://example.com/first.png", "https://example.com/second.png"], |
| 486 | signal: new AbortController().signal, |
| 487 | }), |
| 488 | ).toEqual({ kind: "captured", dataUrl: PNG }); |
| 489 | expect(cancel).toHaveBeenCalledOnce(); |
| 490 | expect(fetch).toHaveBeenCalledTimes(2); |
| 491 | }); |
| 492 | |
| 493 | it("stops a pending fetch when its capture is aborted", async () => { |
| 494 | const controller = new AbortController(); |
| 495 | const { webContents } = makeWebContents({ |
| 496 | fetch: (_url, init) => |
| 497 | new Promise<Response>((_resolve, reject) => { |
| 498 | init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { |
| 499 | once: true, |
| 500 | }); |
| 501 | }), |
| 502 | }); |
| 503 | const capture = captureFavicon({ |
| 504 | webContents, |
| 505 | pageUrl: "https://example.com/page", |
| 506 | candidates: ["https://example.com/favicon.png"], |
| 507 | signal: controller.signal, |
| 508 | }); |
| 509 | controller.abort(); |
| 510 | expect(await capture).toEqual({ kind: "none" }); |
| 511 | }); |
| 512 | |
| 513 | it("ends candidate fallback when the overall capture deadline expires", async () => { |
| 514 | const timeoutController = new AbortController(); |
| 515 | const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal); |
| 516 | const { webContents, fetch } = makeWebContents({ |
| 517 | fetch: (url, init) => { |
| 518 | if (url.endsWith("first.png")) return Promise.resolve(new Response(null, { status: 404 })); |
| 519 | return new Promise<Response>((_resolve, reject) => { |
| 520 | init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { |
| 521 | once: true, |
| 522 | }); |
| 523 | }); |
| 524 | }, |
| 525 | }); |
| 526 | try { |
| 527 | const capture = captureFavicon({ |
| 528 | webContents, |
| 529 | pageUrl: "https://example.com/page", |
| 530 | candidates: [ |
| 531 | "https://example.com/first.png", |
| 532 | "https://example.com/second.png", |
| 533 | "https://example.com/third.png", |
| 534 | ], |
| 535 | signal: new AbortController().signal, |
| 536 | }); |
| 537 | await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)); |
| 538 | timeoutController.abort(new DOMException("capture timed out", "TimeoutError")); |
| 539 | |
| 540 | expect(await capture).toEqual({ kind: "timed-out" }); |
| 541 | expect(fetch).toHaveBeenCalledTimes(2); |
| 542 | expect(timeout).toHaveBeenCalledOnce(); |
| 543 | } finally { |
| 544 | timeout.mockRestore(); |
| 545 | } |
| 546 | }); |
| 547 | |
| 548 | it("does not publish a rasterization that completes after the capture deadline", async () => { |
| 549 | const captureTimeoutController = new AbortController(); |
| 550 | const rasterTimeoutController = new AbortController(); |
| 551 | const timeout = vi |
| 552 | .spyOn(AbortSignal, "timeout") |
| 553 | .mockImplementation((milliseconds) => |
| 554 | milliseconds === 5_000 ? captureTimeoutController.signal : rasterTimeoutController.signal, |
| 555 | ); |
| 556 | let resolveRasterization!: (value: unknown) => void; |
| 557 | const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents({ |
| 558 | rasterize: () => |
| 559 | new Promise((resolve) => { |
| 560 | resolveRasterization = resolve; |
| 561 | }), |
| 562 | }); |
| 563 | try { |
| 564 | const capture = captureFavicon({ |
| 565 | webContents, |
| 566 | pageUrl: "https://example.com/page", |
| 567 | candidates: [SOURCE_PNG_URL], |
| 568 | signal: new AbortController().signal, |
| 569 | }); |
| 570 | await vi.waitFor(() => expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledOnce()); |
| 571 | captureTimeoutController.abort(new DOMException("capture timed out", "TimeoutError")); |
| 572 | |
| 573 | expect(await capture).toEqual({ kind: "timed-out" }); |
| 574 | resolveRasterization(PNG); |
| 575 | } finally { |
| 576 | timeout.mockRestore(); |
| 577 | } |
| 578 | }); |
| 579 | |
| 580 | it("cancels a stalled response body when the capture deadline expires", async () => { |
| 581 | const timeoutController = new AbortController(); |
| 582 | const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal); |
| 583 | const cancel = vi.fn(); |
| 584 | const { webContents } = makeWebContents({ |
| 585 | fetch: async () => |
| 586 | new Response( |
| 587 | new ReadableStream<Uint8Array>({ |
| 588 | cancel, |
| 589 | }), |
| 590 | { headers: { "content-type": "image/png" } }, |
| 591 | ), |
| 592 | }); |
| 593 | try { |
| 594 | const capture = captureFavicon({ |
| 595 | webContents, |
| 596 | pageUrl: "https://example.com/page", |
| 597 | candidates: ["https://example.com/favicon.png"], |
| 598 | signal: new AbortController().signal, |
| 599 | }); |
| 600 | timeoutController.abort(new DOMException("capture timed out", "TimeoutError")); |
| 601 | |
| 602 | expect(await capture).toEqual({ kind: "timed-out" }); |
| 603 | expect(cancel).toHaveBeenCalledOnce(); |
| 604 | } finally { |
| 605 | timeout.mockRestore(); |
| 606 | } |
| 607 | }); |
| 608 | |
| 609 | it("rejects and cancels an oversized streamed response", async () => { |
| 610 | const cancel = vi.fn(); |
| 611 | const body = new ReadableStream<Uint8Array>({ |
| 612 | start(controller) { |
| 613 | controller.enqueue(new Uint8Array(MAX_FAVICON_RESPONSE_BYTES)); |
| 614 | controller.enqueue(new Uint8Array(1)); |
| 615 | }, |
| 616 | cancel, |
| 617 | }); |
| 618 | const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents({ |
| 619 | fetch: async () => new Response(body, { headers: { "content-type": "image/png" } }), |
| 620 | }); |
| 621 | |
| 622 | expect( |
| 623 | await captureFavicon({ |
| 624 | webContents, |
| 625 | pageUrl: "https://example.com/page", |
| 626 | candidates: ["https://example.com/favicon.png"], |
| 627 | signal: new AbortController().signal, |
| 628 | }), |
| 629 | ).toEqual({ kind: "none" }); |
| 630 | expect(cancel).toHaveBeenCalledOnce(); |
| 631 | expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled(); |
| 632 | }); |
| 633 | |
| 634 | it("retains bounded compatibility with common favicon formats", async () => { |
| 635 | const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents(); |
| 636 | for (const [mime, buffer] of [ |
| 637 | ["image/gif", sourceGif(32, 32)], |
| 638 | ["image/jpeg", sourceJpeg(32, 32)], |
| 639 | ["image/webp", sourceWebp(32, 32)], |
| 640 | ["image/x-icon", sourceIco(SOURCE_PNG)], |
| 641 | ] as const) { |
| 642 | expect( |
| 643 | await captureFavicon({ |
| 644 | webContents, |
| 645 | pageUrl: "https://example.com/page", |
| 646 | candidates: [`data:${mime};base64,${buffer.toString("base64")}`], |
| 647 | signal: new AbortController().signal, |
| 648 | }), |
| 649 | ).toEqual({ kind: "captured", dataUrl: PNG }); |
| 650 | } |
| 651 | expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(4); |
| 652 | }); |
| 653 | |
| 654 | it.each([ |
| 655 | { |
| 656 | label: "landscape", |
| 657 | source: sourcePng(64, 32), |
| 658 | resizeWidth: 32, |
| 659 | resizeHeight: 16, |
| 660 | draw: "context.drawImage(bitmap, 0, 8, 32, 16)", |
| 661 | }, |
| 662 | { |
| 663 | label: "portrait", |
| 664 | source: sourcePng(32, 64), |
| 665 | resizeWidth: 16, |
| 666 | resizeHeight: 32, |
| 667 | draw: "context.drawImage(bitmap, 8, 0, 16, 32)", |
| 668 | }, |
| 669 | ])("preserves $label aspect ratio within the 32x32 output", async (testCase) => { |
| 670 | const { webContents } = makeWebContents({ |
| 671 | rasterize: async (code) => { |
| 672 | expect(code).toContain(`resizeWidth: ${testCase.resizeWidth}`); |
| 673 | expect(code).toContain(`resizeHeight: ${testCase.resizeHeight}`); |
| 674 | expect(code).toContain('resizeQuality: "high"'); |
| 675 | expect(code).toContain(testCase.draw); |
| 676 | return PNG; |
| 677 | }, |
| 678 | }); |
| 679 | |
| 680 | expect( |
| 681 | await captureFavicon({ |
| 682 | webContents, |
| 683 | pageUrl: "https://example.com/page", |
| 684 | candidates: [`data:image/png;base64,${testCase.source.toString("base64")}`], |
| 685 | signal: new AbortController().signal, |
| 686 | }), |
| 687 | ).toEqual({ kind: "captured", dataUrl: PNG }); |
| 688 | }); |
| 689 | |
| 690 | it.each([ |
| 691 | ...[1, 2, 3, 4].map((orientation) => ({ |
| 692 | label: `keeps stored dimensions for orientation ${orientation}`, |
| 693 | layout: JPEG_LANDSCAPE_LAYOUT, |
| 694 | source: sourceJpeg(64, 32, orientation), |
| 695 | })), |
| 696 | ...[5, 6, 7, 8].map((orientation) => ({ |
| 697 | label: `uses display dimensions for orientation ${orientation}`, |
| 698 | layout: JPEG_PORTRAIT_LAYOUT, |
| 699 | source: sourceJpeg(64, 32, orientation), |
| 700 | })), |
| 701 | { |
| 702 | label: "uses the first separate EXIF segment when it is transposed", |
| 703 | layout: JPEG_PORTRAIT_LAYOUT, |
| 704 | source: sourceJpeg(64, 32, [6, 1]), |
| 705 | }, |
| 706 | { |
| 707 | label: "uses the first separate EXIF segment when it is untransposed", |
| 708 | layout: JPEG_LANDSCAPE_LAYOUT, |
| 709 | source: sourceJpeg(64, 32, [1, 6]), |
| 710 | }, |
| 711 | { |
| 712 | label: "does not consult a later EXIF segment after an invalid orientation", |
| 713 | layout: JPEG_LANDSCAPE_LAYOUT, |
| 714 | source: sourceJpeg(64, 32, [9, 6]), |
| 715 | }, |
| 716 | { |
| 717 | label: "uses a later valid orientation in the same IFD", |
| 718 | layout: JPEG_PORTRAIT_LAYOUT, |
| 719 | source: sourceJpegWithOrientationEntries(64, 32, [9, 6]), |
| 720 | }, |
| 721 | { |
| 722 | label: "skips a non-EXIF APP1 segment", |
| 723 | layout: JPEG_PORTRAIT_LAYOUT, |
| 724 | source: sourceJpegWithApp1Segments(64, 32, [ |
| 725 | sourceJpegApp1Segment(Buffer.from("not-exif")), |
| 726 | sourceJpegExifSegment([6]), |
| 727 | ]), |
| 728 | }, |
| 729 | { |
| 730 | label: "skips an empty EXIF APP1 segment", |
| 731 | layout: JPEG_PORTRAIT_LAYOUT, |
| 732 | source: sourceJpegWithApp1Segments(64, 32, [ |
| 733 | sourceJpegApp1Segment(Buffer.from("Exif\0\0", "binary")), |
| 734 | sourceJpegExifSegment([6]), |
| 735 | ]), |
| 736 | }, |
| 737 | { |
| 738 | label: "stops after a malformed qualifying EXIF APP1 segment", |
| 739 | layout: JPEG_LANDSCAPE_LAYOUT, |
| 740 | source: sourceJpegWithApp1Segments(64, 32, [ |
| 741 | sourceJpegApp1Segment(Buffer.from("Exif\0\0broken", "binary")), |
| 742 | sourceJpegExifSegment([6]), |
| 743 | ]), |
| 744 | }, |
| 745 | { |
| 746 | label: "ignores the EXIF padding byte", |
| 747 | layout: JPEG_PORTRAIT_LAYOUT, |
| 748 | source: sourceJpegWithApp1Segments(64, 32, [sourceJpegExifSegment([6], { padding: 0xff })]), |
| 749 | }, |
| 750 | { |
| 751 | label: "reads big-endian EXIF", |
| 752 | layout: JPEG_PORTRAIT_LAYOUT, |
| 753 | source: sourceJpegWithApp1Segments(64, 32, [sourceJpegExifSegment([6], { byteOrder: "MM" })]), |
| 754 | }, |
| 755 | { |
| 756 | label: "matches Chromium for a nonstandard TIFF magic field", |
| 757 | layout: JPEG_PORTRAIT_LAYOUT, |
| 758 | source: sourceJpegWithApp1Segments(64, 32, [sourceJpegExifSegment([6], { magic: 0 })]), |
| 759 | }, |
| 760 | { |
| 761 | label: "rejects a high-bit little-endian alias", |
| 762 | layout: JPEG_LANDSCAPE_LAYOUT, |
| 763 | source: sourceJpegWithEndianAlias(0xc9, "II"), |
| 764 | }, |
| 765 | { |
| 766 | label: "rejects a high-bit big-endian alias", |
| 767 | layout: JPEG_LANDSCAPE_LAYOUT, |
| 768 | source: sourceJpegWithEndianAlias(0xcd, "MM"), |
| 769 | }, |
| 770 | { |
| 771 | label: "reads an orientation from a SubIFD", |
| 772 | layout: JPEG_PORTRAIT_LAYOUT, |
| 773 | source: sourceJpegWithApp1Segments(64, 32, [ |
| 774 | sourceJpegExifWithSubIfd({ subIfdOrientation: 6 }), |
| 775 | ]), |
| 776 | }, |
| 777 | { |
| 778 | label: "uses a SubIFD orientation before a later root orientation", |
| 779 | layout: JPEG_PORTRAIT_LAYOUT, |
| 780 | source: sourceJpegWithApp1Segments(64, 32, [ |
| 781 | sourceJpegExifWithSubIfd({ |
| 782 | rootOrientation: 1, |
| 783 | subIfdFirst: true, |
| 784 | subIfdOrientation: 6, |
| 785 | }), |
| 786 | ]), |
| 787 | }, |
| 788 | { |
| 789 | label: "uses a root orientation before a later SubIFD orientation", |
| 790 | layout: JPEG_LANDSCAPE_LAYOUT, |
| 791 | source: sourceJpegWithApp1Segments(64, 32, [ |
| 792 | sourceJpegExifWithSubIfd({ rootOrientation: 1, subIfdOrientation: 6 }), |
| 793 | ]), |
| 794 | }, |
| 795 | { |
| 796 | label: "memoizes repeated aliases to the same SubIFD", |
| 797 | layout: JPEG_PORTRAIT_LAYOUT, |
| 798 | source: sourceJpegWithApp1Segments(64, 32, [ |
| 799 | sourceJpegExifWithSubIfdPointers({ pointerCount: 32, subIfdEntries: 32 }), |
| 800 | ]), |
| 801 | }, |
| 802 | ])("matches Chromium JPEG layout: $label", async ({ source, layout }) => { |
| 803 | await expectJpegLayout(source, layout); |
| 804 | }); |
| 805 | |
| 806 | it("rejects JPEG metadata when distinct SubIFDs exhaust the linear work budget", async () => { |
| 807 | const source = sourceJpegWithApp1Segments(64, 32, [ |
| 808 | sourceJpegExifWithOverlappingSubIfds(32, 32), |
| 809 | ]); |
| 810 | const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents(); |
| 811 | |
| 812 | expect( |
| 813 | await captureFavicon({ |
| 814 | webContents, |
| 815 | pageUrl: "https://example.com/page", |
| 816 | candidates: [`data:image/jpeg;base64,${source.toString("base64")}`], |
| 817 | signal: new AbortController().signal, |
| 818 | }), |
| 819 | ).toEqual({ kind: "none" }); |
| 820 | expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled(); |
| 821 | }); |
| 822 | |
| 823 | it("rejects JPEGs with multiple frame headers before rasterization", async () => { |
| 824 | const buffer = Buffer.concat([sourceJpeg(4096, 4096), sourceJpeg(1, 1).subarray(2)]); |
| 825 | const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents(); |
| 826 | |
| 827 | expect( |
| 828 | await captureFavicon({ |
| 829 | webContents, |
| 830 | pageUrl: "https://example.com/page", |
| 831 | candidates: [`data:image/jpeg;base64,${buffer.toString("base64")}`], |
| 832 | signal: new AbortController().signal, |
| 833 | }), |
| 834 | ).toEqual({ kind: "none" }); |
| 835 | expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled(); |
| 836 | }); |
| 837 | |
| 838 | it("rejects an unsafe PNG size before rasterization", async () => { |
| 839 | const buffer = makeUnsafePng(); |
| 840 | const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents({ |
| 841 | fetch: async () => |
| 842 | new Response(new Uint8Array(buffer), { |
| 843 | headers: { "content-type": "image/png" }, |
| 844 | }), |
| 845 | }); |
| 846 | |
| 847 | expect( |
| 848 | await captureFavicon({ |
| 849 | webContents, |
| 850 | pageUrl: "https://example.com/page", |
| 851 | candidates: ["https://example.com/favicon.png"], |
| 852 | signal: new AbortController().signal, |
| 853 | }), |
| 854 | ).toEqual({ kind: "none" }); |
| 855 | expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled(); |
| 856 | }); |
| 857 | |
| 858 | it.each([ |
| 859 | ["GIF", "image/gif", sourceGif(4096, 4096)], |
| 860 | ["GIF frame", "image/gif", sourceGif(1, 1, 4096, 4096)], |
| 861 | ["GIF later frame", "image/gif", sourceGif(1, 1, 1, 1, [{ width: 4096, height: 4096 }])], |
| 862 | [ |
| 863 | "GIF cumulative frames", |
| 864 | "image/gif", |
| 865 | sourceGif( |
| 866 | 64, |
| 867 | 64, |
| 868 | 64, |
| 869 | 64, |
| 870 | Array.from({ length: 256 }, () => ({ width: 64, height: 64 })), |
| 871 | ), |
| 872 | ], |
| 873 | ["JPEG", "image/jpeg", sourceJpeg(4096, 4096)], |
| 874 | ["WebP", "image/webp", sourceWebp(4096, 4096)], |
| 875 | ["ICO with PNG", "image/x-icon", sourceIco(makeUnsafePng())], |
| 876 | ["ICO with DIB", "image/x-icon", sourceIco(makeUnsafeDib())], |
| 877 | ["SVG", "image/svg+xml", Buffer.from('<svg width="1" height="1"/>')], |
| 878 | [ |
| 879 | "SVG with embedded bitmap", |
| 880 | "image/svg+xml", |
| 881 | Buffer.from( |
| 882 | `<svg width="1" height="1"><image href="data:image/png;base64,${makeUnsafePng().toString("base64")}"/></svg>`, |
| 883 | ), |
| 884 | ], |
| 885 | [ |
| 886 | "ICO invalid payload span", |
| 887 | "image/x-icon", |
| 888 | (() => { |
| 889 | const buffer = Buffer.alloc(22); |
| 890 | buffer.writeUInt16LE(1, 2); |
| 891 | buffer.writeUInt16LE(1, 4); |
| 892 | buffer.writeUInt32LE(100, 14); |
| 893 | buffer.writeUInt32LE(22, 18); |
| 894 | return buffer; |
| 895 | })(), |
| 896 | ], |
| 897 | ])("rejects unsafe or unsupported %s before rasterization", async (_label, mime, buffer) => { |
| 898 | const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents(); |
| 899 | const candidate = `data:${mime};base64,${buffer.toString("base64")}`; |
| 900 | expect( |
| 901 | await captureFavicon({ |
| 902 | webContents, |
| 903 | pageUrl: "https://example.com/page", |
| 904 | candidates: [candidate], |
| 905 | signal: new AbortController().signal, |
| 906 | }), |
| 907 | ).toEqual({ kind: "none" }); |
| 908 | expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled(); |
| 909 | }); |
| 910 | |
| 911 | it("ignores output that is not a bounded PNG data URL", async () => { |
| 912 | const { webContents } = makeWebContents({ |
| 913 | rasterize: async () => "data:image/svg+xml;base64,c3Zn", |
| 914 | }); |
| 915 | |
| 916 | expect( |
| 917 | await captureFavicon({ |
| 918 | webContents, |
| 919 | pageUrl: "https://example.com/page", |
| 920 | candidates: [SOURCE_PNG_URL], |
| 921 | signal: new AbortController().signal, |
| 922 | }), |
| 923 | ).toEqual({ kind: "none" }); |
| 924 | }); |
| 925 | |
| 926 | it("waits for physical rasterization settlement after a logical timeout", async () => { |
| 927 | vi.useFakeTimers(); |
| 928 | try { |
| 929 | let resolveOld!: (value: unknown) => void; |
| 930 | let executions = 0; |
| 931 | const { webContents } = makeWebContents({ |
| 932 | rasterize: () => { |
| 933 | executions += 1; |
| 934 | return executions === 1 |
| 935 | ? new Promise((resolve) => { |
| 936 | resolveOld = resolve; |
| 937 | }) |
| 938 | : Promise.resolve(PNG); |
| 939 | }, |
| 940 | }); |
| 941 | const input = { |
| 942 | webContents, |
| 943 | pageUrl: "https://example.com/page", |
| 944 | candidates: [SOURCE_PNG_URL], |
| 945 | signal: new AbortController().signal, |
| 946 | }; |
| 947 | const timedOut = captureFavicon(input); |
| 948 | await vi.advanceTimersByTimeAsync(1_001); |
| 949 | expect(await timedOut).toEqual({ kind: "timed-out" }); |
| 950 | |
| 951 | const newer = captureFavicon(input); |
| 952 | await Promise.resolve(); |
| 953 | expect(executions).toBe(1); |
| 954 | resolveOld(PNG); |
| 955 | expect(await newer).toEqual({ kind: "captured", dataUrl: PNG }); |
| 956 | expect(executions).toBe(2); |
| 957 | } finally { |
| 958 | vi.useRealTimers(); |
| 959 | } |
| 960 | }); |
| 961 | |
| 962 | it("ends candidate fallback after a rasterization timeout", async () => { |
| 963 | vi.useFakeTimers(); |
| 964 | try { |
| 965 | let resolveRasterization!: (value: unknown) => void; |
| 966 | const { webContents, fetch, executeJavaScriptInIsolatedWorld } = makeWebContents({ |
| 967 | rasterize: () => |
| 968 | new Promise((resolve) => { |
| 969 | resolveRasterization = resolve; |
| 970 | }), |
| 971 | }); |
| 972 | const capture = captureFavicon({ |
| 973 | webContents, |
| 974 | pageUrl: "https://example.com/page", |
| 975 | candidates: ["https://example.com/first.png", "https://example.com/second.png"], |
| 976 | signal: new AbortController().signal, |
| 977 | }); |
| 978 | |
| 979 | await vi.advanceTimersByTimeAsync(1_001); |
| 980 | |
| 981 | expect(await capture).toEqual({ kind: "timed-out" }); |
| 982 | expect(fetch).toHaveBeenCalledOnce(); |
| 983 | expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledOnce(); |
| 984 | resolveRasterization(PNG); |
| 985 | } finally { |
| 986 | vi.useRealTimers(); |
| 987 | } |
| 988 | }); |
| 989 | |
| 990 | it("coalesces queued rasterizations so only the latest pending capture launches", async () => { |
| 991 | let resolveFirst!: (value: unknown) => void; |
| 992 | let executions = 0; |
| 993 | const { webContents } = makeWebContents({ |
| 994 | rasterize: () => { |
| 995 | executions += 1; |
| 996 | return executions === 1 |
| 997 | ? new Promise((resolve) => { |
| 998 | resolveFirst = resolve; |
| 999 | }) |
| 1000 | : Promise.resolve(PNG); |
| 1001 | }, |
| 1002 | }); |
| 1003 | const input = { |
| 1004 | webContents, |
| 1005 | pageUrl: "https://example.com/page", |
| 1006 | candidates: [SOURCE_PNG_URL], |
| 1007 | signal: new AbortController().signal, |
| 1008 | }; |
| 1009 | const first = captureFavicon(input); |
| 1010 | const superseded = captureFavicon(input); |
| 1011 | const newest = captureFavicon(input); |
| 1012 | |
| 1013 | expect(executions).toBe(1); |
| 1014 | resolveFirst(PNG); |
| 1015 | expect(await first).toEqual({ kind: "captured", dataUrl: PNG }); |
| 1016 | expect(await superseded).toEqual({ kind: "none" }); |
| 1017 | expect(await newest).toEqual({ kind: "captured", dataUrl: PNG }); |
| 1018 | expect(executions).toBe(2); |
| 1019 | }); |
| 1020 | }); |