summaryrefslogtreecommitdiffstats
path: root/remote/test/puppeteer/packages/puppeteer-core/src/injected
diff options
context:
space:
mode:
Diffstat (limited to 'remote/test/puppeteer/packages/puppeteer-core/src/injected')
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/injected/ARIAQuerySelector.ts31
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/injected/CustomQuerySelector.ts59
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/injected/PQuerySelector.ts298
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/injected/PSelectorParser.ts105
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/injected/PierceQuerySelector.ts65
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/injected/Poller.ts168
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/injected/TextContent.ts146
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/injected/TextQuerySelector.ts46
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/injected/XPathQuerySelector.ts39
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/injected/injected.ts51
-rw-r--r--remote/test/puppeteer/packages/puppeteer-core/src/injected/util.ts67
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)
+ );
+ }
+ }
+}