import { afterEach, describe, expect, it, vi } from "vitest"; import { defineToolcraft } from "../schema/define-toolcraft"; import { createToolcraftState } from "./create-template-state"; import { createToolcraftPersistenceController, type ToolcraftPersistenceCheckpoint, } from "../composition/public-persistence"; function memoryStorage() { let saved: string | null = null; return { getItem: vi.fn(() => saved), setItem: vi.fn((_key: string, value: string) => { saved = value; }), }; } function createSchema(storage: "localStorage" | "none" = "localStorage") { return defineToolcraft({ base: { identity: { id: "runtime-test", title: "Toolcraft App" }, canvas: { enabled: true }, panels: { controls: { sections: [ { controls: { opacity: { applicability: { mode: "always" as const }, defaultValue: 75, target: "appearance.opacity", type: "slider", }, }, id: "appearance", title: "Appearance", }, ], title: "Controls", }, }, persistence: storage === "none" ? { storage: "none" } : { storage: "localStorage" }, }, modules: [], }); } afterEach(() => { vi.useRealTimers(); }); describe("createToolcraftPersistenceController", () => { it("does not rewrite an equivalent restored snapshot with reordered object keys", () => { const schema = createSchema(); const storage = memoryStorage(); const state = createToolcraftState(schema); const first = createToolcraftPersistenceController({ schema, storage, getCommittedState: () => state, }); expect(first.flush()).toEqual({ status: "success" }); const original = storage.getItem()!; const reordered = JSON.parse(original); reordered.state.canvas.size = Object.fromEntries( Object.entries(reordered.state.canvas.size).reverse(), ); storage.setItem("foreign", JSON.stringify(reordered)); const latest = storage.getItem(); const writes = storage.setItem.mock.calls.length; expect(first.flush()).toEqual({ status: "success" }); expect(storage.setItem).toHaveBeenCalledTimes(writes); expect(storage.getItem()).toBe(latest); first.dispose(); }); it("advances its checkpoint only after successful writes and preserves it across controller restarts", () => { const schema = createSchema(); const storage = memoryStorage(); const checkpoint: ToolcraftPersistenceCheckpoint = {}; let state = createToolcraftState(schema); const options = { schema, storage, checkpoint, getCommittedState: () => state }; const first = createToolcraftPersistenceController(options); expect(first.flush()).toEqual({ status: "success" }); first.dispose(); const restarted = createToolcraftPersistenceController(options); state = createToolcraftState(schema, { values: { "appearance.opacity": 50 } }); expect(restarted.flush()).toEqual({ status: "success" }); expect(JSON.parse(storage.getItem()!).state.values["appearance.opacity"]).toBe(50); storage.setItem("foreign", "newer workspace"); expect(restarted.flush()).toEqual({ status: "failed", reason: "stale-snapshot" }); restarted.dispose(); const stillStale = createToolcraftPersistenceController(options); expect(stillStale.flush()).toEqual({ status: "failed", reason: "stale-snapshot" }); expect(storage.getItem()).toBe("newer workspace"); stillStale.dispose(); }); it("does not adopt unreadable storage later as permission to overwrite it", () => { const schema = createSchema(); const storage = memoryStorage(); storage.getItem.mockImplementationOnce(() => { throw new DOMException("Denied", "SecurityError"); }); const controller = createToolcraftPersistenceController({ schema, storage, getCommittedState: () => createToolcraftState(schema), }); expect(controller.flush()).toEqual({ status: "failed", reason: "unavailable" }); expect(storage.setItem).not.toHaveBeenCalled(); controller.dispose(); }); it("retries a quota failure against the last successful checkpoint", () => { const schema = createSchema(); const storage = memoryStorage(); let state = createToolcraftState(schema); const controller = createToolcraftPersistenceController({ schema, storage, getCommittedState: () => state, }); expect(controller.flush()).toEqual({ status: "success" }); const saved = storage.getItem(); state = createToolcraftState(schema, { values: { "appearance.opacity": 50 } }); storage.setItem.mockImplementationOnce(() => { throw new DOMException("Full", "QuotaExceededError"); }); expect(controller.flush()).toEqual({ status: "failed", reason: "quota" }); expect(storage.getItem()).toBe(saved); expect(controller.flush()).toEqual({ status: "success" }); expect(JSON.parse(storage.getItem()!).state.values["appearance.opacity"]).toBe(50); controller.dispose(); }); it.each(["timer", "flush", "dispose"] as const)( "preserves a newer tab snapshot on a stale %s", (boundary) => { vi.useFakeTimers(); const schema = createSchema(); let saved: string | null = null; const storage = { getItem: () => saved, setItem: vi.fn((_key: string, value: string) => { saved = value; }), }; const first = createToolcraftPersistenceController({ schema, storage, getCommittedState: () => createToolcraftState(schema, { values: { "appearance.opacity": 50 }, }), }); const staleState = createToolcraftState(schema, { values: { "appearance.opacity": 25 }, }); const stale = createToolcraftPersistenceController({ schema, storage, getCommittedState: () => staleState, }); expect(first.flush()).toEqual({ status: "success" }); const latest = saved; stale.schedule(); if (boundary === "timer") vi.advanceTimersByTime(120); else if (boundary === "flush") stale.flush(); else stale.dispose(); expect(stale.getStatus()).toEqual({ status: "failed", reason: "stale-snapshot" }); expect(saved).toBe(latest); expect(storage.setItem).toHaveBeenCalledTimes(1); expect(staleState.values["appearance.opacity"]).toBe(25); stale.schedule(); stale.flush(); stale.dispose(); expect(saved).toBe(latest); first.dispose(); }, ); it("debounces writes and flushes the latest committed snapshot", () => { vi.useFakeTimers(); const schema = createSchema(); let state = createToolcraftState(schema); const storage = memoryStorage(); const { setItem } = storage; const controller = createToolcraftPersistenceController({ getCommittedState: () => state, schema, storage, }); controller.schedule(); state = createToolcraftState(schema, { values: { "appearance.opacity": 50 }, }); controller.schedule(); vi.advanceTimersByTime(119); expect(setItem).not.toHaveBeenCalled(); vi.advanceTimersByTime(1); expect(setItem).toHaveBeenCalledTimes(1); expect(JSON.parse(setItem.mock.calls[0]![1])).toMatchObject({ state: { values: { "appearance.opacity": 50 } }, version: 2, }); }); it("flushes a pending committed snapshot before disposal", () => { vi.useFakeTimers(); const schema = createSchema(); const state = createToolcraftState(schema, { values: { "appearance.opacity": 50 }, }); const storage = memoryStorage(); const { setItem } = storage; const controller = createToolcraftPersistenceController({ getCommittedState: () => state, schema, storage, }); controller.schedule(); expect(controller.dispose()).toEqual({ status: "success" }); expect(setItem).toHaveBeenCalledTimes(1); }); it("does not access storage when persistence is disabled", () => { const schema = createSchema("none"); const storage = memoryStorage(); const { setItem } = storage; const controller = createToolcraftPersistenceController({ getCommittedState: () => createToolcraftState(schema), schema, storage, }); controller.schedule(); expect(controller.flush()).toEqual({ status: "disabled" }); expect(controller.dispose()).toEqual({ status: "disabled" }); expect(setItem).not.toHaveBeenCalled(); expect(storage.getItem).not.toHaveBeenCalled(); }); it("reports quota failure once without an autonomous retry", () => { vi.useFakeTimers(); const schema = createSchema(); const storage = memoryStorage(); const setItem = storage.setItem.mockImplementation(() => { throw new DOMException("Storage full", "QuotaExceededError"); }); const controller = createToolcraftPersistenceController({ getCommittedState: () => createToolcraftState(schema), schema, storage, }); controller.schedule(); vi.advanceTimersByTime(120); expect(controller.getStatus()).toEqual({ reason: "quota", status: "failed", }); expect(setItem).toHaveBeenCalledTimes(1); vi.advanceTimersByTime(1_000); expect(setItem).toHaveBeenCalledTimes(1); }); it("preserves a newer unsupported snapshot by blocking writes", () => { const schema = createSchema(); const storage = memoryStorage(); const { setItem } = storage; const controller = createToolcraftPersistenceController({ blockedReason: "incompatible-version", getCommittedState: () => createToolcraftState(schema), schema, storage, }); controller.schedule(); expect(controller.flush()).toEqual({ reason: "incompatible-version", status: "failed", }); expect(controller.dispose()).toEqual({ reason: "incompatible-version", status: "failed", }); expect(setItem).not.toHaveBeenCalled(); expect(storage.getItem).not.toHaveBeenCalled(); }); });