import { describe, expect, it, vi } from "vitest"; import { defineToolcraft } from "../schema/define-toolcraft"; import { createToolcraftState } from "../state/create-template-state"; import type { ToolcraftImageAsset } from "../state/types"; import { createToolcraftArtifactExportResources } from "./artifact-export-resources"; import { createToolcraftArtifactSnapshot } from "./artifact-export-snapshot"; function asset( id: string, resourceRef = `media:image:sha256:${"a".repeat(64)}`, ): ToolcraftImageAsset { return { assetKind: "image", fileName: `${id}.png`, id, layerId: id, lifecycle: "ready", mimeType: "image/png", position: { x: 0, y: 0 }, resourceRef, size: { height: 20, unit: "px", width: 20 }, sourceSize: { height: 20, unit: "px", width: 20 }, }; } function snapshot(assets: ToolcraftImageAsset[]) { return createToolcraftArtifactSnapshot( createToolcraftState( defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft fixture", }, canvas: { enabled: true }, panels: {}, }, modules: [], }), { mediaAssets: assets }, ), ); } function fixture(assets = [asset("one")]) { const controller = new AbortController(); const release = vi.fn(); const retainResourceRef = vi.fn(() => release); const source = document.createElement("canvas"); const closeImage = vi.fn(); const decodeImage = vi.fn(async () => ({ dispose: closeImage, source })); const resolveResource = vi.fn(async () => new Uint8Array([1, 2, 3])); const options = { decodeImage, resolveResource, retainResourceRef, signal: controller.signal, state: snapshot(assets), }; return { closeImage, controller, decodeImage, options, release, resolveResource, retainResourceRef, source, }; } describe("export resources", () => { it("retains refs synchronously and shares one decode across 30 frames and duplicate assets", async () => { const first = asset("one"); const second = { ...asset("two"), position: { x: 50, y: 20 } }; const setup = fixture([first, second]); const resources = createToolcraftArtifactExportResources(setup.options); expect(setup.retainResourceRef).toHaveBeenCalledExactlyOnceWith( first.resourceRef, ); for (let frame = 0; frame < 30; frame++) { const images = await Promise.all([ resources.loadImage(first), resources.loadImage(second), ]); expect(images).toEqual([setup.source, setup.source]); } expect(setup.decodeImage).toHaveBeenCalledOnce(); expect(setup.resolveResource).toHaveBeenCalledOnce(); expect(setup.release).not.toHaveBeenCalled(); await resources.dispose(); await resources.dispose(); expect(setup.closeImage).toHaveBeenCalledOnce(); expect(setup.release).toHaveBeenCalledOnce(); await expect(resources.loadImage(first)).rejects.toThrow("released"); }); it("does not share decoded sources between MIME types, resource refs, or jobs", async () => { const first = asset("one"); const replacement = asset("two", `media:image:sha256:${"b".repeat(64)}`); const setup = fixture([first, replacement]); const resources = createToolcraftArtifactExportResources(setup.options); await resources.loadImage(first); await resources.loadImage({ ...first, mimeType: "image/jpeg" }); await resources.loadImage(replacement); expect(setup.decodeImage).toHaveBeenCalledTimes(3); await resources.dispose(); const next = createToolcraftArtifactExportResources(setup.options); await next.loadImage(first); expect(setup.decodeImage).toHaveBeenCalledTimes(4); await next.dispose(); }); it("keeps references until a late decode after cancellation has actually settled", async () => { const setup = fixture(); let finish!: (value: { source: HTMLCanvasElement; dispose: () => void; }) => void; let decodingStarted!: () => void; const started = new Promise((resolve) => { decodingStarted = resolve; }); const decodeImage = vi.fn( () => new Promise<{ source: HTMLCanvasElement; dispose: () => void; }>((resolve) => { finish = resolve; decodingStarted(); }), ); const resources = createToolcraftArtifactExportResources({ ...setup.options, decodeImage, }); const loading = resources.loadImage(asset("one")); await started; expect(decodeImage).toHaveBeenCalledOnce(); setup.controller.abort(); const disposal = resources.dispose(); expect(resources.dispose()).toBe(disposal); expect(setup.release).not.toHaveBeenCalled(); finish({ dispose: setup.closeImage, source: setup.source }); await expect(loading).rejects.toBe(setup.controller.signal.reason); await disposal; expect(setup.closeImage).toHaveBeenCalledOnce(); expect(setup.release).toHaveBeenCalledOnce(); }); it("releases all references even if image cleanup fails", async () => { const setup = fixture(); setup.closeImage.mockImplementation(() => { throw new Error("close failed"); }); const resources = createToolcraftArtifactExportResources(setup.options); await resources.loadImage(asset("one")); await expect(resources.dispose()).rejects.toThrow(AggregateError); expect(setup.release).toHaveBeenCalledOnce(); }); it("registers pending decoding before a resolver can re-enter disposal", async () => { const setup = fixture(); const events: string[] = []; setup.closeImage.mockImplementation(() => { events.push("image"); }); setup.release.mockImplementation(() => { events.push("ref"); }); let disposal: Promise | undefined; const resources = createToolcraftArtifactExportResources({ ...setup.options, resolveResource: async () => { disposal = resources.dispose(); return new Uint8Array([1]); }, }); await expect(resources.loadImage(asset("one"))).rejects.toThrow("released"); await disposal; expect(events).toEqual(["image", "ref"]); expect(setup.closeImage).toHaveBeenCalledOnce(); }); it("shares the cache when a resolver re-enters the same image load", async () => { const setup = fixture(); let nested: Promise | undefined; const resources = createToolcraftArtifactExportResources({ ...setup.options, resolveResource: async () => { nested = resources.loadImage(asset("one")); return new Uint8Array([1]); }, }); await expect(resources.loadImage(asset("one"))).resolves.toBe(setup.source); await expect(nested).resolves.toBe(setup.source); expect(setup.decodeImage).toHaveBeenCalledOnce(); await resources.dispose(); }); it("rolls back partial retention and admits no work after an initial abort", () => { const first = asset("one"); const second = asset("two", `media:image:sha256:${"b".repeat(64)}`); const setup = fixture([first, second]); setup.retainResourceRef .mockImplementationOnce(() => setup.release) .mockImplementationOnce(() => { throw new Error("retain failed"); }); expect(() => createToolcraftArtifactExportResources(setup.options)).toThrow( "retain failed", ); expect(setup.release).toHaveBeenCalledOnce(); setup.controller.abort(); expect(() => createToolcraftArtifactExportResources(setup.options), ).toThrow(); expect(setup.retainResourceRef).toHaveBeenCalledTimes(2); expect(setup.decodeImage).not.toHaveBeenCalled(); }); });