@@ -12,6 +12,7 @@ import {
1212import type { DesignTokens } from "./types.js" ;
1313import type { IconCandidate } from "./faviconRanker.js" ;
1414import { CAPTURE_USER_AGENT } from "./userAgent.js" ;
15+ import sharp from "sharp" ;
1516
1617describe ( "isPrivateUrl — SSRF denylist (security: F-003)" , ( ) => {
1718 it ( "blocks loopback, private, and metadata IPv4" , ( ) => {
@@ -199,6 +200,31 @@ function tokensWithNoSvgs(): DesignTokens {
199200 return { svgs : [ ] , sections : [ ] , ogImage : "" } as unknown as DesignTokens ;
200201}
201202
203+ /**
204+ * openai.com's declared icons, in DOM order, with the attributes the page really carries.
205+ * `rankIconCandidates` puts `favicon.svg` first, so that is the file a capture must land.
206+ */
207+ const OPENAI_ICONS : IconCandidate [ ] = [
208+ {
209+ rel : "icon" ,
210+ href : "https://openai.example/favicon.svg" ,
211+ sizes : null ,
212+ type : "image/svg+xml" ,
213+ } ,
214+ {
215+ rel : "icon" ,
216+ href : "https://openai.example/favicon.ico" ,
217+ sizes : "48x48" ,
218+ type : "image/x-icon" ,
219+ } ,
220+ {
221+ rel : "apple-touch-icon" ,
222+ href : "https://openai.example/apple-icon.png" ,
223+ sizes : "180x180" ,
224+ type : "image/png" ,
225+ } ,
226+ ] ;
227+
202228describe ( "drop counts — why a referenced asset is not in the capture" , ( ) => {
203229 afterEach ( ( ) => vi . unstubAllGlobals ( ) ) ;
204230
@@ -334,31 +360,6 @@ describe("drop counts — why a referenced asset is not in the capture", () => {
334360describe ( "asset fetches present the same identity as the page navigation" , ( ) => {
335361 afterEach ( ( ) => vi . unstubAllGlobals ( ) ) ;
336362
337- /**
338- * openai.com's declared icons, in DOM order, with the attributes the page really carries.
339- * `rankIconCandidates` puts `favicon.svg` first, so that is the file a capture must land.
340- */
341- const OPENAI_ICONS : IconCandidate [ ] = [
342- {
343- rel : "icon" ,
344- href : "https://openai.example/favicon.svg" ,
345- sizes : null ,
346- type : "image/svg+xml" ,
347- } ,
348- {
349- rel : "icon" ,
350- href : "https://openai.example/favicon.ico" ,
351- sizes : "48x48" ,
352- type : "image/x-icon" ,
353- } ,
354- {
355- rel : "apple-touch-icon" ,
356- href : "https://openai.example/apple-icon.png" ,
357- sizes : "180x180" ,
358- type : "image/png" ,
359- } ,
360- ] ;
361-
362363 /**
363364 * Measured against the real origin: `GET /favicon.svg` answers `403 text/html` to
364365 * `User-Agent: HyperFrames/1.0` and `200 image/svg+xml` to the browser UA the capture
@@ -383,9 +384,9 @@ describe("asset fetches present the same identity as the page navigation", () =>
383384 // next candidate, so the file on disk is chosen by the CDN rather than by the ranker.
384385 await withTempDir ( async ( dir ) => {
385386 vi . stubGlobal ( "fetch" , serveLikeAnAntiBotEdge ( ) ) ;
386- const { assets } = await downloadAssets ( tokensWithNoSvgs ( ) , dir , [ ] , OPENAI_ICONS ) ;
387- expect ( assets . map ( ( a ) => a . localPath ) ) . toEqual ( [ "assets/favicon.svg" ] ) ;
388- expect ( assets [ 0 ] ?. url ) . toBe ( "https://openai.example/favicon.svg" ) ;
387+ const { icons } = await downloadAssets ( tokensWithNoSvgs ( ) , dir , [ ] , OPENAI_ICONS ) ;
388+ expect ( icons . headline ?. file ) . toBe ( "assets/favicon.svg" ) ;
389+ expect ( icons . icons [ 0 ] ?. url ) . toBe ( "https://openai.example/favicon.svg" ) ;
389390 } ) ;
390391 } ) ;
391392
@@ -402,3 +403,137 @@ describe("asset fetches present the same identity as the page navigation", () =>
402403 } ) ;
403404 } ) ;
404405} ) ;
406+
407+ describe ( "declared icons — keep them all, headline the bare mark" , ( ) => {
408+ afterEach ( ( ) => vi . unstubAllGlobals ( ) ) ;
409+
410+ /** A mark knocked out of a full-bleed rounded rect: the shape openai.com's favicon.svg has. */
411+ const BADGE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180">
412+ <rect width="180" height="180" rx="90" fill="#000" /><path d="M60 60h60v60H60z" fill="#fff" />
413+ </svg>` ;
414+
415+ /** One shape with transparent margins. */
416+ const BARE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180">
417+ <path d="M40 40h100v100H40z" fill="#000" />
418+ </svg>` ;
419+
420+ function solidPng ( alpha : number ) : Promise < Buffer > {
421+ return sharp ( {
422+ create : { width : 64 , height : 64 , channels : 4 , background : { r : 20 , g : 20 , b : 20 , alpha } } ,
423+ } )
424+ . png ( )
425+ . toBuffer ( ) ;
426+ }
427+
428+ const CONTENT_TYPE : Record < string , string > = {
429+ ".svg" : "image/svg+xml" ,
430+ ".png" : "image/png" ,
431+ ".ico" : "image/vnd.microsoft.icon" ,
432+ } ;
433+
434+ /** Serve each URL the bytes the test names for it; 404 anything unexpected. */
435+ function serve ( bodies : Record < string , Buffer | string > ) {
436+ return vi . fn ( async ( url : string ) => {
437+ const body = Object . entries ( bodies ) . find ( ( [ suffix ] ) => url . endsWith ( suffix ) ) ?. [ 1 ] ;
438+ if ( body === undefined ) return new Response ( "nope" , { status : 404 } ) ;
439+ const ext = url . slice ( url . lastIndexOf ( "." ) ) ;
440+ const bytes = typeof body === "string" ? Buffer . from ( body ) : body ;
441+ return new Response ( new Uint8Array ( bytes ) , {
442+ status : 200 ,
443+ headers : { "content-type" : CONTENT_TYPE [ ext ] ?? "application/octet-stream" } ,
444+ } ) ;
445+ } ) ;
446+ }
447+
448+ it ( "writes one file per declared icon, named for its rel and sizes" , async ( ) => {
449+ await withTempDir ( async ( dir ) => {
450+ vi . stubGlobal (
451+ "fetch" ,
452+ serve ( {
453+ "favicon.svg" : BADGE_SVG ,
454+ "favicon.ico" : Buffer . from ( "not a decodable ico" ) ,
455+ "apple-icon.png" : await solidPng ( 1 ) ,
456+ } ) ,
457+ ) ;
458+ const { icons } = await downloadAssets ( tokensWithNoSvgs ( ) , dir , [ ] , OPENAI_ICONS ) ;
459+ expect ( icons . icons . map ( ( i ) => i . file ) ) . toEqual ( [
460+ "assets/icon-icon-unsized.svg" ,
461+ "assets/icon-apple-touch-icon-180x180.png" ,
462+ "assets/icon-icon-48x48.ico" ,
463+ ] ) ;
464+ } ) ;
465+ } ) ;
466+
467+ it ( "records openai.com's icons as badges, and says the headline had no bare mark to pick" , async ( ) => {
468+ // Measured against the real files: the SVG is a mark knocked out of a full-bleed disc, and
469+ // the apple-touch PNG is an opaque white square, because Apple composites those onto an
470+ // opaque tile. openai.com declares no bare mark at all, so the headline falls back to the
471+ // ranker and the manifest has to say so rather than implying a preference was satisfied.
472+ await withTempDir ( async ( dir ) => {
473+ vi . stubGlobal (
474+ "fetch" ,
475+ serve ( {
476+ "favicon.svg" : BADGE_SVG ,
477+ "favicon.ico" : Buffer . from ( "not a decodable ico" ) ,
478+ "apple-icon.png" : await solidPng ( 1 ) ,
479+ } ) ,
480+ ) ;
481+ const { icons } = await downloadAssets ( tokensWithNoSvgs ( ) , dir , [ ] , OPENAI_ICONS ) ;
482+ expect ( icons . icons . map ( ( i ) => i . shape ) ) . toEqual ( [ "badge" , "badge" , "unknown" ] ) ;
483+ expect ( icons . headline ) . toMatchObject ( {
484+ file : "assets/favicon.svg" ,
485+ source : "assets/icon-icon-unsized.svg" ,
486+ shape : "badge" ,
487+ } ) ;
488+ expect ( icons . headline ?. reason ) . toContain ( "no bare-mark candidate" ) ;
489+ } ) ;
490+ } ) ;
491+
492+ it ( "headlines a lower-ranked bare mark over the better-ranked badge" , async ( ) => {
493+ // The ordering rule, on its own. By declared quality the SVG wins outright; by shape the
494+ // PNG does, and shape is what the brand band needs. Under the old rule this lands
495+ // assets/favicon.svg.
496+ await withTempDir ( async ( dir ) => {
497+ vi . stubGlobal (
498+ "fetch" ,
499+ serve ( { "favicon.svg" : BADGE_SVG , "apple-icon.png" : await solidPng ( 0 ) } ) ,
500+ ) ;
501+ const { icons } = await downloadAssets ( tokensWithNoSvgs ( ) , dir , [ ] , [
502+ { rel : "icon" , href : "https://x.test/favicon.svg" , sizes : null , type : "image/svg+xml" } ,
503+ {
504+ rel : "apple-touch-icon" ,
505+ href : "https://x.test/apple-icon.png" ,
506+ sizes : "180x180" ,
507+ type : "image/png" ,
508+ } ,
509+ ] as IconCandidate [ ] ) ;
510+ expect ( icons . headline ) . toMatchObject ( {
511+ file : "assets/favicon.png" ,
512+ source : "assets/icon-apple-touch-icon-180x180.png" ,
513+ shape : "bare-mark" ,
514+ rank : 1 ,
515+ } ) ;
516+ expect ( icons . headline ?. reason ) . toContain ( "bare mark preferred over 1 badge" ) ;
517+ } ) ;
518+ } ) ;
519+
520+ it ( "headlines the SVG when it is the bare mark" , async ( ) => {
521+ await withTempDir ( async ( dir ) => {
522+ vi . stubGlobal ( "fetch" , serve ( { "favicon.svg" : BARE_SVG } ) ) ;
523+ const { icons } = await downloadAssets ( tokensWithNoSvgs ( ) , dir , [ ] , [
524+ { rel : "icon" , href : "https://x.test/favicon.svg" , sizes : null , type : "image/svg+xml" } ,
525+ ] as IconCandidate [ ] ) ;
526+ expect ( icons . headline ) . toMatchObject ( { file : "assets/favicon.svg" , shape : "bare-mark" } ) ;
527+ } ) ;
528+ } ) ;
529+
530+ it ( "keeps assets/favicon.<ext> so a stem match still finds the icon" , async ( ) => {
531+ await withTempDir ( async ( dir ) => {
532+ vi . stubGlobal ( "fetch" , serve ( { "favicon.svg" : BARE_SVG } ) ) ;
533+ const { assets } = await downloadAssets ( tokensWithNoSvgs ( ) , dir , [ ] , [
534+ { rel : "icon" , href : "https://x.test/favicon.svg" , sizes : null , type : "image/svg+xml" } ,
535+ ] as IconCandidate [ ] ) ;
536+ expect ( assets . map ( ( a ) => a . localPath ) ) . toContain ( "assets/favicon.svg" ) ;
537+ } ) ;
538+ } ) ;
539+ } ) ;
0 commit comments