// @vitest-environment node import { IDBFactory } from "fake-indexeddb"; import { expect, it } from "vitest"; import { defineToolcraft } from "../schema/define-toolcraft"; import { mediaSourceModule } from "../modules/built-ins/media-source/media-source-module"; import { createToolcraftState } from "../state/create-template-state"; import { createToolcraftPersistenceController } from "../composition/public-persistence"; import { parseToolcraftPersistenceSnapshot } from "../composition/public-persistence"; import { readToolcraftPersistedResourceRefs } from "../state/persistence-resource-refs"; import { toolcraftReducer } from "../composition/public-state"; import { toolcraftFileSourceAssetHandler } from "./file-source-asset-handler"; import { createIndexedDbToolcraftBinaryAssetRepository } from "./repository/indexeddb-binary-asset-repository"; import { createManualHeartbeatScheduler, createManualOwnerLivenessProvider, } from "./repository/indexeddb-binary-asset-test-support"; import { createToolcraftSourceAssetCoordinator } from "../composition/source-asset-coordinator"; import { createToolcraftSourceAssetRegistry } from "./source-asset-registry"; it("retains the last durable import after undo, new branch, quota failure and owner reload", async () => { const control = { applicability: { mode: "always" as const }, type: "fileDrop", assetKind: "file", target: "source.files", multiple: true, } as const; const schema = defineToolcraft({ base: { identity: { id: "retention", title: "Toolcraft fixture", }, canvas: { enabled: true }, panels: { controls: { title: "Controls", sections: [ { id: "fixture-section-1", title: "Source", controls: { files: control }, }, ], }, }, persistence: { storage: "localStorage", }, }, modules: [mediaSourceModule()], }); let state = createToolcraftState(schema); let saved: string | null = null; let quota = false; const indexedDB = new IDBFactory(); const ownerLivenessProvider = createManualOwnerLivenessProvider().provider; const openRepository = () => createIndexedDbToolcraftBinaryAssetRepository({ indexedDB, databaseName: "persistence-retention-regression", ownerLivenessProvider, heartbeatScheduler: createManualHeartbeatScheduler().scheduler, getPersistedResourceRefs: () => readToolcraftPersistedResourceRefs(() => ({ get length() { return saved === null ? 0 : 1; }, key: (index) => index === 0 && saved !== null ? "custom-retention-workspace" : null, getItem: () => saved, })), }); const repository = openRepository(); const coordinator = createToolcraftSourceAssetCoordinator({ repository, getState: () => state, dispatch: (command) => { state = toolcraftReducer(state, command); }, registry: createToolcraftSourceAssetRegistry([ toolcraftFileSourceAssetHandler, ]), }); const controller = createToolcraftPersistenceController({ schema, getCommittedState: () => state, storage: { getItem: () => saved, setItem(_key, value) { if (quota) throw new DOMException("full", "QuotaExceededError"); saved = value; }, }, }); const upload = (name: string) => coordinator.importBatch( { files: [new File([name], name, { type: "text/plain" })], origin: "panel", target: control.target, }, control, ); try { expect((await upload("a.txt")).kind).toBe("committed"); const assetA = state.mediaAssets[0]!; if (assetA.assetKind === "model") throw new Error("Expected file import"); expect(controller.flush()).toEqual({ status: "success" }); state = toolcraftReducer(state, { type: "history.undo" }); expect(state.mediaAssets).toHaveLength(0); expect(await repository.get(assetA.resourceRef)).not.toBeNull(); quota = true; expect((await upload("b.txt")).kind).toBe("committed"); expect(state.history.redo).toHaveLength(0); expect(controller.flush()).toEqual({ reason: "quota", status: "failed" }); expect(await repository.get(assetA.resourceRef)).not.toBeNull(); const restored = createToolcraftState( schema, parseToolcraftPersistenceSnapshot(schema, saved), ); expect(restored.mediaAssets.map((asset) => asset.fileName)).toEqual([ "a.txt", ]); controller.dispose(); await coordinator.dispose(); const reopened = openRepository(); try { await reopened.collect(new Set()); expect((await reopened.get(assetA.resourceRef))?.bytes).toEqual( new TextEncoder().encode("a.txt"), ); } finally { await reopened.dispose(); } } finally { controller.dispose(); await coordinator.dispose(); } });