Skip to content

Commit

Permalink
feat(role selector): allow unquoted name attribute
Browse files Browse the repository at this point in the history
- 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.
  • Loading branch information
dgozman committed Mar 31, 2022
1 parent 6a46319 commit ffe0cc3
Show file tree
Hide file tree
Showing 6 changed files with 48 additions and 11 deletions.
25 changes: 19 additions & 6 deletions packages/playwright-core/src/server/injected/componentUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
}
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
14 changes: 13 additions & 1 deletion tests/library/component-parser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('.');
Expand Down Expand Up @@ -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[');
Expand Down
9 changes: 9 additions & 0 deletions tests/page/selectors-role.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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>`,
Expand All @@ -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 }) => {
Expand Down

0 comments on commit ffe0cc3

Please sign in to comment.