mirror of
https://github.com/microsoft/playwright.git
synced 2025-01-07 11:46:42 +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 = {
|
type QueryContext = {
|
||||||
scope: Element | Document;
|
scope: Element | Document;
|
||||||
pierceShadow: boolean;
|
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.
|
// Place for more options, e.g. normalizing whitespace.
|
||||||
};
|
};
|
||||||
export type Selector = any; // Opaque selector type.
|
export type Selector = any; // Opaque selector type.
|
||||||
@ -123,9 +125,11 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
|||||||
const selector = this._checkSelector(s);
|
const selector = this._checkSelector(s);
|
||||||
this.begin();
|
this.begin();
|
||||||
try {
|
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))
|
if (Array.isArray(selector))
|
||||||
return this._matchesEngine(isEngine, element, selector, context);
|
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))
|
if (!this._matchesSimple(element, selector.simples[selector.simples.length - 1].selector, context))
|
||||||
return false;
|
return false;
|
||||||
return this._matchesParents(element, selector, selector.simples.length - 2, context);
|
return this._matchesParents(element, selector, selector.simples.length - 2, context);
|
||||||
@ -139,9 +143,11 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
|||||||
const selector = this._checkSelector(s);
|
const selector = this._checkSelector(s);
|
||||||
this.begin();
|
this.begin();
|
||||||
try {
|
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))
|
if (Array.isArray(selector))
|
||||||
return this._queryEngine(isEngine, context, 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.
|
// query() recursively calls itself, so we set up a new map for this particular query() call.
|
||||||
const previousScoreMap = this._scoreMap;
|
const previousScoreMap = this._scoreMap;
|
||||||
@ -177,10 +183,22 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
|||||||
this._scoreMap.set(element, score);
|
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 {
|
private _matchesSimple(element: Element, simple: CSSSimpleSelector, context: QueryContext): boolean {
|
||||||
return this._cached<boolean>(this._cacheMatchesSimple, element, [simple, context.scope, context.pierceShadow], () => {
|
return this._cached<boolean>(this._cacheMatchesSimple, element, [simple, context.scope, context.pierceShadow, context.originalScope], () => {
|
||||||
const isPossiblyScopeClause = simple.functions.some(f => f.name === 'scope' || f.name === 'is');
|
if (element === context.scope)
|
||||||
if (!isPossiblyScopeClause && element === context.scope)
|
|
||||||
return false;
|
return false;
|
||||||
if (simple.css && !this._matchesCSS(element, simple.css))
|
if (simple.css && !this._matchesCSS(element, simple.css))
|
||||||
return false;
|
return false;
|
||||||
@ -196,7 +214,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
|||||||
if (!simple.functions.length)
|
if (!simple.functions.length)
|
||||||
return this._queryCSS(context, simple.css || '*');
|
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;
|
let css = simple.css;
|
||||||
const funcs = simple.functions;
|
const funcs = simple.functions;
|
||||||
if (css === '*' && funcs.length)
|
if (css === '*' && funcs.length)
|
||||||
@ -206,9 +224,6 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
|||||||
let firstIndex = -1;
|
let firstIndex = -1;
|
||||||
if (css !== undefined) {
|
if (css !== undefined) {
|
||||||
elements = this._queryCSS(context, css);
|
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 {
|
} else {
|
||||||
firstIndex = funcs.findIndex(func => this._getEngine(func.name).query !== undefined);
|
firstIndex = funcs.findIndex(func => this._getEngine(func.name).query !== undefined);
|
||||||
if (firstIndex === -1)
|
if (firstIndex === -1)
|
||||||
@ -236,7 +251,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
|||||||
private _matchesParents(element: Element, complex: CSSComplexSelector, index: number, context: QueryContext): boolean {
|
private _matchesParents(element: Element, complex: CSSComplexSelector, index: number, context: QueryContext): boolean {
|
||||||
if (index < 0)
|
if (index < 0)
|
||||||
return true;
|
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];
|
const { selector: simple, combinator } = complex.simples[index];
|
||||||
if (combinator === '>') {
|
if (combinator === '>') {
|
||||||
const parent = parentElementOrShadowHostInContext(element, context);
|
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 {
|
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);
|
return engine.matches!(element, args, context, this);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private _callQuery(engine: SelectorEngine, args: CSSFunctionArgument[], context: QueryContext): Element[] {
|
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);
|
return engine.query!(context, args, this);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -326,7 +341,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_queryCSS(context: QueryContext, css: string): Element[] {
|
_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[] = [];
|
let result: Element[] = [];
|
||||||
function query(root: Element | ShadowRoot | Document) {
|
function query(root: Element | ShadowRoot | Document) {
|
||||||
result = result.concat([...root.querySelectorAll(css)]);
|
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 {
|
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
|
||||||
if (args.length !== 0)
|
if (args.length !== 0)
|
||||||
throw new Error(`"scope" engine expects no arguments`);
|
throw new Error(`"scope" engine expects no arguments`);
|
||||||
if (context.scope.nodeType === 9 /* Node.DOCUMENT_NODE */)
|
const actualScope = context.originalScope || context.scope;
|
||||||
return element === (context.scope as Document).documentElement;
|
if (actualScope.nodeType === 9 /* Node.DOCUMENT_NODE */)
|
||||||
return element === context.scope;
|
return element === (actualScope as Document).documentElement;
|
||||||
|
return element === actualScope;
|
||||||
},
|
},
|
||||||
|
|
||||||
query(context: QueryContext, args: (string | number | Selector)[], evaluator: SelectorEvaluator): Element[] {
|
query(context: QueryContext, args: (string | number | Selector)[], evaluator: SelectorEvaluator): Element[] {
|
||||||
if (args.length !== 0)
|
if (args.length !== 0)
|
||||||
throw new Error(`"scope" engine expects no arguments`);
|
throw new Error(`"scope" engine expects no arguments`);
|
||||||
if (context.scope.nodeType === 9 /* Node.DOCUMENT_NODE */) {
|
const actualScope = context.originalScope || context.scope;
|
||||||
const root = (context.scope as Document).documentElement;
|
if (actualScope.nodeType === 9 /* Node.DOCUMENT_NODE */) {
|
||||||
|
const root = (actualScope as Document).documentElement;
|
||||||
return root ? [root] : [];
|
return root ? [root] : [];
|
||||||
}
|
}
|
||||||
if (context.scope.nodeType === 1 /* Node.ELEMENT_NODE */)
|
if (actualScope.nodeType === 1 /* Node.ELEMENT_NODE */)
|
||||||
return [context.scope as Element];
|
return [actualScope as Element];
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -143,7 +143,7 @@ export function parseCSS(selector: string, customNames: Set<string>): { selector
|
|||||||
const result: CSSComplexSelector = { simples: [] };
|
const result: CSSComplexSelector = { simples: [] };
|
||||||
skipWhitespace();
|
skipWhitespace();
|
||||||
if (isClauseCombinator()) {
|
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: '' });
|
result.simples.push({ selector: { functions: [{ name: 'scope', args: [] }] }, combinator: '' });
|
||||||
} else {
|
} else {
|
||||||
result.simples.push({ selector: consumeSimpleSelector(), combinator: '' });
|
result.simples.push({ selector: consumeSimpleSelector(), combinator: '' });
|
||||||
|
@ -289,6 +289,12 @@ it('should work with ~', async ({ page }) => {
|
|||||||
<div id=div5></div>
|
<div id=div5></div>
|
||||||
<div id=div6></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 ~ #div6`, els => els.length)).toBe(1);
|
||||||
expect(await page.$$eval(`css=#div1 ~ div ~ div`, els => els.length)).toBe(4);
|
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);
|
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>
|
<div id=div6></div>
|
||||||
</section>
|
</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 + #div6`, els => els.length)).toBe(1);
|
||||||
expect(await page.$$eval(`css=#div1 ~ div + div`, els => els.length)).toBe(4);
|
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);
|
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 > 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 + #div2)`, els => els.length)).toBe(1);
|
||||||
expect(await page.$$eval(`css=section:has(:scope > div + #div1)`, els => els.length)).toBe(0);
|
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 }) => {
|
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: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(`div >> :scope.target:has(span)`, e => e.textContent)).toBe('hello');
|
||||||
expect(await page.$eval(`html:scope`, e => e.nodeName)).toBe('HTML');
|
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 }) => {
|
it('should work with :scope and class', async ({ page }) => {
|
||||||
|
Loading…
Reference in New Issue
Block a user