feat(a11y-query): extend aria handler with waitFor (#6472)

This commit adds waitFor to the built-in aria handler (#6307).
This commit is contained in:
Johan Bay 2020-10-07 10:49:11 +02:00 committed by GitHub
parent cc7f1fd063
commit 3afe1935da
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 576 additions and 125 deletions

View File

@ -18,6 +18,7 @@ import { InternalQueryHandler } from './QueryHandler.js';
import { ElementHandle, JSHandle } from './JSHandle.js'; import { ElementHandle, JSHandle } from './JSHandle.js';
import { Protocol } from 'devtools-protocol'; import { Protocol } from 'devtools-protocol';
import { CDPSession } from './Connection.js'; import { CDPSession } from './Connection.js';
import { DOMWorld, WaitForSelectorOptions } from './DOMWorld.js';
async function queryAXTree( async function queryAXTree(
client: CDPSession, client: CDPSession,
@ -82,8 +83,17 @@ const queryOne = async (
return exeCtx._adoptBackendNodeId(res[0].backendDOMNodeId); return exeCtx._adoptBackendNodeId(res[0].backendDOMNodeId);
}; };
const waitFor = () => { const waitFor = async (
throw new Error('waitForSelector is not supported for aria selectors'); domWorld: DOMWorld,
selector: string,
options: WaitForSelectorOptions
) => {
await addHandlerToWorld(domWorld);
return domWorld.waitForSelectorInPage(
(_: Element, selector: string) => globalThis.ariaQuerySelector(selector),
selector,
options
);
}; };
const queryAll = async ( const queryAll = async (
@ -111,6 +121,14 @@ const queryAllArray = async (
return jsHandle; 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 * @internal
*/ */

View File

@ -15,7 +15,7 @@
*/ */
import { assert } from './assert.js'; import { assert } from './assert.js';
import { helper } from './helper.js'; import { helper, debugError } from './helper.js';
import { import {
LifecycleWatcher, LifecycleWatcher,
PuppeteerLifeCycleEvent, PuppeteerLifeCycleEvent,
@ -36,13 +36,14 @@ import {
UnwrapPromiseLike, UnwrapPromiseLike,
} from './EvalTypes.js'; } from './EvalTypes.js';
import { isNode } from '../environment.js'; import { isNode } from '../environment.js';
import { Protocol } from 'devtools-protocol';
// predicateQueryHandler and checkWaitForOptions are declared here so that // predicateQueryHandler and checkWaitForOptions are declared here so that
// TypeScript knows about them when used in the predicate function below. // TypeScript knows about them when used in the predicate function below.
declare const predicateQueryHandler: ( declare const predicateQueryHandler: (
element: Element | Document, element: Element | Document,
selector: string selector: string
) => Element | Element[] | NodeListOf<Element>; ) => Promise<Element | Element[] | NodeListOf<Element>>;
declare const checkWaitForOptions: ( declare const checkWaitForOptions: (
node: Node, node: Node,
waitForVisible: boolean, waitForVisible: boolean,
@ -76,6 +77,11 @@ export class DOMWorld {
*/ */
_waitTasks = new Set<WaitTask>(); _waitTasks = new Set<WaitTask>();
// Contains mapping from functions that should be bound to Puppeteer functions.
private _boundFunctions = new Map<string, Function>();
// Set of bindings that have been registered in the current context.
private _ctxBindings = new Set<string>();
constructor( constructor(
frameManager: FrameManager, frameManager: FrameManager,
frame: Frame, frame: Frame,
@ -85,16 +91,23 @@ export class DOMWorld {
this._frame = frame; this._frame = frame;
this._timeoutSettings = timeoutSettings; this._timeoutSettings = timeoutSettings;
this._setContext(null); this._setContext(null);
frameManager._client.on('Runtime.bindingCalled', (event) =>
this._onBindingCalled(event)
);
} }
frame(): Frame { frame(): Frame {
return this._frame; return this._frame;
} }
_setContext(context?: ExecutionContext): void { async _setContext(context?: ExecutionContext): Promise<void> {
if (context) { if (context) {
this._contextResolveCallback.call(null, context); this._contextResolveCallback.call(null, context);
this._contextResolveCallback = null; 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(); for (const waitTask of this._waitTasks) waitTask.rerun();
} else { } else {
this._documentPromise = null; this._documentPromise = null;
@ -119,7 +132,7 @@ export class DOMWorld {
executionContext(): Promise<ExecutionContext> { executionContext(): Promise<ExecutionContext> {
if (this._detached) if (this._detached)
throw new Error( 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; return this._contextPromise;
} }
@ -438,7 +451,6 @@ export class DOMWorld {
async tap(selector: string): Promise<void> { async tap(selector: string): Promise<void> {
const handle = await this.$(selector); const handle = await this.$(selector);
assert(handle, 'No node found for selector: ' + selector);
await handle.tap(); await handle.tap();
await handle.dispose(); await handle.dispose();
} }
@ -464,6 +476,90 @@ export class DOMWorld {
return queryHandler.waitFor(this, updatedSelector, options); 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<void> | 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<void> {
this._boundFunctions.set(name, puppeteerFunction);
await this.addBindingToContext(name);
}
private async _onBindingCalled(
event: Protocol.Runtime.BindingCalledEvent
): Promise<void> {
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 * @internal
*/ */
@ -481,19 +577,19 @@ export class DOMWorld {
const title = `selector \`${selector}\`${ const title = `selector \`${selector}\`${
waitForHidden ? ' to be hidden' : '' waitForHidden ? ' to be hidden' : ''
}`; }`;
function predicate( async function predicate(
selector: string, selector: string,
waitForVisible: boolean, waitForVisible: boolean,
waitForHidden: boolean waitForHidden: boolean
): Node | null | boolean { ): Promise<Node | null | boolean> {
const node = predicateQueryHandler const node = predicateQueryHandler
? (predicateQueryHandler(document, selector) as Element) ? ((await predicateQueryHandler(document, selector)) as Element)
: document.querySelector(selector); : document.querySelector(selector);
return checkWaitForOptions(node, waitForVisible, waitForHidden); return checkWaitForOptions(node, waitForVisible, waitForHidden);
} }
const waitTask = new WaitTask( const waitTask = new WaitTask(
this, this,
this._makePredicateString(predicate, queryOne), helper.makePredicateString(predicate, queryOne),
title, title,
polling, polling,
timeout, timeout,
@ -537,7 +633,7 @@ export class DOMWorld {
} }
const waitTask = new WaitTask( const waitTask = new WaitTask(
this, this,
this._makePredicateString(predicate), helper.makePredicateString(predicate),
title, title,
polling, polling,
timeout, timeout,
@ -554,45 +650,6 @@ export class DOMWorld {
return elementHandle; 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( waitForFunction(
pageFunction: Function | string, pageFunction: Function | string,
options: { polling?: string | number; timeout?: number } = {}, options: { polling?: string | number; timeout?: number } = {},
@ -617,7 +674,10 @@ export class DOMWorld {
} }
} }
class WaitTask { /**
* @internal
*/
export class WaitTask {
_domWorld: DOMWorld; _domWorld: DOMWorld;
_polling: string | number; _polling: string | number;
_timeout: number; _timeout: number;
@ -715,23 +775,34 @@ class WaitTask {
await success.dispose(); await success.dispose();
return; 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. // When the page is navigated, the promise is rejected.
// We will try again in the new execution context. // We will try again in the new execution context.
if (error && error.message.includes('Execution context was destroyed')) if (error.message.includes('Execution context was destroyed')) return;
return;
// We could have tried to evaluate in a context which was already // We could have tried to evaluate in a context which was already
// destroyed. // destroyed.
if ( if (error.message.includes('Cannot find context with specified id'))
error &&
error.message.includes('Cannot find context with specified id')
)
return; return;
if (error) this._reject(error); this._reject(error);
else this._resolve(success); } else {
this._resolve(success);
}
this._cleanup(); this._cleanup();
} }

View File

@ -52,7 +52,10 @@ export class ExecutionContext {
* @internal * @internal
*/ */
_world: DOMWorld; _world: DOMWorld;
private _contextId: number; /**
* @internal
*/
_contextId: number;
/** /**
* @internal * @internal

View File

@ -1050,7 +1050,7 @@ export class Page extends EventEmitter {
); );
this._pageBindings.set(name, puppeteerFunction); 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('Runtime.addBinding', { name: name });
await this._client.send('Page.addScriptToEvaluateOnNewDocument', { await this._client.send('Page.addScriptToEvaluateOnNewDocument', {
source: expression, source: expression,
@ -1058,30 +1058,6 @@ export class Page extends EventEmitter {
await Promise.all( await Promise.all(
this.frames().map((frame) => frame.evaluate(expression).catch(debugError)) 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<unknown> => {
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<void> { async authenticate(credentials: Credentials): Promise<void> {
@ -1156,23 +1132,22 @@ export class Page extends EventEmitter {
private async _onBindingCalled( private async _onBindingCalled(
event: Protocol.Runtime.BindingCalledEvent event: Protocol.Runtime.BindingCalledEvent
): Promise<void> { ): Promise<void> {
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; let expression = null;
try { try {
const result = await this._pageBindings.get(name)(...args); const result = await this._pageBindings.get(name)(...args);
expression = helper.evaluationString(deliverResult, name, seq, result); expression = helper.pageBindingDeliverResultString(name, seq, result);
} catch (error) { } catch (error) {
if (error instanceof Error) if (error instanceof Error)
expression = helper.evaluationString( expression = helper.pageBindingDeliverErrorString(
deliverError,
name, name,
seq, seq,
error.message, error.message,
error.stack error.stack
); );
else else
expression = helper.evaluationString( expression = helper.pageBindingDeliverErrorValueString(
deliverErrorValue,
name, name,
seq, seq,
error error
@ -1184,32 +1159,6 @@ export class Page extends EventEmitter {
contextId: event.executionContextId, contextId: event.executionContextId,
}) })
.catch(debugError); .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( private _addConsoleMessage(

View File

@ -177,6 +177,114 @@ function evaluationString(fun: Function | string, ...args: unknown[]): string {
return `(${fun})(${args.map(serializeArgument).join(',')})`; 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<unknown> => {
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<T extends any>( async function waitWithTimeout<T extends any>(
promise: Promise<T>, promise: Promise<T>,
taskName: string, taskName: string,
@ -229,6 +337,11 @@ async function readProtocolStream(
export const helper = { export const helper = {
evaluationString, evaluationString,
pageBindingInitString,
pageBindingDeliverResultString,
pageBindingDeliverErrorString,
pageBindingDeliverErrorValueString,
makePredicateString,
readProtocolStream, readProtocolStream,
waitWithTimeout, waitWithTimeout,
waitForEvent, waitForEvent,

View File

@ -23,6 +23,7 @@ import {
} from './mocha-utils'; // eslint-disable-line import/extensions } from './mocha-utils'; // eslint-disable-line import/extensions
import { ElementHandle } from '../lib/cjs/puppeteer/common/JSHandle.js'; import { ElementHandle } from '../lib/cjs/puppeteer/common/JSHandle.js';
import utils from './utils.js';
describeChromeOnly('AriaQueryHandler', () => { describeChromeOnly('AriaQueryHandler', () => {
setupTestBrowserHooks(); 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(`<h1>anything</h1>`),
]);
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 =
'<h3><div aria-label="name"></div></h3>')
);
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(
`<div aria-label='name' style='display: none; visibility: hidden;'>1</div>`
);
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(
`<div style='display: none; visibility: hidden;'><div aria-label="inner">hi</div></div>`
);
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(
`<div role='button' style='display: block;'></div>`
);
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(`<div role='main' style='display: block;'></div>`);
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(`<div role='main'></div>`);
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(`<div role='main'></div>`);
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(`<div aria-label='notZombo'></div>`);
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(`<div aria-label='zombo'>anything</div>`);
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 () => { describe('queryOne (Chromium web test)', async () => {
beforeEach(async () => { beforeEach(async () => {
const { page } = getTestState(); const { page } = getTestState();

View File

@ -76,7 +76,7 @@ describe('Frame specs', function () {
let error = null; let error = null;
await frame1.evaluate(() => 7 * 8).catch((error_) => (error = error_)); await frame1.evaluate(() => 7 * 8).catch((error_) => (error = error_));
expect(error.message).toContain( expect(error.message).toContain(
'Execution Context is not available in detached frame' 'Execution context is not available in detached frame'
); );
}); });
}); });