import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ToolcraftControlSchema } from "../schema/types"; import { toolcraftFileSourceAssetHandler } from "./file-source-asset-handler"; import { toolcraftImageSourceAssetHandler } from "./image-source-asset-handler"; import { deferred, testFile } from "./source-asset-coordinator-test-support"; import type { ToolcraftSourceAssetPrepareContext, } from "./source-asset-types"; const mediaFileMocks = vi.hoisted(() => ({ isToolcraftImageFile: vi.fn(() => true), readImportedFile: vi.fn(), readImportedImageFile: vi.fn(), })); vi.mock("../react/canvas/media-file", () => mediaFileMocks); function createAbortError(): Error { const error = new Error("cancelled"); error.name = "AbortError"; return error; } function rejectAfterAbort( signal: AbortSignal, cleanup: Promise, onAbort: () => void, ): Promise { return new Promise((_, reject) => { const handleAbort = (): void => { onAbort(); void cleanup.then(() => reject(createAbortError())); }; if (signal.aborted) { handleAbort(); return; } signal.addEventListener("abort", handleAbort, { once: true }); }); } function createPrepareContext( kind: "file" | "image", files: readonly File[], ): ToolcraftSourceAssetPrepareContext { const target = `source.${kind}`; const claim = { handlerKind: kind, rootFileNames: files.map((file) => file.name), specificity: 1, }; const control: ToolcraftControlSchema = { assetKind: kind, multiple: true, target, type: "fileDrop", }; return { batch: { files, origin: "panel", target }, canvasFrame: { kind: "finite", size: { height: 200, unit: "px", width: 300 }, }, claim, control, jobId: "source-asset-test", plan: { claim, logicalAssetCount: files.length, replaceExisting: false, sourceTarget: target, target, }, reportOperation: vi.fn(), signal: new AbortController().signal, stageResource: vi.fn(async () => undefined), }; } beforeEach(() => { vi.clearAllMocks(); }); describe("source asset handler sibling cancellation", () => { it("aborts and awaits every file reader sibling after the first failure", async () => { const failure = new Error("primary file read failed"); const siblingCleanup = deferred(); const siblingAbort = vi.fn(); mediaFileMocks.readImportedFile.mockImplementation( (file: File, signal: AbortSignal) => file.name === "broken.txt" ? Promise.reject(failure) : rejectAfterAbort(signal, siblingCleanup.promise, siblingAbort), ); const result = toolcraftFileSourceAssetHandler.prepare( createPrepareContext("file", [ testFile("broken.txt"), testFile("pending.txt"), ]), ); let settled = false; const outcome = result.then( (value) => ({ kind: "resolved" as const, value }), (error: unknown) => ({ error, kind: "rejected" as const }), ); void outcome.then(() => { settled = true; }); await vi.waitFor(() => expect(siblingAbort).toHaveBeenCalledTimes(1)); expect(settled).toBe(false); siblingCleanup.resolve(); await expect(outcome).resolves.toEqual({ error: failure, kind: "rejected", }); }); it("aborts and awaits every image decoder sibling after the first failure", async () => { const failure = new Error("primary image decode failed"); const siblingCleanup = deferred(); const siblingAbort = vi.fn(); mediaFileMocks.readImportedImageFile.mockImplementation( (file: File, _size: unknown, signal: AbortSignal) => file.name === "broken.png" ? Promise.reject(failure) : rejectAfterAbort(signal, siblingCleanup.promise, siblingAbort), ); const result = toolcraftImageSourceAssetHandler.prepare( createPrepareContext("image", [ testFile("broken.png", "image/png"), testFile("pending.png", "image/png"), ]), ); let settled = false; const outcome = result.then( (value) => ({ kind: "resolved" as const, value }), (error: unknown) => ({ error, kind: "rejected" as const }), ); void outcome.then(() => { settled = true; }); await vi.waitFor(() => expect(siblingAbort).toHaveBeenCalledTimes(1)); expect(settled).toBe(false); siblingCleanup.resolve(); await expect(outcome).resolves.toEqual({ error: failure, kind: "rejected", }); }); });