import { describe, expect, it, vi } from "vitest"; import { createToolcraftArtifactExportOwner } from "./artifact-export-owner"; function deferred() { let resolve!: (value: T) => void; let reject!: (error: unknown) => void; const promise = new Promise((accept, fail) => { resolve = accept; reject = fail; }); return { promise, reject, resolve }; } describe("artifact export owner", () => { it("admits synchronously and keeps one slot until the callback settles", async () => { const owner = createToolcraftArtifactExportOwner(); const pending = deferred(); const run = vi.fn(() => pending.promise); const first = owner.start(run); expect(first.status).toBe("started"); expect(run).toHaveBeenCalledOnce(); const duplicate = vi.fn(); expect(owner.start(duplicate)).toEqual({ status: "busy" }); expect(duplicate).not.toHaveBeenCalled(); pending.resolve("artifact"); if (first.status !== "started") throw new Error("Expected admission"); await expect(first.completion).resolves.toEqual({ status: "completed", value: "artifact" }); expect(owner.getStatus()).toEqual({ phase: "idle" }); }); it("does not share admission between independent owners", async () => { const pending = deferred(); const first = createToolcraftArtifactExportOwner().start(() => pending.promise); const second = createToolcraftArtifactExportOwner().start(() => pending.promise); expect(first.status).toBe("started"); expect(second.status).toBe("started"); pending.resolve(); if (first.status !== "started" || second.status !== "started") throw new Error("Expected admission"); await Promise.all([first.completion, second.completion]); }); it("keeps cancelling busy until cooperative work and cleanup settle", async () => { const owner = createToolcraftArtifactExportOwner(); const pending = deferred(); const cleanup = deferred(); let signal: AbortSignal | undefined; const start = owner.start(async (context) => { signal = context.signal; try { await pending.promise; } finally { await cleanup.promise; } return "late artifact"; }); owner.cancel(); owner.cancel(); expect(signal?.aborted).toBe(true); expect(owner.getStatus()).toEqual({ phase: "cancelling", progress: 0 }); pending.resolve(); await Promise.resolve(); expect(owner.start(vi.fn())).toEqual({ status: "busy" }); cleanup.resolve(); if (start.status !== "started") throw new Error("Expected admission"); await expect(start.completion).resolves.toEqual({ status: "cancelled" }); expect(owner.getStatus()).toEqual({ phase: "idle" }); }); it("rejects real failures even when cancellation has also been requested", async () => { const owner = createToolcraftArtifactExportOwner(); const pending = deferred(); const start = owner.start(() => pending.promise); owner.cancel(); const failure = new Error("encoder failed"); pending.reject(failure); if (start.status !== "started") throw new Error("Expected admission"); await expect(start.completion).rejects.toBe(failure); expect(owner.getStatus()).toEqual({ phase: "idle" }); }); it("recognizes signal cancellation and releases the slot after synchronous failure", async () => { const owner = createToolcraftArtifactExportOwner(); const failure = new Error("startup failed"); const failed = owner.start(() => { throw failure; }); if (failed.status !== "started") throw new Error("Expected admission"); await expect(failed.completion).rejects.toBe(failure); const cancelled = owner.start(async ({ signal }) => { owner.cancel(); signal.throwIfAborted(); }); if (cancelled.status !== "started") throw new Error("Expected admission"); await expect(cancelled.completion).resolves.toEqual({ status: "cancelled" }); }); it("does not mistake an unrelated AbortError for its own cancellation", async () => { const owner = createToolcraftArtifactExportOwner(); const pending = deferred(); const start = owner.start(() => pending.promise); owner.cancel(); const unrelated = new DOMException("A different operation failed", "AbortError"); pending.reject(unrelated); if (start.status !== "started") throw new Error("Expected admission"); await expect(start.completion).rejects.toBe(unrelated); }); it("keeps admission and disposal independent of throwing subscribers", async () => { const owner = createToolcraftArtifactExportOwner(); const pending = deferred(); owner.subscribe(() => { throw new Error("observer failed"); }); const healthy = vi.fn(); owner.subscribe(healthy); const run = vi.fn(() => pending.promise); const start = owner.start(run); expect(run).toHaveBeenCalledOnce(); expect(owner.start(vi.fn())).toEqual({ status: "busy" }); const disposal = owner.dispose(); pending.resolve(); if (start.status !== "started") throw new Error("Expected admission"); await expect(start.completion).resolves.toEqual({ status: "cancelled" }); await disposal; expect(owner.getStatus()).toEqual({ phase: "disposed" }); expect(healthy).toHaveBeenCalledTimes(3); }); it("uses a terminal disposing phase and shares its idempotent cleanup promise", async () => { const owner = createToolcraftArtifactExportOwner(); const pending = deferred(); const start = owner.start(() => pending.promise); const disposed = owner.dispose(); expect(owner.dispose()).toBe(disposed); expect(owner.getStatus()).toEqual({ phase: "disposing", progress: 0 }); expect(() => owner.start(vi.fn())).toThrowError(expect.objectContaining({ feedback: expect.objectContaining({ code: "export-owner-disposed" }), })); pending.resolve(); await disposed; expect(owner.getStatus()).toEqual({ phase: "disposed" }); if (start.status !== "started") throw new Error("Expected admission"); await expect(start.completion).resolves.toEqual({ status: "cancelled" }); expect(() => owner.start(vi.fn())).toThrow(); }); it("bounds monotonic progress and ignores stale or cancelled callbacks", async () => { const owner = createToolcraftArtifactExportOwner(); const pending = deferred(); let report = (_value: number) => {}; const listener = vi.fn(); const unsubscribe = owner.subscribe(listener); const start = owner.start(({ reportProgress }) => { report = reportProgress; return pending.promise; }); report(0.6); report(0.2); report(Number.NaN); expect(owner.getStatus()).toEqual({ phase: "running", progress: 0.6 }); report(2); expect(owner.getStatus()).toEqual({ phase: "running", progress: 1 }); owner.cancel(); const count = listener.mock.calls.length; report(0.9); expect(listener).toHaveBeenCalledTimes(count); pending.resolve(); if (start.status !== "started") throw new Error("Expected admission"); await start.completion; report(0.8); expect(owner.getStatus()).toEqual({ phase: "idle" }); unsubscribe(); }); it("reserves admission before notifying subscribers", async () => { const owner = createToolcraftArtifactExportOwner(); const duplicate = vi.fn(); owner.subscribe(() => { if (owner.getStatus().phase === "running") { expect(owner.start(duplicate)).toEqual({ status: "busy" }); } }); const start = owner.start(() => "artifact"); if (start.status !== "started") throw new Error("Expected admission"); await start.completion; expect(duplicate).not.toHaveBeenCalled(); }); it("notifies re-subscribing observers once per publication", async () => { const owner = createToolcraftArtifactExportOwner(); let unsubscribe = () => {}; const observer = vi.fn(() => { unsubscribe(); // Bound the regression fixture so a live-Set implementation fails instead of hanging. if (observer.mock.calls.length <= 5) unsubscribe = owner.subscribe(observer); }); unsubscribe = owner.subscribe(observer); const run = vi.fn(() => "artifact"); const start = owner.start(run); if (start.status !== "started") throw new Error("Expected admission"); await start.completion; expect(run).toHaveBeenCalledOnce(); expect(observer).toHaveBeenCalledTimes(2); unsubscribe(); }); });