feat(selectors): support various selectors in waitFor methods (#122)
This commit is contained in:
Родитель
9cb0c95f5d
Коммит
6b3c2632e7
81
src/dom.ts
81
src/dom.ts
|
@ -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');
|
||||
|
|
Загрузка…
Ссылка в новой задаче