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,44 @@ 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 runGit ( cwd : string , args : readonly string [ ] ) : void {
32+ execFileSync ( "git" , args , {
33+ cwd,
34+ stdio : "ignore" ,
35+ env : {
36+ ...process . env ,
37+ GIT_AUTHOR_NAME : "Test User" ,
38+ GIT_AUTHOR_EMAIL : "test@example.com" ,
39+ GIT_COMMITTER_NAME : "Test User" ,
40+ GIT_COMMITTER_EMAIL : "test@example.com" ,
41+ } ,
42+ } ) ;
43+ }
44+
2345async function withRouteServer ( run : ( baseUrl : string ) => Promise < void > ) : Promise < void > {
2446 const server = http . createServer ( ( req , res ) => {
2547 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" ) ;
48+ void Effect . runPromise (
49+ Effect . gen ( function * ( ) {
50+ if ( yield * tryHandleProjectFaviconRequest ( url , res ) ) {
51+ return ;
52+ }
53+ res . writeHead ( 404 , { "Content-Type" : "text/plain" } ) ;
54+ res . end ( "Not Found" ) ;
55+ } ) . pipe ( Effect . provide ( NodeServices . layer ) ) ,
56+ ) . catch ( ( error ) => {
57+ if ( ! res . headersSent ) {
58+ res . writeHead ( 500 , { "Content-Type" : "text/plain" } ) ;
59+ }
60+ if ( ! res . writableEnded ) {
61+ res . end ( error instanceof Error ? error . message : "Unhandled error" ) ;
62+ }
63+ } ) ;
3164 } ) ;
3265
3366 await new Promise < void > ( ( resolve , reject ) => {
@@ -70,6 +103,22 @@ async function request(baseUrl: string, pathname: string): Promise<HttpResponse>
70103 } ;
71104}
72105
106+ function requestProjectFavicon ( baseUrl : string , projectDir : string ) : Promise < HttpResponse > {
107+ return request ( baseUrl , `/api/project-favicon?cwd=${ encodeURIComponent ( projectDir ) } ` ) ;
108+ }
109+
110+ function expectSvgResponse ( response : HttpResponse , expectedBody : string ) : void {
111+ expect ( response . statusCode ) . toBe ( 200 ) ;
112+ expect ( response . contentType ) . toContain ( "image/svg+xml" ) ;
113+ expect ( response . body ) . toBe ( expectedBody ) ;
114+ }
115+
116+ function expectFallbackSvgResponse ( response : HttpResponse ) : void {
117+ expect ( response . statusCode ) . toBe ( 200 ) ;
118+ expect ( response . contentType ) . toContain ( "image/svg+xml" ) ;
119+ expect ( response . body ) . toContain ( 'data-fallback="project-favicon"' ) ;
120+ }
121+
73122describe ( "tryHandleProjectFaviconRequest" , ( ) => {
74123 afterEach ( ( ) => {
75124 for ( const dir of tempDirs . splice ( 0 , tempDirs . length ) ) {
@@ -87,85 +136,121 @@ describe("tryHandleProjectFaviconRequest", () => {
87136
88137 it ( "serves a well-known favicon file from the project root" , async ( ) => {
89138 const projectDir = makeTempDir ( "t3code-favicon-route-root-" ) ;
90- fs . writeFileSync ( path . join ( projectDir , "favicon.svg" ) , "<svg>favicon</svg>" , "utf8" ) ;
139+ writeFile ( path . join ( projectDir , "favicon.svg" ) , "<svg>favicon</svg>" ) ;
140+
141+ await withRouteServer ( async ( baseUrl ) => {
142+ expectSvgResponse ( await requestProjectFavicon ( baseUrl , projectDir ) , "<svg>favicon</svg>" ) ;
143+ } ) ;
144+ } ) ;
145+
146+ it . each ( [
147+ {
148+ name : "resolves icon link when href appears before rel in HTML" ,
149+ prefix : "t3code-favicon-route-html-order-" ,
150+ sourcePath : [ "index.html" ] ,
151+ sourceContents : '<link href="/brand/logo.svg" rel="icon">' ,
152+ iconPath : [ "public" , "brand" , "logo.svg" ] ,
153+ expectedBody : "<svg>brand-html-order</svg>" ,
154+ } ,
155+ {
156+ name : "resolves object-style icon metadata when href appears before rel" ,
157+ prefix : "t3code-favicon-route-obj-order-" ,
158+ sourcePath : [ "src" , "root.tsx" ] ,
159+ sourceContents : 'const links = [{ href: "/brand/obj.svg", rel: "icon" }];' ,
160+ iconPath : [ "public" , "brand" , "obj.svg" ] ,
161+ expectedBody : "<svg>brand-obj-order</svg>" ,
162+ } ,
163+ ] ) ( "$name" , async ( { prefix, sourcePath, sourceContents, iconPath, expectedBody } ) => {
164+ const projectDir = makeTempDir ( prefix ) ;
165+ writeFile ( path . join ( projectDir , ...sourcePath ) , sourceContents ) ;
166+ writeFile ( path . join ( projectDir , ...iconPath ) , expectedBody ) ;
91167
92168 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>" ) ;
169+ expectSvgResponse ( await requestProjectFavicon ( baseUrl , projectDir ) , expectedBody ) ;
98170 } ) ;
99171 } ) ;
100172
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" ) ,
173+ it ( "serves a fallback favicon when no icon exists" , async ( ) => {
174+ const projectDir = makeTempDir ( "t3code-favicon-route-fallback-" ) ;
175+
176+ await withRouteServer ( async ( baseUrl ) => {
177+ expectFallbackSvgResponse ( await requestProjectFavicon ( baseUrl , projectDir ) ) ;
178+ } ) ;
179+ } ) ;
180+
181+ it ( "finds a nested app favicon from source metadata when cwd is a monorepo root" , async ( ) => {
182+ const projectDir = makeTempDir ( "t3code-favicon-route-monorepo-source-" ) ;
183+ writeFile (
184+ path . join ( projectDir , "apps" , "frontend" , "index.html" ) ,
107185 '<link rel="icon" href="/brand/logo.svg">' ,
108186 ) ;
109- fs . writeFileSync ( iconPath , "<svg>brand</svg>" , "utf8" ) ;
187+ writeFile (
188+ path . join ( projectDir , "apps" , "frontend" , "public" , "brand" , "logo.svg" ) ,
189+ "<svg>nested-app</svg>" ,
190+ ) ;
110191
111192 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>" ) ;
193+ expectSvgResponse ( await requestProjectFavicon ( baseUrl , projectDir ) , "<svg>nested-app</svg>" ) ;
117194 } ) ;
118195 } ) ;
119196
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">' ,
197+ it ( "skips nested search roots that workspace entries ignore" , async ( ) => {
198+ const projectDir = makeTempDir ( "t3code-favicon-route-ignored-search-root-" ) ;
199+ writeFile ( path . join ( projectDir , ".next" , "public" , "favicon.svg" ) , "<svg>ignored-next</svg>" ) ;
200+
201+ await withRouteServer ( async ( baseUrl ) => {
202+ expectFallbackSvgResponse ( await requestProjectFavicon ( baseUrl , projectDir ) ) ;
203+ } ) ;
204+ } ) ;
205+
206+ it ( "prefers a root favicon over nested workspace matches" , async ( ) => {
207+ const projectDir = makeTempDir ( "t3code-favicon-route-root-priority-" ) ;
208+ writeFile ( path . join ( projectDir , "favicon.svg" ) , "<svg>root-first</svg>" ) ;
209+ writeFile ( path . join ( projectDir , "apps" , "frontend" , "public" , "favicon.ico" ) , "nested-ico" ) ;
210+
211+ await withRouteServer ( async ( baseUrl ) => {
212+ expectSvgResponse ( await requestProjectFavicon ( baseUrl , projectDir ) , "<svg>root-first</svg>" ) ;
213+ } ) ;
214+ } ) ;
215+
216+ it ( "skips a gitignored nested app directory" , async ( ) => {
217+ const projectDir = makeTempDir ( "t3code-favicon-route-gitignored-app-" ) ;
218+ runGit ( projectDir , [ "init" ] ) ;
219+ writeFile ( path . join ( projectDir , ".gitignore" ) , "apps/frontend/\n" ) ;
220+ writeFile (
221+ path . join ( projectDir , "apps" , "frontend" , "public" , "favicon.svg" ) ,
222+ "<svg>ignored-app</svg>" ,
127223 ) ;
128- fs . writeFileSync ( iconPath , "<svg>brand-html-order</svg>" , "utf8" ) ;
129224
130225 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>" ) ;
226+ expectFallbackSvgResponse ( await requestProjectFavicon ( baseUrl , projectDir ) ) ;
136227 } ) ;
137228 } ) ;
138229
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" ,
230+ it ( "skips a gitignored root favicon and falls through to a nested app" , async ( ) => {
231+ const projectDir = makeTempDir ( "t3code-favicon-route-gitignored-root-" ) ;
232+ runGit ( projectDir , [ "init" ] ) ;
233+ writeFile ( path . join ( projectDir , ".gitignore" ) , "/favicon.svg\n" ) ;
234+ writeFile ( path . join ( projectDir , "favicon.svg" ) , "<svg>ignored-root</svg>" ) ;
235+ writeFile (
236+ path . join ( projectDir , "apps" , "frontend" , "public" , "favicon.svg" ) ,
237+ "<svg>nested-kept</svg>" ,
148238 ) ;
149- fs . writeFileSync ( iconPath , "<svg>brand-obj-order</svg>" , "utf8" ) ;
150239
151240 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>" ) ;
241+ expectSvgResponse ( await requestProjectFavicon ( baseUrl , projectDir ) , "<svg>nested-kept</svg>" ) ;
157242 } ) ;
158243 } ) ;
159244
160- it ( "serves a fallback favicon when no icon exists" , async ( ) => {
161- const projectDir = makeTempDir ( "t3code-favicon-route-fallback-" ) ;
245+ it ( "skips a gitignored source file when resolving icon metadata" , async ( ) => {
246+ const projectDir = makeTempDir ( "t3code-favicon-route-gitignored-source-" ) ;
247+ runGit ( projectDir , [ "init" ] ) ;
248+ writeFile ( path . join ( projectDir , ".gitignore" ) , "index.html\n" ) ;
249+ writeFile ( path . join ( projectDir , "index.html" ) , '<link rel="icon" href="/brand/logo.svg">' ) ;
250+ writeFile ( path . join ( projectDir , "public" , "brand" , "logo.svg" ) , "<svg>ignored-source</svg>" ) ;
162251
163252 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"' ) ;
253+ expectFallbackSvgResponse ( await requestProjectFavicon ( baseUrl , projectDir ) ) ;
169254 } ) ;
170255 } ) ;
171256} ) ;
0 commit comments