mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
fix(locators): properly escape various locator methods (#17798)
References #17604.
This commit is contained in:
parent
8810b55504
commit
a9c02b9753
@ -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) {
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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, '\\"')}"`;
|
||||
}
|
||||
|
@ -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 = `!#'!?:`);
|
||||
|
@ -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');
|
||||
});
|
||||
|
@ -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 }) => {
|
||||
|
@ -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);
|
||||
|
Loading…
x
Reference in New Issue
Block a user