fix(locators): properly escape various locator methods (#17798)

References #17604.
This commit is contained in:
Dmitry Gozman 2022-10-04 13:21:38 -07:00 committed by GitHub
parent 8810b55504
commit a9c02b9753
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 102 additions and 60 deletions

View File

@ -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) {

View File

@ -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);

View File

@ -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<Element, boo
const candidates: SelectorToken[] = [];
if (element.getAttribute('data-testid'))
candidates.push({ engine: 'attr', selector: `[data-testid=${quoteAttributeValue(element.getAttribute('data-testid')!)}]`, score: 1 });
candidates.push({ engine: 'attr', selector: `[data-testid=${escapeForAttributeSelector(element.getAttribute('data-testid')!)}]`, score: 1 });
for (const attr of ['data-test-id', 'data-test']) {
if (element.getAttribute(attr))
@ -157,20 +158,20 @@ function buildCandidates(element: Element, accessibleNameCache: Map<Element, boo
if (element.nodeName === 'INPUT') {
const input = element as HTMLInputElement;
if (input.placeholder)
candidates.push({ engine: 'attr', selector: `[placeholder=${quoteAttributeValue(input.placeholder)}]`, score: 3 });
candidates.push({ engine: 'attr', selector: `[placeholder=${escapeForAttributeSelector(input.placeholder)}]`, score: 3 });
}
const ariaRole = getAriaRole(element);
if (ariaRole) {
const ariaName = getElementAccessibleName(element, false, accessibleNameCache);
if (ariaName)
candidates.push({ engine: 'role', selector: `${ariaRole}[name=${quoteAttributeValue(ariaName)}]`, score: 3 });
candidates.push({ engine: 'role', selector: `${ariaRole}[name=${escapeForAttributeSelector(ariaName)}]`, score: 3 });
else
candidates.push({ engine: 'role', selector: ariaRole, score: 150 });
}
if (element.getAttribute('alt') && ['APPLET', 'AREA', 'IMG', 'INPUT'].includes(element.nodeName))
candidates.push({ engine: 'attr', selector: `[alt=${quoteAttributeValue(element.getAttribute('alt')!)}]`, score: 10 });
candidates.push({ engine: 'attr', selector: `[alt=${escapeForAttributeSelector(element.getAttribute('alt')!)}]`, score: 10 });
if (element.getAttribute('name') && ['BUTTON', 'FORM', 'FIELDSET', 'FRAME', 'IFRAME', 'INPUT', 'KEYGEN', 'OBJECT', 'OUTPUT', 'SELECT', 'TEXTAREA', 'MAP', 'META', 'PARAM'].includes(element.nodeName))
candidates.push({ engine: 'css', selector: `${cssEscape(element.nodeName.toLowerCase())}[name=${quoteAttributeValue(element.getAttribute('name')!)}]`, score: 50 });
@ -199,9 +200,7 @@ function buildTextCandidates(injectedScript: InjectedScript, element: Element, i
return [];
const candidates: SelectorToken[] = [];
let escaped = text;
if (text.includes('"') || text.includes('>>') || 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);
}

View File

@ -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, '\\"')}"`;
}

View File

@ -319,7 +319,8 @@ it.describe('selector generator', () => {
await page.setContent(`<button><span></span></button><button></button>`);
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(`<div><span></span></div>`);
await page.$eval('div', div => div.id = `!#'!?:`);

View File

@ -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(`<label id=label for=control>Hello
wo"rld</label><input id=control />`);
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(`<label id=label for=control>Hello
world</label><input id=control />`);
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');
});

View File

@ -35,6 +35,9 @@ it('getByText should work', async ({ page }) => {
await page.setContent(`<div> ye </div><div>ye</div>`);
expect(await page.getByText('ye', { exact: true }).first().evaluate(e => e.outerHTML)).toContain('> ye </div>');
await page.setContent(`<div>Hello world</div><div>Hello</div>`);
expect(await page.getByText('Hello', { exact: true }).evaluate(e => e.outerHTML)).toBe('<div>Hello</div>');
});
it('getByLabel should work', async ({ page }) => {

View File

@ -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(`<div id=me>hello
wo"r>>ld</div>`);
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);