mirror of
https://github.com/microsoft/playwright.git
synced 2025-01-05 19:04:43 +03:00
fix(css): relative-to-scope selectors work (#23665)
Chained selectors where the second part starts with a scope did not work before: ```ts page.locator('div').locator(':scope + span') page.locator('div >> +span') ```
This commit is contained in:
parent
76532160d3
commit
c80a23842b
@ -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<boolean>(this._cacheMatches, element, [selector, context.scope, context.pierceShadow], () => {
|
||||
return this._cached<boolean>(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<Element[]>(this._cacheQuery, selector, [context.scope, context.pierceShadow], () => {
|
||||
return this._cached<Element[]>(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<boolean>(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<boolean>(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<Element[]>(this._cacheQuerySimple, simple, [context.scope, context.pierceShadow], () => {
|
||||
return this._cached<Element[]>(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<boolean>(this._cacheMatchesParents, element, [complex, index, context.scope, context.pierceShadow], () => {
|
||||
return this._cached<boolean>(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<boolean>(this._cacheCallMatches, element, [engine, context.scope, context.pierceShadow, ...args], () => {
|
||||
return this._cached<boolean>(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<Element[]>(this._cacheCallQuery, engine, [context.scope, context.pierceShadow, ...args], () => {
|
||||
return this._cached<Element[]>(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<Element[]>(this._cacheQueryCSS, css, [context.scope, context.pierceShadow], () => {
|
||||
return this._cached<Element[]>(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 [];
|
||||
},
|
||||
};
|
||||
|
@ -143,7 +143,7 @@ export function parseCSS(selector: string, customNames: Set<string>): { 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: '' });
|
||||
|
@ -289,6 +289,12 @@ it('should work with ~', async ({ page }) => {
|
||||
<div id=div5></div>
|
||||
<div id=div6></div>
|
||||
`);
|
||||
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 }) => {
|
||||
<div id=div6></div>
|
||||
</section>
|
||||
`);
|
||||
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(`<section><span id=span1><span id=inner></span></span><span id=span2></span></section>`);
|
||||
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 }) => {
|
||||
|
Loading…
Reference in New Issue
Block a user