diff options
Diffstat (limited to 'remote/test/puppeteer/packages/puppeteer-core/src/injected')
11 files changed, 1075 insertions, 0 deletions
diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/ARIAQuerySelector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/ARIAQuerySelector.ts new file mode 100644 index 0000000000..972b6a6c64 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/ARIAQuerySelector.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +declare global { + interface Window { + /** + * @internal + */ + __ariaQuerySelector(root: Node, selector: string): Promise<Node | null>; + /** + * @internal + */ + __ariaQuerySelectorAll(root: Node, selector: string): Promise<Node[]>; + } +} + +export const ariaQuerySelector = ( + root: Node, + selector: string +): Promise<Node | null> => { + return window.__ariaQuerySelector(root, selector); +}; +export const ariaQuerySelectorAll = async function* ( + root: Node, + selector: string +): AsyncIterable<Node> { + yield* await window.__ariaQuerySelectorAll(root, selector); +}; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/CustomQuerySelector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/CustomQuerySelector.ts new file mode 100644 index 0000000000..ccd041deea --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/CustomQuerySelector.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {CustomQueryHandler} from '../common/CustomQueryHandler.js'; +import type {Awaitable, AwaitableIterable} from '../common/types.js'; + +export interface CustomQuerySelector { + querySelector(root: Node, selector: string): Awaitable<Node | null>; + querySelectorAll(root: Node, selector: string): AwaitableIterable<Node>; +} + +/** + * This class mimics the injected {@link CustomQuerySelectorRegistry}. + */ +class CustomQuerySelectorRegistry { + #selectors = new Map<string, CustomQuerySelector>(); + + register(name: string, handler: CustomQueryHandler): void { + if (!handler.queryOne && handler.queryAll) { + const querySelectorAll = handler.queryAll; + handler.queryOne = (node, selector) => { + for (const result of querySelectorAll(node, selector)) { + return result; + } + return null; + }; + } else if (handler.queryOne && !handler.queryAll) { + const querySelector = handler.queryOne; + handler.queryAll = (node, selector) => { + const result = querySelector(node, selector); + return result ? [result] : []; + }; + } else if (!handler.queryOne || !handler.queryAll) { + throw new Error('At least one query method must be defined.'); + } + + this.#selectors.set(name, { + querySelector: handler.queryOne, + querySelectorAll: handler.queryAll!, + }); + } + + unregister(name: string): void { + this.#selectors.delete(name); + } + + get(name: string): CustomQuerySelector | undefined { + return this.#selectors.get(name); + } + + clear() { + this.#selectors.clear(); + } +} + +export const customQuerySelectors = new CustomQuerySelectorRegistry(); diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/PQuerySelector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/PQuerySelector.ts new file mode 100644 index 0000000000..11499c072f --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/PQuerySelector.ts @@ -0,0 +1,298 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {AwaitableIterable} from '../common/types.js'; +import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js'; + +import {ariaQuerySelectorAll} from './ARIAQuerySelector.js'; +import {customQuerySelectors} from './CustomQuerySelector.js'; +import { + type ComplexPSelector, + type ComplexPSelectorList, + type CompoundPSelector, + type CSSSelector, + parsePSelectors, + PCombinator, + type PPseudoSelector, +} from './PSelectorParser.js'; +import {textQuerySelectorAll} from './TextQuerySelector.js'; +import {pierce, pierceAll} from './util.js'; +import {xpathQuerySelectorAll} from './XPathQuerySelector.js'; + +const IDENT_TOKEN_START = /[-\w\P{ASCII}*]/; + +interface QueryableNode extends Node { + querySelectorAll: typeof Document.prototype.querySelectorAll; +} + +const isQueryableNode = (node: Node): node is QueryableNode => { + return 'querySelectorAll' in node; +}; + +class SelectorError extends Error { + constructor(selector: string, message: string) { + super(`${selector} is not a valid selector: ${message}`); + } +} + +class PQueryEngine { + #input: string; + + #complexSelector: ComplexPSelector; + #compoundSelector: CompoundPSelector = []; + #selector: CSSSelector | PPseudoSelector | undefined = undefined; + + elements: AwaitableIterable<Node>; + + constructor(element: Node, input: string, complexSelector: ComplexPSelector) { + this.elements = [element]; + this.#input = input; + this.#complexSelector = complexSelector; + this.#next(); + } + + 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; + } + } + + for (; this.#selector !== undefined; this.#next()) { + const selector = this.#selector; + const input = this.#input; + if (typeof selector === 'string') { + // The regular expression tests if the selector is a type/universal + // selector. Any other case means we want to apply the selector onto + // the element itself (e.g. `element.class`, `element>div`, + // `element:hover`, etc.). + if (selector[0] && IDENT_TOKEN_START.test(selector[0])) { + this.elements = AsyncIterableUtil.flatMap( + this.elements, + async function* (element) { + if (isQueryableNode(element)) { + yield* element.querySelectorAll(selector); + } + } + ); + } else { + this.elements = AsyncIterableUtil.flatMap( + this.elements, + async function* (element) { + if (!element.parentElement) { + if (!isQueryableNode(element)) { + return; + } + yield* 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}` + ); + } + ); + } + } else { + this.elements = AsyncIterableUtil.flatMap( + this.elements, + async function* (element) { + 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.#compoundSelector.length !== 0) { + this.#selector = this.#compoundSelector.shift(); + return; + } + if (this.#complexSelector.length === 0) { + this.#selector = undefined; + return; + } + const selector = this.#complexSelector.shift(); + switch (selector) { + case PCombinator.Child: { + this.elements = AsyncIterableUtil.flatMap(this.elements, pierce); + this.#next(); + break; + } + case PCombinator.Descendent: { + this.elements = AsyncIterableUtil.flatMap(this.elements, pierceAll); + this.#next(); + break; + } + default: + this.#compoundSelector = selector as CompoundPSelector; + this.#next(); + break; + } + } +} + +class DepthCalculator { + #cache = new WeakMap<Node, number[]>(); + + calculate(node: Node | null, depth: number[] = []): number[] { + if (node === null) { + return depth; + } + if (node instanceof ShadowRoot) { + node = node.host; + } + + const cachedDepth = this.#cache.get(node); + if (cachedDepth) { + return [...cachedDepth, ...depth]; + } + + let index = 0; + for ( + let prevSibling = node.previousSibling; + prevSibling; + prevSibling = prevSibling.previousSibling + ) { + ++index; + } + + const value = this.calculate(node.parentNode, [index]); + this.#cache.set(node, value); + return [...value, ...depth]; + } +} + +const compareDepths = (a: number[], b: number[]): -1 | 0 | 1 => { + if (a.length + b.length === 0) { + return 0; + } + const [i = -1, ...otherA] = a; + const [j = -1, ...otherB] = b; + if (i === j) { + return compareDepths(otherA, otherB); + } + return i < j ? -1 : 1; +}; + +const domSort = async function* (elements: AwaitableIterable<Node>) { + const results = new Set<Node>(); + for await (const element of elements) { + results.add(element); + } + const calculator = new DepthCalculator(); + yield* [...results.values()] + .map(result => { + return [result, calculator.calculate(result)] as const; + }) + .sort(([, a], [, b]) => { + return compareDepths(a, b); + }) + .map(([result]) => { + return result; + }); +}; + +/** + * Queries the given node for all nodes matching the given text selector. + * + * @internal + */ +export const pQuerySelectorAll = function ( + root: Node, + selector: string +): AwaitableIterable<Node> { + let selectors: ComplexPSelectorList; + let isPureCSS: boolean; + try { + [selectors, isPureCSS] = parsePSelectors(selector); + } catch (error) { + return (root as unknown as QueryableNode).querySelectorAll(selector); + } + + if (isPureCSS) { + return (root as unknown as QueryableNode).querySelectorAll(selector); + } + // 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 ( + selectors.some(parts => { + let i = 0; + return parts.some(parts => { + if (typeof parts === 'string') { + ++i; + } else { + i = 0; + } + return i > 1; + }); + }) + ) { + throw new SelectorError( + selector, + 'Multiple deep combinators found in sequence.' + ); + } + + return domSort( + AsyncIterableUtil.flatMap(selectors, selectorParts => { + const query = new PQueryEngine(root, selector, selectorParts); + void query.run(); + return 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; +}; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/PSelectorParser.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/PSelectorParser.ts new file mode 100644 index 0000000000..8044562348 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/PSelectorParser.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2023 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {type Token, tokenize, TOKENS, stringify} from 'parsel-js'; + +export type CSSSelector = string; +export interface PPseudoSelector { + name: string; + value: string; +} +export const enum PCombinator { + Descendent = '>>>', + Child = '>>>>', +} +export type CompoundPSelector = Array<CSSSelector | PPseudoSelector>; +export type ComplexPSelector = Array<CompoundPSelector | PCombinator>; +export type ComplexPSelectorList = ComplexPSelector[]; + +TOKENS['combinator'] = /\s*(>>>>?|[\s>+~])\s*/g; + +const ESCAPE_REGEXP = /\\[\s\S]/g; +const unquote = (text: string): string => { + if (text.length <= 1) { + return text; + } + if ((text[0] === '"' || text[0] === "'") && text.endsWith(text[0])) { + text = text.slice(1, -1); + } + return text.replace(ESCAPE_REGEXP, match => { + return match[1] as string; + }); +}; + +export function parsePSelectors( + selector: string +): [selector: ComplexPSelectorList, isPureCSS: boolean] { + let isPureCSS = true; + const tokens = tokenize(selector); + if (tokens.length === 0) { + return [[], isPureCSS]; + } + let compoundSelector: CompoundPSelector = []; + let complexSelector: ComplexPSelector = [compoundSelector]; + const selectors: ComplexPSelectorList = [complexSelector]; + const storage: Token[] = []; + for (const token of tokens) { + switch (token.type) { + case 'combinator': + switch (token.content) { + case PCombinator.Descendent: + isPureCSS = false; + if (storage.length) { + compoundSelector.push(stringify(storage)); + storage.splice(0); + } + compoundSelector = []; + complexSelector.push(PCombinator.Descendent); + complexSelector.push(compoundSelector); + continue; + case PCombinator.Child: + isPureCSS = false; + if (storage.length) { + compoundSelector.push(stringify(storage)); + storage.splice(0); + } + compoundSelector = []; + complexSelector.push(PCombinator.Child); + complexSelector.push(compoundSelector); + continue; + } + break; + case 'pseudo-element': + if (!token.name.startsWith('-p-')) { + break; + } + isPureCSS = false; + if (storage.length) { + compoundSelector.push(stringify(storage)); + storage.splice(0); + } + compoundSelector.push({ + name: token.name.slice(3), + value: unquote(token.argument ?? ''), + }); + continue; + case 'comma': + if (storage.length) { + compoundSelector.push(stringify(storage)); + storage.splice(0); + } + compoundSelector = []; + complexSelector = [compoundSelector]; + selectors.push(complexSelector); + continue; + } + storage.push(token); + } + if (storage.length) { + compoundSelector.push(stringify(storage)); + } + return [selectors, isPureCSS]; +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/PierceQuerySelector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/PierceQuerySelector.ts new file mode 100644 index 0000000000..c224ee8324 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/PierceQuerySelector.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @internal + */ +export const pierceQuerySelector = ( + root: Node, + selector: string +): Element | null => { + let found: Node | null = null; + const search = (root: Node) => { + const iter = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); + do { + const currentNode = iter.currentNode as Element; + if (currentNode.shadowRoot) { + search(currentNode.shadowRoot); + } + if (currentNode instanceof ShadowRoot) { + continue; + } + if (currentNode !== root && !found && currentNode.matches(selector)) { + found = currentNode; + } + } while (!found && iter.nextNode()); + }; + if (root instanceof Document) { + root = root.documentElement; + } + search(root); + return found; +}; + +/** + * @internal + */ +export const pierceQuerySelectorAll = ( + element: Node, + selector: string +): Element[] => { + const result: Element[] = []; + const collect = (root: Node) => { + const iter = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); + do { + const currentNode = iter.currentNode as Element; + if (currentNode.shadowRoot) { + collect(currentNode.shadowRoot); + } + if (currentNode instanceof ShadowRoot) { + continue; + } + if (currentNode !== root && currentNode.matches(selector)) { + result.push(currentNode); + } + } while (iter.nextNode()); + }; + if (element instanceof Document) { + element = element.documentElement; + } + collect(element); + return result; +}; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/Poller.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/Poller.ts new file mode 100644 index 0000000000..68b9f1812b --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/Poller.ts @@ -0,0 +1,168 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {assert} from '../util/assert.js'; +import {Deferred} from '../util/Deferred.js'; + +/** + * @internal + */ +export interface Poller<T> { + start(): Promise<void>; + stop(): Promise<void>; + result(): Promise<T>; +} + +/** + * @internal + */ +export class MutationPoller<T> implements Poller<T> { + #fn: () => Promise<T>; + + #root: Node; + + #observer?: MutationObserver; + #deferred?: Deferred<T>; + constructor(fn: () => Promise<T>, root: Node) { + this.#fn = fn; + this.#root = root; + } + + async start(): Promise<void> { + const deferred = (this.#deferred = Deferred.create<T>()); + const result = await this.#fn(); + if (result) { + deferred.resolve(result); + return; + } + + this.#observer = new MutationObserver(async () => { + const result = await this.#fn(); + if (!result) { + return; + } + deferred.resolve(result); + await this.stop(); + }); + this.#observer.observe(this.#root, { + childList: true, + subtree: true, + attributes: true, + }); + } + + async stop(): Promise<void> { + assert(this.#deferred, 'Polling never started.'); + if (!this.#deferred.finished()) { + this.#deferred.reject(new Error('Polling stopped')); + } + if (this.#observer) { + this.#observer.disconnect(); + this.#observer = undefined; + } + } + + result(): Promise<T> { + assert(this.#deferred, 'Polling never started.'); + return this.#deferred.valueOrThrow(); + } +} + +/** + * @internal + */ +export class RAFPoller<T> implements Poller<T> { + #fn: () => Promise<T>; + #deferred?: Deferred<T>; + constructor(fn: () => Promise<T>) { + this.#fn = fn; + } + + async start(): Promise<void> { + const deferred = (this.#deferred = Deferred.create<T>()); + const result = await this.#fn(); + if (result) { + deferred.resolve(result); + return; + } + + const poll = async () => { + if (deferred.finished()) { + return; + } + const result = await this.#fn(); + if (!result) { + window.requestAnimationFrame(poll); + return; + } + deferred.resolve(result); + await this.stop(); + }; + window.requestAnimationFrame(poll); + } + + async stop(): Promise<void> { + assert(this.#deferred, 'Polling never started.'); + if (!this.#deferred.finished()) { + this.#deferred.reject(new Error('Polling stopped')); + } + } + + result(): Promise<T> { + assert(this.#deferred, 'Polling never started.'); + return this.#deferred.valueOrThrow(); + } +} + +/** + * @internal + */ + +export class IntervalPoller<T> implements Poller<T> { + #fn: () => Promise<T>; + #ms: number; + + #interval?: NodeJS.Timeout; + #deferred?: Deferred<T>; + constructor(fn: () => Promise<T>, ms: number) { + this.#fn = fn; + this.#ms = ms; + } + + async start(): Promise<void> { + const deferred = (this.#deferred = Deferred.create<T>()); + const result = await this.#fn(); + if (result) { + deferred.resolve(result); + return; + } + + this.#interval = setInterval(async () => { + const result = await this.#fn(); + if (!result) { + return; + } + deferred.resolve(result); + await this.stop(); + }, this.#ms); + } + + async stop(): Promise<void> { + assert(this.#deferred, 'Polling never started.'); + if (!this.#deferred.finished()) { + this.#deferred.reject(new Error('Polling stopped')); + } + if (this.#interval) { + clearInterval(this.#interval); + this.#interval = undefined; + } + } + + result(): Promise<T> { + assert(this.#deferred, 'Polling never started.'); + return this.#deferred.valueOrThrow(); + } +} diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/TextContent.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/TextContent.ts new file mode 100644 index 0000000000..ffe8980d5e --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/TextContent.ts @@ -0,0 +1,146 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +interface NonTrivialValueNode extends Node { + value: string; +} + +const TRIVIAL_VALUE_INPUT_TYPES = new Set(['checkbox', 'image', 'radio']); + +/** + * Determines if the node has a non-trivial value property. + * + * @internal + */ +const isNonTrivialValueNode = (node: Node): node is NonTrivialValueNode => { + if (node instanceof HTMLSelectElement) { + return true; + } + if (node instanceof HTMLTextAreaElement) { + return true; + } + if ( + node instanceof HTMLInputElement && + !TRIVIAL_VALUE_INPUT_TYPES.has(node.type) + ) { + return true; + } + return false; +}; + +const UNSUITABLE_NODE_NAMES = new Set(['SCRIPT', 'STYLE']); + +/** + * Determines whether a given node is suitable for text matching. + * + * @internal + */ +export const isSuitableNodeForTextMatching = (node: Node): boolean => { + return ( + !UNSUITABLE_NODE_NAMES.has(node.nodeName) && !document.head?.contains(node) + ); +}; + +/** + * @internal + */ +export interface TextContent { + // Contains the full text of the node. + full: string; + // Contains the text immediately beneath the node. + immediate: string[]; +} + +/** + * Maps {@link Node}s to their computed {@link TextContent}. + */ +const textContentCache = new WeakMap<Node, TextContent>(); +const eraseFromCache = (node: Node | null) => { + while (node) { + textContentCache.delete(node); + if (node instanceof ShadowRoot) { + node = node.host; + } else { + node = node.parentNode; + } + } +}; + +/** + * Erases the cache when the tree has mutated text. + */ +const observedNodes = new WeakSet<Node>(); +const textChangeObserver = new MutationObserver(mutations => { + for (const mutation of mutations) { + eraseFromCache(mutation.target); + } +}); + +/** + * Builds the text content of a node using some custom logic. + * + * @remarks + * The primary reason this function exists is due to {@link ShadowRoot}s not having + * text content. + * + * @internal + */ +export const createTextContent = (root: Node): TextContent => { + let value = textContentCache.get(root); + if (value) { + return value; + } + value = {full: '', immediate: []}; + if (!isSuitableNodeForTextMatching(root)) { + return value; + } + + let currentImmediate = ''; + if (isNonTrivialValueNode(root)) { + value.full = root.value; + value.immediate.push(root.value); + + root.addEventListener( + 'input', + event => { + eraseFromCache(event.target as HTMLInputElement); + }, + {once: true, capture: true} + ); + } else { + for (let child = root.firstChild; child; child = child.nextSibling) { + if (child.nodeType === Node.TEXT_NODE) { + value.full += child.nodeValue ?? ''; + currentImmediate += child.nodeValue ?? ''; + continue; + } + if (currentImmediate) { + value.immediate.push(currentImmediate); + } + currentImmediate = ''; + if (child.nodeType === Node.ELEMENT_NODE) { + value.full += createTextContent(child).full; + } + } + if (currentImmediate) { + value.immediate.push(currentImmediate); + } + if (root instanceof Element && root.shadowRoot) { + value.full += createTextContent(root.shadowRoot).full; + } + + if (!observedNodes.has(root)) { + textChangeObserver.observe(root, { + childList: true, + characterData: true, + subtree: true, + }); + observedNodes.add(root); + } + } + textContentCache.set(root, value); + return value; +}; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/TextQuerySelector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/TextQuerySelector.ts new file mode 100644 index 0000000000..debc423ccf --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/TextQuerySelector.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + createTextContent, + isSuitableNodeForTextMatching, +} from './TextContent.js'; + +/** + * Queries the given node for all nodes matching the given text selector. + * + * @internal + */ +export const textQuerySelectorAll = function* ( + root: Node, + selector: string +): Generator<Element> { + let yielded = false; + for (const node of root.childNodes) { + if (node instanceof Element && isSuitableNodeForTextMatching(node)) { + let matches: Generator<Element, boolean>; + if (!node.shadowRoot) { + matches = textQuerySelectorAll(node, selector); + } else { + matches = textQuerySelectorAll(node.shadowRoot, selector); + } + for (const match of matches) { + yield match; + yielded = true; + } + } + } + if (yielded) { + return; + } + + if (root instanceof Element && isSuitableNodeForTextMatching(root)) { + const textContent = createTextContent(root); + if (textContent.full.includes(selector)) { + yield root; + } + } +}; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/XPathQuerySelector.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/XPathQuerySelector.ts new file mode 100644 index 0000000000..039bfa5e54 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/XPathQuerySelector.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @internal + */ +export const xpathQuerySelectorAll = function* ( + root: Node, + selector: string, + maxResults = -1 +): Iterable<Node> { + const doc = root.ownerDocument || document; + const iterator = doc.evaluate( + selector, + root, + null, + XPathResult.ORDERED_NODE_ITERATOR_TYPE + ); + const items = []; + let item; + + // Read all results upfront to avoid + // https://stackoverflow.com/questions/48235278/xpath-error-the-document-has-mutated-since-the-result-was-returned. + while ((item = iterator.iterateNext())) { + items.push(item); + if (maxResults && items.length === maxResults) { + break; + } + } + + for (let i = 0; i < items.length; i++) { + item = items[i]; + yield item as Node; + delete items[i]; + } +}; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/injected.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/injected.ts new file mode 100644 index 0000000000..e81d274290 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/injected.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2022 Google Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Deferred} from '../util/Deferred.js'; +import {createFunction} from '../util/Function.js'; + +import * as ARIAQuerySelector from './ARIAQuerySelector.js'; +import * as CustomQuerySelectors from './CustomQuerySelector.js'; +import * as PierceQuerySelector from './PierceQuerySelector.js'; +import {IntervalPoller, MutationPoller, RAFPoller} from './Poller.js'; +import * as PQuerySelector from './PQuerySelector.js'; +import { + createTextContent, + isSuitableNodeForTextMatching, +} from './TextContent.js'; +import * as TextQuerySelector from './TextQuerySelector.js'; +import * as util from './util.js'; +import * as XPathQuerySelector from './XPathQuerySelector.js'; + +/** + * @internal + */ +const PuppeteerUtil = Object.freeze({ + ...ARIAQuerySelector, + ...CustomQuerySelectors, + ...PierceQuerySelector, + ...PQuerySelector, + ...TextQuerySelector, + ...util, + ...XPathQuerySelector, + Deferred, + createFunction, + createTextContent, + IntervalPoller, + isSuitableNodeForTextMatching, + MutationPoller, + RAFPoller, +}); + +/** + * @internal + */ +type PuppeteerUtil = typeof PuppeteerUtil; + +/** + * @internal + */ +export default PuppeteerUtil; diff --git a/remote/test/puppeteer/packages/puppeteer-core/src/injected/util.ts b/remote/test/puppeteer/packages/puppeteer-core/src/injected/util.ts new file mode 100644 index 0000000000..34fe8f7748 --- /dev/null +++ b/remote/test/puppeteer/packages/puppeteer-core/src/injected/util.ts @@ -0,0 +1,67 @@ +const HIDDEN_VISIBILITY_VALUES = ['hidden', 'collapse']; + +/** + * @internal + */ +export const checkVisibility = ( + node: Node | null, + visible?: boolean +): Node | boolean => { + if (!node) { + return visible === false; + } + if (visible === undefined) { + return node; + } + const element = ( + node.nodeType === Node.TEXT_NODE ? node.parentElement : node + ) as Element; + + const style = window.getComputedStyle(element); + const isVisible = + style && + !HIDDEN_VISIBILITY_VALUES.includes(style.visibility) && + !isBoundingBoxEmpty(element); + return visible === isVisible ? node : false; +}; + +function isBoundingBoxEmpty(element: Element): boolean { + const rect = element.getBoundingClientRect(); + return rect.width === 0 || rect.height === 0; +} + +const hasShadowRoot = (node: Node): node is Node & {shadowRoot: ShadowRoot} => { + return 'shadowRoot' in node && node.shadowRoot instanceof ShadowRoot; +}; + +/** + * @internal + */ +export function* pierce(root: Node): IterableIterator<Node | ShadowRoot> { + if (hasShadowRoot(root)) { + yield root.shadowRoot; + } else { + yield root; + } +} + +/** + * @internal + */ +export function* pierceAll(root: Node): IterableIterator<Node | ShadowRoot> { + root = pierce(root).next().value; + yield root; + const walkers = [document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT)]; + for (const walker of walkers) { + let node: Element | null; + while ((node = walker.nextNode() as Element | null)) { + if (!node.shadowRoot) { + continue; + } + yield node.shadowRoot; + walkers.push( + document.createTreeWalker(node.shadowRoot, NodeFilter.SHOW_ELEMENT) + ); + } + } +} |