From ac6a9ec193926cf9329c32c6b50b3bdd13c25dfc Mon Sep 17 00:00:00 2001 From: Randolf J Date: Wed, 3 Aug 2022 23:28:35 +0200 Subject: [PATCH] feat: use an `xpath` query handler --- src/common/AriaQueryHandler.ts | 2 +- src/common/DOMWorld.ts | 46 --------- src/common/ElementHandle.ts | 52 ++-------- src/common/FrameManager.ts | 12 +-- src/common/QueryHandler.ts | 177 ++++++++++++++++++++++----------- test/src/elementhandle.spec.ts | 10 +- test/src/queryhandler.spec.ts | 160 +++++++++++++++++++++++++++++ test/src/queryselector.spec.ts | 74 +------------- test/src/waittask.spec.ts | 2 +- 9 files changed, 297 insertions(+), 238 deletions(-) create mode 100644 test/src/queryhandler.spec.ts diff --git a/src/common/AriaQueryHandler.ts b/src/common/AriaQueryHandler.ts index 466e158951264..a2f82ccc89c65 100644 --- a/src/common/AriaQueryHandler.ts +++ b/src/common/AriaQueryHandler.ts @@ -114,7 +114,7 @@ const waitFor = async ( return (await domWorld._waitForSelectorInPage( (_: Element, selector: string) => { return ( - globalThis as any as unknown as { + globalThis as unknown as { ariaQuerySelector(selector: string): void; } ).ariaQuerySelector(selector); diff --git a/src/common/DOMWorld.ts b/src/common/DOMWorld.ts index a6d7d05141082..1db47d1fd5296 100644 --- a/src/common/DOMWorld.ts +++ b/src/common/DOMWorld.ts @@ -717,52 +717,6 @@ export class DOMWorld { return elementHandle; } - async waitForXPath( - xpath: string, - options: WaitForSelectorOptions - ): Promise | null> { - const { - visible: waitForVisible = false, - hidden: waitForHidden = false, - timeout = this.#timeoutSettings.timeout(), - } = options; - const polling = waitForVisible || waitForHidden ? 'raf' : 'mutation'; - const title = `XPath \`${xpath}\`${waitForHidden ? ' to be hidden' : ''}`; - function predicate( - root: Element | Document, - xpath: string, - waitForVisible: boolean, - waitForHidden: boolean - ): Node | null | boolean { - const node = document.evaluate( - xpath, - root, - null, - XPathResult.FIRST_ORDERED_NODE_TYPE, - null - ).singleNodeValue; - return checkWaitForOptions(node, waitForVisible, waitForHidden); - } - const waitTaskOptions: WaitTaskOptions = { - domWorld: this, - predicateBody: makePredicateString(predicate), - predicateAcceptsContextElement: true, - title, - polling, - timeout, - args: [xpath, waitForVisible, waitForHidden], - root: options.root, - }; - const waitTask = new WaitTask(waitTaskOptions); - const jsHandle = await waitTask.promise; - const elementHandle = jsHandle.asElement(); - if (!elementHandle) { - await jsHandle.dispose(); - return null; - } - return elementHandle; - } - waitForFunction( pageFunction: Function | string, options: {polling?: string | number; timeout?: number} = {}, diff --git a/src/common/ElementHandle.ts b/src/common/ElementHandle.ts index dbd7d8ccf9217..cf18342508ec6 100644 --- a/src/common/ElementHandle.ts +++ b/src/common/ElementHandle.ts @@ -140,6 +140,8 @@ export class ElementHandle< } /** + * @deprecated Use {@link waitForSelector} with the `xpath` prefix. + * * Wait for the `xpath` within the element. If at the moment of calling the * method the `xpath` already exists, the method will return immediately. If * the `xpath` doesn't appear after the `timeout` milliseconds of waiting, the @@ -197,27 +199,7 @@ export class ElementHandle< timeout?: number; } = {} ): Promise | null> { - const frame = this._context.frame(); - assert(frame); - const secondaryContext = await frame._secondaryWorld.executionContext(); - const adoptedRoot = await secondaryContext._adoptElementHandle(this); - xpath = xpath.startsWith('//') ? '.' + xpath : xpath; - if (!xpath.startsWith('.//')) { - await adoptedRoot.dispose(); - throw new Error('Unsupported xpath expression: ' + xpath); - } - const handle = await frame._secondaryWorld.waitForXPath(xpath, { - ...options, - root: adoptedRoot, - }); - await adoptedRoot.dispose(); - if (!handle) { - return null; - } - const mainExecutionContext = await frame._mainWorld.executionContext(); - const result = await mainExecutionContext._adoptElementHandle(handle); - await handle.dispose(); - return result; + return this.waitForSelector(`xpath/${xpath}`, options); } override asElement(): ElementHandle | null { @@ -964,36 +946,14 @@ export class ElementHandle< } /** + * @deprecated Use {@link this.$$} with the `xpath` prefix. + * * The method evaluates the XPath expression relative to the elementHandle. * If there are no such elements, the method will resolve to an empty array. * @param expression - Expression to {@link https://developer.mozilla.org/en-US/docs/Web/API/Document/evaluate | evaluate} */ async $x(expression: string): Promise>> { - const arrayHandle = await this.evaluateHandle((element, expression) => { - const doc = element.ownerDocument || document; - const iterator = doc.evaluate( - expression, - element, - null, - XPathResult.ORDERED_NODE_ITERATOR_TYPE - ); - const array = []; - let item; - while ((item = iterator.iterateNext())) { - array.push(item); - } - return array; - }, expression); - 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; + return this.$$(`xpath/${expression}`); } /** diff --git a/src/common/FrameManager.ts b/src/common/FrameManager.ts index 25f8067f2da05..8b00e808571ed 100644 --- a/src/common/FrameManager.ts +++ b/src/common/FrameManager.ts @@ -1374,7 +1374,8 @@ export class Frame { } /** - * @remarks + * @deprecated Use {@link waitForSelector} with the `xpath` prefix. + * * Wait for the `xpath` to appear in page. If at the moment of calling the * method the `xpath` already exists, the method will return immediately. If * the xpath doesn't appear after the `timeout` milliseconds of waiting, the @@ -1392,14 +1393,7 @@ export class Frame { xpath: string, options: WaitForSelectorOptions = {} ): Promise | null> { - const handle = await this._secondaryWorld.waitForXPath(xpath, options); - if (!handle) { - return null; - } - const mainExecutionContext = await this._mainWorld.executionContext(); - const result = await mainExecutionContext._adoptElementHandle(handle); - await handle.dispose(); - return result; + return this.waitForSelector(`xpath/${xpath}`, options); } /** diff --git a/src/common/QueryHandler.ts b/src/common/QueryHandler.ts index f9a140f8f79db..240f50dbab326 100644 --- a/src/common/QueryHandler.ts +++ b/src/common/QueryHandler.ts @@ -19,47 +19,66 @@ import {DOMWorld, WaitForSelectorOptions} from './DOMWorld.js'; import {ElementHandle} from './ElementHandle.js'; import {JSHandle} from './JSHandle.js'; +/** + * @public + */ +export interface CustomQueryHandler { + /** + * @returns A {@link Node} matching the given {@link selector} from {@link node}. + */ + queryOne?: (node: Node, selector: string) => Node | null; + /** + * @returns Some {@link Node}s matching the given {@link selector} from {@link node}. + */ + queryAll?: (node: Node, selector: string) => Node[]; +} + /** * @internal */ export interface InternalQueryHandler { + /** + * Queries for a single node given a selector and {@link ElementHandle}. + * + * Akin to {@link Window.prototype.querySelector}. + */ queryOne?: ( element: ElementHandle, selector: string ) => Promise | null>; + /** + * Queries for multiple nodes given a selector and {@link ElementHandle}. + * + * Akin to {@link Window.prototype.querySelectorAll}. + */ queryAll?: ( element: ElementHandle, selector: string ) => Promise>>; - + /** + * Queries for multiple nodes given a selector and {@link ElementHandle}. + * Unlike {@link queryAll}, this returns a handle to a node array. + * + * Akin to {@link Window.prototype.querySelectorAll}. + */ + queryAllArray?: ( + element: ElementHandle, + selector: string + ) => Promise>; + /** + * Waits until a single node appears for a given selector and + * {@link ElementHandle}. + * + * Akin to {@link Window.prototype.querySelectorAll}. + */ waitFor?: ( domWorld: DOMWorld, selector: string, options: WaitForSelectorOptions ) => Promise | null>; - queryAllArray?: ( - element: ElementHandle, - selector: string - ) => Promise>; } -/** - * Contains two functions `queryOne` and `queryAll` that can - * be {@link registerCustomQueryHandler | registered} - * as alternative querying strategies. The functions `queryOne` and `queryAll` - * are executed in the page context. `queryOne` should take an `Element` and a - * selector string as argument and return a single `Element` or `null` if no - * element is found. `queryAll` takes the same arguments but should instead - * return a `NodeListOf` or `Array` with all the elements - * that match the given query selector. - * @public - */ -export interface CustomQueryHandler { - queryOne?: (element: Node, selector: string) => Node | null; - queryAll?: (element: Node, selector: string) => Node[]; -} - -function createInternalQueryHandler( +function internalizeCustomQueryHandler( handler: CustomQueryHandler ): InternalQueryHandler { const internalHandler: InternalQueryHandler = {}; @@ -114,7 +133,7 @@ function createInternalQueryHandler( return internalHandler; } -const defaultHandler = createInternalQueryHandler({ +const defaultHandler = internalizeCustomQueryHandler({ queryOne: (element, selector) => { if (!('querySelector' in element)) { throw new Error( @@ -141,7 +160,7 @@ const defaultHandler = createInternalQueryHandler({ }, }); -const pierceHandler = createInternalQueryHandler({ +const pierceHandler = internalizeCustomQueryHandler({ queryOne: (element, selector) => { let found: Node | null = null; const search = (root: Node) => { @@ -191,11 +210,60 @@ const pierceHandler = createInternalQueryHandler({ }, }); -const builtInHandlers = new Map([ - ['aria', ariaHandler], - ['pierce', pierceHandler], +const xpathHandler = internalizeCustomQueryHandler({ + queryOne: (element, selector) => { + const doc = element.ownerDocument || document; + const result = doc.evaluate( + selector, + element, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE + ); + return result.singleNodeValue; + }, + + queryAll: (element, selector) => { + const doc = element.ownerDocument || document; + const iterator = doc.evaluate( + selector, + element, + null, + XPathResult.ORDERED_NODE_ITERATOR_TYPE + ); + const array: Node[] = []; + let item; + while ((item = iterator.iterateNext())) { + array.push(item); + } + return array; + }, +}); + +const transformXPathSelector = (selector: string): string => { + if (selector.startsWith('//')) { + selector = `.${selector}`; + } + return selector; +}; + +const INTERNAL_QUERY_HANDLERS = new Map< + string, + [ + handler: InternalQueryHandler, + transformSelector?: (selector: string) => string + ] +>([ + ['aria', [ariaHandler]], + ['pierce', [pierceHandler]], + ['xpath', [xpathHandler, transformXPathSelector]], ]); -const queryHandlers = new Map(builtInHandlers); +const QUERY_HANDLERS = new Map< + string, + [ + handler: InternalQueryHandler, + transformSelector?: (selector: string) => string + ] +>(); /** * Registers a {@link CustomQueryHandler | custom query handler}. @@ -222,7 +290,10 @@ export function registerCustomQueryHandler( name: string, handler: CustomQueryHandler ): void { - if (queryHandlers.get(name)) { + if (INTERNAL_QUERY_HANDLERS.has(name)) { + throw new Error(`A query handler named "${name}" already exists`); + } + if (QUERY_HANDLERS.has(name)) { throw new Error(`A custom query handler named "${name}" already exists`); } @@ -231,9 +302,7 @@ export function registerCustomQueryHandler( throw new Error(`Custom query handler names may only contain [a-zA-Z]`); } - const internalHandler = createInternalQueryHandler(handler); - - queryHandlers.set(name, internalHandler); + QUERY_HANDLERS.set(name, [internalizeCustomQueryHandler(handler)]); } /** @@ -242,9 +311,7 @@ export function registerCustomQueryHandler( * @public */ export function unregisterCustomQueryHandler(name: string): void { - if (queryHandlers.has(name) && !builtInHandlers.has(name)) { - queryHandlers.delete(name); - } + QUERY_HANDLERS.delete(name); } /** @@ -253,9 +320,7 @@ export function unregisterCustomQueryHandler(name: string): void { * @public */ export function customQueryHandlerNames(): string[] { - return [...queryHandlers.keys()].filter(name => { - return !builtInHandlers.has(name); - }); + return [...QUERY_HANDLERS.keys()]; } /** @@ -264,9 +329,11 @@ export function customQueryHandlerNames(): string[] { * @public */ export function clearCustomQueryHandlers(): void { - customQueryHandlerNames().forEach(unregisterCustomQueryHandler); + QUERY_HANDLERS.clear(); } +const CUSTOM_QUERY_SEPARATORS = ['=', '/']; + /** * @internal */ @@ -274,23 +341,19 @@ export function getQueryHandlerAndSelector(selector: string): { updatedSelector: string; queryHandler: InternalQueryHandler; } { - const hasCustomQueryHandler = /^[a-zA-Z]+\//.test(selector); - if (!hasCustomQueryHandler) { - return {updatedSelector: selector, queryHandler: defaultHandler}; - } - - const index = selector.indexOf('/'); - const name = selector.slice(0, index); - const updatedSelector = selector.slice(index + 1); - const queryHandler = queryHandlers.get(name); - if (!queryHandler) { - throw new Error( - `Query set to use "${name}", but no query handler of that name was found` - ); + for (const handlerMap of [QUERY_HANDLERS, INTERNAL_QUERY_HANDLERS]) { + for (const [name, [queryHandler, transformSelector]] of handlerMap) { + for (const separator of CUSTOM_QUERY_SEPARATORS) { + const prefix = `${name}${separator}`; + if (selector.startsWith(prefix)) { + selector = selector.slice(prefix.length); + if (transformSelector) { + selector = transformSelector(selector); + } + return {updatedSelector: selector, queryHandler}; + } + } + } } - - return { - updatedSelector, - queryHandler, - }; + return {updatedSelector: selector, queryHandler: defaultHandler}; } diff --git a/test/src/elementhandle.spec.ts b/test/src/elementhandle.spec.ts index 3a11c2b152463..b066c8c8dbe95 100644 --- a/test/src/elementhandle.spec.ts +++ b/test/src/elementhandle.spec.ts @@ -319,12 +319,12 @@ describe('ElementHandle specs', function () { ` ); - const el2 = (await page.waitForSelector( + const el1 = (await page.waitForSelector( '#el1' )) as ElementHandle; for (const path of ['//div', './/div']) { - const e = (await el2.waitForXPath( + const e = (await el1.waitForXPath( path )) as ElementHandle; expect( @@ -423,10 +423,8 @@ describe('ElementHandle specs', function () { await page.$('getById/foo'); throw new Error('Custom query handler name not set - throw expected'); } catch (error) { - expect(error).toStrictEqual( - new Error( - 'Query set to use "getById", but no query handler of that name was found' - ) + expect(error).not.toStrictEqual( + new Error('Custom query handler name not set - throw expected') ); } const handlerNamesAfterUnregistering = diff --git a/test/src/queryhandler.spec.ts b/test/src/queryhandler.spec.ts new file mode 100644 index 0000000000000..30e5d69254d52 --- /dev/null +++ b/test/src/queryhandler.spec.ts @@ -0,0 +1,160 @@ +/** + * Copyright 2018 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import expect from 'expect'; +import {ElementHandle} from '../../lib/cjs/puppeteer/common/ElementHandle.js'; +import { + getTestState, + setupTestBrowserHooks, + setupTestPageAndContextHooks, +} from './mocha-utils.js'; + +describe('Query handler tests', function () { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + + describe('Pierce selectors', function () { + beforeEach(async () => { + const {page} = getTestState(); + await page.setContent( + `` + ); + }); + it('should find first element in shadow', async () => { + const {page} = getTestState(); + const div = (await page.$('pierce/.foo')) as ElementHandle; + const text = await div.evaluate(element => { + return element.textContent; + }); + expect(text).toBe('Hello'); + }); + it('should find all elements in shadow', async () => { + const {page} = getTestState(); + const divs = (await page.$$('pierce/.foo')) as Array< + ElementHandle + >; + const text = await Promise.all( + divs.map(div => { + return div.evaluate(element => { + return element.textContent; + }); + }) + ); + expect(text.join(' ')).toBe('Hello World'); + }); + it('should find first child element', async () => { + const {page} = getTestState(); + const parentElement = (await page.$('html > div'))!; + const childElement = (await parentElement.$( + 'pierce/div' + )) as ElementHandle; + const text = await childElement.evaluate(element => { + return element.textContent; + }); + expect(text).toBe('Hello'); + }); + it('should find all child elements', async () => { + const {page} = getTestState(); + const parentElement = (await page.$('html > div'))!; + const childElements = (await parentElement.$$('pierce/div')) as Array< + ElementHandle + >; + const text = await Promise.all( + childElements.map(div => { + return div.evaluate(element => { + return element.textContent; + }); + }) + ); + expect(text.join(' ')).toBe('Hello World'); + }); + }); + + describe('XPath selectors', function () { + describe('in Page', function () { + it('should query existing element', async () => { + const {page} = getTestState(); + + await page.setContent('
test
'); + + expect(await page.$('xpath/html/body/section')).toBeTruthy(); + expect((await page.$$('xpath/html/body/section')).length).toBe(1); + }); + it('should return empty array for non-existing element', async () => { + const {page} = getTestState(); + + expect( + await page.$('xpath/html/body/non-existing-element') + ).toBeFalsy(); + expect( + (await page.$$('xpath/html/body/non-existing-element')).length + ).toBe(0); + }); + it('should return first element', async () => { + const {page} = getTestState(); + + await page.setContent('
a
'); + + const element = await page.$('xpath/html/body/div'); + expect( + await element?.evaluate(e => { + return e.textContent === 'a'; + }) + ).toBeTruthy(); + }); + it('should return multiple elements', async () => { + const {page} = getTestState(); + + await page.setContent('
'); + + const elements = await page.$$('xpath/html/body/div'); + expect(elements.length).toBe(2); + }); + }); + describe('in ElementHandles', function () { + it('should query existing element', async () => { + const {page} = getTestState(); + + await page.setContent('
a
'); + + const elementHandle = (await page.$('div'))!; + expect(await elementHandle.$(`xpath/span`)).toBeTruthy(); + expect((await elementHandle.$$(`xpath/span`)).length).toBe(1); + }); + + it('should return null for non-existing element', async () => { + const {page} = getTestState(); + + await page.setContent('
a
'); + + const elementHandle = (await page.$('div'))!; + expect(await elementHandle.$(`xpath/span`)).toBeFalsy(); + expect((await elementHandle.$$(`xpath/span`)).length).toBe(0); + }); + }); + }); +}); diff --git a/test/src/queryselector.spec.ts b/test/src/queryselector.spec.ts index 24f81da586747..7eeab8e44e7f6 100644 --- a/test/src/queryselector.spec.ts +++ b/test/src/queryselector.spec.ts @@ -14,13 +14,12 @@ * limitations under the License. */ import expect from 'expect'; +import {CustomQueryHandler} from '../../lib/cjs/puppeteer/common/QueryHandler.js'; import { getTestState, setupTestBrowserHooks, setupTestPageAndContextHooks, } from './mocha-utils.js'; -import {CustomQueryHandler} from '../../lib/cjs/puppeteer/common/QueryHandler.js'; -import {ElementHandle} from '../../lib/cjs/puppeteer/common/ElementHandle.js'; describe('querySelector', function () { setupTestBrowserHooks(); @@ -79,75 +78,6 @@ describe('querySelector', function () { }); }); - describe('pierceHandler', function () { - beforeEach(async () => { - const {page} = getTestState(); - await page.setContent( - `` - ); - }); - it('should find first element in shadow', async () => { - const {page} = getTestState(); - const div = (await page.$('pierce/.foo')) as ElementHandle; - const text = await div.evaluate(element => { - return element.textContent; - }); - expect(text).toBe('Hello'); - }); - it('should find all elements in shadow', async () => { - const {page} = getTestState(); - const divs = (await page.$$('pierce/.foo')) as Array< - ElementHandle - >; - const text = await Promise.all( - divs.map(div => { - return div.evaluate(element => { - return element.textContent; - }); - }) - ); - expect(text.join(' ')).toBe('Hello World'); - }); - it('should find first child element', async () => { - const {page} = getTestState(); - const parentElement = (await page.$('html > div'))!; - const childElement = (await parentElement.$( - 'pierce/div' - )) as ElementHandle; - const text = await childElement.evaluate(element => { - return element.textContent; - }); - expect(text).toBe('Hello'); - }); - it('should find all child elements', async () => { - const {page} = getTestState(); - const parentElement = (await page.$('html > div'))!; - const childElements = (await parentElement.$$('pierce/div')) as Array< - ElementHandle - >; - const text = await Promise.all( - childElements.map(div => { - return div.evaluate(element => { - return element.textContent; - }); - }) - ); - expect(text.join(' ')).toBe('Hello World'); - }); - }); - // The tests for $$eval are repeated later in this file in the test group 'QueryAll'. // This is done to also test a query handler where QueryAll returns an Element[] // as opposed to NodeListOf. @@ -256,7 +186,7 @@ describe('querySelector', function () { }); }); - describe('Path.$x', function () { + describe('Page.$x', function () { it('should query existing element', async () => { const {page} = getTestState(); diff --git a/test/src/waittask.spec.ts b/test/src/waittask.spec.ts index 81b8809741c96..6eccf9ca89775 100644 --- a/test/src/waittask.spec.ts +++ b/test/src/waittask.spec.ts @@ -735,7 +735,7 @@ describe('waittask specs', function () { }); expect(error).toBeInstanceOf(puppeteer.errors.TimeoutError); expect(error?.message).toContain( - 'waiting for XPath `//div` failed: timeout' + 'waiting for selector `.//div` failed: timeout 10ms exceeded' ); }); itFailsFirefox('should run in specified frame', async () => {