collab.test.tsx 8.0 KB

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