collab.test.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  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.getSnapshot()).toEqual([
  180. expect.objectContaining(rect1Props),
  181. expect.objectContaining({ ...rect2Props, isDeleted: false }),
  182. ]);
  183. expect(h.elements).toEqual([
  184. expect.objectContaining(rect1Props),
  185. expect.objectContaining({ ...rect2Props, isDeleted: false }),
  186. ]);
  187. });
  188. // simulate force deleting the element remotely
  189. API.updateScene({
  190. elements: syncInvalidIndices([rect1]),
  191. captureUpdate: CaptureUpdateAction.NEVER,
  192. });
  193. await waitFor(() => {
  194. expect(API.getUndoStack().length).toBe(1);
  195. expect(API.getRedoStack().length).toBe(1);
  196. expect(API.getSnapshot()).toEqual([
  197. expect.objectContaining(rect1Props),
  198. expect.objectContaining({ ...rect2Props, isDeleted: true }),
  199. ]);
  200. expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
  201. });
  202. const redoAction = createRedoAction(h.history);
  203. act(() => h.app.actionManager.executeAction(redoAction));
  204. // with explicit redo (as removal) we again restore the element from the snapshot!
  205. await waitFor(() => {
  206. expect(API.getUndoStack().length).toBe(2);
  207. expect(API.getRedoStack().length).toBe(0);
  208. expect(API.getSnapshot()).toEqual([
  209. expect.objectContaining(rect1Props),
  210. expect.objectContaining({ ...rect2Props, isDeleted: true }),
  211. ]);
  212. expect(h.elements).toEqual([
  213. expect.objectContaining(rect1Props),
  214. expect.objectContaining({ ...rect2Props, isDeleted: true }),
  215. ]);
  216. });
  217. act(() => h.app.actionManager.executeAction(undoAction));
  218. // simulate local update
  219. API.updateScene({
  220. elements: syncInvalidIndices([
  221. h.elements[0],
  222. newElementWith(h.elements[1], { x: 100 }),
  223. ]),
  224. captureUpdate: CaptureUpdateAction.IMMEDIATELY,
  225. });
  226. await waitFor(() => {
  227. expect(API.getUndoStack().length).toBe(2);
  228. expect(API.getRedoStack().length).toBe(0);
  229. expect(API.getSnapshot()).toEqual([
  230. expect.objectContaining(rect1Props),
  231. expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
  232. ]);
  233. expect(h.elements).toEqual([
  234. expect.objectContaining(rect1Props),
  235. expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
  236. ]);
  237. });
  238. act(() => h.app.actionManager.executeAction(undoAction));
  239. // we expect to iterate the stack to the first visible change
  240. await waitFor(() => {
  241. expect(API.getUndoStack().length).toBe(1);
  242. expect(API.getRedoStack().length).toBe(1);
  243. expect(API.getSnapshot()).toEqual([
  244. expect.objectContaining(rect1Props),
  245. expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
  246. ]);
  247. expect(h.elements).toEqual([
  248. expect.objectContaining(rect1Props),
  249. expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
  250. ]);
  251. });
  252. // simulate force deleting the element remotely
  253. API.updateScene({
  254. elements: syncInvalidIndices([rect1]),
  255. captureUpdate: CaptureUpdateAction.NEVER,
  256. });
  257. // snapshot was correctly updated and marked the element as deleted
  258. await waitFor(() => {
  259. expect(API.getUndoStack().length).toBe(1);
  260. expect(API.getRedoStack().length).toBe(1);
  261. expect(API.getSnapshot()).toEqual([
  262. expect.objectContaining(rect1Props),
  263. expect.objectContaining({ ...rect2Props, isDeleted: true, x: 0 }),
  264. ]);
  265. expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
  266. });
  267. act(() => h.app.actionManager.executeAction(redoAction));
  268. // with explicit redo (as update) we again restored the element from the snapshot!
  269. await waitFor(() => {
  270. expect(API.getUndoStack().length).toBe(2);
  271. expect(API.getRedoStack().length).toBe(0);
  272. expect(API.getSnapshot()).toEqual([
  273. expect.objectContaining({ id: "A", isDeleted: false }),
  274. expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
  275. ]);
  276. expect(h.history.isRedoStackEmpty).toBeTruthy();
  277. expect(h.elements).toEqual([
  278. expect.objectContaining({ id: "A", isDeleted: false }),
  279. expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
  280. ]);
  281. });
  282. });
  283. });