Skip to content

Commit

Permalink
feat: add text query handler (#8956)
Browse files Browse the repository at this point in the history
  • Loading branch information
jrandolf committed Sep 15, 2022
1 parent 42cd6d0 commit 633e7cf
Show file tree
Hide file tree
Showing 6 changed files with 373 additions and 30 deletions.
3 changes: 2 additions & 1 deletion src/common/IsolatedWorld.ts
Expand Up @@ -518,7 +518,8 @@ export class IsolatedWorld {
}
const node = (await PuppeteerUtil.createFunction(query)(
root || document,
selector
selector,
PuppeteerUtil
)) as Node | null;
return PuppeteerUtil.checkVisibility(node, visible);
},
Expand Down
91 changes: 88 additions & 3 deletions src/common/QueryHandler.ts
Expand Up @@ -14,6 +14,7 @@
* limitations under the License.
*/

import PuppeteerUtil from '../injected/injected.js';
import {ariaHandler} from './AriaQueryHandler.js';
import {ElementHandle} from './ElementHandle.js';
import {Frame} from './Frame.js';
Expand All @@ -37,6 +38,28 @@ export interface CustomQueryHandler {
queryAll?: (node: Node, selector: string) => Node[];
}

/**
* @internal
*/
export interface InternalQueryHandler {
/**
* @returns A {@link Node} matching the given `selector` from {@link node}.
*/
queryOne?: (
node: Node,
selector: string,
PuppeteerUtil: PuppeteerUtil
) => Node | null;
/**
* @returns Some {@link Node}s matching the given `selector` from {@link node}.
*/
queryAll?: (
node: Node,
selector: string,
PuppeteerUtil: PuppeteerUtil
) => Node[];
}

/**
* @internal
*/
Expand Down Expand Up @@ -72,14 +95,18 @@ export interface PuppeteerQueryHandler {
}

function createPuppeteerQueryHandler(
handler: CustomQueryHandler
handler: InternalQueryHandler
): PuppeteerQueryHandler {
const internalHandler: PuppeteerQueryHandler = {};

if (handler.queryOne) {
const queryOne = handler.queryOne;
internalHandler.queryOne = async (element, selector) => {
const jsHandle = await element.evaluateHandle(queryOne, selector);
const jsHandle = await element.evaluateHandle(
queryOne,
selector,
await element.executionContext()._world!.puppeteerUtil
);
const elementHandle = jsHandle.asElement();
if (elementHandle) {
return elementHandle;
Expand Down Expand Up @@ -121,7 +148,11 @@ function createPuppeteerQueryHandler(
if (handler.queryAll) {
const queryAll = handler.queryAll;
internalHandler.queryAll = async (element, selector) => {
const jsHandle = await element.evaluateHandle(queryAll, selector);
const jsHandle = await element.evaluateHandle(
queryAll,
selector,
await element.executionContext()._world!.puppeteerUtil
);
const properties = await jsHandle.getProperties();
await jsHandle.dispose();
const result = [];
Expand Down Expand Up @@ -244,6 +275,59 @@ const xpathHandler = createPuppeteerQueryHandler({
},
});

const textQueryHandler = createPuppeteerQueryHandler({
queryOne: (element, selector, {createTextContent}) => {
const search = (root: Node): Node | null => {
for (const node of root.childNodes) {
if (node instanceof Element) {
let matchedNode: Node | null;
if (node.shadowRoot) {
matchedNode = search(node.shadowRoot);
} else {
matchedNode = search(node);
}
if (matchedNode) {
return matchedNode;
}
}
}
const textContent = createTextContent(root);
if (textContent.full.includes(selector)) {
return root;
}
return null;
};
return search(element);
},

queryAll: (element, selector, {createTextContent}) => {
const search = (root: Node): Node[] => {
let results: Node[] = [];
for (const node of root.childNodes) {
if (node instanceof Element) {
let matchedNodes: Node[];
if (node.shadowRoot) {
matchedNodes = search(node.shadowRoot);
} else {
matchedNodes = search(node);
}
results = results.concat(matchedNodes);
}
}
if (results.length > 0) {
return results;
}

const textContent = createTextContent(root);
if (textContent.full.includes(selector)) {
return [root];
}
return [];
};
return search(element);
},
});

interface RegisteredQueryHandler {
handler: PuppeteerQueryHandler;
transformSelector?: (selector: string) => string;
Expand All @@ -253,6 +337,7 @@ const INTERNAL_QUERY_HANDLERS = new Map<string, RegisteredQueryHandler>([
['aria', {handler: ariaHandler}],
['pierce', {handler: pierceHandler}],
['xpath', {handler: xpathHandler}],
['text', {handler: textQueryHandler}],
]);
const QUERY_HANDLERS = new Map<string, RegisteredQueryHandler>();

Expand Down
98 changes: 98 additions & 0 deletions src/injected/TextContent.ts
@@ -0,0 +1,98 @@
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.
*/
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.
*/
const isSuitableNodeForTextMatching = (node: Node): boolean => {
return (
!UNSUITABLE_NODE_NAMES.has(node.nodeName) && !document.head?.contains(node)
);
};

/**
* @internal
*/
export type 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 Map<Node, TextContent>();

/**
* 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);
} 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;
}
}
textContentCache.set(root, value);
return value;
};
2 changes: 2 additions & 0 deletions src/injected/injected.ts
@@ -1,10 +1,12 @@
import {createDeferredPromise} from '../util/DeferredPromise.js';
import * as util from './util.js';
import * as Poller from './Poller.js';
import * as TextContent from './TextContent.js';

const PuppeteerUtil = Object.freeze({
...util,
...Poller,
...TextContent,
createDeferredPromise,
});

Expand Down
82 changes: 56 additions & 26 deletions test/src/page.spec.ts
Expand Up @@ -545,39 +545,69 @@ describe('Page', function () {
it('should work', async () => {
const {page} = getTestState();

// Instantiate an object
await page.evaluate(() => {
return ((globalThis as any).set = new Set(['hello', 'world']));
});
const prototypeHandle = await page.evaluateHandle(() => {
return Set.prototype;
// Create a custom class
const classHandle = await page.evaluateHandle(() => {
return class CustomClass {};
});

// Create an instance.
await page.evaluate(CustomClass => {
// @ts-expect-error: Different context.
self.customClass = new CustomClass();
}, classHandle);

// Validate only one has been added.
const prototypeHandle = await page.evaluateHandle(CustomClass => {
return CustomClass.prototype;
}, classHandle);
const objectsHandle = await page.queryObjects(prototypeHandle);
const count = await page.evaluate(objects => {
return objects.length;
}, objectsHandle);
expect(count).toBe(1);
const values = await page.evaluate(objects => {
return Array.from(objects[0]!.values());
}, objectsHandle);
expect(values).toEqual(['hello', 'world']);
await expect(
page.evaluate(objects => {
return objects.length;
}, objectsHandle)
).resolves.toBe(1);

// Check that instances.
await expect(
page.evaluate(objects => {
// @ts-expect-error: Different context.
return objects[0] === self.customClass;
}, objectsHandle)
).resolves.toBeTruthy();
});
it('should work for non-blank page', async () => {
it('should work for non-trivial page', async () => {
const {page, server} = getTestState();

// Instantiate an object
await page.goto(server.EMPTY_PAGE);
await page.evaluate(() => {
return ((globalThis as any).set = new Set(['hello', 'world']));
});
const prototypeHandle = await page.evaluateHandle(() => {
return Set.prototype;

// Create a custom class
const classHandle = await page.evaluateHandle(() => {
return class CustomClass {};
});

// Create an instance.
await page.evaluate(CustomClass => {
// @ts-expect-error: Different context.
self.customClass = new CustomClass();
}, classHandle);

// Validate only one has been added.
const prototypeHandle = await page.evaluateHandle(CustomClass => {
return CustomClass.prototype;
}, classHandle);
const objectsHandle = await page.queryObjects(prototypeHandle);
const count = await page.evaluate(objects => {
return objects.length;
}, objectsHandle);
expect(count).toBe(1);
await expect(
page.evaluate(objects => {
return objects.length;
}, objectsHandle)
).resolves.toBe(1);

// Check that instances.
await expect(
page.evaluate(objects => {
// @ts-expect-error: Different context.
return objects[0] === self.customClass;
}, objectsHandle)
).resolves.toBeTruthy();
});
it('should fail for disposed handles', async () => {
const {page} = getTestState();
Expand Down

0 comments on commit 633e7cf

Please sign in to comment.