From 2b3cf3ace94e1a78c424b653b118512f9104e42b Mon Sep 17 00:00:00 2001 From: jrandolf <101637635+jrandolf@users.noreply.github.com> Date: Wed, 15 Feb 2023 10:42:32 -0800 Subject: [PATCH] chore: implement P queries (#9639) --- .../src/common/AriaQueryHandler.ts | 8 +- packages/puppeteer-core/src/common/Binding.ts | 8 +- .../src/common/ElementHandle.ts | 4 +- .../src/common/ExecutionContext.ts | 31 ++- .../src/common/GetQueryHandler.ts | 10 +- .../{CSSQueryHandler.ts => PQueryHandler.ts} | 18 +- .../puppeteer-core/src/common/QueryHandler.ts | 4 +- packages/puppeteer-core/src/common/types.ts | 5 + .../src/injected/ARIAQuerySelector.ts | 14 +- .../src/injected/CustomQuerySelector.ts | 5 +- .../src/injected/PQuerySelector.ts | 193 ++++++++++++++++++ .../src/injected/PSelectorParser.ts | 158 ++++++++++++++ .../puppeteer-core/src/injected/injected.ts | 2 + packages/puppeteer-core/src/injected/util.ts | 39 ++++ .../AsyncIterableUtil.ts} | 18 +- packages/puppeteer-core/src/util/util.ts | 1 + test/TestExpectations.json | 12 ++ test/src/queryhandler.spec.ts | 145 +++++++++++++ 18 files changed, 638 insertions(+), 37 deletions(-) rename packages/puppeteer-core/src/common/{CSSQueryHandler.ts => PQueryHandler.ts} (66%) create mode 100644 packages/puppeteer-core/src/injected/PQuerySelector.ts create mode 100644 packages/puppeteer-core/src/injected/PSelectorParser.ts rename packages/puppeteer-core/src/{common/IterableUtil.ts => util/AsyncIterableUtil.ts} (78%) diff --git a/packages/puppeteer-core/src/common/AriaQueryHandler.ts b/packages/puppeteer-core/src/common/AriaQueryHandler.ts index f16b6de9..ff056d16 100644 --- a/packages/puppeteer-core/src/common/AriaQueryHandler.ts +++ b/packages/puppeteer-core/src/common/AriaQueryHandler.ts @@ -18,8 +18,8 @@ import {Protocol} from 'devtools-protocol'; import {ElementHandle} from '../api/ElementHandle.js'; import {assert} from '../util/assert.js'; +import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js'; import {CDPSession} from './Connection.js'; -import {IterableUtil} from './IterableUtil.js'; import {QueryHandler, QuerySelector} from './QueryHandler.js'; import {AwaitableIterable} from './types.js'; @@ -105,7 +105,7 @@ export class ARIAQueryHandler extends QueryHandler { const {name, role} = parseARIASelector(selector); const results = await queryAXTree(context._client, element, name, role); const world = context._world!; - yield* IterableUtil.map(results, node => { + yield* AsyncIterableUtil.map(results, node => { return world.adoptBackendNode(node.backendDOMNodeId) as Promise< ElementHandle >; @@ -116,6 +116,8 @@ export class ARIAQueryHandler extends QueryHandler { element: ElementHandle, selector: string ): Promise | null> => { - return (await IterableUtil.first(this.queryAll(element, selector))) ?? null; + return ( + (await AsyncIterableUtil.first(this.queryAll(element, selector))) ?? null + ); }; } diff --git a/packages/puppeteer-core/src/common/Binding.ts b/packages/puppeteer-core/src/common/Binding.ts index b9fdb0f4..f0933b72 100644 --- a/packages/puppeteer-core/src/common/Binding.ts +++ b/packages/puppeteer-core/src/common/Binding.ts @@ -6,10 +6,10 @@ import {debugError} from './util.js'; /** * @internal */ -export class Binding { +export class Binding { #name: string; - #fn: (...args: Params) => unknown; - constructor(name: string, fn: (...args: Params) => unknown) { + #fn: (...args: unknown[]) => unknown; + constructor(name: string, fn: (...args: unknown[]) => unknown) { this.#name = name; this.#fn = fn; } @@ -28,7 +28,7 @@ export class Binding { async run( context: ExecutionContext, id: number, - args: Params, + args: unknown[], isTrivial: boolean ): Promise { const garbage = []; diff --git a/packages/puppeteer-core/src/common/ElementHandle.ts b/packages/puppeteer-core/src/common/ElementHandle.ts index 284b00ca..5d44e970 100644 --- a/packages/puppeteer-core/src/common/ElementHandle.ts +++ b/packages/puppeteer-core/src/common/ElementHandle.ts @@ -33,7 +33,7 @@ import {Frame} from './Frame.js'; import {FrameManager} from './FrameManager.js'; import {getQueryHandlerAndSelector} from './GetQueryHandler.js'; import {WaitForSelectorOptions} from './IsolatedWorld.js'; -import {IterableUtil} from './IterableUtil.js'; +import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js'; import {CDPPage} from './Page.js'; import { ElementFor, @@ -189,7 +189,7 @@ export class CDPElementHandle< ): Promise>>> { const {updatedSelector, QueryHandler} = getQueryHandlerAndSelector(selector); - return IterableUtil.collect( + return AsyncIterableUtil.collect( QueryHandler.queryAll(this, updatedSelector) ) as Promise>>>; } diff --git a/packages/puppeteer-core/src/common/ExecutionContext.ts b/packages/puppeteer-core/src/common/ExecutionContext.ts index 0cfa8954..be955659 100644 --- a/packages/puppeteer-core/src/common/ExecutionContext.ts +++ b/packages/puppeteer-core/src/common/ExecutionContext.ts @@ -15,8 +15,10 @@ */ import {Protocol} from 'devtools-protocol'; +import type {ElementHandle} from '../api/ElementHandle.js'; import {JSHandle} from '../api/JSHandle.js'; import type PuppeteerUtil from '../injected/injected.js'; +import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js'; import {stringifyFunction} from '../util/Function.js'; import {ARIAQueryHandler} from './AriaQueryHandler.js'; import {Binding} from './Binding.js'; @@ -78,7 +80,7 @@ export class ExecutionContext { /** * @internal */ - _contextName: string; + _contextName?: string; /** * @internal @@ -91,7 +93,9 @@ export class ExecutionContext { this._client = client; this._world = world; this._contextId = contextPayload.id; - this._contextName = contextPayload.name; + if (contextPayload.name) { + this._contextName = contextPayload.name; + } } #puppeteerUtil?: Promise>; @@ -104,17 +108,30 @@ export class ExecutionContext { } this.#puppeteerUtil = Promise.all([ this.#installGlobalBinding( - new Binding('__ariaQuerySelector', ARIAQueryHandler.queryOne) + new Binding( + '__ariaQuerySelector', + ARIAQueryHandler.queryOne as (...args: unknown[]) => unknown + ) ), - this.evaluateHandle(script) as Promise>, - ]).then(([, util]) => { - return util; + this.#installGlobalBinding( + new Binding('__ariaQuerySelectorAll', (async ( + element: ElementHandle, + selector: string + ): Promise> => { + const results = ARIAQueryHandler.queryAll(element, selector); + return element.executionContext().evaluateHandle((...elements) => { + return elements; + }, ...(await AsyncIterableUtil.collect(results))); + }) as (...args: unknown[]) => unknown) + ), + ]).then(() => { + return this.evaluateHandle(script) as Promise>; }); }, !this.#puppeteerUtil); return this.#puppeteerUtil as Promise>; } - async #installGlobalBinding(binding: Binding) { + async #installGlobalBinding(binding: Binding) { try { if (this._world) { this._world._bindings.set(binding.name, binding); diff --git a/packages/puppeteer-core/src/common/GetQueryHandler.ts b/packages/puppeteer-core/src/common/GetQueryHandler.ts index 931f0ddb..405f09e2 100644 --- a/packages/puppeteer-core/src/common/GetQueryHandler.ts +++ b/packages/puppeteer-core/src/common/GetQueryHandler.ts @@ -15,12 +15,12 @@ */ import {ARIAQueryHandler} from './AriaQueryHandler.js'; -import {PierceQueryHandler} from './PierceQueryHandler.js'; -import {XPathQueryHandler} from './XPathQueryHandler.js'; -import {TextQueryHandler} from './TextQueryHandler.js'; -import {CSSQueryHandler} from './CSSQueryHandler.js'; import {customQueryHandlers} from './CustomQueryHandler.js'; +import {PierceQueryHandler} from './PierceQueryHandler.js'; +import {PQueryHandler} from './PQueryHandler.js'; import type {QueryHandler} from './QueryHandler.js'; +import {TextQueryHandler} from './TextQueryHandler.js'; +import {XPathQueryHandler} from './XPathQueryHandler.js'; export const BUILTIN_QUERY_HANDLERS = Object.freeze({ aria: ARIAQueryHandler, @@ -66,5 +66,5 @@ export function getQueryHandlerAndSelector(selector: string): { } } } - return {updatedSelector: selector, QueryHandler: CSSQueryHandler}; + return {updatedSelector: selector, QueryHandler: PQueryHandler}; } diff --git a/packages/puppeteer-core/src/common/CSSQueryHandler.ts b/packages/puppeteer-core/src/common/PQueryHandler.ts similarity index 66% rename from packages/puppeteer-core/src/common/CSSQueryHandler.ts rename to packages/puppeteer-core/src/common/PQueryHandler.ts index 03ca2266..4044c4ba 100644 --- a/packages/puppeteer-core/src/common/CSSQueryHandler.ts +++ b/packages/puppeteer-core/src/common/PQueryHandler.ts @@ -19,11 +19,19 @@ import {QueryHandler, QuerySelector, QuerySelectorAll} from './QueryHandler.js'; /** * @internal */ -export class CSSQueryHandler extends QueryHandler { - static override querySelector: QuerySelector = (element, selector) => { - return (element as Element).querySelector(selector); +export class PQueryHandler extends QueryHandler { + static override querySelectorAll: QuerySelectorAll = ( + element, + selector, + {pQuerySelectorAll} + ) => { + return pQuerySelectorAll(element, selector); }; - static override querySelectorAll: QuerySelectorAll = (element, selector) => { - return (element as Element).querySelectorAll(selector); + static override querySelector: QuerySelector = ( + element, + selector, + {pQuerySelector} + ) => { + return pQuerySelector(element, selector); }; } diff --git a/packages/puppeteer-core/src/common/QueryHandler.ts b/packages/puppeteer-core/src/common/QueryHandler.ts index 4238a9d4..52a7410e 100644 --- a/packages/puppeteer-core/src/common/QueryHandler.ts +++ b/packages/puppeteer-core/src/common/QueryHandler.ts @@ -100,7 +100,7 @@ export class QueryHandler { /** * Queries for multiple nodes given a selector and {@link ElementHandle}. * - * Akin to {@link Window.prototype.querySelectorAll}. + * Akin to {@link Document.prototype.querySelectorAll}. */ static async *queryAll( element: ElementHandle, @@ -121,7 +121,7 @@ export class QueryHandler { /** * Queries for a single node given a selector and {@link ElementHandle}. * - * Akin to {@link Window.prototype.querySelector}. + * Akin to {@link Document.prototype.querySelector}. */ static async queryOne( element: ElementHandle, diff --git a/packages/puppeteer-core/src/common/types.ts b/packages/puppeteer-core/src/common/types.ts index 7d2635e1..8b601001 100644 --- a/packages/puppeteer-core/src/common/types.ts +++ b/packages/puppeteer-core/src/common/types.ts @@ -32,6 +32,11 @@ export type BindingPayload = { isTrivial: boolean; }; +/** + * @internal + */ +export type AwaitableIterator = Iterator | AsyncIterator; + /** * @public */ diff --git a/packages/puppeteer-core/src/injected/ARIAQuerySelector.ts b/packages/puppeteer-core/src/injected/ARIAQuerySelector.ts index 52b3ea90..90168e2d 100644 --- a/packages/puppeteer-core/src/injected/ARIAQuerySelector.ts +++ b/packages/puppeteer-core/src/injected/ARIAQuerySelector.ts @@ -19,13 +19,23 @@ declare global { /** * @internal */ - __ariaQuerySelector(root: Node, selector: string): Promise; + __ariaQuerySelector(root: Node, selector: string): Promise; + /** + * @internal + */ + __ariaQuerySelectorAll(root: Node, selector: string): Promise; } } export const ariaQuerySelector = ( root: Node, selector: string -): Promise => { +): Promise => { return window.__ariaQuerySelector(root, selector); }; +export const ariaQuerySelectorAll = async function* ( + root: Node, + selector: string +): AsyncIterable { + yield* await window.__ariaQuerySelectorAll(root, selector); +}; diff --git a/packages/puppeteer-core/src/injected/CustomQuerySelector.ts b/packages/puppeteer-core/src/injected/CustomQuerySelector.ts index 869e385e..41bb1a84 100644 --- a/packages/puppeteer-core/src/injected/CustomQuerySelector.ts +++ b/packages/puppeteer-core/src/injected/CustomQuerySelector.ts @@ -15,10 +15,11 @@ */ import type {CustomQueryHandler} from '../common/CustomQueryHandler.js'; +import type {Awaitable, AwaitableIterable} from '../common/types.js'; export interface CustomQuerySelector { - querySelector(root: Node, selector: string): Node | null; - querySelectorAll(root: Node, selector: string): Iterable; + querySelector(root: Node, selector: string): Awaitable; + querySelectorAll(root: Node, selector: string): AwaitableIterable; } /** diff --git a/packages/puppeteer-core/src/injected/PQuerySelector.ts b/packages/puppeteer-core/src/injected/PQuerySelector.ts new file mode 100644 index 00000000..e3a0ff26 --- /dev/null +++ b/packages/puppeteer-core/src/injected/PQuerySelector.ts @@ -0,0 +1,193 @@ +import type {AwaitableIterable} from '../common/types.js'; +import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js'; +import {isErrorLike} from '../util/ErrorLike.js'; +import {ariaQuerySelectorAll} from './ARIAQuerySelector.js'; +import {customQuerySelectors} from './CustomQuerySelector.js'; +import {parsePSelectors, PSelector} from './PSelectorParser.js'; +import {textQuerySelectorAll} from './TextQuerySelector.js'; +import {deepChildren, deepDescendents} from './util.js'; +import {xpathQuerySelectorAll} from './XPathQuerySelector.js'; + +class SelectorError extends Error { + constructor(selector: string, message: string) { + super(`${selector} is not a valid selector: ${message}`); + } +} + +class PQueryEngine { + #input: string; + + #deepShadowSelectors: PSelector[][][]; + #shadowSelectors: PSelector[][]; + #selectors: PSelector[]; + #selector: PSelector | undefined; + + elements: AwaitableIterable; + + constructor(element: Node, selector: string) { + this.#input = selector.trim(); + + if (this.#input.length === 0) { + throw new SelectorError(this.#input, 'The provided selector is empty.'); + } + + try { + this.#deepShadowSelectors = parsePSelectors(this.#input); + } catch (error) { + if (!isErrorLike(error)) { + throw new SelectorError(this.#input, String(error)); + } + throw new SelectorError(this.#input, error.message); + } + + // If there are any empty elements, then this implies the selector has + // contiguous combinators (e.g. `>>> >>>>`) or starts/ends with one which we + // treat as illegal, similar to existing behavior. + if ( + this.#deepShadowSelectors.some(shadowSelectors => { + return shadowSelectors.some(selectors => { + return selectors.length === 0; + }); + }) + ) { + throw new SelectorError( + this.#input, + 'Multiple deep combinators found in sequence.' + ); + } + + this.#shadowSelectors = this.#deepShadowSelectors.shift() as PSelector[][]; + this.#selectors = this.#shadowSelectors.shift() as PSelector[]; + this.#selector = this.#selectors.shift(); + + this.elements = [element]; + } + + async run(): Promise { + if (typeof this.#selector === 'string') { + switch (this.#selector.trimStart()) { + case ':scope': + // `:scope` has some special behavior depending on the node. It always + // represents the current node within a compound selector, but by + // itself, it depends on the node. For example, Document is + // represented by ``, but any HTMLElement is not represented by + // itself (i.e. `null`). This can be troublesome if our combinators + // are used right after so we treat this selector specially. + this.#next(); + break; + default: + /** + * We add the space since `.foo` will interpolate incorrectly (see + * {@link PQueryAllEngine.query}). This is always equivalent. + */ + this.#selector = ` ${this.#selector}`; + break; + } + } + + for (; this.#selector !== undefined; this.#next()) { + const selector = this.#selector; + const input = this.#input; + this.elements = AsyncIterableUtil.flatMap( + this.elements, + async function* (element) { + if (typeof selector === 'string') { + if (!element.parentElement) { + yield* (element as Element).querySelectorAll(selector); + return; + } + + let index = 0; + for (const child of element.parentElement.children) { + ++index; + if (child === element) { + break; + } + } + yield* element.parentElement.querySelectorAll( + `:scope > :nth-child(${index})${selector}` + ); + return; + } + + switch (selector.name) { + case 'text': + yield* textQuerySelectorAll(element, selector.value); + break; + case 'xpath': + yield* xpathQuerySelectorAll(element, selector.value); + break; + case 'aria': + yield* ariaQuerySelectorAll(element, selector.value); + break; + default: + const querySelector = customQuerySelectors.get(selector.name); + if (!querySelector) { + throw new SelectorError( + input, + `Unknown selector type: ${selector.name}` + ); + } + yield* querySelector.querySelectorAll(element, selector.value); + } + } + ); + } + } + + #next() { + if (this.#selectors.length === 0) { + if (this.#shadowSelectors.length === 0) { + if (this.#deepShadowSelectors.length === 0) { + this.#selector = undefined; + return; + } + this.elements = AsyncIterableUtil.flatMap( + this.elements, + function* (element) { + yield* deepDescendents(element); + } + ); + this.#shadowSelectors = + this.#deepShadowSelectors.shift() as PSelector[][]; + } + this.elements = AsyncIterableUtil.flatMap( + this.elements, + function* (element) { + yield* deepChildren(element); + } + ); + this.#selectors = this.#shadowSelectors.shift() as PSelector[]; + } + this.#selector = this.#selectors.shift() as PSelector; + } +} + +/** + * Queries the given node for all nodes matching the given text selector. + * + * @internal + */ +export const pQuerySelectorAll = async function* ( + root: Node, + selector: string +): AwaitableIterable { + const query = new PQueryEngine(root, selector); + query.run(); + yield* query.elements; +}; + +/** + * Queries the given node for all nodes matching the given text selector. + * + * @internal + */ +export const pQuerySelector = async function ( + root: Node, + selector: string +): Promise { + for await (const element of pQuerySelectorAll(root, selector)) { + return element; + } + return null; +}; diff --git a/packages/puppeteer-core/src/injected/PSelectorParser.ts b/packages/puppeteer-core/src/injected/PSelectorParser.ts new file mode 100644 index 00000000..3488bc03 --- /dev/null +++ b/packages/puppeteer-core/src/injected/PSelectorParser.ts @@ -0,0 +1,158 @@ +/** + * Copyright 2023 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. + */ + +type CSSSelector = string; + +export type PSelector = + | { + name: string; + value: string; + } + | CSSSelector; + +const PUPPETEER_PSEUDO_ELEMENT = /^::-p-([-a-zA-Z_]+)\(/; + +class PSelectorParser { + #input: string; + #escaped = false; + #quoted = false; + + // The first level are deep roots. The second level are shallow roots. + #selectors: PSelector[][][] = [[[]]]; + + constructor(input: string) { + this.#input = input; + } + + get selectors(): PSelector[][][] { + return this.#selectors; + } + + parse(): void { + for (let i = 0; i < this.#input.length; ++i) { + if (this.#escaped) { + this.#escaped = false; + continue; + } + switch (this.#input[i]) { + case '\\': { + this.#escaped = true; + break; + } + case '"': { + this.#quoted = !this.#quoted; + break; + } + default: { + if (this.#quoted) { + break; + } + const remainder = this.#input.slice(i); + if (remainder.startsWith('>>>>')) { + this.#push(this.#input.slice(0, i)); + this.#input = remainder.slice('>>>>'.length); + this.#parseDeepChild(); + } else if (remainder.startsWith('>>>')) { + this.#push(this.#input.slice(0, i)); + this.#input = remainder.slice('>>>'.length); + this.#parseDeepDescendent(); + } else { + const result = PUPPETEER_PSEUDO_ELEMENT.exec(remainder); + if (!result) { + continue; + } + const [match, name] = result; + this.#push(this.#input.slice(0, i)); + this.#input = remainder.slice(match.length); + this.#push({ + name: name as string, + value: this.#scanParameter(), + }); + } + } + } + } + this.#push(this.#input); + } + + #push(selector: PSelector) { + if (typeof selector === 'string') { + // We only trim the end only since `.foo` and ` .foo` are different. + selector = selector.trimEnd(); + if (selector.length === 0) { + return; + } + } + const roots = this.#selectors[this.#selectors.length - 1]!; + roots[roots.length - 1]!.push(selector); + } + + #parseDeepChild() { + this.#selectors[this.#selectors.length - 1]!.push([]); + } + + #parseDeepDescendent() { + this.#selectors.push([[]]); + } + + #scanParameter(): string { + const char = this.#input[0]; + switch (char) { + case "'": + case '"': + this.#input = this.#input.slice(1); + const parameter = this.#scanEscapedValueTill(char); + if (!this.#input.startsWith(')')) { + throw new Error("Expected ')'"); + } + this.#input = this.#input.slice(1); + return parameter; + default: + return this.#scanEscapedValueTill(')'); + } + } + + #scanEscapedValueTill(end: string): string { + let string = ''; + for (let i = 0; i < this.#input.length; ++i) { + if (this.#escaped) { + this.#escaped = false; + string += this.#input[i]; + continue; + } + switch (this.#input[i]) { + case '\\': { + this.#escaped = true; + break; + } + case end: { + this.#input = this.#input.slice(i + 1); + return string; + } + default: { + string += this.#input[i]; + } + } + } + throw new Error(`Expected \`${end}\``); + } +} + +export function parsePSelectors(selector: string): PSelector[][][] { + const parser = new PSelectorParser(selector); + parser.parse(); + return parser.selectors; +} diff --git a/packages/puppeteer-core/src/injected/injected.ts b/packages/puppeteer-core/src/injected/injected.ts index f4000b92..a052d602 100644 --- a/packages/puppeteer-core/src/injected/injected.ts +++ b/packages/puppeteer-core/src/injected/injected.ts @@ -25,6 +25,7 @@ import { isSuitableNodeForTextMatching, } from './TextContent.js'; import * as TextQuerySelector from './TextQuerySelector.js'; +import * as PQuerySelector from './PQuerySelector.js'; import * as util from './util.js'; import * as XPathQuerySelector from './XPathQuerySelector.js'; @@ -35,6 +36,7 @@ const PuppeteerUtil = Object.freeze({ ...ARIAQuerySelector, ...CustomQuerySelectors, ...PierceQuerySelector, + ...PQuerySelector, ...TextQuerySelector, ...util, ...XPathQuerySelector, diff --git a/packages/puppeteer-core/src/injected/util.ts b/packages/puppeteer-core/src/injected/util.ts index d41b4eec..6833fd6c 100644 --- a/packages/puppeteer-core/src/injected/util.ts +++ b/packages/puppeteer-core/src/injected/util.ts @@ -29,3 +29,42 @@ function isBoundingBoxEmpty(element: Element): boolean { const rect = element.getBoundingClientRect(); return rect.width === 0 || rect.height === 0; } + +/** + * @internal + */ +export function* deepChildren( + root: Node +): IterableIterator { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); + let node = walker.nextNode() as Element | null; + for (; node; node = walker.nextNode() as Element | null) { + yield node.shadowRoot ?? node; + } +} + +/** + * @internal + */ +export function* deepDescendents( + root: Node +): IterableIterator { + const walkers = [document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT)]; + let walker: TreeWalker | undefined; + while ((walker = walkers.shift())) { + for ( + let node = walker.nextNode() as Element | null; + node; + node = walker.nextNode() as Element | null + ) { + if (!node.shadowRoot) { + yield node; + continue; + } + walkers.push( + document.createTreeWalker(node.shadowRoot, NodeFilter.SHOW_ELEMENT) + ); + yield node.shadowRoot; + } + } +} diff --git a/packages/puppeteer-core/src/common/IterableUtil.ts b/packages/puppeteer-core/src/util/AsyncIterableUtil.ts similarity index 78% rename from packages/puppeteer-core/src/common/IterableUtil.ts rename to packages/puppeteer-core/src/util/AsyncIterableUtil.ts index f864e60f..a35e4794 100644 --- a/packages/puppeteer-core/src/common/IterableUtil.ts +++ b/packages/puppeteer-core/src/util/AsyncIterableUtil.ts @@ -13,22 +13,30 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import type {AwaitableIterable} from './types.js'; +import {AwaitableIterable} from '../common/types.js'; /** * @internal */ -export class IterableUtil { +export class AsyncIterableUtil { static async *map( iterable: AwaitableIterable, map: (item: T) => Promise - ): AwaitableIterable { + ): AsyncIterable { for await (const value of iterable) { yield await map(value); } } + static async *flatMap( + iterable: AwaitableIterable, + map: (item: T) => AwaitableIterable + ): AsyncIterable { + for await (const value of iterable) { + yield* map(value); + } + } + static async collect(iterable: AwaitableIterable): Promise { const result = []; for await (const value of iterable) { @@ -43,6 +51,6 @@ export class IterableUtil { for await (const value of iterable) { return value; } - return undefined; + return; } } diff --git a/packages/puppeteer-core/src/util/util.ts b/packages/puppeteer-core/src/util/util.ts index be9cc7fe..d3160757 100644 --- a/packages/puppeteer-core/src/util/util.ts +++ b/packages/puppeteer-core/src/util/util.ts @@ -18,3 +18,4 @@ export * from './assert.js'; export * from './DebuggableDeferredPromise.js'; export * from './DeferredPromise.js'; export * from './ErrorLike.js'; +export * from './AsyncIterableUtil.js'; diff --git a/test/TestExpectations.json b/test/TestExpectations.json index 69cb0f1a..64d91e40 100644 --- a/test/TestExpectations.json +++ b/test/TestExpectations.json @@ -1810,5 +1810,17 @@ "platforms": ["darwin", "linux", "win32"], "parameters": ["webDriverBiDi"], "expectations": ["FAIL"] + }, + { + "testIdPattern": "[queryhandler.spec] Query handler tests P selectors should work ARIA selectors", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["firefox"], + "expectations": ["FAIL"] + }, + { + "testIdPattern": "[queryhandler.spec]", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["SKIP", "FAIL"] } ] diff --git a/test/src/queryhandler.spec.ts b/test/src/queryhandler.spec.ts index a8402937..713763e7 100644 --- a/test/src/queryhandler.spec.ts +++ b/test/src/queryhandler.spec.ts @@ -13,7 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import assert from 'assert'; import expect from 'expect'; +import {Puppeteer} from 'puppeteer-core'; import {ElementHandle} from 'puppeteer-core/internal/api/ElementHandle.js'; import { getTestState, @@ -351,4 +353,147 @@ describe('Query handler tests', function () { }); }); }); + + describe('P selectors', () => { + beforeEach(async () => { + const {page} = getTestState(); + await page.setContent('
hello
'); + }); + + it('should work with CSS selectors', async () => { + const {page} = getTestState(); + const element = await page.$('div > button'); + assert(element, 'Could not find element'); + expect( + await element.evaluate(element => { + return element.tagName === 'BUTTON'; + }) + ).toBeTruthy(); + }); + + it('should work with text selectors', async () => { + const {page} = getTestState(); + const element = await page.$('div ::-p-text(world)'); + assert(element, 'Could not find element'); + expect( + await element.evaluate(element => { + return element.tagName === 'BUTTON'; + }) + ).toBeTruthy(); + }); + + it('should work ARIA selectors', async () => { + const {page} = getTestState(); + await page.setContent('
hello
'); + + const element = await page.$('div ::-p-aria(world)'); + assert(element, 'Could not find element'); + expect( + await element.evaluate(element => { + return element.tagName === 'BUTTON'; + }) + ).toBeTruthy(); + }); + + it('should work XPath selectors', async () => { + const {page} = getTestState(); + await page.setContent('
hello
'); + + const element = await page.$('div ::-p-xpath(//button)'); + assert(element, 'Could not find element'); + expect( + await element.evaluate(element => { + return element.tagName === 'BUTTON'; + }) + ).toBeTruthy(); + }); + + it('should work with custom selectors', async () => { + const {page} = getTestState(); + await page.setContent('
hello
'); + Puppeteer.clearCustomQueryHandlers(); + Puppeteer.registerCustomQueryHandler('div', { + queryOne() { + return document.querySelector('div'); + }, + }); + + const element = await page.$('::-p-div()'); + assert(element, 'Could not find element'); + expect( + await element.evaluate(element => { + return element.tagName === 'DIV'; + }) + ).toBeTruthy(); + }); + + it('should work with custom selectors with args', async () => { + const {page} = getTestState(); + await page.setContent('
hello
'); + Puppeteer.clearCustomQueryHandlers(); + Puppeteer.registerCustomQueryHandler('div', { + queryOne(_, selector) { + if (selector === 'true') { + return document.querySelector('div'); + } else { + return document.querySelector('button'); + } + }, + }); + + { + const element = await page.$('::-p-div(true)'); + assert(element, 'Could not find element'); + expect( + await element.evaluate(element => { + return element.tagName === 'DIV'; + }) + ).toBeTruthy(); + } + { + const element = await page.$('::-p-div("true")'); + assert(element, 'Could not find element'); + expect( + await element.evaluate(element => { + return element.tagName === 'DIV'; + }) + ).toBeTruthy(); + } + { + const element = await page.$("::-p-div('true')"); + assert(element, 'Could not find element'); + expect( + await element.evaluate(element => { + return element.tagName === 'DIV'; + }) + ).toBeTruthy(); + } + { + const element = await page.$('::-p-div()'); + assert(element, 'Could not find element'); + expect( + await element.evaluate(element => { + return element.tagName === 'BUTTON'; + }) + ).toBeTruthy(); + } + }); + + it('should work with :hover', async () => { + const {page} = getTestState(); + await page.setContent('
hello
'); + + let button = await page.$('div ::-p-text(world)'); + assert(button, 'Could not find element'); + await button.hover(); + await button.dispose(); + + button = await page.$('div ::-p-text(world):hover'); + assert(button, 'Could not find element'); + const value = await button.evaluate(span => { + return {textContent: span.textContent, tagName: span.tagName}; + }); + expect(value).toMatchObject({textContent: 'world', tagName: 'BUTTON'}); + }); + }); });