diff --git a/src/dom.ts b/src/dom.ts index b8923b54df..c777a1ef22 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -113,6 +113,11 @@ export class ElementHandle extends js.JSHandle { return utility.doEvaluateInternal(true /* returnByValue */, true /* waitForNavigations */, pageFunction, { injected: await utility.injectedScript(), node: this }, arg); } + async _evaluateHandleInUtility(pageFunction: types.FuncOn<{ injected: InjectedScript, node: T }, Arg, R>, arg: Arg): Promise> { + const utility = await this._context.frame._utilityContext(); + return utility.doEvaluateInternal(false /* returnByValue */, true /* waitForNavigations */, pageFunction, { injected: await utility.injectedScript(), node: this }, arg); + } + async ownerFrame(): Promise { const frameId = await this._page._delegate.getOwnerFrame(this); if (!frameId) @@ -349,7 +354,7 @@ export class ElementHandle extends js.JSHandle { } return await this._page._frameManager.waitForSignalsCreatedBy(async () => { const injectedResult = await this._evaluateInUtility(({ injected, node }, selectOptions) => injected.selectOptions(node, selectOptions), selectOptions); - return handleInjectedResult(injectedResult, ''); + return handleInjectedResult(injectedResult); }, deadline, options); } @@ -359,7 +364,7 @@ export class ElementHandle extends js.JSHandle { const deadline = this._page._timeoutSettings.computeDeadline(options); await this._page._frameManager.waitForSignalsCreatedBy(async () => { const injectedResult = await this._evaluateInUtility(({ injected, node }, value) => injected.fill(node, value), value); - const needsInput = handleInjectedResult(injectedResult, ''); + const needsInput = handleInjectedResult(injectedResult); if (needsInput) { if (value) await this._page.keyboard.insertText(value); @@ -372,7 +377,7 @@ export class ElementHandle extends js.JSHandle { async selectText(): Promise { this._page._log(inputLog, `elementHandle.selectText()`); const injectedResult = await this._evaluateInUtility(({ injected, node }) => injected.selectText(node), {}); - handleInjectedResult(injectedResult, ''); + handleInjectedResult(injectedResult); } async setInputFiles(files: string | types.FilePayload | string[] | types.FilePayload[], options?: types.NavigatingActionWaitOptions) { @@ -386,7 +391,7 @@ export class ElementHandle extends js.JSHandle { const input = node as Node as HTMLInputElement; return { status: 'success', value: input.multiple }; }, {}); - const multiple = handleInjectedResult(injectedResult, ''); + const multiple = handleInjectedResult(injectedResult); let ff: string[] | types.FilePayload[]; if (!Array.isArray(files)) ff = [ files ] as string[] | types.FilePayload[]; @@ -414,7 +419,7 @@ export class ElementHandle extends js.JSHandle { async focus() { this._page._log(inputLog, `elementHandle.focus()`); const injectedResult = await this._evaluateInUtility(({ injected, node }) => injected.focusNode(node), {}); - handleInjectedResult(injectedResult, ''); + handleInjectedResult(injectedResult); } async type(text: string, options?: { delay?: number } & types.NavigatingActionWaitOptions) { @@ -492,13 +497,17 @@ export class ElementHandle extends js.JSHandle { async _waitForDisplayedAtStablePositionAndEnabled(deadline: number): Promise { this._page._log(inputLog, 'waiting for element to be displayed, enabled and not moving...'); const rafCount = this._page._delegate.rafCountForStablePosition(); - const stablePromise = this._evaluateInUtility(({ injected, node }, { rafCount, timeout }) => { - return injected.waitForDisplayedAtStablePositionAndEnabled(node, rafCount, timeout); - }, { rafCount, timeout: helper.timeUntilDeadline(deadline) }); - const timeoutMessage = 'element to be displayed and not moving'; - const injectedResult = await helper.waitWithDeadline(stablePromise, timeoutMessage, deadline, 'pw:input'); - handleInjectedResult(injectedResult, timeoutMessage); - this._page._log(inputLog, '...element is displayed, enabled and does not move'); + const poll = await this._evaluateHandleInUtility(({ injected, node }, { rafCount }) => { + return injected.waitForDisplayedAtStablePositionAndEnabled(node, rafCount); + }, { rafCount }); + try { + const stablePromise = poll.evaluate(poll => poll.result); + const injectedResult = await helper.waitWithDeadline(stablePromise, 'element to be displayed and not moving', deadline, 'pw:input'); + handleInjectedResult(injectedResult); + } finally { + poll.evaluate(poll => poll.cancel()).catch(e => {}).then(() => poll.dispose()); + } + this._page._log(inputLog, '...element is displayed and does not move'); } async _checkHitTargetAt(point: types.Point): Promise { @@ -514,7 +523,7 @@ export class ElementHandle extends js.JSHandle { const injectedResult = await this._evaluateInUtility(({ injected, node }, { point }) => { return injected.checkHitTargetAt(node, point); }, { point }); - return handleInjectedResult(injectedResult, ''); + return handleInjectedResult(injectedResult); } } @@ -526,11 +535,9 @@ export function toFileTransferPayload(files: types.FilePayload[]): types.FileTra })); } -function handleInjectedResult(injectedResult: types.InjectedScriptResult, timeoutMessage: string): T { +function handleInjectedResult(injectedResult: types.InjectedScriptResult): T { if (injectedResult.status === 'notconnected') throw new NotConnectedError(); - if (injectedResult.status === 'timeout') - throw new TimeoutError(`waiting for ${timeoutMessage} failed: timeout exceeded. Re-run with the DEBUG=pw:input env variable to see the debug log.`); if (injectedResult.status === 'error') throw new Error(injectedResult.error); return injectedResult.value as T; diff --git a/src/frames.ts b/src/frames.ts index 71b1c9782a..0eb8a8c162 100644 --- a/src/frames.ts +++ b/src/frames.ts @@ -36,7 +36,7 @@ type ContextData = { contextPromise: Promise; contextResolveCallback: (c: dom.FrameExecutionContext) => void; context: dom.FrameExecutionContext | null; - rerunnableTasks: Set; + rerunnableTasks: Set>; }; export type GotoOptions = types.NavigateOptions & { @@ -451,7 +451,7 @@ export class Frame { throw new Error(`Unsupported waitFor option "${state}"`); const deadline = this._page._timeoutSettings.computeDeadline(options); - const { world, task } = selectors._waitForSelectorTask(selector, state, deadline); + const { world, task } = selectors._waitForSelectorTask(selector, state); const result = await this._scheduleRerunnableTask(task, world, deadline, `selector "${selector}"${state === 'attached' ? '' : ' to be ' + state}`); if (!result.asElement()) { result.dispose(); @@ -469,7 +469,7 @@ export class Frame { async dispatchEvent(selector: string, type: string, eventInit?: Object, options?: types.TimeoutOptions): Promise { const deadline = this._page._timeoutSettings.computeDeadline(options); - const task = selectors._dispatchEventTask(selector, type, eventInit || {}, deadline); + const task = selectors._dispatchEventTask(selector, type, eventInit || {}); const result = await this._scheduleRerunnableTask(task, 'main', deadline, `selector "${selector}"`); result.dispose(); } @@ -700,7 +700,7 @@ export class Frame { this._page._log(dom.inputLog, `(page|frame).${actionName}("${selector}")`); while (!helper.isPastDeadline(deadline)) { try { - const { world, task } = selectors._waitForSelectorTask(selector, 'attached', deadline); + const { world, task } = selectors._waitForSelectorTask(selector, 'attached'); this._page._log(dom.inputLog, `waiting for the selector "${selector}"`); const handle = await this._scheduleRerunnableTask(task, world, deadline, `selector "${selector}"`); this._page._log(dom.inputLog, `...got element for the selector`); @@ -812,11 +812,11 @@ export class Frame { throw new Error('Unknown polling options: ' + polling); const predicateBody = helper.isString(pageFunction) ? 'return (' + pageFunction + ')' : 'return (' + pageFunction + ')(arg)'; - const task = async (context: dom.FrameExecutionContext) => context.evaluateHandleInternal(({ injected, predicateBody, polling, timeout, arg }) => { - const innerPredicate = new Function('arg', predicateBody); - return injected.poll(polling, timeout, () => innerPredicate(arg)); - }, { injected: await context.injectedScript(), predicateBody, polling, timeout: helper.timeUntilDeadline(deadline), arg }); - return this._scheduleRerunnableTask(task, 'main', deadline) as any as types.SmartHandle; + const task = async (context: dom.FrameExecutionContext) => context.evaluateHandleInternal(({ injected, predicateBody, polling, arg }) => { + const innerPredicate = new Function('arg', predicateBody) as (arg: any) => R; + return injected.poll(polling, () => innerPredicate(arg)); + }, { injected: await context.injectedScript(), predicateBody, polling, arg }); + return this._scheduleRerunnableTask(task, 'main', deadline); } async title(): Promise { @@ -836,10 +836,9 @@ export class Frame { this._parentFrame = null; } - private _scheduleRerunnableTask(task: Task, contextType: ContextType, deadline: number, title?: string): Promise { + private _scheduleRerunnableTask(task: SchedulableTask, contextType: ContextType, deadline: number, title?: string): Promise> { const data = this._contextData.get(contextType)!; const rerunnableTask = new RerunnableTask(data, task, deadline, title); - data.rerunnableTasks.add(rerunnableTask); if (data.context) rerunnableTask.rerun(data.context); return rerunnableTask.promise; @@ -890,84 +889,77 @@ export class Frame { } } -type Task = (context: dom.FrameExecutionContext) => Promise; +export type SchedulableTask = (context: dom.FrameExecutionContext) => Promise>>; -class RerunnableTask { - readonly promise: Promise; - private _contextData: ContextData; - private _task: Task; - private _runCount: number; - private _resolve: (result: js.JSHandle) => void = () => {}; +class RerunnableTask { + readonly promise: Promise>; + terminate: (reason: Error) => void = () => {}; + private _task: SchedulableTask; + private _resolve: (result: types.SmartHandle) => void = () => {}; private _reject: (reason: Error) => void = () => {}; - private _timeoutTimer?: NodeJS.Timer; - private _terminated = false; + private _terminatedPromise: Promise; - constructor(data: ContextData, task: Task, deadline: number, title?: string) { - this._contextData = data; + constructor(data: ContextData, task: SchedulableTask, deadline: number, title?: string) { this._task = task; - this._runCount = 0; - this.promise = new Promise((resolve, reject) => { - this._resolve = resolve; - this._reject = reject; - }); + data.rerunnableTasks.add(this); + // Since page navigation requires us to re-install the pageScript, we should track // timeout on our end. const timeoutError = new TimeoutError(`waiting for ${title || 'function'} failed: timeout exceeded. Re-run with the DEBUG=pw:input env variable to see the debug log.`); - this._timeoutTimer = setTimeout(() => this.terminate(timeoutError), helper.timeUntilDeadline(deadline)); - } + let timeoutTimer: NodeJS.Timer | undefined; + this._terminatedPromise = new Promise(resolve => { + timeoutTimer = setTimeout(() => resolve(timeoutError), helper.timeUntilDeadline(deadline)); + this.terminate = resolve; + }); - terminate(error: Error) { - this._terminated = true; - this._reject(error); - this._doCleanup(); + // This promise is either resolved with the task result, or rejected with a meaningful + // evaluation error. + const resultPromise = new Promise>((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + }); + const failPromise = this._terminatedPromise.then(error => Promise.reject(error)); + + this.promise = Promise.race([resultPromise, failPromise]).finally(() => { + if (timeoutTimer) + clearTimeout(timeoutTimer); + data.rerunnableTasks.delete(this); + }); } async rerun(context: dom.FrameExecutionContext) { - const runCount = ++this._runCount; - let success: js.JSHandle | null = null; - let error = null; + let poll: js.JSHandle> | null = null; + + // On timeout or error, cancel current poll. + const cancelPoll = () => { + if (!poll) + return; + const copy = poll; + poll = null; + copy.evaluate(p => p.cancel()).catch(e => {}).then(() => copy.dispose()); + }; + this._terminatedPromise.then(cancelPoll); + try { - success = await this._task(context); + poll = await this._task(context); + const result = await poll.evaluateHandle(poll => poll.result); + cancelPoll(); + this._resolve(result); } catch (e) { - error = e; + cancelPoll(); + + // When the page is navigated, the promise is rejected. + // We will try again in the new execution context. + if (e.message.includes('Execution context was destroyed')) + return; + + // We could have tried to evaluate in a context which was already + // destroyed. + if (e.message.includes('Cannot find context with specified id')) + return; + + this._reject(e); } - - if (this._terminated || runCount !== this._runCount) { - if (success) - success.dispose(); - return; - } - - // Ignore timeouts in pageScript - we track timeouts ourselves. - // If execution context has been already destroyed, `context.evaluate` will - // throw an error - ignore this predicate run altogether. - if (!error && await context.evaluateInternal(s => !s, success).catch(e => true)) { - success!.dispose(); - return; - } - - // When the page is navigated, the promise is rejected. - // We will try again in the new execution context. - if (error && error.message.includes('Execution context was destroyed')) - return; - - // We could have tried to evaluate in a context which was already - // destroyed. - if (error && error.message.includes('Cannot find context with specified id')) - return; - - if (error) - this._reject(error); - else - this._resolve(success!); - - this._doCleanup(); - } - - _doCleanup() { - if (this._timeoutTimer) - clearTimeout(this._timeoutTimer); - this._contextData.rerunnableTasks.delete(this); } } diff --git a/src/injected/injectedScript.ts b/src/injected/injectedScript.ts index 6176a1fde3..5d316340f0 100644 --- a/src/injected/injectedScript.ts +++ b/src/injected/injectedScript.ts @@ -21,7 +21,15 @@ import { SelectorEngine, SelectorRoot } from './selectorEngine'; import { createTextSelector } from './textSelectorEngine'; import { XPathEngine } from './xpathSelectorEngine'; -type Predicate = () => T; +type Falsy = false | 0 | '' | undefined | null; +type Predicate = () => T | Falsy; +type InjectedScriptProgress = { + canceled: boolean; +}; + +function asCancelablePoll(result: T): types.CancelablePoll { + return { result: Promise.resolve(result), cancel: () => {} }; +} export default class InjectedScript { readonly engines: Map; @@ -103,19 +111,13 @@ export default class InjectedScript { return rect.width > 0 && rect.height > 0; } - private _pollRaf(predicate: Predicate, timeout: number): Promise { - let timedOut = false; - if (timeout) - setTimeout(() => timedOut = true, timeout); - - let fulfill: (result?: any) => void; - const result = new Promise(x => fulfill = x); + private _pollRaf(progress: InjectedScriptProgress, predicate: Predicate): Promise { + let fulfill: (result: T) => void; + const result = new Promise(x => fulfill = x); const onRaf = () => { - if (timedOut) { - fulfill(); + if (progress.canceled) return; - } const success = predicate(); if (success) fulfill(success); @@ -127,18 +129,12 @@ export default class InjectedScript { return result; } - private _pollInterval(pollInterval: number, predicate: Predicate, timeout: number): Promise { - let timedOut = false; - if (timeout) - setTimeout(() => timedOut = true, timeout); - - let fulfill: (result?: any) => void; - const result = new Promise(x => fulfill = x); + private _pollInterval(progress: InjectedScriptProgress, pollInterval: number, predicate: Predicate): Promise { + let fulfill: (result: T) => void; + const result = new Promise(x => fulfill = x); const onTimeout = () => { - if (timedOut) { - fulfill(); + if (progress.canceled) return; - } const success = predicate(); if (success) fulfill(success); @@ -150,10 +146,11 @@ export default class InjectedScript { return result; } - poll(polling: 'raf' | number, timeout: number, predicate: Predicate): Promise { - if (polling === 'raf') - return this._pollRaf(predicate, timeout); - return this._pollInterval(polling, predicate, timeout); + poll(polling: 'raf' | number, predicate: Predicate): types.CancelablePoll { + const progress = { canceled: false }; + const cancel = () => { progress.canceled = true; }; + const result = polling === 'raf' ? this._pollRaf(progress, predicate) : this._pollInterval(progress, polling, predicate); + return { result, cancel }; } getElementBorderWidth(node: Node): { left: number; top: number; } { @@ -330,25 +327,25 @@ export default class InjectedScript { input.dispatchEvent(new Event('change', { 'bubbles': true })); } - async waitForDisplayedAtStablePositionAndEnabled(node: Node, rafCount: number, timeout: number): Promise { + waitForDisplayedAtStablePositionAndEnabled(node: Node, rafCount: number): types.CancelablePoll { if (!node.isConnected) - return { status: 'notconnected' }; + return asCancelablePoll({ status: 'notconnected' }); const element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement; if (!element) - return { status: 'notconnected' }; + return asCancelablePoll({ status: 'notconnected' }); let lastRect: types.Rect | undefined; let counter = 0; let samePositionCounter = 0; let lastTime = 0; - const result = await this.poll('raf', timeout, (): 'notconnected' | boolean => { + return this.poll('raf', (): types.InjectedScriptResult | false => { // First raf happens in the same animation frame as evaluation, so it does not produce // any client rect difference compared to synchronous call. We skip the synchronous call // and only force layout during actual rafs as a small optimisation. if (++counter === 1) return false; if (!node.isConnected) - return 'notconnected'; + return { status: 'notconnected' }; // Drop frames that are shorter than 16ms - WebKit Win bug. const time = performance.now(); @@ -373,9 +370,8 @@ export default class InjectedScript { const elementOrButton = element.closest('button, [role=button]') || element; const isDisabled = ['BUTTON', 'INPUT', 'SELECT'].includes(elementOrButton.nodeName) && elementOrButton.hasAttribute('disabled'); - return isDisplayedAndStable && isVisible && !isDisabled; + return isDisplayedAndStable && isVisible && !isDisabled ? { status: 'success' } : false; }); - return { status: result === 'notconnected' ? 'notconnected' : (result ? 'success' : 'timeout') }; } checkHitTargetAt(node: Node, point: types.Point): types.InjectedScriptResult { diff --git a/src/selectors.ts b/src/selectors.ts index 92891e9022..0c6d60ea65 100644 --- a/src/selectors.ts +++ b/src/selectors.ts @@ -111,12 +111,12 @@ export class Selectors { return result; } - _waitForSelectorTask(selector: string, state: 'attached' | 'detached' | 'visible' | 'hidden', deadline: number): { world: 'main' | 'utility', task: (context: dom.FrameExecutionContext) => Promise } { + _waitForSelectorTask(selector: string, state: 'attached' | 'detached' | 'visible' | 'hidden'): { world: 'main' | 'utility', task: frames.SchedulableTask } { const parsed = this._parseSelector(selector); const task = async (context: dom.FrameExecutionContext) => { const injectedScript = await context.injectedScript(); - return injectedScript.evaluateHandle((injected, { parsed, state, timeout }) => { - return injected.poll('raf', timeout, () => { + return injectedScript.evaluateHandle((injected, { parsed, state }) => { + return injected.poll('raf', () => { const element = injected.querySelector(parsed, document); switch (state) { case 'attached': @@ -129,23 +129,23 @@ export class Selectors { return !element || !injected.isVisible(element); } }); - }, { parsed, state, timeout: helper.timeUntilDeadline(deadline) }); + }, { parsed, state }); }; return { world: this._needsMainContext(parsed) ? 'main' : 'utility', task }; } - _dispatchEventTask(selector: string, type: string, eventInit: Object, deadline: number): (context: dom.FrameExecutionContext) => Promise { + _dispatchEventTask(selector: string, type: string, eventInit: Object): frames.SchedulableTask { const parsed = this._parseSelector(selector); const task = async (context: dom.FrameExecutionContext) => { const injectedScript = await context.injectedScript(); - return injectedScript.evaluateHandle((injected, { parsed, type, eventInit, timeout }) => { - return injected.poll('raf', timeout, () => { + return injectedScript.evaluateHandle((injected, { parsed, type, eventInit }) => { + return injected.poll('raf', () => { const element = injected.querySelector(parsed, document); if (element) injected.dispatchEvent(element, type, eventInit); return element || false; }); - }, { parsed, type, eventInit, timeout: helper.timeUntilDeadline(deadline) }); + }, { parsed, type, eventInit }); }; return task; } diff --git a/src/types.ts b/src/types.ts index 298e38278e..60b7ead6a9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -167,5 +167,9 @@ export type ParsedSelector = { export type InjectedScriptResult = (T extends undefined ? { status: 'success', value?: T} : { status: 'success', value: T }) | { status: 'notconnected' } | - { status: 'timeout' } | { status: 'error', error: string }; + +export type CancelablePoll = { + result: Promise, + cancel: () => void, +};