Open History to see the last commit
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it, describe, expect } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Path from "effect/Path";
7import { TestClock } from "effect/testing";
8
9import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
10import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
11import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts";
12
13const layerTest = Layer.empty.pipe(
14 Layer.provideMerge(
15 ProjectFaviconResolver.layer.pipe(
16 Layer.provide(WorkspacePaths.layer),
17 Layer.provide(T3ProjectFileLoader.layer),
18 ),
19 ),
20 Layer.provideMerge(NodeServices.layer),
21);
22
23const makeTempDir = Effect.gen(function* () {
24 const fileSystem = yield* FileSystem.FileSystem;
25 return yield* fileSystem.makeTempDirectoryScoped({
26 prefix: "t3code-project-favicon-",
27 });
28});
29
30const writeTextFile = Effect.fn("writeTextFile")(function* (
31 cwd: string,
32 relativePath: string,
33 contents: string,
34) {
35 const fileSystem = yield* FileSystem.FileSystem;
36 const path = yield* Path.Path;
37 const absolutePath = path.join(cwd, relativePath);
38 yield* fileSystem
39 .makeDirectory(path.dirname(absolutePath), { recursive: true })
40 .pipe(Effect.orDie);
41 yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie);
42});
43
44const makeResolverWithFileSystem = (fileSystem: FileSystem.FileSystem) =>
45 ProjectFaviconResolver.make.pipe(
46 Effect.provide([Layer.fresh(WorkspacePaths.layer), T3ProjectFileLoader.layer]),
47 Effect.provideService(FileSystem.FileSystem, fileSystem),
48 );
49
50it.layer(layerTest)("ProjectFaviconResolverLive", (it) => {
51 describe("resolvePath", () => {
52 it.effect("finds a favicon added after a cached miss once the workspace is invalidated", () =>
53 Effect.gen(function* () {
54 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
55 const path = yield* Path.Path;
56 const cwd = yield* makeTempDir;
57
58 // A clone's project exists while its folder is still empty.
59 expect(yield* resolver.resolvePath(cwd)).toBeNull();
60 yield* writeTextFile(cwd, "favicon.ico", "icon");
61 expect(yield* resolver.resolvePath(cwd)).toBeNull();
62
63 yield* resolver.invalidate(cwd);
64 expect(yield* resolver.resolvePath(cwd)).toBe(path.join(cwd, "favicon.ico"));
65 }),
66 );
67
68 it.effect("falls back at once when a cached favicon is deleted", () =>
69 Effect.gen(function* () {
70 const fileSystem = yield* FileSystem.FileSystem;
71 const path = yield* Path.Path;
72 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
73 const cwd = yield* makeTempDir;
74 yield* writeTextFile(cwd, "favicon.svg", "<svg>favicon</svg>");
75
76 expect(yield* resolver.resolvePath(cwd)).not.toBeNull();
77
78 yield* fileSystem.remove(path.join(cwd, "favicon.svg")).pipe(Effect.orDie);
79
80 expect(yield* resolver.resolvePath(cwd)).toBeNull();
81 }).pipe(Effect.provide(TestClock.layer())),
82 );
83
84 it.effect("looks up a missing workspace root once within the negative cache window", () =>
85 Effect.gen(function* () {
86 const fileSystem = yield* FileSystem.FileSystem;
87 const path = yield* Path.Path;
88 const cwd = path.join(yield* makeTempDir, "deleted-project");
89 let rootStats = 0;
90 const resolver = yield* makeResolverWithFileSystem(
91 FileSystem.FileSystem.of({
92 ...fileSystem,
93 stat: (filePath) => {
94 if (filePath === cwd) rootStats += 1;
95 return fileSystem.stat(filePath);
96 },
97 }),
98 );
99
100 yield* Effect.exit(resolver.resolvePath(cwd));
101 yield* Effect.exit(resolver.resolvePath(cwd));
102 expect(rootStats).toBe(1);
103 }),
104 );
105
106 it.effect("prefers a t3.json iconPath over well-known files", () =>
107 Effect.gen(function* () {
108 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
109 const path = yield* Path.Path;
110 const cwd = yield* makeTempDir;
111 yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "brand/mark.svg" }');
112 yield* writeTextFile(cwd, "brand/mark.svg", "<svg>mark</svg>");
113 yield* writeTextFile(cwd, "favicon.svg", "<svg>favicon</svg>");
114
115 const resolved = yield* resolver.resolvePath(cwd);
116
117 expect(resolved).not.toBeNull();
118 expect(resolved).toBe(path.join(cwd, "brand", "mark.svg"));
119 }),
120 );
121
122 it.effect("uses a saved project favicon override", () =>
123 Effect.gen(function* () {
124 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
125 const path = yield* Path.Path;
126 const cwd = yield* makeTempDir;
127 yield* writeTextFile(cwd, "brand/custom.svg", "<svg>custom</svg>");
128 yield* writeTextFile(cwd, "favicon.svg", "<svg>automatic</svg>");
129
130 const resolved = yield* resolver.resolvePath(cwd, "brand/custom.svg");
131
132 expect(resolved).not.toBeNull();
133 expect(resolved).toBe(path.join(cwd, "brand", "custom.svg"));
134 }),
135 );
136
137 it.effect("uses a saved project favicon outside the workspace", () =>
138 Effect.gen(function* () {
139 const path = yield* Path.Path;
140 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
141 const cwd = yield* makeTempDir;
142 const pictures = yield* makeTempDir;
143 yield* writeTextFile(pictures, "custom.png", "image");
144 const externalPath = path.join(pictures, "custom.png");
145
146 const resolved = yield* resolver.resolvePath(cwd, externalPath);
147
148 expect(resolved).toBe(externalPath);
149 }),
150 );
151
152 it.effect("falls back when a saved override is missing from a checkout", () =>
153 Effect.gen(function* () {
154 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
155 const cwd = yield* makeTempDir;
156 yield* writeTextFile(cwd, "favicon.svg", "<svg>automatic</svg>");
157
158 const resolved = yield* resolver.resolvePath(cwd, "brand/missing.svg");
159
160 expect(resolved).not.toBeNull();
161 expect(resolved).toContain("favicon.svg");
162 }),
163 );
164
165 it.effect("ignores invalid t3.json files", () =>
166 Effect.gen(function* () {
167 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
168 const cwd = yield* makeTempDir;
169 yield* writeTextFile(cwd, "t3.json", "{ not json");
170 yield* writeTextFile(cwd, "favicon.svg", "<svg>favicon</svg>");
171
172 const resolved = yield* resolver.resolvePath(cwd);
173
174 expect(resolved).not.toBeNull();
175 expect(resolved).toContain("favicon.svg");
176 }),
177 );
178
179 it.effect("does not resolve a t3.json iconPath outside the workspace root", () =>
180 Effect.gen(function* () {
181 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
182 const parent = yield* makeTempDir;
183 const cwd = `${parent}/app`;
184 yield* writeTextFile(parent, "secret.svg", "<svg>secret</svg>");
185 yield* writeTextFile(cwd, "t3.json", '{ "iconPath": "../secret.svg" }');
186
187 const resolved = yield* resolver.resolvePath(cwd);
188
189 expect(resolved).toBeNull();
190 }),
191 );
192
193 it.effect("resolves icon hrefs from project source files", () =>
194 Effect.gen(function* () {
195 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
196 const cwd = yield* makeTempDir;
197 yield* writeTextFile(cwd, "index.html", '<link rel="icon" href="/brand/logo.svg">');
198 yield* writeTextFile(cwd, "public/brand/logo.svg", "<svg>brand</svg>");
199
200 const resolved = yield* resolver.resolvePath(cwd);
201
202 expect(resolved).not.toBeNull();
203 expect(resolved).toBe((yield* Path.Path).join(cwd, "public", "brand", "logo.svg"));
204
205 const paddedCwd = yield* makeTempDir;
206 yield* writeTextFile(
207 paddedCwd,
208 "index.html",
209 '<link rel="icon" href=" brand/custom.svg ">',
210 );
211 yield* writeTextFile(paddedCwd, "brand/custom.svg", "<svg>custom</svg>");
212 expect(yield* resolver.resolvePath(paddedCwd)).toBe(
213 (yield* Path.Path).join(paddedCwd, "brand", "custom.svg"),
214 );
215 }),
216 );
217
218 it.effect("resolves icon hrefs from object-literal route metadata", () =>
219 Effect.gen(function* () {
220 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
221 const cwd = yield* makeTempDir;
222 yield* writeTextFile(
223 cwd,
224 "src/routes/__root.tsx",
225 `export const Route = createRootRoute({
226 head: () => ({
227 links: [
228 { rel: "stylesheet", href: "/app.css" },
229 { rel: "icon", href: "/brand/logo.svg" },
230 ],
231 }),
232});`,
233 );
234 yield* writeTextFile(cwd, "public/brand/logo.svg", "<svg>brand</svg>");
235
236 const resolved = yield* resolver.resolvePath(cwd);
237
238 expect(resolved).not.toBeNull();
239 expect(resolved).toBe((yield* Path.Path).join(cwd, "public", "brand", "logo.svg"));
240 }),
241 );
242
243 // regression: #5538 favicon resolution pinned the event loop
244 it.effect("skips icon metadata without an href and keeps scanning", () =>
245 Effect.gen(function* () {
246 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
247 const cwd = yield* makeTempDir;
248 yield* writeTextFile(
249 cwd,
250 "src/root.tsx",
251 `const links = [{ rel: "icon" }, { rel: "icon", href: "/brand/logo.svg" }];`,
252 );
253 yield* writeTextFile(cwd, "public/brand/logo.svg", "<svg>brand</svg>");
254
255 const resolved = yield* resolver.resolvePath(cwd);
256
257 expect(resolved).not.toBeNull();
258 expect(resolved).toBe((yield* Path.Path).join(cwd, "public", "brand", "logo.svg"));
259 }),
260 );
261
262 it.effect("returns a found favicon without waiting for lower-ranked probes", () =>
263 Effect.gen(function* () {
264 const fileSystem = yield* FileSystem.FileSystem;
265 const path = yield* Path.Path;
266 const cwd = yield* makeTempDir;
267 yield* writeTextFile(cwd, "favicon.svg", "<svg>root</svg>");
268 const stuckPath = path.join(cwd, "favicon.ico");
269 const resolver = yield* makeResolverWithFileSystem(
270 FileSystem.FileSystem.of({
271 ...fileSystem,
272 stat: (filePath) => (filePath === stuckPath ? Effect.never : fileSystem.stat(filePath)),
273 }),
274 );
275
276 expect(yield* resolver.resolvePath(cwd)).toBe(path.join(cwd, "favicon.svg"));
277 }),
278 );
279
280 it.effect("skips icon metadata paths outside the workspace", () =>
281 Effect.gen(function* () {
282 const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver;
283 const cwd = yield* makeTempDir;
284 yield* writeTextFile(cwd, "index.html", '<link rel="icon" href="../../secret.svg">');
285
286 const resolved = yield* resolver.resolvePath(cwd);
287
288 expect(resolved).toBeNull();
289 }),
290 );
291 });
292});