chore: implement P queries (#9639)

This commit is contained in:
jrandolf 2023-02-15 10:42:32 -08:00 committed by GitHub
parent e8f25e403f
commit 2b3cf3ace9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 638 additions and 37 deletions

View File

@ -18,8 +18,8 @@ import {Protocol} from 'devtools-protocol';
import {ElementHandle} from '../api/ElementHandle.js';
import {assert} from '../util/assert.js';
import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js';
import {CDPSession} from './Connection.js';
import {IterableUtil} from './IterableUtil.js';
import {QueryHandler, QuerySelector} from './QueryHandler.js';
import {AwaitableIterable} from './types.js';
@ -105,7 +105,7 @@ export class ARIAQueryHandler extends QueryHandler {
const {name, role} = parseARIASelector(selector);
const results = await queryAXTree(context._client, element, name, role);
const world = context._world!;
yield* IterableUtil.map(results, node => {
yield* AsyncIterableUtil.map(results, node => {
return world.adoptBackendNode(node.backendDOMNodeId) as Promise<
ElementHandle<Node>
>;
@ -116,6 +116,8 @@ export class ARIAQueryHandler extends QueryHandler {
element: ElementHandle<Node>,
selector: string
): Promise<ElementHandle<Node> | null> => {
return (await IterableUtil.first(this.queryAll(element, selector))) ?? null;
return (
(await AsyncIterableUtil.first(this.queryAll(element, selector))) ?? null
);
};
}

View File

@ -6,10 +6,10 @@ import {debugError} from './util.js';
/**
* @internal
*/
export class Binding<Params extends unknown[] = any[]> {
export class Binding {
#name: string;
#fn: (...args: Params) => unknown;
constructor(name: string, fn: (...args: Params) => unknown) {
#fn: (...args: unknown[]) => unknown;
constructor(name: string, fn: (...args: unknown[]) => unknown) {
this.#name = name;
this.#fn = fn;
}
@ -28,7 +28,7 @@ export class Binding<Params extends unknown[] = any[]> {
async run(
context: ExecutionContext,
id: number,
args: Params,
args: unknown[],
isTrivial: boolean
): Promise<void> {
const garbage = [];

View File

@ -33,7 +33,7 @@ import {Frame} from './Frame.js';
import {FrameManager} from './FrameManager.js';
import {getQueryHandlerAndSelector} from './GetQueryHandler.js';
import {WaitForSelectorOptions} from './IsolatedWorld.js';
import {IterableUtil} from './IterableUtil.js';
import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js';
import {CDPPage} from './Page.js';
import {
ElementFor,
@ -189,7 +189,7 @@ export class CDPElementHandle<
): Promise<Array<CDPElementHandle<NodeFor<Selector>>>> {
const {updatedSelector, QueryHandler} =
getQueryHandlerAndSelector(selector);
return IterableUtil.collect(
return AsyncIterableUtil.collect(
QueryHandler.queryAll(this, updatedSelector)
) as Promise<Array<CDPElementHandle<NodeFor<Selector>>>>;
}

View File

@ -15,8 +15,10 @@
*/
import {Protocol} from 'devtools-protocol';
import type {ElementHandle} from '../api/ElementHandle.js';
import {JSHandle} from '../api/JSHandle.js';
import type PuppeteerUtil from '../injected/injected.js';
import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js';
import {stringifyFunction} from '../util/Function.js';
import {ARIAQueryHandler} from './AriaQueryHandler.js';
import {Binding} from './Binding.js';
@ -78,7 +80,7 @@ export class ExecutionContext {
/**
* @internal
*/
_contextName: string;
_contextName?: string;
/**
* @internal
@ -91,7 +93,9 @@ export class ExecutionContext {
this._client = client;
this._world = world;
this._contextId = contextPayload.id;
this._contextName = contextPayload.name;
if (contextPayload.name) {
this._contextName = contextPayload.name;
}
}
#puppeteerUtil?: Promise<JSHandle<PuppeteerUtil>>;
@ -104,17 +108,30 @@ export class ExecutionContext {
}
this.#puppeteerUtil = Promise.all([
this.#installGlobalBinding(
new Binding('__ariaQuerySelector', ARIAQueryHandler.queryOne)
new Binding(
'__ariaQuerySelector',
ARIAQueryHandler.queryOne as (...args: unknown[]) => unknown
)
),
this.evaluateHandle(script) as Promise<JSHandle<PuppeteerUtil>>,
]).then(([, util]) => {
return util;
this.#installGlobalBinding(
new Binding('__ariaQuerySelectorAll', (async (
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);
return this.#puppeteerUtil as Promise<JSHandle<PuppeteerUtil>>;
}
async #installGlobalBinding(binding: Binding<any[]>) {
async #installGlobalBinding(binding: Binding) {
try {
if (this._world) {
this._world._bindings.set(binding.name, binding);

View File

@ -15,12 +15,12 @@
*/
import {ARIAQueryHandler} from './AriaQueryHandler.js';
import {PierceQueryHandler} from './PierceQueryHandler.js';
import {XPathQueryHandler} from './XPathQueryHandler.js';
import {TextQueryHandler} from './TextQueryHandler.js';
import {CSSQueryHandler} from './CSSQueryHandler.js';
import {customQueryHandlers} from './CustomQueryHandler.js';
import {PierceQueryHandler} from './PierceQueryHandler.js';
import {PQueryHandler} from './PQueryHandler.js';
import type {QueryHandler} from './QueryHandler.js';
import {TextQueryHandler} from './TextQueryHandler.js';
import {XPathQueryHandler} from './XPathQueryHandler.js';
export const BUILTIN_QUERY_HANDLERS = Object.freeze({
aria: ARIAQueryHandler,
@ -66,5 +66,5 @@ export function getQueryHandlerAndSelector(selector: string): {
}
}
}
return {updatedSelector: selector, QueryHandler: CSSQueryHandler};
return {updatedSelector: selector, QueryHandler: PQueryHandler};
}

View File

@ -19,11 +19,19 @@ import {QueryHandler, QuerySelector, QuerySelectorAll} from './QueryHandler.js';
/**
* @internal
*/
export class CSSQueryHandler extends QueryHandler {
static override querySelector: QuerySelector = (element, selector) => {
return (element as Element).querySelector(selector);
export class PQueryHandler extends QueryHandler {
static override querySelectorAll: QuerySelectorAll = (
element,
selector,
{pQuerySelectorAll}
) => {
return pQuerySelectorAll(element, selector);
};
static override querySelectorAll: QuerySelectorAll = (element, selector) => {
return (element as Element).querySelectorAll(selector);
static override querySelector: QuerySelector = (
element,
selector,
{pQuerySelector}
) => {
return pQuerySelector(element, selector);
};
}

View File

@ -100,7 +100,7 @@ export class QueryHandler {
/**
* Queries for multiple nodes given a selector and {@link ElementHandle}.
*
* Akin to {@link Window.prototype.querySelectorAll}.
* Akin to {@link Document.prototype.querySelectorAll}.
*/
static async *queryAll(
element: ElementHandle<Node>,
@ -121,7 +121,7 @@ export class QueryHandler {
/**
* Queries for a single node given a selector and {@link ElementHandle}.
*
* Akin to {@link Window.prototype.querySelector}.
* Akin to {@link Document.prototype.querySelector}.
*/
static async queryOne(
element: ElementHandle<Node>,

View File

@ -32,6 +32,11 @@ export type BindingPayload = {
isTrivial: boolean;
};
/**
* @internal
*/
export type AwaitableIterator<T> = Iterator<T> | AsyncIterator<T>;
/**
* @public
*/

View File

@ -19,13 +19,23 @@ declare global {
/**
* @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 = (
root: Node,
selector: string
): Promise<Element | null> => {
): 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);
};

View File

@ -15,10 +15,11 @@
*/
import type {CustomQueryHandler} from '../common/CustomQueryHandler.js';
import type {Awaitable, AwaitableIterable} from '../common/types.js';
export interface CustomQuerySelector {
querySelector(root: Node, selector: string): Node | null;
querySelectorAll(root: Node, selector: string): Iterable<Node>;
querySelector(root: Node, selector: string): Awaitable<Node | null>;
querySelectorAll(root: Node, selector: string): AwaitableIterable<Node>;
}
/**

View 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;
};

View 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;
}

View File

@ -25,6 +25,7 @@ import {
isSuitableNodeForTextMatching,
} from './TextContent.js';
import * as TextQuerySelector from './TextQuerySelector.js';
import * as PQuerySelector from './PQuerySelector.js';
import * as util from './util.js';
import * as XPathQuerySelector from './XPathQuerySelector.js';
@ -35,6 +36,7 @@ const PuppeteerUtil = Object.freeze({
...ARIAQuerySelector,
...CustomQuerySelectors,
...PierceQuerySelector,
...PQuerySelector,
...TextQuerySelector,
...util,
...XPathQuerySelector,

View File

@ -29,3 +29,42 @@ function isBoundingBoxEmpty(element: Element): boolean {
const rect = element.getBoundingClientRect();
return rect.width === 0 || rect.height === 0;
}
/**
* @internal
*/
export function* deepChildren(
root: Node
): IterableIterator<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;
}
}
}

View File

@ -13,22 +13,30 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type {AwaitableIterable} from './types.js';
import {AwaitableIterable} from '../common/types.js';
/**
* @internal
*/
export class IterableUtil {
export class AsyncIterableUtil {
static async *map<T, U>(
iterable: AwaitableIterable<T>,
map: (item: T) => Promise<U>
): AwaitableIterable<U> {
): AsyncIterable<U> {
for await (const value of iterable) {
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[]> {
const result = [];
for await (const value of iterable) {
@ -43,6 +51,6 @@ export class IterableUtil {
for await (const value of iterable) {
return value;
}
return undefined;
return;
}
}

View File

@ -18,3 +18,4 @@ export * from './assert.js';
export * from './DebuggableDeferredPromise.js';
export * from './DeferredPromise.js';
export * from './ErrorLike.js';
export * from './AsyncIterableUtil.js';

View File

@ -1810,5 +1810,17 @@
"platforms": ["darwin", "linux", "win32"],
"parameters": ["webDriverBiDi"],
"expectations": ["FAIL"]
},
{
"testIdPattern": "[queryhandler.spec] Query handler tests P selectors should work ARIA selectors",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["firefox"],
"expectations": ["FAIL"]
},
{
"testIdPattern": "[queryhandler.spec]",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["webDriverBiDi"],
"expectations": ["SKIP", "FAIL"]
}
]

View File

@ -13,7 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import assert from 'assert';
import expect from 'expect';
import {Puppeteer} from 'puppeteer-core';
import {ElementHandle} from 'puppeteer-core/internal/api/ElementHandle.js';
import {
getTestState,
@ -351,4 +353,147 @@ describe('Query handler tests', function () {
});
});
});
describe('P selectors', () => {
beforeEach(async () => {
const {page} = getTestState();
await page.setContent('<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'});
});
});
});