blob: 52bcca5c06aeabe1437eee2b159860699c50f39f [file] [log] [blame]
import { ElementHandle, Serializable } from 'puppeteer';
import { asyncFilter, asyncFind, asyncForEach, asyncMap } from '../async';
// Custom type guard to tell DOM elements and Puppeteer element handles apart.
function isPptrElement(
element: HTMLElement | ElementHandle<HTMLElement>): element is ElementHandle<HTMLElement> {
return (element as ElementHandle).asElement !== undefined;
}
/**
* 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 (HTMLElement) or a Puppeteer
* handle (ElementHandle). Its API is analogous to that of HTMLElement, 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<HTMLElement | ElementHandle<HTMLElement> | null>;
constructor(
element:
HTMLElement |
ElementHandle<HTMLElement> |
Promise<HTMLElement | ElementHandle<HTMLElement> | 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 HTMLElement methods and properties. //
/////////////////////////////////////////////////////////////////
// Please add any missing wrappers as needed.
/** Analogous to HTMLElement#innerText. */
get innerText(): Promise<string> {
return this.applyFnToDOMNode((el) => el.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 HTMLElement#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 HTMLElement#focus(). */
async focus() {
const element = await this.elementPromise;
await element!.focus();
}
/** Analogous to HTMLElement#click(). */
async click() {
const element = await this.elementPromise;
await element!.click();
}
/** Analogous to HTMLElement#hasAttribute(). */
async hasAttribute(attribute: string): Promise<boolean> {
return this.applyFnToDOMNode(
(el, attribute) => el.hasAttribute(attribute as string), attribute);
}
/** Analogous to HTMLElement#getAttribute(). */
async getAttribute(attribute: string): Promise<string | null> {
return this.applyFnToDOMNode(
(el, attribute) => el.getAttribute(attribute as string), attribute);
}
/** Analogous to the HTMLElement#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) {
const element = await this.elementPromise;
if (isPptrElement(element!)) {
return element.type(key);
}
element!.dispatchEvent(new KeyboardEvent('keydown', {bubbles: true, key: key}));
element!.dispatchEvent(new KeyboardEvent('keypress', {bubbles: true, key: key}));
element!.dispatchEvent(new KeyboardEvent('keyup', {bubbles: true, key: key}));
}
/**
* Analogous to the HTMLElement#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 HTMLElement (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: HTMLElement, ...args: Serializable[]) => T,
...args: Serializable[]): Promise<T> {
const element = await this.elementPromise;
if (isPptrElement(element!)) {
return await element.evaluate(fn, ...args) as T;
}
return fn(element!, ...args);
}
////////////////////////////////////////////////////////////////////
// Query selectors and convenience methods using query selectors. //
////////////////////////////////////////////////////////////////////
/** Analogous to HTMLElement#querySelector(). */
bySelector(selector: string): PageObjectElement {
return new PageObjectElement(this.elementPromise.then((element) => {
if (!element) {
return new Promise((resolve) => resolve(null));
}
if (isPptrElement(element)) {
// Note that common-sk functions $ and $$ are aliases for HTMLElement#querySelectorAll() and
// HTMLElement#querySelector(), respectively, whereas Puppeteer's ElementHandle#$() and
// ElementHandle#$$() methods are the other way around.
return element.$(selector);
}
return new Promise((resolve) => resolve(element.querySelector<HTMLElement>(selector)));
}));
}
/** Analogous to HTMLElement#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 HTMLElement#querySelectorAll() and
// HTMLElement#querySelector(), respectively, whereas Puppeteer's ElementHandle#$() and
// ElementHandle#$$() methods are the other way around.
return element.$$(selector);
}
return new Promise((resolve) =>
resolve(Array.from(element.querySelectorAll<HTMLElement>(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<HTMLElement[] | ElementHandle<HTMLElement>[]>) {
super(itemsPromise?.then((items) =>
items.map((item: HTMLElement | ElementHandle<HTMLElement>) =>
new PageObjectElement(item))));
}
}