import { describe, expect, it, vi } from "vitest"; import { createToolcraftSourceAssetRuntime } from "../source-assets/source-asset-coordinator"; import { createToolcraftSourceAssetRegistry } from "../source-assets/source-asset-registry"; import { createMemoryToolcraftBinaryAssetRepository } from "../source-assets/repository/memory-binary-asset-repository"; import { createStateHarness, deferred, fileControl } from "../source-assets/source-asset-coordinator-test-support"; import type { ToolcraftSourceAssetRuntimeBinding } from "../source-assets/source-asset-runtime-binding"; import type { ToolcraftSourceAssetImportOutcome } from "../source-assets/source-asset-types"; import { createPaletteHandler } from "./testing/extensibility/palette-source"; type Extension = ReturnType["attach"]>; function createRuntime(hooks: Partial>) { const repository = createMemoryToolcraftBinaryAssetRepository(); const disposeRepository = vi.spyOn(repository, "dispose"); const runtime = createToolcraftSourceAssetRuntime({ ...createStateHarness(false), repository, binding: { registry: createToolcraftSourceAssetRegistry([createPaletteHandler()]), isDefaultReplacementCurrent: () => false, attach: () => ({ services: {}, cancel: () => {}, settle: async () => {}, dispose: () => {}, ...hooks, }), }, }); return { ...runtime, disposeRepository }; } describe("source extension disposal contract", () => { it("keeps the repository alive until an asynchronous extension disposer finishes", async () => { const release = deferred(); const dispose = vi.fn(() => release.promise); const runtime = createRuntime({ dispose }); const disposal = runtime.coordinator.dispose(); try { await vi.waitFor(() => expect(dispose).toHaveBeenCalledOnce()); expect(runtime.disposeRepository).not.toHaveBeenCalled(); } finally { release.resolve(); await disposal; } expect(runtime.disposeRepository).toHaveBeenCalledOnce(); }); it.each(["cancel", "dispose"] as const)("collects an asynchronous %s error and still releases shared resources", async (phase) => { const failure = new Error(`asynchronous ${phase}`); const rejected = Promise.reject(failure); // The test observes the error independently; a broken coordinator must not // leave an unhandled rejection that hides the missing lifecycle assertion. void rejected.catch(() => {}); const runtime = createRuntime({ [phase]: () => rejected }); await expect(runtime.coordinator.dispose()).rejects.toMatchObject({ errors: [failure] }); expect(runtime.disposeRepository).toHaveBeenCalledOnce(); }); it("returns the same disposal promise when cancellation re-enters the coordinator", async () => { let nested: Promise | undefined; let calls = 0; const dispose = vi.fn(); const runtime = createRuntime({ cancel: () => { if (++calls === 1) nested = runtime.coordinator.dispose(); }, dispose, }); const outer = runtime.coordinator.dispose(); await Promise.all([outer, nested]); expect(nested).toBe(outer); expect(calls).toBe(1); expect(dispose).toHaveBeenCalledOnce(); expect(runtime.disposeRepository).toHaveBeenCalledOnce(); }); it("closes import admission before invoking extension cancellation", async () => { let attempted: Promise | undefined; const runtime = createRuntime({ cancel: () => { attempted = runtime.coordinator.importBatch({ files: [new File(['{"version":1,"colors":[]}'], "empty.palette")], origin: "panel", target: "source.files", }, fileControl()); }, }); await runtime.coordinator.dispose(); expect(await attempted).toMatchObject({ kind: "rejected", feedback: { code: "coordinator-disposed" } }); }); });