2
0

collab.test.tsx 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. import { CaptureUpdateAction, newElementWith } from "@excalidraw/excalidraw";
  2. import {
  3. createRedoAction,
  4. createUndoAction,
  5. } from "@excalidraw/excalidraw/actions/actionHistory";
  6. import { syncInvalidIndices } from "@excalidraw/element";
  7. import { API } from "@excalidraw/excalidraw/tests/helpers/api";
  8. import { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils";
  9. import { vi } from "vitest";
  10. import { StoreIncrement } from "@excalidraw/element";
  11. import type { DurableIncrement, EphemeralIncrement } from "@excalidraw/element";
  12. import ExcalidrawApp from "../App";
  13. const { h } = window;
  14. Object.defineProperty(window, "crypto", {
  15. value: {
  16. getRandomValues: (arr: number[]) =>
  17. arr.forEach((v, i) => (arr[i] = Math.floor(Math.random() * 256))),
  18. subtle: {
  19. generateKey: () => {},
  20. exportKey: () => ({ k: "sTdLvMC_M3V8_vGa3UVRDg" }),
  21. },
  22. },
  23. });
  24. vi.mock("../../excalidraw-app/data/firebase.ts", () => {
  25. const loadFromFirebase = async () => null;
  26. const saveToFirebase = () => {};
  27. const isSavedToFirebase = () => true;
  28. const loadFilesFromFirebase = async () => ({
  29. loadedFiles: [],
  30. erroredFiles: [],
  31. });
  32. const saveFilesToFirebase = async () => ({
  33. savedFiles: new Map(),
  34. erroredFiles: new Map(),
  35. });
  36. return {
  37. loadFromFirebase,
  38. saveToFirebase,
  39. isSavedToFirebase,
  40. loadFilesFromFirebase,
  41. saveFilesToFirebase,
  42. };
  43. });
  44. vi.mock("socket.io-client", () => {
  45. return {
  46. default: () => {
  47. return {
  48. close: () => {},
  49. on: () => {},
  50. once: () => {},
  51. off: () => {},
  52. emit: () => {},
  53. };
  54. },
  55. };
  56. });
  57. /**
  58. * These test would deserve to be extended by testing collab with (at least) two clients simultanouesly,
  59. * while having access to both scenes, appstates stores, histories and etc.
  60. * i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
  61. */
  62. describe("collaboration", () => {
  63. it("should emit two ephemeral increments even though updates get batched", async () => {
  64. const durableIncrements: DurableIncrement[] = [];
  65. const ephemeralIncrements: EphemeralIncrement[] = [];
  66. await render(<ExcalidrawApp />);
  67. h.store.onStoreIncrementEmitter.on((increment) => {
  68. if (StoreIncrement.isDurable(increment)) {
  69. durableIncrements.push(increment);
  70. } else {
  71. ephemeralIncrements.push(increment);
  72. }
  73. });
  74. // eslint-disable-next-line dot-notation
  75. expect(h.store["scheduledMicroActions"].length).toBe(0);
  76. expect(durableIncrements.length).toBe(0);
  77. expect(ephemeralIncrements.length).toBe(0);
  78. const rectProps = {
  79. type: "rectangle",
  80. id: "A",
  81. height: 200,
  82. width: 100,
  83. x: 0,
  84. y: 0,
  85. } as const;
  86. const rect = API.createElement({ ...rectProps });
  87. API.updateScene({
  88. elements: [rect],
  89. captureUpdate: CaptureUpdateAction.IMMEDIATELY,
  90. });
  91. await waitFor(() => {
  92. // expect(commitSpy).toHaveBeenCalledTimes(1);
  93. expect(durableIncrements.length).toBe(1);
  94. });
  95. // simulate two batched remote updates
  96. act(() => {
  97. h.app.updateScene({
  98. elements: [newElementWith(h.elements[0], { x: 100 })],
  99. captureUpdate: CaptureUpdateAction.NEVER,
  100. });
  101. h.app.updateScene({
  102. elements: [newElementWith(h.elements[0], { x: 200 })],
  103. captureUpdate: CaptureUpdateAction.NEVER,
  104. });
  105. // we scheduled two micro actions,
  106. // which confirms they are going to be executed as part of one batched component update
  107. // eslint-disable-next-line dot-notation
  108. expect(h.store["scheduledMicroActions"].length).toBe(2);
  109. });
  110. await waitFor(() => {
  111. // altough the updates get batched,
  112. // we expect two ephemeral increments for each update,
  113. // and each such update should have the expected change
  114. expect(ephemeralIncrements.length).toBe(2);
  115. expect(ephemeralIncrements[0].change.elements.A).toEqual(
  116. expect.objectContaining({ x: 100 }),
  117. );
  118. expect(ephemeralIncrements[1].change.elements.A).toEqual(
  119. expect.objectContaining({ x: 200 }),
  120. );
  121. // eslint-disable-next-line dot-notation
  122. expect(h.store["scheduledMicroActions"].length).toBe(0);
  123. });
  124. });
  125. it("should allow to undo / redo even on force-deleted elements", async () => {
  126. await render(<ExcalidrawApp />);
  127. const rect1Props = {
  128. type: "rectangle",
  129. id: "A",
  130. height: 200,
  131. width: 100,
  132. } as const;
  133. const rect2Props = {
  134. type: "rectangle",
  135. id: "B",
  136. width: 100,
  137. height: 200,
  138. } as const;
  139. const rect1 = API.createElement({ ...rect1Props });
  140. const rect2 = API.createElement({ ...rect2Props });
  141. API.updateScene({
  142. elements: syncInvalidIndices([rect1, rect2]),
  143. captureUpdate: CaptureUpdateAction.IMMEDIATELY,
  144. });
  145. API.updateScene({
  146. elements: syncInvalidIndices([
  147. rect1,
  148. newElementWith(h.elements[1], { isDeleted: true }),
  149. ]),
  150. captureUpdate: CaptureUpdateAction.IMMEDIATELY,
  151. });
  152. await waitFor(() => {
  153. expect(API.getUndoStack().length).toBe(2);
  154. expect(API.getSnapshot()).toEqual([
  155. expect.objectContaining(rect1Props),
  156. expect.objectContaining({ ...rect2Props, isDeleted: true }),
  157. ]);
  158. expect(h.elements).toEqual([
  159. expect.objectContaining(rect1Props),
  160. expect.objectContaining({ ...rect2Props, isDeleted: true }),
  161. ]);
  162. });
  163. // one form of force deletion happens when starting the collab, not to sync potentially sensitive data into the server
  164. window.collab.startCollaboration(null);
  165. await waitFor(() => {
  166. expect(API.getUndoStack().length).toBe(2);
  167. // we never delete from the local snapshot as it is used for correct diff calculation
  168. expect(API.getSnapshot()).toEqual([
  169. expect.objectContaining(rect1Props),
  170. expect.objectContaining({ ...rect2Props, isDeleted: true }),
  171. ]);
  172. expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
  173. });
  174. const undoAction = createUndoAction(h.history);
  175. act(() => h.app.actionManager.executeAction(undoAction));
  176. // with explicit undo (as addition) we expect our item to be restored from the snapshot!
  177. await waitFor(() => {
  178. expect(API.getUndoStack().length).toBe(1);
  179. expect(API.getRedoStack().length).toBe(1);
  180. expect(API.getSnapshot()).toEqual([
  181. expect.objectContaining(rect1Props),
  182. expect.objectContaining({ ...rect2Props, isDeleted: false }),
  183. ]);
  184. expect(h.elements).toEqual([
  185. expect.objectContaining(rect1Props),
  186. expect.objectContaining({ ...rect2Props, isDeleted: false }),
  187. ]);
  188. });
  189. // simulate force deleting the element remotely
  190. API.updateScene({
  191. elements: syncInvalidIndices([rect1]),
  192. captureUpdate: CaptureUpdateAction.NEVER,
  193. });
  194. await waitFor(() => {
  195. expect(API.getUndoStack().length).toBe(1);
  196. expect(API.getRedoStack().length).toBe(1);
  197. expect(API.getSnapshot()).toEqual([
  198. expect.objectContaining(rect1Props),
  199. expect.objectContaining({ ...rect2Props, isDeleted: true }),
  200. ]);
  201. expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
  202. });
  203. const redoAction = createRedoAction(h.history);
  204. act(() => h.app.actionManager.executeAction(redoAction));
  205. // with explicit redo (as removal) we again restore the element from the snapshot!
  206. await waitFor(() => {
  207. expect(API.getUndoStack().length).toBe(2);
  208. expect(API.getRedoStack().length).toBe(0);
  209. expect(API.getSnapshot()).toEqual([
  210. expect.objectContaining(rect1Props),
  211. expect.objectContaining({ ...rect2Props, isDeleted: true }),
  212. ]);
  213. expect(h.elements).toEqual([
  214. expect.objectContaining(rect1Props),
  215. expect.objectContaining({ ...rect2Props, isDeleted: true }),
  216. ]);
  217. });
  218. });
  219. });