1import { assert, it } from "@effect/vitest";
2import type { RepositoryIdentity } from "@t3tools/contracts";
3import * as Deferred from "effect/Deferred";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Logger from "effect/Logger";
7import * as PubSub from "effect/PubSub";
8import * as Ref from "effect/Ref";
9import { TestClock } from "effect/testing";
10
11import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
12
13import * as ProjectEnrichment from "./ProjectEnrichmentService.ts";
14import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
15import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts";
16
17const identity = (workspaceRoot: string, version = 1): RepositoryIdentity => ({
18 canonicalKey: `example.test/v${version}${workspaceRoot}`,
19 locator: {
20 source: "git-remote",
21 remoteName: "origin",
22 remoteUrl: `https://example.test/v${version}${workspaceRoot}.git`,
23 },
24 rootPath: workspaceRoot,
25});
26
27const waitForAvailable = Effect.fn("ProjectEnrichmentServiceTest.waitForAvailable")(function* (
28 service: ProjectEnrichment.ProjectEnrichmentService["Service"],
29 workspaceRoot: string,
30 predicate: (value: ProjectEnrichment.ProjectEnrichment) => boolean,
31) {
32 for (let attempt = 0; attempt < 100; attempt += 1) {
33 const available = yield* service.peek(workspaceRoot);
34 if (predicate(available)) return available;
35 yield* Effect.yieldNow;
36 }
37 return yield* Effect.die(`Project metadata for ${workspaceRoot} was not resolved in time.`);
38});
39
40const layer = (
41 metadataLayer: Layer.Layer<
42 | ProjectFaviconResolver.ProjectFaviconResolver
43 | RepositoryIdentityResolver.RepositoryIdentityResolver
44 >,
45 options: ProjectEnrichment.ProjectEnrichmentServiceOptions = {},
46) =>
47 Layer.effect(ProjectEnrichment.ProjectEnrichmentService, ProjectEnrichment.make(options)).pipe(
48 Layer.provide(metadataLayer),
49 );
50
51it.effect("preserves either enrichment field when the other resolver fails", () =>
52 Effect.gen(function* () {
53 const layerMetadata = Layer.merge(
54 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
55 resolve: (workspaceRoot) =>
56 workspaceRoot === "/repo-fails"
57 ? Effect.die("repository resolver failed")
58 : Effect.succeed(identity(workspaceRoot)),
59 }),
60 Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
61 resolvePath: (workspaceRoot) =>
62 workspaceRoot === "/favicon-fails"
63 ? Effect.fail(
64 new ProjectFaviconResolver.ProjectFaviconResolutionError({
65 operation: "stat-candidate",
66 workspaceRoot,
67 cause: "favicon resolver failed",
68 }),
69 )
70 : Effect.succeed(`${workspaceRoot}/favicon.svg`),
71 }),
72 );
73
74 yield* Effect.gen(function* () {
75 const service = yield* ProjectEnrichment.ProjectEnrichmentService;
76 yield* service.request("/favicon-fails");
77 yield* service.request("/repo-fails");
78
79 const faviconFailure = yield* waitForAvailable(
80 service,
81 "/favicon-fails",
82 (value) => value.repositoryIdentity !== null,
83 );
84 assert.equal(
85 faviconFailure.repositoryIdentity?.canonicalKey,
86 "example.test/v1/favicon-fails",
87 );
88 assert.isNull(faviconFailure.faviconPath);
89
90 const repositoryFailure = yield* waitForAvailable(
91 service,
92 "/repo-fails",
93 (value) => value.faviconPath !== null,
94 );
95 assert.isNull(repositoryFailure.repositoryIdentity);
96 assert.equal(repositoryFailure.faviconPath, "/repo-fails/favicon.svg");
97 }).pipe(Effect.provide(layer(layerMetadata)));
98 }),
99);
100
101it.effect("does not warn about favicons for workspace roots that no longer exist", () =>
102 Effect.gen(function* () {
103 const warnings: Array<unknown> = [];
104 const logger = Logger.make(({ message }) => {
105 warnings.push(message);
106 });
107 const metadataLayer = Layer.merge(
108 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
109 resolve: (workspaceRoot) => Effect.succeed(identity(workspaceRoot)),
110 }),
111 Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
112 resolvePath: (workspaceRoot) =>
113 Effect.fail(
114 workspaceRoot === "/missing"
115 ? new ProjectFaviconResolver.ProjectFaviconResolutionError({
116 operation: "normalize-workspace",
117 workspaceRoot,
118 cause: new WorkspacePaths.WorkspaceRootNotExistsError({
119 workspaceRoot,
120 normalizedWorkspaceRoot: workspaceRoot,
121 }),
122 })
123 : new ProjectFaviconResolver.ProjectFaviconResolutionError({
124 operation: "stat-candidate",
125 workspaceRoot,
126 cause: "favicon resolver failed",
127 }),
128 ),
129 }),
130 );
131
132 yield* Effect.gen(function* () {
133 const service = yield* ProjectEnrichment.ProjectEnrichmentService;
134 yield* service.request("/missing");
135 yield* service.request("/broken");
136
137 for (let attempt = 0; attempt < 100 && warnings.length === 0; attempt += 1) {
138 yield* Effect.yieldNow;
139 }
140
141 assert.lengthOf(warnings, 1);
142 assert.nestedPropertyVal(warnings[0], "[1].workspaceRoot", "/broken");
143 }).pipe(
144 Effect.provide(
145 layer(metadataLayer, { concurrency: 1 }).pipe(
146 Layer.provide(Logger.layer([logger], { mergeWithExisting: false })),
147 ),
148 ),
149 );
150 }),
151);
152
153it.effect("publishes repository completion while favicon enrichment is still pending", () =>
154 Effect.gen(function* () {
155 const releaseFavicon = yield* Deferred.make<void>();
156 const layerMetadata = Layer.merge(
157 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
158 resolve: (workspaceRoot) => Effect.succeed(identity(workspaceRoot)),
159 }),
160 Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
161 resolvePath: (workspaceRoot) =>
162 Deferred.await(releaseFavicon).pipe(Effect.as(`${workspaceRoot}/favicon.svg`)),
163 }),
164 );
165
166 yield* Effect.gen(function* () {
167 const service = yield* ProjectEnrichment.ProjectEnrichmentService;
168 const changes = yield* service.subscribeChanges;
169 yield* service.request("/completed");
170
171 const change = yield* PubSub.take(changes);
172 assert.equal(change.workspaceRoot, "/completed");
173 assert.isTrue(change.repositoryIdentityResolved);
174 assert.equal(change.enrichment.repositoryIdentity?.canonicalKey, "example.test/v1/completed");
175 assert.isNull(change.enrichment.faviconPath);
176 }).pipe(Effect.provide(layer(layerMetadata)));
177 }),
178);
179
180it.effect("keeps repository workers available when every favicon worker is hung", () =>
181 Effect.gen(function* () {
182 const faviconWorkersStarted = yield* Deferred.make<void>();
183 const faviconStarts = yield* Ref.make(0);
184 const layerMetadata = Layer.merge(
185 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
186 resolve: (workspaceRoot) => Effect.succeed(identity(workspaceRoot)),
187 }),
188 Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
189 resolvePath: () =>
190 Effect.gen(function* () {
191 const started = yield* Ref.updateAndGet(faviconStarts, (count) => count + 1);
192 if (started === 2) {
193 yield* Deferred.succeed(faviconWorkersStarted, undefined);
194 }
195 return yield* Effect.never;
196 }),
197 }),
198 );
199
200 yield* Effect.gen(function* () {
201 const service = yield* ProjectEnrichment.ProjectEnrichmentService;
202 yield* service.request("/favicon-hung-a");
203 yield* service.request("/favicon-hung-b");
204 yield* Deferred.await(faviconWorkersStarted);
205
206 yield* service.request("/repository-later");
207 const available = yield* waitForAvailable(
208 service,
209 "/repository-later",
210 (value) => value.repositoryIdentity !== null,
211 );
212
213 assert.equal(available.repositoryIdentity?.canonicalKey, "example.test/v1/repository-later");
214 assert.equal(yield* Ref.get(faviconStarts), 2);
215 }).pipe(
216 Effect.provide(
217 layer(layerMetadata, {
218 cacheCapacity: 8,
219 maxPending: 4,
220 concurrency: 2,
221 }),
222 ),
223 );
224 }),
225);
226
227it.effect("getAvailable returns immediately while repository identity is still unresolved", () =>
228 Effect.gen(function* () {
229 const repositoryStarted = yield* Deferred.make<void>();
230 const releaseRepository = yield* Deferred.make<void>();
231 const layerMetadata = Layer.merge(
232 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
233 resolve: (workspaceRoot) =>
234 Deferred.succeed(repositoryStarted, undefined).pipe(
235 Effect.andThen(Deferred.await(releaseRepository)),
236 Effect.as(identity(workspaceRoot)),
237 ),
238 }),
239 Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
240 resolvePath: (workspaceRoot) => Effect.succeed(`${workspaceRoot}/favicon.svg`),
241 }),
242 );
243
244 yield* Effect.gen(function* () {
245 const service = yield* ProjectEnrichment.ProjectEnrichmentService;
246
247 // Non-blocking path must not wait on hung git/identity resolution.
248 const immediate = yield* service.getAvailable("/pending-identity");
249 assert.isNull(immediate.repositoryIdentity);
250 assert.isNull(immediate.faviconPath);
251 assert.isFalse(immediate.repositoryIdentityResolved);
252
253 yield* Deferred.await(repositoryStarted);
254
255 // Background lane still resolves after the snapshot path has returned.
256 yield* Deferred.succeed(releaseRepository, undefined);
257 const resolved = yield* waitForAvailable(
258 service,
259 "/pending-identity",
260 (value) => value.repositoryIdentity !== null,
261 );
262 assert.equal(resolved.repositoryIdentity?.canonicalKey, "example.test/v1/pending-identity");
263 assert.isTrue(resolved.repositoryIdentityResolved);
264 }).pipe(Effect.provide(layer(layerMetadata)));
265 }),
266);
267
268it.effect(
269 "reports successful cached null as resolved while cold and failed lookups stay unresolved",
270 () =>
271 Effect.gen(function* () {
272 const repositoryStarted = yield* Deferred.make<void>();
273 const releaseRepository = yield* Deferred.make<void>();
274 const layerMetadata = Layer.merge(
275 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
276 resolve: (workspaceRoot) => {
277 if (workspaceRoot === "/no-remote") {
278 return Effect.succeed(null);
279 }
280 if (workspaceRoot === "/fails") {
281 return Effect.die("repository resolver failed");
282 }
283 return Deferred.succeed(repositoryStarted, undefined).pipe(
284 Effect.andThen(Deferred.await(releaseRepository)),
285 Effect.as(identity(workspaceRoot)),
286 );
287 },
288 }),
289 Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
290 resolvePath: () => Effect.succeed(null),
291 }),
292 );
293
294 yield* Effect.gen(function* () {
295 const service = yield* ProjectEnrichment.ProjectEnrichmentService;
296 const changes = yield* service.subscribeChanges;
297
298 const cold = yield* service.peek("/never-requested");
299 assert.isNull(cold.repositoryIdentity);
300 assert.isFalse(cold.repositoryIdentityResolved);
301
302 const inFlight = yield* service.getAvailable("/in-flight");
303 assert.isNull(inFlight.repositoryIdentity);
304 assert.isFalse(inFlight.repositoryIdentityResolved);
305 yield* Deferred.await(repositoryStarted);
306 assert.isFalse((yield* service.peek("/in-flight")).repositoryIdentityResolved);
307
308 yield* service.request("/no-remote");
309 const nullChange = yield* PubSub.take(changes);
310 assert.equal(nullChange.workspaceRoot, "/no-remote");
311 assert.isTrue(nullChange.repositoryIdentityResolved);
312 assert.isNull(nullChange.enrichment.repositoryIdentity);
313 assert.isTrue(nullChange.enrichment.repositoryIdentityResolved);
314
315 // Warm success null stays resolved and does not republish.
316 const warmNull = yield* service.getAvailable("/no-remote");
317 assert.isNull(warmNull.repositoryIdentity);
318 assert.isTrue(warmNull.repositoryIdentityResolved);
319
320 yield* service.request("/fails");
321 const failChange = yield* PubSub.take(changes);
322 assert.equal(failChange.workspaceRoot, "/fails");
323 assert.isFalse(failChange.repositoryIdentityResolved);
324 const failed = yield* service.peek("/fails");
325 assert.isNull(failed.repositoryIdentity);
326 assert.isFalse(failed.repositoryIdentityResolved);
327 }).pipe(Effect.provide(layer(layerMetadata)));
328 }),
329);
330
331it.effect("deduplicates requests, bounds pending work, and reloads invalidated roots", () =>
332 Effect.gen(function* () {
333 const firstStarted = yield* Deferred.make<void>();
334 const releaseFirst = yield* Deferred.make<void>();
335 const repositoryCalls = yield* Ref.make<ReadonlyArray<string>>([]);
336 const version = yield* Ref.make(1);
337 const layerMetadata = Layer.merge(
338 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
339 resolve: (workspaceRoot) =>
340 Effect.gen(function* () {
341 yield* Ref.update(repositoryCalls, (calls) => [...calls, workspaceRoot]);
342 if (workspaceRoot === "/first" && (yield* Ref.get(version)) === 1) {
343 yield* Deferred.succeed(firstStarted, undefined);
344 yield* Deferred.await(releaseFirst);
345 }
346 return identity(workspaceRoot, yield* Ref.get(version));
347 }),
348 }),
349 Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
350 resolvePath: (workspaceRoot) => Effect.succeed(`${workspaceRoot}/favicon.svg`),
351 }),
352 );
353
354 yield* Effect.gen(function* () {
355 const service = yield* ProjectEnrichment.ProjectEnrichmentService;
356 yield* Effect.forEach(Array.from({ length: 20 }), () => service.request("/first"), {
357 concurrency: "unbounded",
358 discard: true,
359 });
360 yield* Deferred.await(firstStarted);
361 yield* service.request("/second");
362 yield* service.request("/dropped");
363 assert.deepEqual(yield* Ref.get(repositoryCalls), ["/first"]);
364
365 yield* Deferred.succeed(releaseFirst, undefined);
366 yield* waitForAvailable(service, "/second", (value) => value.repositoryIdentity !== null);
367 assert.deepEqual(yield* Ref.get(repositoryCalls), ["/first", "/second"]);
368
369 yield* service.request("/dropped");
370 yield* waitForAvailable(service, "/dropped", (value) => value.repositoryIdentity !== null);
371 assert.deepEqual(yield* Ref.get(repositoryCalls), ["/first", "/second", "/dropped"]);
372
373 yield* Ref.set(version, 2);
374 yield* service.invalidate(["/first"]);
375 const invalidated = yield* service.getAvailable("/first");
376 assert.isNull(invalidated.repositoryIdentity);
377 const refreshed = yield* waitForAvailable(
378 service,
379 "/first",
380 (value) => value.repositoryIdentity?.canonicalKey === "example.test/v2/first",
381 );
382 assert.equal(refreshed.repositoryIdentity?.canonicalKey, "example.test/v2/first");
383 assert.deepEqual(yield* Ref.get(repositoryCalls), [
384 "/first",
385 "/second",
386 "/dropped",
387 "/first",
388 ]);
389 }).pipe(
390 Effect.provide(
391 layer(layerMetadata, {
392 cacheCapacity: 8,
393 maxPending: 2,
394 concurrency: 1,
395 }),
396 ),
397 );
398 }),
399);
400
401it.effect("rescans a favicon only after 15 minutes", () =>
402 Effect.gen(function* () {
403 const faviconScans = yield* Ref.make(0);
404 const layerMetadata = Layer.merge(
405 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
406 resolve: (workspaceRoot) => Effect.succeed(identity(workspaceRoot)),
407 }),
408 Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
409 // Each scan returns a new path, so a wait can tell a rescan from the cached value.
410 resolvePath: (workspaceRoot) =>
411 Ref.updateAndGet(faviconScans, (count) => count + 1).pipe(
412 Effect.map((scan) => `${workspaceRoot}/favicon-${scan}.svg`),
413 ),
414 }),
415 );
416
417 yield* Effect.gen(function* () {
418 const service = yield* ProjectEnrichment.ProjectEnrichmentService;
419 yield* service.getAvailable("/repo");
420 yield* waitForAvailable(
421 service,
422 "/repo",
423 (value) => value.faviconPath === "/repo/favicon-1.svg",
424 );
425
426 // Callers such as the shell stream read projects far more often than this.
427 for (let minute = 1; minute < 15; minute += 1) {
428 yield* TestClock.adjust("1 minute");
429 yield* service.getAvailable("/repo");
430 }
431 assert.equal(yield* Ref.get(faviconScans), 1);
432
433 yield* TestClock.adjust("1 minute");
434 yield* service.getAvailable("/repo");
435 yield* waitForAvailable(
436 service,
437 "/repo",
438 (value) => value.faviconPath === "/repo/favicon-2.svg",
439 );
440 assert.equal(yield* Ref.get(faviconScans), 2);
441 }).pipe(Effect.provide(layer(layerMetadata)));
442 }),
443);
444
445it.effect(
446 "follows repository identity changes within a minute without rescanning the favicon",
447 () =>
448 Effect.gen(function* () {
449 // 0: no repository yet, then one version per remote.
450 const remoteVersion = yield* Ref.make(0);
451 const faviconScans = yield* Ref.make(0);
452 const layerMetadata = Layer.merge(
453 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
454 resolve: (workspaceRoot) =>
455 Ref.get(remoteVersion).pipe(
456 Effect.map((version) => (version === 0 ? null : identity(workspaceRoot, version))),
457 ),
458 }),
459 Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
460 resolvePath: () => Ref.update(faviconScans, (count) => count + 1).pipe(Effect.as(null)),
461 }),
462 );
463
464 yield* Effect.gen(function* () {
465 const service = yield* ProjectEnrichment.ProjectEnrichmentService;
466 yield* service.getAvailable("/folder");
467 yield* waitForAvailable(service, "/folder", (value) => value.repositoryIdentityResolved);
468 assert.equal((yield* service.peek("/folder")).repositoryIdentity, null);
469
470 // The folder is published as a repository, then its remote moves to another host.
471 for (const version of [1, 2]) {
472 yield* Ref.set(remoteVersion, version);
473 yield* TestClock.adjust("1 minute");
474 yield* service.getAvailable("/folder");
475 const available = yield* waitForAvailable(
476 service,
477 "/folder",
478 (value) => value.repositoryIdentity?.canonicalKey === `example.test/v${version}/folder`,
479 );
480 assert.equal(
481 available.repositoryIdentity?.canonicalKey,
482 `example.test/v${version}/folder`,
483 );
484 }
485 assert.equal(yield* Ref.get(faviconScans), 1);
486 }).pipe(Effect.provide(layer(layerMetadata)));
487 }),
488);