diff --git a/docs/src/selectors.md b/docs/src/selectors.md index e7f3f99e57..b68971de9d 100644 --- a/docs/src/selectors.md +++ b/docs/src/selectors.md @@ -765,7 +765,7 @@ Selector examples: - match by component and property value **prefix**: `_react=BookItem[author ^= "Steven"]` - match by component and property value **suffix**: `_react=BookItem[author $= "Steven"]` - match by component and **key**: `_react=BookItem[key = '2']` - +- match by property value **regex**: `_react=[author = /Steven(\\s+King)?/i]` To find React element names in a tree use [React DevTools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi). @@ -801,6 +801,7 @@ Selector examples: - match by **nested** property value: `_vue=[some.nested.value = 12]` - match by component and property value **prefix**: `_vue=book-item[author ^= "Steven"]` - match by component and property value **suffix**: `_vue=book-item[author $= "Steven"]` +- match by property value **regex**: `_vue=[author = /Steven(\\s+King)?/i]` To find Vue element names in a tree use [Vue DevTools](https://chrome.google.com/webstore/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd?hl=en). diff --git a/packages/playwright-core/src/server/injected/componentUtils.ts b/packages/playwright-core/src/server/injected/componentUtils.ts index 25ad18dc9b..5b82a1ed5e 100644 --- a/packages/playwright-core/src/server/injected/componentUtils.ts +++ b/packages/playwright-core/src/server/injected/componentUtils.ts @@ -32,13 +32,20 @@ export function checkComponentAttribute(obj: any, attr: ParsedComponentAttribute if (obj !== undefined && obj !== null) obj = obj[token]; } - const objValue = typeof obj === 'string' && !attr.caseSensitive ? obj.toUpperCase() : obj; + return matchesAttribute(obj, attr); +} + +export function matchesAttribute(value: any, attr: ParsedComponentAttribute) { + const objValue = typeof value === 'string' && !attr.caseSensitive ? value.toUpperCase() : value; const attrValue = typeof attr.value === 'string' && !attr.caseSensitive ? attr.value.toUpperCase() : attr.value; if (attr.op === '') return !!objValue; - if (attr.op === '=') + if (attr.op === '=') { + if (attrValue instanceof RegExp) + return typeof objValue === 'string' && !!objValue.match(attrValue); return objValue === attrValue; + } if (typeof objValue !== 'string' || typeof attrValue !== 'string') return false; if (attr.op === '*=') @@ -100,6 +107,39 @@ export function parseComponentSelector(selector: string): ParsedComponentSelecto return result; } + function readRegularExpression() { + if (eat1() !== '/') + syntaxError('parsing regular expression'); + let source = ''; + let inClass = false; + // https://262.ecma-international.org/11.0/#sec-literals-regular-expression-literals + while (!EOL) { + if (next() === '\\') { + source += eat1(); + if (EOL) + syntaxError('parsing regular expressiion'); + } else if (inClass && next() === ']') { + inClass = false; + } else if (!inClass && next() === '[') { + inClass = true; + } else if (!inClass && next() === '/') { + break; + } + source += eat1(); + } + if (eat1() !== '/') + syntaxError('parsing regular expression'); + let flags = ''; + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions + while (!EOL && next().match(/[dgimsuy]/)) + flags += eat1(); + try { + return new RegExp(source, flags); + } catch (e) { + throw new Error(`Error while parsing selector \`${selector}\`: ${e.message}`); + } + } + function readAttributeToken() { let token = ''; skipSpaces(); @@ -150,7 +190,11 @@ export function parseComponentSelector(selector: string): ParsedComponentSelecto let value = undefined; let caseSensitive = true; skipSpaces(); - if (next() === `'` || next() === `"`) { + if (next() === '/') { + if (operator !== '=') + throw new Error(`Error while parsing selector \`${selector}\` - cannot use ${operator} in attribute with regular expression`); + value = readRegularExpression(); + } else if (next() === `'` || next() === `"`) { value = readQuotedString(next()).slice(1, -1); skipSpaces(); if (next() === 'i' || next() === 'I') { diff --git a/tests/component-parser.spec.ts b/tests/component-parser.spec.ts index cbde2fd6a1..fe03b6fb61 100644 --- a/tests/component-parser.spec.ts +++ b/tests/component-parser.spec.ts @@ -23,7 +23,8 @@ const serialize = (parsed: ParsedComponentSelector) => { const path = attr.jsonPath.map(token => /^[a-zA-Z0-9]+$/i.test(token) ? token : JSON.stringify(token)).join('.'); if (attr.op === '') return '[' + path + ']'; - return '[' + path + ' ' + attr.op + ' ' + JSON.stringify(attr.value) + (attr.caseSensitive ? ']' : ' i]'); + const value = attr.value instanceof RegExp ? attr.value.toString() : JSON.stringify(attr.value); + return '[' + path + ' ' + attr.op + ' ' + value + (attr.caseSensitive ? ']' : ' i]'); }).join(''); }; @@ -99,6 +100,13 @@ it('shoulud parse bool', async () => { expect(serialize(parse(`ColorButton[ enabled =true][ color = "red"i][nested.index = 6]`))).toBe('ColorButton[enabled = true][color = "red" i][nested.index = 6]'); }); +it('should parse regex', async () => { + expect(serialize(parse(`ColorButton[color = /red$/]`))).toBe('ColorButton[color = /red$/]'); + expect(serialize(parse(`ColorButton[color=/red/ig]`))).toBe('ColorButton[color = /red/gi]'); + expect(serialize(parse(`ColorButton[color= / \\/ [/]/ ]`))).toBe('ColorButton[color = / \\/ [/]/]'); + expect(serialize(parse(`ColorButton[color=/[\\]/][[/]/]`))).toBe('ColorButton[color = /[\\]/][[/]/]'); +}); + it('should throw on malformed selector', async () => { expectError('foo['); expectError('foo['); @@ -129,4 +137,10 @@ it('should throw on malformed selector', async () => { expectError('[foo=abc \s]'); expectError('[foo=abc"\s"]'); expectError('[foo="\\"]'); + expectError('[foo s]'); + expectError('[foo*=/bar/]'); + expectError('[foo=/bar/ s]'); + expectError('[foo=/bar//]'); + expectError('[foo=/bar/pt]'); + expectError('[foo=/[\\]/'); }); diff --git a/tests/page/selectors-react.spec.ts b/tests/page/selectors-react.spec.ts index a3d6788f00..068c0fda64 100644 --- a/tests/page/selectors-react.spec.ts +++ b/tests/page/selectors-react.spec.ts @@ -102,6 +102,15 @@ for (const [name, url] of Object.entries(reacts)) { expect(await page.$$eval(`_react=BookItem[name *= " gatsby" i]`, els => els.length)).toBe(1); }); + it('should support regex', async ({ page }) => { + expect(await page.$$eval(`_react=ColorButton[color = /red/]`, els => els.length)).toBe(3); + expect(await page.$$eval(`_react=ColorButton[color = /^red$/]`, els => els.length)).toBe(3); + expect(await page.$$eval(`_react=ColorButton[color = /RED/i]`, els => els.length)).toBe(3); + expect(await page.$$eval(`_react=ColorButton[color = /[pqr]ed/]`, els => els.length)).toBe(3); + expect(await page.$$eval(`_react=ColorButton[color = /[pq]ed/]`, els => els.length)).toBe(0); + expect(await page.$$eval(`_react=BookItem[name = /gat.by/i]`, els => els.length)).toBe(1); + }); + it('should support truthy querying', async ({ page }) => { expect(await page.$$eval(`_react=ColorButton[enabled]`, els => els.length)).toBe(5); }); diff --git a/tests/page/selectors-vue.spec.ts b/tests/page/selectors-vue.spec.ts index eaa2edb63f..1412a100d9 100644 --- a/tests/page/selectors-vue.spec.ts +++ b/tests/page/selectors-vue.spec.ts @@ -102,6 +102,15 @@ for (const [name, url] of Object.entries(vues)) { expect(await page.$$eval(`_vue=book-item[name *= " gatsby" i]`, els => els.length)).toBe(1); }); + it('should support regex', async ({ page }) => { + expect(await page.$$eval(`_vue=color-button[color = /red/]`, els => els.length)).toBe(3); + expect(await page.$$eval(`_vue=color-button[color = /^red$/]`, els => els.length)).toBe(3); + expect(await page.$$eval(`_vue=color-button[color = /RED/i]`, els => els.length)).toBe(3); + expect(await page.$$eval(`_vue=color-button[color = /[pqr]ed/]`, els => els.length)).toBe(3); + expect(await page.$$eval(`_vue=color-button[color = /[pq]ed/]`, els => els.length)).toBe(0); + expect(await page.$$eval(`_vue=book-item[name = /gat.by/i]`, els => els.length)).toBe(1); + }); + it('should support truthy querying', async ({ page }) => { expect(await page.$$eval(`_vue=color-button[enabled]`, els => els.length)).toBe(5); });