Path: blob/main/core/lib/FrameNavigationsObserver.ts
1029 views
import { assert, createPromise } from '@secret-agent/commons/utils';1import type {2ILocationStatus,3ILocationTrigger,4IPipelineStatus,5} from '@secret-agent/interfaces/Location';6import { LocationStatus, LocationTrigger, PipelineStatus } from '@secret-agent/interfaces/Location';7import INavigation, { LoadStatus, NavigationReason } from '@secret-agent/interfaces/INavigation';8import type ICommandMeta from '@secret-agent/interfaces/ICommandMeta';9import type IWaitForOptions from '@secret-agent/interfaces/IWaitForOptions';10import type IResolvablePromise from '@secret-agent/interfaces/IResolvablePromise';11import { CanceledPromiseError } from '@secret-agent/commons/interfaces/IPendingWaitEvent';12import * as moment from 'moment';13import type { IBoundLog } from '@secret-agent/interfaces/ILog';14import type FrameNavigations from './FrameNavigations';1516export default class FrameNavigationsObserver {17private readonly navigations: FrameNavigations;1819// this is the default "starting" point for a wait-for location change if a previous command id is not specified20private defaultWaitForLocationCommandId = 0;2122private waitingForLoadTimeout: NodeJS.Timeout;23private resourceIdResolvable: IResolvablePromise<number>;24private statusTriggerResolvable: IResolvablePromise<void>;25private statusTrigger: ILocationStatus;26private statusTriggerStartCommandId: number;27private logger: IBoundLog;2829constructor(navigations: FrameNavigations) {30this.navigations = navigations;31this.logger = navigations.logger.createChild(module);32navigations.on('status-change', this.onLoadStatusChange.bind(this));33}3435// this function will find the "starting command" to look for waitForLocation(change/reload)36public willRunCommand(newCommand: ICommandMeta, previousCommands: ICommandMeta[]) {37let last: ICommandMeta;38for (const command of previousCommands) {39// if this is a goto, set this to the "waitForLocation(change/reload)" command marker40if (command.name === 'goto') this.defaultWaitForLocationCommandId = command.id;41// find the last "waitFor" command that is not followed by another waitFor42if (last?.name.startsWith('waitFor') && !command.name.startsWith('waitFor')) {43this.defaultWaitForLocationCommandId = command.id;44}45last = command;46}47// handle cases like waitForLocation two times in a row48if (49newCommand.name === 'waitForLocation' &&50last &&51last.name.startsWith('waitFor') &&52last.name !== 'waitForMillis'53) {54this.defaultWaitForLocationCommandId = newCommand.id;55}56}5758public waitForLocation(status: ILocationTrigger, options: IWaitForOptions = {}): Promise<void> {59assert(LocationTrigger[status], `Invalid location status: ${status}`);6061// determine if this location trigger has already been satisfied62const sinceCommandId = Number.isInteger(options.sinceCommandId)63? options.sinceCommandId64: this.defaultWaitForLocationCommandId;65if (this.hasLocationTrigger(status, sinceCommandId)) {66return Promise.resolve();67}68// otherwise set pending69return this.createStatusTriggeredPromise(status, options.timeoutMs, sinceCommandId);70}7172public waitForLoad(status: IPipelineStatus, options: IWaitForOptions = {}): Promise<void> {73assert(PipelineStatus[status], `Invalid load status: ${status}`);7475if (options.sinceCommandId) {76throw new Error('Not implemented');77}7879const top = this.navigations.top;80if (top) {81if (top.stateChanges.has(status as LoadStatus)) {82return;83}84if (status === LocationStatus.DomContentLoaded && top.stateChanges.has(LoadStatus.Load)) {85return;86}87if (status === LocationStatus.PaintingStable && this.getPaintStableStatus().isStable) {88return;89}90}91const promise = this.createStatusTriggeredPromise(status, options.timeoutMs);9293if (top) this.onLoadStatusChange();94return promise;95}9697public waitForReady(): Promise<void> {98return this.waitForLoad(LocationStatus.HttpResponded);99}100101public async waitForNavigationResourceId(): Promise<number> {102const top = this.navigations.top;103104this.resourceIdResolvable = top?.resourceId;105const resourceId = await this.resourceIdResolvable?.promise;106if (top?.navigationError) {107throw top.navigationError;108}109return resourceId;110}111112public cancelWaiting(cancelMessage: string): void {113clearTimeout(this.waitingForLoadTimeout);114for (const promise of [this.resourceIdResolvable, this.statusTriggerResolvable]) {115if (!promise || promise.isResolved) continue;116117const canceled = new CanceledPromiseError(cancelMessage);118canceled.stack += `\n${'------LOCATION'.padEnd(50, '-')}\n${promise.stack}`;119promise.reject(canceled);120}121}122123public getPaintStableStatus(): { isStable: boolean; timeUntilReadyMs?: number } {124const top = this.navigations.top;125if (!top) return { isStable: false };126127// need to wait for both load + painting stable, or wait 3 seconds after either one128const loadDate = top.stateChanges.get(LoadStatus.Load);129const contentPaintedDate = top.stateChanges.get(LoadStatus.ContentPaint);130131if (contentPaintedDate) return { isStable: true };132if (!loadDate && !contentPaintedDate) return { isStable: false };133134// NOTE: LargestContentfulPaint, which currently drives PaintingStable will NOT trigger if the page135// doesn't have any "contentful" items that are eligible (image, headers, divs, paragraphs that fill the page)136137// have contentPaintedDate date, but no load138const timeUntilReadyMs = moment().diff(contentPaintedDate ?? loadDate, 'milliseconds');139return {140isStable: timeUntilReadyMs >= 3e3,141timeUntilReadyMs: Math.min(3e3, 3e3 - timeUntilReadyMs),142};143}144145private onLoadStatusChange(): void {146if (147this.statusTrigger === LocationTrigger.change ||148this.statusTrigger === LocationTrigger.reload149) {150if (this.hasLocationTrigger(this.statusTrigger, this.statusTriggerStartCommandId)) {151this.resolvePendingStatus(this.statusTrigger);152}153return;154}155156const loadTrigger = PipelineStatus[this.statusTrigger];157if (!this.statusTriggerResolvable || this.statusTriggerResolvable.isResolved || !loadTrigger)158return;159160if (this.statusTrigger === LocationStatus.PaintingStable) {161this.waitForPageLoaded();162return;163}164165// otherwise just look for state changes > the trigger166for (const state of this.navigations.top.stateChanges.keys()) {167// don't resolve states for redirected168if (state === LocationStatus.HttpRedirected) continue;169let pipelineStatus = PipelineStatus[state as IPipelineStatus];170if (state === LoadStatus.Load) {171pipelineStatus = PipelineStatus.AllContentLoaded;172}173if (pipelineStatus >= loadTrigger) {174this.resolvePendingStatus(state);175return;176}177}178}179180private waitForPageLoaded(): void {181clearTimeout(this.waitingForLoadTimeout);182183const { isStable, timeUntilReadyMs } = this.getPaintStableStatus();184185if (isStable) this.resolvePendingStatus('PaintingStable + Load');186187if (!isStable && timeUntilReadyMs) {188const loadDate = this.navigations.top.stateChanges.get(LoadStatus.Load);189const contentPaintDate = this.navigations.top.stateChanges.get(LoadStatus.ContentPaint);190this.waitingForLoadTimeout = setTimeout(191() =>192this.resolvePendingStatus(193`TimeElapsed. Loaded="${loadDate}", ContentPaint="${contentPaintDate}"`,194),195timeUntilReadyMs,196).unref();197}198}199200private resolvePendingStatus(resolvedWithStatus: string): void {201if (this.statusTriggerResolvable && !this.statusTriggerResolvable?.isResolved) {202this.logger.info(`Resolving pending "${this.statusTrigger}" with trigger`, {203resolvedWithStatus,204waitingForStatus: this.statusTrigger,205url: this.navigations.currentUrl,206});207clearTimeout(this.waitingForLoadTimeout);208this.statusTriggerResolvable.resolve();209this.statusTriggerResolvable = null;210this.statusTrigger = null;211this.statusTriggerStartCommandId = null;212}213}214215private hasLocationTrigger(trigger: ILocationTrigger, sinceCommandId: number) {216let previousLoadedNavigation: INavigation;217for (const history of this.navigations.history) {218const isMatch = history.startCommandId >= sinceCommandId;219if (isMatch) {220let isLocationChange = false;221if (trigger === LocationTrigger.reload) {222isLocationChange = FrameNavigationsObserver.isNavigationReload(history.navigationReason);223if (224!isLocationChange &&225!history.stateChanges.has(LoadStatus.HttpRedirected) &&226previousLoadedNavigation &&227previousLoadedNavigation.finalUrl === history.finalUrl228) {229isLocationChange = previousLoadedNavigation.loaderId !== history.loaderId;230}231}232233// if there was a previously loaded url, use this change234if (235trigger === LocationTrigger.change &&236previousLoadedNavigation &&237previousLoadedNavigation.finalUrl !== history.finalUrl238) {239// Don't accept adding a slash as a page change240const isInPageUrlAdjust =241history.navigationReason === 'inPage' &&242history.finalUrl.replace(previousLoadedNavigation.finalUrl, '').length <= 1;243244if (!isInPageUrlAdjust) isLocationChange = true;245}246247if (isLocationChange) {248this.logger.info(`Resolving waitForLocation(${trigger}) with navigation history`, {249historyEntry: history,250status: trigger,251sinceCommandId,252});253return true;254}255}256257if (258(history.stateChanges.has(LoadStatus.HttpResponded) ||259history.stateChanges.has(LoadStatus.DomContentLoaded)) &&260!history.stateChanges.has(LoadStatus.HttpRedirected)261) {262previousLoadedNavigation = history;263}264}265return false;266}267268private createStatusTriggeredPromise(269status: ILocationStatus,270timeoutMs: number,271sinceCommandId?: number,272): Promise<void> {273if (this.statusTriggerResolvable) this.cancelWaiting('New location trigger created');274275this.statusTrigger = status;276this.statusTriggerStartCommandId = sinceCommandId;277this.statusTriggerResolvable = createPromise<void>(timeoutMs ?? 60e3);278return this.statusTriggerResolvable.promise;279}280281private static isNavigationReload(reason: NavigationReason): boolean {282return reason === 'httpHeaderRefresh' || reason === 'metaTagRefresh' || reason === 'reload';283}284}285286287