UNPKG

appium-remote-debugger

Version:
274 lines (255 loc) 9.28 kB
import {checkParams} from '../utils'; import {events} from './events'; import {timing, util} from '@appium/support'; import _ from 'lodash'; import B, {TimeoutError as BTimeoutError} from 'bluebird'; import { getAppIdKey, setPageLoading, getPageLoadDelay, getPageLoadStartegy, setPageLoadDelay, getPageReadyTimeout, getPageIdKey, setNavigatingToPage, } from './property-accessors'; import type {RemoteDebugger} from '../remote-debugger'; import type {AppIdKey, PageIdKey} from '../types'; export const DEFAULT_PAGE_READINESS_TIMEOUT_MS = 20 * 1000; const PAGE_READINESS_CHECK_INTERVAL_MS = 50; /** * pageLoadStrategy in WebDriver definitions. */ const PAGE_LOAD_STRATEGY = Object.freeze({ EAGER: 'eager', NONE: 'none', NORMAL: 'normal', }); /** * Emits a frame detached event when a frame is detached from the page. * This is typically called by the RPC client when receiving a Page.frameDetached event. */ export function frameDetached(this: RemoteDebugger): void { this.emit(events.EVENT_FRAMES_DETACHED); } /** * Cancels the current page load operation by unregistering from page readiness * notifications and canceling any pending page load delay. */ export function cancelPageLoad(this: RemoteDebugger): void { this.log.debug('Unregistering from page readiness notifications'); setPageLoading(this, false); getPageLoadDelay(this)?.cancel(); } /** * Determines if the current readyState indicates that page loading is completed * based on the configured page load strategy. * * @param readyState - The document readyState value ('loading', 'interactive', or 'complete'). * @returns True if the page load is considered complete for the current strategy: * - 'eager': returns true when readyState is not 'loading' * - 'none': always returns true * - 'normal' (default): returns true only when readyState is 'complete' */ export function isPageLoadingCompleted(this: RemoteDebugger, readyState: string): boolean { const pageLoadStrategy = _.toLower(getPageLoadStartegy(this)); switch (pageLoadStrategy) { case PAGE_LOAD_STRATEGY.EAGER: // This could include 'interactive' or 'complete' return readyState !== 'loading'; case PAGE_LOAD_STRATEGY.NONE: return true; case PAGE_LOAD_STRATEGY.NORMAL: default: return readyState === 'complete'; } } /** * Waits for the DOM to be ready by periodically checking the page readiness state. * Uses exponential backoff for retry intervals and respects the configured page load * strategy and timeout settings. * * @param startPageLoadTimer - Optional timer instance to use for tracking elapsed time. * If not provided, a new timer will be created and started. */ export async function waitForDom( this: RemoteDebugger, startPageLoadTimer?: timing.Timer, ): Promise<void> { const readinessTimeoutMs = this.pageLoadMs; this.log.debug(`Waiting up to ${readinessTimeoutMs}ms for the page to be ready`); const timer = startPageLoadTimer ?? new timing.Timer().start(); let isPageLoading = true; setPageLoading(this, true); setPageLoadDelay(this, util.cancellableDelay(readinessTimeoutMs)); const pageReadinessPromise = B.resolve( (async () => { let retry = 0; while (isPageLoading) { // if we are ready, or we've spend too much time on this const elapsedMs = timer.getDuration().asMilliSeconds; // exponential retry const intervalMs = Math.min( PAGE_READINESS_CHECK_INTERVAL_MS * Math.pow(2, retry), readinessTimeoutMs - elapsedMs, ); await B.delay(intervalMs); // we can get this called in the middle of trying to find a new app if (!getAppIdKey(this)) { this.log.debug('Not connected to an application. Ignoring page readiess check'); return; } if (!isPageLoading) { return; } const maxWaitMs = (readinessTimeoutMs - elapsedMs) * 0.95; if (await this.checkPageIsReady(maxWaitMs)) { if (isPageLoading) { this.log.debug(`Page is ready in ${elapsedMs}ms`); isPageLoading = false; } return; } if (elapsedMs > readinessTimeoutMs) { this.log.info( `Timed out after ${readinessTimeoutMs}ms of waiting for the page readiness. Continuing anyway`, ); isPageLoading = false; return; } retry++; } })(), ); const cancellationPromise = B.resolve( (async () => { try { await getPageLoadDelay(this); } catch {} })(), ); try { await B.any([cancellationPromise, pageReadinessPromise]); } finally { isPageLoading = false; setPageLoading(this, false); setPageLoadDelay(this, B.resolve()); } } /** * Checks if the current page is ready by executing a JavaScript command to * retrieve the document readyState and evaluating it against the page load strategy. * * @param timeoutMs - Optional timeout in milliseconds for the readyState check. * If not provided, uses the configured page ready timeout. * @returns A promise that resolves to true if the page is ready according to * the page load strategy, false otherwise or if the check times out. */ export async function checkPageIsReady(this: RemoteDebugger, timeoutMs?: number): Promise<boolean> { const readyCmd = 'document.readyState;'; const actualTimeoutMs = timeoutMs ?? getPageReadyTimeout(this); try { const readyState = await B.resolve(this.execute(readyCmd)).timeout(actualTimeoutMs); this.log.debug( JSON.stringify({ readyState, pageLoadStrategy: getPageLoadStartegy(this) ?? PAGE_LOAD_STRATEGY.NORMAL, }), ); return this.isPageLoadingCompleted(readyState); } catch (err: any) { if (err instanceof BTimeoutError) { this.log.debug(`Page readiness check timed out after ${actualTimeoutMs}ms`); } else { this.log.warn(`Page readiness check has failed. Original error: ${err.message}`); } return false; } } /** * Navigates to a new URL and waits for the page to be ready. * Validates the URL format, waits for the page to be available, sets window.location.href * via Runtime.evaluate (Page.navigate was removed from WebKit Inspector protocol), and * monitors for the Page.loadEventFired event or timeout. * * @param url - The URL to navigate to. Must be a valid URL format. * @throws TypeError if the provided URL is not a valid URL format. */ export async function navToUrl(this: RemoteDebugger, url: string): Promise<void> { const {appIdKey, pageIdKey} = checkParams({ appIdKey: getAppIdKey(this), pageIdKey: getPageIdKey(this), }); const rpcClient = this.requireRpcClient(); try { new URL(url); } catch { throw new TypeError(`'${url}' is not a valid URL`); } this.log.debug(`Navigating to new URL: '${url}'`); setNavigatingToPage(this, true); await rpcClient.waitForPage(appIdKey as AppIdKey, pageIdKey as PageIdKey); const readinessTimeoutMs = this.pageLoadMs; let onPageLoaded: (() => void) | undefined; let onPageLoadedTimeout: NodeJS.Timeout | undefined | null; setPageLoadDelay(this, util.cancellableDelay(readinessTimeoutMs)); setPageLoading(this, true); let isPageLoading = true; const start = new timing.Timer().start(); const pageReadinessPromise = new B<void>((resolve) => { onPageLoadedTimeout = setTimeout(() => { if (isPageLoading) { isPageLoading = false; this.log.info( `Timed out after ${start.getDuration().asMilliSeconds.toFixed(0)}ms of waiting ` + `for the ${url} page readiness. Continuing anyway`, ); } return resolve(); }, readinessTimeoutMs); onPageLoaded = () => { if (isPageLoading) { isPageLoading = false; this.log.debug( `The page ${url} is ready in ${start.getDuration().asMilliSeconds.toFixed(0)}ms`, ); } if (onPageLoadedTimeout) { clearTimeout(onPageLoadedTimeout); onPageLoadedTimeout = null; } return resolve(); }; // https://chromedevtools.github.io/devtools-protocol/tot/Page/#event-loadEventFired rpcClient.once('Page.loadEventFired', onPageLoaded); // Page.navigate was removed from the WebKit Inspector protocol since iOS 26.4 // See https://github.com/appium/appium/issues/21976 rpcClient.send('Runtime.evaluate', { expression: `window.location.href = ${JSON.stringify(url)};`, appIdKey, pageIdKey, }); }); const cancellationPromise = B.resolve( (async () => { try { await getPageLoadDelay(this); } catch {} })(), ); try { await B.any([cancellationPromise, pageReadinessPromise]); } finally { setPageLoading(this, false); isPageLoading = false; setNavigatingToPage(this, false); setPageLoadDelay(this, B.resolve()); if (onPageLoadedTimeout && pageReadinessPromise.isFulfilled()) { clearTimeout(onPageLoadedTimeout); onPageLoadedTimeout = null; } if (onPageLoaded) { rpcClient.off('Page.loadEventFired', onPageLoaded); } } }