diff --git a/packages/playwright-core/src/server/injected/selectorEvaluator.ts b/packages/playwright-core/src/server/injected/selectorEvaluator.ts index 5428e22500..098cbb59ce 100644 --- a/packages/playwright-core/src/server/injected/selectorEvaluator.ts +++ b/packages/playwright-core/src/server/injected/selectorEvaluator.ts @@ -24,6 +24,8 @@ import { normalizeWhiteSpace } from '../../utils/isomorphic/stringUtils'; type QueryContext = { scope: Element | Document; pierceShadow: boolean; + // When context expands to accomodate :scope matching, original scope is saved here. + originalScope?: Element | Document; // Place for more options, e.g. normalizing whitespace. }; export type Selector = any; // Opaque selector type. @@ -123,9 +125,11 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { const selector = this._checkSelector(s); this.begin(); try { - return this._cached(this._cacheMatches, element, [selector, context.scope, context.pierceShadow], () => { + return this._cached(this._cacheMatches, element, [selector, context.scope, context.pierceShadow, context.originalScope], () => { if (Array.isArray(selector)) return this._matchesEngine(isEngine, element, selector, context); + if (this._hasScopeClause(selector)) + context = this._expandContextForScopeMatching(context); if (!this._matchesSimple(element, selector.simples[selector.simples.length - 1].selector, context)) return false; return this._matchesParents(element, selector, selector.simples.length - 2, context); @@ -139,9 +143,11 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { const selector = this._checkSelector(s); this.begin(); try { - return this._cached(this._cacheQuery, selector, [context.scope, context.pierceShadow], () => { + return this._cached(this._cacheQuery, selector, [context.scope, context.pierceShadow, context.originalScope], () => { if (Array.isArray(selector)) return this._queryEngine(isEngine, context, selector); + if (this._hasScopeClause(selector)) + context = this._expandContextForScopeMatching(context); // query() recursively calls itself, so we set up a new map for this particular query() call. const previousScoreMap = this._scoreMap; @@ -177,10 +183,22 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { this._scoreMap.set(element, score); } + private _hasScopeClause(selector: CSSComplexSelector): boolean { + return selector.simples.some(simple => simple.selector.functions.some(f => f.name === 'scope')); + } + + private _expandContextForScopeMatching(context: QueryContext): QueryContext { + if (context.scope.nodeType !== 1 /* Node.ELEMENT_NODE */) + return context; + const scope = parentElementOrShadowHost(context.scope as Element); + if (!scope) + return context; + return { ...context, scope, originalScope: context.originalScope || context.scope }; + } + private _matchesSimple(element: Element, simple: CSSSimpleSelector, context: QueryContext): boolean { - return this._cached(this._cacheMatchesSimple, element, [simple, context.scope, context.pierceShadow], () => { - const isPossiblyScopeClause = simple.functions.some(f => f.name === 'scope' || f.name === 'is'); - if (!isPossiblyScopeClause && element === context.scope) + return this._cached(this._cacheMatchesSimple, element, [simple, context.scope, context.pierceShadow, context.originalScope], () => { + if (element === context.scope) return false; if (simple.css && !this._matchesCSS(element, simple.css)) return false; @@ -196,7 +214,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { if (!simple.functions.length) return this._queryCSS(context, simple.css || '*'); - return this._cached(this._cacheQuerySimple, simple, [context.scope, context.pierceShadow], () => { + return this._cached(this._cacheQuerySimple, simple, [context.scope, context.pierceShadow, context.originalScope], () => { let css = simple.css; const funcs = simple.functions; if (css === '*' && funcs.length) @@ -206,9 +224,6 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { let firstIndex = -1; if (css !== undefined) { elements = this._queryCSS(context, css); - const hasScopeClause = funcs.some(f => f.name === 'scope'); - if (hasScopeClause && context.scope.nodeType === 1 /* Node.ELEMENT_NODE */ && this._matchesCSS(context.scope as Element, css)) - elements.unshift(context.scope as Element); } else { firstIndex = funcs.findIndex(func => this._getEngine(func.name).query !== undefined); if (firstIndex === -1) @@ -236,7 +251,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { private _matchesParents(element: Element, complex: CSSComplexSelector, index: number, context: QueryContext): boolean { if (index < 0) return true; - return this._cached(this._cacheMatchesParents, element, [complex, index, context.scope, context.pierceShadow], () => { + return this._cached(this._cacheMatchesParents, element, [complex, index, context.scope, context.pierceShadow, context.originalScope], () => { const { selector: simple, combinator } = complex.simples[index]; if (combinator === '>') { const parent = parentElementOrShadowHostInContext(element, context); @@ -310,13 +325,13 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { } private _callMatches(engine: SelectorEngine, element: Element, args: CSSFunctionArgument[], context: QueryContext): boolean { - return this._cached(this._cacheCallMatches, element, [engine, context.scope, context.pierceShadow, ...args], () => { + return this._cached(this._cacheCallMatches, element, [engine, context.scope, context.pierceShadow, context.originalScope, ...args], () => { return engine.matches!(element, args, context, this); }); } private _callQuery(engine: SelectorEngine, args: CSSFunctionArgument[], context: QueryContext): Element[] { - return this._cached(this._cacheCallQuery, engine, [context.scope, context.pierceShadow, ...args], () => { + return this._cached(this._cacheCallQuery, engine, [context.scope, context.pierceShadow, context.originalScope, ...args], () => { return engine.query!(context, args, this); }); } @@ -326,7 +341,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { } _queryCSS(context: QueryContext, css: string): Element[] { - return this._cached(this._cacheQueryCSS, css, [context.scope, context.pierceShadow], () => { + return this._cached(this._cacheQueryCSS, css, [context.scope, context.pierceShadow, context.originalScope], () => { let result: Element[] = []; function query(root: Element | ShadowRoot | Document) { result = result.concat([...root.querySelectorAll(css)]); @@ -384,20 +399,22 @@ const scopeEngine: SelectorEngine = { matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean { if (args.length !== 0) throw new Error(`"scope" engine expects no arguments`); - if (context.scope.nodeType === 9 /* Node.DOCUMENT_NODE */) - return element === (context.scope as Document).documentElement; - return element === context.scope; + const actualScope = context.originalScope || context.scope; + if (actualScope.nodeType === 9 /* Node.DOCUMENT_NODE */) + return element === (actualScope as Document).documentElement; + return element === actualScope; }, query(context: QueryContext, args: (string | number | Selector)[], evaluator: SelectorEvaluator): Element[] { if (args.length !== 0) throw new Error(`"scope" engine expects no arguments`); - if (context.scope.nodeType === 9 /* Node.DOCUMENT_NODE */) { - const root = (context.scope as Document).documentElement; + const actualScope = context.originalScope || context.scope; + if (actualScope.nodeType === 9 /* Node.DOCUMENT_NODE */) { + const root = (actualScope as Document).documentElement; return root ? [root] : []; } - if (context.scope.nodeType === 1 /* Node.ELEMENT_NODE */) - return [context.scope as Element]; + if (actualScope.nodeType === 1 /* Node.ELEMENT_NODE */) + return [actualScope as Element]; return []; }, }; diff --git a/packages/playwright-core/src/utils/isomorphic/cssParser.ts b/packages/playwright-core/src/utils/isomorphic/cssParser.ts index d5fb882310..5b45bcaa4e 100644 --- a/packages/playwright-core/src/utils/isomorphic/cssParser.ts +++ b/packages/playwright-core/src/utils/isomorphic/cssParser.ts @@ -143,7 +143,7 @@ export function parseCSS(selector: string, customNames: Set): { selector const result: CSSComplexSelector = { simples: [] }; skipWhitespace(); if (isClauseCombinator()) { - // Put implicit ":scope" at the start. https://drafts.csswg.org/selectors-4/#absolutize + // Put implicit ":scope" at the start. https://drafts.csswg.org/selectors-4/#relative result.simples.push({ selector: { functions: [{ name: 'scope', args: [] }] }, combinator: '' }); } else { result.simples.push({ selector: consumeSimpleSelector(), combinator: '' }); diff --git a/tests/page/selectors-css.spec.ts b/tests/page/selectors-css.spec.ts index be03ac1e7a..cb12f40807 100644 --- a/tests/page/selectors-css.spec.ts +++ b/tests/page/selectors-css.spec.ts @@ -289,6 +289,12 @@ it('should work with ~', async ({ page }) => {
`); + expect(await page.$$eval(`#div3 >> :scope ~ div`, els => els.map(e => e.id))).toEqual(['div4', 'div5', 'div6']); + expect(await page.$$eval(`#div3 >> :scope ~ *`, els => els.map(e => e.id))).toEqual(['div4', 'div5', 'div6']); + expect(await page.$$eval(`#div3 >> ~ div`, els => els.map(e => e.id))).toEqual(['div4', 'div5', 'div6']); + expect(await page.$$eval(`#div3 >> ~ *`, els => els.map(e => e.id))).toEqual(['div4', 'div5', 'div6']); + expect(await page.$$eval(`#div3 >> #div1 ~ :scope`, els => els.map(e => e.id))).toEqual(['div3']); + expect(await page.$$eval(`#div3 >> #div4 ~ :scope`, els => els.map(e => e.id))).toEqual([]); expect(await page.$$eval(`css=#div1 ~ div ~ #div6`, els => els.length)).toBe(1); expect(await page.$$eval(`css=#div1 ~ div ~ div`, els => els.length)).toBe(4); expect(await page.$$eval(`css=#div3 ~ div ~ div`, els => els.length)).toBe(2); @@ -309,6 +315,12 @@ it('should work with +', async ({ page }) => {
`); + expect(await page.$$eval(`#div1 >> :scope+div`, els => els.map(e => e.id))).toEqual(['div2']); + expect(await page.$$eval(`#div1 >> :scope+*`, els => els.map(e => e.id))).toEqual(['div2']); + expect(await page.$$eval(`#div1 >> + div`, els => els.map(e => e.id))).toEqual(['div2']); + expect(await page.$$eval(`#div1 >> + *`, els => els.map(e => e.id))).toEqual(['div2']); + expect(await page.$$eval(`#div3 >> div + :scope`, els => els.map(e => e.id))).toEqual(['div3']); + expect(await page.$$eval(`#div3 >> #div1 + :scope`, els => els.map(e => e.id))).toEqual([]); expect(await page.$$eval(`css=#div1 ~ div + #div6`, els => els.length)).toBe(1); expect(await page.$$eval(`css=#div1 ~ div + div`, els => els.length)).toBe(4); expect(await page.$$eval(`css=#div3 + div + div`, els => els.length)).toBe(1); @@ -321,8 +333,7 @@ it('should work with +', async ({ page }) => { expect(await page.$$eval(`css=section > div + #div4 ~ div`, els => els.length)).toBe(2); expect(await page.$$eval(`css=section:has(:scope > div + #div2)`, els => els.length)).toBe(1); expect(await page.$$eval(`css=section:has(:scope > div + #div1)`, els => els.length)).toBe(0); - // TODO: the following does not work. Should it? - // expect(await page.$eval(`css=div:has(:scope + #div5)`, e => e.id)).toBe('div4'); + expect(await page.$eval(`css=div:has(:scope + #div5)`, e => e.id)).toBe('div4'); }); it('should work with spaces in :nth-child and :not', async ({ page, server }) => { @@ -390,6 +401,16 @@ it('should work with :scope', async ({ page, server }) => { expect(await page.$eval(`div >> :scope:nth-child(1)`, e => e.textContent)).toBe('hello'); expect(await page.$eval(`div >> :scope.target:has(span)`, e => e.textContent)).toBe('hello'); expect(await page.$eval(`html:scope`, e => e.nodeName)).toBe('HTML'); + + await page.setContent(`
`); + expect(await page.$$eval(`#span1 >> span:not(:has(:scope > div))`, els => els.map(e => e.id))).toEqual(['inner']); + expect(await page.$$eval(`#span1 >> #inner,:scope`, els => els.map(e => e.id))).toEqual(['span1', 'inner']); + expect(await page.$$eval(`#span1 >> span,:scope`, els => els.map(e => e.id))).toEqual(['span1', 'inner']); + expect(await page.$$eval(`#span1 >> span:not(:scope)`, els => els.map(e => e.id))).toEqual(['inner']); + // TODO: the following two do not work. We do not expand the context for the inner :scope, + // because we should only expand for one clause of :is() that contains :scope, but not the other. + // expect(await page.$$eval(`#span1 >> span:is(:scope)`, els => els.map(e => e.id))).toEqual(['span1']); + // expect(await page.$$eval(`#span1 >> span:is(:scope,#inner)`, els => els.map(e => e.id))).toEqual(['span1', 'inner']); }); it('should work with :scope and class', async ({ page }) => {