import { vi } from "vitest"; import { act, render, waitFor, } from "../../packages/excalidraw/tests/test-utils"; import ExcalidrawApp from "../App"; import { API } from "../../packages/excalidraw/tests/helpers/api"; import { syncInvalidIndices } from "../../packages/excalidraw/fractionalIndex"; import { createRedoAction, createUndoAction, } from "../../packages/excalidraw/actions/actionHistory"; import { StoreAction, newElementWith } from "../../packages/excalidraw"; const { h } = window; Object.defineProperty(window, "crypto", { value: { getRandomValues: (arr: number[]) => arr.forEach((v, i) => (arr[i] = Math.floor(Math.random() * 256))), subtle: { generateKey: () => {}, exportKey: () => ({ k: "sTdLvMC_M3V8_vGa3UVRDg" }), }, }, }); vi.mock("../../excalidraw-app/data/firebase.ts", () => { const loadFromFirebase = async () => null; const saveToFirebase = () => {}; const isSavedToFirebase = () => true; const loadFilesFromFirebase = async () => ({ loadedFiles: [], erroredFiles: [], }); const saveFilesToFirebase = async () => ({ savedFiles: new Map(), erroredFiles: new Map(), }); return { loadFromFirebase, saveToFirebase, isSavedToFirebase, loadFilesFromFirebase, saveFilesToFirebase, }; }); vi.mock("socket.io-client", () => { return { default: () => { return { close: () => {}, on: () => {}, once: () => {}, off: () => {}, emit: () => {}, }; }, }; }); /** * These test would deserve to be extended by testing collab with (at least) two clients simultanouesly, * while having access to both scenes, appstates stores, histories and etc. * i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously. */ describe("collaboration", () => { it("should allow to undo / redo even on force-deleted elements", async () => { await render(); const rect1Props = { type: "rectangle", id: "A", height: 200, width: 100, } as const; const rect2Props = { type: "rectangle", id: "B", width: 100, height: 200, } as const; const rect1 = API.createElement({ ...rect1Props }); const rect2 = API.createElement({ ...rect2Props }); API.updateScene({ elements: syncInvalidIndices([rect1, rect2]), storeAction: StoreAction.CAPTURE, }); API.updateScene({ elements: syncInvalidIndices([ rect1, newElementWith(h.elements[1], { isDeleted: true }), ]), storeAction: StoreAction.CAPTURE, }); await waitFor(() => { expect(API.getUndoStack().length).toBe(2); expect(API.getSnapshot()).toEqual([ expect.objectContaining(rect1Props), expect.objectContaining({ ...rect2Props, isDeleted: true }), ]); expect(h.elements).toEqual([ expect.objectContaining(rect1Props), expect.objectContaining({ ...rect2Props, isDeleted: true }), ]); }); // one form of force deletion happens when starting the collab, not to sync potentially sensitive data into the server window.collab.startCollaboration(null); await waitFor(() => { expect(API.getUndoStack().length).toBe(2); // we never delete from the local snapshot as it is used for correct diff calculation expect(API.getSnapshot()).toEqual([ expect.objectContaining(rect1Props), expect.objectContaining({ ...rect2Props, isDeleted: true }), ]); expect(h.elements).toEqual([expect.objectContaining(rect1Props)]); }); const undoAction = createUndoAction(h.history, h.store); act(() => h.app.actionManager.executeAction(undoAction)); // with explicit undo (as addition) we expect our item to be restored from the snapshot! await waitFor(() => { expect(API.getUndoStack().length).toBe(1); expect(API.getSnapshot()).toEqual([ expect.objectContaining(rect1Props), expect.objectContaining({ ...rect2Props, isDeleted: false }), ]); expect(h.elements).toEqual([ expect.objectContaining(rect1Props), expect.objectContaining({ ...rect2Props, isDeleted: false }), ]); }); // simulate force deleting the element remotely API.updateScene({ elements: syncInvalidIndices([rect1]), storeAction: StoreAction.UPDATE, }); await waitFor(() => { expect(API.getUndoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(1); expect(API.getSnapshot()).toEqual([ expect.objectContaining(rect1Props), expect.objectContaining({ ...rect2Props, isDeleted: true }), ]); expect(h.elements).toEqual([expect.objectContaining(rect1Props)]); }); const redoAction = createRedoAction(h.history, h.store); act(() => h.app.actionManager.executeAction(redoAction)); // with explicit redo (as removal) we again restore the element from the snapshot! await waitFor(() => { expect(API.getUndoStack().length).toBe(2); expect(API.getRedoStack().length).toBe(0); expect(API.getSnapshot()).toEqual([ expect.objectContaining(rect1Props), expect.objectContaining({ ...rect2Props, isDeleted: true }), ]); expect(h.elements).toEqual([ expect.objectContaining(rect1Props), expect.objectContaining({ ...rect2Props, isDeleted: true }), ]); }); act(() => h.app.actionManager.executeAction(undoAction)); // simulate local update API.updateScene({ elements: syncInvalidIndices([ h.elements[0], newElementWith(h.elements[1], { x: 100 }), ]), storeAction: StoreAction.CAPTURE, }); await waitFor(() => { expect(API.getUndoStack().length).toBe(2); expect(API.getRedoStack().length).toBe(0); expect(API.getSnapshot()).toEqual([ expect.objectContaining(rect1Props), expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }), ]); expect(h.elements).toEqual([ expect.objectContaining(rect1Props), expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }), ]); }); act(() => h.app.actionManager.executeAction(undoAction)); // we expect to iterate the stack to the first visible change await waitFor(() => { expect(API.getUndoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(1); expect(API.getSnapshot()).toEqual([ expect.objectContaining(rect1Props), expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }), ]); expect(h.elements).toEqual([ expect.objectContaining(rect1Props), expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }), ]); }); // simulate force deleting the element remotely API.updateScene({ elements: syncInvalidIndices([rect1]), storeAction: StoreAction.UPDATE, }); // snapshot was correctly updated and marked the element as deleted await waitFor(() => { expect(API.getUndoStack().length).toBe(1); expect(API.getRedoStack().length).toBe(1); expect(API.getSnapshot()).toEqual([ expect.objectContaining(rect1Props), expect.objectContaining({ ...rect2Props, isDeleted: true, x: 0 }), ]); expect(h.elements).toEqual([expect.objectContaining(rect1Props)]); }); act(() => h.app.actionManager.executeAction(redoAction)); // with explicit redo (as update) we again restored the element from the snapshot! await waitFor(() => { expect(API.getUndoStack().length).toBe(2); expect(API.getRedoStack().length).toBe(0); expect(API.getSnapshot()).toEqual([ expect.objectContaining({ id: "A", isDeleted: false }), expect.objectContaining({ id: "B", isDeleted: true, x: 100 }), ]); expect(h.history.isRedoStackEmpty).toBeTruthy(); expect(h.elements).toEqual([ expect.objectContaining({ id: "A", isDeleted: false }), expect.objectContaining({ id: "B", isDeleted: true, x: 100 }), ]); }); }); });