diff options
| author | kj_sh604 | 2026-03-15 16:19:35 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-03-15 16:19:35 -0400 |
| commit | bfc2cec7d43eb8eaa46dd3f91084932381257059 (patch) | |
| tree | 0857e3aac2cff922826d4871ff54536b26fad6fc /excalidraw-app/tests/collab.test.tsx | |
| parent | 225db4a7805befe009fe055fc2ef5daedd6c04f9 (diff) | |
refactor: excalidraw-app/
Diffstat (limited to 'excalidraw-app/tests/collab.test.tsx')
| -rw-r--r-- | excalidraw-app/tests/collab.test.tsx | 247 |
1 files changed, 247 insertions, 0 deletions
diff --git a/excalidraw-app/tests/collab.test.tsx b/excalidraw-app/tests/collab.test.tsx new file mode 100644 index 0000000..80cf4b0 --- /dev/null +++ b/excalidraw-app/tests/collab.test.tsx @@ -0,0 +1,247 @@ +import { vi } from "vitest"; +import { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils"; +import ExcalidrawApp from "../App"; +import { API } from "@excalidraw/excalidraw/tests/helpers/api"; +import { syncInvalidIndices } from "@excalidraw/excalidraw/fractionalIndex"; +import { + createRedoAction, + createUndoAction, +} from "@excalidraw/excalidraw/actions/actionHistory"; +import { CaptureUpdateAction, newElementWith } from "@excalidraw/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(<ExcalidrawApp />); + 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]), + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }); + + API.updateScene({ + elements: syncInvalidIndices([ + rect1, + newElementWith(h.elements[1], { isDeleted: true }), + ]), + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }); + + 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]), + captureUpdate: CaptureUpdateAction.NEVER, + }); + + 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 }), + ]), + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }); + + 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]), + captureUpdate: CaptureUpdateAction.NEVER, + }); + + // 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 }), + ]); + }); + }); +}); |
