| ... | @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; | ... | @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; | 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as PubSub from "effect/PubSub"; | 6 | import * as PubSub from "effect/PubSub"; |
| 7 | import * as Ref from "effect/Ref"; | 7 | import * as Ref from "effect/Ref"; |
| | 8 | import { TestClock } from "effect/testing"; |
| 8 | | 9 | |
| 9 | import * as ProjectEnrichment from "./ProjectEnrichmentService.ts"; | 10 | import * as ProjectEnrichment from "./ProjectEnrichmentService.ts"; |
| 10 | import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; | 11 | import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; |
| ... | @@ -341,3 +342,92 @@ it.effect("deduplicates requests, bounds pending work, and reloads invalidated r | ... | @@ -341,3 +342,92 @@ it.effect("deduplicates requests, bounds pending work, and reloads invalidated r |
| 341 | ); | 342 | ); |
| 342 | }), | 343 | }), |
| 343 | ); | 344 | ); |
| | 345 | |
| | 346 | it.effect("rescans a favicon only after 15 minutes", () => |
| | 347 | Effect.gen(function* () { |
| | 348 | const faviconScans = yield* Ref.make(0); |
| | 349 | const layerMetadata = Layer.merge( |
| | 350 | Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { |
| | 351 | resolve: (workspaceRoot) => Effect.succeed(identity(workspaceRoot)), |
| | 352 | }), |
| | 353 | Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { |
| | 354 | // Each scan returns a new path, so a wait can tell a rescan from the cached value. |
| | 355 | resolvePath: (workspaceRoot) => |
| | 356 | Ref.updateAndGet(faviconScans, (count) => count + 1).pipe( |
| | 357 | Effect.map((scan) => `${workspaceRoot}/favicon-${scan}.svg`), |
| | 358 | ), |
| | 359 | }), |
| | 360 | ); |
| | 361 | |
| | 362 | yield* Effect.gen(function* () { |
| | 363 | const service = yield* ProjectEnrichment.ProjectEnrichmentService; |
| | 364 | yield* service.getAvailable("/repo"); |
| | 365 | yield* waitForAvailable( |
| | 366 | service, |
| | 367 | "/repo", |
| | 368 | (value) => value.faviconPath === "/repo/favicon-1.svg", |
| | 369 | ); |
| | 370 | |
| | 371 | // Callers such as the shell stream read projects far more often than this. |
| | 372 | for (let minute = 1; minute < 15; minute += 1) { |
| | 373 | yield* TestClock.adjust("1 minute"); |
| | 374 | yield* service.getAvailable("/repo"); |
| | 375 | } |
| | 376 | assert.equal(yield* Ref.get(faviconScans), 1); |
| | 377 | |
| | 378 | yield* TestClock.adjust("1 minute"); |
| | 379 | yield* service.getAvailable("/repo"); |
| | 380 | yield* waitForAvailable( |
| | 381 | service, |
| | 382 | "/repo", |
| | 383 | (value) => value.faviconPath === "/repo/favicon-2.svg", |
| | 384 | ); |
| | 385 | assert.equal(yield* Ref.get(faviconScans), 2); |
| | 386 | }).pipe(Effect.provide(layer(layerMetadata))); |
| | 387 | }), |
| | 388 | ); |
| | 389 | |
| | 390 | it.effect( |
| | 391 | "follows repository identity changes within a minute without rescanning the favicon", |
| | 392 | () => |
| | 393 | Effect.gen(function* () { |
| | 394 | // 0: no repository yet, then one version per remote. |
| | 395 | const remoteVersion = yield* Ref.make(0); |
| | 396 | const faviconScans = yield* Ref.make(0); |
| | 397 | const layerMetadata = Layer.merge( |
| | 398 | Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { |
| | 399 | resolve: (workspaceRoot) => |
| | 400 | Ref.get(remoteVersion).pipe( |
| | 401 | Effect.map((version) => (version === 0 ? null : identity(workspaceRoot, version))), |
| | 402 | ), |
| | 403 | }), |
| | 404 | Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { |
| | 405 | resolvePath: () => Ref.update(faviconScans, (count) => count + 1).pipe(Effect.as(null)), |
| | 406 | }), |
| | 407 | ); |
| | 408 | |
| | 409 | yield* Effect.gen(function* () { |
| | 410 | const service = yield* ProjectEnrichment.ProjectEnrichmentService; |
| | 411 | yield* service.getAvailable("/folder"); |
| | 412 | yield* waitForAvailable(service, "/folder", (value) => value.repositoryIdentityResolved); |
| | 413 | assert.equal((yield* service.peek("/folder")).repositoryIdentity, null); |
| | 414 | |
| | 415 | // The folder is published as a repository, then its remote moves to another host. |
| | 416 | for (const version of [1, 2]) { |
| | 417 | yield* Ref.set(remoteVersion, version); |
| | 418 | yield* TestClock.adjust("1 minute"); |
| | 419 | yield* service.getAvailable("/folder"); |
| | 420 | const available = yield* waitForAvailable( |
| | 421 | service, |
| | 422 | "/folder", |
| | 423 | (value) => value.repositoryIdentity?.canonicalKey === `example.test/v${version}/folder`, |
| | 424 | ); |
| | 425 | assert.equal( |
| | 426 | available.repositoryIdentity?.canonicalKey, |
| | 427 | `example.test/v${version}/folder`, |
| | 428 | ); |
| | 429 | } |
| | 430 | assert.equal(yield* Ref.get(faviconScans), 1); |
| | 431 | }).pipe(Effect.provide(layer(layerMetadata))); |
| | 432 | }), |
| | 433 | ); |