@serenity-js/webdriverio
Version:
Adapter that integrates @serenity-js/web with the latest stable version of WebdriverIO, enabling Serenity/JS reporting and using the Screenplay Pattern to write web and mobile test scenarios
325 lines (266 loc) • 11.5 kB
text/typescript
import 'webdriverio';
import { URL } from 'node:url';
import { type Discardable, List, LogicError } from '@serenity-js/core';
import type { CorrelationId } from '@serenity-js/core/lib/model/index.js';
import type { Cookie, CookieData, ModalDialogHandler, PageElements, Selector } from '@serenity-js/web';
import {
ArgumentDehydrator,
BrowserWindowClosedError,
ByCss,
Key,
Page,
PageElement,
PageElementsLocator
} from '@serenity-js/web';
import * as scripts from '@serenity-js/web/lib/scripts/index.js';
import type { TransformElement } from 'webdriverio';
import { WebdriverIOExistingElementLocator, WebdriverIOLocator, WebdriverIORootLocator } from './locators/index.js';
import type { WebdriverIOBrowsingSession } from './WebdriverIOBrowsingSession.js';
import { WebdriverIOCookie } from './WebdriverIOCookie.js';
import type { WebdriverIOErrorHandler } from './WebdriverIOErrorHandler.js';
import type { WebdriverIOModalDialogHandler } from './WebdriverIOModalDialogHandler.js';
import { WebdriverIOPageElement } from './WebdriverIOPageElement.js';
/**
* WebdriverIO-specific implementation of [`Page`](https://serenity-js.org/api/web/class/Page/).
*
* @group Models
*/
export class WebdriverIOPage extends Page<WebdriverIO.Element> implements Discardable {
private lastScriptExecutionSummary: LastScriptExecutionSummary;
/* eslint-disable unicorn/consistent-function-scoping */
private dehydrator: ArgumentDehydrator<PageElement<WebdriverIO.Element>, WebdriverIO.Element> = new ArgumentDehydrator(
(item: any): item is PageElement<WebdriverIO.Element> => item instanceof PageElement,
(item: PageElement<WebdriverIO.Element>) => item.nativeElement(),
);
/* eslint-enable */
constructor(
session: WebdriverIOBrowsingSession,
private readonly browser: WebdriverIO.Browser,
modalDialogHandler: ModalDialogHandler,
private readonly errorHandler: WebdriverIOErrorHandler,
pageId: CorrelationId,
) {
super(
session,
new WebdriverIORootLocator(browser),
modalDialogHandler,
pageId,
);
}
createPageElement(nativeElement: WebdriverIO.Element): PageElement<WebdriverIO.Element> {
return new WebdriverIOPageElement(
new WebdriverIOExistingElementLocator(
this.rootLocator,
new ByCss(String(nativeElement.selector)),
this.errorHandler,
nativeElement
)
);
}
locate(selector: Selector): PageElement<WebdriverIO.Element> {
return new WebdriverIOPageElement(
new WebdriverIOLocator(this.rootLocator, selector, this.errorHandler)
)
}
locateAll(selector: Selector): PageElements<WebdriverIO.Element> {
return List.of(
new PageElementsLocator(
new WebdriverIOLocator(this.rootLocator, selector, this.errorHandler)
)
);
}
async navigateTo(destination: string): Promise<void> {
await this.inContextOfThisPage(() => this.browser.url(destination));
await this.resetState();
}
async navigateBack(): Promise<void> {
await this.inContextOfThisPage(() => this.browser.back());
await this.resetState();
}
async navigateForward(): Promise<void> {
await this.inContextOfThisPage(() => this.browser.forward());
await this.resetState();
}
async reload(): Promise<void> {
await this.inContextOfThisPage(() => this.browser.refresh());
await this.resetState();
}
async sendKeys(keys: Array<Key | string>): Promise<void> {
const keySequence = keys.map(key => {
if (! Key.isKey(key)) {
return key;
}
return key.utf16codePoint;
});
await this.inContextOfThisPage(() => this.browser.keys(keySequence));
}
async executeScript<Result, InnerArguments extends any[]>(
script: string | ((...parameters: InnerArguments) => Result),
...args: InnerArguments
): Promise<Result> {
const serialisedScript = typeof script === 'function'
? String(script)
: String(`function script() { ${ script } }`);
const executableScript = new Function(`
var parameters = (${ scripts.rehydrate }).apply(null, arguments);
return (${ serialisedScript }).apply(null, parameters);
`);
const result = await this.inContextOfThisPage<Result>(async () => {
const dehydratedArguments = await this.dehydrator.dehydrate(args);
return await this.browser.execute(executableScript as any, ...dehydratedArguments) as Promise<Result>;
});
this.lastScriptExecutionSummary = new LastScriptExecutionSummary(result);
return result;
}
async executeAsyncScript<Result, Parameters extends any[]>(
script: string | ((...args: [...parameters: Parameters, callback: (result: Result) => void]) => void),
...args: Parameters
): Promise<Result> {
const serialisedScript = typeof script === 'function'
? String(script)
: String(`function script() { ${ script } }`);
const executableScript = new Function(`
var args = Array.prototype.slice.call(arguments, 0, -1);
var callback = arguments[arguments.length - 1];
var parameters = (${ scripts.rehydrate }).apply(null, args);
(${ serialisedScript }).apply(null, parameters.concat(callback));
`);
const result = await this.inContextOfThisPage<Result>(async () => {
const dehydratedArguments = await this.dehydrator.dehydrate(args);
return this.browser.executeAsync<Result, [ { argsCount: number, refsCount: number }, ...any[] ]>(
executableScript as (...args: [ { argsCount: number, refsCount: number }, ...any[], callback: (result: TransformElement<Result>) => void ]) => void,
...dehydratedArguments as [ { argsCount: number, refsCount: number }, ...any[] ],
);
});
this.lastScriptExecutionSummary = new LastScriptExecutionSummary(result);
return result;
}
lastScriptExecutionResult<Result = any>(): Result {
if (! this.lastScriptExecutionSummary) {
throw new LogicError(`Make sure to execute a script before checking on the result`);
}
// Selenium returns `null` when the script it executed returns `undefined`
// so we're mapping the result back.
return this.lastScriptExecutionSummary.result === null
? undefined
: this.lastScriptExecutionSummary.result;
}
async takeScreenshot(): Promise<string> {
return await this.inContextOfThisPage(async () => {
try {
return await this.browser.takeScreenshot();
}
catch (error) {
if (error.name === 'ProtocolError' && error.message.includes('Target closed')) {
throw new BrowserWindowClosedError(
`Couldn't take screenshot since the browser window is already closed`,
error
);
}
throw error;
}
});
}
async cookie(name: string): Promise<Cookie> {
return new WebdriverIOCookie(this.browser, name);
}
async setCookie(cookieData: CookieData): Promise<void> {
return await this.inContextOfThisPage(() => {
return this.browser.setCookies({
name: cookieData.name,
value: cookieData.value,
path: cookieData.path,
domain: cookieData.domain,
secure: cookieData.secure,
httpOnly: cookieData.httpOnly,
expiry: cookieData.expiry
? cookieData.expiry.toSeconds()
: undefined,
// see https://w3c.github.io/webdriver-bidi/#type-network-Cookie
sameSite: cookieData?.sameSite?.toLowerCase() as 'lax' | 'strict' | 'none',
});
});
}
async deleteAllCookies(): Promise<void> {
return await this.inContextOfThisPage(() => {
return this.browser.deleteCookies() as Promise<void>;
});
}
async title(): Promise<string> {
return await this.inContextOfThisPage(() => this.browser.execute(() => document.title));
}
async name(): Promise<string> {
return await this.inContextOfThisPage(() => {
return this.browser.execute(() => window.name);
});
}
async url(): Promise<URL> {
return await this.inContextOfThisPage(async () => {
return new URL(await this.browser.execute(() => window.location.href));
});
}
async viewportSize(): Promise<{ width: number, height: number }> {
return await this.inContextOfThisPage(async () => {
const calculatedViewportSize = await this.browser.execute(`
return {
width: Math.max(document.documentElement.clientWidth, window.innerWidth || 0),
height: Math.max(document.documentElement.clientHeight, window.innerHeight || 0),
}
`) as { width: number, height: number };
// Chrome headless hard-codes window.innerWidth and window.innerHeight to 0
if (calculatedViewportSize.width > 0 && calculatedViewportSize.height > 0) {
return calculatedViewportSize;
}
return this.browser.getWindowSize();
});
}
async setViewportSize(size: { width: number, height: number }): Promise<void> {
return await this.inContextOfThisPage(async () => {
await this.browser.setViewport(size);
});
}
async close(): Promise<void> {
await this.resetState();
await this.inContextOfThisPage(() => this.browser.closeWindow());
}
async closeOthers(): Promise<void> {
await this.session.closePagesOtherThan(this);
}
async isPresent(): Promise<boolean> {
const allPages = await this.session.allPages();
for (const page of allPages) {
if (page === this) {
return true;
}
}
return false;
}
private async resetState() {
this.lastScriptExecutionSummary = undefined;
await this.rootLocator.switchToMainFrame()
await this.modalDialogHandler.reset();
}
async discard(): Promise<void> {
await (this.modalDialogHandler as WebdriverIOModalDialogHandler).discard();
}
private async inContextOfThisPage<T>(action: () => Promise<T> | T): Promise<T> {
let originalCurrentPage;
try {
originalCurrentPage = await this.session.currentPage();
await this.session.changeCurrentPageTo(this);
return await action();
}
catch (error) {
return await this.errorHandler.executeIfHandled(error, action);
}
finally {
await this.session.changeCurrentPageTo(originalCurrentPage);
}
}
}
/**
* @package
*/
class LastScriptExecutionSummary<Result = any> {
constructor(public readonly result: Result) {}
}