| 1 | import { assert, it } from "@effect/vitest"; |
| 2 | import type { RepositoryIdentity } from "@t3tools/contracts"; |
| 3 | import * as Deferred from "effect/Deferred"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Logger from "effect/Logger"; |
| 7 | import * as PubSub from "effect/PubSub"; |
| 8 | import * as Ref from "effect/Ref"; |
| 9 | import { TestClock } from "effect/testing"; |
| 10 | |
| 11 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 12 | |
| 13 | import * as ProjectEnrichment from "./ProjectEnrichmentService.ts"; |
| 14 | import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; |
| 15 | import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; |
| 16 | |
| 17 | const 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 | |
| 27 | const 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 | |
| 40 | const 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 | |
| 51 | it.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 | |
| 101 | it.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 | |
| 153 | it.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 | |
| 180 | it.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 | |
| 227 | it.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 | |
| 268 | it.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 | |
| 331 | it.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 | |
| 401 | it.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 | |
| 445 | it.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 | ); |