diff --git a/src/features/housekeeping/foundation/providers/orchestrate.test.ts b/src/features/housekeeping/foundation/providers/orchestrate.test.ts index 41e9bd53fa..a79dc0c2f1 100644 --- a/src/features/housekeeping/foundation/providers/orchestrate.test.ts +++ b/src/features/housekeeping/foundation/providers/orchestrate.test.ts @@ -66,6 +66,68 @@ describe("orchestrateProviders", () => { vi.useRealTimers(); }); + it("preserves a successful sibling when another provider throws synchronously", async () => { + const synchronousFailure: HousekeepingProvider = { + id: "synchronously-broken", + capability: anyCapability("housekeeping.read"), + run: () => { + throw new Error("synchronous dependency failure"); + }, + }; + + expect( + await orchestrateProviders( + [ + synchronousFailure, + provider("fast", [{ key: "fast", label: "Fast", order: 1 }]), + ], + allowedContext, + policy, + ), + ).toMatchObject({ + items: [{ key: "fast", label: "Fast", order: 1 }], + errors: [{ providerId: "synchronously-broken", code: "INTERNAL" }], + }); + }); + + it("keeps the timeout result and sibling success when a provider rejects late", async () => { + vi.useFakeTimers(); + let observedAbort = false; + const lateFailure: HousekeepingProvider = { + id: "late-failure", + capability: anyCapability("housekeeping.read"), + run: async (_context, signal) => + await new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => { + observedAbort = true; + }); + setTimeout(() => reject(new Error("late dependency failure")), 2_001); + }), + }; + + const batch = orchestrateProviders( + [ + lateFailure, + provider("fast", [{ key: "fast", label: "Fast", order: 1 }]), + ], + allowedContext, + policy, + ); + await vi.advanceTimersByTimeAsync(2_000); + + expect(await batch).toMatchObject({ + items: [{ key: "fast", label: "Fast", order: 1 }], + errors: [{ providerId: "late-failure", code: "TIMEOUT" }], + }); + expect(observedAbort).toBe(true); + await vi.advanceTimersByTimeAsync(1); + expect(await batch).toMatchObject({ + items: [{ key: "fast", label: "Fast", order: 1 }], + errors: [{ providerId: "late-failure", code: "TIMEOUT" }], + }); + vi.useRealTimers(); + }); + it("keeps the first duplicate before applying deterministic sorting", async () => { const result = await orchestrateProviders( [ @@ -108,4 +170,20 @@ describe("orchestrateProviders", () => { { key: "one", label: "One", order: 3 }, ]); }); + + it("applies the per-provider cap before cross-provider deduplication", async () => { + const result = await orchestrateProviders( + [ + provider("first", [{ key: "shared", label: "First", order: 1 }]), + provider("second", [ + { key: "shared", label: "Second", order: 2 }, + { key: "unique", label: "Unique", order: 3 }, + ]), + ], + allowedContext, + { ...policy, perProviderLimit: 1, combinedLimit: 5 }, + ); + + expect(result.items).toEqual([{ key: "shared", label: "First", order: 1 }]); + }); }); diff --git a/src/features/housekeeping/foundation/providers/run-provider.test.ts b/src/features/housekeeping/foundation/providers/run-provider.test.ts index d72c6b9ebd..1afb82f4c6 100644 --- a/src/features/housekeeping/foundation/providers/run-provider.test.ts +++ b/src/features/housekeeping/foundation/providers/run-provider.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { anyCapability, + fail, type HousekeepingCapabilityContext, ok, } from "../contracts"; @@ -75,4 +76,75 @@ describe("runProvider", () => { await runProvider(provider, context(["housekeeping.read"]), 2_000), ).toEqual({ providerId: "broken", items: [], error: "INTERNAL" }); }); + + it("maps a synchronous provider throw to one INTERNAL partial error", async () => { + const provider: HousekeepingProvider = { + id: "synchronously-broken", + capability: anyCapability("housekeeping.read"), + run: () => { + throw new Error("synchronous dependency failure"); + }, + }; + + await expect( + runProvider(provider, context(["housekeeping.read"]), 2_000), + ).resolves.toEqual({ + providerId: "synchronously-broken", + items: [], + error: "INTERNAL", + }); + }); + + it("times out an abort-ignoring provider exactly at 2,000 ms", async () => { + vi.useFakeTimers(); + let observedAbort = false; + const provider: HousekeepingProvider = { + id: "abort-ignoring", + capability: anyCapability("housekeeping.read"), + run: async (_context, signal) => { + signal.addEventListener("abort", () => { + observedAbort = true; + }); + return await new Promise(() => undefined); + }, + }; + + const result = runProvider(provider, context(["housekeeping.read"]), 2_000); + let settled = false; + void result.then(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(1_999); + expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + + expect(await result).toEqual({ + providerId: "abort-ignoring", + items: [], + error: "TIMEOUT", + }); + expect(observedAbort).toBe(true); + vi.useRealTimers(); + }); + + it("preserves a returned typed provider failure code once", async () => { + const provider: HousekeepingProvider = { + id: "dependency-failure", + capability: anyCapability("housekeeping.read"), + run: async () => + fail( + "DEPENDENCY_UNAVAILABLE", + "errors.housekeeping.dependencyUnavailable", + "provider-correlation", + ), + }; + + expect( + await runProvider(provider, context(["housekeeping.read"]), 2_000), + ).toEqual({ + providerId: "dependency-failure", + items: [], + error: "DEPENDENCY_UNAVAILABLE", + }); + }); }); diff --git a/src/features/housekeeping/foundation/providers/run-provider.ts b/src/features/housekeeping/foundation/providers/run-provider.ts index f64ae06ae0..295dc5cc27 100644 --- a/src/features/housekeeping/foundation/providers/run-provider.ts +++ b/src/features/housekeeping/foundation/providers/run-provider.ts @@ -32,8 +32,8 @@ export async function runProvider( const controller = new AbortController(); let timeout: ReturnType | undefined; - const run = provider - .run(context, controller.signal) + const run = Promise.resolve() + .then(() => provider.run(context, controller.signal)) .then( (result): ProviderRunResult => result.ok