import 'webdriverio'; import { URL } from 'node:url'; import { type Discardable, List, LogicError } from '@serenity-js/core'; import type { CorrelationId } from '@serenity-js/core/model'; 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/scripts'; 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 implements Discardable { private lastScriptExecutionSummary: LastScriptExecutionSummary; private dehydrator: ArgumentDehydrator, WebdriverIO.Element> = new ArgumentDehydrator( (item: any): item is PageElement => item instanceof PageElement, (item: PageElement) => item.nativeElement(), ); 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 { return new WebdriverIOPageElement( new WebdriverIOExistingElementLocator( this.rootLocator, new ByCss(String(nativeElement.selector)), this.errorHandler, nativeElement ) ); } locate(selector: Selector): PageElement { return new WebdriverIOPageElement( new WebdriverIOLocator(this.rootLocator, selector, this.errorHandler) ) } locateAll(selector: Selector): PageElements { return List.of( new PageElementsLocator( new WebdriverIOLocator(this.rootLocator, selector, this.errorHandler) ) ); } async navigateTo(destination: string): Promise { await this.inContextOfThisPage(() => this.browser.url(destination)); await this.resetState(); } async navigateBack(): Promise { await this.inContextOfThisPage(() => this.browser.back()); await this.resetState(); } async navigateForward(): Promise { await this.inContextOfThisPage(() => this.browser.forward()); await this.resetState(); } async reload(): Promise { await this.inContextOfThisPage(() => this.browser.refresh()); await this.resetState(); } async sendKeys(keys: Array): Promise { const keySequence = keys.map(key => { if (! Key.isKey(key)) { return key; } return key.utf16codePoint; }); await this.inContextOfThisPage(() => this.browser.keys(keySequence)); } async executeScript( script: string | ((...parameters: InnerArguments) => Result), ...args: InnerArguments ): Promise { 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(async () => { const dehydratedArguments = await this.dehydrator.dehydrate(args); return await this.browser.execute(executableScript as any, ...dehydratedArguments) as Promise; }); this.lastScriptExecutionSummary = new LastScriptExecutionSummary(result); return result; } async executeAsyncScript( script: string | ((...args: [...parameters: Parameters, callback: (result: Result) => void]) => void), ...args: Parameters ): Promise { 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(async () => { const dehydratedArguments = await this.dehydrator.dehydrate(args); return this.browser.executeAsync( executableScript as (...args: [ { argsCount: number, refsCount: number }, ...any[], callback: (result: TransformElement) => void ]) => void, ...dehydratedArguments as [ { argsCount: number, refsCount: number }, ...any[] ], ); }); this.lastScriptExecutionSummary = new LastScriptExecutionSummary(result); return result; } lastScriptExecutionResult(): 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 { 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 { return new WebdriverIOCookie(this.browser, name); } async setCookie(cookieData: CookieData): Promise { 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 { return await this.inContextOfThisPage(() => { return this.browser.deleteCookies() as Promise; }); } async title(): Promise { return await this.inContextOfThisPage(() => this.browser.execute(() => document.title)); } async name(): Promise { return await this.inContextOfThisPage(() => { return this.browser.execute(() => window.name); }); } async url(): Promise { 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 { return await this.inContextOfThisPage(async () => { // Use BiDi setViewport when available as it handles devicePixelRatio correctly if (this.browser.isBidi) { return this.browser.setViewport(size); } // Fall back to classic WebDriver approach for non-BiDi sessions const desiredWindowSize = await this.browser.execute(` var currentViewportWidth = Math.max(document.documentElement.clientWidth, window.innerWidth || 0) var currentViewportHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0) return { width: Math.max(window.outerWidth - currentViewportWidth + ${ size.width }, ${ size.width }), height: Math.max(window.outerHeight - currentViewportHeight + ${ size.height }, ${ size.height }), }; `) as { width: number, height: number }; return this.browser.setWindowSize(desiredWindowSize.width, desiredWindowSize.height); }); } async close(): Promise { await this.resetState(); await this.inContextOfThisPage(() => this.browser.closeWindow()); } async closeOthers(): Promise { await this.session.closePagesOtherThan(this); } async isPresent(): Promise { 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 { await (this.modalDialogHandler as WebdriverIOModalDialogHandler).discard(); } private async inContextOfThisPage(action: () => Promise | T): Promise { 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 { constructor(public readonly result: Result) {} }