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`.
This commit is contained in:
Dmitry Gozman 2020-12-09 16:05:51 -08:00 committed by GitHub
parent aacd8e633c
commit b67e022111
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 56 additions and 35 deletions

View File

@ -194,14 +194,11 @@ Use `:visible` with caution, because it has two major drawbacks:
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:
* `:text("exact match")` - Only matches when element's text exactly equals to passed string.
* `:text("substring", "g")` - Matches when element's text contains "substring" somewhere.
* `:text("String", "i")` - Performs case-insensitive match.
* `:text("string with spaces", "s")` - Normalizes whitespace when matching, for example turns multiple spaces into one and line breaks into spaces.
* `:text("substring", "sgi")` - Different flags may be combined. For example, pass `"sgi"` to match by case-insensitive substring with normalized whitespace.
* `: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.
* `:text-is("string")` - Matches when element's text equals the "string". Matching is case-insensitive and normalizes whitespace.
* `button:text("Sign in")` - Text selector may be combined with regular CSS.
* `:matches-text("[+-]?\\d+")` - Matches text against a regular expression. Note that back-slash `\` and quotes `"` must be escaped.
* `: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).
* `: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).
* `:text-matches("value", "i")` - Matches text against a regular expression with specified flags.
```js
// Click a button with text "Sign in".

View File

@ -35,7 +35,7 @@ export function selectorsV2Enabled() {
}
export function selectorsV2EngineNames() {
return ['not', 'is', 'where', 'has', 'scope', 'light', 'visible', 'matches-text', 'above', 'below', 'right-of', 'left-of', 'near', 'within'];
return ['not', 'is', 'where', 'has', 'scope', 'light', 'visible', 'text-matches', 'text-is', 'above', 'below', 'right-of', 'left-of', 'near', 'within'];
}
export function parseSelector(selector: string, customNames: Set<string>): ParsedSelector {
@ -128,18 +128,22 @@ function textSelectorToSimple(selector: string): CSSSimpleSelector {
return r.join('');
}
let functionName = 'text';
function escapeRegExp(s: string) {
return s.replace(/[.*+\?^${}()|[\]\\]/g, '\\$&').replace(/-/g, '\\x2d');
}
let functionName = 'text-matches';
let args: string[];
if (selector.length > 1 && selector[0] === '"' && selector[selector.length - 1] === '"') {
args = [unescape(selector.substring(1, selector.length - 1))];
args = ['^' + escapeRegExp(unescape(selector.substring(1, selector.length - 1))) + '$'];
} else if (selector.length > 1 && selector[0] === "'" && selector[selector.length - 1] === "'") {
args = [unescape(selector.substring(1, selector.length - 1))];
args = ['^' + escapeRegExp(unescape(selector.substring(1, selector.length - 1))) + '$'];
} else if (selector[0] === '/' && selector.lastIndexOf('/') > 0) {
functionName = 'matches-text';
const lastSlash = selector.lastIndexOf('/');
args = [selector.substring(1, lastSlash), selector.substring(lastSlash + 1)];
} else {
args = [selector, 'sgi'];
functionName = 'text';
args = [selector];
}
return callWith(functionName, args);
}

View File

@ -55,7 +55,8 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
this._engines.set('light', lightEngine);
this._engines.set('visible', visibleEngine);
this._engines.set('text', textEngine);
this._engines.set('matches-text', matchesTextEngine);
this._engines.set('text-is', textIsEngine);
this._engines.set('text-matches', textMatchesEngine);
this._engines.set('xpath', xpathEngine);
for (const attr of ['id', 'data-testid', 'data-test-id', 'data-test'])
this._engines.set(attr, createAttributeEngine(attr));
@ -362,37 +363,35 @@ const visibleEngine: SelectorEngine = {
const textEngine: SelectorEngine = {
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
if (args.length === 0 || typeof args[0] !== 'string' || args.length > 2 || (args.length === 2 && typeof args[1] !== 'string'))
throw new Error(`"text" engine expects a string and an optional flags string`);
const text = args[0];
const flags = args.length === 2 ? args[1] : '';
const matcher = textMatcher(text, flags);
return elementMatchesText(element, context, matcher);
if (args.length === 0 || typeof args[0] !== 'string')
throw new Error(`"text" engine expects a single string`);
return elementMatchesText(element, context, textMatcher(args[0], true));
},
};
const matchesTextEngine: SelectorEngine = {
const textIsEngine: SelectorEngine = {
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
if (args.length === 0 || typeof args[0] !== 'string')
throw new Error(`"text-is" engine expects a single string`);
return elementMatchesText(element, context, textMatcher(args[0], false));
},
};
const textMatchesEngine: SelectorEngine = {
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
if (args.length === 0 || typeof args[0] !== 'string' || args.length > 2 || (args.length === 2 && typeof args[1] !== 'string'))
throw new Error(`"matches-text" engine expects a regexp body and optional regexp flags`);
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));
},
};
function textMatcher(text: string, flags: string): (s: string) => boolean {
const normalizeSpace = flags.includes('s');
const lowerCase = flags.includes('i');
const substring = flags.includes('g');
if (normalizeSpace)
text = text.trim().replace(/\s+/g, ' ');
if (lowerCase)
text = text.toLowerCase();
function textMatcher(text: string, substring: boolean): (s: string) => boolean {
text = text.trim().replace(/\s+/g, ' ');
text = text.toLowerCase();
return (s: string) => {
if (normalizeSpace)
s = s.trim().replace(/\s+/g, ' ');
if (lowerCase)
s = s.toLowerCase();
s = s.trim().replace(/\s+/g, ' ');
s = s.toLowerCase();
return substring ? s.includes(text) : s === text;
};
}

View File

@ -16,8 +16,11 @@
*/
import { it, expect } from './fixtures';
import * as path from 'path';
it('query', async ({page}) => {
const { selectorsV2Enabled } = require(path.join(__dirname, '..', 'lib', 'server', 'common', 'selectorParser'));
it('should work', async ({page}) => {
await page.setContent(`<div>yo</div><div>ya</div><div>\nye </div>`);
expect(await page.$eval(`text=ya`, e => e.outerHTML)).toBe('<div>ya</div>');
expect(await page.$eval(`text="ya"`, e => e.outerHTML)).toBe('<div>ya</div>');
@ -106,6 +109,24 @@ it('query', async ({page}) => {
expect((await page.$$(`text="lo wo"`)).length).toBe(0);
});
it('should work in v2', async ({page}) => {
if (!selectorsV2Enabled())
return; // Selectors v1 do not support this.
await page.setContent(`<div>yo</div><div>ya</div><div>\nHELLO \n world </div>`);
expect(await page.$eval(`:text("ya")`, e => e.outerHTML)).toBe('<div>ya</div>');
expect(await page.$eval(`:text-is("ya")`, e => e.outerHTML)).toBe('<div>ya</div>');
expect(await page.$eval(`:text("y")`, e => e.outerHTML)).toBe('<div>yo</div>');
expect(await page.$(`:text-is("y")`)).toBe(null);
expect(await page.$eval(`:text("hello world")`, e => e.outerHTML)).toBe('<div>\nHELLO \n world </div>');
expect(await page.$eval(`:text-is("hello world")`, e => e.outerHTML)).toBe('<div>\nHELLO \n world </div>');
expect(await page.$eval(`:text("lo wo")`, e => e.outerHTML)).toBe('<div>\nHELLO \n world </div>');
expect(await page.$(`:text-is("lo wo")`)).toBe(null);
expect(await page.$eval(`:text-matches("^[ay]+$")`, e => e.outerHTML)).toBe('<div>ya</div>');
expect(await page.$eval(`:text-matches("y", "g")`, e => e.outerHTML)).toBe('<div>yo</div>');
expect(await page.$eval(`:text-matches("Y", "i")`, e => e.outerHTML)).toBe('<div>yo</div>');
expect(await page.$(`:text-matches("^y$")`)).toBe(null);
});
it('should be case sensitive if quotes are specified', async ({page}) => {
await page.setContent(`<div>yo</div><div>ya</div><div>\nye </div>`);
expect(await page.$eval(`text=yA`, e => e.outerHTML)).toBe('<div>ya</div>');