import "pepjs"; import type { RenderResult, RenderOptions } from "@testing-library/react"; import { act } from "@testing-library/react"; import { render, queries, waitFor, fireEvent } from "@testing-library/react"; import * as toolQueries from "./queries/toolQueries"; import type { ImportedDataState } from "../data/types"; import { STORAGE_KEYS } from "../../../excalidraw-app/app_constants"; import { getSelectedElements } from "../scene/selection"; import type { ExcalidrawElement } from "../element/types"; import { UI } from "./helpers/ui"; import { diffStringsUnified } from "jest-diff"; import ansi from "ansicolor"; import { ORIG_ID } from "../constants"; import { arrayToMap } from "../utils"; import type { AllPossibleKeys } from "../utility-types"; const customQueries = { ...queries, ...toolQueries, }; type TestRenderFn = ( ui: React.ReactElement, options?: Omit< RenderOptions & { localStorageData?: ImportedDataState }, "queries" >, ) => Promise>; const renderApp: TestRenderFn = async (ui, options) => { 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 = 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, ) => { mockBoundingClientRect(dimensions); act(() => { // @ts-ignore h.app.refreshViewportBreakpoints(); // @ts-ignore h.app.refreshEditorBreakpoints(); window.h.app.refresh(); }); await cb(); restoreOriginalGetBoundingClientRect(); act(() => { // @ts-ignore h.app.refreshViewportBreakpoints(); // @ts-ignore h.app.refreshEditorBreakpoints(); window.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, }; }, toCloselyEqualPoints(received, expected, precision) { if (!Array.isArray(received) || !Array.isArray(expected)) { throw new Error("expected and received are not point arrays"); } const COMPARE = 1 / Math.pow(10, precision || 2); const pass = received.every( (point, idx) => Math.abs(expected[idx]?.[0] - point[0]) < COMPARE && Math.abs(expected[idx]?.[1] - point[1]) < COMPARE, ); if (!pass) { return { message: () => ` The provided array of points are not close enough. ${diffStringsUnified( JSON.stringify(expected, undefined, 2), JSON.stringify(received, undefined, 2), )}`, pass: false, }; } return { message: () => `expected ${received} to not be close to ${expected}`, pass: true, }; }, }); /** * 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 = ( 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 = >( actualElements: readonly ExcalidrawElement[], /** array order matters */ expectedElements: (Partial> & { /** 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, 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 = {}; 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`, )}`; const error = new Error(errStr); const stack = err.stack.split("\n"); stack.splice(1, 1); error.stack = stack.join("\n"); throw error; } expect(mappedActualElements).toEqual( expect.arrayContaining(expectedElementsWithIds), ); expect(h.state.selectedElementIds).toEqual(selectedElementIds); };