import { afterEach, beforeEach, expect, test } from "bun:test"; import localforage from "localforage"; import { apiClient } from "../src/services/api/request"; import { canvasContentHash } from "../src/lib/canvas/canvas-content"; import { rebaseCanvasProjects, parseCanvasStorageDocument } from "../src/lib/canvas/canvas-storage-revision"; import { readCanvasSyncDrafts } from "../src/services/canvas-sync-drafts"; import { applyAgentCanvasPatches, deleteCanvasProjectsWithRemoteSync, refreshCanvasAfterAgent, initializeRemoteUserDataSession, installRemoteUserDataAutoSync, loadCanvasProjectForEditing, resetRemoteUserDataSync, saveRemoteUserDataNow, syncRemoteUserData, } from "../src/services/user-data-sync"; import { createAgentCanvasSync } from "../src/services/agent-canvas-sync"; import { flushCanvasStorePersistence, useCanvasStore, type CanvasProject } from "../src/stores/canvas/use-canvas-store"; import { flushAssetStorePersistence, useAssetStore } from "../src/stores/use-asset-store"; import { useSyncProgressStore } from "../src/stores/use-sync-progress-store"; import { CanvasNodeType } from "../src/types/canvas"; const originalWindow = globalThis.window; const originalAdapter = apiClient.defaults.adapter; const originalGet = localforage.getItem; const originalSet = localforage.setItem; const indexed = new Map(); let scope = ""; let sequence = 0; let autoSave: (() => void) | undefined; let remote = new Map(); let requests: Array<{ method: string; id: string; project?: CanvasProject }> = []; let beforePut: (() => Promise) | undefined; let deleteFailureId: string | undefined; let temporaryPutFailures = 0; function canvas(id = "canvas"): CanvasProject { return { id, revision: 1, title: id, createdAt: "2026-09-01T00:00:00Z", updatedAt: "2026-09-01T00:00:00Z", nodes: [{ id: "old-image", type: CanvasNodeType.Image, title: "image", position: { x: 0, y: 0 }, width: 100, height: 100 }], connections: [], chatSessions: [], activeChatId: null, backgroundMode: "dots", showImageInfo: false, viewport: { x: 0, y: 0, k: 1 }, directorScenes: [], }; } function addNode(project: CanvasProject, id: string) { return { ...project, nodes: [...project.nodes, { ...project.nodes[0], id, type: CanvasNodeType.Video }] }; } beforeEach(async () => { resetRemoteUserDataSync(); scope = `canvas-revision-test-${++sequence}`; indexed.clear(); requests = []; autoSave = undefined; beforePut = undefined; deleteFailureId = undefined; temporaryPutFailures = 0; remote = new Map([ ["canvas", canvas()], ["other", canvas("other")], ]); Object.defineProperty(globalThis, "window", { configurable: true, value: { setTimeout: (callback: () => void) => { autoSave = callback; return 1; }, clearTimeout: () => { autoSave = undefined; }, localStorage: { getItem: () => scope, setItem: () => undefined }, }, }); localforage.getItem = (async (key: string) => indexed.get(key) ?? null) as typeof localforage.getItem; localforage.setItem = (async (key: string, value: string) => { indexed.set(key, value); return value; }) as typeof localforage.setItem; apiClient.defaults.adapter = async (config) => { const method = config.method || "get"; const id = String(config.url).split("/").at(-1)!; const body = config.data ? JSON.parse(config.data) : undefined; requests.push({ method, id, project: body?.project }); let data: unknown; let status = 200; if (id === "snapshot") data = { projects: structuredClone([...remote.values()]), assets: [] }; else if (method === "delete") { if (id === deleteFailureId) status = 403; else { remote.delete(id); data = { id }; } } else if (id === "batch" && method === "post") { data = { assets: [ { id: "invalid-mime-asset", kind: "image", title: "历史素材", coverUrl: "", tags: [], createdAt: "2026-09-01", updatedAt: "2026-09-01", data: { dataUrl: "https://example.com/image.png", width: 100, height: 100, bytes: 1, mimeType: "image/*" }, }, ], }; } else if (id === "restore" && method === "post") { const current = remote.get("canvas")!; if (body.revision !== current.revision) status = 409; else { const saved = { ...canvas(), title: "historical content", revision: current.revision! + 1 }; remote.set("canvas", saved); data = { project: saved }; } } else if (method === "put") { await beforePut?.(); const project = body.project as CanvasProject; const current = remote.get(id); if (temporaryPutFailures > 0) { temporaryPutFailures -= 1; status = 503; } else if (project.revision !== (current?.revision ?? 0)) status = 409; else { const saved = { ...structuredClone(project), revision: project.revision! + 1 }; remote.set(id, saved); data = { project: saved }; } } else data = { project: structuredClone(remote.get(id)) }; return { config, status, statusText: "", headers: {}, data: { code: status === 200 ? 0 : status, data, msg: status === 409 ? "版本冲突" : "ok" } }; }; useCanvasStore.setState({ projects: [] }); useAssetStore.setState({ assets: [] }); installRemoteUserDataAutoSync(); await syncRemoteUserData(scope); }); async function waitForSyncState(predicate: () => boolean) { const deadline = Date.now() + 1_000; while (!predicate()) { if (Date.now() >= deadline) throw new Error("同步测试状态等待超时"); await new Promise((resolve) => globalThis.setTimeout(resolve, 0)); } } afterEach(async () => { resetRemoteUserDataSync(); await Promise.all([flushCanvasStorePersistence(), flushAssetStorePersistence()]); apiClient.defaults.adapter = originalAdapter; localforage.getItem = originalGet; localforage.setItem = originalSet; if (originalWindow === undefined) delete (globalThis as { window?: unknown }).window; else Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow }); }); test("deletion skips editing and invalid MIME assets, including an uncached canvas", async () => { const project = canvas(); project.nodes[0].metadata = { assetId: "invalid-mime-asset" }; remote.set(project.id, project); await initializeRemoteUserDataSession(scope); await expect(loadCanvasProjectForEditing(project.id)).rejects.toThrow("具体 MIME"); useCanvasStore.getState().deleteProjects(["other"]); await flushCanvasStorePersistence(); requests = []; await deleteCanvasProjectsWithRemoteSync([" canvas ", "canvas", "other"]); expect(requests.map(({ method, id }) => ({ method, id }))).toEqual([ { method: "delete", id: "canvas" }, { method: "delete", id: "other" }, ]); expect(remote.size).toBe(0); expect(useCanvasStore.getState().projects).toEqual([]); await useCanvasStore.persist.rehydrate(); expect(useCanvasStore.getState().projects).toEqual([]); }); test("batch deletion retains failed canvases and persists successful deletions", async () => { await initializeRemoteUserDataSession(scope); deleteFailureId = "other"; await expect(deleteCanvasProjectsWithRemoteSync(["canvas", "other"])).rejects.toThrow(); expect([...remote.keys()]).toEqual(["other"]); expect(useCanvasStore.getState().projects.map((project) => project.id)).toEqual(["other"]); await useCanvasStore.persist.rehydrate(); expect(useCanvasStore.getState().projects.map((project) => project.id)).toEqual(["other"]); }); test("stale viewport, no-op restore and open do not submit old content", async () => { const old = useCanvasStore.getState().openProject("canvas")!; remote.set("canvas", { ...addNode(canvas(), "afternoon-video"), revision: 2 }); useCanvasStore.getState().updateProject("canvas", { viewport: { x: 100, y: 40, k: 2 } }); useCanvasStore.getState().updateProject("canvas", { nodes: structuredClone(old.nodes), connections: [] }); expect(autoSave).toBeUndefined(); expect(useCanvasStore.getState().openProject("canvas")!.updatedAt).toBe(old.updatedAt); await flushCanvasStorePersistence(); await saveRemoteUserDataNow(); const latest = await loadCanvasProjectForEditing("canvas"); expect(latest.nodes.map((node) => node.id)).toEqual(["old-image", "afternoon-video"]); expect(latest.revision).toBe(2); expect(latest.viewport).toEqual({ x: 100, y: 40, k: 2 }); await saveRemoteUserDataNow(); expect(requests.filter((request) => request.method === "put")).toHaveLength(0); }); test("load latest, save and reload stay synced when Agent history replays an unversioned delta", async () => { remote.set("canvas", { ...canvas(), revision: 9 }); await loadCanvasProjectForEditing("canvas", { latest: true }); useCanvasStore.getState().renameProject("canvas", "已保存的标题"); await saveRemoteUserDataNow("canvas"); expect(remote.get("canvas")!.revision).toBe(10); for (let reload = 0; reload < 3; reload++) { resetRemoteUserDataSync(); await useCanvasStore.persist.rehydrate(); await initializeRemoteUserDataSession(scope); await loadCanvasProjectForEditing("canvas"); let resolve!: () => void; let reject!: (error: unknown) => void; const reconciled = new Promise((done, fail) => { resolve = done; reject = fail; }); const sync = createAgentCanvasSync({ canvasId: "canvas", batchMs: 0, applyPatches: (patches) => applyAgentCanvasPatches("canvas", patches), refresh: async () => { await refreshCanvasAfterAgent("canvas"); resolve(); }, onError: reject, }); try { sync.receive({ eventId: "historical-event", runId: "completed-run", seq: 1, type: "canvas_updated", createdAt: "2026-09-17", payload: { canvasId: "canvas", canvasPatch: { canvasId: "canvas", updatedAt: "2026-09-17", nodes: [{ before: null, after: { ...canvas().nodes[0], id: "stale-node" } }], connections: [] }, }, }); await reconciled; expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("done"); await saveRemoteUserDataNow("canvas"); expect(useCanvasStore.getState().openProject("canvas")).toMatchObject({ revision: 10, title: "已保存的标题", nodes: canvas().nodes }); expect(await readCanvasSyncDrafts("canvas")).toHaveLength(0); } finally { sync.dispose(); } } expect(requests.filter((request) => request.method === "put")).toHaveLength(1); }); test("a stale save auto-reconciles when the cloud already contains the identical local content", async () => { useCanvasStore.getState().renameProject("canvas", "same content saved elsewhere"); beforePut = async () => { const current = useCanvasStore.getState().openProject("canvas")!; remote.set("canvas", { ...structuredClone(current), revision: current.revision! + 1 }); }; await expect(saveRemoteUserDataNow("canvas")).resolves.toBeUndefined(); expect(useCanvasStore.getState().openProject("canvas")).toMatchObject({ title: "same content saved elsewhere", revision: 2 }); expect(useSyncProgressStore.getState().syncingProjects.canvas).toMatchObject({ phase: "done", message: "云端内容一致,已自动校准版本" }); expect(await readCanvasSyncDrafts("canvas")).toHaveLength(0); }); test("a stale save rebases disjoint Agent edits and keeps saving", async () => { remote.set("canvas", { ...addNode(canvas(), "remote-video"), revision: 2 }); useCanvasStore.getState().updateProject("canvas", { nodes: addNode(canvas(), "local-video").nodes }); useCanvasStore.getState().renameProject("other", "other edit"); await expect(saveRemoteUserDataNow()).resolves.toBeUndefined(); expect(remote.get("canvas")!.nodes.map((node) => node.id)).toEqual(["old-image", "local-video", "remote-video"]); expect(remote.get("canvas")!.revision).toBe(3); expect(remote.get("other")!.title).toBe("other edit"); expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("done"); useCanvasStore.getState().renameProject("canvas", "more local edits"); await expect(saveRemoteUserDataNow("canvas")).resolves.toBeUndefined(); expect(remote.get("canvas")!.title).toBe("more local edits"); expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("done"); }); test("transient cloud failures schedule a bounded automatic retry", async () => { temporaryPutFailures = 1; useCanvasStore.getState().renameProject("canvas", "retry me"); expect(autoSave).toBeDefined(); autoSave?.(); await waitForSyncState(() => requests.filter((request) => request.method === "put" && request.id === "canvas").length === 1); expect(autoSave).toBeDefined(); expect(useSyncProgressStore.getState().syncingProjects.canvas.message).toContain("自动重试"); autoSave?.(); await waitForSyncState(() => remote.get("canvas")?.title === "retry me"); expect(requests.filter((request) => request.method === "put" && request.id === "canvas")).toHaveLength(2); expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("done"); }); test("edits during a save retain the right ancestor revision and are saved next", async () => { let release!: () => void; let started!: () => void; const waiting = new Promise((resolve) => { release = resolve; }); const entered = new Promise((resolve) => { started = resolve; }); beforePut = async () => { started(); await waiting; }; useCanvasStore.getState().updateProject("canvas", { nodes: addNode(canvas(), "first-video").nodes }); const saving = saveRemoteUserDataNow(); await entered; useCanvasStore.getState().updateProject("canvas", { nodes: addNode(useCanvasStore.getState().openProject("canvas")!, "second-video").nodes }); release(); await saving; expect(requests.filter((request) => request.method === "put").map((request) => request.project!.revision)).toEqual([1, 2]); expect(remote.get("canvas")!.revision).toBe(3); expect(remote.get("canvas")!.nodes.map((node) => node.id)).toEqual(["old-image", "first-video", "second-video"]); expect(useCanvasStore.getState().openProject("canvas")!.remoteContentHash).toBe(await canvasContentHash(useCanvasStore.getState().openProject("canvas")!)); expect(requests.filter((request) => request.method === "put").every((request) => !("viewport" in request.project!) && !("remoteContentHash" in request.project!))).toBe(true); }); test("login archives unsynced changes before replacing cache and keeps user scopes separate", async () => { useCanvasStore.getState().renameProject("canvas", "unsaved draft"); await flushCanvasStorePersistence(); resetRemoteUserDataSync(); await useCanvasStore.persist.rehydrate(); await syncRemoteUserData(scope); expect(useCanvasStore.getState().openProject("canvas")!.title).toBe("canvas"); expect((await readCanvasSyncDrafts("canvas"))[0].project.title).toBe("unsaved draft"); expect(await readCanvasSyncDrafts("canvas", "another-user")).toEqual([]); expect(requests.some((request) => request.method === "put")).toBe(false); }); test("failed draft storage prevents replacement by the latest server content", async () => { const working = localforage.setItem; useCanvasStore.getState().renameProject("canvas", "must survive"); localforage.setItem = (async (key: string, value: unknown) => { if (key.includes("sync-drafts")) throw new Error("disk full"); return working(key, value); }) as typeof localforage.setItem; await expect(loadCanvasProjectForEditing("canvas", { latest: true })).rejects.toThrow("disk full"); expect(useCanvasStore.getState().openProject("canvas")!.title).toBe("must survive"); }); test("local storage rebase cannot pair old edits with another tab's newer revision", () => { const base = canvas(); const local = addNode(base, "unsaved-local"); const durable = { ...addNode(base, "remote-video"), revision: 2 }; const document = parseCanvasStorageDocument(null, [durable]); const rebased = rebaseCanvasProjects({ document, baseProjects: [base], localProjects: [local], baseRevision: 0 }).document.state.projects[0]; expect(rebased.revision).toBe(1); expect(rebased.nodes.at(-1)!.id).toBe("unsaved-local"); }); test("loading aligns live editor content before publishing its server revision", async () => { remote.set("canvas", { ...addNode(canvas(), "new-cloud-node"), revision: 2 }); let applied = false; await loadCanvasProjectForEditing("canvas", { onLoad: (project) => { expect(useCanvasStore.getState().openProject("canvas")!.revision).toBe(1); expect(project.revision).toBe(2); expect(project.nodes.at(-1)!.id).toBe("new-cloud-node"); applied = true; }, }); expect(applied).toBe(true); expect(useCanvasStore.getState().openProject("canvas")!.revision).toBe(2); }); test("viewport-only updates during latest load still adopt cloud content", async () => { remote.set("canvas", { ...addNode(canvas(), "remote-video"), revision: 2 }); useCanvasStore.getState().updateProject("canvas", { nodes: addNode(canvas(), "local-video").nodes }); const working = localforage.setItem; let release!: () => void; let started!: () => void; const waiting = new Promise((resolve) => { release = resolve; }); const entered = new Promise((resolve) => { started = resolve; }); localforage.setItem = (async (key: string, value: unknown) => { if (key.includes("sync-drafts:")) { started(); await waiting; } return working(key, value); }) as typeof localforage.setItem; const loading = loadCanvasProjectForEditing("canvas", { latest: true }); await entered; useCanvasStore.getState().updateProject("canvas", { viewport: { x: 40, y: 12, k: 1.5 } }); release(); const loaded = await loading; expect(loaded.nodes.at(-1)!.id).toBe("remote-video"); expect(useCanvasStore.getState().openProject("canvas")!.revision).toBe(2); expect(useCanvasStore.getState().openProject("canvas")!.viewport).toEqual({ x: 40, y: 12, k: 1.5 }); }); test("edits arriving during draft backup prevent replacement of the live canvas", async () => { useCanvasStore.getState().renameProject("canvas", "first local edit"); const working = localforage.setItem; let release!: () => void; let started!: () => void; const waiting = new Promise((resolve) => { release = resolve; }); const entered = new Promise((resolve) => { started = resolve; }); localforage.setItem = (async (key: string, value: unknown) => { if (key.includes("sync-drafts:")) { started(); await waiting; } return working(key, value); }) as typeof localforage.setItem; const loading = loadCanvasProjectForEditing("canvas", { latest: true }); await entered; useCanvasStore.getState().renameProject("canvas", "second local edit"); release(); await expect(loading).rejects.toThrow("画布仍在更新"); expect(useCanvasStore.getState().openProject("canvas")!.title).toBe("second local edit"); }); test("unknown revision cannot borrow the latest revision to submit stale content", async () => { const local = useCanvasStore.getState().openProject("canvas")!; useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => (project.id === local.id ? { ...local, revision: undefined, title: "legacy draft" } : project)) })); await expect(saveRemoteUserDataNow()).rejects.toThrow("缺少画布版本"); expect(requests.filter((request) => request.method === "put")).toHaveLength(0); expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("conflict"); expect((await readCanvasSyncDrafts("canvas"))[0].project.title).toBe("legacy draft"); }); test("HTTP environments without Web Crypto still distinguish unsaved content", async () => { const descriptor = Object.getOwnPropertyDescriptor(globalThis, "crypto")!; Object.defineProperty(globalThis, "crypto", { configurable: true, value: undefined }); try { const baseline = await canvasContentHash(canvas()); expect(baseline).toBe(await canvasContentHash({ ...canvas(), viewport: { x: 100, y: 0, k: 2 } })); expect(baseline).not.toBe(await canvasContentHash(addNode(canvas(), "new-video"))); } finally { Object.defineProperty(globalThis, "crypto", descriptor); } }); test("history restore preserves the draft and installs a new revision without an extra content PUT", async () => { useCanvasStore.getState().updateProject("canvas", { nodes: addNode(canvas(), "unsaved-video").nodes }); let applied = false; const restored = await loadCanvasProjectForEditing("canvas", { historyRestore: { snapshotId: "historical", revision: 1 }, onLoad: (project) => { expect(useCanvasStore.getState().openProject("canvas")!.revision).toBe(1); expect(project.revision).toBe(2); applied = true; }, }); expect(applied).toBe(true); expect(restored.title).toBe("historical content"); expect((await readCanvasSyncDrafts("canvas")).some((draft) => draft.project.nodes.some((node) => node.id === "unsaved-video"))).toBe(true); await saveRemoteUserDataNow("canvas"); expect(requests.filter((request) => request.method === "post")).toHaveLength(1); expect(requests.filter((request) => request.method === "put")).toHaveLength(0); }); test("a stale history restore preserves both current cloud content and local edits", async () => { remote.set("canvas", { ...addNode(canvas(), "new-cloud-video"), revision: 2 }); useCanvasStore.getState().renameProject("canvas", "local draft"); await expect(loadCanvasProjectForEditing("canvas", { historyRestore: { snapshotId: "historical", revision: 1 } })).rejects.toThrow("版本冲突"); expect(remote.get("canvas")!.nodes.at(-1)!.id).toBe("new-cloud-video"); expect(useCanvasStore.getState().openProject("canvas")!.title).toBe("local draft"); expect((await readCanvasSyncDrafts("canvas"))[0].project.title).toBe("local draft"); }); test("reopened dirty cache with the same ancestor stays pending until actually saved", async () => { useCanvasStore.getState().renameProject("canvas", "offline draft"); await flushCanvasStorePersistence(); await initializeRemoteUserDataSession(scope); const loaded = await loadCanvasProjectForEditing("canvas"); expect(loaded.title).toBe("offline draft"); expect(useSyncProgressStore.getState().syncingProjects.canvas.phase).toBe("pending"); expect(remote.get("canvas")!.title).toBe("canvas"); await saveRemoteUserDataNow("canvas"); expect(remote.get("canvas")!.title).toBe("offline draft"); expect(remote.get("canvas")!.revision).toBe(2); }); test("failed draft backup prevents a history restore request", async () => { const working = localforage.setItem; localforage.setItem = (async (key: string, value: unknown) => { if (key.includes("sync-drafts:")) throw new Error("disk full"); return working(key, value); }) as typeof localforage.setItem; await expect(loadCanvasProjectForEditing("canvas", { historyRestore: { snapshotId: "historical", revision: 1 } })).rejects.toThrow("disk full"); expect(requests.some((request) => request.method === "post")).toBe(false); expect(remote.get("canvas")!.revision).toBe(1); });