snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s
Some checks failed
Tests / test (push) Failing after 15s
This commit is contained in:
493
packages/excalidraw/tests/test-utils.ts
Normal file
493
packages/excalidraw/tests/test-utils.ts
Normal file
@@ -0,0 +1,493 @@
|
||||
import "pepjs";
|
||||
import { act } from "@testing-library/react";
|
||||
import {
|
||||
render,
|
||||
queries,
|
||||
waitFor,
|
||||
fireEvent,
|
||||
cleanup,
|
||||
} from "@testing-library/react";
|
||||
import ansi from "ansicolor";
|
||||
|
||||
import { ORIG_ID, arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import { getSelectedElements } from "@excalidraw/element";
|
||||
|
||||
import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import type { AllPossibleKeys } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { STORAGE_KEYS } from "../../../excalidraw-app/app_constants";
|
||||
|
||||
import { Pointer, UI } from "./helpers/ui";
|
||||
import * as toolQueries from "./queries/toolQueries";
|
||||
|
||||
import type { History } from "../history";
|
||||
|
||||
import type { RenderResult, RenderOptions } from "@testing-library/react";
|
||||
|
||||
import type { ImportedDataState } from "../data/types";
|
||||
|
||||
export { cleanup as unmountComponent };
|
||||
|
||||
const customQueries = {
|
||||
...queries,
|
||||
...toolQueries,
|
||||
};
|
||||
|
||||
type TestRenderFn = (
|
||||
ui: React.ReactElement,
|
||||
options?: Omit<
|
||||
RenderOptions & { localStorageData?: ImportedDataState },
|
||||
"queries"
|
||||
>,
|
||||
) => Promise<RenderResult<typeof customQueries>>;
|
||||
|
||||
const renderApp: TestRenderFn = async (ui, options) => {
|
||||
// when tests reuse Pointer instances let's reset the last
|
||||
// pointer poisitions so there's no leak between tests
|
||||
Pointer.resetAll();
|
||||
|
||||
if (options?.localStorageData) {
|
||||
initLocalStorage(options.localStorageData);
|
||||
delete options.localStorageData;
|
||||
}
|
||||
|
||||
const renderResult = render(ui, {
|
||||
queries: customQueries,
|
||||
...options,
|
||||
});
|
||||
|
||||
GlobalTestState.renderResult = renderResult;
|
||||
|
||||
Object.defineProperty(GlobalTestState, "canvas", {
|
||||
// must be a getter because at the time of ExcalidrawApp render the
|
||||
// child App component isn't likely mounted yet (and thus canvas not
|
||||
// present in DOM)
|
||||
get() {
|
||||
return renderResult.container.querySelector("canvas.static")!;
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(GlobalTestState, "interactiveCanvas", {
|
||||
// must be a getter because at the time of ExcalidrawApp render the
|
||||
// child App component isn't likely mounted yet (and thus canvas not
|
||||
// present in DOM)
|
||||
get() {
|
||||
return renderResult.container.querySelector("canvas.interactive")!;
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const canvas = renderResult.container.querySelector("canvas.static");
|
||||
if (!canvas) {
|
||||
throw new Error("not initialized yet");
|
||||
}
|
||||
|
||||
const interactiveCanvas =
|
||||
renderResult.container.querySelector("canvas.interactive");
|
||||
if (!interactiveCanvas) {
|
||||
throw new Error("not initialized yet");
|
||||
}
|
||||
|
||||
// hack-awaiting app.initialScene() which solves some test race conditions
|
||||
// (later we may switch this with proper event listener)
|
||||
if (window.h.state.isLoading) {
|
||||
throw new Error("still loading");
|
||||
}
|
||||
});
|
||||
|
||||
return renderResult;
|
||||
};
|
||||
|
||||
// re-export everything
|
||||
export * from "@testing-library/react";
|
||||
|
||||
// override render method
|
||||
export { renderApp as render };
|
||||
|
||||
/**
|
||||
* For state-sharing across test helpers.
|
||||
* NOTE: there shouldn't be concurrency issues as each test is running in its
|
||||
* own process and thus gets its own instance of this module when running
|
||||
* tests in parallel.
|
||||
*/
|
||||
export class GlobalTestState {
|
||||
/**
|
||||
* automatically updated on each call to render()
|
||||
*/
|
||||
static renderResult: RenderResult<typeof customQueries> = null!;
|
||||
/**
|
||||
* retrieves static canvas for currently rendered app instance
|
||||
*/
|
||||
static get canvas(): HTMLCanvasElement {
|
||||
return null!;
|
||||
}
|
||||
/**
|
||||
* retrieves interactive canvas for currently rendered app instance
|
||||
*/
|
||||
static get interactiveCanvas(): HTMLCanvasElement {
|
||||
return null!;
|
||||
}
|
||||
}
|
||||
|
||||
const initLocalStorage = (data: ImportedDataState) => {
|
||||
if (data.elements) {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||
JSON.stringify(data.elements),
|
||||
);
|
||||
}
|
||||
if (data.appState) {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
||||
JSON.stringify(data.appState),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const originalGetBoundingClientRect =
|
||||
global.window.HTMLDivElement.prototype.getBoundingClientRect;
|
||||
|
||||
export const mockBoundingClientRect = (
|
||||
{
|
||||
top = 0,
|
||||
left = 0,
|
||||
bottom = 0,
|
||||
right = 0,
|
||||
width = 1920,
|
||||
height = 1080,
|
||||
x = 0,
|
||||
y = 0,
|
||||
toJSON = () => {},
|
||||
} = {
|
||||
top: 10,
|
||||
left: 20,
|
||||
bottom: 10,
|
||||
right: 10,
|
||||
width: 200,
|
||||
x: 10,
|
||||
y: 20,
|
||||
height: 100,
|
||||
},
|
||||
) => {
|
||||
// override getBoundingClientRect as by default it will always return all values as 0 even if customized in html
|
||||
global.window.HTMLDivElement.prototype.getBoundingClientRect = () => ({
|
||||
top,
|
||||
left,
|
||||
bottom,
|
||||
right,
|
||||
width,
|
||||
height,
|
||||
x,
|
||||
y,
|
||||
toJSON,
|
||||
});
|
||||
};
|
||||
|
||||
export const withExcalidrawDimensions = async (
|
||||
dimensions: { width: number; height: number },
|
||||
cb: () => void,
|
||||
) => {
|
||||
const { h } = window;
|
||||
|
||||
mockBoundingClientRect(dimensions);
|
||||
act(() => {
|
||||
h.app.refreshEditorInterface();
|
||||
h.app.refresh();
|
||||
});
|
||||
|
||||
await cb();
|
||||
|
||||
restoreOriginalGetBoundingClientRect();
|
||||
act(() => {
|
||||
h.app.refreshEditorInterface();
|
||||
h.app.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
export const restoreOriginalGetBoundingClientRect = () => {
|
||||
global.window.HTMLDivElement.prototype.getBoundingClientRect =
|
||||
originalGetBoundingClientRect;
|
||||
};
|
||||
|
||||
export const assertSelectedElements = (
|
||||
...elements: (
|
||||
| (ExcalidrawElement["id"] | ExcalidrawElement)[]
|
||||
| ExcalidrawElement["id"]
|
||||
| ExcalidrawElement
|
||||
)[]
|
||||
) => {
|
||||
const { h } = window;
|
||||
const selectedElementIds = getSelectedElements(
|
||||
h.app.getSceneElements(),
|
||||
h.state,
|
||||
).map((el) => el.id);
|
||||
const ids = elements
|
||||
.flat()
|
||||
.map((item) => (typeof item === "string" ? item : item.id));
|
||||
expect(selectedElementIds.length).toBe(ids.length);
|
||||
expect(selectedElementIds).toEqual(expect.arrayContaining(ids));
|
||||
};
|
||||
|
||||
export const toggleMenu = (container: HTMLElement) => {
|
||||
// open menu
|
||||
fireEvent.click(container.querySelector(".dropdown-menu-button")!);
|
||||
};
|
||||
|
||||
export const togglePopover = (label: string) => {
|
||||
// Needed for radix-ui/react-popover as tests fail due to resize observer not being present
|
||||
(global as any).ResizeObserver = class ResizeObserver {
|
||||
constructor(cb: any) {
|
||||
(this as any).cb = cb;
|
||||
}
|
||||
|
||||
observe() {}
|
||||
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
|
||||
UI.clickLabeledElement(label);
|
||||
};
|
||||
|
||||
expect.extend({
|
||||
toBeNonNaNNumber(received) {
|
||||
const pass = typeof received === "number" && !isNaN(received);
|
||||
if (pass) {
|
||||
return {
|
||||
message: () => `expected ${received} not to be a non-NaN number`,
|
||||
pass: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
message: () => `expected ${received} to be a non-NaN number`,
|
||||
pass: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Serializer for IEE754 float pointing numbers to avoid random failures due to tiny precision differences
|
||||
*/
|
||||
expect.addSnapshotSerializer({
|
||||
serialize(val, config, indentation, depth, refs, printer) {
|
||||
return printer(val.toFixed(5), config, indentation, depth, refs);
|
||||
},
|
||||
test(val) {
|
||||
return (
|
||||
typeof val === "number" &&
|
||||
Number.isFinite(val) &&
|
||||
!Number.isNaN(val) &&
|
||||
!Number.isInteger(val)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const getCloneByOrigId = <T extends boolean = false>(
|
||||
origId: ExcalidrawElement["id"],
|
||||
returnNullIfNotExists: T = false as T,
|
||||
): T extends true ? ExcalidrawElement | null : ExcalidrawElement => {
|
||||
const clonedElement = window.h.elements?.find(
|
||||
(el) => (el as any)[ORIG_ID] === origId,
|
||||
);
|
||||
if (clonedElement) {
|
||||
return clonedElement;
|
||||
}
|
||||
if (returnNullIfNotExists !== true) {
|
||||
throw new Error(`cloned element not found for origId: ${origId}`);
|
||||
}
|
||||
return null as T extends true ? ExcalidrawElement | null : ExcalidrawElement;
|
||||
};
|
||||
|
||||
/**
|
||||
* Assertion helper that strips the actual elements of extra attributes
|
||||
* so that diffs are easier to read in case of failure.
|
||||
*
|
||||
* Asserts element order as well, and selected element ids
|
||||
* (when `selected: true` set for given element).
|
||||
*
|
||||
* If testing cloned elements, you can use { `[ORIG_ID]: origElement.id }
|
||||
* If you need to refer to cloned element properties, you can use
|
||||
* `getCloneByOrigId()`, e.g.: `{ frameId: getCloneByOrigId(origFrame.id)?.id }`
|
||||
*/
|
||||
export const assertElements = <T extends AllPossibleKeys<ExcalidrawElement>>(
|
||||
actualElements: readonly ExcalidrawElement[],
|
||||
/** array order matters */
|
||||
expectedElements: (Partial<Record<T, any>> & {
|
||||
/** meta, will be stripped for element attribute checks */
|
||||
selected?: true;
|
||||
} & (
|
||||
| {
|
||||
id: ExcalidrawElement["id"];
|
||||
}
|
||||
| { [ORIG_ID]?: string }
|
||||
))[],
|
||||
) => {
|
||||
const h = window.h;
|
||||
|
||||
const expectedElementsWithIds: (typeof expectedElements[number] & {
|
||||
id: ExcalidrawElement["id"];
|
||||
})[] = expectedElements.map((el) => {
|
||||
if ("id" in el) {
|
||||
return el;
|
||||
}
|
||||
const actualElement = actualElements.find(
|
||||
(act) => (act as any)[ORIG_ID] === el[ORIG_ID],
|
||||
);
|
||||
if (actualElement) {
|
||||
return { ...el, id: actualElement.id };
|
||||
}
|
||||
return {
|
||||
...el,
|
||||
id: "UNKNOWN_ID",
|
||||
};
|
||||
});
|
||||
|
||||
const map_expectedElements = arrayToMap(expectedElementsWithIds);
|
||||
|
||||
const selectedElementIds = expectedElementsWithIds.reduce(
|
||||
(acc: Record<ExcalidrawElement["id"], true>, el) => {
|
||||
if (el.selected) {
|
||||
acc[el.id] = true;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const mappedActualElements = actualElements.map((el) => {
|
||||
const expectedElement = map_expectedElements.get(el.id);
|
||||
if (expectedElement) {
|
||||
const pickedAttrs: Record<string, any> = {};
|
||||
|
||||
for (const key of Object.keys(expectedElement)) {
|
||||
if (key === "selected") {
|
||||
delete expectedElement.selected;
|
||||
continue;
|
||||
}
|
||||
pickedAttrs[key] = (el as any)[key];
|
||||
}
|
||||
|
||||
if (ORIG_ID in expectedElement) {
|
||||
// @ts-ignore
|
||||
pickedAttrs[ORIG_ID] = (el as any)[ORIG_ID];
|
||||
}
|
||||
|
||||
return pickedAttrs;
|
||||
}
|
||||
return el;
|
||||
});
|
||||
|
||||
try {
|
||||
// testing order separately for even easier diffs
|
||||
expect(actualElements.map((x) => x.id)).toEqual(
|
||||
expectedElementsWithIds.map((x) => x.id),
|
||||
);
|
||||
} catch (err: any) {
|
||||
let errStr = "\n\nmismatched element order\n\n";
|
||||
|
||||
errStr += `actual: ${ansi.lightGray(
|
||||
`[${err.actual
|
||||
.map((id: string, index: number) => {
|
||||
const act = actualElements[index];
|
||||
|
||||
return `${
|
||||
id === err.expected[index] ? ansi.green(id) : ansi.red(id)
|
||||
} (${act.type.slice(0, 4)}${
|
||||
ORIG_ID in act ? ` ↳ ${(act as any)[ORIG_ID]}` : ""
|
||||
})`;
|
||||
})
|
||||
.join(", ")}]`,
|
||||
)}\n${ansi.lightGray(
|
||||
`expected: [${err.expected
|
||||
.map((exp: string, index: number) => {
|
||||
const expEl = actualElements.find((el) => el.id === exp);
|
||||
const origEl =
|
||||
expEl &&
|
||||
actualElements.find((el) => el.id === (expEl as any)[ORIG_ID]);
|
||||
return expEl
|
||||
? `${
|
||||
exp === err.actual[index]
|
||||
? ansi.green(expEl.id)
|
||||
: ansi.red(expEl.id)
|
||||
} (${expEl.type.slice(0, 4)}${origEl ? ` ↳ ${origEl.id}` : ""})`
|
||||
: exp;
|
||||
})
|
||||
.join(", ")}]\n`,
|
||||
)}`;
|
||||
|
||||
throw trimErrorStack(new Error(errStr), 1);
|
||||
}
|
||||
|
||||
expect(mappedActualElements).toEqual(
|
||||
expect.arrayContaining(expectedElementsWithIds),
|
||||
);
|
||||
|
||||
expect(h.state.selectedElementIds).toEqual(selectedElementIds);
|
||||
};
|
||||
|
||||
const stripProps = (
|
||||
deltas: Record<string, { deleted: any; inserted: any }>,
|
||||
props: string[],
|
||||
) =>
|
||||
Object.entries(deltas).reduce((acc, curr) => {
|
||||
const { inserted, deleted, ...rest } = curr[1];
|
||||
|
||||
for (const prop of props) {
|
||||
delete inserted[prop];
|
||||
delete deleted[prop];
|
||||
}
|
||||
|
||||
acc[curr[0]] = {
|
||||
inserted,
|
||||
deleted,
|
||||
...rest,
|
||||
};
|
||||
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
|
||||
export const checkpointHistory = (history: History, name: string) => {
|
||||
expect(
|
||||
history.undoStack.map((x) => ({
|
||||
...x,
|
||||
elements: {
|
||||
...x.elements,
|
||||
added: stripProps(x.elements.added, ["seed", "versionNonce"]),
|
||||
removed: stripProps(x.elements.removed, ["seed", "versionNonce"]),
|
||||
updated: stripProps(x.elements.updated, ["seed", "versionNonce"]),
|
||||
},
|
||||
})),
|
||||
).toMatchSnapshot(`[${name}] undo stack`);
|
||||
|
||||
expect(
|
||||
history.redoStack.map((x) => ({
|
||||
...x,
|
||||
elements: {
|
||||
...x.elements,
|
||||
added: stripProps(x.elements.added, ["seed", "versionNonce"]),
|
||||
removed: stripProps(x.elements.removed, ["seed", "versionNonce"]),
|
||||
updated: stripProps(x.elements.updated, ["seed", "versionNonce"]),
|
||||
},
|
||||
})),
|
||||
).toMatchSnapshot(`[${name}] redo stack`);
|
||||
};
|
||||
|
||||
/**
|
||||
* removes one or more leading stack trace lines (leading to files) from the
|
||||
* error stack trace
|
||||
*/
|
||||
export const trimErrorStack = (error: Error, range = 1) => {
|
||||
const stack = error.stack?.split("\n");
|
||||
if (stack) {
|
||||
stack.splice(1, range);
|
||||
error.stack = stack.join("\n");
|
||||
}
|
||||
return error;
|
||||
};
|
||||
|
||||
export const stripIgnoredNodesFromErrorMessage = (error: Error) => {
|
||||
error.message = error.message.replace(/\s+Ignored nodes:[\s\S]+/, "");
|
||||
return error;
|
||||
};
|
||||
Reference in New Issue
Block a user