From 100d3b260171746cf6012a035535731c08ce19f0 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 27 Oct 2023 13:16:12 -0700 Subject: [PATCH] fix(css parser): support nested builtin functions (#27841) Things like `:nth-child(1 of :has(span:nth-last-child(3)))`. Fixes #27743. --- .../src/utils/isomorphic/cssParser.ts | 22 ++++++++++++++--- tests/page/selectors-css.spec.ts | 24 +++++++++++++++++++ 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/packages/playwright-core/src/utils/isomorphic/cssParser.ts b/packages/playwright-core/src/utils/isomorphic/cssParser.ts index 5b45bcaa4e..58d4df243b 100644 --- a/packages/playwright-core/src/utils/isomorphic/cssParser.ts +++ b/packages/playwright-core/src/utils/isomorphic/cssParser.ts @@ -98,10 +98,18 @@ export function parseCSS(selector: string, customNames: Set): { selector return tokens[p] instanceof css.CommaToken; } + function isOpenParen(p = pos) { + return tokens[p] instanceof css.OpenParenToken; + } + function isCloseParen(p = pos) { return tokens[p] instanceof css.CloseParenToken; } + function isFunction(p = pos) { + return tokens[p] instanceof css.FunctionToken; + } + function isStar(p = pos) { return (tokens[p] instanceof css.DelimToken) && tokens[p].value === '*'; } @@ -186,7 +194,7 @@ export function parseCSS(selector: string, customNames: Set): { selector functions.push({ name, args: [] }); names.add(name); } - } else if (tokens[pos] instanceof css.FunctionToken) { + } else if (isFunction()) { const name = (tokens[pos++].value as string).toLowerCase(); if (!customNames.has(name)) { rawCSSString += `:${name}(${consumeBuiltinFunctionArguments()})`; @@ -221,14 +229,22 @@ export function parseCSS(selector: string, customNames: Set): { selector function consumeBuiltinFunctionArguments(): string { let s = ''; - while (!isCloseParen() && !isEOF()) + let balance = 1; // First open paren is a part of a function token. + while (!isEOF()) { + if (isOpenParen() || isFunction()) + balance++; + if (isCloseParen()) + balance--; + if (!balance) + break; s += tokens[pos++].toSource(); + } return s; } const result = consumeFunctionArguments(); if (!isEOF()) - throw new InvalidSelectorError(`Error while parsing selector "${selector}"`); + throw unexpected(); if (result.some(arg => typeof arg !== 'object' || !('simples' in arg))) throw new InvalidSelectorError(`Error while parsing selector "${selector}"`); return { selector: result as CSSComplexSelector[], names: Array.from(names) }; diff --git a/tests/page/selectors-css.spec.ts b/tests/page/selectors-css.spec.ts index 302def0a51..5ce11f11ba 100644 --- a/tests/page/selectors-css.spec.ts +++ b/tests/page/selectors-css.spec.ts @@ -274,6 +274,30 @@ it('should work with :nth-child', async ({ page, server }) => { expect(await page.$$eval(`css=span:nth-child(23n+2)`, els => els.length)).toBe(1); }); +it('should work with :nth-child(of) notation with nested functions', async ({ page, browserName }) => { + it.fixme(browserName === 'firefox', 'Should enable once Firefox supports this syntax'); + + await page.setContent(` +
+ span1 + span2
+ span3
+ span4
+ span5
+ span6 +
+ `); + expect(await page.$$eval(`css=span:nth-child(1)`, els => els.map(e => e.textContent))).toEqual(['span1']); + expect(await page.$$eval(`css=span:nth-child(1 of .foo)`, els => els.map(e => e.textContent))).toEqual(['span2']); + expect(await page.$$eval(`css=span:nth-child(1 of .foo:has(dd.marker))`, els => els.map(e => e.textContent))).toEqual(['span3']); + expect(await page.$$eval(`css=span:nth-last-child(1 of .foo:has(dd.marker))`, els => els.map(e => e.textContent))).toEqual(['span4']); + expect(await page.$$eval(`css=span:nth-last-child(1 of .foo)`, els => els.map(e => e.textContent))).toEqual(['span5']); + expect(await page.$$eval(`css=span:nth-last-child( 1 )`, els => els.map(e => e.textContent))).toEqual(['span6']); + + expect(await page.$$eval(`css=span:nth-child(1 of .foo:nth-child(3))`, els => els.map(e => e.textContent))).toEqual(['span3']); + expect(await page.$$eval(`css=span:nth-child(1 of .foo:nth-child(6))`, els => els.map(e => e.textContent))).toEqual([]); +}); + it('should work with :not', async ({ page, server }) => { await page.goto(server.PREFIX + '/deep-shadow.html'); expect(await page.$$eval(`css=div:not(#root1)`, els => els.length)).toBe(2);