1import { describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 MAX_FAVICON_CANDIDATES,
5 MAX_FAVICON_RESPONSE_BYTES,
6 captureFavicon,
7 selectFaviconCandidates,
8 sourceDimensions,
9} from "./FaviconCapture.ts";
10
11const PNG = "data:image/png;base64,cG5n";
12const SOURCE_PNG = Buffer.alloc(24);
13Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(SOURCE_PNG);
14SOURCE_PNG.writeUInt32BE(1, 16);
15SOURCE_PNG.writeUInt32BE(1, 20);
16const SOURCE_PNG_URL = `data:image/png;base64,${SOURCE_PNG.toString("base64")}`;
17
18function 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
51it.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
63it("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
71function 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
95function 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
104function 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
134function 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
143function sourceJpegWithOrientationEntries(
144 width: number,
145 height: number,
146 orientations: ReadonlyArray<number>,
147): Buffer {
148 return sourceJpegWithApp1Segments(width, height, [sourceJpegExifSegment(orientations)]);
149}
150
151function 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
158function 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
202function 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
239function 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
264function 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
274function 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
284function makeUnsafePng(): Buffer {
285 const buffer = Buffer.from(SOURCE_PNG);
286 buffer.writeUInt32BE(4096, 16);
287 buffer.writeUInt32BE(4096, 20);
288 return buffer;
289}
290
291function 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
298function 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
306function 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
331const JPEG_LANDSCAPE_LAYOUT = {
332 draw: "context.drawImage(bitmap, 0, 8, 32, 16)",
333 resizeHeight: 16,
334 resizeWidth: 32,
335} as const;
336const JPEG_PORTRAIT_LAYOUT = {
337 draw: "context.drawImage(bitmap, 8, 0, 16, 32)",
338 resizeHeight: 32,
339 resizeWidth: 16,
340} as const;
341
342async 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
365describe("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
392describe("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});