1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it, describe, expect } from "@effect/vitest";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Path from "effect/Path";
8import * as PlatformError from "effect/PlatformError";
9import { TestClock } from "effect/testing";
10
11import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
12import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
13import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts";
14
15const layerTest = Layer.empty.pipe(
16 Layer.provideMerge(
17 ProjectFaviconResolver.layer.pipe(
18 Layer.provide(WorkspacePaths.layer),
19 Layer.provide(T3ProjectFileLoader.layer),
20 ),
21 ),
22 Layer.provideMerge(NodeServices.layer),
23);
24
25const makeTempDir = Effect.gen(function* () {
26 const fileSystem = yield* FileSystem.FileSystem;
27 return yield* fileSystem.makeTempDirectoryScoped({
28 prefix: "t3code-project-favicon-",
29 });
30});
31
32const writeTextFile = Effect.fn("writeTextFile")(function* (
33 cwd: string,
34 relativePath: string,
35 contents: string,
36) {
37 const fileSystem = yield* FileSystem.FileSystem;
38 const path = yield* Path.Path;
39 const absolutePath = path.join(cwd, relativePath);
40 yield* fileSystem
41 .makeDirectory(path.dirname(absolutePath), { recursive: true })
42 .pipe(Effect.orDie);
43 yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie);
44});
45
46const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) =>
47 ProjectFaviconResolver.make.pipe(
48 Effect.provide([WorkspacePaths.layer, T3ProjectFileLoader.layer]),
49 Effect.provideService(FileSystem.FileSystem, fileSystem),
50 );
51
52it.layer(layerTest)("ProjectFaviconResolverLive", (it) => {
53 describe("resolvePath", () => {
54 it.effect("serves repeated resolves from cache instead of re-walking candidates", () =>
55 Effect.gen(function* () {
56 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
57 const path = yield* Path.Path;
58 const cwd = yield* makeTempDir;
59 yield* writeTextFile(cwd, "public/favicon.svg", "<svg>public</svg>");
60
61 const resolved = yield* resolver.resolvePath(cwd);
62 expect(resolved?.endsWith(path.join("public", "favicon.svg"))).toBe(true);
63
64 // `favicon.svg` outranks `public/favicon.svg`, so a resolver that walked
65 // the candidate list again would switch to it. Staying on the original
66 // answer is only possible from cache.
67 yield* writeTextFile(cwd, "favicon.svg", "<svg>root</svg>");
68
69 for (const _attempt of [1, 2, 3]) {
70 expect(yield* resolver.resolvePath(cwd)).toBe(resolved);
71 }
72
73 yield* TestClock.adjust(Duration.minutes(11));
74
75 expect(yield* resolver.resolvePath(cwd)).toBe(path.join(cwd, "favicon.svg"));
76 expect(yield* resolver.resolvePath(cwd)).not.toBe(resolved);
77 }).pipe(Effect.provide(TestClock.layer())),
78 );
79
80 it.effect("falls back at once when a cached favicon is deleted", () =>
81 Effect.gen(function* () {
82 const fileSystem = yield* FileSystem.FileSystem;
83 const path = yield* Path.Path;
84 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
85 const cwd = yield* makeTempDir;
86 yield* writeTextFile(cwd, "favicon.svg", "<svg>favicon</svg>");
87
88 expect(yield* resolver.resolvePath(cwd)).not.toBeNull();
89
90 yield* fileSystem.remove(path.join(cwd, "favicon.svg")).pipe(Effect.orDie);
91
92 // Still inside the positive TTL: the cached path must not be served.
93 expect(yield* resolver.resolvePath(cwd)).toBeNull();
94 }).pipe(Effect.provide(TestClock.layer())),
95 );
96
97 it.effect("re-probes for a favicon added after a miss once the negative TTL expires", () =>
98 Effect.gen(function* () {
99 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
100 const cwd = yield* makeTempDir;
101
102 expect(yield* resolver.resolvePath(cwd)).toBeNull();
103
104 yield* writeTextFile(cwd, "favicon.svg", "<svg>favicon</svg>");
105 expect(yield* resolver.resolvePath(cwd)).toBeNull();
106
107 yield* TestClock.adjust(Duration.minutes(2));
108
109 expect(yield* resolver.resolvePath(cwd)).not.toBeNull();
110 }).pipe(Effect.provide(TestClock.layer())),
111 );
112
113 it.effect("prefers well-known favicon files", () =>
114 Effect.gen(function* () {
115 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
116 const cwd = yield* makeTempDir;
117 yield* writeTextFile(cwd, "favicon.svg", "<svg>favicon</svg>");
118
119 const resolved = yield* resolver.resolvePath(cwd);
120
121 expect(resolved).not.toBeNull();
122 expect(resolved).toContain("favicon.svg");
123 }),
124 );
125
126 it.effect("prefers a t3.json iconPath over well-known files", () =>
127 Effect.gen(function* () {
128 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
129 const path = yield* Path.Path;
130 const cwd = yield* makeTempDir;
131 yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "brand/mark.svg" }');
132 yield* writeTextFile(cwd, "brand/mark.svg", "<svg>mark</svg>");
133 yield* writeTextFile(cwd, "favicon.svg", "<svg>favicon</svg>");
134
135 const resolved = yield* resolver.resolvePath(cwd);
136
137 expect(resolved).not.toBeNull();
138 expect(resolved).toBe(path.join(cwd, "brand", "mark.svg"));
139 }),
140 );
141
142 it.effect("uses a saved project favicon override", () =>
143 Effect.gen(function* () {
144 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
145 const path = yield* Path.Path;
146 const cwd = yield* makeTempDir;
147 yield* writeTextFile(cwd, "brand/custom.svg", "<svg>custom</svg>");
148 yield* writeTextFile(cwd, "favicon.svg", "<svg>automatic</svg>");
149
150 const resolved = yield* resolver.resolvePath(cwd, "brand/custom.svg");
151
152 expect(resolved).not.toBeNull();
153 expect(resolved).toBe(path.join(cwd, "brand", "custom.svg"));
154 }),
155 );
156
157 it.effect("uses a saved project favicon outside the workspace", () =>
158 Effect.gen(function* () {
159 const path = yield* Path.Path;
160 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
161 const cwd = yield* makeTempDir;
162 const pictures = yield* makeTempDir;
163 yield* writeTextFile(pictures, "custom.png", "image");
164 const externalPath = path.join(pictures, "custom.png");
165
166 const resolved = yield* resolver.resolvePath(cwd, externalPath);
167
168 expect(resolved).toBe(externalPath);
169 }),
170 );
171
172 it.effect("falls back when a saved override is missing from a checkout", () =>
173 Effect.gen(function* () {
174 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
175 const cwd = yield* makeTempDir;
176 yield* writeTextFile(cwd, "favicon.svg", "<svg>automatic</svg>");
177
178 const resolved = yield* resolver.resolvePath(cwd, "brand/missing.svg");
179
180 expect(resolved).not.toBeNull();
181 expect(resolved).toContain("favicon.svg");
182 }),
183 );
184
185 it.effect("falls back to well-known files when the t3.json iconPath does not exist", () =>
186 Effect.gen(function* () {
187 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
188 const cwd = yield* makeTempDir;
189 yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "brand/missing.svg" }');
190 yield* writeTextFile(cwd, "favicon.svg", "<svg>favicon</svg>");
191
192 const resolved = yield* resolver.resolvePath(cwd);
193
194 expect(resolved).not.toBeNull();
195 expect(resolved).toContain("favicon.svg");
196 }),
197 );
198
199 it.effect("ignores invalid t3.json files", () =>
200 Effect.gen(function* () {
201 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
202 const cwd = yield* makeTempDir;
203 yield* writeTextFile(cwd, "t3.json", "{ not json");
204 yield* writeTextFile(cwd, "favicon.svg", "<svg>favicon</svg>");
205
206 const resolved = yield* resolver.resolvePath(cwd);
207
208 expect(resolved).not.toBeNull();
209 expect(resolved).toContain("favicon.svg");
210 }),
211 );
212
213 it.effect("does not resolve a t3.json iconPath outside the workspace root", () =>
214 Effect.gen(function* () {
215 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
216 const parent = yield* makeTempDir;
217 const cwd = `${parent}/app`;
218 yield* writeTextFile(parent, "secret.svg", "<svg>secret</svg>");
219 yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "../secret.svg" }');
220
221 const resolved = yield* resolver.resolvePath(cwd);
222
223 expect(resolved).toBeNull();
224 }),
225 );
226
227 it.effect("resolves icon hrefs from project source files", () =>
228 Effect.gen(function* () {
229 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
230 const cwd = yield* makeTempDir;
231 yield* writeTextFile(cwd, "index.html", '<link rel="icon" href="/brand/logo.svg">');
232 yield* writeTextFile(cwd, "public/brand/logo.svg", "<svg>brand</svg>");
233
234 const resolved = yield* resolver.resolvePath(cwd);
235
236 expect(resolved).not.toBeNull();
237 expect(resolved).toBe((yield* Path.Path).join(cwd, "public", "brand", "logo.svg"));
238 }),
239 );
240
241 it.effect("resolves icon hrefs from object-literal route metadata", () =>
242 Effect.gen(function* () {
243 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
244 const cwd = yield* makeTempDir;
245 yield* writeTextFile(
246 cwd,
247 "src/routes/__root.tsx",
248 `export const Route = createRootRoute({
249 head: () => ({
250 links: [
251 { rel: "stylesheet", href: "/app.css" },
252 { rel: "icon", href: "/brand/logo.svg" },
253 ],
254 }),
255});`,
256 );
257 yield* writeTextFile(cwd, "public/brand/logo.svg", "<svg>brand</svg>");
258
259 const resolved = yield* resolver.resolvePath(cwd);
260
261 expect(resolved).not.toBeNull();
262 expect(resolved).toBe((yield* Path.Path).join(cwd, "public", "brand", "logo.svg"));
263 }),
264 );
265
266 it.effect("resolves object-literal icon metadata when href precedes rel", () =>
267 Effect.gen(function* () {
268 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
269 const cwd = yield* makeTempDir;
270 yield* writeTextFile(
271 cwd,
272 "src/root.tsx",
273 `const links = [{ href: "/brand/logo.svg", rel: "shortcut icon" }];`,
274 );
275 yield* writeTextFile(cwd, "public/brand/logo.svg", "<svg>brand</svg>");
276
277 const resolved = yield* resolver.resolvePath(cwd);
278
279 expect(resolved).not.toBeNull();
280 expect(resolved).toBe((yield* Path.Path).join(cwd, "public", "brand", "logo.svg"));
281 }),
282 );
283
284 it.effect("resolves object-literal icon metadata alongside nested objects", () =>
285 Effect.gen(function* () {
286 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
287 const cwd = yield* makeTempDir;
288 yield* writeTextFile(
289 cwd,
290 "src/root.tsx",
291 `const links = [{ attributes: {}, rel: "icon", href: "/brand/logo.svg" }];`,
292 );
293 yield* writeTextFile(cwd, "public/brand/logo.svg", "<svg>brand</svg>");
294
295 const resolved = yield* resolver.resolvePath(cwd);
296
297 expect(resolved).not.toBeNull();
298 expect(resolved).toBe((yield* Path.Path).join(cwd, "public", "brand", "logo.svg"));
299 }),
300 );
301
302 it.effect("skips icon metadata without an href and keeps scanning", () =>
303 Effect.gen(function* () {
304 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
305 const cwd = yield* makeTempDir;
306 yield* writeTextFile(
307 cwd,
308 "src/root.tsx",
309 `const links = [{ rel: "icon" }, { rel: "icon", href: "/brand/logo.svg" }];`,
310 );
311 yield* writeTextFile(cwd, "public/brand/logo.svg", "<svg>brand</svg>");
312
313 const resolved = yield* resolver.resolvePath(cwd);
314
315 expect(resolved).not.toBeNull();
316 expect(resolved).toBe((yield* Path.Path).join(cwd, "public", "brand", "logo.svg"));
317 }),
318 );
319
320 // A large icon source with no icon metadata used to pin the server's event loop for
321 // minutes: the object pattern was unanchored, so it restarted at every offset and
322 // rescanned forward from each one. Anchoring keeps this proportional to file size.
323 it.effect("scans large icon sources without an icon in reasonable time", () =>
324 Effect.gen(function* () {
325 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
326 const cwd = yield* makeTempDir;
327 // Mirrors a generated single-file build: large, brace-sparse, and no icon metadata.
328 const filler = `<p>${"pokopia companion guide ".repeat(24)}</p>\n`;
329 yield* writeTextFile(
330 cwd,
331 "index.html",
332 `<!doctype html><html><head><title>guide</title></head><body>\n${filler.repeat(1200)}</body></html>`,
333 );
334
335 const startedAt = performance.now();
336 const resolved = yield* resolver.resolvePath(cwd);
337 const elapsedMs = performance.now() - startedAt;
338
339 expect(resolved).toBeNull();
340 expect(elapsedMs).toBeLessThan(5_000);
341 }),
342 );
343
344 it.effect("returns null when no icon is present", () =>
345 Effect.gen(function* () {
346 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
347 const cwd = yield* makeTempDir;
348
349 const resolved = yield* resolver.resolvePath(cwd);
350
351 expect(resolved).toBeNull();
352 }),
353 );
354
355 it.effect("preserves workspace normalization context", () =>
356 Effect.gen(function* () {
357 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
358 const cwd = yield* makeTempDir;
359 const missingCwd = `${cwd}/missing`;
360
361 const error = yield* resolver.resolvePath(missingCwd).pipe(Effect.flip);
362
363 expect(error).toMatchObject({
364 _tag: "ProjectFaviconResolutionError",
365 operation: "normalize-workspace",
366 workspaceRoot: missingCwd,
367 });
368 expect(error.cause).toBeInstanceOf(WorkspacePaths.WorkspaceRootNotExistsError);
369 }),
370 );
371
372 it.effect("preserves non-missing candidate stat failures", () =>
373 Effect.gen(function* () {
374 const fileSystem = yield* FileSystem.FileSystem;
375 const path = yield* Path.Path;
376 const cwd = yield* makeTempDir;
377 const faviconPath = path.join(cwd, "favicon.svg");
378 const cause = PlatformError.systemError({
379 _tag: "PermissionDenied",
380 module: "FileSystem",
381 method: "stat",
382 pathOrDescriptor: faviconPath,
383 });
384 const resolver = yield* makeResolverWithFileSystem(
385 FileSystem.FileSystem.of({
386 ...fileSystem,
387 stat: (filePath) =>
388 filePath === faviconPath ? Effect.fail(cause) : fileSystem.stat(filePath),
389 }),
390 );
391
392 const error = yield* resolver.resolvePath(cwd).pipe(Effect.flip);
393
394 expect(error).toMatchObject({
395 _tag: "ProjectFaviconResolutionError",
396 operation: "stat-candidate",
397 workspaceRoot: cwd,
398 relativePath: "favicon.svg",
399 absolutePath: faviconPath,
400 });
401 expect(error.cause).toBe(cause);
402 }),
403 );
404
405 it.effect("probes well-known candidates concurrently", () =>
406 Effect.gen(function* () {
407 const fileSystem = yield* FileSystem.FileSystem;
408 const cwd = yield* makeTempDir;
409 let inFlight = 0;
410 let peakInFlight = 0;
411 const resolver = yield* makeResolverWithFileSystem(
412 FileSystem.FileSystem.of({
413 ...fileSystem,
414 stat: (filePath) =>
415 Effect.suspend(() => {
416 inFlight += 1;
417 peakInFlight = Math.max(peakInFlight, inFlight);
418 return Effect.yieldNow.pipe(Effect.andThen(fileSystem.stat(filePath)));
419 }).pipe(Effect.ensuring(Effect.sync(() => (inFlight -= 1)))),
420 }),
421 );
422
423 expect(yield* resolver.resolvePath(cwd)).toBeNull();
424 expect(peakInFlight).toBeGreaterThan(1);
425 }),
426 );
427
428 it.effect("returns a found favicon without waiting for lower-ranked probes", () =>
429 Effect.gen(function* () {
430 const fileSystem = yield* FileSystem.FileSystem;
431 const path = yield* Path.Path;
432 const cwd = yield* makeTempDir;
433 yield* writeTextFile(cwd, "favicon.svg", "<svg>root</svg>");
434 const stuckPath = path.join(cwd, "favicon.ico");
435 const resolver = yield* makeResolverWithFileSystem(
436 FileSystem.FileSystem.of({
437 ...fileSystem,
438 stat: (filePath) => (filePath === stuckPath ? Effect.never : fileSystem.stat(filePath)),
439 }),
440 );
441
442 expect(yield* resolver.resolvePath(cwd)).toBe(path.join(cwd, "favicon.svg"));
443 }),
444 );
445
446 it.effect("ignores a failing candidate that ranks below a found favicon", () =>
447 Effect.gen(function* () {
448 const fileSystem = yield* FileSystem.FileSystem;
449 const path = yield* Path.Path;
450 const cwd = yield* makeTempDir;
451 yield* writeTextFile(cwd, "favicon.svg", "<svg>root</svg>");
452 const failingPath = path.join(cwd, "favicon.ico");
453 const resolver = yield* makeResolverWithFileSystem(
454 FileSystem.FileSystem.of({
455 ...fileSystem,
456 stat: (filePath) =>
457 filePath === failingPath
458 ? Effect.fail(
459 PlatformError.systemError({
460 _tag: "PermissionDenied",
461 module: "FileSystem",
462 method: "stat",
463 pathOrDescriptor: failingPath,
464 }),
465 )
466 : fileSystem.stat(filePath),
467 }),
468 );
469
470 expect(yield* resolver.resolvePath(cwd)).toBe(path.join(cwd, "favicon.svg"));
471 }),
472 );
473
474 it.effect("preserves icon source read failures", () =>
475 Effect.gen(function* () {
476 const fileSystem = yield* FileSystem.FileSystem;
477 const path = yield* Path.Path;
478 const cwd = yield* makeTempDir;
479 const sourcePath = path.join(cwd, "index.html");
480 yield* writeTextFile(cwd, "index.html", '<link rel="icon" href="/favicon.svg">');
481 const cause = PlatformError.systemError({
482 _tag: "PermissionDenied",
483 module: "FileSystem",
484 method: "readFileString",
485 pathOrDescriptor: sourcePath,
486 });
487 const resolver = yield* makeResolverWithFileSystem(
488 FileSystem.FileSystem.of({
489 ...fileSystem,
490 readFileString: (filePath, options) =>
491 filePath === sourcePath
492 ? Effect.fail(cause)
493 : fileSystem.readFileString(filePath, options),
494 }),
495 );
496
497 const error = yield* resolver.resolvePath(cwd).pipe(Effect.flip);
498
499 expect(error).toMatchObject({
500 _tag: "ProjectFaviconResolutionError",
501 operation: "read-source",
502 workspaceRoot: cwd,
503 relativePath: "index.html",
504 absolutePath: sourcePath,
505 });
506 expect(error.cause).toBe(cause);
507 }),
508 );
509
510 it.effect("skips icon metadata paths outside the workspace", () =>
511 Effect.gen(function* () {
512 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
513 const cwd = yield* makeTempDir;
514 yield* writeTextFile(cwd, "index.html", '<link rel="icon" href="../../secret.svg">');
515
516 const resolved = yield* resolver.resolvePath(cwd);
517
518 expect(resolved).toBeNull();
519 }),
520 );
521
522 it.effect("continues to later sources after an outside-root icon href", () =>
523 Effect.gen(function* () {
524 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
525 const cwd = yield* makeTempDir;
526 yield* writeTextFile(cwd, "index.html", '<link rel="icon" href="../../secret.svg">');
527 yield* writeTextFile(cwd, "public/index.html", '<link rel="icon" href="/brand/logo.svg">');
528 yield* writeTextFile(cwd, "public/brand/logo.svg", "<svg>brand</svg>");
529
530 const resolved = yield* resolver.resolvePath(cwd);
531
532 expect(resolved).not.toBeNull();
533 expect(resolved).toBe((yield* Path.Path).join(cwd, "public", "brand", "logo.svg"));
534 }),
535 );
536 });
537});