You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
success/packages/excalidraw/tests/test-utils.ts

449 lines
12 KiB
TypeScript

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<RenderResult<typeof customQueries>>;
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<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,
) => {
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));
};
feat: new Menu Component API (#6034) * feat: new Menu Component API * allow valid children types * introduce menu group to group items * Add lang footer * use display name * displayName * define types inside * fix default menu * add json export to menu * fix * simplify expression * put open menu into own compo to optimize perf So that we don't rerun `useOutsideClickHook` (and rebind event listeners all the time) * naming tweaks * rename MenuComponents->MenuDefaultItems and export default items from Menu.Items * import Menu.scss in Menu.tsx * move menu scss to excal app * Don't filter children inside menu group * move E+ out of socials * support style prop for MenuItem and MenuGroup * Support header in menu group and add Excalidraw links header for default items in social section * rename header to title * fix padding for lang * render menu in mobile * review fixes * tweaks * Export collaborators and show in mobile menu * revert .env * lint :p * again lint * show correct actions in view mode for mobile * Whitelist Collaborators Comp * mobile styling * padding * don't show nerds when menu open in mobile * lint :( * hide shortcuts * refactor userlist to support mobile and keep a wrapper comp for excal app * use only UserList * render only on mobile for default items * remove unused hooks * Show collab button in menu when onCollabButtonClick present and hide export when UIOptions.canvasActions.export is false * fix tests * lint * inject userlist inside menu on mobile * revert userlist * move menu socials to default menu * fix collab * use meny in library * Make Menu generic and create hamburgemenu for public excal menu and use menu in library as well * use appState.openMenu for mobile * fix tests * styling fixes and support style and class name in menu content * fix test * rename MenuDefaultItems->DefaultItems * move footer css to its own comp * rename HamburgerMenu -> MainMenu * rename menu -> dropdownMenu and update classes, onClick->onToggle * close main menu when dialog closes * by bye filtering * update docs * fix lint * update example, docs for useDevice and footer in mobile, rename menu ->DropDownMenu everywhere * spec * remove isMenuOpenAtom and set openMenu as canvas for main menu, render decreases in specs :) * [temp] remove cyclic depenedency to fix build * hack- update appstate to sync lang change * Add more specs * wip: rewrite MainMenu footer * fix margin * fix snaps * not needed as lang list no more imported * simplify custom footer rendering * Add DropdownMenuItemLink and DropdownMenuItemCustom and update API, docs * fix `MainMenu.ItemCustom` * naming * use onSelect and base class for custom items * fix lint * fix snap * use custom item for lang * update docs * fix * properly use `MainMenu.ItemCustom` for `LanguageList` * add margin top to custom items * flex Co-authored-by: dwelle <luzar.david@gmail.com>
2 years ago
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 = <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`,
)}`;
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);
};