From b67e0221117cc0dca11ae3717b70442649078afe Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 9 Dec 2020 16:05:51 -0800 Subject: [PATCH] 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`. --- docs/selectors.md | 11 +++---- src/server/common/selectorParser.ts | 16 +++++---- src/server/injected/selectorEvaluator.ts | 41 ++++++++++++------------ test/selectors-text.spec.ts | 23 ++++++++++++- 4 files changed, 56 insertions(+), 35 deletions(-) diff --git a/docs/selectors.md b/docs/selectors.md index 41f369a664..a646f002a8 100644 --- a/docs/selectors.md +++ b/docs/selectors.md @@ -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". diff --git a/src/server/common/selectorParser.ts b/src/server/common/selectorParser.ts index c7f581a3e9..d1d33f4954 100644 --- a/src/server/common/selectorParser.ts +++ b/src/server/common/selectorParser.ts @@ -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): 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); } diff --git a/src/server/injected/selectorEvaluator.ts b/src/server/injected/selectorEvaluator.ts index 7ebe2e6f83..820144a9fe 100644 --- a/src/server/injected/selectorEvaluator.ts +++ b/src/server/injected/selectorEvaluator.ts @@ -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; }; } diff --git a/test/selectors-text.spec.ts b/test/selectors-text.spec.ts index ac56028df3..c64f966093 100644 --- a/test/selectors-text.spec.ts +++ b/test/selectors-text.spec.ts @@ -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(`
yo
ya
\nye
`); expect(await page.$eval(`text=ya`, e => e.outerHTML)).toBe('
ya
'); expect(await page.$eval(`text="ya"`, e => e.outerHTML)).toBe('
ya
'); @@ -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(`
yo
ya
\nHELLO \n world
`); + expect(await page.$eval(`:text("ya")`, e => e.outerHTML)).toBe('
ya
'); + expect(await page.$eval(`:text-is("ya")`, e => e.outerHTML)).toBe('
ya
'); + expect(await page.$eval(`:text("y")`, e => e.outerHTML)).toBe('
yo
'); + expect(await page.$(`:text-is("y")`)).toBe(null); + expect(await page.$eval(`:text("hello world")`, e => e.outerHTML)).toBe('
\nHELLO \n world
'); + expect(await page.$eval(`:text-is("hello world")`, e => e.outerHTML)).toBe('
\nHELLO \n world
'); + expect(await page.$eval(`:text("lo wo")`, e => e.outerHTML)).toBe('
\nHELLO \n world
'); + expect(await page.$(`:text-is("lo wo")`)).toBe(null); + expect(await page.$eval(`:text-matches("^[ay]+$")`, e => e.outerHTML)).toBe('
ya
'); + expect(await page.$eval(`:text-matches("y", "g")`, e => e.outerHTML)).toBe('
yo
'); + expect(await page.$eval(`:text-matches("Y", "i")`, e => e.outerHTML)).toBe('
yo
'); + expect(await page.$(`:text-matches("^y$")`)).toBe(null); +}); + it('should be case sensitive if quotes are specified', async ({page}) => { await page.setContent(`
yo
ya
\nye
`); expect(await page.$eval(`text=yA`, e => e.outerHTML)).toBe('
ya
');