collab.test.tsx 8.0 KB

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