feat(selectors): support various selectors in waitFor methods (#122)

This commit is contained in:
Dmitry Gozman 2019-12-03 10:43:13 -08:00 коммит произвёл GitHub
Родитель 9cb0c95f5d
Коммит 6b3c2632e7
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
4 изменённых файлов: 97 добавлений и 110 удалений

Просмотреть файл

@ -10,6 +10,7 @@ import * as cssSelectorEngineSource from './generated/cssSelectorEngineSource';
import * as xpathSelectorEngineSource from './generated/xpathSelectorEngineSource';
import { assert, helper } from './helper';
import Injected from './injected/injected';
import { WaitTaskParams } from './waitTask';
export interface DOMWorldDelegate {
keyboard: input.Keyboard;
@ -47,7 +48,7 @@ export class DOMWorld {
return null;
}
private _injected(): Promise<js.JSHandle> {
injected(): Promise<js.JSHandle> {
if (!this._injectedPromise) {
const engineSources = [cssSelectorEngineSource.source, xpathSelectorEngineSource.source];
const source = `
@ -65,36 +66,21 @@ export class DOMWorld {
return this.delegate.adoptElementHandle(handle, this);
}
private _normalizeSelector(selector: string): string {
const eqIndex = selector.indexOf('=');
if (eqIndex !== -1 && selector.substring(0, eqIndex).trim().match(/^[a-zA-Z_0-9]+$/))
return selector;
if (selector.startsWith('//'))
return 'xpath=' + selector;
return 'css=' + selector;
}
private async _resolveSelector(selector: Selector): Promise<ResolvedSelector> {
if (helper.isString(selector))
return { selector: this._normalizeSelector(selector) };
return { selector: normalizeSelector(selector) };
if (selector.root && selector.root.executionContext() !== this.context) {
const root = await this.adoptElementHandle(selector.root);
return { root, selector: this._normalizeSelector(selector.selector), disposeRoot: true };
return { root, selector: normalizeSelector(selector.selector), disposeRoot: true };
}
return { root: selector.root, selector: this._normalizeSelector(selector.selector) };
}
private _selectorToString(selector: Selector): string {
if (typeof selector === 'string')
return selector;
return `:scope >> ${selector.selector}`;
return { root: selector.root, selector: normalizeSelector(selector.selector) };
}
async $(selector: Selector): Promise<ElementHandle | null> {
const resolved = await this._resolveSelector(selector);
const handle = await this.context.evaluateHandle(
(injected: Injected, selector: string, root: SelectorRoot | undefined) => injected.querySelector(selector, root || document),
await this._injected(), resolved.selector, resolved.root
await this.injected(), resolved.selector, resolved.root
);
if (resolved.disposeRoot)
await resolved.root.dispose();
@ -107,7 +93,7 @@ export class DOMWorld {
const resolved = await this._resolveSelector(selector);
const arrayHandle = await this.context.evaluateHandle(
(injected: Injected, selector: string, root: SelectorRoot | undefined) => injected.querySelectorAll(selector, root || document),
await this._injected(), resolved.selector, resolved.root
await this.injected(), resolved.selector, resolved.root
);
if (resolved.disposeRoot)
await resolved.root.dispose();
@ -127,7 +113,7 @@ export class DOMWorld {
$eval: types.$Eval<Selector> = async (selector, pageFunction, ...args) => {
const elementHandle = await this.$(selector);
if (!elementHandle)
throw new Error(`Error: failed to find element matching selector "${this._selectorToString(selector)}"`);
throw new Error(`Error: failed to find element matching selector "${selectorToString(selector)}"`);
const result = await elementHandle.evaluate(pageFunction, ...args as any);
await elementHandle.dispose();
return result;
@ -137,7 +123,7 @@ export class DOMWorld {
const resolved = await this._resolveSelector(selector);
const arrayHandle = await this.context.evaluateHandle(
(injected: Injected, selector: string, root: SelectorRoot | undefined) => injected.querySelectorAll(selector, root || document),
await this._injected(), resolved.selector, resolved.root
await this.injected(), resolved.selector, resolved.root
);
const result = await arrayHandle.evaluate(pageFunction, ...args as any);
await arrayHandle.dispose();
@ -305,3 +291,52 @@ export class ElementHandle extends js.JSHandle {
});
}
}
function normalizeSelector(selector: string): string {
const eqIndex = selector.indexOf('=');
if (eqIndex !== -1 && selector.substring(0, eqIndex).trim().match(/^[a-zA-Z_0-9]+$/))
return selector;
if (selector.startsWith('//'))
return 'xpath=' + selector;
return 'css=' + selector;
}
function selectorToString(selector: Selector): string {
if (typeof selector === 'string')
return selector;
return `:scope >> ${selector.selector}`;
}
export type WaitForSelectorOptions = { visible?: boolean, hidden?: boolean, timeout?: number };
export function waitForSelectorTask(selector: string, options: WaitForSelectorOptions): WaitTaskParams {
const { visible: waitForVisible = false, hidden: waitForHidden = false, timeout } = options;
const polling = waitForVisible || waitForHidden ? 'raf' : 'mutation';
const title = `selector "${selector}"${waitForHidden ? ' to be hidden' : ''}`;
const params: WaitTaskParams = {
predicateBody: predicate,
title,
polling,
timeout,
args: [normalizeSelector(selector), waitForVisible, waitForHidden],
passInjected: true
};
return params;
function predicate(injected: Injected, selector: string, waitForVisible: boolean, waitForHidden: boolean): (Node | boolean) | null {
const element = injected.querySelector(selector, document);
if (!element)
return waitForHidden;
if (!waitForVisible && !waitForHidden)
return element;
const style = window.getComputedStyle(element);
const isVisible = style && style.visibility !== 'hidden' && hasVisibleBoundingBox();
const success = (waitForVisible === isVisible || waitForHidden === !isVisible);
return success ? element : null;
function hasVisibleBoundingBox(): boolean {
const rect = element.getBoundingClientRect();
return !!(rect.top || rect.bottom || rect.width || rect.height);
}
}
}

Просмотреть файл

@ -22,7 +22,7 @@ import * as dom from './dom';
import * as network from './network';
import { helper, assert } from './helper';
import { ClickOptions, MultiClickOptions, PointerActionOptions, SelectOption } from './input';
import { waitForSelectorOrXPath, WaitTaskParams, WaitTask } from './waitTask';
import { WaitTaskParams, WaitTask } from './waitTask';
import { TimeoutSettings } from './TimeoutSettings';
const readFileAsync = helper.promisify(fs.readFile);
@ -376,14 +376,8 @@ export class Frame {
}
waitFor(selectorOrFunctionOrTimeout: (string | number | Function), options: any = {}, ...args: any[]): Promise<js.JSHandle | null> {
const xPathPattern = '//';
if (helper.isString(selectorOrFunctionOrTimeout)) {
const string = selectorOrFunctionOrTimeout as string;
if (string.startsWith(xPathPattern))
return this.waitForXPath(string, options) as any;
return this.waitForSelector(string, options) as any;
}
if (helper.isString(selectorOrFunctionOrTimeout))
return this.waitForSelector(selectorOrFunctionOrTimeout as string, options) as any;
if (helper.isNumber(selectorOrFunctionOrTimeout))
return new Promise(fulfill => setTimeout(fulfill, selectorOrFunctionOrTimeout as number));
if (typeof selectorOrFunctionOrTimeout === 'function')
@ -391,12 +385,9 @@ export class Frame {
return Promise.reject(new Error('Unsupported target type: ' + (typeof selectorOrFunctionOrTimeout)));
}
async waitForSelector(selector: string, options: {
visible?: boolean;
hidden?: boolean;
timeout?: number; } | undefined): Promise<dom.ElementHandle | null> {
const params = waitForSelectorOrXPath(selector, false /* isXPath */, { timeout: this._timeoutSettings.timeout(), ...options });
const handle = await this._scheduleWaitTask(params, this._worlds.get('utility'));
async waitForSelector(selector: string, options: dom.WaitForSelectorOptions = {}): Promise<dom.ElementHandle | null> {
const params = dom.waitForSelectorTask(selector, { timeout: this._timeoutSettings.timeout(), ...options });
const handle = await this._scheduleWaitTask(params, 'utility');
if (!handle.asElement()) {
await handle.dispose();
return null;
@ -409,22 +400,8 @@ export class Frame {
return adopted;
}
async waitForXPath(xpath: string, options: {
visible?: boolean;
hidden?: boolean;
timeout?: number; } | undefined): Promise<dom.ElementHandle | null> {
const params = waitForSelectorOrXPath(xpath, true /* isXPath */, { timeout: this._timeoutSettings.timeout(), ...options });
const handle = await this._scheduleWaitTask(params, this._worlds.get('utility'));
if (!handle.asElement()) {
await handle.dispose();
return null;
}
const mainDOMWorld = await this._mainDOMWorld();
if (handle.executionContext() === mainDOMWorld.context)
return handle.asElement();
const adopted = await mainDOMWorld.adoptElementHandle(handle.asElement());
await handle.dispose();
return adopted;
async waitForXPath(xpath: string, options: dom.WaitForSelectorOptions = {}): Promise<dom.ElementHandle | null> {
return this.waitForSelector('xpath=' + xpath, options);
}
waitForFunction(
@ -442,7 +419,7 @@ export class Frame {
timeout,
args
};
return this._scheduleWaitTask(params, this._worlds.get('main'));
return this._scheduleWaitTask(params, 'main');
}
async title(): Promise<string> {
@ -466,7 +443,8 @@ export class Frame {
this._parentFrame = null;
}
private _scheduleWaitTask(params: WaitTaskParams, world: World): Promise<js.JSHandle> {
private _scheduleWaitTask(params: WaitTaskParams, worldType: WorldType): Promise<js.JSHandle> {
const world = this._worlds.get(worldType);
const task = new WaitTask(params, () => world.waitTasks.delete(task));
world.waitTasks.add(task);
if (world.context)

Просмотреть файл

@ -4,6 +4,7 @@
import { assert, helper } from './helper';
import * as js from './javascript';
import { TimeoutError } from './Errors';
import Injected from './injected/injected';
export type WaitTaskParams = {
// TODO: ensure types.
@ -12,6 +13,7 @@ export type WaitTaskParams = {
polling: string | number;
timeout: number;
args: any[];
passInjected?: boolean;
};
export class WaitTask {
@ -61,7 +63,8 @@ export class WaitTask {
let success: js.JSHandle | null = null;
let error = null;
try {
success = await context.evaluateHandle(waitForPredicatePageFunction, this._params.predicateBody, this._params.polling, this._params.timeout, ...this._params.args);
assert(context._domWorld, 'Wait task requires a dom world');
success = await context.evaluateHandle(waitForPredicatePageFunction, await context._domWorld.injected(), this._params.predicateBody, this._params.polling, this._params.timeout, !!this._params.passInjected, ...this._params.args);
} catch (e) {
error = e;
}
@ -104,44 +107,9 @@ export class WaitTask {
}
}
export function waitForSelectorOrXPath(
selectorOrXPath: string,
isXPath: boolean,
options: { visible?: boolean, hidden?: boolean, timeout: number }): WaitTaskParams {
const { visible: waitForVisible = false, hidden: waitForHidden = false, timeout } = options;
const polling = waitForVisible || waitForHidden ? 'raf' : 'mutation';
const title = `${isXPath ? 'XPath' : 'selector'} "${selectorOrXPath}"${waitForHidden ? ' to be hidden' : ''}`;
const params: WaitTaskParams = {
predicateBody: predicate,
title,
polling,
timeout,
args: [selectorOrXPath, isXPath, waitForVisible, waitForHidden]
};
return params;
function predicate(selectorOrXPath: string, isXPath: boolean, waitForVisible: boolean, waitForHidden: boolean): (Node | boolean) | null {
const node = isXPath
? document.evaluate(selectorOrXPath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue
: document.querySelector(selectorOrXPath);
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);
}
}
}
async function waitForPredicatePageFunction(predicateBody: string, polling: string | number, timeout: number, ...args): Promise<any> {
async function waitForPredicatePageFunction(injected: Injected, predicateBody: string, polling: string | number, timeout: number, passInjected: boolean, ...args): Promise<any> {
if (passInjected)
args = [injected, ...args];
const predicate = new Function('...args', predicateBody);
let timedOut = false;
if (timeout)

Просмотреть файл

@ -90,16 +90,15 @@ module.exports.addTests = function({testRunner, expect, product, playwright, FFO
});
});
it('should poll on interval', async({page, server}) => {
let success = false;
const startTime = Date.now();
const polling = 100;
const watchdog = page.waitForFunction(() => window.__FOO === 'hit', {polling})
.then(() => success = true);
await page.evaluate(() => window.__FOO = 'hit');
expect(success).toBe(false);
await page.evaluate(() => document.body.appendChild(document.createElement('div')));
await watchdog;
expect(Date.now() - startTime).not.toBeLessThan(polling / 2);
const timeDelta = await page.waitForFunction(() => {
if (!window.__startTime) {
window.__startTime = Date.now();
return false;
}
return Date.now() - window.__startTime;
}, {polling});
expect(timeDelta).not.toBeLessThan(polling);
});
it('should poll on mutation', async({page, server}) => {
let success = false;
@ -377,6 +376,18 @@ module.exports.addTests = function({testRunner, expect, product, playwright, FFO
await page.waitForSelector('.zombo', {timeout: 10}).catch(e => error = e);
expect(error.stack).toContain('waittask.spec.js');
});
it('should support >> selector syntax', async({page, server}) => {
await page.goto(server.EMPTY_PAGE);
const frame = page.mainFrame();
const watchdog = frame.waitForSelector('css=div >> css=span');
await frame.evaluate(addElement, 'br');
await frame.evaluate(addElement, 'div');
await frame.evaluate(() => document.querySelector('div').appendChild(document.createElement('span')));
const eHandle = await watchdog;
const tagName = await eHandle.getProperty('tagName').then(e => e.jsonValue());
expect(tagName).toBe('SPAN');
});
});
describe('Frame.waitForXPath', function() {
@ -391,7 +402,7 @@ module.exports.addTests = function({testRunner, expect, product, playwright, FFO
let error = null;
await page.waitForXPath('//div', {timeout: 10}).catch(e => error = e);
expect(error).toBeTruthy();
expect(error.message).toContain('waiting for XPath "//div" failed: timeout');
expect(error.message).toContain('waiting for selector "xpath=//div" failed: timeout');
expect(error).toBeInstanceOf(playwright.errors.TimeoutError);
});
it('should run in specified frame', async({page, server}) => {
@ -430,11 +441,6 @@ module.exports.addTests = function({testRunner, expect, product, playwright, FFO
await page.setContent(`<div class='zombo'>anything</div>`);
expect(await page.evaluate(x => x.textContent, await waitForXPath)).toBe('anything');
});
it('should allow you to select a text node', async({page, server}) => {
await page.setContent(`<div>some text</div>`);
const text = await page.waitForXPath('//div/text()');
expect(await (await text.getProperty('nodeType')).jsonValue()).toBe(3 /* Node.TEXT_NODE */);
});
it('should allow you to select an element with single slash', async({page, server}) => {
await page.setContent(`<div>some text</div>`);
const waitForXPath = page.waitForXPath('/html/body/div');