2023-07-27 20:20:11 +02:00
|
|
|
import { vi } from "vitest";
|
2023-12-12 07:02:51 +01:00
|
|
|
import {
|
2024-04-17 14:01:24 +02:00
|
|
|
act,
|
2023-12-12 07:02:51 +01:00
|
|
|
render,
|
|
|
|
updateSceneData,
|
|
|
|
waitFor,
|
|
|
|
} from "../../packages/excalidraw/tests/test-utils";
|
2023-12-07 12:09:11 +01:00
|
|
|
import ExcalidrawApp from "../App";
|
2023-12-12 07:02:51 +01:00
|
|
|
import { API } from "../../packages/excalidraw/tests/helpers/api";
|
2024-04-04 14:51:11 +02:00
|
|
|
import { syncInvalidIndices } from "../../packages/excalidraw/fractionalIndex";
|
2024-04-17 14:01:24 +02:00
|
|
|
import {
|
|
|
|
createRedoAction,
|
|
|
|
createUndoAction,
|
|
|
|
} from "../../packages/excalidraw/actions/actionHistory";
|
|
|
|
import { newElementWith } from "../../packages/excalidraw";
|
2024-04-04 14:51:11 +02:00
|
|
|
|
2020-10-26 15:53:55 +01:00
|
|
|
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" }),
|
|
|
|
},
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
2023-09-21 05:58:48 +02:00
|
|
|
vi.mock("../../excalidraw-app/data/firebase.ts", () => {
|
2020-10-26 15:53:55 +01:00
|
|
|
const loadFromFirebase = async () => null;
|
|
|
|
const saveToFirebase = () => {};
|
|
|
|
const isSavedToFirebase = () => true;
|
2021-10-21 22:05:48 +02:00
|
|
|
const loadFilesFromFirebase = async () => ({
|
|
|
|
loadedFiles: [],
|
|
|
|
erroredFiles: [],
|
|
|
|
});
|
|
|
|
const saveFilesToFirebase = async () => ({
|
|
|
|
savedFiles: new Map(),
|
|
|
|
erroredFiles: new Map(),
|
|
|
|
});
|
2020-10-26 15:53:55 +01:00
|
|
|
|
|
|
|
return {
|
|
|
|
loadFromFirebase,
|
|
|
|
saveToFirebase,
|
|
|
|
isSavedToFirebase,
|
2021-10-21 22:05:48 +02:00
|
|
|
loadFilesFromFirebase,
|
|
|
|
saveFilesToFirebase,
|
2020-10-26 15:53:55 +01:00
|
|
|
};
|
|
|
|
});
|
|
|
|
|
2023-07-27 20:20:11 +02:00
|
|
|
vi.mock("socket.io-client", () => {
|
|
|
|
return {
|
|
|
|
default: () => {
|
|
|
|
return {
|
|
|
|
close: () => {},
|
|
|
|
on: () => {},
|
|
|
|
once: () => {},
|
|
|
|
off: () => {},
|
|
|
|
emit: () => {},
|
|
|
|
};
|
|
|
|
},
|
2020-10-30 21:01:41 +01:00
|
|
|
};
|
|
|
|
});
|
|
|
|
|
2024-04-17 14:01:24 +02:00
|
|
|
/**
|
|
|
|
* 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.
|
|
|
|
*/
|
2020-10-26 15:53:55 +01:00
|
|
|
describe("collaboration", () => {
|
2024-04-17 14:01:24 +02:00
|
|
|
it("should allow to undo / redo even on force-deleted elements", async () => {
|
2020-12-05 15:30:53 +01:00
|
|
|
await render(<ExcalidrawApp />);
|
2024-04-17 14:01:24 +02:00
|
|
|
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 });
|
|
|
|
|
|
|
|
updateSceneData({
|
|
|
|
elements: syncInvalidIndices([rect1, rect2]),
|
|
|
|
commitToStore: true,
|
|
|
|
});
|
|
|
|
|
2020-12-05 15:30:53 +01:00
|
|
|
updateSceneData({
|
2024-04-04 14:51:11 +02:00
|
|
|
elements: syncInvalidIndices([
|
2024-04-17 14:01:24 +02:00
|
|
|
rect1,
|
|
|
|
newElementWith(h.elements[1], { isDeleted: true }),
|
2024-04-04 14:51:11 +02:00
|
|
|
]),
|
2024-04-17 14:01:24 +02:00
|
|
|
commitToStore: true,
|
2020-12-05 15:30:53 +01:00
|
|
|
});
|
2024-04-17 14:01:24 +02:00
|
|
|
|
2020-10-26 15:53:55 +01:00
|
|
|
await waitFor(() => {
|
2024-04-17 14:01:24 +02:00
|
|
|
expect(API.getUndoStack().length).toBe(2);
|
|
|
|
expect(API.getSnapshot()).toEqual([
|
|
|
|
expect.objectContaining(rect1Props),
|
|
|
|
expect.objectContaining({ ...rect2Props, isDeleted: true }),
|
|
|
|
]);
|
2020-10-26 15:53:55 +01:00
|
|
|
expect(h.elements).toEqual([
|
2024-04-17 14:01:24 +02:00
|
|
|
expect.objectContaining(rect1Props),
|
|
|
|
expect.objectContaining({ ...rect2Props, isDeleted: true }),
|
2020-10-26 15:53:55 +01:00
|
|
|
]);
|
|
|
|
});
|
2024-04-17 14:01:24 +02:00
|
|
|
|
|
|
|
// one form of force deletion happens when starting the collab, not to sync potentially sensitive data into the server
|
2022-07-05 16:03:40 +02:00
|
|
|
window.collab.startCollaboration(null);
|
2024-04-17 14:01:24 +02:00
|
|
|
|
|
|
|
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!
|
2020-10-26 15:53:55 +01:00
|
|
|
await waitFor(() => {
|
2024-04-17 14:01:24 +02:00
|
|
|
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
|
|
|
|
updateSceneData({
|
|
|
|
elements: syncInvalidIndices([rect1]),
|
|
|
|
});
|
|
|
|
|
|
|
|
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)]);
|
2020-10-26 15:53:55 +01:00
|
|
|
});
|
|
|
|
|
2024-04-17 14:01:24 +02:00
|
|
|
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!
|
2020-10-26 15:53:55 +01:00
|
|
|
await waitFor(() => {
|
2024-04-17 14:01:24 +02:00
|
|
|
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
|
|
|
|
updateSceneData({
|
|
|
|
elements: syncInvalidIndices([
|
|
|
|
h.elements[0],
|
|
|
|
newElementWith(h.elements[1], { x: 100 }),
|
|
|
|
]),
|
|
|
|
commitToStore: true,
|
|
|
|
});
|
|
|
|
|
|
|
|
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
|
|
|
|
updateSceneData({
|
|
|
|
elements: syncInvalidIndices([rect1]),
|
|
|
|
});
|
|
|
|
|
|
|
|
// 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 }),
|
|
|
|
]);
|
2020-10-26 15:53:55 +01:00
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|