From bb1433a1437bcf7b48bb405477a759cba4572ba9 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Mon, 2 Dec 2019 17:33:44 -0800 Subject: [PATCH] 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. --- src/dom.ts | 165 ++++++++++++++++++++--------------- src/frames.ts | 60 ++++++------- src/javascript.ts | 2 +- src/types.ts | 4 +- test/assets/deep-shadow.html | 27 ++++++ test/queryselector.spec.js | 86 +++++++++++++++++- 6 files changed, 238 insertions(+), 106 deletions(-) create mode 100644 test/assets/deep-shadow.html diff --git a/src/dom.ts b/src/dom.ts index f88a35e25b..506f556e16 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -25,25 +25,29 @@ export interface DOMWorldDelegate { adoptElementHandle(handle: ElementHandle, to: DOMWorld): Promise; } +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; - private _documentPromise?: Promise; 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 { + private _injected(): Promise { if (!this._injectedPromise) { const engineSources = [cssSelectorEngineSource.source, xpathSelectorEngineSource.source]; const source = ` @@ -56,24 +60,91 @@ export class DOMWorld { return this._injectedPromise; } - _document(): Promise { - if (!this._documentPromise) - this._documentPromise = this.context.evaluateHandle('document').then(handle => handle.asElement()!); - return this._documentPromise; + async adoptElementHandle(handle: ElementHandle): Promise { + 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 { - 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 { + 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 { + 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 { + 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 = 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 = 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 { - 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 { + return this._world.$({ root: this, selector }); } - async $$(selector: string): Promise { - 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 { + 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 { - 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 { + return this._world.$$({ root: this, selector: 'xpath=' + expression }); } isIntersectingViewport(): Promise { diff --git a/src/frames.ts b/src/frames.ts index eff794c9db..e2b87ef0aa 100644 --- a/src/frames.ts +++ b/src/frames.ts @@ -124,32 +124,27 @@ export class Frame { async $(selector: string): Promise { const domWorld = await this._mainDOMWorld(); - const document = await domWorld._document(); - return document.$(selector); + return domWorld.$(selector); } async $x(expression: string): Promise { 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 { const domWorld = await this._mainDOMWorld(); - const document = await domWorld._document(); - return document.$$(selector); + return domWorld.$$(selector); } async content(): Promise { @@ -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 { 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[] = []; 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( diff --git a/src/javascript.ts b/src/javascript.ts index f33aaafe7e..06b835c7b8 100644 --- a/src/javascript.ts +++ b/src/javascript.ts @@ -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); } } diff --git a/src/types.ts b/src/types.ts index 9587ee3619..9dc8880215 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,8 +9,8 @@ type PageFunctionOn = string | ((on: On, ...arg export type Evaluate = (pageFunction: PageFunction, ...args: Boxed) => Promise; export type EvaluateHandle = (pageFunction: PageFunction, ...args: Boxed) => Promise; -export type $Eval = (selector: string, pageFunction: PageFunctionOn, ...args: Boxed) => Promise; -export type $$Eval = (selector: string, pageFunction: PageFunctionOn, ...args: Boxed) => Promise; +export type $Eval = (selector: S, pageFunction: PageFunctionOn, ...args: Boxed) => Promise; +export type $$Eval = (selector: S, pageFunction: PageFunctionOn, ...args: Boxed) => Promise; export type EvaluateOn = (pageFunction: PageFunctionOn, ...args: Boxed) => Promise; export type EvaluateHandleOn = (pageFunction: PageFunctionOn, ...args: Boxed) => Promise; diff --git a/test/assets/deep-shadow.html b/test/assets/deep-shadow.html new file mode 100644 index 0000000000..eda35c0af4 --- /dev/null +++ b/test/assets/deep-shadow.html @@ -0,0 +1,27 @@ + diff --git a/test/queryselector.spec.js b/test/queryselector.spec.js index 725bfde9f5..528548027b 100644 --- a/test/queryselector.spec.js +++ b/test/queryselector.spec.js @@ -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('
43543
'); + 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('
43543
'); + 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('
43543
'); 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('
hello
'); + 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('
hello
'); + 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('
hello
beautiful
world!
'); + 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('
hello
beautiful
world!
'); + 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('
hello
beautiful
world!
'); const divsCount = await page.$$eval('div', divs => divs.length); expect(divsCount).toBe(3); }); + it('should support >> syntax', async({page, server}) => { + await page.setContent('
hello
beautiful
world!
Not this one'); + 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('
test
'); - 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('
test
'); + 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('
test
'); + const element = await page.$('//html/body/section'); + expect(element).toBeTruthy(); + }); + it('should auto-detect css selector', async({page, server}) => { + await page.setContent('
test
'); + const element = await page.$('section'); + expect(element).toBeTruthy(); + }); + it('should support >> syntax', async({page, server}) => { + await page.setContent('
test
'); + 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() {