diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index 553ab3561e..50cb762e37 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -19,12 +19,12 @@ import type * as api from '../../types/types'; import type * as channels from '@protocol/channels'; import type { ParsedStackTrace } from '../utils/stackTrace'; import * as util from 'util'; -import { isRegExp, isString, monotonicTime } from '../utils'; +import { isString, monotonicTime } from '../utils'; import { ElementHandle } from './elementHandle'; import type { Frame } from './frame'; import type { FilePayload, FrameExpectOptions, Rect, SelectOption, SelectOptionOptions, TimeoutOptions } from './types'; import { parseResult, serializeArgument } from './jsHandle'; -import { escapeWithQuotes } from '../utils/isomorphic/stringUtils'; +import { escapeForAttributeSelector, escapeForTextSelector } from '../utils/isomorphic/stringUtils'; export type LocatorOptions = { hasText?: string | RegExp; @@ -58,15 +58,11 @@ export class Locator implements api.Locator { private static getByAttributeTextSelector(attrName: string, text: string | RegExp, options?: { exact?: boolean }): string { if (!isString(text)) return `attr=[${attrName}=${text}]`; - return `attr=[${attrName}=${JSON.stringify(text)}${options?.exact ? 's' : 'i'}]`; + return `attr=[${attrName}=${escapeForAttributeSelector(text)}${options?.exact ? 's' : 'i'}]`; } static getByLabelSelector(text: string | RegExp, options?: { exact?: boolean }): string { - if (!isString(text)) - return `text=${text}`; - const escaped = JSON.stringify(text); - const selector = options?.exact ? `text=${escaped}` : `text=${escaped.substring(1, escaped.length - 1)}`; - return selector + ' >> control=resolve-label'; + return Locator.getByTextSelector(text, options) + ' >> control=resolve-label'; } static getByAltTextSelector(text: string | RegExp, options?: { exact?: boolean }): string { @@ -82,11 +78,7 @@ export class Locator implements api.Locator { } static getByTextSelector(text: string | RegExp, options?: { exact?: boolean }): string { - if (!isString(text)) - return `text=${text}`; - const escaped = JSON.stringify(text); - const selector = options?.exact ? `text=${escaped}` : `text=${escaped.substring(1, escaped.length - 1)}`; - return selector; + return 'text=' + escapeForTextSelector(text, !!options?.exact); } static getByRoleSelector(role: string, options: ByRoleOptions = {}): string { @@ -104,7 +96,7 @@ export class Locator implements api.Locator { if (options.level !== undefined) props.push(['level', String(options.level)]); if (options.name !== undefined) - props.push(['name', isString(options.name) ? escapeWithQuotes(options.name, '"') : String(options.name)]); + props.push(['name', isString(options.name) ? escapeForAttributeSelector(options.name) : String(options.name)]); if (options.pressed !== undefined) props.push(['pressed', String(options.pressed)]); return `role=${role}${props.map(([n, v]) => `[${n}=${v}]`).join('')}`; @@ -115,11 +107,8 @@ export class Locator implements api.Locator { this._selector = selector; if (options?.hasText) { - const text = options.hasText; - if (isRegExp(text)) - this._selector += ` >> has=${JSON.stringify('text=' + text.toString())}`; - else - this._selector += ` >> :scope:has-text(${escapeWithQuotes(text, '"')})`; + const textSelector = 'text=' + escapeForTextSelector(options.hasText, false); + this._selector += ` >> has=${JSON.stringify(textSelector)}`; } if (options?.has) { diff --git a/packages/playwright-core/src/server/injected/consoleApi.ts b/packages/playwright-core/src/server/injected/consoleApi.ts index 64d7db02c6..cea839dd74 100644 --- a/packages/playwright-core/src/server/injected/consoleApi.ts +++ b/packages/playwright-core/src/server/injected/consoleApi.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { escapeWithQuotes } from '../../utils/isomorphic/stringUtils'; +import { escapeForTextSelector } from '../../utils/isomorphic/stringUtils'; import { type InjectedScript } from './injectedScript'; import { generateSelector } from './selectorGenerator'; @@ -27,11 +27,8 @@ function createLocator(injectedScript: InjectedScript, initial: string, options? constructor(selector: string, options?: { hasText?: string | RegExp, has?: Locator }) { this.selector = selector; if (options?.hasText) { - const text = options.hasText; - if (text instanceof RegExp) - this.selector += ` >> :scope:text-matches(${escapeWithQuotes(text.source, '"')}, "${text.flags}")`; - else - this.selector += ` >> :scope:has-text(${escapeWithQuotes(text)})`; + const textSelector = 'text=' + escapeForTextSelector(options.hasText, false); + this.selector += ` >> has=${JSON.stringify(textSelector)}`; } if (options?.has) this.selector += ` >> has=` + JSON.stringify(options.has.selector); diff --git a/packages/playwright-core/src/server/injected/selectorGenerator.ts b/packages/playwright-core/src/server/injected/selectorGenerator.ts index 5709791bf7..35ab65efe8 100644 --- a/packages/playwright-core/src/server/injected/selectorGenerator.ts +++ b/packages/playwright-core/src/server/injected/selectorGenerator.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { cssEscape, escapeForAttributeSelector, escapeForTextSelector } from '../../utils/isomorphic/stringUtils'; import { type InjectedScript } from './injectedScript'; import { getAriaRole, getElementAccessibleName } from './roleUtils'; import { elementText } from './selectorUtils'; @@ -147,7 +148,7 @@ function buildCandidates(element: Element, accessibleNameCache: Map>') || text[0] === '/') - escaped = `/.*${escapeForRegex(text)}.*/`; + const escaped = escapeForTextSelector(text, false, true); if (isTargetNode) candidates.push({ engine: 'text', selector: escaped, score: 10 }); @@ -304,10 +303,6 @@ function cssFallback(injectedScript: InjectedScript, targetElement: Element, str return makeStrict(uniqueCSSSelector()!); } -function escapeForRegex(text: string): string { - return text.replace(/[.*+?^>${}()|[\]\\]/g, '\\$&'); -} - function quoteAttributeValue(text: string): string { return `"${cssEscape(text).replace(/\\ /g, ' ')}"`; } @@ -387,26 +382,3 @@ function isGuidLike(id: string): boolean { } return transitionCount >= id.length / 4; } - -function cssEscape(s: string): string { - let result = ''; - for (let i = 0; i < s.length; i++) - result += cssEscapeOne(s, i); - return result; -} - -function cssEscapeOne(s: string, i: number): string { - // https://drafts.csswg.org/cssom/#serialize-an-identifier - const c = s.charCodeAt(i); - if (c === 0x0000) - return '\uFFFD'; - if ((c >= 0x0001 && c <= 0x001f) || - (c >= 0x0030 && c <= 0x0039 && (i === 0 || (i === 1 && s.charCodeAt(0) === 0x002d)))) - return '\\' + c.toString(16) + ' '; - if (i === 0 && c === 0x002d && s.length === 1) - return '\\' + s.charAt(i); - if (c >= 0x0080 || c === 0x002d || c === 0x005f || (c >= 0x0030 && c <= 0x0039) || - (c >= 0x0041 && c <= 0x005a) || (c >= 0x0061 && c <= 0x007a)) - return s.charAt(i); - return '\\' + s.charAt(i); -} diff --git a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts index e82e1890ed..8f0727732f 100644 --- a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts +++ b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +// NOTE: this function should not be used to escape any selectors. export function escapeWithQuotes(text: string, char: string = '\'') { const stringified = JSON.stringify(text); const escapedText = stringified.substring(1, stringified.length - 1).replace(/\\"/g, '"'); @@ -29,3 +30,48 @@ export function escapeWithQuotes(text: string, char: string = '\'') { export function toTitleCase(name: string) { return name.charAt(0).toUpperCase() + name.substring(1); } + +export function cssEscape(s: string): string { + let result = ''; + for (let i = 0; i < s.length; i++) + result += cssEscapeOne(s, i); + return result; +} + +function cssEscapeOne(s: string, i: number): string { + // https://drafts.csswg.org/cssom/#serialize-an-identifier + const c = s.charCodeAt(i); + if (c === 0x0000) + return '\uFFFD'; + if ((c >= 0x0001 && c <= 0x001f) || + (c >= 0x0030 && c <= 0x0039 && (i === 0 || (i === 1 && s.charCodeAt(0) === 0x002d)))) + return '\\' + c.toString(16) + ' '; + if (i === 0 && c === 0x002d && s.length === 1) + return '\\' + s.charAt(i); + if (c >= 0x0080 || c === 0x002d || c === 0x005f || (c >= 0x0030 && c <= 0x0039) || + (c >= 0x0041 && c <= 0x005a) || (c >= 0x0061 && c <= 0x007a)) + return s.charAt(i); + return '\\' + s.charAt(i); +} + +function escapeForRegex(text: string): string { + return text.replace(/[.*+?^>${}()|[\]\\]/g, '\\$&'); +} + +export function escapeForTextSelector(text: string | RegExp, exact: boolean, caseSensitive = false): string { + if (typeof text !== 'string') + return String(text); + if (exact) + return '"' + text.replace(/["]/g, '\\"') + '"'; + if (text.includes('"') || text.includes('>>') || text[0] === '/') + return `/.*${escapeForRegex(text).replace(/\s+/, '\\s+')}.*/` + (caseSensitive ? '' : 'i'); + return text; +} + +export function escapeForAttributeSelector(value: string): string { + // TODO: this should actually be + // cssEscape(value).replace(/\\ /g, ' ') + // However, our attribute selectors do not conform to CSS parsing spec, + // so we escape them differently. + return `"${value.replace(/["]/g, '\\"')}"`; +} diff --git a/tests/library/selector-generator.spec.ts b/tests/library/selector-generator.spec.ts index 87424d453d..d6358c1738 100644 --- a/tests/library/selector-generator.spec.ts +++ b/tests/library/selector-generator.spec.ts @@ -319,7 +319,8 @@ it.describe('selector generator', () => { await page.setContent(``); await page.$eval('button', button => button.setAttribute('aria-label', `!#'!?:`)); - expect(await generate(page, 'button')).toBe("role=button[name=\"\\!\\#\\'\\!\\?\\:\"]"); + expect(await generate(page, 'button')).toBe(`role=button[name="!#'!?:"]`); + expect(await page.$(`role=button[name="!#'!?:"]`)).toBeTruthy(); await page.setContent(`
`); await page.$eval('div', div => div.id = `!#'!?:`); diff --git a/tests/page/locator-query.spec.ts b/tests/page/locator-query.spec.ts index 80e477254d..cd8e789898 100644 --- a/tests/page/locator-query.spec.ts +++ b/tests/page/locator-query.spec.ts @@ -180,3 +180,31 @@ it('alias methods coverage', async ({ page }) => { await expect(page.locator('div').getByRole('button')).toHaveCount(1); await expect(page.mainFrame().locator('button')).toHaveCount(1); }); + +it('getBy escaping', async ({ page }) => { + await page.setContent(``); + await page.$eval('input', input => { + input.setAttribute('placeholder', 'hello\nwo"rld'); + input.setAttribute('title', 'hello\nwo"rld'); + input.setAttribute('alt', 'hello\nwo"rld'); + }); + await expect(page.getByText('hello\nwo"rld')).toHaveAttribute('id', 'label'); + await expect(page.getByLabel('hello\nwo"rld')).toHaveAttribute('id', 'control'); + await expect(page.getByPlaceholder('hello\nwo"rld')).toHaveAttribute('id', 'control'); + await expect(page.getByAltText('hello\nwo"rld')).toHaveAttribute('id', 'control'); + await expect(page.getByTitle('hello\nwo"rld')).toHaveAttribute('id', 'control'); + + await page.setContent(``); + await page.$eval('input', input => { + input.setAttribute('placeholder', 'hello\nworld'); + input.setAttribute('title', 'hello\nworld'); + input.setAttribute('alt', 'hello\nworld'); + }); + await expect(page.getByText('hello\nworld')).toHaveAttribute('id', 'label'); + await expect(page.getByLabel('hello\nworld')).toHaveAttribute('id', 'control'); + await expect(page.getByPlaceholder('hello\nworld')).toHaveAttribute('id', 'control'); + await expect(page.getByAltText('hello\nworld')).toHaveAttribute('id', 'control'); + await expect(page.getByTitle('hello\nworld')).toHaveAttribute('id', 'control'); +}); diff --git a/tests/page/selectors-get-by.spec.ts b/tests/page/selectors-get-by.spec.ts index 547ee3e5d3..70af9e14ad 100644 --- a/tests/page/selectors-get-by.spec.ts +++ b/tests/page/selectors-get-by.spec.ts @@ -35,6 +35,9 @@ it('getByText should work', async ({ page }) => { await page.setContent(`
ye
ye
`); expect(await page.getByText('ye', { exact: true }).first().evaluate(e => e.outerHTML)).toContain('> ye '); + + await page.setContent(`
Hello world
Hello
`); + expect(await page.getByText('Hello', { exact: true }).evaluate(e => e.outerHTML)).toBe('
Hello
'); }); it('getByLabel should work', async ({ page }) => { diff --git a/tests/page/selectors-text.spec.ts b/tests/page/selectors-text.spec.ts index ff57d6dcee..e32d556fa9 100644 --- a/tests/page/selectors-text.spec.ts +++ b/tests/page/selectors-text.spec.ts @@ -238,6 +238,12 @@ it('should work with :has-text', async ({ page }) => { expect(await page.$eval(`div:has-text("find me") :has-text("maybe me")`, e => e.tagName)).toBe('WRAP'); expect(await page.$eval(`div:has-text("find me") span:has-text("maybe me")`, e => e.id)).toBe('span2'); + await page.setContent(`
hello + wo"r>>ld
`); + expect(await page.$eval(`div:has-text("hello wo\\"r>>ld")`, e => e.id)).toBe('me'); + expect(await page.$eval(`div:has-text("hello\\a wo\\"r>>ld")`, e => e.id)).toBe('me'); + expect(await page.locator('div', { hasText: 'hello\nwo"r>>ld' }).getAttribute('id')).toBe('me'); + const error1 = await page.$(`:has-text("foo", "bar")`).catch(e => e); expect(error1.message).toContain(`"has-text" engine expects a single string`); const error2 = await page.$(`:has-text(foo > bar)`).catch(e => e);