diff --git a/packages/playwright-core/src/server/injected/componentUtils.ts b/packages/playwright-core/src/server/injected/componentUtils.ts index 4ba5c82a43..3faef3ba58 100644 --- a/packages/playwright-core/src/server/injected/componentUtils.ts +++ b/packages/playwright-core/src/server/injected/componentUtils.ts @@ -62,7 +62,7 @@ export function matchesAttribute(value: any, attr: ParsedComponentAttribute) { return false; } -export function parseComponentSelector(selector: string): ParsedComponentSelector { +export function parseComponentSelector(selector: string, allowUnquotedStrings: boolean): ParsedComponentSelector { let wp = 0; let EOL = selector.length === 0; @@ -85,10 +85,21 @@ export function parseComponentSelector(selector: string): ParsedComponentSelecto eat1(); } + function isCSSNameChar(char: string) { + // https://www.w3.org/TR/css-syntax-3/#ident-token-diagram + return (char >= '\u0080') // non-ascii + || (char >= '\u0030' && char <= '\u0039') // digit + || (char >= '\u0041' && char <= '\u005a') // uppercase letter + || (char >= '\u0061' && char <= '\u007a') // lowercase letter + || (char >= '\u0030' && char <= '\u0039') // digit + || char === '\u005f' // "_" + || char === '\u002d'; // "-" + } + function readIdentifier() { let result = ''; skipSpaces(); - while (!EOL && /[-$0-9A-Z_]/i.test(next())) + while (!EOL && isCSSNameChar(next())) result += eat1(); return result; } @@ -207,16 +218,18 @@ export function parseComponentSelector(selector: string): ParsedComponentSelecto } } else { value = ''; - while (!EOL && !/\s/.test(next()) && next() !== ']') + while (!EOL && (isCSSNameChar(next()) || next() === '+' || next() === '.')) value += eat1(); if (value === 'true') { value = true; } else if (value === 'false') { value = false; } else { - value = +value; - if (isNaN(value)) - syntaxError('parsing attribute value'); + if (!allowUnquotedStrings) { + value = +value; + if (Number.isNaN(value)) + syntaxError('parsing attribute value'); + } } } skipSpaces(); diff --git a/packages/playwright-core/src/server/injected/reactSelectorEngine.ts b/packages/playwright-core/src/server/injected/reactSelectorEngine.ts index c60c52fe44..012be43940 100644 --- a/packages/playwright-core/src/server/injected/reactSelectorEngine.ts +++ b/packages/playwright-core/src/server/injected/reactSelectorEngine.ts @@ -167,7 +167,7 @@ function findReactRoots(root: Document | ShadowRoot, roots: ReactVNode[] = []): export const ReactEngine: SelectorEngine = { queryAll(scope: SelectorRoot, selector: string): Element[] { - const { name, attributes } = parseComponentSelector(selector); + const { name, attributes } = parseComponentSelector(selector, false); const reactRoots = findReactRoots(document); const trees = reactRoots.map(reactRoot => buildComponentsTree(reactRoot)); diff --git a/packages/playwright-core/src/server/injected/roleSelectorEngine.ts b/packages/playwright-core/src/server/injected/roleSelectorEngine.ts index 4ae908d897..f5315c0236 100644 --- a/packages/playwright-core/src/server/injected/roleSelectorEngine.ts +++ b/packages/playwright-core/src/server/injected/roleSelectorEngine.ts @@ -75,7 +75,10 @@ function validateAttributes(attrs: ParsedComponentAttribute[], role: string) { } case 'level': { validateSupportedRole(attr.name, kAriaLevelRoles, role); - if (attr.op !== '=' || typeof attr.value !== 'number') + // Level is a number, convert it from string. + if (typeof attr.value === 'string') + attr.value = +attr.value; + if (attr.op !== '=' || typeof attr.value !== 'number' || Number.isNaN(attr.value)) throw new Error(`"level" attribute must be compared to a number`); break; } @@ -105,7 +108,7 @@ function validateAttributes(attrs: ParsedComponentAttribute[], role: string) { export const RoleEngine: SelectorEngine = { queryAll(scope: SelectorRoot, selector: string): Element[] { - const parsed = parseComponentSelector(selector); + const parsed = parseComponentSelector(selector, true); const role = parsed.name.toLowerCase(); if (!role) throw new Error(`Role must not be empty`); diff --git a/packages/playwright-core/src/server/injected/vueSelectorEngine.ts b/packages/playwright-core/src/server/injected/vueSelectorEngine.ts index 66e4b5f113..29689475eb 100644 --- a/packages/playwright-core/src/server/injected/vueSelectorEngine.ts +++ b/packages/playwright-core/src/server/injected/vueSelectorEngine.ts @@ -232,7 +232,7 @@ function findVueRoots(root: Document | ShadowRoot, roots: VueRoot[] = []): VueRo export const VueEngine: SelectorEngine = { queryAll(scope: SelectorRoot, selector: string): Element[] { - const { name, attributes } = parseComponentSelector(selector); + const { name, attributes } = parseComponentSelector(selector, false); const vueRoots = findVueRoots(document); const trees = vueRoots.map(vueRoot => vueRoot.version === 3 ? buildComponentsTreeVue3(vueRoot.root) : buildComponentsTreeVue2(vueRoot.root)); const treeNodes = trees.map(tree => filterComponentsTree(tree, treeNode => { diff --git a/tests/library/component-parser.spec.ts b/tests/library/component-parser.spec.ts index 250fda319a..7a937b6437 100644 --- a/tests/library/component-parser.spec.ts +++ b/tests/library/component-parser.spec.ts @@ -17,7 +17,7 @@ import { playwrightTest as it, expect } from '../config/browserTest'; import { ParsedComponentSelector, parseComponentSelector } from '../../packages/playwright-core/src/server/injected/componentUtils'; -const parse = parseComponentSelector; +const parse = (selector: string) => parseComponentSelector(selector, false); const serialize = (parsed: ParsedComponentSelector) => { return parsed.name + parsed.attributes.map(attr => { const path = attr.jsonPath.map(token => /^[a-zA-Z0-9]+$/i.test(token) ? token : JSON.stringify(token)).join('.'); @@ -107,6 +107,18 @@ it('should parse regex', async () => { expect(serialize(parse(`ColorButton[color=/[\\]/][[/]/]`))).toBe('ColorButton[color = /[\\]/][[/]/]'); }); +it('should parse identifiers', async () => { + expect(serialize(parse('[привет=true]'))).toBe('["привет" = true]'); + expect(serialize(parse('[__-__=true]'))).toBe('["__-__" = true]'); + expect(serialize(parse('[😀=true]'))).toBe('["😀" = true]'); +}); + +it('should parse unqouted string', async () => { + expect(serialize(parseComponentSelector('[hey=foo]', true))).toBe('[hey = "foo"]'); + expect(serialize(parseComponentSelector('[yay=and😀more]', true))).toBe('[yay = "and😀more"]'); + expect(serialize(parseComponentSelector('[yay= trims ]', true))).toBe('[yay = "trims"]'); +}); + it('should throw on malformed selector', async () => { expectError('foo['); expectError('foo['); diff --git a/tests/page/selectors-role.spec.ts b/tests/page/selectors-role.spec.ts index 9a41ece1b6..95f6dcfc0f 100644 --- a/tests/page/selectors-role.spec.ts +++ b/tests/page/selectors-role.spec.ts @@ -16,6 +16,8 @@ import { test, expect } from './pageTest'; +test.skip(({ mode }) => mode !== 'default', 'Experimental features only work in default mode'); + test('should detect roles', async ({ page }) => { await page.setContent(` @@ -267,6 +269,7 @@ test('should support name', async ({ page }) => {
+ `); expect(await page.$$eval(`role=button[name="Hello"]`, els => els.map(e => e.outerHTML))).toEqual([ ``, @@ -286,6 +289,12 @@ test('should support name', async ({ page }) => { ``, ``, ]); + expect(await page.$$eval(`role=button[name=Hello]`, els => els.map(e => e.outerHTML))).toEqual([ + ``, + ]); + expect(await page.$$eval(`role=button[name=123][include-hidden]`, els => els.map(e => e.outerHTML))).toEqual([ + ``, + ]); }); test('errors', async ({ page }) => {