1import { assert, it } from "@effect/vitest";
2import type { RepositoryIdentity } from "@t3tools/contracts";
3import * as Deferred from "effect/Deferred";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Fiber from "effect/Fiber";
7import * as Layer from "effect/Layer";
8import * as PubSub from "effect/PubSub";
9import * as Ref from "effect/Ref";
10import * as TestClock from "effect/testing/TestClock";
11
12import * as ProjectEnrichment from "./ProjectEnrichmentService.ts";
13import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts";
14import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts";
15
16const identity = (workspaceRoot: string, version = 1): RepositoryIdentity => ({
17 canonicalKey: `example.test/v${version}${workspaceRoot}`,
18 locator: {
19 source: "git-remote",
20 remoteName: "origin",
21 remoteUrl: `https://example.test/v${version}${workspaceRoot}.git`,
22 },
23 rootPath: workspaceRoot,
24});
25
26const waitForAvailable = Effect.fn("ProjectEnrichmentServiceTest.waitForAvailable")(function* (
27 service: ProjectEnrichment.ProjectEnrichmentService["Service"],
28 workspaceRoot: string,
29 predicate: (value: ProjectEnrichment.ProjectEnrichment) => boolean,
30) {
31 return yield* Effect.scoped(
32 Effect.gen(function* () {
33 const changes = yield* service.subscribeChanges;
34 for (;;) {
35 const available = yield* service.peek(workspaceRoot);
36 if (predicate(available)) return available;
37 yield* PubSub.take(changes);
38 }
39 }),
40 );
41});
42
43const layer = (
44 metadataLayer: Layer.Layer<
45 | ProjectFaviconResolver.ProjectFaviconResolver
46 | RepositoryIdentityResolver.RepositoryIdentityResolver
47 >,
48 options: ProjectEnrichment.ProjectEnrichmentServiceOptions = {},
49) =>
50 Layer.effect(ProjectEnrichment.ProjectEnrichmentService, ProjectEnrichment.make(options)).pipe(
51 Layer.provide(metadataLayer),
52 );
53
54it.effect("publishes repository completion while favicon enrichment is still pending", () =>
55 Effect.gen(function* () {
56 const releaseFavicon = yield* Deferred.make<void>();
57 const layerMetadata = Layer.merge(
58 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
59 resolve: (workspaceRoot) => Effect.succeed(identity(workspaceRoot)),
60 }),
61 Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
62 invalidate: () => Effect.void,
63 resolvePath: (workspaceRoot) =>
64 Deferred.await(releaseFavicon).pipe(Effect.as(`${workspaceRoot}/favicon.svg`)),
65 }),
66 );
67
68 yield* Effect.gen(function* () {
69 const service = yield* ProjectEnrichment.ProjectEnrichmentService;
70 const changes = yield* service.subscribeChanges;
71 yield* service.request("/completed");
72
73 const change = yield* PubSub.take(changes);
74 assert.equal(change.workspaceRoot, "/completed");
75 assert.isTrue(change.repositoryIdentityResolved);
76 assert.equal(change.enrichment.repositoryIdentity?.canonicalKey, "example.test/v1/completed");
77 assert.isNull(change.enrichment.faviconPath);
78 }).pipe(Effect.provide(layer(layerMetadata)));
79 }),
80);
81
82it.effect("keeps repository workers available when every favicon worker is hung", () =>
83 Effect.gen(function* () {
84 const faviconWorkersStarted = yield* Deferred.make<void>();
85 const faviconStarts = yield* Ref.make(0);
86 const layerMetadata = Layer.merge(
87 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
88 resolve: (workspaceRoot) => Effect.succeed(identity(workspaceRoot)),
89 }),
90 Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
91 invalidate: () => Effect.void,
92 resolvePath: () =>
93 Effect.gen(function* () {
94 const started = yield* Ref.updateAndGet(faviconStarts, (count) => count + 1);
95 if (started === 2) {
96 yield* Deferred.succeed(faviconWorkersStarted, undefined);
97 }
98 return yield* Effect.never;
99 }),
100 }),
101 );
102
103 yield* Effect.gen(function* () {
104 const service = yield* ProjectEnrichment.ProjectEnrichmentService;
105 yield* service.request("/favicon-hung-a");
106 yield* service.request("/favicon-hung-b");
107 yield* Deferred.await(faviconWorkersStarted);
108
109 yield* service.request("/repository-later");
110 const available = yield* waitForAvailable(
111 service,
112 "/repository-later",
113 (value) => value.repositoryIdentity !== null,
114 );
115
116 assert.equal(available.repositoryIdentity?.canonicalKey, "example.test/v1/repository-later");
117 assert.equal(yield* Ref.get(faviconStarts), 2);
118 }).pipe(
119 Effect.provide(
120 layer(layerMetadata, {
121 cacheCapacity: 8,
122 maxPending: 4,
123 concurrency: 2,
124 }),
125 ),
126 );
127 }),
128);
129
130it.effect(
131 "reports successful cached null as resolved while cold and failed lookups stay unresolved",
132 () =>
133 Effect.gen(function* () {
134 const repositoryStarted = yield* Deferred.make<void>();
135 const releaseRepository = yield* Deferred.make<void>();
136 const layerMetadata = Layer.merge(
137 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
138 resolve: (workspaceRoot) => {
139 if (workspaceRoot === "/no-remote") {
140 return Effect.succeed(null);
141 }
142 if (workspaceRoot === "/fails") {
143 return Effect.die("repository resolver failed");
144 }
145 return Deferred.succeed(repositoryStarted, undefined).pipe(
146 Effect.andThen(Deferred.await(releaseRepository)),
147 Effect.as(identity(workspaceRoot)),
148 );
149 },
150 }),
151 Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
152 invalidate: () => Effect.void,
153 resolvePath: () => Effect.succeed(null),
154 }),
155 );
156
157 yield* Effect.gen(function* () {
158 const service = yield* ProjectEnrichment.ProjectEnrichmentService;
159 const changes = yield* service.subscribeChanges;
160
161 const cold = yield* service.peek("/never-requested");
162 assert.isNull(cold.repositoryIdentity);
163 assert.isFalse(cold.repositoryIdentityResolved);
164
165 const inFlight = yield* service.getAvailable("/in-flight");
166 assert.isNull(inFlight.repositoryIdentity);
167 assert.isFalse(inFlight.repositoryIdentityResolved);
168 yield* Deferred.await(repositoryStarted);
169 assert.isFalse((yield* service.peek("/in-flight")).repositoryIdentityResolved);
170
171 yield* service.request("/no-remote");
172 const nullChange = yield* PubSub.take(changes);
173 assert.equal(nullChange.workspaceRoot, "/no-remote");
174 assert.isTrue(nullChange.repositoryIdentityResolved);
175 assert.isNull(nullChange.enrichment.repositoryIdentity);
176 assert.isTrue(nullChange.enrichment.repositoryIdentityResolved);
177
178 const warmNull = yield* service.getAvailable("/no-remote");
179 assert.isNull(warmNull.repositoryIdentity);
180 assert.isTrue(warmNull.repositoryIdentityResolved);
181
182 yield* service.request("/fails");
183 const failChange = yield* PubSub.take(changes);
184 assert.equal(failChange.workspaceRoot, "/fails");
185 assert.isFalse(failChange.repositoryIdentityResolved);
186 const failed = yield* service.peek("/fails");
187 assert.isNull(failed.repositoryIdentity);
188 assert.isFalse(failed.repositoryIdentityResolved);
189 }).pipe(Effect.provide(layer(layerMetadata)));
190 }),
191);
192
193it.effect("deduplicates requests, bounds pending work, and reloads invalidated roots", () =>
194 Effect.gen(function* () {
195 const firstStarted = yield* Deferred.make<void>();
196 const releaseFirst = yield* Deferred.make<void>();
197 const repositoryCalls = yield* Ref.make<ReadonlyArray<string>>([]);
198 const version = yield* Ref.make(1);
199 const layerMetadata = Layer.merge(
200 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
201 resolve: (workspaceRoot) =>
202 Effect.gen(function* () {
203 yield* Ref.update(repositoryCalls, (calls) => [...calls, workspaceRoot]);
204 if (workspaceRoot === "/first" && (yield* Ref.get(version)) === 1) {
205 yield* Deferred.succeed(firstStarted, undefined);
206 yield* Deferred.await(releaseFirst);
207 }
208 return identity(workspaceRoot, yield* Ref.get(version));
209 }),
210 }),
211 Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
212 invalidate: () => Effect.void,
213 resolvePath: (workspaceRoot) => Effect.succeed(`${workspaceRoot}/favicon.svg`),
214 }),
215 );
216
217 yield* Effect.gen(function* () {
218 const service = yield* ProjectEnrichment.ProjectEnrichmentService;
219 yield* Effect.forEach(Array.from({ length: 20 }), () => service.request("/first"), {
220 concurrency: "unbounded",
221 discard: true,
222 });
223 yield* Deferred.await(firstStarted);
224 yield* service.request("/second");
225 yield* service.request("/dropped");
226 assert.deepEqual(yield* Ref.get(repositoryCalls), ["/first"]);
227
228 yield* Deferred.succeed(releaseFirst, undefined);
229 yield* waitForAvailable(service, "/second", (value) => value.repositoryIdentity !== null);
230 assert.deepEqual(yield* Ref.get(repositoryCalls), ["/first", "/second"]);
231
232 yield* service.request("/dropped");
233 yield* waitForAvailable(service, "/dropped", (value) => value.repositoryIdentity !== null);
234 assert.deepEqual(yield* Ref.get(repositoryCalls), ["/first", "/second", "/dropped"]);
235
236 yield* Ref.set(version, 2);
237 yield* service.invalidate(["/first"]);
238 const invalidated = yield* service.getAvailable("/first");
239 assert.isNull(invalidated.repositoryIdentity);
240 const refreshed = yield* waitForAvailable(
241 service,
242 "/first",
243 (value) => value.repositoryIdentity?.canonicalKey === "example.test/v2/first",
244 );
245 assert.equal(refreshed.repositoryIdentity?.canonicalKey, "example.test/v2/first");
246 assert.deepEqual(yield* Ref.get(repositoryCalls), [
247 "/first",
248 "/second",
249 "/dropped",
250 "/first",
251 ]);
252 }).pipe(
253 Effect.provide(
254 layer(layerMetadata, {
255 cacheCapacity: 8,
256 maxPending: 2,
257 concurrency: 1,
258 }),
259 ),
260 );
261 }),
262);
263
264it.effect(
265 "keeps the last resolved repository identity after it expires and while it refreshes",
266 () =>
267 Effect.gen(function* () {
268 const refreshStarted = yield* Deferred.make<void>();
269 const releaseRefresh = yield* Deferred.make<void>();
270 const resolveCalls = yield* Ref.make(0);
271 const layerMetadata = Layer.merge(
272 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
273 resolve: (workspaceRoot) =>
274 Effect.gen(function* () {
275 if ((yield* Ref.updateAndGet(resolveCalls, (count) => count + 1)) > 1) {
276 yield* Deferred.succeed(refreshStarted, undefined);
277 yield* Deferred.await(releaseRefresh);
278 }
279 return identity(workspaceRoot);
280 }),
281 }),
282 Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
283 invalidate: () => Effect.void,
284 resolvePath: () => Effect.succeed(null),
285 }),
286 );
287
288 yield* Effect.gen(function* () {
289 const service = yield* ProjectEnrichment.ProjectEnrichmentService;
290 const changes = yield* service.subscribeChanges;
291 yield* service.request("/widgets");
292 const resolved = yield* PubSub.take(changes);
293 assert.equal(
294 resolved.enrichment.repositoryIdentity?.canonicalKey,
295 "example.test/v1/widgets",
296 );
297
298 yield* TestClock.adjust(Duration.seconds(61));
299
300 const expired = yield* service.getAvailable("/widgets");
301 assert.equal(
302 expired.repositoryIdentity?.canonicalKey,
303 "example.test/v1/widgets",
304 "an expired repository identity should still be returned until a refresh replaces it",
305 );
306
307 yield* Deferred.await(refreshStarted);
308 const refreshing = yield* service.peek("/widgets");
309 assert.equal(
310 refreshing.repositoryIdentity?.canonicalKey,
311 "example.test/v1/widgets",
312 "a repository identity should still be returned while its refresh is pending",
313 );
314 }).pipe(Effect.provide(layer(layerMetadata)));
315 }),
316);
317
318it.effect("does not let a lookup invalidated mid-flight replace a newer last identity", () =>
319 Effect.gen(function* () {
320 const firstStarted = yield* Deferred.make<void>();
321 const releaseFirst = yield* Deferred.make<void>();
322 const resolveCalls = yield* Ref.make(0);
323 const layerMetadata = Layer.merge(
324 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
325 resolve: (workspaceRoot) =>
326 Effect.gen(function* () {
327 const call = yield* Ref.updateAndGet(resolveCalls, (count) => count + 1);
328 if (call === 1) {
329 yield* Deferred.succeed(firstStarted, undefined);
330 yield* Deferred.await(releaseFirst);
331 }
332 return identity(workspaceRoot, call);
333 }),
334 }),
335 Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
336 invalidate: () => Effect.void,
337 resolvePath: () => Effect.succeed(null),
338 }),
339 );
340
341 yield* Effect.gen(function* () {
342 const service = yield* ProjectEnrichment.ProjectEnrichmentService;
343 const firstLookup = yield* Effect.forkChild(service.readRepositoryIdentity("/widgets"));
344 yield* Deferred.await(firstStarted);
345 yield* service.invalidate(["/widgets"]);
346 const newer = yield* service.readRepositoryIdentity("/widgets");
347 assert.equal(newer?.canonicalKey, "example.test/v2/widgets");
348
349 yield* Deferred.succeed(releaseFirst, undefined);
350 yield* Fiber.join(firstLookup);
351 yield* TestClock.adjust(Duration.seconds(61));
352
353 const expired = yield* service.peek("/widgets");
354 assert.equal(expired.repositoryIdentity?.canonicalKey, "example.test/v2/widgets");
355 }).pipe(Effect.provide(layer(layerMetadata)));
356 }),
357);
358
359it.effect("keeps a lookup's identity when another root is invalidated mid-flight", () =>
360 Effect.gen(function* () {
361 const started = yield* Deferred.make<void>();
362 const release = yield* Deferred.make<void>();
363 const layerMetadata = Layer.merge(
364 Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, {
365 resolve: (workspaceRoot) =>
366 Deferred.succeed(started, undefined).pipe(
367 Effect.andThen(Deferred.await(release)),
368 Effect.as(identity(workspaceRoot)),
369 ),
370 }),
371 Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, {
372 invalidate: () => Effect.void,
373 resolvePath: () => Effect.succeed(null),
374 }),
375 );
376
377 yield* Effect.gen(function* () {
378 const service = yield* ProjectEnrichment.ProjectEnrichmentService;
379 const lookup = yield* Effect.forkChild(service.readRepositoryIdentity("/widgets"));
380 yield* Deferred.await(started);
381 yield* service.invalidate(["/other"]);
382 yield* Deferred.succeed(release, undefined);
383 yield* Fiber.join(lookup);
384 yield* TestClock.adjust(Duration.seconds(61));
385
386 const expired = yield* service.peek("/widgets");
387 assert.equal(expired.repositoryIdentity?.canonicalKey, "example.test/v1/widgets");
388 }).pipe(Effect.provide(layer(layerMetadata)));
389 }),
390);