import { timelineModule } from "../modules/built-ins/timeline/timeline-module"; import { describe, expect, it, vi } from "vitest"; import { defineToolcraft } from "../schema/define-toolcraft"; import { createToolcraftState } from "../state/create-template-state"; import { defaultToolcraftRuntimeSceneVisibility } from "../scene"; import { createToolcraftArtifactSnapshot } from "./artifact-export-snapshot"; import { exportToolcraftImageArtifact } from "./image-artifact-export"; import { exportToolcraftSvgArtifact } from "./svg-artifact-export"; import { exportToolcraftVideoArtifact } from "./video-artifact-export"; function deferred() { let resolve!: () => void; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; } function fixture() { const controller = new AbortController(); const context = { clearRect: vi.fn(), fillRect: vi.fn(), fillStyle: "", restore: vi.fn(), save: vi.fn(), setTransform: vi.fn(), }; const toBlob = vi.fn((callback: BlobCallback) => callback(new Blob(["pixels"])), ); const canvas = { getContext: () => context, height: 0, width: 0, toBlob, } as unknown as HTMLCanvasElement; const downloadArtifact = vi.fn(() => ({ fileName: "artifact" })); const cancel = vi.fn(async () => undefined); const addFrame = vi.fn<() => Promise>(async () => undefined); const finalize = vi.fn(async () => new Blob(["video"])); const request = { backendFactory: vi.fn(async () => ({ addFrame, cancel, extension: ".mp4" as const, finalize, mediaType: "video/mp4" as const, })), boundsProvider: undefined, canvasFactory: vi.fn(() => canvas), downloadArtifact, exportRenderer: { baseFileName: "image", renderFrame: vi.fn<() => Promise>(async () => undefined), }, renderRuntimeScene: vi.fn(async () => undefined), rendererPipeline: null, reportProgress: vi.fn<(progress: number) => void>(), signal: controller.signal, state: createToolcraftArtifactSnapshot( createToolcraftState( defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft fixture", }, canvas: { enabled: true }, panels: {}, }, modules: [ timelineModule({ mode: "playback", defaultDurationSeconds: 1 }), ], }), ), ), svgExportRenderer: { baseFileName: "vector", renderFrame: vi.fn<() => Promise>(async () => undefined), }, visibility: defaultToolcraftRuntimeSceneVisibility, yieldToBrowser: async () => undefined, }; return { addFrame, cancel, canvas, controller, downloadArtifact, finalize, request, toBlob, }; } describe("artifact export cancellation boundaries", () => { it.each([ ["image", exportToolcraftImageArtifact], ["svg", exportToolcraftSvgArtifact], ["video", exportToolcraftVideoArtifact], ] as const)( "rejects %s before any work when already cancelled", async (_name, exporter) => { const { controller, request } = fixture(); controller.abort(); await expect(exporter(request)).rejects.toBe(controller.signal.reason); expect(request.canvasFactory).not.toHaveBeenCalled(); expect(request.backendFactory).not.toHaveBeenCalled(); expect(request.downloadArtifact).not.toHaveBeenCalled(); }, ); it("does not encode after a cancelled product renderer eventually settles", async () => { const { controller, request, toBlob } = fixture(); const entered = deferred(); const finish = deferred(); request.exportRenderer.renderFrame.mockImplementation(async () => { entered.resolve(); await finish.promise; }); const result = exportToolcraftImageArtifact(request); await entered.promise; controller.abort(); finish.resolve(); await expect(result).rejects.toBe(controller.signal.reason); expect(toBlob).not.toHaveBeenCalled(); expect(request.downloadArtifact).not.toHaveBeenCalled(); }); it("does not allocate video resources when cancelled across the planning await", async () => { const { controller, request } = fixture(); const result = exportToolcraftVideoArtifact(request); controller.abort(); await expect(result).rejects.toBe(controller.signal.reason); expect(request.canvasFactory).not.toHaveBeenCalled(); expect(request.backendFactory).not.toHaveBeenCalled(); expect(request.downloadArtifact).not.toHaveBeenCalled(); }); it("discards a late toBlob result after cancellation", async () => { const { canvas, controller, request, toBlob } = fixture(); const entered = deferred(); let finish!: BlobCallback; toBlob.mockImplementation((callback) => { finish = callback; entered.resolve(); }); const result = exportToolcraftImageArtifact(request); await entered.promise; controller.abort(); expect(canvas.width).toBeGreaterThan(1); finish(new Blob(["late pixels"])); await expect(result).rejects.toBe(controller.signal.reason); expect(request.downloadArtifact).not.toHaveBeenCalled(); expect(canvas.width).toBe(1); }); it("does not start image encoding when a progress observer cancels", async () => { const { controller, request, toBlob } = fixture(); const reason = new DOMException("Cancelled before encoding", "AbortError"); request.reportProgress.mockImplementation((progress) => { if (progress === 0.75) controller.abort(reason); }); await expect(exportToolcraftImageArtifact(request)).rejects.toBe(reason); expect(toBlob).not.toHaveBeenCalled(); }); it("does not invoke the SVG renderer when a progress observer cancels", async () => { const { controller, request } = fixture(); const reason = new DOMException( "Cancelled before SVG rendering", "AbortError", ); request.reportProgress.mockImplementation((progress) => { if (progress === 0.1) controller.abort(reason); }); await expect(exportToolcraftSvgArtifact(request)).rejects.toBe(reason); expect(request.svgExportRenderer.renderFrame).not.toHaveBeenCalled(); }); it.each(["addFrame", "finalize"] as const)( "waits for cancelled video %s without downloading", async (phase) => { const { addFrame, cancel, canvas, controller, finalize, request } = fixture(); const entered = deferred(); const finish = deferred(); if (phase === "addFrame") { addFrame.mockImplementation(async () => { entered.resolve(); await finish.promise; }); } else { finalize.mockImplementation(async () => { entered.resolve(); await finish.promise; return new Blob(["late"]); }); } const result = exportToolcraftVideoArtifact(request); await entered.promise; controller.abort(); expect(canvas.width).toBeGreaterThan(1); expect(cancel).not.toHaveBeenCalled(); finish.resolve(); await expect(result).rejects.toBe(controller.signal.reason); expect(request.downloadArtifact).not.toHaveBeenCalled(); expect(cancel).toHaveBeenCalledTimes(phase === "addFrame" ? 1 : 0); expect(canvas.width).toBe(1); }, ); it("does not serialize or download a cancelled SVG callback", async () => { const { controller, request } = fixture(); const entered = deferred(); const finish = deferred(); request.svgExportRenderer.renderFrame.mockImplementation(async () => { entered.resolve(); await finish.promise; }); const result = exportToolcraftSvgArtifact(request); await entered.promise; controller.abort(); finish.resolve(); await expect(result).rejects.toBe(controller.signal.reason); expect(request.downloadArtifact).not.toHaveBeenCalled(); }); it("rechecks cancellation immediately before the download side effect", async () => { const { controller, request } = fixture(); const reason = new DOMException("Stopped before download", "AbortError"); request.reportProgress.mockImplementation((progress: number) => { if (progress === 0.9) controller.abort(reason); }); await expect(exportToolcraftImageArtifact(request)).rejects.toBe(reason); expect(request.downloadArtifact).not.toHaveBeenCalled(); }); it("does not mask a real renderer failure with concurrent cancellation", async () => { const { controller, request } = fixture(); const failure = new Error("broken renderer"); request.exportRenderer.renderFrame.mockImplementation(async () => { controller.abort(); throw failure; }); await expect(exportToolcraftImageArtifact(request)).rejects.toMatchObject({ cause: failure, feedback: { code: "product-frame-render-failed" }, }); }); });