import { test, expect } from "@playwright/test"; import { CanvasRevisionConflict, VideoCanvasSaveQueue } from "@/lib/video-canvas/save-queue"; test("coalesces edits made during a save and uses the returned revision", async () => { let release!: (value: string) => void; const calls: string[][] = []; const queue = new VideoCanvasSaveQueue( "initial", "r0", async (value, revision) => { calls.push([value, revision]); return calls.length === 1 ? new Promise((resolve) => { release = resolve; }) : "r2"; }, () => {}, ); queue.update("first"); const pending = queue.flush(); queue.update("second"); queue.update("latest"); expect(queue.flush()).toBe(pending); release("r1"); await pending; expect(calls).toEqual([ ["first", "r0"], ["latest", "r1"], ]); expect(queue.dirty).toBe(false); }); test("failed save retains edits and retries only the latest snapshot", async () => { const calls: string[][] = []; const queue = new VideoCanvasSaveQueue( "initial", "r0", async (value, revision) => { calls.push([value, revision]); if (calls.length === 1) throw new Error("offline"); return "r1"; }, () => {}, ); queue.update("first"); await queue.flush(); expect(queue.dirty).toBe(true); expect(queue.failed).toBe(true); queue.update("latest"); await queue.flush(); expect(calls).toEqual([ ["first", "r0"], ["latest", "r0"], ]); expect(queue.dirty).toBe(false); }); test("revision conflict cannot be retried as an overwrite", async () => { let calls = 0; const queue = new VideoCanvasSaveQueue( "initial", "r0", async () => { calls++; throw new CanvasRevisionConflict("changed elsewhere"); }, () => {}, ); queue.update("first"); await queue.flush(); queue.update("latest"); await queue.flush(); expect(calls).toBe(1); expect(queue.dirty).toBe(true); }); test("unchanged canvas does not write", async () => { let calls = 0; const queue = new VideoCanvasSaveQueue( "initial", "r0", async () => { calls++; return "r1"; }, () => {}, ); queue.update("initial"); await queue.flush(); expect(calls).toBe(0); }); import { mergeCanvasSnapshots } from "@/lib/video-canvas/merge-snapshot"; const snapshot = (nodes: any[], edges: any[] = []) => JSON.stringify({ title: "QA", canvasData: { nodes, edges, viewport: { x: 0, y: 0, zoom: 1 }, selectedNodeIds: [], tool: "select", }, }); const base = snapshot([ { id: "a", title: "A", x: 0, draftValues: { text: "original" } }, { id: "b", title: "B", x: 0 }, ]); test("merges independent node edits, additions and deletion without reviving removed edges", () => { const local = snapshot([ { id: "a", title: "A", x: 20, draftValues: { text: "original" } }, { id: "b", title: "B", x: 0 }, { id: "c", title: "C" }, ]); const remote = snapshot( [ { id: "a", title: "new A", x: 0, draftValues: { text: "original" } }, { id: "d", title: "D" }, ], [{ id: "e", source: "a", target: "b" }], ); const merged = mergeCanvasSnapshots(base, local, remote); expect(merged.conflicts).toEqual([]); expect(JSON.parse(merged.snapshot).canvasData.nodes).toEqual([ { id: "a", title: "new A", x: 20, draftValues: { text: "original" } }, { id: "d", title: "D" }, { id: "c", title: "C" }, ]); expect(JSON.parse(merged.snapshot).canvasData.edges).toEqual([]); }); test("same field and deletion versus modification require a decision, preserving other edits", () => { const local = snapshot([{ id: "a", title: "local", x: 10, draftValues: { text: "original" } }]); const remote = snapshot([ { id: "a", title: "remote", x: 0, draftValues: { text: "new" } }, { id: "b", title: "changed B", x: 0 }, ]); const merged = mergeCanvasSnapshots(base, local, remote); expect(merged.conflicts).toEqual(["canvasData.nodes[a].title", "canvasData.nodes[b]"]); const localChoice = JSON.parse(mergeCanvasSnapshots(base, local, remote, "local").snapshot) .canvasData.nodes; expect(localChoice).toEqual([{ id: "a", title: "local", x: 10, draftValues: { text: "new" } }]); const remoteChoice = JSON.parse(mergeCanvasSnapshots(base, local, remote, "remote").snapshot) .canvasData.nodes; expect(remoteChoice[0]).toMatchObject({ title: "remote", x: 10, draftValues: { text: "new" } }); expect(remoteChoice).toHaveLength(2); }); test("view state stays local, metadata timestamps do not create false content conflicts", () => { const local = JSON.parse(base), remote = JSON.parse(base); local.canvasData.nodes[0].updatedAt = "local"; remote.canvasData.nodes[0].updatedAt = "remote"; remote.canvasData.nodes[0].title = "remote"; remote.canvasData.viewport.x = 50; const merged = mergeCanvasSnapshots(base, JSON.stringify(local), JSON.stringify(remote)); expect(merged.conflicts).toEqual([]); expect(JSON.parse(merged.snapshot).canvasData.viewport.x).toBe(0); }); test("queued edits made while reading a conflict survive the automatic merge and CAS retry", async () => { let release!: (e: Error) => void; let calls = 0; let applied = ""; const local = snapshot([ { id: "a", title: "A", x: 10, draftValues: { text: "original" } }, { id: "b", title: "B", x: 0 }, ]); const newer = snapshot([ { id: "a", title: "A", x: 20, draftValues: { text: "original" } }, { id: "b", title: "B", x: 0 }, ]); const remote = snapshot([ { id: "a", title: "remote", x: 0, draftValues: { text: "original" } }, { id: "b", title: "B", x: 0 }, ]); const queue = new VideoCanvasSaveQueue( base, "r0", async (value, revision) => { calls++; if (calls === 1) return new Promise((_, reject) => (release = reject)); expect(revision).toBe("r1"); expect(JSON.parse(value).canvasData.nodes[0]).toMatchObject({ title: "remote", x: 20 }); return "r2"; }, () => {}, { merge: mergeCanvasSnapshots, apply: (value) => (applied = value), conflict: () => {} }, ); queue.update(local); const saving = queue.flush(); queue.update(newer); release(new CanvasRevisionConflict("conflict", remote, "r1")); await saving; expect(calls).toBe(2); expect(queue.dirty).toBe(false); expect(applied).toContain("remote"); }); test("conflict resolution and restored browser drafts never blindly overwrite the remote document", async () => { const local = snapshot([ { id: "a", title: "local", x: 0, draftValues: { text: "original" } }, { id: "b", title: "B", x: 0 }, ]); const remote = snapshot([ { id: "a", title: "remote", x: 0, draftValues: { text: "new" } }, { id: "b", title: "B", x: 0 }, ]); let conflict: string[] = []; let written = ""; const queue = new VideoCanvasSaveQueue( remote, "r2", async (value, revision) => { expect(revision).toBe("r2"); written = value; return "r3"; }, () => {}, { merge: mergeCanvasSnapshots, apply: () => {}, conflict: (fields) => (conflict = fields) }, ); queue.restoreBackup(base, local); await queue.flush(); expect(written).toBe(""); expect(conflict).toHaveLength(1); await queue.resolveConflict("local"); expect(JSON.parse(written).canvasData.nodes[0]).toMatchObject({ title: "local", draftValues: { text: "new" }, }); expect(conflict).toEqual([]); expect(queue.dirty).toBe(false); }); test("rapid remote saves pause automatic merging without permanently blocking manual retry", async () => { let calls = 0; let conflictFields: string[] = []; const local = JSON.parse(base); local.canvasData.nodes[0].title = "local"; const queue = new VideoCanvasSaveQueue( base, "r0", async (value) => { calls++; if (calls <= 4) { const remote = JSON.parse(base); remote.canvasData.nodes[1].title = `remote-${calls}`; throw new CanvasRevisionConflict("conflict", JSON.stringify(remote), `r${calls}`); } const nodes = JSON.parse(value).canvasData.nodes; expect(nodes[0].title).toBe("local"); expect(nodes[1].title).toBe("remote-4"); return "r5"; }, () => {}, { merge: mergeCanvasSnapshots, apply: () => {}, conflict: (fields) => { conflictFields = fields; }, }, ); queue.update(JSON.stringify(local)); await queue.flush(); expect(calls).toBe(4); expect(queue.failed).toBe(true); expect(queue.dirty).toBe(true); expect(conflictFields).toEqual([]); await queue.flush(); expect(calls).toBe(5); expect(queue.dirty).toBe(false); });