From 3afe1935da5ee3b3a3ed8e910dd8dc280a0ae094 Mon Sep 17 00:00:00 2001 From: Johan Bay Date: Wed, 7 Oct 2020 10:49:11 +0200 Subject: [PATCH] feat(a11y-query): extend aria handler with waitFor (#6472) This commit adds waitFor to the built-in aria handler (#6307). --- src/common/AriaQueryHandler.ts | 22 ++- src/common/DOMWorld.ts | 199 +++++++++++++++------- src/common/ExecutionContext.ts | 5 +- src/common/Page.ts | 63 +------ src/common/helper.ts | 113 +++++++++++++ test/ariaqueryhandler.spec.ts | 297 +++++++++++++++++++++++++++++++++ test/frame.spec.ts | 2 +- 7 files changed, 576 insertions(+), 125 deletions(-) diff --git a/src/common/AriaQueryHandler.ts b/src/common/AriaQueryHandler.ts index efe01ab17ef..ef151d005fa 100644 --- a/src/common/AriaQueryHandler.ts +++ b/src/common/AriaQueryHandler.ts @@ -18,6 +18,7 @@ import { InternalQueryHandler } from './QueryHandler.js'; import { ElementHandle, JSHandle } from './JSHandle.js'; import { Protocol } from 'devtools-protocol'; import { CDPSession } from './Connection.js'; +import { DOMWorld, WaitForSelectorOptions } from './DOMWorld.js'; async function queryAXTree( client: CDPSession, @@ -82,8 +83,17 @@ const queryOne = async ( return exeCtx._adoptBackendNodeId(res[0].backendDOMNodeId); }; -const waitFor = () => { - throw new Error('waitForSelector is not supported for aria selectors'); +const waitFor = async ( + domWorld: DOMWorld, + selector: string, + options: WaitForSelectorOptions +) => { + await addHandlerToWorld(domWorld); + return domWorld.waitForSelectorInPage( + (_: Element, selector: string) => globalThis.ariaQuerySelector(selector), + selector, + options + ); }; const queryAll = async ( @@ -111,6 +121,14 @@ const queryAllArray = async ( return jsHandle; }; +async function addHandlerToWorld(domWorld: DOMWorld) { + await domWorld.addBinding('ariaQuerySelector', async (selector: string) => { + const document = await domWorld._document(); + const element = await queryOne(document, selector); + return element; + }); +} + /** * @internal */ diff --git a/src/common/DOMWorld.ts b/src/common/DOMWorld.ts index d078116aede..825d0c533e8 100644 --- a/src/common/DOMWorld.ts +++ b/src/common/DOMWorld.ts @@ -15,7 +15,7 @@ */ import { assert } from './assert.js'; -import { helper } from './helper.js'; +import { helper, debugError } from './helper.js'; import { LifecycleWatcher, PuppeteerLifeCycleEvent, @@ -36,13 +36,14 @@ import { UnwrapPromiseLike, } from './EvalTypes.js'; import { isNode } from '../environment.js'; +import { Protocol } from 'devtools-protocol'; // predicateQueryHandler and checkWaitForOptions are declared here so that // TypeScript knows about them when used in the predicate function below. declare const predicateQueryHandler: ( element: Element | Document, selector: string -) => Element | Element[] | NodeListOf; +) => Promise>; declare const checkWaitForOptions: ( node: Node, waitForVisible: boolean, @@ -76,6 +77,11 @@ export class DOMWorld { */ _waitTasks = new Set(); + // Contains mapping from functions that should be bound to Puppeteer functions. + private _boundFunctions = new Map(); + // Set of bindings that have been registered in the current context. + private _ctxBindings = new Set(); + constructor( frameManager: FrameManager, frame: Frame, @@ -85,16 +91,23 @@ export class DOMWorld { this._frame = frame; this._timeoutSettings = timeoutSettings; this._setContext(null); + frameManager._client.on('Runtime.bindingCalled', (event) => + this._onBindingCalled(event) + ); } frame(): Frame { return this._frame; } - _setContext(context?: ExecutionContext): void { + async _setContext(context?: ExecutionContext): Promise { if (context) { this._contextResolveCallback.call(null, context); this._contextResolveCallback = null; + this._ctxBindings.clear(); + for (const name of this._boundFunctions.keys()) { + await this.addBindingToContext(name); + } for (const waitTask of this._waitTasks) waitTask.rerun(); } else { this._documentPromise = null; @@ -119,7 +132,7 @@ export class DOMWorld { executionContext(): Promise { if (this._detached) throw new Error( - `Execution Context is not available in detached frame "${this._frame.url()}" (are you trying to evaluate?)` + `Execution context is not available in detached frame "${this._frame.url()}" (are you trying to evaluate?)` ); return this._contextPromise; } @@ -438,7 +451,6 @@ export class DOMWorld { async tap(selector: string): Promise { const handle = await this.$(selector); - assert(handle, 'No node found for selector: ' + selector); await handle.tap(); await handle.dispose(); } @@ -464,6 +476,90 @@ export class DOMWorld { return queryHandler.waitFor(this, updatedSelector, options); } + // If multiple waitFor are set up asynchronously, we need to wait for the + // first one to set up the binding in the page before running the others. + private _settingUpBinding: Promise | null = null; + /** + * @internal + */ + async addBindingToContext(name: string) { + // Previous operation added the binding so we are done. + if (this._ctxBindings.has(name)) return; + // Wait for other operation to finish + if (this._settingUpBinding) { + await this._settingUpBinding; + return this.addBindingToContext(name); + } + + const bind = async (name: string) => { + const expression = helper.pageBindingInitString('internal', name); + try { + const context = await this.executionContext(); + await context._client.send('Runtime.addBinding', { + name, + executionContextId: context._contextId, + }); + await context.evaluate(expression); + } catch (error) { + // We could have tried to evaluate in a context which was already + // destroyed. This happens, for example, if the page is navigated while + // we are trying to add the binding + const ctxDestroyed = error.message.includes( + 'Execution context was destroyed' + ); + const ctxNotFound = error.message.includes( + 'Cannot find context with specified id' + ); + if (ctxDestroyed || ctxNotFound) { + // Retry adding the binding in the next context + await bind(name); + } else { + debugError(error); + return; + } + } + this._ctxBindings.add(name); + }; + + this._settingUpBinding = bind(name); + await this._settingUpBinding; + this._settingUpBinding = null; + } + + /** + * @internal + */ + async addBinding(name: string, puppeteerFunction: Function): Promise { + this._boundFunctions.set(name, puppeteerFunction); + await this.addBindingToContext(name); + } + + private async _onBindingCalled( + event: Protocol.Runtime.BindingCalledEvent + ): Promise { + const { type, name, seq, args } = JSON.parse(event.payload); + if (type !== 'internal' || !this._ctxBindings.has(name)) return; + if (!this._hasContext()) return; + const context = await this.executionContext(); + if (context._contextId !== event.executionContextId) return; + try { + const result = await this._boundFunctions.get(name)(...args); + await context.evaluate(deliverResult, name, seq, result); + } catch (error) { + // The WaitTask may already have been resolved by timing out, or the + // exection context may have been destroyed. + // In both caes, the promises above are rejected with a protocol error. + // We can safely ignores these, as the WaitTask is re-installed in + // the next execution context if needed. + if (error.message.includes('Protocol error')) return; + debugError(error); + } + function deliverResult(name: string, seq: number, result: unknown): void { + globalThis[name].callbacks.get(seq).resolve(result); + globalThis[name].callbacks.delete(seq); + } + } + /** * @internal */ @@ -481,19 +577,19 @@ export class DOMWorld { const title = `selector \`${selector}\`${ waitForHidden ? ' to be hidden' : '' }`; - function predicate( + async function predicate( selector: string, waitForVisible: boolean, waitForHidden: boolean - ): Node | null | boolean { + ): Promise { const node = predicateQueryHandler - ? (predicateQueryHandler(document, selector) as Element) + ? ((await predicateQueryHandler(document, selector)) as Element) : document.querySelector(selector); return checkWaitForOptions(node, waitForVisible, waitForHidden); } const waitTask = new WaitTask( this, - this._makePredicateString(predicate, queryOne), + helper.makePredicateString(predicate, queryOne), title, polling, timeout, @@ -537,7 +633,7 @@ export class DOMWorld { } const waitTask = new WaitTask( this, - this._makePredicateString(predicate), + helper.makePredicateString(predicate), title, polling, timeout, @@ -554,45 +650,6 @@ export class DOMWorld { return elementHandle; } - private _makePredicateString( - predicate: Function, - predicateQueryHandler?: Function - ): string { - const predicateQueryHandlerDef = predicateQueryHandler - ? `const predicateQueryHandler = ${predicateQueryHandler};` - : ''; - return ` - (() => { - ${predicateQueryHandlerDef} - const checkWaitForOptions = ${checkWaitForOptions}; - return (${predicate})(...args) - })() `; - function checkWaitForOptions( - node: Node, - waitForVisible: boolean, - waitForHidden: boolean - ): Node | null | boolean { - if (!node) return waitForHidden; - if (!waitForVisible && !waitForHidden) return node; - const element = - node.nodeType === Node.TEXT_NODE - ? node.parentElement - : (node as Element); - - const style = window.getComputedStyle(element); - const isVisible = - style && style.visibility !== 'hidden' && hasVisibleBoundingBox(); - const success = - waitForVisible === isVisible || waitForHidden === !isVisible; - return success ? node : null; - - function hasVisibleBoundingBox(): boolean { - const rect = element.getBoundingClientRect(); - return !!(rect.top || rect.bottom || rect.width || rect.height); - } - } - } - waitForFunction( pageFunction: Function | string, options: { polling?: string | number; timeout?: number } = {}, @@ -617,7 +674,10 @@ export class DOMWorld { } } -class WaitTask { +/** + * @internal + */ +export class WaitTask { _domWorld: DOMWorld; _polling: string | number; _timeout: number; @@ -715,23 +775,34 @@ class WaitTask { await success.dispose(); return; } + // When frame is detached the task should have been terminated by the DOMWorld. + // This can fail if we were adding this task while the frame was detached, + // so we terminate here instead. + if (error) { + if ( + error.message.includes( + 'Execution context is not available in detached frame' + ) + ) { + this.terminate( + new Error('waitForFunction failed: frame got detached.') + ); + 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; + // When the page is navigated, the promise is rejected. + // We will try again in the new execution context. + if (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); + // We could have tried to evaluate in a context which was already + // destroyed. + if (error.message.includes('Cannot find context with specified id')) + return; + this._reject(error); + } else { + this._resolve(success); + } this._cleanup(); } diff --git a/src/common/ExecutionContext.ts b/src/common/ExecutionContext.ts index 68bbd369b72..4420410de05 100644 --- a/src/common/ExecutionContext.ts +++ b/src/common/ExecutionContext.ts @@ -52,7 +52,10 @@ export class ExecutionContext { * @internal */ _world: DOMWorld; - private _contextId: number; + /** + * @internal + */ + _contextId: number; /** * @internal diff --git a/src/common/Page.ts b/src/common/Page.ts index a6b2146ccf2..215e48576df 100644 --- a/src/common/Page.ts +++ b/src/common/Page.ts @@ -1050,7 +1050,7 @@ export class Page extends EventEmitter { ); this._pageBindings.set(name, puppeteerFunction); - const expression = helper.evaluationString(addPageBinding, name); + const expression = helper.pageBindingInitString('exposedFun', name); await this._client.send('Runtime.addBinding', { name: name }); await this._client.send('Page.addScriptToEvaluateOnNewDocument', { source: expression, @@ -1058,30 +1058,6 @@ export class Page extends EventEmitter { await Promise.all( this.frames().map((frame) => frame.evaluate(expression).catch(debugError)) ); - - function addPageBinding(bindingName): void { - /* Cast window to any here as we're about to add properties to it - * via win[bindingName] which TypeScript doesn't like. - */ - const win = window as any; - const binding = win[bindingName]; - - win[bindingName] = (...args: unknown[]): Promise => { - const me = window[bindingName]; - let callbacks = me['callbacks']; - if (!callbacks) { - callbacks = new Map(); - me['callbacks'] = callbacks; - } - const seq = (me['lastSeq'] || 0) + 1; - me['lastSeq'] = seq; - const promise = new Promise((resolve, reject) => - callbacks.set(seq, { resolve, reject }) - ); - binding(JSON.stringify({ name: bindingName, seq, args })); - return promise; - }; - } } async authenticate(credentials: Credentials): Promise { @@ -1156,23 +1132,22 @@ export class Page extends EventEmitter { private async _onBindingCalled( event: Protocol.Runtime.BindingCalledEvent ): Promise { - const { name, seq, args } = JSON.parse(event.payload); + const { type, name, seq, args } = JSON.parse(event.payload); + if (type !== 'exposedFun' || !this._pageBindings.has(name)) return; let expression = null; try { const result = await this._pageBindings.get(name)(...args); - expression = helper.evaluationString(deliverResult, name, seq, result); + expression = helper.pageBindingDeliverResultString(name, seq, result); } catch (error) { if (error instanceof Error) - expression = helper.evaluationString( - deliverError, + expression = helper.pageBindingDeliverErrorString( name, seq, error.message, error.stack ); else - expression = helper.evaluationString( - deliverErrorValue, + expression = helper.pageBindingDeliverErrorValueString( name, seq, error @@ -1184,32 +1159,6 @@ export class Page extends EventEmitter { contextId: event.executionContextId, }) .catch(debugError); - - function deliverResult(name: string, seq: number, result: unknown): void { - window[name]['callbacks'].get(seq).resolve(result); - window[name]['callbacks'].delete(seq); - } - - function deliverError( - name: string, - seq: number, - message: string, - stack: string - ): void { - const error = new Error(message); - error.stack = stack; - window[name]['callbacks'].get(seq).reject(error); - window[name]['callbacks'].delete(seq); - } - - function deliverErrorValue( - name: string, - seq: number, - value: unknown - ): void { - window[name]['callbacks'].get(seq).reject(value); - window[name]['callbacks'].delete(seq); - } } private _addConsoleMessage( diff --git a/src/common/helper.ts b/src/common/helper.ts index 72c51c0ee0a..fd2e8190efc 100644 --- a/src/common/helper.ts +++ b/src/common/helper.ts @@ -177,6 +177,114 @@ function evaluationString(fun: Function | string, ...args: unknown[]): string { return `(${fun})(${args.map(serializeArgument).join(',')})`; } +function pageBindingInitString(type: string, name: string): string { + function addPageBinding(type: string, bindingName: string): void { + /* Cast window to any here as we're about to add properties to it + * via win[bindingName] which TypeScript doesn't like. + */ + const win = window as any; + const binding = win[bindingName]; + + win[bindingName] = (...args: unknown[]): Promise => { + const me = window[bindingName]; + let callbacks = me.callbacks; + if (!callbacks) { + callbacks = new Map(); + me.callbacks = callbacks; + } + const seq = (me.lastSeq || 0) + 1; + me.lastSeq = seq; + const promise = new Promise((resolve, reject) => + callbacks.set(seq, { resolve, reject }) + ); + binding(JSON.stringify({ type, name: bindingName, seq, args })); + return promise; + }; + } + return evaluationString(addPageBinding, type, name); +} + +function pageBindingDeliverResultString( + name: string, + seq: number, + result: unknown +): string { + function deliverResult(name: string, seq: number, result: unknown): void { + window[name].callbacks.get(seq).resolve(result); + window[name].callbacks.delete(seq); + } + return evaluationString(deliverResult, name, seq, result); +} + +function pageBindingDeliverErrorString( + name: string, + seq: number, + message: string, + stack: string +): string { + function deliverError( + name: string, + seq: number, + message: string, + stack: string + ): void { + const error = new Error(message); + error.stack = stack; + window[name].callbacks.get(seq).reject(error); + window[name].callbacks.delete(seq); + } + return evaluationString(deliverError, name, seq, message, stack); +} + +function pageBindingDeliverErrorValueString( + name: string, + seq: number, + value: unknown +): string { + function deliverErrorValue(name: string, seq: number, value: unknown): void { + window[name].callbacks.get(seq).reject(value); + window[name].callbacks.delete(seq); + } + return evaluationString(deliverErrorValue, name, seq, value); +} + +function makePredicateString( + predicate: Function, + predicateQueryHandler?: Function +): string { + function checkWaitForOptions( + node: Node, + waitForVisible: boolean, + waitForHidden: boolean + ): Node | null | boolean { + if (!node) return waitForHidden; + if (!waitForVisible && !waitForHidden) return node; + const element = + node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as Element); + + const style = window.getComputedStyle(element); + const isVisible = + style && style.visibility !== 'hidden' && hasVisibleBoundingBox(); + const success = + waitForVisible === isVisible || waitForHidden === !isVisible; + return success ? node : null; + + function hasVisibleBoundingBox(): boolean { + const rect = element.getBoundingClientRect(); + return !!(rect.top || rect.bottom || rect.width || rect.height); + } + } + const predicateQueryHandlerDef = predicateQueryHandler + ? `const predicateQueryHandler = ${predicateQueryHandler};` + : ''; + return ` + (() => { + ${predicateQueryHandlerDef} + const checkWaitForOptions = ${checkWaitForOptions}; + return (${predicate})(...args) + })() `; +} + async function waitWithTimeout( promise: Promise, taskName: string, @@ -229,6 +337,11 @@ async function readProtocolStream( export const helper = { evaluationString, + pageBindingInitString, + pageBindingDeliverResultString, + pageBindingDeliverErrorString, + pageBindingDeliverErrorValueString, + makePredicateString, readProtocolStream, waitWithTimeout, waitForEvent, diff --git a/test/ariaqueryhandler.spec.ts b/test/ariaqueryhandler.spec.ts index 51fcbee966d..83f78fd611c 100644 --- a/test/ariaqueryhandler.spec.ts +++ b/test/ariaqueryhandler.spec.ts @@ -23,6 +23,7 @@ import { } from './mocha-utils'; // eslint-disable-line import/extensions import { ElementHandle } from '../lib/cjs/puppeteer/common/JSHandle.js'; +import utils from './utils.js'; describeChromeOnly('AriaQueryHandler', () => { setupTestBrowserHooks(); @@ -173,6 +174,302 @@ describeChromeOnly('AriaQueryHandler', () => { }); }); + describe('waitForSelector (aria)', function () { + const addElement = (tag) => + document.body.appendChild(document.createElement(tag)); + + it('should immediately resolve promise if node exists', async () => { + const { page, server } = getTestState(); + await page.goto(server.EMPTY_PAGE); + await page.evaluate(addElement, 'button'); + await page.waitForSelector('aria/[role="button"]'); + }); + + it('should work independently of `exposeFunction`', async () => { + const { page, server } = getTestState(); + await page.goto(server.EMPTY_PAGE); + await page.exposeFunction('ariaQuerySelector', (a, b) => a + b); + await page.evaluate(addElement, 'button'); + await page.waitForSelector('aria/[role="button"]'); + const result = await page.evaluate('globalThis.ariaQuerySelector(2,8)'); + expect(result).toBe(10); + }); + + it('should work with removed MutationObserver', async () => { + const { page } = getTestState(); + + await page.evaluate(() => delete window.MutationObserver); + const [handle] = await Promise.all([ + page.waitForSelector('aria/anything'), + page.setContent(`

anything

`), + ]); + expect( + await page.evaluate((x: HTMLElement) => x.textContent, handle) + ).toBe('anything'); + }); + + it('should resolve promise when node is added', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const frame = page.mainFrame(); + const watchdog = frame.waitForSelector('aria/[role="heading"]'); + await frame.evaluate(addElement, 'br'); + await frame.evaluate(addElement, 'h1'); + const elementHandle = await watchdog; + const tagName = await elementHandle + .getProperty('tagName') + .then((element) => element.jsonValue()); + expect(tagName).toBe('H1'); + }); + + it('should work when node is added through innerHTML', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + const watchdog = page.waitForSelector('aria/name'); + await page.evaluate(addElement, 'span'); + await page.evaluate( + () => + (document.querySelector('span').innerHTML = + '

') + ); + await watchdog; + }); + + it('Page.waitForSelector is shortcut for main frame', async () => { + const { page, server } = getTestState(); + + await page.goto(server.EMPTY_PAGE); + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + const otherFrame = page.frames()[1]; + const watchdog = page.waitForSelector('aria/[role="button"]'); + await otherFrame.evaluate(addElement, 'button'); + await page.evaluate(addElement, 'button'); + const elementHandle = await watchdog; + expect(elementHandle.executionContext().frame()).toBe(page.mainFrame()); + }); + + it('should run in specified frame', async () => { + const { page, server } = getTestState(); + + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + await utils.attachFrame(page, 'frame2', server.EMPTY_PAGE); + const frame1 = page.frames()[1]; + const frame2 = page.frames()[2]; + const waitForSelectorPromise = frame2.waitForSelector( + 'aria/[role="button"]' + ); + await frame1.evaluate(addElement, 'button'); + await frame2.evaluate(addElement, 'button'); + const elementHandle = await waitForSelectorPromise; + expect(elementHandle.executionContext().frame()).toBe(frame2); + }); + + it('should throw when frame is detached', async () => { + const { page, server } = getTestState(); + + await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); + const frame = page.frames()[1]; + let waitError = null; + const waitPromise = frame + .waitForSelector('aria/does-not-exist') + .catch((error) => (waitError = error)); + await utils.detachFrame(page, 'frame1'); + await waitPromise; + expect(waitError).toBeTruthy(); + expect(waitError.message).toContain( + 'waitForFunction failed: frame got detached.' + ); + }); + + it('should survive cross-process navigation', async () => { + const { page, server } = getTestState(); + + let imgFound = false; + const waitForSelector = page + .waitForSelector('aria/[role="img"]') + .then(() => (imgFound = true)); + await page.goto(server.EMPTY_PAGE); + expect(imgFound).toBe(false); + await page.reload(); + expect(imgFound).toBe(false); + await page.goto(server.CROSS_PROCESS_PREFIX + '/grid.html'); + await waitForSelector; + expect(imgFound).toBe(true); + }); + + it('should wait for visible', async () => { + const { page } = getTestState(); + + let divFound = false; + const waitForSelector = page + .waitForSelector('aria/name', { visible: true }) + .then(() => (divFound = true)); + await page.setContent( + `
1
` + ); + expect(divFound).toBe(false); + await page.evaluate(() => + document.querySelector('div').style.removeProperty('display') + ); + expect(divFound).toBe(false); + await page.evaluate(() => + document.querySelector('div').style.removeProperty('visibility') + ); + expect(await waitForSelector).toBe(true); + expect(divFound).toBe(true); + }); + + it('should wait for visible recursively', async () => { + const { page } = getTestState(); + + let divVisible = false; + const waitForSelector = page + .waitForSelector('aria/inner', { visible: true }) + .then(() => (divVisible = true)); + await page.setContent( + `
hi
` + ); + expect(divVisible).toBe(false); + await page.evaluate(() => + document.querySelector('div').style.removeProperty('display') + ); + expect(divVisible).toBe(false); + await page.evaluate(() => + document.querySelector('div').style.removeProperty('visibility') + ); + expect(await waitForSelector).toBe(true); + expect(divVisible).toBe(true); + }); + + it('hidden should wait for visibility: hidden', async () => { + const { page } = getTestState(); + + let divHidden = false; + await page.setContent( + `
` + ); + const waitForSelector = page + .waitForSelector('aria/[role="button"]', { hidden: true }) + .then(() => (divHidden = true)); + await page.waitForSelector('aria/[role="button"]'); // do a round trip + expect(divHidden).toBe(false); + await page.evaluate(() => + document.querySelector('div').style.setProperty('visibility', 'hidden') + ); + expect(await waitForSelector).toBe(true); + expect(divHidden).toBe(true); + }); + + it('hidden should wait for display: none', async () => { + const { page } = getTestState(); + + let divHidden = false; + await page.setContent(`
`); + const waitForSelector = page + .waitForSelector('aria/[role="main"]', { hidden: true }) + .then(() => (divHidden = true)); + await page.waitForSelector('aria/[role="main"]'); // do a round trip + expect(divHidden).toBe(false); + await page.evaluate(() => + document.querySelector('div').style.setProperty('display', 'none') + ); + expect(await waitForSelector).toBe(true); + expect(divHidden).toBe(true); + }); + + it('hidden should wait for removal', async () => { + const { page } = getTestState(); + + await page.setContent(`
`); + let divRemoved = false; + const waitForSelector = page + .waitForSelector('aria/[role="main"]', { hidden: true }) + .then(() => (divRemoved = true)); + await page.waitForSelector('aria/[role="main"]'); // do a round trip + expect(divRemoved).toBe(false); + await page.evaluate(() => document.querySelector('div').remove()); + expect(await waitForSelector).toBe(true); + expect(divRemoved).toBe(true); + }); + + it('should return null if waiting to hide non-existing element', async () => { + const { page } = getTestState(); + + const handle = await page.waitForSelector('aria/non-existing', { + hidden: true, + }); + expect(handle).toBe(null); + }); + + it('should respect timeout', async () => { + const { page, puppeteer } = getTestState(); + + let error = null; + await page + .waitForSelector('aria/[role="button"]', { timeout: 10 }) + .catch((error_) => (error = error_)); + expect(error).toBeTruthy(); + expect(error.message).toContain( + 'waiting for selector `[role="button"]` failed: timeout' + ); + expect(error).toBeInstanceOf(puppeteer.errors.TimeoutError); + }); + + it('should have an error message specifically for awaiting an element to be hidden', async () => { + const { page } = getTestState(); + + await page.setContent(`
`); + let error = null; + await page + .waitForSelector('aria/[role="main"]', { hidden: true, timeout: 10 }) + .catch((error_) => (error = error_)); + expect(error).toBeTruthy(); + expect(error.message).toContain( + 'waiting for selector `[role="main"]` to be hidden failed: timeout' + ); + }); + + it('should respond to node attribute mutation', async () => { + const { page } = getTestState(); + + let divFound = false; + const waitForSelector = page + .waitForSelector('aria/zombo') + .then(() => (divFound = true)); + await page.setContent(`
`); + expect(divFound).toBe(false); + await page.evaluate(() => + document.querySelector('div').setAttribute('aria-label', 'zombo') + ); + expect(await waitForSelector).toBe(true); + }); + + it('should return the element handle', async () => { + const { page } = getTestState(); + + const waitForSelector = page.waitForSelector('aria/zombo'); + await page.setContent(`
anything
`); + expect( + await page.evaluate( + (x: HTMLElement) => x.textContent, + await waitForSelector + ) + ).toBe('anything'); + }); + + it('should have correct stack trace for timeout', async () => { + const { page } = getTestState(); + + let error; + await page + .waitForSelector('aria/zombo', { timeout: 10 }) + .catch((error_) => (error = error_)); + expect(error.stack).toContain('waiting for selector `zombo` failed'); + }); + }); + describe('queryOne (Chromium web test)', async () => { beforeEach(async () => { const { page } = getTestState(); diff --git a/test/frame.spec.ts b/test/frame.spec.ts index ecea9359677..269da7d7f1b 100644 --- a/test/frame.spec.ts +++ b/test/frame.spec.ts @@ -76,7 +76,7 @@ describe('Frame specs', function () { let error = null; await frame1.evaluate(() => 7 * 8).catch((error_) => (error = error_)); expect(error.message).toContain( - 'Execution Context is not available in detached frame' + 'Execution context is not available in detached frame' ); }); });