1+ import { execFileSync } from "node:child_process" ;
12import fs from "node:fs" ;
23import http from "node:http" ;
34import os from "node:os" ;
45import path from "node:path" ;
56
7+ import * as NodeServices from "@effect/platform-node/NodeServices" ;
8+ import { Effect } from "effect" ;
69import { afterEach , describe , expect , it } from "vitest" ;
710import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute" ;
811
@@ -20,14 +23,48 @@ function makeTempDir(prefix: string): string {
2023 return dir ;
2124}
2225
26+ function writeFile ( filePath : string , contents : string ) : void {
27+ fs . mkdirSync ( path . dirname ( filePath ) , { recursive : true } ) ;
28+ fs . writeFileSync ( filePath , contents , "utf8" ) ;
29+ }
30+
31+ function makeUnreadable ( filePath : string ) : void {
32+ fs . chmodSync ( filePath , 0o000 ) ;
33+ }
34+
35+ function runGit ( cwd : string , args : readonly string [ ] ) : void {
36+ execFileSync ( "git" , args , {
37+ cwd,
38+ stdio : "ignore" ,
39+ env : {
40+ ...process . env ,
41+ GIT_AUTHOR_NAME : "Test User" ,
42+ GIT_AUTHOR_EMAIL : "test@example.com" ,
43+ GIT_COMMITTER_NAME : "Test User" ,
44+ GIT_COMMITTER_EMAIL : "test@example.com" ,
45+ } ,
46+ } ) ;
47+ }
48+
2349async function withRouteServer ( run : ( baseUrl : string ) => Promise < void > ) : Promise < void > {
2450 const server = http . createServer ( ( req , res ) => {
2551 const url = new URL ( req . url ?? "/" , "http://127.0.0.1" ) ;
26- if ( tryHandleProjectFaviconRequest ( url , res ) ) {
27- return ;
28- }
29- res . writeHead ( 404 , { "Content-Type" : "text/plain" } ) ;
30- res . end ( "Not Found" ) ;
52+ void Effect . runPromise (
53+ Effect . gen ( function * ( ) {
54+ if ( yield * tryHandleProjectFaviconRequest ( url , res ) ) {
55+ return ;
56+ }
57+ res . writeHead ( 404 , { "Content-Type" : "text/plain" } ) ;
58+ res . end ( "Not Found" ) ;
59+ } ) . pipe ( Effect . provide ( NodeServices . layer ) ) ,
60+ ) . catch ( ( error ) => {
61+ if ( ! res . headersSent ) {
62+ res . writeHead ( 500 , { "Content-Type" : "text/plain" } ) ;
63+ }
64+ if ( ! res . writableEnded ) {
65+ res . end ( error instanceof Error ? error . message : "Unhandled error" ) ;
66+ }
67+ } ) ;
3168 } ) ;
3269
3370 await new Promise < void > ( ( resolve , reject ) => {
@@ -70,6 +107,22 @@ async function request(baseUrl: string, pathname: string): Promise<HttpResponse>
70107 } ;
71108}
72109
110+ function requestProjectFavicon ( baseUrl : string , projectDir : string ) : Promise < HttpResponse > {
111+ return request ( baseUrl , `/api/project-favicon?cwd=${ encodeURIComponent ( projectDir ) } ` ) ;
112+ }
113+
114+ function expectSvgResponse ( response : HttpResponse , expectedBody : string ) : void {
115+ expect ( response . statusCode ) . toBe ( 200 ) ;
116+ expect ( response . contentType ) . toContain ( "image/svg+xml" ) ;
117+ expect ( response . body ) . toBe ( expectedBody ) ;
118+ }
119+
120+ function expectFallbackSvgResponse ( response : HttpResponse ) : void {
121+ expect ( response . statusCode ) . toBe ( 200 ) ;
122+ expect ( response . contentType ) . toContain ( "image/svg+xml" ) ;
123+ expect ( response . body ) . toContain ( 'data-fallback="project-favicon"' ) ;
124+ }
125+
73126describe ( "tryHandleProjectFaviconRequest" , ( ) => {
74127 afterEach ( ( ) => {
75128 for ( const dir of tempDirs . splice ( 0 , tempDirs . length ) ) {
@@ -87,85 +140,146 @@ describe("tryHandleProjectFaviconRequest", () => {
87140
88141 it ( "serves a well-known favicon file from the project root" , async ( ) => {
89142 const projectDir = makeTempDir ( "t3code-favicon-route-root-" ) ;
90- fs . writeFileSync ( path . join ( projectDir , "favicon.svg" ) , "<svg>favicon</svg>" , "utf8 ") ;
143+ writeFile ( path . join ( projectDir , "favicon.svg" ) , "<svg>favicon</svg>" ) ;
91144
92145 await withRouteServer ( async ( baseUrl ) => {
93- const pathname = `/api/project-favicon?cwd=${ encodeURIComponent ( projectDir ) } ` ;
94- const response = await request ( baseUrl , pathname ) ;
95- expect ( response . statusCode ) . toBe ( 200 ) ;
96- expect ( response . contentType ) . toContain ( "image/svg+xml" ) ;
97- expect ( response . body ) . toBe ( "<svg>favicon</svg>" ) ;
146+ expectSvgResponse ( await requestProjectFavicon ( baseUrl , projectDir ) , "<svg>favicon</svg>" ) ;
98147 } ) ;
99148 } ) ;
100149
101- it ( "resolves icon href from source files when no well-known favicon exists" , async ( ) => {
102- const projectDir = makeTempDir ( "t3code-favicon-route-source-" ) ;
103- const iconPath = path . join ( projectDir , "public" , "brand" , "logo.svg" ) ;
104- fs . mkdirSync ( path . dirname ( iconPath ) , { recursive : true } ) ;
105- fs . writeFileSync (
106- path . join ( projectDir , "index.html" ) ,
150+ it . each ( [
151+ {
152+ name : "resolves icon link when href appears before rel in HTML" ,
153+ prefix : "t3code-favicon-route-html-order-" ,
154+ sourcePath : [ "index.html" ] ,
155+ sourceContents : '<link href="/brand/logo.svg" rel="icon">' ,
156+ iconPath : [ "public" , "brand" , "logo.svg" ] ,
157+ expectedBody : "<svg>brand-html-order</svg>" ,
158+ } ,
159+ {
160+ name : "resolves object-style icon metadata when href appears before rel" ,
161+ prefix : "t3code-favicon-route-obj-order-" ,
162+ sourcePath : [ "src" , "root.tsx" ] ,
163+ sourceContents : 'const links = [{ href: "/brand/obj.svg", rel: "icon" }];' ,
164+ iconPath : [ "public" , "brand" , "obj.svg" ] ,
165+ expectedBody : "<svg>brand-obj-order</svg>" ,
166+ } ,
167+ ] ) ( "$name" , async ( { prefix, sourcePath, sourceContents, iconPath, expectedBody } ) => {
168+ const projectDir = makeTempDir ( prefix ) ;
169+ writeFile ( path . join ( projectDir , ...sourcePath ) , sourceContents ) ;
170+ writeFile ( path . join ( projectDir , ...iconPath ) , expectedBody ) ;
171+
172+ await withRouteServer ( async ( baseUrl ) => {
173+ expectSvgResponse ( await requestProjectFavicon ( baseUrl , projectDir ) , expectedBody ) ;
174+ } ) ;
175+ } ) ;
176+
177+ it ( "serves a fallback favicon when no icon exists" , async ( ) => {
178+ const projectDir = makeTempDir ( "t3code-favicon-route-fallback-" ) ;
179+
180+ await withRouteServer ( async ( baseUrl ) => {
181+ expectFallbackSvgResponse ( await requestProjectFavicon ( baseUrl , projectDir ) ) ;
182+ } ) ;
183+ } ) ;
184+
185+ it ( "treats unreadable favicon probes as misses and continues searching" , async ( ) => {
186+ const projectDir = makeTempDir ( "t3code-favicon-route-unreadable-probes-" ) ;
187+ const unreadableFaviconPath = path . join ( projectDir , "favicon.svg" ) ;
188+ writeFile ( unreadableFaviconPath , "<svg>blocked-root</svg>" ) ;
189+ makeUnreadable ( unreadableFaviconPath ) ;
190+ const unreadableSourcePath = path . join ( projectDir , "index.html" ) ;
191+ writeFile ( unreadableSourcePath , '<link rel="icon" href="/brand/blocked.svg">' ) ;
192+ makeUnreadable ( unreadableSourcePath ) ;
193+ writeFile (
194+ path . join ( projectDir , "src" , "root.tsx" ) ,
195+ 'const links = [{ rel: "icon", href: "/brand/readable.svg" }];' ,
196+ ) ;
197+ writeFile (
198+ path . join ( projectDir , "public" , "brand" , "readable.svg" ) ,
199+ "<svg>readable-from-source</svg>" ,
200+ ) ;
201+
202+ await withRouteServer ( async ( baseUrl ) => {
203+ expectSvgResponse (
204+ await requestProjectFavicon ( baseUrl , projectDir ) ,
205+ "<svg>readable-from-source</svg>" ,
206+ ) ;
207+ } ) ;
208+ } ) ;
209+
210+ it ( "finds a nested app favicon from source metadata when cwd is a monorepo root" , async ( ) => {
211+ const projectDir = makeTempDir ( "t3code-favicon-route-monorepo-source-" ) ;
212+ writeFile (
213+ path . join ( projectDir , "apps" , "frontend" , "index.html" ) ,
107214 '<link rel="icon" href="/brand/logo.svg">' ,
108215 ) ;
109- fs . writeFileSync ( iconPath , "<svg>brand</svg>" , "utf8" ) ;
216+ writeFile (
217+ path . join ( projectDir , "apps" , "frontend" , "public" , "brand" , "logo.svg" ) ,
218+ "<svg>nested-app</svg>" ,
219+ ) ;
110220
111221 await withRouteServer ( async ( baseUrl ) => {
112- const pathname = `/api/project-favicon?cwd=${ encodeURIComponent ( projectDir ) } ` ;
113- const response = await request ( baseUrl , pathname ) ;
114- expect ( response . statusCode ) . toBe ( 200 ) ;
115- expect ( response . contentType ) . toContain ( "image/svg+xml" ) ;
116- expect ( response . body ) . toBe ( "<svg>brand</svg>" ) ;
222+ expectSvgResponse ( await requestProjectFavicon ( baseUrl , projectDir ) , "<svg>nested-app</svg>" ) ;
117223 } ) ;
118224 } ) ;
119225
120- it ( "resolves icon link when href appears before rel in HTML" , async ( ) => {
121- const projectDir = makeTempDir ( "t3code-favicon-route-html-order-" ) ;
122- const iconPath = path . join ( projectDir , "public" , "brand" , "logo.svg" ) ;
123- fs . mkdirSync ( path . dirname ( iconPath ) , { recursive : true } ) ;
124- fs . writeFileSync (
125- path . join ( projectDir , "index.html" ) ,
126- '<link href="/brand/logo.svg" rel="icon">' ,
226+ it ( "skips nested search roots that workspace entries ignore" , async ( ) => {
227+ const projectDir = makeTempDir ( "t3code-favicon-route-ignored-search-root-" ) ;
228+ writeFile ( path . join ( projectDir , ".next" , "public" , "favicon.svg" ) , "<svg>ignored-next</svg>" ) ;
229+
230+ await withRouteServer ( async ( baseUrl ) => {
231+ expectFallbackSvgResponse ( await requestProjectFavicon ( baseUrl , projectDir ) ) ;
232+ } ) ;
233+ } ) ;
234+
235+ it ( "prefers a root favicon over nested workspace matches" , async ( ) => {
236+ const projectDir = makeTempDir ( "t3code-favicon-route-root-priority-" ) ;
237+ writeFile ( path . join ( projectDir , "favicon.svg" ) , "<svg>root-first</svg>" ) ;
238+ writeFile ( path . join ( projectDir , "apps" , "frontend" , "public" , "favicon.ico" ) , "nested-ico" ) ;
239+
240+ await withRouteServer ( async ( baseUrl ) => {
241+ expectSvgResponse ( await requestProjectFavicon ( baseUrl , projectDir ) , "<svg>root-first</svg>" ) ;
242+ } ) ;
243+ } ) ;
244+
245+ it ( "skips a gitignored nested app directory" , async ( ) => {
246+ const projectDir = makeTempDir ( "t3code-favicon-route-gitignored-app-" ) ;
247+ runGit ( projectDir , [ "init" ] ) ;
248+ writeFile ( path . join ( projectDir , ".gitignore" ) , "apps/frontend/\n" ) ;
249+ writeFile (
250+ path . join ( projectDir , "apps" , "frontend" , "public" , "favicon.svg" ) ,
251+ "<svg>ignored-app</svg>" ,
127252 ) ;
128- fs . writeFileSync ( iconPath , "<svg>brand-html-order</svg>" , "utf8" ) ;
129253
130254 await withRouteServer ( async ( baseUrl ) => {
131- const pathname = `/api/project-favicon?cwd=${ encodeURIComponent ( projectDir ) } ` ;
132- const response = await request ( baseUrl , pathname ) ;
133- expect ( response . statusCode ) . toBe ( 200 ) ;
134- expect ( response . contentType ) . toContain ( "image/svg+xml" ) ;
135- expect ( response . body ) . toBe ( "<svg>brand-html-order</svg>" ) ;
255+ expectFallbackSvgResponse ( await requestProjectFavicon ( baseUrl , projectDir ) ) ;
136256 } ) ;
137257 } ) ;
138258
139- it ( "resolves object-style icon metadata when href appears before rel" , async ( ) => {
140- const projectDir = makeTempDir ( "t3code-favicon-route-obj-order-" ) ;
141- const iconPath = path . join ( projectDir , "public" , "brand" , "obj.svg" ) ;
142- fs . mkdirSync ( path . dirname ( iconPath ) , { recursive : true } ) ;
143- fs . mkdirSync ( path . join ( projectDir , "src" ) , { recursive : true } ) ;
144- fs . writeFileSync (
145- path . join ( projectDir , "src" , "root.tsx" ) ,
146- 'const links = [{ href: "/brand/obj.svg", rel: "icon" }];' ,
147- "utf8" ,
259+ it ( "skips a gitignored root favicon and falls through to a nested app" , async ( ) => {
260+ const projectDir = makeTempDir ( "t3code-favicon-route-gitignored-root-" ) ;
261+ runGit ( projectDir , [ "init" ] ) ;
262+ writeFile ( path . join ( projectDir , ".gitignore" ) , "/favicon.svg\n" ) ;
263+ writeFile ( path . join ( projectDir , "favicon.svg" ) , "<svg>ignored-root</svg>" ) ;
264+ writeFile (
265+ path . join ( projectDir , "apps" , "frontend" , "public" , "favicon.svg" ) ,
266+ "<svg>nested-kept</svg>" ,
148267 ) ;
149- fs . writeFileSync ( iconPath , "<svg>brand-obj-order</svg>" , "utf8" ) ;
150268
151269 await withRouteServer ( async ( baseUrl ) => {
152- const pathname = `/api/project-favicon?cwd=${ encodeURIComponent ( projectDir ) } ` ;
153- const response = await request ( baseUrl , pathname ) ;
154- expect ( response . statusCode ) . toBe ( 200 ) ;
155- expect ( response . contentType ) . toContain ( "image/svg+xml" ) ;
156- expect ( response . body ) . toBe ( "<svg>brand-obj-order</svg>" ) ;
270+ expectSvgResponse ( await requestProjectFavicon ( baseUrl , projectDir ) , "<svg>nested-kept</svg>" ) ;
157271 } ) ;
158272 } ) ;
159273
160- it ( "serves a fallback favicon when no icon exists" , async ( ) => {
161- const projectDir = makeTempDir ( "t3code-favicon-route-fallback-" ) ;
274+ it ( "skips a gitignored source file when resolving icon metadata" , async ( ) => {
275+ const projectDir = makeTempDir ( "t3code-favicon-route-gitignored-source-" ) ;
276+ runGit ( projectDir , [ "init" ] ) ;
277+ writeFile ( path . join ( projectDir , ".gitignore" ) , "index.html\n" ) ;
278+ writeFile ( path . join ( projectDir , "index.html" ) , '<link rel="icon" href="/brand/logo.svg">' ) ;
279+ writeFile ( path . join ( projectDir , "public" , "brand" , "logo.svg" ) , "<svg>ignored-source</svg>" ) ;
162280
163281 await withRouteServer ( async ( baseUrl ) => {
164- const pathname = `/api/project-favicon?cwd=${ encodeURIComponent ( projectDir ) } ` ;
165- const response = await request ( baseUrl , pathname ) ;
166- expect ( response . statusCode ) . toBe ( 200 ) ;
167- expect ( response . contentType ) . toContain ( "image/svg+xml" ) ;
168- expect ( response . body ) . toContain ( 'data-fallback="project-favicon"' ) ;
282+ expectFallbackSvgResponse ( await requestProjectFavicon ( baseUrl , projectDir ) ) ;
169283 } ) ;
170284 } ) ;
171285} ) ;
0 commit comments