mirror of
https://github.com/puppeteer/puppeteer
synced 2024-06-14 14:02:48 +00:00
chore: implement P queries (#9639)
This commit is contained in:
parent
e8f25e403f
commit
2b3cf3ace9
@ -18,8 +18,8 @@ import {Protocol} from 'devtools-protocol';
|
|||||||
|
|
||||||
import {ElementHandle} from '../api/ElementHandle.js';
|
import {ElementHandle} from '../api/ElementHandle.js';
|
||||||
import {assert} from '../util/assert.js';
|
import {assert} from '../util/assert.js';
|
||||||
|
import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js';
|
||||||
import {CDPSession} from './Connection.js';
|
import {CDPSession} from './Connection.js';
|
||||||
import {IterableUtil} from './IterableUtil.js';
|
|
||||||
import {QueryHandler, QuerySelector} from './QueryHandler.js';
|
import {QueryHandler, QuerySelector} from './QueryHandler.js';
|
||||||
import {AwaitableIterable} from './types.js';
|
import {AwaitableIterable} from './types.js';
|
||||||
|
|
||||||
@ -105,7 +105,7 @@ export class ARIAQueryHandler extends QueryHandler {
|
|||||||
const {name, role} = parseARIASelector(selector);
|
const {name, role} = parseARIASelector(selector);
|
||||||
const results = await queryAXTree(context._client, element, name, role);
|
const results = await queryAXTree(context._client, element, name, role);
|
||||||
const world = context._world!;
|
const world = context._world!;
|
||||||
yield* IterableUtil.map(results, node => {
|
yield* AsyncIterableUtil.map(results, node => {
|
||||||
return world.adoptBackendNode(node.backendDOMNodeId) as Promise<
|
return world.adoptBackendNode(node.backendDOMNodeId) as Promise<
|
||||||
ElementHandle<Node>
|
ElementHandle<Node>
|
||||||
>;
|
>;
|
||||||
@ -116,6 +116,8 @@ export class ARIAQueryHandler extends QueryHandler {
|
|||||||
element: ElementHandle<Node>,
|
element: ElementHandle<Node>,
|
||||||
selector: string
|
selector: string
|
||||||
): Promise<ElementHandle<Node> | null> => {
|
): Promise<ElementHandle<Node> | null> => {
|
||||||
return (await IterableUtil.first(this.queryAll(element, selector))) ?? null;
|
return (
|
||||||
|
(await AsyncIterableUtil.first(this.queryAll(element, selector))) ?? null
|
||||||
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -6,10 +6,10 @@ import {debugError} from './util.js';
|
|||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
export class Binding<Params extends unknown[] = any[]> {
|
export class Binding {
|
||||||
#name: string;
|
#name: string;
|
||||||
#fn: (...args: Params) => unknown;
|
#fn: (...args: unknown[]) => unknown;
|
||||||
constructor(name: string, fn: (...args: Params) => unknown) {
|
constructor(name: string, fn: (...args: unknown[]) => unknown) {
|
||||||
this.#name = name;
|
this.#name = name;
|
||||||
this.#fn = fn;
|
this.#fn = fn;
|
||||||
}
|
}
|
||||||
@ -28,7 +28,7 @@ export class Binding<Params extends unknown[] = any[]> {
|
|||||||
async run(
|
async run(
|
||||||
context: ExecutionContext,
|
context: ExecutionContext,
|
||||||
id: number,
|
id: number,
|
||||||
args: Params,
|
args: unknown[],
|
||||||
isTrivial: boolean
|
isTrivial: boolean
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const garbage = [];
|
const garbage = [];
|
||||||
|
@ -33,7 +33,7 @@ import {Frame} from './Frame.js';
|
|||||||
import {FrameManager} from './FrameManager.js';
|
import {FrameManager} from './FrameManager.js';
|
||||||
import {getQueryHandlerAndSelector} from './GetQueryHandler.js';
|
import {getQueryHandlerAndSelector} from './GetQueryHandler.js';
|
||||||
import {WaitForSelectorOptions} from './IsolatedWorld.js';
|
import {WaitForSelectorOptions} from './IsolatedWorld.js';
|
||||||
import {IterableUtil} from './IterableUtil.js';
|
import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js';
|
||||||
import {CDPPage} from './Page.js';
|
import {CDPPage} from './Page.js';
|
||||||
import {
|
import {
|
||||||
ElementFor,
|
ElementFor,
|
||||||
@ -189,7 +189,7 @@ export class CDPElementHandle<
|
|||||||
): Promise<Array<CDPElementHandle<NodeFor<Selector>>>> {
|
): Promise<Array<CDPElementHandle<NodeFor<Selector>>>> {
|
||||||
const {updatedSelector, QueryHandler} =
|
const {updatedSelector, QueryHandler} =
|
||||||
getQueryHandlerAndSelector(selector);
|
getQueryHandlerAndSelector(selector);
|
||||||
return IterableUtil.collect(
|
return AsyncIterableUtil.collect(
|
||||||
QueryHandler.queryAll(this, updatedSelector)
|
QueryHandler.queryAll(this, updatedSelector)
|
||||||
) as Promise<Array<CDPElementHandle<NodeFor<Selector>>>>;
|
) as Promise<Array<CDPElementHandle<NodeFor<Selector>>>>;
|
||||||
}
|
}
|
||||||
|
@ -15,8 +15,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {Protocol} from 'devtools-protocol';
|
import {Protocol} from 'devtools-protocol';
|
||||||
|
import type {ElementHandle} from '../api/ElementHandle.js';
|
||||||
import {JSHandle} from '../api/JSHandle.js';
|
import {JSHandle} from '../api/JSHandle.js';
|
||||||
import type PuppeteerUtil from '../injected/injected.js';
|
import type PuppeteerUtil from '../injected/injected.js';
|
||||||
|
import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js';
|
||||||
import {stringifyFunction} from '../util/Function.js';
|
import {stringifyFunction} from '../util/Function.js';
|
||||||
import {ARIAQueryHandler} from './AriaQueryHandler.js';
|
import {ARIAQueryHandler} from './AriaQueryHandler.js';
|
||||||
import {Binding} from './Binding.js';
|
import {Binding} from './Binding.js';
|
||||||
@ -78,7 +80,7 @@ export class ExecutionContext {
|
|||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
_contextName: string;
|
_contextName?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
@ -91,7 +93,9 @@ export class ExecutionContext {
|
|||||||
this._client = client;
|
this._client = client;
|
||||||
this._world = world;
|
this._world = world;
|
||||||
this._contextId = contextPayload.id;
|
this._contextId = contextPayload.id;
|
||||||
this._contextName = contextPayload.name;
|
if (contextPayload.name) {
|
||||||
|
this._contextName = contextPayload.name;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#puppeteerUtil?: Promise<JSHandle<PuppeteerUtil>>;
|
#puppeteerUtil?: Promise<JSHandle<PuppeteerUtil>>;
|
||||||
@ -104,17 +108,30 @@ export class ExecutionContext {
|
|||||||
}
|
}
|
||||||
this.#puppeteerUtil = Promise.all([
|
this.#puppeteerUtil = Promise.all([
|
||||||
this.#installGlobalBinding(
|
this.#installGlobalBinding(
|
||||||
new Binding('__ariaQuerySelector', ARIAQueryHandler.queryOne)
|
new Binding(
|
||||||
|
'__ariaQuerySelector',
|
||||||
|
ARIAQueryHandler.queryOne as (...args: unknown[]) => unknown
|
||||||
|
)
|
||||||
),
|
),
|
||||||
this.evaluateHandle(script) as Promise<JSHandle<PuppeteerUtil>>,
|
this.#installGlobalBinding(
|
||||||
]).then(([, util]) => {
|
new Binding('__ariaQuerySelectorAll', (async (
|
||||||
return util;
|
element: ElementHandle<Node>,
|
||||||
|
selector: string
|
||||||
|
): Promise<JSHandle<Node[]>> => {
|
||||||
|
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<JSHandle<PuppeteerUtil>>;
|
||||||
});
|
});
|
||||||
}, !this.#puppeteerUtil);
|
}, !this.#puppeteerUtil);
|
||||||
return this.#puppeteerUtil as Promise<JSHandle<PuppeteerUtil>>;
|
return this.#puppeteerUtil as Promise<JSHandle<PuppeteerUtil>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async #installGlobalBinding(binding: Binding<any[]>) {
|
async #installGlobalBinding(binding: Binding) {
|
||||||
try {
|
try {
|
||||||
if (this._world) {
|
if (this._world) {
|
||||||
this._world._bindings.set(binding.name, binding);
|
this._world._bindings.set(binding.name, binding);
|
||||||
|
@ -15,12 +15,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {ARIAQueryHandler} from './AriaQueryHandler.js';
|
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 {customQueryHandlers} from './CustomQueryHandler.js';
|
||||||
|
import {PierceQueryHandler} from './PierceQueryHandler.js';
|
||||||
|
import {PQueryHandler} from './PQueryHandler.js';
|
||||||
import type {QueryHandler} from './QueryHandler.js';
|
import type {QueryHandler} from './QueryHandler.js';
|
||||||
|
import {TextQueryHandler} from './TextQueryHandler.js';
|
||||||
|
import {XPathQueryHandler} from './XPathQueryHandler.js';
|
||||||
|
|
||||||
export const BUILTIN_QUERY_HANDLERS = Object.freeze({
|
export const BUILTIN_QUERY_HANDLERS = Object.freeze({
|
||||||
aria: ARIAQueryHandler,
|
aria: ARIAQueryHandler,
|
||||||
@ -66,5 +66,5 @@ export function getQueryHandlerAndSelector(selector: string): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {updatedSelector: selector, QueryHandler: CSSQueryHandler};
|
return {updatedSelector: selector, QueryHandler: PQueryHandler};
|
||||||
}
|
}
|
||||||
|
@ -19,11 +19,19 @@ import {QueryHandler, QuerySelector, QuerySelectorAll} from './QueryHandler.js';
|
|||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
export class CSSQueryHandler extends QueryHandler {
|
export class PQueryHandler extends QueryHandler {
|
||||||
static override querySelector: QuerySelector = (element, selector) => {
|
static override querySelectorAll: QuerySelectorAll = (
|
||||||
return (element as Element).querySelector(selector);
|
element,
|
||||||
|
selector,
|
||||||
|
{pQuerySelectorAll}
|
||||||
|
) => {
|
||||||
|
return pQuerySelectorAll(element, selector);
|
||||||
};
|
};
|
||||||
static override querySelectorAll: QuerySelectorAll = (element, selector) => {
|
static override querySelector: QuerySelector = (
|
||||||
return (element as Element).querySelectorAll(selector);
|
element,
|
||||||
|
selector,
|
||||||
|
{pQuerySelector}
|
||||||
|
) => {
|
||||||
|
return pQuerySelector(element, selector);
|
||||||
};
|
};
|
||||||
}
|
}
|
@ -100,7 +100,7 @@ export class QueryHandler {
|
|||||||
/**
|
/**
|
||||||
* Queries for multiple nodes given a selector and {@link ElementHandle}.
|
* 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(
|
static async *queryAll(
|
||||||
element: ElementHandle<Node>,
|
element: ElementHandle<Node>,
|
||||||
@ -121,7 +121,7 @@ export class QueryHandler {
|
|||||||
/**
|
/**
|
||||||
* Queries for a single node given a selector and {@link ElementHandle}.
|
* 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(
|
static async queryOne(
|
||||||
element: ElementHandle<Node>,
|
element: ElementHandle<Node>,
|
||||||
|
@ -32,6 +32,11 @@ export type BindingPayload = {
|
|||||||
isTrivial: boolean;
|
isTrivial: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export type AwaitableIterator<T> = Iterator<T> | AsyncIterator<T>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
|
@ -19,13 +19,23 @@ declare global {
|
|||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
__ariaQuerySelector(root: Node, selector: string): Promise<Element | null>;
|
__ariaQuerySelector(root: Node, selector: string): Promise<Node | null>;
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
__ariaQuerySelectorAll(root: Node, selector: string): Promise<Node[]>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ariaQuerySelector = (
|
export const ariaQuerySelector = (
|
||||||
root: Node,
|
root: Node,
|
||||||
selector: string
|
selector: string
|
||||||
): Promise<Element | null> => {
|
): Promise<Node | null> => {
|
||||||
return window.__ariaQuerySelector(root, selector);
|
return window.__ariaQuerySelector(root, selector);
|
||||||
};
|
};
|
||||||
|
export const ariaQuerySelectorAll = async function* (
|
||||||
|
root: Node,
|
||||||
|
selector: string
|
||||||
|
): AsyncIterable<Node> {
|
||||||
|
yield* await window.__ariaQuerySelectorAll(root, selector);
|
||||||
|
};
|
||||||
|
@ -15,10 +15,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type {CustomQueryHandler} from '../common/CustomQueryHandler.js';
|
import type {CustomQueryHandler} from '../common/CustomQueryHandler.js';
|
||||||
|
import type {Awaitable, AwaitableIterable} from '../common/types.js';
|
||||||
|
|
||||||
export interface CustomQuerySelector {
|
export interface CustomQuerySelector {
|
||||||
querySelector(root: Node, selector: string): Node | null;
|
querySelector(root: Node, selector: string): Awaitable<Node | null>;
|
||||||
querySelectorAll(root: Node, selector: string): Iterable<Node>;
|
querySelectorAll(root: Node, selector: string): AwaitableIterable<Node>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
193
packages/puppeteer-core/src/injected/PQuerySelector.ts
Normal file
193
packages/puppeteer-core/src/injected/PQuerySelector.ts
Normal file
@ -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<Node>;
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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 `<html>`, 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<Node> {
|
||||||
|
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<Node | null> {
|
||||||
|
for await (const element of pQuerySelectorAll(root, selector)) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
158
packages/puppeteer-core/src/injected/PSelectorParser.ts
Normal file
158
packages/puppeteer-core/src/injected/PSelectorParser.ts
Normal file
@ -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;
|
||||||
|
}
|
@ -25,6 +25,7 @@ import {
|
|||||||
isSuitableNodeForTextMatching,
|
isSuitableNodeForTextMatching,
|
||||||
} from './TextContent.js';
|
} from './TextContent.js';
|
||||||
import * as TextQuerySelector from './TextQuerySelector.js';
|
import * as TextQuerySelector from './TextQuerySelector.js';
|
||||||
|
import * as PQuerySelector from './PQuerySelector.js';
|
||||||
import * as util from './util.js';
|
import * as util from './util.js';
|
||||||
import * as XPathQuerySelector from './XPathQuerySelector.js';
|
import * as XPathQuerySelector from './XPathQuerySelector.js';
|
||||||
|
|
||||||
@ -35,6 +36,7 @@ const PuppeteerUtil = Object.freeze({
|
|||||||
...ARIAQuerySelector,
|
...ARIAQuerySelector,
|
||||||
...CustomQuerySelectors,
|
...CustomQuerySelectors,
|
||||||
...PierceQuerySelector,
|
...PierceQuerySelector,
|
||||||
|
...PQuerySelector,
|
||||||
...TextQuerySelector,
|
...TextQuerySelector,
|
||||||
...util,
|
...util,
|
||||||
...XPathQuerySelector,
|
...XPathQuerySelector,
|
||||||
|
@ -29,3 +29,42 @@ function isBoundingBoxEmpty(element: Element): boolean {
|
|||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
return rect.width === 0 || rect.height === 0;
|
return rect.width === 0 || rect.height === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function* deepChildren(
|
||||||
|
root: Node
|
||||||
|
): IterableIterator<Element | ShadowRoot> {
|
||||||
|
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<Element | ShadowRoot> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -13,22 +13,30 @@
|
|||||||
* See the License for the specific language governing permissions and
|
* See the License for the specific language governing permissions and
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
import {AwaitableIterable} from '../common/types.js';
|
||||||
import type {AwaitableIterable} from './types.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
export class IterableUtil {
|
export class AsyncIterableUtil {
|
||||||
static async *map<T, U>(
|
static async *map<T, U>(
|
||||||
iterable: AwaitableIterable<T>,
|
iterable: AwaitableIterable<T>,
|
||||||
map: (item: T) => Promise<U>
|
map: (item: T) => Promise<U>
|
||||||
): AwaitableIterable<U> {
|
): AsyncIterable<U> {
|
||||||
for await (const value of iterable) {
|
for await (const value of iterable) {
|
||||||
yield await map(value);
|
yield await map(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async *flatMap<T>(
|
||||||
|
iterable: AwaitableIterable<T>,
|
||||||
|
map: (item: T) => AwaitableIterable<T>
|
||||||
|
): AsyncIterable<T> {
|
||||||
|
for await (const value of iterable) {
|
||||||
|
yield* map(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static async collect<T>(iterable: AwaitableIterable<T>): Promise<T[]> {
|
static async collect<T>(iterable: AwaitableIterable<T>): Promise<T[]> {
|
||||||
const result = [];
|
const result = [];
|
||||||
for await (const value of iterable) {
|
for await (const value of iterable) {
|
||||||
@ -43,6 +51,6 @@ export class IterableUtil {
|
|||||||
for await (const value of iterable) {
|
for await (const value of iterable) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
return undefined;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -18,3 +18,4 @@ export * from './assert.js';
|
|||||||
export * from './DebuggableDeferredPromise.js';
|
export * from './DebuggableDeferredPromise.js';
|
||||||
export * from './DeferredPromise.js';
|
export * from './DeferredPromise.js';
|
||||||
export * from './ErrorLike.js';
|
export * from './ErrorLike.js';
|
||||||
|
export * from './AsyncIterableUtil.js';
|
||||||
|
@ -1810,5 +1810,17 @@
|
|||||||
"platforms": ["darwin", "linux", "win32"],
|
"platforms": ["darwin", "linux", "win32"],
|
||||||
"parameters": ["webDriverBiDi"],
|
"parameters": ["webDriverBiDi"],
|
||||||
"expectations": ["FAIL"]
|
"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"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -13,7 +13,9 @@
|
|||||||
* See the License for the specific language governing permissions and
|
* See the License for the specific language governing permissions and
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
import assert from 'assert';
|
||||||
import expect from 'expect';
|
import expect from 'expect';
|
||||||
|
import {Puppeteer} from 'puppeteer-core';
|
||||||
import {ElementHandle} from 'puppeteer-core/internal/api/ElementHandle.js';
|
import {ElementHandle} from 'puppeteer-core/internal/api/ElementHandle.js';
|
||||||
import {
|
import {
|
||||||
getTestState,
|
getTestState,
|
||||||
@ -351,4 +353,147 @@ describe('Query handler tests', function () {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('P selectors', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const {page} = getTestState();
|
||||||
|
await page.setContent('<div>hello <button>world</button></div>');
|
||||||
|
});
|
||||||
|
|
||||||
|
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('<div>hello <button>world</button></div>');
|
||||||
|
|
||||||
|
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('<div>hello <button>world</button></div>');
|
||||||
|
|
||||||
|
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('<div>hello <button>world</button></div>');
|
||||||
|
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('<div>hello <button>world</button></div>');
|
||||||
|
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('<div>hello <button>world</button></div>');
|
||||||
|
|
||||||
|
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'});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user