aboutsummaryrefslogtreecommitdiffstats
path: root/excalidraw-app/tests/collab.test.tsx
diff options
context:
space:
mode:
authorkj_sh6042026-03-15 16:19:35 -0400
committerkj_sh6042026-03-15 16:19:35 -0400
commitbfc2cec7d43eb8eaa46dd3f91084932381257059 (patch)
tree0857e3aac2cff922826d4871ff54536b26fad6fc /excalidraw-app/tests/collab.test.tsx
parent225db4a7805befe009fe055fc2ef5daedd6c04f9 (diff)
refactor: excalidraw-app/
Diffstat (limited to 'excalidraw-app/tests/collab.test.tsx')
-rw-r--r--excalidraw-app/tests/collab.test.tsx247
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 }),
+ ]);
+ });
+ });
+});