Skip to content

Instantly share code, notes, and snippets.

@mainfraame
Last active November 2, 2020 16:13
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mainfraame/e9013f498755c3c4fa1389df956303f6 to your computer and use it in GitHub Desktop.
Save mainfraame/e9013f498755c3c4fa1389df956303f6 to your computer and use it in GitHub Desktop.
import {isEqual} from "lodash";
import {outdent} from "outdent";
import {act} from "react-dom/test-utils";
import {fireEvent} from "@testing-library/react";
import {dblClick} from "@testing-library/user-event";
export type JSDOMElementProps = {
id?: string;
root?: string;
selector?: string;
};
export class JSDOMElement<T = HTMLDivElement> {
component: string;
_element: T;
_id: string;
_rootSelector: string;
_selector: string;
constructor(props: JSDOMElementProps) {
const root = props.root ? `${props.root} ` : "";
const selector =
props.selector || (props.id ? `[data-test-id="${props.id}"]` : "");
this._id = props.id;
this._rootSelector = root;
this._selector = selector;
}
private element(): T {
if (
// ensure the element is still valid
this._element?.isConnected &&
this._element["__proto__"]
) {
return this._element;
}
this._element = document.querySelector(this.selector());
return this._element;
}
hasClass(className: string): boolean {
return this.element().classList.contains(className);
}
blur(): void {
act(() => {
fireEvent.blur(this.element());
});
}
click(): void {
act(() => {
this.element().click();
});
}
dblClick(): void {
act(() => {
dblClick(this.element());
});
}
focus(): void {
act(() => {
fireEvent.focus(this.element());
});
}
getAttribute<T>(attr: string): T {
return this.element().getAttribute(attr) as T;
}
getDataAttrs(pick?: string[]): { [index: string]: any } {
const dataset = this.element().dataset;
return Object.keys(dataset)
.filter((key) => !pick || pick.includes(key))
.reduce(
(acc, key) => ({
...acc,
[key]: dataset[key],
}),
{}
);
}
getDataAttr<T>(attr: string): T {
return this.element().dataset[attr] as T;
}
getStyle(styleProp: string): string {
return this.element().style[styleProp];
}
getValue(): any {
return this.element().value;
}
hasAttribute(attr: string): boolean {
return this.element().hasAttribute(attr);
}
isDisabled(): boolean {
return !!this.hasAttribute("disabled") === true;
}
isEnabled(): boolean {
return !this.isDisabled();
}
isInDom(): boolean {
return !!this.element();
}
innerText(): string {
// for some reason, i've found that some third party components will
// have their innerText only available in the textContent property
return (this.element().innerText || this.element().textContent)
.trim()
.replace(/\n/g, "");
}
isReadOnly(): boolean {
return !!this.getAttribute("readOnly");
}
isValid(): boolean {
// use a data attribute or use the native way to check input validity
return this.getDataAttr("valid");
}
keyDown(key: string): void {
act(() => {
fireEvent.keyDown(this.element(), {key});
});
}
mouseDown(): void {
act(() => {
fireEvent.mouseDown(this.element());
});
}
mouseEnter(): void {
act(() => {
fireEvent.mouseEnter(this.element());
});
}
mouseLeave(): void {
act(() => {
fireEvent.mouseLeave(this.element());
});
}
mouseOver(): void {
act(() => {
fireEvent.mouseOver(this.element());
});
}
mouseUp(): void {
act(() => {
fireEvent.mouseUp(this.element());
});
}
outerHTML(): string {
return this.element().outerHTML.trim();
}
scrollTo(coordinates: { top?: number; left?: number }): void {
const $el = this.element();
if (typeof coordinates.top === "number") {
act(() => {
$el.scrollTop = coordinates.top;
});
}
if (typeof coordinates.left === "number") {
act(() => {
$el.scrollLeft = coordinates.left;
});
}
act(() => {
fireEvent.scroll(this.element());
});
}
selector(selector?: string, trim?: boolean): string {
return `${this._rootSelector}${this._selector}${
selector ? `${trim || !this._selector ? "" : " "}${selector}` : ""
}`;
}
setValue(value: string): void {
if (isEqual(value, this.getValue())) {
return;
}
act(() => {
fireEvent.change(this.element(), {
bubbles: true,
currentTarget: {
value: value,
rawValue: value,
},
target: {
value: value,
rawValue: value,
},
});
});
}
waitForAttribute(attr: string, value: any, timeout?: number): Promise<void> {
return waitForCondition(() => this.getAttribute(attr) === value, {
timeout,
msg: outdent`
waited for attribute ${attr} to have value ${value}
selector: ${this.selector()}
`,
});
}
waitForDataAttr(attr: string, value: any, timeout?: number): Promise<void> {
return waitForCondition(() => isEqual(this.getDataAttr(attr), value), {
timeout,
msg: outdent`
waited for data attribute ${attr} to have value ${value}
latest value for data attribute is ${this.getDataAttr(attr)}
selector: ${this.selector()}
`,
});
}
waitForClass(className: string, timeout?: number): Promise<void> {
return waitForCondition(
() => this.element().classList.contains(className),
{
timeout,
msg: outdent`
waited for the element to have the "${className}"
selector: ${this.selector()}
`,
}
);
}
waitForNoClass(className: string, timeout?: number): Promise<void> {
return waitForCondition(
() => !this.element().classList.contains(className),
{
timeout,
msg: outdent`
waited for the element to not have the "${className}"
selector: ${this.selector()}
`,
}
);
}
async waitForDataAttrChange(attr: string, startValue: any, timeout?: number): Promise<void> {
const original =
startValue === undefined ? this.getDataAttr(attr) : startValue;
await waitForCondition(() => !isEqual(this.getDataAttr(attr), original), {
timeout,
msg: outdent`
waited for data attribute ${attr} value to change from ${original}
selector: ${this.selector()}
`,
});
}
waitForDisabled(): Promise<void> {
return this.waitForDataAttr("disabled", true);
}
waitForEnabled(timeout?: number): Promise<void> {
return this.waitForDataAttr("disabled", false, timeout);
}
async waitForInDom(timeout?: number): Promise<void> {
return waitForCondition(() => !!this.element(), {
timeout,
msg: outdent`
waited for element to appear in dom
selector: ${this.selector()}
`,
});
}
async waitForInnerText(timeout?: number): Promise<void> {
await waitForCondition(() => !!this.innerText(), {
timeout,
msg: outdent`
waited for element's inner text to be rendered
selector: ${this.selector()}
`,
});
}
async waitForInnerTextChange(timeout?: number): Promise<void> {
const original = this.innerText();
await waitForCondition(() => this.innerText() !== original, {
timeout,
msg: outdent`
waited for element's inner text to change
original text: ${original}
selector: ${this.selector()}
`,
});
}
async waitForInnerTextChangeFrom(origText, timeout?: number): Promise<void> {
await waitForCondition(() => this.innerText() !== origText, {
timeout,
msg: outdent`
waited for element to appear inner text to change
original text: ${origText}
selector: ${this.selector()}
`,
});
}
async waitForInvalid(): Promise<void> {
await this.waitForDataAttr("valid", false);
}
async waitForNotInDom(timeout?: number): Promise<void> {
await act(async () => {
await waitForCondition(
() => document.querySelector(this.selector()) === null,
{
timeout,
msg: outdent`
waited for element to not be in the dom
selector: ${this.selector()}
`,
}
);
});
}
async waitForOuterHTMLChange(action: () => void, timeout?: number): Promise<void> {
const original = this.outerHTML();
action();
await waitForCondition(() => this.outerHTML() !== original, {
timeout,
msg: outdent`
waited for element's outerHTML to change
original text: ${original}
selector: ${this.selector()}
`,
});
}
async waitForStyle(style: string, value: any, timeout?: number): Promise<void> {
await waitForCondition(() => this.getStyle(style) === value, {
timeout,
msg: outdent`
waited for element style
selector: ${this.selector()}
property: ${style}
value: ${value}
`,
});
}
waitForValid(): Promise<void> {
return this.waitForDataAttr("valid", true);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment