feat(role selector): allow unquoted name attribute (#13224)
- This supports `role=button[name=Hello]` similarly to CSS selectors. - Does not change `_react` or `_vue` behavior that insist on quoting the string. - Uses CSS notion of "identifier" characters.
This commit is contained in:
parent
a87794dae6
commit
356fc35b85
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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[');
|
||||
|
|
|
|||
|
|
@ -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(`
|
||||
<button>Hello</button>
|
||||
|
|
@ -267,6 +269,7 @@ test('should support name', async ({ page }) => {
|
|||
<div role="button" aria-label="Hello"></div>
|
||||
<div role="button" aria-label="Hallo"></div>
|
||||
<div role="button" aria-label="Hello" aria-hidden="true"></div>
|
||||
<div role="button" aria-label="123" aria-hidden="true"></div>
|
||||
`);
|
||||
expect(await page.$$eval(`role=button[name="Hello"]`, els => els.map(e => e.outerHTML))).toEqual([
|
||||
`<div role="button" aria-label="Hello"></div>`,
|
||||
|
|
@ -286,6 +289,12 @@ test('should support name', async ({ page }) => {
|
|||
`<div role="button" aria-label="Hello"></div>`,
|
||||
`<div role="button" aria-label="Hello" aria-hidden="true"></div>`,
|
||||
]);
|
||||
expect(await page.$$eval(`role=button[name=Hello]`, els => els.map(e => e.outerHTML))).toEqual([
|
||||
`<div role="button" aria-label="Hello"></div>`,
|
||||
]);
|
||||
expect(await page.$$eval(`role=button[name=123][include-hidden]`, els => els.map(e => e.outerHTML))).toEqual([
|
||||
`<div role="button" aria-label="123" aria-hidden="true"></div>`,
|
||||
]);
|
||||
});
|
||||
|
||||
test('errors', async ({ page }) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue