Skip to content

Commit b67e022

Browse files
authored
feat(selectors): update new text selector (#4654)
We now default to `text` that does substring case-insensitive match with normalized whitespace. `text-is` matches the whole string. `matches-text` is renamed to `text-matches`.
1 parent aacd8e6 commit b67e022

File tree

4 files changed

+56
-35
lines changed

4 files changed

+56
-35
lines changed

docs/selectors.md

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -194,14 +194,11 @@ Use `:visible` with caution, because it has two major drawbacks:
194194

195195
The `:text` pseudo-class matches elements that have a text node child with specific text. It is similar to the [text engine](#text-and-textlight). There are a few variations that support different arguments:
196196

197-
* `:text("exact match")` - Only matches when element's text exactly equals to passed string.
198-
* `:text("substring", "g")` - Matches when element's text contains "substring" somewhere.
199-
* `:text("String", "i")` - Performs case-insensitive match.
200-
* `:text("string with spaces", "s")` - Normalizes whitespace when matching, for example turns multiple spaces into one and line breaks into spaces.
201-
* `:text("substring", "sgi")` - Different flags may be combined. For example, pass `"sgi"` to match by case-insensitive substring with normalized whitespace.
197+
* `:text("substring")` - Matches when element's text contains "substring" somewhere. Matching is case-insensitive. Matching also normalizes whitespace, for example it turns multiple spaces into one, trusn line breaks into spaces and ignores leading and trailing whitespace.
198+
* `:text-is("string")` - Matches when element's text equals the "string". Matching is case-insensitive and normalizes whitespace.
202199
* `button:text("Sign in")` - Text selector may be combined with regular CSS.
203-
* `:matches-text("[+-]?\\d+")` - Matches text against a regular expression. Note that back-slash `\` and quotes `"` must be escaped.
204-
* `:matches-text("regex", "g")` - Matches text against a regular expression with specified flags. Learn more about [regular expressions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp).
200+
* `:text-matches("[+-]?\\d+")` - Matches text against a regular expression. Note that special characters like back-slash `\`, quotes `"`, square brackets `[]` and more should be escaped. Learn more about [regular expressions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp).
201+
* `:text-matches("value", "i")` - Matches text against a regular expression with specified flags.
205202

206203
```js
207204
// Click a button with text "Sign in".

src/server/common/selectorParser.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export function selectorsV2Enabled() {
3535
}
3636

3737
export function selectorsV2EngineNames() {
38-
return ['not', 'is', 'where', 'has', 'scope', 'light', 'visible', 'matches-text', 'above', 'below', 'right-of', 'left-of', 'near', 'within'];
38+
return ['not', 'is', 'where', 'has', 'scope', 'light', 'visible', 'text-matches', 'text-is', 'above', 'below', 'right-of', 'left-of', 'near', 'within'];
3939
}
4040

4141
export function parseSelector(selector: string, customNames: Set<string>): ParsedSelector {
@@ -128,18 +128,22 @@ function textSelectorToSimple(selector: string): CSSSimpleSelector {
128128
return r.join('');
129129
}
130130

131-
let functionName = 'text';
131+
function escapeRegExp(s: string) {
132+
return s.replace(/[.*+\?^${}()|[\]\\]/g, '\\$&').replace(/-/g, '\\x2d');
133+
}
134+
135+
let functionName = 'text-matches';
132136
let args: string[];
133137
if (selector.length > 1 && selector[0] === '"' && selector[selector.length - 1] === '"') {
134-
args = [unescape(selector.substring(1, selector.length - 1))];
138+
args = ['^' + escapeRegExp(unescape(selector.substring(1, selector.length - 1))) + '$'];
135139
} else if (selector.length > 1 && selector[0] === "'" && selector[selector.length - 1] === "'") {
136-
args = [unescape(selector.substring(1, selector.length - 1))];
140+
args = ['^' + escapeRegExp(unescape(selector.substring(1, selector.length - 1))) + '$'];
137141
} else if (selector[0] === '/' && selector.lastIndexOf('/') > 0) {
138-
functionName = 'matches-text';
139142
const lastSlash = selector.lastIndexOf('/');
140143
args = [selector.substring(1, lastSlash), selector.substring(lastSlash + 1)];
141144
} else {
142-
args = [selector, 'sgi'];
145+
functionName = 'text';
146+
args = [selector];
143147
}
144148
return callWith(functionName, args);
145149
}

src/server/injected/selectorEvaluator.ts

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
5555
this._engines.set('light', lightEngine);
5656
this._engines.set('visible', visibleEngine);
5757
this._engines.set('text', textEngine);
58-
this._engines.set('matches-text', matchesTextEngine);
58+
this._engines.set('text-is', textIsEngine);
59+
this._engines.set('text-matches', textMatchesEngine);
5960
this._engines.set('xpath', xpathEngine);
6061
for (const attr of ['id', 'data-testid', 'data-test-id', 'data-test'])
6162
this._engines.set(attr, createAttributeEngine(attr));
@@ -362,37 +363,35 @@ const visibleEngine: SelectorEngine = {
362363

363364
const textEngine: SelectorEngine = {
364365
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
365-
if (args.length === 0 || typeof args[0] !== 'string' || args.length > 2 || (args.length === 2 && typeof args[1] !== 'string'))
366-
throw new Error(`"text" engine expects a string and an optional flags string`);
367-
const text = args[0];
368-
const flags = args.length === 2 ? args[1] : '';
369-
const matcher = textMatcher(text, flags);
370-
return elementMatchesText(element, context, matcher);
366+
if (args.length === 0 || typeof args[0] !== 'string')
367+
throw new Error(`"text" engine expects a single string`);
368+
return elementMatchesText(element, context, textMatcher(args[0], true));
369+
},
370+
};
371+
372+
const textIsEngine: SelectorEngine = {
373+
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
374+
if (args.length === 0 || typeof args[0] !== 'string')
375+
throw new Error(`"text-is" engine expects a single string`);
376+
return elementMatchesText(element, context, textMatcher(args[0], false));
371377
},
372378
};
373379

374-
const matchesTextEngine: SelectorEngine = {
380+
const textMatchesEngine: SelectorEngine = {
375381
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
376382
if (args.length === 0 || typeof args[0] !== 'string' || args.length > 2 || (args.length === 2 && typeof args[1] !== 'string'))
377-
throw new Error(`"matches-text" engine expects a regexp body and optional regexp flags`);
383+
throw new Error(`"text-matches" engine expects a regexp body and optional regexp flags`);
378384
const re = new RegExp(args[0], args.length === 2 ? args[1] : undefined);
379385
return elementMatchesText(element, context, s => re.test(s));
380386
},
381387
};
382388

383-
function textMatcher(text: string, flags: string): (s: string) => boolean {
384-
const normalizeSpace = flags.includes('s');
385-
const lowerCase = flags.includes('i');
386-
const substring = flags.includes('g');
387-
if (normalizeSpace)
388-
text = text.trim().replace(/\s+/g, ' ');
389-
if (lowerCase)
390-
text = text.toLowerCase();
389+
function textMatcher(text: string, substring: boolean): (s: string) => boolean {
390+
text = text.trim().replace(/\s+/g, ' ');
391+
text = text.toLowerCase();
391392
return (s: string) => {
392-
if (normalizeSpace)
393-
s = s.trim().replace(/\s+/g, ' ');
394-
if (lowerCase)
395-
s = s.toLowerCase();
393+
s = s.trim().replace(/\s+/g, ' ');
394+
s = s.toLowerCase();
396395
return substring ? s.includes(text) : s === text;
397396
};
398397
}

test/selectors-text.spec.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@
1616
*/
1717

1818
import { it, expect } from './fixtures';
19+
import * as path from 'path';
1920

20-
it('query', async ({page}) => {
21+
const { selectorsV2Enabled } = require(path.join(__dirname, '..', 'lib', 'server', 'common', 'selectorParser'));
22+
23+
it('should work', async ({page}) => {
2124
await page.setContent(`<div>yo</div><div>ya</div><div>\nye </div>`);
2225
expect(await page.$eval(`text=ya`, e => e.outerHTML)).toBe('<div>ya</div>');
2326
expect(await page.$eval(`text="ya"`, e => e.outerHTML)).toBe('<div>ya</div>');
@@ -106,6 +109,24 @@ it('query', async ({page}) => {
106109
expect((await page.$$(`text="lo wo"`)).length).toBe(0);
107110
});
108111

112+
it('should work in v2', async ({page}) => {
113+
if (!selectorsV2Enabled())
114+
return; // Selectors v1 do not support this.
115+
await page.setContent(`<div>yo</div><div>ya</div><div>\nHELLO \n world </div>`);
116+
expect(await page.$eval(`:text("ya")`, e => e.outerHTML)).toBe('<div>ya</div>');
117+
expect(await page.$eval(`:text-is("ya")`, e => e.outerHTML)).toBe('<div>ya</div>');
118+
expect(await page.$eval(`:text("y")`, e => e.outerHTML)).toBe('<div>yo</div>');
119+
expect(await page.$(`:text-is("y")`)).toBe(null);
120+
expect(await page.$eval(`:text("hello world")`, e => e.outerHTML)).toBe('<div>\nHELLO \n world </div>');
121+
expect(await page.$eval(`:text-is("hello world")`, e => e.outerHTML)).toBe('<div>\nHELLO \n world </div>');
122+
expect(await page.$eval(`:text("lo wo")`, e => e.outerHTML)).toBe('<div>\nHELLO \n world </div>');
123+
expect(await page.$(`:text-is("lo wo")`)).toBe(null);
124+
expect(await page.$eval(`:text-matches("^[ay]+$")`, e => e.outerHTML)).toBe('<div>ya</div>');
125+
expect(await page.$eval(`:text-matches("y", "g")`, e => e.outerHTML)).toBe('<div>yo</div>');
126+
expect(await page.$eval(`:text-matches("Y", "i")`, e => e.outerHTML)).toBe('<div>yo</div>');
127+
expect(await page.$(`:text-matches("^y$")`)).toBe(null);
128+
});
129+
109130
it('should be case sensitive if quotes are specified', async ({page}) => {
110131
await page.setContent(`<div>yo</div><div>ya</div><div>\nye </div>`);
111132
expect(await page.$eval(`text=yA`, e => e.outerHTML)).toBe('<div>ya</div>');

0 commit comments

Comments
 (0)