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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseComponentSelector(selector: string): ParsedComponentSelector {
|
export function parseComponentSelector(selector: string, allowUnquotedStrings: boolean): ParsedComponentSelector {
|
||||||
let wp = 0;
|
let wp = 0;
|
||||||
let EOL = selector.length === 0;
|
let EOL = selector.length === 0;
|
||||||
|
|
||||||
|
|
@ -85,10 +85,21 @@ export function parseComponentSelector(selector: string): ParsedComponentSelecto
|
||||||
eat1();
|
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() {
|
function readIdentifier() {
|
||||||
let result = '';
|
let result = '';
|
||||||
skipSpaces();
|
skipSpaces();
|
||||||
while (!EOL && /[-$0-9A-Z_]/i.test(next()))
|
while (!EOL && isCSSNameChar(next()))
|
||||||
result += eat1();
|
result += eat1();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
@ -207,16 +218,18 @@ export function parseComponentSelector(selector: string): ParsedComponentSelecto
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
value = '';
|
value = '';
|
||||||
while (!EOL && !/\s/.test(next()) && next() !== ']')
|
while (!EOL && (isCSSNameChar(next()) || next() === '+' || next() === '.'))
|
||||||
value += eat1();
|
value += eat1();
|
||||||
if (value === 'true') {
|
if (value === 'true') {
|
||||||
value = true;
|
value = true;
|
||||||
} else if (value === 'false') {
|
} else if (value === 'false') {
|
||||||
value = false;
|
value = false;
|
||||||
} else {
|
} else {
|
||||||
value = +value;
|
if (!allowUnquotedStrings) {
|
||||||
if (isNaN(value))
|
value = +value;
|
||||||
syntaxError('parsing attribute value');
|
if (Number.isNaN(value))
|
||||||
|
syntaxError('parsing attribute value');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
skipSpaces();
|
skipSpaces();
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ function findReactRoots(root: Document | ShadowRoot, roots: ReactVNode[] = []):
|
||||||
|
|
||||||
export const ReactEngine: SelectorEngine = {
|
export const ReactEngine: SelectorEngine = {
|
||||||
queryAll(scope: SelectorRoot, selector: string): Element[] {
|
queryAll(scope: SelectorRoot, selector: string): Element[] {
|
||||||
const { name, attributes } = parseComponentSelector(selector);
|
const { name, attributes } = parseComponentSelector(selector, false);
|
||||||
|
|
||||||
const reactRoots = findReactRoots(document);
|
const reactRoots = findReactRoots(document);
|
||||||
const trees = reactRoots.map(reactRoot => buildComponentsTree(reactRoot));
|
const trees = reactRoots.map(reactRoot => buildComponentsTree(reactRoot));
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,10 @@ function validateAttributes(attrs: ParsedComponentAttribute[], role: string) {
|
||||||
}
|
}
|
||||||
case 'level': {
|
case 'level': {
|
||||||
validateSupportedRole(attr.name, kAriaLevelRoles, role);
|
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`);
|
throw new Error(`"level" attribute must be compared to a number`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -105,7 +108,7 @@ function validateAttributes(attrs: ParsedComponentAttribute[], role: string) {
|
||||||
|
|
||||||
export const RoleEngine: SelectorEngine = {
|
export const RoleEngine: SelectorEngine = {
|
||||||
queryAll(scope: SelectorRoot, selector: string): Element[] {
|
queryAll(scope: SelectorRoot, selector: string): Element[] {
|
||||||
const parsed = parseComponentSelector(selector);
|
const parsed = parseComponentSelector(selector, true);
|
||||||
const role = parsed.name.toLowerCase();
|
const role = parsed.name.toLowerCase();
|
||||||
if (!role)
|
if (!role)
|
||||||
throw new Error(`Role must not be empty`);
|
throw new Error(`Role must not be empty`);
|
||||||
|
|
|
||||||
|
|
@ -232,7 +232,7 @@ function findVueRoots(root: Document | ShadowRoot, roots: VueRoot[] = []): VueRo
|
||||||
|
|
||||||
export const VueEngine: SelectorEngine = {
|
export const VueEngine: SelectorEngine = {
|
||||||
queryAll(scope: SelectorRoot, selector: string): Element[] {
|
queryAll(scope: SelectorRoot, selector: string): Element[] {
|
||||||
const { name, attributes } = parseComponentSelector(selector);
|
const { name, attributes } = parseComponentSelector(selector, false);
|
||||||
const vueRoots = findVueRoots(document);
|
const vueRoots = findVueRoots(document);
|
||||||
const trees = vueRoots.map(vueRoot => vueRoot.version === 3 ? buildComponentsTreeVue3(vueRoot.root) : buildComponentsTreeVue2(vueRoot.root));
|
const trees = vueRoots.map(vueRoot => vueRoot.version === 3 ? buildComponentsTreeVue3(vueRoot.root) : buildComponentsTreeVue2(vueRoot.root));
|
||||||
const treeNodes = trees.map(tree => filterComponentsTree(tree, treeNode => {
|
const treeNodes = trees.map(tree => filterComponentsTree(tree, treeNode => {
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
import { playwrightTest as it, expect } from '../config/browserTest';
|
import { playwrightTest as it, expect } from '../config/browserTest';
|
||||||
import { ParsedComponentSelector, parseComponentSelector } from '../../packages/playwright-core/src/server/injected/componentUtils';
|
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) => {
|
const serialize = (parsed: ParsedComponentSelector) => {
|
||||||
return parsed.name + parsed.attributes.map(attr => {
|
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('.');
|
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 = /[\\]/][[/]/]');
|
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 () => {
|
it('should throw on malformed selector', async () => {
|
||||||
expectError('foo[');
|
expectError('foo[');
|
||||||
expectError('foo[');
|
expectError('foo[');
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@
|
||||||
|
|
||||||
import { test, expect } from './pageTest';
|
import { test, expect } from './pageTest';
|
||||||
|
|
||||||
|
test.skip(({ mode }) => mode !== 'default', 'Experimental features only work in default mode');
|
||||||
|
|
||||||
test('should detect roles', async ({ page }) => {
|
test('should detect roles', async ({ page }) => {
|
||||||
await page.setContent(`
|
await page.setContent(`
|
||||||
<button>Hello</button>
|
<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="Hello"></div>
|
||||||
<div role="button" aria-label="Hallo"></div>
|
<div role="button" aria-label="Hallo"></div>
|
||||||
<div role="button" aria-label="Hello" aria-hidden="true"></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([
|
expect(await page.$$eval(`role=button[name="Hello"]`, els => els.map(e => e.outerHTML))).toEqual([
|
||||||
`<div role="button" aria-label="Hello"></div>`,
|
`<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"></div>`,
|
||||||
`<div role="button" aria-label="Hello" aria-hidden="true"></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 }) => {
|
test('errors', async ({ page }) => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue