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.
+
+
+
+`;
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