feat(selectors): support various kinds of selectors (#118)

This adds support for generic "engine=body [>> engine=body]*" selector syntax
and auto-detects simple css or xpath.
This commit is contained in:
Dmitry Gozman 2019-12-02 17:33:44 -08:00 committed by GitHub
parent 505c9e3660
commit bb1433a143
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 238 additions and 106 deletions

View File

@ -25,25 +25,29 @@ export interface DOMWorldDelegate {
adoptElementHandle(handle: ElementHandle, to: DOMWorld): Promise<ElementHandle>;
}
type SelectorRoot = Element | ShadowRoot | Document;
type ResolvedSelector = { root?: ElementHandle, selector: string, disposeRoot?: boolean };
type Selector = string | { root?: ElementHandle, selector: string };
export class DOMWorld {
readonly context: js.ExecutionContext;
readonly delegate: DOMWorldDelegate;
private _injectedPromise?: Promise<js.JSHandle>;
private _documentPromise?: Promise<ElementHandle>;
constructor(context: js.ExecutionContext, delegate: DOMWorldDelegate) {
this.context = context;
this.delegate = delegate;
}
_createHandle(remoteObject: any): ElementHandle | null {
createHandle(remoteObject: any): ElementHandle | null {
if (this.delegate.isElement(remoteObject))
return new ElementHandle(this.context, remoteObject);
return null;
}
injected(): Promise<js.JSHandle> {
private _injected(): Promise<js.JSHandle> {
if (!this._injectedPromise) {
const engineSources = [cssSelectorEngineSource.source, xpathSelectorEngineSource.source];
const source = `
@ -56,24 +60,91 @@ export class DOMWorld {
return this._injectedPromise;
}
_document(): Promise<ElementHandle> {
if (!this._documentPromise)
this._documentPromise = this.context.evaluateHandle('document').then(handle => handle.asElement()!);
return this._documentPromise;
async adoptElementHandle(handle: ElementHandle): Promise<ElementHandle> {
assert(handle.executionContext() !== this.context, 'Should not adopt to the same context');
return this.delegate.adoptElementHandle(handle, this);
}
async adoptElementHandle(handle: ElementHandle, dispose: boolean): Promise<ElementHandle> {
if (handle.executionContext() === this.context)
return handle;
const adopted = this.delegate.adoptElementHandle(handle, this);
if (dispose)
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) };
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.root, selector: this._normalizeSelector(selector.selector) };
}
private _selectorToString(selector: Selector): string {
if (typeof selector === 'string')
return selector;
return `:scope >> ${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
);
if (resolved.disposeRoot)
await resolved.root.dispose();
if (!handle.asElement())
await handle.dispose();
return adopted;
return handle.asElement();
}
async $$(selector: Selector): Promise<ElementHandle[]> {
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
);
if (resolved.disposeRoot)
await resolved.root.dispose();
const properties = await arrayHandle.getProperties();
await arrayHandle.dispose();
const result = [];
for (const property of properties.values()) {
const elementHandle = property.asElement();
if (elementHandle)
result.push(elementHandle);
else
await property.dispose();
}
return result;
}
$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)}"`);
const result = await elementHandle.evaluate(pageFunction, ...args as any);
await elementHandle.dispose();
return result;
}
$$eval: types.$$Eval<Selector> = async (selector, pageFunction, ...args) => {
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
);
const result = await arrayHandle.evaluate(pageFunction, ...args as any);
await arrayHandle.dispose();
return result;
}
}
type SelectorRoot = Element | ShadowRoot | Document;
export class ElementHandle extends js.JSHandle {
private readonly _world: DOMWorld;
@ -198,68 +269,24 @@ export class ElementHandle extends js.JSHandle {
return this._world.delegate.screenshot(this, options);
}
async $(selector: string): Promise<ElementHandle | null> {
const handle = await this.evaluateHandle(
(root: SelectorRoot, selector: string, injected: Injected) => injected.querySelector('css=' + selector, root),
selector, await this._world.injected()
);
const element = handle.asElement();
if (element)
return element;
await handle.dispose();
return null;
$(selector: string): Promise<ElementHandle | null> {
return this._world.$({ root: this, selector });
}
async $$(selector: string): Promise<ElementHandle[]> {
const arrayHandle = await this.evaluateHandle(
(root: SelectorRoot, selector: string, injected: Injected) => injected.querySelectorAll('css=' + selector, root),
selector, await this._world.injected()
);
const properties = await arrayHandle.getProperties();
await arrayHandle.dispose();
const result = [];
for (const property of properties.values()) {
const elementHandle = property.asElement();
if (elementHandle)
result.push(elementHandle);
}
return result;
$$(selector: string): Promise<ElementHandle[]> {
return this._world.$$({ root: this, selector });
}
$eval: types.$Eval = async (selector, pageFunction, ...args) => {
const elementHandle = await this.$(selector);
if (!elementHandle)
throw new Error(`Error: failed to find element matching selector "${selector}"`);
const result = await elementHandle.evaluate(pageFunction, ...args as any);
await elementHandle.dispose();
return result;
$eval: types.$Eval = (selector, pageFunction, ...args) => {
return this._world.$eval({ root: this, selector }, pageFunction, ...args as any);
}
$$eval: types.$$Eval = async (selector, pageFunction, ...args) => {
const arrayHandle = await this.evaluateHandle(
(root: SelectorRoot, selector: string, injected: Injected) => injected.querySelectorAll('css=' + selector, root),
selector, await this._world.injected()
);
const result = await arrayHandle.evaluate(pageFunction, ...args as any);
await arrayHandle.dispose();
return result;
$$eval: types.$$Eval = (selector, pageFunction, ...args) => {
return this._world.$$eval({ root: this, selector }, pageFunction, ...args as any);
}
async $x(expression: string): Promise<ElementHandle[]> {
const arrayHandle = await this.evaluateHandle(
(root: SelectorRoot, expression: string, injected: Injected) => injected.querySelectorAll('xpath=' + expression, root),
expression, await this._world.injected()
);
const properties = await arrayHandle.getProperties();
await arrayHandle.dispose();
const result = [];
for (const property of properties.values()) {
const elementHandle = property.asElement();
if (elementHandle)
result.push(elementHandle);
}
return result;
$x(expression: string): Promise<ElementHandle[]> {
return this._world.$$({ root: this, selector: 'xpath=' + expression });
}
isIntersectingViewport(): Promise<boolean> {

View File

@ -124,32 +124,27 @@ export class Frame {
async $(selector: string): Promise<dom.ElementHandle | null> {
const domWorld = await this._mainDOMWorld();
const document = await domWorld._document();
return document.$(selector);
return domWorld.$(selector);
}
async $x(expression: string): Promise<dom.ElementHandle[]> {
const domWorld = await this._mainDOMWorld();
const document = await domWorld._document();
return document.$x(expression);
return domWorld.$$('xpath=' + expression);
}
$eval: types.$Eval = async (selector, pageFunction, ...args) => {
const domWorld = await this._mainDOMWorld();
const document = await domWorld._document();
return document.$eval(selector, pageFunction, ...args as any);
return domWorld.$eval(selector, pageFunction, ...args as any);
}
$$eval: types.$$Eval = async (selector, pageFunction, ...args) => {
const domWorld = await this._mainDOMWorld();
const document = await domWorld._document();
return document.$$eval(selector, pageFunction, ...args as any);
return domWorld.$$eval(selector, pageFunction, ...args as any);
}
async $$(selector: string): Promise<dom.ElementHandle[]> {
const domWorld = await this._mainDOMWorld();
const document = await domWorld._document();
return document.$$(selector);
return domWorld.$$(selector);
}
async content(): Promise<string> {
@ -307,8 +302,7 @@ export class Frame {
async click(selector: string, options?: ClickOptions) {
const domWorld = await this._utilityDOMWorld();
const document = await domWorld._document();
const handle = await document.$(selector);
const handle = await domWorld.$(selector);
assert(handle, 'No node found for selector: ' + selector);
await handle.click(options);
await handle.dispose();
@ -316,8 +310,7 @@ export class Frame {
async dblclick(selector: string, options?: MultiClickOptions) {
const domWorld = await this._utilityDOMWorld();
const document = await domWorld._document();
const handle = await document.$(selector);
const handle = await domWorld.$(selector);
assert(handle, 'No node found for selector: ' + selector);
await handle.dblclick(options);
await handle.dispose();
@ -325,8 +318,7 @@ export class Frame {
async tripleclick(selector: string, options?: MultiClickOptions) {
const domWorld = await this._utilityDOMWorld();
const document = await domWorld._document();
const handle = await document.$(selector);
const handle = await domWorld.$(selector);
assert(handle, 'No node found for selector: ' + selector);
await handle.tripleclick(options);
await handle.dispose();
@ -334,8 +326,7 @@ export class Frame {
async fill(selector: string, value: string) {
const domWorld = await this._utilityDOMWorld();
const document = await domWorld._document();
const handle = await document.$(selector);
const handle = await domWorld.$(selector);
assert(handle, 'No node found for selector: ' + selector);
await handle.fill(value);
await handle.dispose();
@ -343,8 +334,7 @@ export class Frame {
async focus(selector: string) {
const domWorld = await this._utilityDOMWorld();
const document = await domWorld._document();
const handle = await document.$(selector);
const handle = await domWorld.$(selector);
assert(handle, 'No node found for selector: ' + selector);
await handle.focus();
await handle.dispose();
@ -352,8 +342,7 @@ export class Frame {
async hover(selector: string, options?: PointerActionOptions) {
const domWorld = await this._utilityDOMWorld();
const document = await domWorld._document();
const handle = await document.$(selector);
const handle = await domWorld.$(selector);
assert(handle, 'No node found for selector: ' + selector);
await handle.hover(options);
await handle.dispose();
@ -361,23 +350,26 @@ export class Frame {
async select(selector: string, ...values: (string | dom.ElementHandle | SelectOption)[]): Promise<string[]> {
const domWorld = await this._utilityDOMWorld();
const document = await domWorld._document();
const handle = await document.$(selector);
const handle = await domWorld.$(selector);
assert(handle, 'No node found for selector: ' + selector);
const toDispose: Promise<dom.ElementHandle>[] = [];
const adoptedValues = await Promise.all(values.map(async value => {
if (value instanceof dom.ElementHandle)
return domWorld.adoptElementHandle(value, false /* dispose */);
if (value instanceof dom.ElementHandle && value.executionContext() !== domWorld.context) {
const adopted = domWorld.adoptElementHandle(value);
toDispose.push(adopted);
return adopted;
}
return value;
}));
const result = await handle.select(...adoptedValues);
await handle.dispose();
await Promise.all(toDispose.map(handlePromise => handlePromise.then(handle => handle.dispose())));
return result;
}
async type(selector: string, text: string, options: { delay: (number | undefined); } | undefined) {
const domWorld = await this._utilityDOMWorld();
const document = await domWorld._document();
const handle = await document.$(selector);
const handle = await domWorld.$(selector);
assert(handle, 'No node found for selector: ' + selector);
await handle.type(text, options);
await handle.dispose();
@ -410,7 +402,11 @@ export class Frame {
return null;
}
const mainDOMWorld = await this._mainDOMWorld();
return mainDOMWorld.adoptElementHandle(handle.asElement(), true /* dispose */);
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: {
@ -424,7 +420,11 @@ export class Frame {
return null;
}
const mainDOMWorld = await this._mainDOMWorld();
return mainDOMWorld.adoptElementHandle(handle.asElement(), true /* dispose */);
if (handle.executionContext() === mainDOMWorld.context)
return handle.asElement();
const adopted = await mainDOMWorld.adoptElementHandle(handle.asElement());
await handle.dispose();
return adopted;
}
waitForFunction(

View File

@ -34,7 +34,7 @@ export class ExecutionContext {
}
_createHandle(remoteObject: any): JSHandle {
return (this._domWorld && this._domWorld._createHandle(remoteObject)) || new JSHandle(this, remoteObject);
return (this._domWorld && this._domWorld.createHandle(remoteObject)) || new JSHandle(this, remoteObject);
}
}

View File

@ -9,8 +9,8 @@ type PageFunctionOn<On, Args extends any[], R = any> = string | ((on: On, ...arg
export type Evaluate = <Args extends any[], R>(pageFunction: PageFunction<Args, R>, ...args: Boxed<Args>) => Promise<R>;
export type EvaluateHandle = <Args extends any[]>(pageFunction: PageFunction<Args>, ...args: Boxed<Args>) => Promise<js.JSHandle>;
export type $Eval = <Args extends any[], R>(selector: string, pageFunction: PageFunctionOn<Element, Args, R>, ...args: Boxed<Args>) => Promise<R>;
export type $$Eval = <Args extends any[], R>(selector: string, pageFunction: PageFunctionOn<Element[], Args, R>, ...args: Boxed<Args>) => Promise<R>;
export type $Eval<S = string> = <Args extends any[], R>(selector: S, pageFunction: PageFunctionOn<Element, Args, R>, ...args: Boxed<Args>) => Promise<R>;
export type $$Eval<S = string> = <Args extends any[], R>(selector: S, pageFunction: PageFunctionOn<Element[], Args, R>, ...args: Boxed<Args>) => Promise<R>;
export type EvaluateOn = <Args extends any[], R>(pageFunction: PageFunctionOn<any, Args, R>, ...args: Boxed<Args>) => Promise<R>;
export type EvaluateHandleOn = <Args extends any[]>(pageFunction: PageFunctionOn<any, Args>, ...args: Boxed<Args>) => Promise<js.JSHandle>;

View File

@ -0,0 +1,27 @@
<script>
window.addEventListener('DOMContentLoaded', () => {
const outer = document.createElement('section');
document.body.appendChild(outer);
const root1 = document.createElement('div');
outer.appendChild(root1);
const shadowRoot1 = root1.attachShadow({mode: 'open'});
const span1 = document.createElement('span');
span1.textContent = 'Hello from root1';
shadowRoot1.appendChild(span1);
const root2 = document.createElement('div');
shadowRoot1.appendChild(root2);
const shadowRoot2 = root2.attachShadow({mode: 'open'});
const span2 = document.createElement('span');
span2.textContent = 'Hello from root2';
shadowRoot2.appendChild(span2);
const root3 = document.createElement('div');
shadowRoot1.appendChild(root3);
const shadowRoot3 = root3.attachShadow({mode: 'open'});
const span3 = document.createElement('span');
span3.textContent = 'Hello from root3';
shadowRoot3.appendChild(span3);
});
</script>

View File

@ -20,7 +20,17 @@ module.exports.addTests = function({testRunner, expect, product, FFOX, CHROME, W
const {beforeAll, beforeEach, afterAll, afterEach} = testRunner;
describe('Page.$eval', function() {
it('should work', async({page, server}) => {
it('should work with css selector', async({page, server}) => {
await page.setContent('<section id="testAttribute">43543</section>');
const idAttribute = await page.$eval('css=section', e => e.id);
expect(idAttribute).toBe('testAttribute');
});
it('should work with xpath selector', async({page, server}) => {
await page.setContent('<section id="testAttribute">43543</section>');
const idAttribute = await page.$eval('xpath=/html/body/section', e => e.id);
expect(idAttribute).toBe('testAttribute');
});
it('should auto-detect css selector', async({page, server}) => {
await page.setContent('<section id="testAttribute">43543</section>');
const idAttribute = await page.$eval('section', e => e.id);
expect(idAttribute).toBe('testAttribute');
@ -41,26 +51,94 @@ module.exports.addTests = function({testRunner, expect, product, FFOX, CHROME, W
await page.$eval('section', e => e.id).catch(e => error = e);
expect(error.message).toContain('failed to find element matching selector "section"');
});
it('should support >> syntax', async({page, server}) => {
await page.setContent('<section><div>hello</div></section>');
const text = await page.$eval('css=section >> css=div', (e, suffix) => e.textContent + suffix, ' world!');
expect(text).toBe('hello world!');
});
it('should support >> syntax with different engines', async({page, server}) => {
await page.setContent('<section><div>hello</div></section>');
const text = await page.$eval('xpath=/html/body/section >> css=div', (e, suffix) => e.textContent + suffix, ' world!');
expect(text).toBe('hello world!');
});
it('should support spaces with >> syntax', async({page, server}) => {
await page.goto(server.PREFIX + '/deep-shadow.html');
const text = await page.$eval(' css = div >>css=div>>css = span ', e => e.textContent);
expect(text).toBe('Hello from root2');
});
it('should enter shadow roots with >> syntax', async({page, server}) => {
await page.goto(server.PREFIX + '/deep-shadow.html');
const text1 = await page.$eval('css=div >> css=span', e => e.textContent);
expect(text1).toBe('Hello from root1');
const text2 = await page.$eval('css=div >> css=*:nth-child(2) >> css=span', e => e.textContent);
expect(text2).toBe('Hello from root2');
const nonExisting = await page.$('css=div div >> css=span');
expect(nonExisting).not.toBeTruthy();
const text3 = await page.$eval('css=section div >> css=span', e => e.textContent);
expect(text3).toBe('Hello from root1');
const text4 = await page.$eval('xpath=/html/body/section/div >> css=div >> css=span', e => e.textContent);
expect(text4).toBe('Hello from root2');
});
});
describe('Page.$$eval', function() {
it('should work', async({page, server}) => {
it('should work with css selector', async({page, server}) => {
await page.setContent('<div>hello</div><div>beautiful</div><div>world!</div>');
const divsCount = await page.$$eval('css=div', divs => divs.length);
expect(divsCount).toBe(3);
});
it('should work with xpath selector', async({page, server}) => {
await page.setContent('<div>hello</div><div>beautiful</div><div>world!</div>');
const divsCount = await page.$$eval('xpath=/html/body/div', divs => divs.length);
expect(divsCount).toBe(3);
});
it('should auto-detect css selector', async({page, server}) => {
await page.setContent('<div>hello</div><div>beautiful</div><div>world!</div>');
const divsCount = await page.$$eval('div', divs => divs.length);
expect(divsCount).toBe(3);
});
it('should support >> syntax', async({page, server}) => {
await page.setContent('<div><span>hello</span></div><div>beautiful</div><div><span>wo</span><span>rld!</span></div><span>Not this one</span>');
const spansCount = await page.$$eval('css=div >> css=span', spans => spans.length);
expect(spansCount).toBe(3);
});
it('should enter shadow roots with >> syntax', async({page, server}) => {
await page.goto(server.PREFIX + '/deep-shadow.html');
const spansCount = await page.$$eval('css=div >> css=div >> css=span', spans => spans.length);
expect(spansCount).toBe(2);
});
});
describe('Page.$', function() {
it('should query existing element', async({page, server}) => {
await page.setContent('<section>test</section>');
const element = await page.$('section');
const element = await page.$('css=section');
expect(element).toBeTruthy();
});
it('should query existing element with xpath', async({page, server}) => {
await page.setContent('<section>test</section>');
const element = await page.$('xpath=/html/body/section');
expect(element).toBeTruthy();
});
it('should return null for non-existing element', async({page, server}) => {
const element = await page.$('non-existing-element');
expect(element).toBe(null);
});
it('should auto-detect xpath selector', async({page, server}) => {
await page.setContent('<section>test</section>');
const element = await page.$('//html/body/section');
expect(element).toBeTruthy();
});
it('should auto-detect css selector', async({page, server}) => {
await page.setContent('<section>test</section>');
const element = await page.$('section');
expect(element).toBeTruthy();
});
it('should support >> syntax', async({page, server}) => {
await page.setContent('<section><div>test</div></section>');
const element = await page.$('css=section >> css=div');
expect(element).toBeTruthy();
});
});
describe('Page.$$', function() {
@ -136,7 +214,7 @@ module.exports.addTests = function({testRunner, expect, product, FFOX, CHROME, W
await page.setContent(htmlContent);
const elementHandle = await page.$('#myId');
const errorMessage = await elementHandle.$eval('.a', node => node.innerText).catch(error => error.message);
expect(errorMessage).toBe(`Error: failed to find element matching selector ".a"`);
expect(errorMessage).toBe(`Error: failed to find element matching selector ":scope >> .a"`);
});
});
describe('ElementHandle.$$eval', function() {