From bfc2cec7d43eb8eaa46dd3f91084932381257059 Mon Sep 17 00:00:00 2001 From: kj_sh604 Date: Sun, 15 Mar 2026 16:19:35 -0400 Subject: refactor: excalidraw-app/ --- excalidraw-app/tests/LanguageList.test.tsx | 34 +++ excalidraw-app/tests/MobileMenu.test.tsx | 51 +++++ .../tests/__snapshots__/MobileMenu.test.tsx.snap | 247 +++++++++++++++++++++ excalidraw-app/tests/collab.test.tsx | 247 +++++++++++++++++++++ 4 files changed, 579 insertions(+) create mode 100644 excalidraw-app/tests/LanguageList.test.tsx create mode 100644 excalidraw-app/tests/MobileMenu.test.tsx create mode 100644 excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap create mode 100644 excalidraw-app/tests/collab.test.tsx (limited to 'excalidraw-app/tests') diff --git a/excalidraw-app/tests/LanguageList.test.tsx b/excalidraw-app/tests/LanguageList.test.tsx new file mode 100644 index 0000000..a0cfe07 --- /dev/null +++ b/excalidraw-app/tests/LanguageList.test.tsx @@ -0,0 +1,34 @@ +import { defaultLang } from "@excalidraw/excalidraw/i18n"; +import { UI } from "@excalidraw/excalidraw/tests/helpers/ui"; +import { + screen, + fireEvent, + waitFor, + render, +} from "@excalidraw/excalidraw/tests/test-utils"; + +import ExcalidrawApp from "../App"; + +describe("Test LanguageList", () => { + it("rerenders UI on language change", async () => { + await render(); + + // select rectangle tool to show properties menu + UI.clickTool("rectangle"); + // english lang should display `thin` label + expect(screen.queryByTitle(/thin/i)).not.toBeNull(); + fireEvent.click(document.querySelector(".dropdown-menu-button")!); + + fireEvent.change(document.querySelector(".dropdown-select__language")!, { + target: { value: "de-DE" }, + }); + // switching to german, `thin` label should no longer exist + await waitFor(() => expect(screen.queryByTitle(/thin/i)).toBeNull()); + // reset language + fireEvent.change(document.querySelector(".dropdown-select__language")!, { + target: { value: defaultLang.code }, + }); + // switching back to English + await waitFor(() => expect(screen.queryByTitle(/thin/i)).not.toBeNull()); + }); +}); diff --git a/excalidraw-app/tests/MobileMenu.test.tsx b/excalidraw-app/tests/MobileMenu.test.tsx new file mode 100644 index 0000000..a6eef3b --- /dev/null +++ b/excalidraw-app/tests/MobileMenu.test.tsx @@ -0,0 +1,51 @@ +import ExcalidrawApp from "../App"; +import { + mockBoundingClientRect, + render, + restoreOriginalGetBoundingClientRect, +} from "@excalidraw/excalidraw/tests/test-utils"; + +import { UI } from "@excalidraw/excalidraw/tests/helpers/ui"; + +describe("Test MobileMenu", () => { + const { h } = window; + const dimensions = { height: 400, width: 800 }; + + beforeAll(() => { + mockBoundingClientRect(dimensions); + }); + + beforeEach(async () => { + await render(); + // @ts-ignore + h.app.refreshViewportBreakpoints(); + // @ts-ignore + h.app.refreshEditorBreakpoints(); + }); + + afterAll(() => { + restoreOriginalGetBoundingClientRect(); + }); + + it("should set device correctly", () => { + expect(h.app.device).toMatchInlineSnapshot(` + { + "editor": { + "canFitSidebar": false, + "isMobile": true, + }, + "isTouchScreen": false, + "viewport": { + "isLandscape": false, + "isMobile": true, + }, + } + `); + }); + + it("should initialize with welcome screen and hide once user interacts", async () => { + expect(document.querySelector(".welcome-screen-center")).toMatchSnapshot(); + UI.clickTool("rectangle"); + expect(document.querySelector(".welcome-screen-center")).toBeNull(); + }); +}); diff --git a/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap b/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap new file mode 100644 index 0000000..77fc147 --- /dev/null +++ b/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap @@ -0,0 +1,247 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Test MobileMenu > should initialize with welcome screen and hide once user interacts 1`] = ` +
+ +
+ All your data is saved locally in your browser. +
+
+ + + + +
+ +
+
+ Sign up +
+
+
+
+`; 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(); + 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 }), + ]); + }); + }); +}); -- cgit v1.2.3