blob: 0cf3b5ef0a8d8f7cd3dc7490275d700357b77afd [file] [log] [blame]
import { ElementHandle } from 'puppeteer';
import { asyncFilter, asyncFind, asyncForEach, asyncMap } from '../async';
// Puppeteer used to have a Serializable type, but it is now just "unknown".
export type Serializable = unknown;
// Custom type guard to tell DOM elements and Puppeteer element handles apart.
function isPptrElement(
element: Element | ElementHandle<Element>
): element is ElementHandle<Element> {
return !!(element as ElementHandle).asElement;
}
/**
* A helper class to write page objects[1] that work both on in-browser and Puppeteer tests.
*
* It's essentially a wrapper class that contains either a DOM node (Element) or a Puppeteer
* handle (ElementHandle). Its API is analogous to that of Element, with the exception that
* most functions return promises due to Puppeteer's asynchronous nature.
*
* A number of select* async methods are included to facilitate common tasks involving query
* selectors and reduce the number of await statements in client code.
*
* To ensure compatibility with both in-browser and Puppeteer tests, page objects must be built
* exclusively using PageObjectElement without ever referencing DOM nodes or Puppeteer element
* handles directly.
*
* PageObjectElement is inspired by PageLoader[2], a Dart framework for creating page objects
* compatible with both in-browser and WebDriver tests.
*
* [1] https://martinfowler.com/bliki/PageObject.html
* [2] https://github.com/google/pageloader
*/
export class PageObjectElement {
private readonly elementPromise: Promise<
Element | ElementHandle<Element> | null
>;
constructor(
element:
| Element
| ElementHandle<Element>
| Promise<Element | ElementHandle<Element> | null>
) {
if (element instanceof Promise) {
this.elementPromise = element;
} else {
this.elementPromise = new Promise((resolve) => resolve(element));
}
}
/** Returns true if the underlying DOM node or Puppeteer handle is empty. */
async isEmpty(): Promise<boolean> {
return !(await this.elementPromise);
}
/// //////////////////////////////////////////////////////////
// Wrappers around various Element methods and properties. //
/// //////////////////////////////////////////////////////////
// Please add any missing wrappers as needed.
/** Analogous to HTMLElement#innerText. */
get innerText(): Promise<string> {
return this.applyFnToDOMNode((el) => (el as HTMLElement).innerText);
}
/** Returns true if the element's inner text equals the given string. */
async isInnerTextEqualTo(text: string): Promise<boolean> {
return (await this.innerText) === text;
}
/** Analogous to Element#className. */
get className(): Promise<string> {
return this.applyFnToDOMNode((el) => el.className);
}
/** Returns true if the element has the given CSS class. */
async hasClassName(className: string) {
return this.applyFnToDOMNode(
(el, className) => el.classList.contains(className as string),
className
);
}
/** Analogous to Element#focus(). */
async focus() {
const element = (await this.elementPromise) as ElementHandle;
await element.focus();
}
/** Analogous to Element#click(). */
async click() {
const element = (await this.elementPromise) as ElementHandle;
await element.click();
}
/** Analogous to Element#hasAttribute(). */
async hasAttribute(attribute: string): Promise<boolean> {
return this.applyFnToDOMNode(
(el, attribute) => el.hasAttribute(attribute as string),
attribute
);
}
/** Analogous to Element#getAttribute(). */
async getAttribute(attribute: string): Promise<string | null> {
return this.applyFnToDOMNode(
(el: Element, attribute: unknown) => el.getAttribute(attribute as string),
attribute
);
}
/**
* Analogous to the HTMLInputElement#value property getter (e.g. for text inputs, selects, etc.).
*/
get value(): Promise<string> {
return this.applyFnToDOMNode((el) => (el as HTMLInputElement).value);
}
/**
* Sends a single key press.
*
* Sends actual key presses on Puppeteer. Simulates events "keydown", "keypress" and "keyup" on
* the browser.
*
* @param key The "key" attribute of the KeyboardEvent to be dispatched.
*/
async typeKey(key: string): Promise<void> {
const element = await this.elementPromise;
if (isPptrElement(element!)) {
await (element as ElementHandle).type(key);
return;
}
const ele = element as Element;
ele.dispatchEvent(
new KeyboardEvent('keydown', { bubbles: true, key: key })
);
ele.dispatchEvent(
new KeyboardEvent('keypress', { bubbles: true, key: key })
);
ele.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: key }));
}
/**
* Analogous to the Element#value property setter (e.g. for text inputs, selects, etc.).
*
* Simulates events "input" and "change".
*
* Note: Only one "input" event is dispatched. The browser normally dispatches one "input" event
* per keystroke.
*/
async enterValue(value: string) {
// A future version of this method might take advantage of Puppeteer's ElementHandle.type()
// method and/or simulate DOM events in a more realistic way as done in the PageLoader library:
// https://github.com/google/pageloader/blob/80766100da9fe05d99eb92edd69b7ddfa82cc10e/lib/src/html/html_page_loader_element.dart#L393.
await this.applyFnToDOMNode((el, value) => {
// The below type union is non-exhaustive and for illustration purposes only.
(el as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement).value =
value as string;
// Simulate a subset of the input events (just one). This should be enough for most tests.
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}, value);
}
/**
* Returns the result of evaluating the given function, passing the wrapped Element (i.e.
* the DOM node) as the first argument, followed by any number of Serializable parameters.
*
* The function will be evaluated natively or via Puppeteer according to the type of the wrapped
* element.
*/
async applyFnToDOMNode<T extends Serializable | void>(
fn: (element: Element, ...args: Serializable[]) => T,
...args: Serializable[]
): Promise<T> {
const element = await this.elementPromise;
if (isPptrElement(element!)) {
return (await (element as ElementHandle<Element>).evaluate(
fn,
...args
)) as T;
}
return fn(element as Element, ...args);
}
/// /////////////////////////////////////////////////////////////////
// Query selectors and convenience methods using query selectors. //
/// /////////////////////////////////////////////////////////////////
/** Analogous to Element#querySelector(). */
bySelector(selector: string): PageObjectElement {
return new PageObjectElement(
this.elementPromise.then((element) => {
if (!element) {
return null;
}
if (isPptrElement(element)) {
// Note that common-sk functions $ and $$ are aliases for
// Element#querySelectorAll() and Element#querySelector(), respectively,
// whereas Puppeteer's ElementHandle#$() and ElementHandle#$$() methods are the other
// way around.
return (element as ElementHandle).$(selector) as Promise<
ElementHandle<Element>
>;
}
return new Promise((resolve) =>
resolve((element as Element).querySelector<Element>(selector))
);
})
);
}
/** Analogous to Element#querySelectorAll(). */
bySelectorAll(selector: string): PageObjectElementList {
return new PageObjectElementList(
this.elementPromise.then((element) => {
if (!element) {
return [];
}
if (isPptrElement(element)) {
// Note that common-sk functions $ and $$ are aliases for Element#querySelectorAll() and
// Element#querySelector(), respectively, whereas Puppeteer's ElementHandle#$() and
// ElementHandle#$$() methods are the other way around.
return (element as ElementHandle).$$(selector) as Promise<
ElementHandle<Element>[]
>;
}
return new Promise((resolve) =>
resolve(
Array.from((element as Element).querySelectorAll<Element>(selector))
)
);
})
);
}
}
/** Convenience wrapper around a promise that returns a list. */
export abstract class AsyncList<T> {
private readonly itemsPromise: Promise<T[]>;
protected constructor(items?: Promise<T[]>) {
if (!items) {
items = new Promise<T[]>((resolve) => resolve([]));
}
this.itemsPromise = items;
}
/** Returns the item with the given index from the list. */
async item(index: number): Promise<T> {
return (await this.itemsPromise)[index];
}
/** Analogous to Array.prototype.length. */
get length(): Promise<number> {
return this.itemsPromise.then((items) => items.length);
}
/** Analogous to Array.prototype.filter, where the callback function returns a promise. */
filter(fn: (item: T, index: number) => Promise<boolean>): Promise<T[]> {
return asyncFilter(this.itemsPromise, fn);
}
/** Analogous to Array.prototype.find, where the callback function returns a promise. */
find(fn: (item: T, index: number) => Promise<boolean>): Promise<T | null> {
return asyncFind(this.itemsPromise, fn);
}
/** Analogous to Array.prototype.forEach, where the callback function returns a promise. */
forEach(fn: (item: T, index: number) => Promise<void>): Promise<void> {
return asyncForEach(this.itemsPromise, fn);
}
/** Analogous to Array.prototype.map, where the callback function returns a promise. */
map<U>(fn: (item: T, index: number) => Promise<U>): Promise<U[]> {
return asyncMap(this.itemsPromise, fn);
}
}
/** Convenience wrapper around a Promise<PageObjectElement[]>. */
export class PageObjectElementList extends AsyncList<PageObjectElement> {
constructor(itemsPromise?: Promise<Element[] | ElementHandle<Element>[]>) {
super(
itemsPromise?.then((items) =>
items.map(
(item: Element | ElementHandle<Element>) =>
new PageObjectElement(item)
)
)
);
}
}