feat(text selector): match text in child nodes (#5293)

This changes `text=` and `:text()` selectors to match the element when:
- it's combined text content matches the text;
- combined text content of any immediate child does not match the text.

This allows the following markup to match "Some bold and italics text":
`<div>Some <b>bold</b> and <i>italics</i> text</div>`.

For the reference, "combined text content" is almost equal to `element.textContent`,
but with some changes like using value of `<input type=button>` or ignoring `<head>`.

This also includes some caching optimizations, meaningful in complex matches
that involve multiple calls to the text engine.

Performance changes (measured on large page with ~25000 elements):
- `:has-text()` - 14% faster.
- `text=` - 50% faster.
- `:text()` - 0-35% slower.
- `:text-matches()` - 28% slower.
This commit is contained in:
Dmitry Gozman 2021-02-04 17:44:55 -08:00 committed by GitHub
parent c1b08f1a8c
commit 0cbb2c14e6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 196 additions and 212 deletions

View file

@ -15,11 +15,10 @@
*/
import { SelectorEngine, SelectorRoot } from './selectorEngine';
import { createTextSelector } from './textSelectorEngine';
import { XPathEngine } from './xpathSelectorEngine';
import { ParsedSelector, ParsedSelectorPart, parseSelector } from '../common/selectorParser';
import { FatalDOMError } from '../common/domErrors';
import { SelectorEvaluatorImpl, isVisible, parentElementOrShadowHost } from './selectorEvaluator';
import { SelectorEvaluatorImpl, isVisible, parentElementOrShadowHost, elementMatchesText } from './selectorEvaluator';
import { CSSComplexSelectorList } from '../common/cssParser';
type Predicate<T> = (progress: InjectedScriptProgress, continuePolling: symbol) => T | symbol;
@ -47,8 +46,8 @@ export class InjectedScript {
this._enginesV1 = new Map();
this._enginesV1.set('xpath', XPathEngine);
this._enginesV1.set('xpath:light', XPathEngine);
this._enginesV1.set('text', createTextSelector(true));
this._enginesV1.set('text:light', createTextSelector(false));
this._enginesV1.set('text', this._createTextEngine(true));
this._enginesV1.set('text:light', this._createTextEngine(false));
this._enginesV1.set('id', this._createAttributeEngine('id', true));
this._enginesV1.set('id:light', this._createAttributeEngine('id', false));
this._enginesV1.set('data-testid', this._createAttributeEngine('data-testid', true));
@ -76,7 +75,9 @@ export class InjectedScript {
querySelector(selector: ParsedSelector, root: Node): Element | undefined {
if (!(root as any)['querySelector'])
throw new Error('Node is not queryable.');
return this._querySelectorRecursively(root as SelectorRoot, selector, 0);
const result = this._querySelectorRecursively(root as SelectorRoot, selector, 0);
this._evaluator.clearCaches();
return result;
}
private _querySelectorRecursively(root: SelectorRoot, selector: ParsedSelector, index: number): Element | undefined {
@ -111,22 +112,24 @@ export class InjectedScript {
}
set = newSet;
}
const candidates = Array.from(set) as Element[];
if (!partsToCheckOne.length)
return candidates;
const partial = { parts: partsToCheckOne };
return candidates.filter(e => !!this._querySelectorRecursively(e, partial, 0));
let result = Array.from(set) as Element[];
if (partsToCheckOne.length) {
const partial = { parts: partsToCheckOne };
result = result.filter(e => !!this._querySelectorRecursively(e, partial, 0));
}
this._evaluator.clearCaches();
return result;
}
private _queryEngine(part: ParsedSelectorPart, root: SelectorRoot): Element | undefined {
if (Array.isArray(part))
return this._evaluator.evaluate({ scope: root as Document | Element, pierceShadow: true }, part)[0];
return this._evaluator.query({ scope: root as Document | Element, pierceShadow: true }, part)[0];
return this._enginesV1.get(part.name)!.query(root, part.body);
}
private _queryEngineAll(part: ParsedSelectorPart, root: SelectorRoot): Element[] {
if (Array.isArray(part))
return this._evaluator.evaluate({ scope: root as Document | Element, pierceShadow: true }, part);
return this._evaluator.query({ scope: root as Document | Element, pierceShadow: true }, part);
return this._enginesV1.get(part.name)!.queryAll(root, part.body);
}
@ -137,10 +140,33 @@ export class InjectedScript {
};
return {
query: (root: SelectorRoot, selector: string): Element | undefined => {
return this._evaluator.evaluate({ scope: root as Document | Element, pierceShadow: shadow }, toCSS(selector))[0];
return this._evaluator.query({ scope: root as Document | Element, pierceShadow: shadow }, toCSS(selector))[0];
},
queryAll: (root: SelectorRoot, selector: string): Element[] => {
return this._evaluator.evaluate({ scope: root as Document | Element, pierceShadow: shadow }, toCSS(selector));
return this._evaluator.query({ scope: root as Document | Element, pierceShadow: shadow }, toCSS(selector));
}
};
}
private _createTextEngine(shadow: boolean): SelectorEngine {
return {
query: (root: SelectorRoot, selector: string): Element | undefined => {
const matcher = createTextMatcher(selector);
if (root.nodeType === Node.ELEMENT_NODE && elementMatchesText(this._evaluator, root as Element, matcher))
return root as Element;
const elements = this._evaluator._queryCSS({ scope: root as Document | Element, pierceShadow: shadow }, '*');
for (const element of elements) {
if (elementMatchesText(this._evaluator, element, matcher))
return element;
}
},
queryAll: (root: SelectorRoot, selector: string): Element[] => {
const matcher = createTextMatcher(selector);
const elements = this._evaluator._queryCSS({ scope: root as Document | Element, pierceShadow: shadow }, '*');
const result = elements.filter(e => elementMatchesText(this._evaluator, e, matcher));
if (root.nodeType === Node.ELEMENT_NODE && elementMatchesText(this._evaluator, root as Element, matcher))
result.unshift(root as Element);
return result;
}
};
}
@ -776,4 +802,44 @@ const eventType = new Map<string, 'mouse'|'keyboard'|'touch'|'pointer'|'focus'|'
['drop', 'drag'],
]);
function unescape(s: string): string {
if (!s.includes('\\'))
return s;
const r: string[] = [];
let i = 0;
while (i < s.length) {
if (s[i] === '\\' && i + 1 < s.length)
i++;
r.push(s[i++]);
}
return r.join('');
}
type Matcher = (text: string) => boolean;
function createTextMatcher(selector: string): Matcher {
if (selector[0] === '/' && selector.lastIndexOf('/') > 0) {
const lastSlash = selector.lastIndexOf('/');
const re = new RegExp(selector.substring(1, lastSlash), selector.substring(lastSlash + 1));
return text => re.test(text);
}
let strict = false;
if (selector.length > 1 && selector[0] === '"' && selector[selector.length - 1] === '"') {
selector = unescape(selector.substring(1, selector.length - 1));
strict = true;
}
if (selector.length > 1 && selector[0] === "'" && selector[selector.length - 1] === "'") {
selector = unescape(selector.substring(1, selector.length - 1));
strict = true;
}
selector = selector.trim().replace(/\s+/g, ' ');
if (!strict)
selector = selector.toLowerCase();
return text => {
text = text.trim().replace(/\s+/g, ' ');
if (!strict)
return text.toLowerCase().includes(selector);
return text === selector;
};
}
export default InjectedScript;

View file

@ -43,6 +43,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
private _cacheCallMatches: QueryCache = new Map();
private _cacheCallQuery: QueryCache = new Map();
private _cacheQuerySimple: QueryCache = new Map();
_cacheText = new Map<Element | ShadowRoot, string>();
private _scoreMap: Map<Element, number> | undefined;
constructor(extraEngines: Map<string, SelectorEngine>) {
@ -74,10 +75,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
throw new Error(`Please keep customCSSNames in sync with evaluator engines`);
}
// This is the only function we should use for querying, because it does
// the right thing with caching.
evaluate(context: QueryContext, s: CSSComplexSelectorList): Element[] {
const result = this.query(context, s);
clearCaches() {
this._cacheQueryCSS.clear();
this._cacheMatches.clear();
this._cacheQuery.clear();
@ -86,7 +84,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
this._cacheCallMatches.clear();
this._cacheCallQuery.clear();
this._cacheQuerySimple.clear();
return result;
this._cacheText.clear();
}
private _cached<T>(cache: QueryCache, main: any, rest: any[], cb: () => T): T {
@ -411,7 +409,8 @@ const textEngine: SelectorEngine = {
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
if (args.length !== 1 || typeof args[0] !== 'string')
throw new Error(`"text" engine expects a single string`);
return elementMatchesText(element, context, textMatcher(args[0], true));
const matcher = textMatcher(args[0], true);
return elementMatchesText(evaluator as SelectorEvaluatorImpl, element, matcher);
},
};
@ -419,7 +418,8 @@ const textIsEngine: SelectorEngine = {
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
if (args.length !== 1 || typeof args[0] !== 'string')
throw new Error(`"text-is" engine expects a single string`);
return elementMatchesText(element, context, textMatcher(args[0], false));
const matcher = textMatcher(args[0], false);
return elementMatchesText(evaluator as SelectorEvaluatorImpl, element, matcher);
},
};
@ -428,7 +428,8 @@ const textMatchesEngine: SelectorEngine = {
if (args.length === 0 || typeof args[0] !== 'string' || args.length > 2 || (args.length === 2 && typeof args[1] !== 'string'))
throw new Error(`"text-matches" engine expects a regexp body and optional regexp flags`);
const re = new RegExp(args[0], args.length === 2 ? args[1] : undefined);
return elementMatchesText(element, context, s => re.test(s));
const matcher = (s: string) => re.test(s);
return elementMatchesText(evaluator as SelectorEvaluatorImpl, element, matcher);
},
};
@ -439,7 +440,7 @@ const hasTextEngine: SelectorEngine = {
if (shouldSkipForTextMatching(element))
return false;
const matcher = textMatcher(args[0], true);
return matcher(element.textContent || '');
return matcher(elementText(evaluator as SelectorEvaluatorImpl, element));
},
};
@ -453,26 +454,45 @@ function textMatcher(text: string, substring: boolean): (s: string) => boolean {
};
}
function shouldSkipForTextMatching(element: Element) {
function shouldSkipForTextMatching(element: Element | ShadowRoot) {
return element.nodeName === 'SCRIPT' || element.nodeName === 'STYLE' || document.head && document.head.contains(element);
}
function elementMatchesText(element: Element, context: QueryContext, matcher: (s: string) => boolean) {
function elementText(evaluator: SelectorEvaluatorImpl, root: Element | ShadowRoot): string {
let value = evaluator._cacheText.get(root);
if (value === undefined) {
value = '';
if (!shouldSkipForTextMatching(root)) {
if ((root instanceof HTMLInputElement) && (root.type === 'submit' || root.type === 'button')) {
value = root.value;
} else {
for (let child = root.firstChild; child; child = child.nextSibling) {
if (child.nodeType === Node.ELEMENT_NODE)
value += elementText(evaluator, child as Element);
else if (child.nodeType === Node.TEXT_NODE)
value += child.nodeValue || '';
}
if ((root as Element).shadowRoot)
value += elementText(evaluator, (root as Element).shadowRoot!);
}
}
evaluator._cacheText.set(root, value);
}
return value;
}
export function elementMatchesText(evaluator: SelectorEvaluatorImpl, element: Element, matcher: (s: string) => boolean): boolean {
if (shouldSkipForTextMatching(element))
return false;
if ((element instanceof HTMLInputElement) && (element.type === 'submit' || element.type === 'button') && matcher(element.value))
return true;
let lastText = '';
if (!matcher(elementText(evaluator, element)))
return false;
for (let child = element.firstChild; child; child = child.nextSibling) {
if (child.nodeType === 3 /* Node.TEXT_NODE */) {
lastText += child.nodeValue;
} else {
if (lastText && matcher(lastText))
return true;
lastText = '';
}
if (child.nodeType === Node.ELEMENT_NODE && matcher(elementText(evaluator, child as Element)))
return false;
}
return !!lastText && matcher(lastText);
if (element.shadowRoot && matcher(elementText(evaluator, element.shadowRoot)))
return false;
return true;
}
function boxRightOf(box1: DOMRect, box2: DOMRect): number | undefined {

View file

@ -1,166 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* 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.
*/
import { SelectorEngine, SelectorRoot } from './selectorEngine';
export function createTextSelector(shadow: boolean): SelectorEngine {
const engine: SelectorEngine = {
query(root: SelectorRoot, selector: string): Element | undefined {
return queryInternal(root, createMatcher(selector), shadow);
},
queryAll(root: SelectorRoot, selector: string): Element[] {
const result: Element[] = [];
queryAllInternal(root, createMatcher(selector), shadow, result);
return result;
}
};
return engine;
}
function unescape(s: string): string {
if (!s.includes('\\'))
return s;
const r: string[] = [];
let i = 0;
while (i < s.length) {
if (s[i] === '\\' && i + 1 < s.length)
i++;
r.push(s[i++]);
}
return r.join('');
}
type Matcher = (text: string) => boolean;
function createMatcher(selector: string): Matcher {
if (selector[0] === '/' && selector.lastIndexOf('/') > 0) {
const lastSlash = selector.lastIndexOf('/');
const re = new RegExp(selector.substring(1, lastSlash), selector.substring(lastSlash + 1));
return text => re.test(text);
}
let strict = false;
if (selector.length > 1 && selector[0] === '"' && selector[selector.length - 1] === '"') {
selector = unescape(selector.substring(1, selector.length - 1));
strict = true;
}
if (selector.length > 1 && selector[0] === "'" && selector[selector.length - 1] === "'") {
selector = unescape(selector.substring(1, selector.length - 1));
strict = true;
}
selector = selector.trim().replace(/\s+/g, ' ');
if (!strict)
selector = selector.toLowerCase();
return text => {
text = text.trim().replace(/\s+/g, ' ');
if (!strict)
return text.toLowerCase().includes(selector);
return text === selector;
};
}
// Skips <head>, <script> and <style> elements and all their children.
const nodeFilter: NodeFilter = {
acceptNode: node => {
return node.nodeName === 'HEAD' || node.nodeName === 'SCRIPT' || node.nodeName === 'STYLE' ?
NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
}
};
// If we are querying inside a filtered element, nodeFilter is never called, so we need a separate check.
function isFilteredNode(root: SelectorRoot, document: Document) {
return root.nodeName === 'SCRIPT' || root.nodeName === 'STYLE' || document.head && document.head.contains(root);
}
function queryInternal(root: SelectorRoot, matcher: Matcher, shadow: boolean): Element | undefined {
const document = root instanceof Document ? root : root.ownerDocument;
if (isFilteredNode(root, document))
return;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, nodeFilter);
const shadowRoots: ShadowRoot[] = [];
if (shadow && (root as Element).shadowRoot)
shadowRoots.push((root as Element).shadowRoot!);
let lastTextParent: Element | null = null;
let lastText = '';
while (true) {
const node = walker.nextNode();
const textParent = (node && node.nodeType === Node.TEXT_NODE) ? node.parentElement : null;
if (lastTextParent && textParent !== lastTextParent) {
if (matcher(lastText))
return lastTextParent;
lastText = '';
}
lastTextParent = textParent;
if (!node)
break;
if (node.nodeType === Node.TEXT_NODE) {
lastText += node.nodeValue;
} else {
const element = node as Element;
if ((element instanceof HTMLInputElement) && (element.type === 'submit' || element.type === 'button') && matcher(element.value))
return element;
if (shadow && element.shadowRoot)
shadowRoots.push(element.shadowRoot);
}
}
for (const shadowRoot of shadowRoots) {
const element = queryInternal(shadowRoot, matcher, shadow);
if (element)
return element;
}
}
function queryAllInternal(root: SelectorRoot, matcher: Matcher, shadow: boolean, result: Element[]) {
const document = root instanceof Document ? root : root.ownerDocument;
if (isFilteredNode(root, document))
return;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, nodeFilter);
const shadowRoots: ShadowRoot[] = [];
if (shadow && (root as Element).shadowRoot)
shadowRoots.push((root as Element).shadowRoot!);
let lastTextParent: Element | null = null;
let lastText = '';
while (true) {
const node = walker.nextNode();
const textParent = (node && node.nodeType === Node.TEXT_NODE) ? node.parentElement : null;
if (lastTextParent && textParent !== lastTextParent) {
if (matcher(lastText))
result.push(lastTextParent);
lastText = '';
}
lastTextParent = textParent;
if (!node)
break;
if (node.nodeType === Node.TEXT_NODE) {
lastText += node.nodeValue;
} else {
const element = node as Element;
if ((element instanceof HTMLInputElement) && (element.type === 'submit' || element.type === 'button') && matcher(element.value))
result.push(element);
if (shadow && element.shadowRoot)
shadowRoots.push(element.shadowRoot);
}
}
for (const shadowRoot of shadowRoots)
queryAllInternal(shadowRoot, matcher, shadow, result);
}

View file

@ -34,13 +34,13 @@ it('should work', async ({page}) => {
expect(await page.$eval(`text=/^\\s*heLLo/i`, e => e.outerHTML)).toBe('<div> hello world! </div>');
await page.setContent(`<div>yo<div>ya</div>hey<div>hey</div></div>`);
expect(await page.$eval(`text=hey`, e => e.outerHTML)).toBe('<div>yo<div>ya</div>hey<div>hey</div></div>');
expect(await page.$eval(`text="yo">>text="ya"`, e => e.outerHTML)).toBe('<div>ya</div>');
expect(await page.$eval(`text='yo'>> text="ya"`, e => e.outerHTML)).toBe('<div>ya</div>');
expect(await page.$eval(`text="yo" >>text='ya'`, e => e.outerHTML)).toBe('<div>ya</div>');
expect(await page.$eval(`text='yo' >> text='ya'`, e => e.outerHTML)).toBe('<div>ya</div>');
expect(await page.$eval(`'yo'>>"ya"`, e => e.outerHTML)).toBe('<div>ya</div>');
expect(await page.$eval(`"yo" >> 'ya'`, e => e.outerHTML)).toBe('<div>ya</div>');
expect(await page.$eval(`text=hey`, e => e.outerHTML)).toBe('<div>hey</div>');
expect(await page.$eval(`text=yo>>text="ya"`, e => e.outerHTML)).toBe('<div>ya</div>');
expect(await page.$eval(`text=yo>> text="ya"`, e => e.outerHTML)).toBe('<div>ya</div>');
expect(await page.$eval(`text=yo >>text='ya'`, e => e.outerHTML)).toBe('<div>ya</div>');
expect(await page.$eval(`text=yo >> text='ya'`, e => e.outerHTML)).toBe('<div>ya</div>');
expect(await page.$eval(`'yoyaheyhey'>>"ya"`, e => e.outerHTML)).toBe('<div>ya</div>');
expect(await page.$eval(`"yoyaheyhey" >> 'ya'`, e => e.outerHTML)).toBe('<div>ya</div>');
await page.setContent(`<div>yo<span id="s1"></span></div><div>yo<span id="s2"></span><span id="s3"></span></div>`);
expect(await page.$$eval(`text=yo`, es => es.map(e => e.outerHTML).join('\n'))).toBe('<div>yo<span id="s1"></span></div>\n<div>yo<span id="s2"></span><span id="s3"></span></div>');
@ -78,10 +78,12 @@ it('should work', async ({page}) => {
await page.setContent(`<div>a<br>b</div><div>a</div>`);
expect(await page.$eval(`text=a`, e => e.outerHTML)).toBe('<div>a<br>b</div>');
expect(await page.$eval(`text=b`, e => e.outerHTML)).toBe('<div>a<br>b</div>');
expect(await page.$(`text=ab`)).toBe(null);
expect(await page.$eval(`text=ab`, e => e.outerHTML)).toBe('<div>a<br>b</div>');
expect(await page.$(`text=abc`)).toBe(null);
expect(await page.$$eval(`text=a`, els => els.length)).toBe(2);
expect(await page.$$eval(`text=b`, els => els.length)).toBe(1);
expect(await page.$$eval(`text=ab`, els => els.length)).toBe(0);
expect(await page.$$eval(`text=ab`, els => els.length)).toBe(1);
expect(await page.$$eval(`text=abc`, els => els.length)).toBe(0);
await page.setContent(`<div></div><span></span>`);
await page.$eval('div', div => {
@ -127,6 +129,64 @@ it('should work with :text', async ({page}) => {
expect(error2.message).toContain(`"text" engine expects a single string`);
});
it('should work across nodes', async ({page}) => {
await page.setContent(`<div id=target1>Hello<i>,</i> <span id=target2>world</span><b>!</b></div>`);
expect(await page.$eval(`:text("Hello, world!")`, e => e.id)).toBe('target1');
expect(await page.$eval(`:text("Hello")`, e => e.id)).toBe('target1');
expect(await page.$eval(`:text("world")`, e => e.id)).toBe('target2');
expect(await page.$$eval(`:text("world")`, els => els.length)).toBe(1);
expect(await page.$(`:text("hello world")`)).toBe(null);
expect(await page.$(`div:text("world")`)).toBe(null);
expect(await page.$eval(`text=Hello, world!`, e => e.id)).toBe('target1');
expect(await page.$eval(`text=Hello`, e => e.id)).toBe('target1');
expect(await page.$eval(`text=world`, e => e.id)).toBe('target2');
expect(await page.$$eval(`text=world`, els => els.length)).toBe(1);
expect(await page.$(`text=hello world`)).toBe(null);
expect(await page.$eval(`:text-is("Hello, world!")`, e => e.id)).toBe('target1');
expect(await page.$(`:text-is("Hello")`)).toBe(null);
expect(await page.$eval(`:text-is("world")`, e => e.id)).toBe('target2');
expect(await page.$$eval(`:text-is("world")`, els => els.length)).toBe(1);
expect(await page.$eval(`text="Hello, world!"`, e => e.id)).toBe('target1');
expect(await page.$(`text="Hello"`)).toBe(null);
expect(await page.$eval(`text="world"`, e => e.id)).toBe('target2');
expect(await page.$$eval(`text="world"`, els => els.length)).toBe(1);
expect(await page.$eval(`:text-matches(".*")`, e => e.nodeName)).toBe('I');
expect(await page.$eval(`:text-matches("world?")`, e => e.id)).toBe('target2');
expect(await page.$$eval(`:text-matches("world")`, els => els.length)).toBe(1);
expect(await page.$(`div:text(".*")`)).toBe(null);
expect(await page.$eval(`text=/.*/`, e => e.nodeName)).toBe('I');
expect(await page.$eval(`text=/world?/`, e => e.id)).toBe('target2');
expect(await page.$$eval(`text=/world/`, els => els.length)).toBe(1);
});
it('should clear caches', async ({page}) => {
await page.setContent(`<div id=target1>text</div><div id=target2>text</div>`);
const div = await page.$('#target1');
await div.evaluate(div => div.textContent = 'text');
expect(await page.$eval(`text=text`, e => e.id)).toBe('target1');
await div.evaluate(div => div.textContent = 'foo');
expect(await page.$eval(`text=text`, e => e.id)).toBe('target2');
await div.evaluate(div => div.textContent = 'text');
expect(await page.$eval(`:text("text")`, e => e.id)).toBe('target1');
await div.evaluate(div => div.textContent = 'foo');
expect(await page.$eval(`:text("text")`, e => e.id)).toBe('target2');
await div.evaluate(div => div.textContent = 'text');
expect(await page.$$eval(`text=text`, els => els.length)).toBe(2);
await div.evaluate(div => div.textContent = 'foo');
expect(await page.$$eval(`text=text`, els => els.length)).toBe(1);
await div.evaluate(div => div.textContent = 'text');
expect(await page.$$eval(`:text("text")`, els => els.length)).toBe(2);
await div.evaluate(div => div.textContent = 'foo');
expect(await page.$$eval(`:text("text")`, els => els.length)).toBe(1);
});
it('should work with :has-text', async ({page}) => {
await page.setContent(`
<input id=input2>
@ -154,7 +214,7 @@ it('should work with :has-text', async ({page}) => {
expect(error2.message).toContain(`"has-text" engine expects a single string`);
});
it(':text and :has-text should work with large DOM', async ({page}) => {
it('should work with large DOM', async ({page}) => {
await page.evaluate(() => {
let id = 0;
const next = (tag: string) => {
@ -185,6 +245,10 @@ it(':text and :has-text should work with large DOM', async ({page}) => {
':text("id18")',
':text("id12345")',
':text("id")',
':text-matches("id12345", "i")',
'text=id18',
'text=id12345',
'text=id',
'#id18',
'#id12345',
'*',