import { describe, expect, it, vi } from "vitest"; import { createToolcraftSourceAssetCoordinator, } from "./source-asset-coordinator"; import { createFileHandler, createLease, createRepository, createStateHarness, deferred, fileControl, fileDraft, prepared, testFile, } from "./source-asset-coordinator-test-support"; import { createToolcraftSourceAssetRegistry } from "./source-asset-registry"; function abortError(): Error { const error = new Error("cancelled after cleanup"); error.name = "AbortError"; return error; } function prepareUntilAbort( signal: AbortSignal, abortSeen: ReturnType>, cleanup: ReturnType>, ): Promise { return new Promise((_resolve, reject) => { signal.addEventListener( "abort", () => { abortSeen.resolve(undefined); void cleanup.promise.then(() => reject(abortError())); }, { once: true }, ); }); } describe("source asset coordinator lifecycle", () => { it("keeps a successful job independent from operation subscriber failures", async () => { const harness = createStateHarness(); const handler = createFileHandler(async ({ reportOperation }) => { reportOperation({ phase: "staging", progress: 0.5 }); return prepared(fileDraft("one.txt")); }); const coordinator = createToolcraftSourceAssetCoordinator({ dispatch: harness.dispatch, getState: harness.getState, registry: createToolcraftSourceAssetRegistry([handler]), repository: createRepository([createLease()]), }); const healthyListener = vi.fn(); coordinator.subscribe(() => { throw new Error("subscriber failed"); }); coordinator.subscribe(healthyListener); await expect( coordinator.importBatch( { files: [testFile("one.txt")], origin: "panel" }, fileControl(), ), ).resolves.toMatchObject({ kind: "committed" }); expect(healthyListener.mock.calls.length).toBeGreaterThanOrEqual(3); expect(harness.getState().mediaAssets).toHaveLength(1); }); it("retains a failed rollback and retries it before later work", async () => { const harness = createStateHarness(); const rollback = vi.fn() .mockRejectedValueOnce(new Error("rollback unavailable")) .mockResolvedValueOnce(undefined); const failedLease = createLease({ rollback }); const successfulLease = createLease(); const handler = createFileHandler(async ({ batch }) => { if (batch.files[0]?.name === "failed.txt") { throw new Error("prepare failed"); } return prepared(fileDraft("kept.txt")); }); const coordinator = createToolcraftSourceAssetCoordinator({ dispatch: harness.dispatch, getState: harness.getState, registry: createToolcraftSourceAssetRegistry([handler]), repository: createRepository([failedLease, successfulLease]), }); await expect( coordinator.importBatch( { files: [testFile("failed.txt")], origin: "panel" }, fileControl(), ), ).resolves.toMatchObject({ kind: "rejected" }); await expect( coordinator.importBatch( { files: [testFile("kept.txt")], origin: "panel" }, fileControl(), ), ).resolves.toMatchObject({ kind: "committed" }); expect(rollback).toHaveBeenCalledTimes(2); }); it("retries failed collection during disposal", async () => { const harness = createStateHarness(); const collect = vi.fn() .mockRejectedValueOnce(new Error("collection unavailable")) .mockResolvedValueOnce([]); const handler = createFileHandler(async () => prepared(fileDraft("one.txt"))); const repository = createRepository([createLease()], { collect }); const coordinator = createToolcraftSourceAssetCoordinator({ dispatch: harness.dispatch, getState: harness.getState, registry: createToolcraftSourceAssetRegistry([handler]), repository, }); await expect( coordinator.importBatch( { files: [testFile("one.txt")], origin: "panel" }, fileControl(), ), ).resolves.toMatchObject({ kind: "committed" }); await expect(coordinator.dispose()).resolves.toBeUndefined(); expect(collect).toHaveBeenCalledTimes(2); expect(repository.dispose).toHaveBeenCalledTimes(1); }); it("rejects disposal when retained cleanup remains irrecoverable", async () => { const harness = createStateHarness(); const rollback = vi.fn(async () => { throw new Error("rollback permanently unavailable"); }); const handler = createFileHandler(async () => { throw new Error("prepare failed"); }); const repository = createRepository([createLease({ rollback })]); const coordinator = createToolcraftSourceAssetCoordinator({ dispatch: harness.dispatch, getState: harness.getState, registry: createToolcraftSourceAssetRegistry([handler]), repository, }); await coordinator.importBatch( { files: [testFile("failed.txt")], origin: "panel" }, fileControl(), ); await expect(coordinator.dispose()).rejects.toThrow(/cleanup/u); expect(rollback).toHaveBeenCalledTimes(2); expect(repository.dispose).toHaveBeenCalledTimes(1); }); it("waits for superseded preparation cleanup before reporting cancellation", async () => { const harness = createStateHarness(); const cleanup = deferred(); const abortSeen = deferred(); const handler = createFileHandler(async ({ batch, signal }) => batch.files[0]?.name === "old.txt" ? prepareUntilAbort(signal, abortSeen, cleanup) : prepared(fileDraft("new.txt")), ); const coordinator = createToolcraftSourceAssetCoordinator({ dispatch: harness.dispatch, getState: harness.getState, registry: createToolcraftSourceAssetRegistry([handler]), repository: createRepository([createLease(), createLease()]), }); const oldImport = coordinator.importBatch( { files: [testFile("old.txt")], origin: "panel" }, fileControl(), ); await vi.waitFor(() => expect(handler.prepare).toHaveBeenCalledTimes(1)); const newImport = coordinator.importBatch( { files: [testFile("new.txt")], origin: "panel" }, fileControl(), ); await abortSeen.promise; let oldSettled = false; void oldImport.then(() => { oldSettled = true; }); await new Promise((resolve) => window.setTimeout(resolve, 0)); expect(oldSettled).toBe(false); await expect(newImport).resolves.toMatchObject({ kind: "committed" }); cleanup.resolve(undefined); await expect(oldImport).resolves.toEqual({ kind: "cancelled" }); }); it("waits for preparation cleanup before disposal settles", async () => { const harness = createStateHarness(); const cleanup = deferred(); const abortSeen = deferred(); const handler = createFileHandler(({ signal }) => prepareUntilAbort(signal, abortSeen, cleanup), ); const coordinator = createToolcraftSourceAssetCoordinator({ dispatch: harness.dispatch, getState: harness.getState, registry: createToolcraftSourceAssetRegistry([handler]), repository: createRepository([createLease()]), }); const importPromise = coordinator.importBatch( { files: [testFile("pending.txt")], origin: "panel" }, fileControl(), ); await vi.waitFor(() => expect(handler.prepare).toHaveBeenCalledTimes(1)); const disposePromise = coordinator.dispose(); await abortSeen.promise; let disposeSettled = false; void disposePromise.then(() => { disposeSettled = true; }); await new Promise((resolve) => window.setTimeout(resolve, 0)); expect(disposeSettled).toBe(false); cleanup.resolve(undefined); await expect(importPromise).resolves.toEqual({ kind: "cancelled" }); await expect(disposePromise).resolves.toBeUndefined(); }); });