feat(selectors): introduce css evaluator (#4573)
Not used for production yet.
This commit is contained in:
parent
52ae218bfc
commit
3d6194e8a1
|
|
@ -23,7 +23,7 @@ module.exports = {
|
||||||
module: {
|
module: {
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
test: /\.tsx?$/,
|
test: /\.(j|t)sx?$/,
|
||||||
loader: 'ts-loader',
|
loader: 'ts-loader',
|
||||||
options: {
|
options: {
|
||||||
transpileOnly: true
|
transpileOnly: true
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,10 @@ type ClauseCombinator = '' | '>' | '+' | '~';
|
||||||
export type CSSFunctionArgument = CSSComplexSelector | number | string;
|
export type CSSFunctionArgument = CSSComplexSelector | number | string;
|
||||||
export type CSSFunction = { name: string, args: CSSFunctionArgument[] };
|
export type CSSFunction = { name: string, args: CSSFunctionArgument[] };
|
||||||
export type CSSSimpleSelector = { css?: string, functions: CSSFunction[] };
|
export type CSSSimpleSelector = { css?: string, functions: CSSFunction[] };
|
||||||
export type CSSComplexSelector = { simple: { selector: CSSSimpleSelector, combinator: ClauseCombinator }[] };
|
export type CSSComplexSelector = { simples: { selector: CSSSimpleSelector, combinator: ClauseCombinator }[] };
|
||||||
export type CSSSelectorList = CSSComplexSelector[];
|
export type CSSComplexSelectorList = CSSComplexSelector[];
|
||||||
|
|
||||||
export function parseCSS(selector: string): CSSSelectorList {
|
export function parseCSS(selector: string): CSSComplexSelectorList {
|
||||||
let tokens: css.CSSTokenInterface[];
|
let tokens: css.CSSTokenInterface[];
|
||||||
try {
|
try {
|
||||||
tokens = css.tokenize(selector);
|
tokens = css.tokenize(selector);
|
||||||
|
|
@ -131,16 +131,16 @@ export function parseCSS(selector: string): CSSSelectorList {
|
||||||
|
|
||||||
function consumeComplexSelector(): CSSComplexSelector {
|
function consumeComplexSelector(): CSSComplexSelector {
|
||||||
skipWhitespace();
|
skipWhitespace();
|
||||||
const result = { simple: [{ selector: consumeSimpleSelector(), combinator: '' as ClauseCombinator }] };
|
const result = { simples: [{ selector: consumeSimpleSelector(), combinator: '' as ClauseCombinator }] };
|
||||||
while (true) {
|
while (true) {
|
||||||
skipWhitespace();
|
skipWhitespace();
|
||||||
if (isClauseCombinator()) {
|
if (isClauseCombinator()) {
|
||||||
result.simple[result.simple.length - 1].combinator = tokens[pos++].value as ClauseCombinator;
|
result.simples[result.simples.length - 1].combinator = tokens[pos++].value as ClauseCombinator;
|
||||||
skipWhitespace();
|
skipWhitespace();
|
||||||
} else if (isSelectorClauseEnd()) {
|
} else if (isSelectorClauseEnd()) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
result.simple.push({ combinator: '', selector: consumeSimpleSelector() });
|
result.simples.push({ combinator: '', selector: consumeSimpleSelector() });
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
@ -163,12 +163,12 @@ export function parseCSS(selector: string): CSSSelectorList {
|
||||||
} else if (tokens[pos] instanceof css.ColonToken) {
|
} else if (tokens[pos] instanceof css.ColonToken) {
|
||||||
pos++;
|
pos++;
|
||||||
if (isIdent()) {
|
if (isIdent()) {
|
||||||
if (builtinCSSFilters.has(tokens[pos].value))
|
if (builtinCSSFilters.has(tokens[pos].value.toLowerCase()))
|
||||||
rawCSSString += ':' + tokens[pos++].toSource();
|
rawCSSString += ':' + tokens[pos++].toSource();
|
||||||
else
|
else
|
||||||
functions.push({ name: tokens[pos++].value, args: [] });
|
functions.push({ name: tokens[pos++].value.toLowerCase(), args: [] });
|
||||||
} else if (tokens[pos] instanceof css.FunctionToken) {
|
} else if (tokens[pos] instanceof css.FunctionToken) {
|
||||||
const name = tokens[pos++].value;
|
const name = tokens[pos++].value.toLowerCase();
|
||||||
if (builtinCSSFunctions.has(name))
|
if (builtinCSSFunctions.has(name))
|
||||||
rawCSSString += `:${name}(${consumeBuiltinFunctionArguments()})`;
|
rawCSSString += `:${name}(${consumeBuiltinFunctionArguments()})`;
|
||||||
else
|
else
|
||||||
|
|
@ -208,7 +208,7 @@ export function parseCSS(selector: string): CSSSelectorList {
|
||||||
const result = consumeFunctionArguments();
|
const result = consumeFunctionArguments();
|
||||||
if (!isEOF())
|
if (!isEOF())
|
||||||
throw new Error(`Error while parsing selector "${selector}"`);
|
throw new Error(`Error while parsing selector "${selector}"`);
|
||||||
if (result.some(arg => typeof arg !== 'object' || !('simple' in arg)))
|
if (result.some(arg => typeof arg !== 'object' || !('simples' in arg)))
|
||||||
throw new Error(`Error while parsing selector "${selector}"`);
|
throw new Error(`Error while parsing selector "${selector}"`);
|
||||||
return result as CSSComplexSelector[];
|
return result as CSSComplexSelector[];
|
||||||
}
|
}
|
||||||
|
|
@ -219,7 +219,7 @@ export function serializeSelector(args: CSSFunctionArgument[]) {
|
||||||
return `"${arg}"`;
|
return `"${arg}"`;
|
||||||
if (typeof arg === 'number')
|
if (typeof arg === 'number')
|
||||||
return String(arg);
|
return String(arg);
|
||||||
return arg.simple.map(({ selector, combinator }) => {
|
return arg.simples.map(({ selector, combinator }) => {
|
||||||
let s = selector.css || '';
|
let s = selector.css || '';
|
||||||
s = s + selector.functions.map(func => `:${func.name}(${serializeSelector(func.args)})`).join('');
|
s = s + selector.functions.map(func => `:${func.name}(${serializeSelector(func.args)})`).join('');
|
||||||
if (combinator)
|
if (combinator)
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ module.exports = {
|
||||||
module: {
|
module: {
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
test: /\.tsx?$/,
|
test: /\.(j|t)sx?$/,
|
||||||
loader: 'ts-loader',
|
loader: 'ts-loader',
|
||||||
options: {
|
options: {
|
||||||
transpileOnly: true
|
transpileOnly: true
|
||||||
|
|
|
||||||
378
src/server/injected/selectorEvaluator.ts
Normal file
378
src/server/injected/selectorEvaluator.ts
Normal file
|
|
@ -0,0 +1,378 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CSSComplexSelector, CSSSimpleSelector, CSSComplexSelectorList, CSSFunctionArgument } from '../common/cssParser';
|
||||||
|
|
||||||
|
export type QueryContext = {
|
||||||
|
scope: Element | ShadowRoot | Document;
|
||||||
|
// Place for more options, e.g. normalizing whitespace or piercing shadow.
|
||||||
|
};
|
||||||
|
export type Selector = any; // Opaque selector type.
|
||||||
|
export interface SelectorEvaluator {
|
||||||
|
query(context: QueryContext, selector: Selector): Element[];
|
||||||
|
matches(element: Element, selector: Selector, context: QueryContext): boolean;
|
||||||
|
}
|
||||||
|
export interface SelectorEngine {
|
||||||
|
matches?(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean;
|
||||||
|
query?(context: QueryContext, args: (string | number | Selector)[], evaluator: SelectorEvaluator): Element[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
||||||
|
private _engines = new Map<string, SelectorEngine>();
|
||||||
|
private _cache = new Map<any, { rest: any[], result: any }[]>();
|
||||||
|
|
||||||
|
constructor(extraEngines: Map<string, SelectorEngine>) {
|
||||||
|
for (const [name, engine] of extraEngines)
|
||||||
|
this._engines.set(name, engine);
|
||||||
|
this._engines.set('not', notEngine);
|
||||||
|
this._engines.set('is', isEngine);
|
||||||
|
this._engines.set('where', isEngine);
|
||||||
|
this._engines.set('has', hasEngine);
|
||||||
|
this._engines.set('scope', scopeEngine);
|
||||||
|
// TODO: host
|
||||||
|
// TODO: host-context?
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is the only function we should use for querying, because it does
|
||||||
|
// the right thing with caching
|
||||||
|
evaluate(context: QueryContext, s: CSSComplexSelectorList): Element[] {
|
||||||
|
const result = this.query(context, s);
|
||||||
|
this._cache.clear();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _cached<T>(main: any, rest: any[], cb: () => T): T {
|
||||||
|
if (!this._cache.has(main))
|
||||||
|
this._cache.set(main, []);
|
||||||
|
const entries = this._cache.get(main)!;
|
||||||
|
const entry = entries.find(e => {
|
||||||
|
return e.rest.length === rest.length &&
|
||||||
|
rest.findIndex((value, index) => e.rest[index] !== value) === -1;
|
||||||
|
});
|
||||||
|
if (entry)
|
||||||
|
return entry.result as T;
|
||||||
|
const result = cb();
|
||||||
|
entries.push({ rest, result });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _checkSelector(s: Selector): CSSComplexSelector | CSSComplexSelectorList {
|
||||||
|
const wellFormed = typeof s === 'object' && s &&
|
||||||
|
(Array.isArray(s) || ('simples' in s) && (s.simples.length));
|
||||||
|
if (!wellFormed)
|
||||||
|
throw new Error(`Malformed selector "${s}"`);
|
||||||
|
return s as CSSComplexSelector | CSSComplexSelectorList;
|
||||||
|
}
|
||||||
|
|
||||||
|
matches(element: Element, s: Selector, context: QueryContext): boolean {
|
||||||
|
const selector = this._checkSelector(s);
|
||||||
|
return this._cached<boolean>(element, ['matches', selector, context], () => {
|
||||||
|
if (Array.isArray(selector))
|
||||||
|
return this._matchesEngine(isEngine, element, selector, 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
query(context: QueryContext, s: any): Element[] {
|
||||||
|
const selector = this._checkSelector(s);
|
||||||
|
return this._cached<Element[]>(selector, ['query', context], () => {
|
||||||
|
if (Array.isArray(selector))
|
||||||
|
return this._queryEngine(isEngine, context, selector);
|
||||||
|
const elements = this._querySimple(context, selector.simples[selector.simples.length - 1].selector);
|
||||||
|
return elements.filter(element => this._matchesParents(element, selector, selector.simples.length - 2, context));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _matchesSimple(element: Element, simple: CSSSimpleSelector, context: QueryContext): boolean {
|
||||||
|
return this._cached<boolean>(element, ['_matchesSimple', simple, context], () => {
|
||||||
|
const isScopeClause = simple.functions.some(f => f.name === 'scope');
|
||||||
|
if (!isScopeClause && element === context.scope)
|
||||||
|
return false;
|
||||||
|
if (simple.css && !this._matchesCSS(element, simple.css))
|
||||||
|
return false;
|
||||||
|
for (const func of simple.functions) {
|
||||||
|
if (!this._matchesEngine(this._getEngine(func.name), element, func.args, context))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _querySimple(context: QueryContext, simple: CSSSimpleSelector): Element[] {
|
||||||
|
return this._cached<Element[]>(simple, ['_querySimple', context], () => {
|
||||||
|
let css = simple.css;
|
||||||
|
const funcs = simple.functions;
|
||||||
|
if (css === '*' && funcs.length)
|
||||||
|
css = undefined;
|
||||||
|
|
||||||
|
let elements: Element[];
|
||||||
|
let firstIndex = -1;
|
||||||
|
if (css !== undefined) {
|
||||||
|
elements = this._queryCSS(context, css);
|
||||||
|
} else {
|
||||||
|
firstIndex = funcs.findIndex(func => this._getEngine(func.name).query !== undefined);
|
||||||
|
if (firstIndex === -1)
|
||||||
|
firstIndex = 0;
|
||||||
|
elements = this._queryEngine(this._getEngine(funcs[firstIndex].name), context, funcs[firstIndex].args);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < funcs.length; i++) {
|
||||||
|
if (i === firstIndex)
|
||||||
|
continue;
|
||||||
|
const engine = this._getEngine(funcs[i].name);
|
||||||
|
if (engine.matches !== undefined)
|
||||||
|
elements = elements.filter(e => this._matchesEngine(engine, e, funcs[i].args, context));
|
||||||
|
}
|
||||||
|
for (let i = 0; i < funcs.length; i++) {
|
||||||
|
if (i === firstIndex)
|
||||||
|
continue;
|
||||||
|
const engine = this._getEngine(funcs[i].name);
|
||||||
|
if (engine.matches === undefined)
|
||||||
|
elements = elements.filter(e => this._matchesEngine(engine, e, funcs[i].args, context));
|
||||||
|
}
|
||||||
|
return elements;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _matchesParents(element: Element, complex: CSSComplexSelector, index: number, context: QueryContext): boolean {
|
||||||
|
return this._cached<boolean>(element, ['_matchesParents', complex, index, context], () => {
|
||||||
|
if (index < 0)
|
||||||
|
return true;
|
||||||
|
const { selector: simple, combinator } = complex.simples[index];
|
||||||
|
if (combinator === '>') {
|
||||||
|
const parent = parentElementOrShadowHostInScope(element, context.scope);
|
||||||
|
if (!parent || !this._matchesSimple(parent, simple, context))
|
||||||
|
return false;
|
||||||
|
return this._matchesParents(parent, complex, index - 1, context);
|
||||||
|
}
|
||||||
|
if (combinator === '+') {
|
||||||
|
const previousSibling = element === context.scope ? null : element.previousElementSibling;
|
||||||
|
if (!previousSibling || !this._matchesSimple(previousSibling, simple, context))
|
||||||
|
return false;
|
||||||
|
return this._matchesParents(previousSibling, complex, index - 1, context);
|
||||||
|
}
|
||||||
|
if (combinator === '') {
|
||||||
|
let parent = parentElementOrShadowHostInScope(element, context.scope);
|
||||||
|
while (parent) {
|
||||||
|
if (this._matchesSimple(parent, simple, context)) {
|
||||||
|
if (this._matchesParents(parent, complex, index - 1, context))
|
||||||
|
return true;
|
||||||
|
if (complex.simples[index - 1].combinator === '')
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
parent = parentElementOrShadowHostInScope(parent, context.scope);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (combinator === '~') {
|
||||||
|
let previousSibling = element === context.scope ? null : element.previousElementSibling;
|
||||||
|
while (previousSibling) {
|
||||||
|
if (this._matchesSimple(previousSibling, simple, context)) {
|
||||||
|
if (this._matchesParents(previousSibling, complex, index - 1, context))
|
||||||
|
return true;
|
||||||
|
if (complex.simples[index - 1].combinator === '~')
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
previousSibling = previousSibling === context.scope ? null : previousSibling.previousElementSibling;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw new Error(`Unsupported combinator "${combinator}"`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _matchesEngine(engine: SelectorEngine, element: Element, args: CSSFunctionArgument[], context: QueryContext): boolean {
|
||||||
|
if (engine.matches)
|
||||||
|
return this._callMatches(engine, element, args, context);
|
||||||
|
if (engine.query)
|
||||||
|
return this._callQuery(engine, args, context).includes(element);
|
||||||
|
throw new Error(`Selector engine should implement "matches" or "query"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _queryEngine(engine: SelectorEngine, context: QueryContext, args: CSSFunctionArgument[]): Element[] {
|
||||||
|
if (engine.query)
|
||||||
|
return this._callQuery(engine, args, context);
|
||||||
|
if (engine.matches)
|
||||||
|
return this._queryCSS(context, '*').filter(element => this._callMatches(engine, element, args, context));
|
||||||
|
throw new Error(`Selector engine should implement "matches" or "query"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _callMatches(engine: SelectorEngine, element: Element, args: CSSFunctionArgument[], context: QueryContext): boolean {
|
||||||
|
return this._cached<boolean>(element, ['_callMatches', engine, args, context.scope], () => {
|
||||||
|
return engine.matches!(element, args, context, this);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _callQuery(engine: SelectorEngine, args: CSSFunctionArgument[], context: QueryContext): Element[] {
|
||||||
|
return this._cached<Element[]>(args, ['_callQuery', engine, context.scope], () => {
|
||||||
|
return engine.query!(context, args, this);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _matchesCSS(element: Element, css: string): boolean {
|
||||||
|
return this._cached<boolean>(element, ['_matchesCSS', css], () => {
|
||||||
|
return element.matches(css);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _queryCSS(context: QueryContext, css: string): Element[] {
|
||||||
|
return this._cached<Element[]>(css, ['_queryCSS', context], () => {
|
||||||
|
const result: Element[] = [];
|
||||||
|
function query(root: Element | ShadowRoot | Document) {
|
||||||
|
result.push(...root.querySelectorAll(css));
|
||||||
|
if ((root as Element).shadowRoot)
|
||||||
|
query((root as Element).shadowRoot!);
|
||||||
|
for (const element of root.querySelectorAll('*')) {
|
||||||
|
if (element.shadowRoot)
|
||||||
|
query(element.shadowRoot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
query(context.scope);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getEngine(name: string): SelectorEngine {
|
||||||
|
const engine = this._engines.get(name);
|
||||||
|
if (!engine)
|
||||||
|
throw new Error(`Unknown selector engine "${name}"`);
|
||||||
|
return engine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEngine: SelectorEngine = {
|
||||||
|
matches(element: Element, args: any[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
|
||||||
|
if (args.length === 0)
|
||||||
|
throw new Error(`"is" engine expects non-empty selector list`);
|
||||||
|
return args.some(selector => evaluator.matches(element, selector, context));
|
||||||
|
},
|
||||||
|
|
||||||
|
query(context: QueryContext, args: any[], evaluator: SelectorEvaluator): Element[] {
|
||||||
|
if (args.length === 0)
|
||||||
|
throw new Error(`"is" engine expects non-empty selector list`);
|
||||||
|
const elements: Element[] = [];
|
||||||
|
for (const arg of args)
|
||||||
|
elements.push(...evaluator.query(context, arg));
|
||||||
|
const result = Array.from(new Set(elements));
|
||||||
|
return args.length > 1 ? sortInDOMOrder(result) : result;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasEngine: SelectorEngine = {
|
||||||
|
matches(element: Element, args: any[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
|
||||||
|
if (args.length === 0)
|
||||||
|
throw new Error(`"has" engine expects non-empty selector list`);
|
||||||
|
return evaluator.query({ ...context, scope: element }, args).length > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
// TODO: we can implement efficient "query" by matching "args" and returning
|
||||||
|
// all parents/descendants, just have to be careful with the ":scope" matching.
|
||||||
|
};
|
||||||
|
|
||||||
|
const scopeEngine: SelectorEngine = {
|
||||||
|
matches(element: Element, args: any[], 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;
|
||||||
|
},
|
||||||
|
|
||||||
|
query(context: QueryContext, args: any[], 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;
|
||||||
|
return root ? [root] : [];
|
||||||
|
}
|
||||||
|
if (context.scope.nodeType === 1 /* Node.ELEMENT_NODE */)
|
||||||
|
return [context.scope as Element];
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const notEngine: SelectorEngine = {
|
||||||
|
matches(element: Element, args: any[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
|
||||||
|
if (args.length === 0)
|
||||||
|
throw new Error(`"not" engine expects non-empty selector list`);
|
||||||
|
return !evaluator.matches(element, args, context);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function parentElementOrShadowHost(element: Element): Element | undefined {
|
||||||
|
if (element.parentElement)
|
||||||
|
return element.parentElement;
|
||||||
|
if (!element.parentNode)
|
||||||
|
return;
|
||||||
|
if (element.parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE && (element.parentNode as ShadowRoot).host)
|
||||||
|
return (element.parentNode as ShadowRoot).host;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parentElementOrShadowHostInScope(element: Element, scope: Element | ShadowRoot | Document): Element | undefined {
|
||||||
|
return element === scope ? undefined : parentElementOrShadowHost(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortInDOMOrder(elements: Element[]): Element[] {
|
||||||
|
type SortEntry = { children: Element[], taken: boolean };
|
||||||
|
|
||||||
|
const elementToEntry = new Map<Element, SortEntry>();
|
||||||
|
const roots: Element[] = [];
|
||||||
|
const result: Element[] = [];
|
||||||
|
|
||||||
|
function append(element: Element): SortEntry {
|
||||||
|
let entry = elementToEntry.get(element);
|
||||||
|
if (entry)
|
||||||
|
return entry;
|
||||||
|
const parent = parentElementOrShadowHost(element);
|
||||||
|
if (parent) {
|
||||||
|
const parentEntry = append(parent);
|
||||||
|
parentEntry.children.push(element);
|
||||||
|
} else {
|
||||||
|
roots.push(element);
|
||||||
|
}
|
||||||
|
entry = { children: [], taken: false };
|
||||||
|
elementToEntry.set(element, entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
elements.forEach(e => append(e).taken = true);
|
||||||
|
|
||||||
|
function visit(element: Element) {
|
||||||
|
const entry = elementToEntry.get(element)!;
|
||||||
|
if (entry.taken)
|
||||||
|
result.push(element);
|
||||||
|
if (entry.children.length > 1) {
|
||||||
|
const set = new Set(entry.children);
|
||||||
|
entry.children = [];
|
||||||
|
let child = element.firstElementChild;
|
||||||
|
while (child && entry.children.length < set.size) {
|
||||||
|
if (set.has(child))
|
||||||
|
entry.children.push(child);
|
||||||
|
child = child.nextElementSibling;
|
||||||
|
}
|
||||||
|
child = element.shadowRoot ? element.shadowRoot.firstElementChild : null;
|
||||||
|
while (child && entry.children.length < set.size) {
|
||||||
|
if (set.has(child))
|
||||||
|
entry.children.push(child);
|
||||||
|
child = child.nextElementSibling;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry.children.forEach(visit);
|
||||||
|
}
|
||||||
|
roots.forEach(visit);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
@ -23,7 +23,7 @@ module.exports = {
|
||||||
module: {
|
module: {
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
test: /\.tsx?$/,
|
test: /\.(j|t)sx?$/,
|
||||||
loader: 'ts-loader',
|
loader: 'ts-loader',
|
||||||
options: {
|
options: {
|
||||||
transpileOnly: true
|
transpileOnly: true
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,8 @@ it('should parse css', async () => {
|
||||||
expect(serialize(parseCSS(':right-of(span):react(foobar)'))).toBe(':right-of(span):react(foobar)');
|
expect(serialize(parseCSS(':right-of(span):react(foobar)'))).toBe(':right-of(span):react(foobar)');
|
||||||
expect(serialize(parseCSS('div:is(span):hover'))).toBe('div:hover:is(span)');
|
expect(serialize(parseCSS('div:is(span):hover'))).toBe('div:hover:is(span)');
|
||||||
expect(serialize(parseCSS('div:scope:hover'))).toBe('div:hover:scope()');
|
expect(serialize(parseCSS('div:scope:hover'))).toBe('div:hover:scope()');
|
||||||
|
expect(serialize(parseCSS('div:sCOpe:HOVER'))).toBe('div:HOVER:scope()');
|
||||||
|
expect(serialize(parseCSS('div:NOT(span):hoVER'))).toBe('div:hoVER:not(span)');
|
||||||
|
|
||||||
expect(serialize(parseCSS(':text("foo")'))).toBe(':text("foo")');
|
expect(serialize(parseCSS(':text("foo")'))).toBe(':text("foo")');
|
||||||
expect(serialize(parseCSS(':text("*")'))).toBe(':text("*")');
|
expect(serialize(parseCSS(':text("*")'))).toBe(':text("*")');
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,42 @@ it('should work with :not', async ({page, server}) => {
|
||||||
expect(await page.$$eval(`css=div > :not(span):not(div)`, els => els.length)).toBe(0);
|
expect(await page.$$eval(`css=div > :not(span):not(div)`, els => els.length)).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should work with ~', async ({page}) => {
|
||||||
|
await page.setContent(`
|
||||||
|
<div id=div1></div>
|
||||||
|
<div id=div2></div>
|
||||||
|
<div id=div3></div>
|
||||||
|
<div id=div4></div>
|
||||||
|
<div id=div5></div>
|
||||||
|
<div id=div6></div>
|
||||||
|
`);
|
||||||
|
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);
|
||||||
|
expect(await page.$$eval(`css=#div4 ~ div ~ div`, els => els.length)).toBe(1);
|
||||||
|
expect(await page.$$eval(`css=#div5 ~ div ~ div`, els => els.length)).toBe(0);
|
||||||
|
expect(await page.$$eval(`css=#div3 ~ #div2 ~ #div6`, els => els.length)).toBe(0);
|
||||||
|
expect(await page.$$eval(`css=#div3 ~ #div4 ~ #div5`, els => els.length)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with +', async ({page}) => {
|
||||||
|
await page.setContent(`
|
||||||
|
<div id=div1></div>
|
||||||
|
<div id=div2></div>
|
||||||
|
<div id=div3></div>
|
||||||
|
<div id=div4></div>
|
||||||
|
<div id=div5></div>
|
||||||
|
<div id=div6></div>
|
||||||
|
`);
|
||||||
|
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);
|
||||||
|
expect(await page.$$eval(`css=#div4 ~ #div5 + div`, els => els.length)).toBe(1);
|
||||||
|
expect(await page.$$eval(`css=#div5 + div + div`, els => els.length)).toBe(0);
|
||||||
|
expect(await page.$$eval(`css=#div3 ~ #div2 + #div6`, els => els.length)).toBe(0);
|
||||||
|
expect(await page.$$eval(`css=#div3 + #div4 + #div5`, els => els.length)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('should work with spaces in :nth-child and :not', test => {
|
it('should work with spaces in :nth-child and :not', test => {
|
||||||
test.fixme('Our selector parser is broken');
|
test.fixme('Our selector parser is broken');
|
||||||
}, async ({page, server}) => {
|
}, async ({page, server}) => {
|
||||||
|
|
@ -167,3 +203,46 @@ it('should work with spaces in :nth-child and :not', test => {
|
||||||
expect(await page.$$eval(`css=body :not(span, div)`, els => els.length)).toBe(1);
|
expect(await page.$$eval(`css=body :not(span, div)`, els => els.length)).toBe(1);
|
||||||
expect(await page.$$eval(`css=span, section:not(span, div)`, els => els.length)).toBe(5);
|
expect(await page.$$eval(`css=span, section:not(span, div)`, els => els.length)).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should work with :is', test => {
|
||||||
|
test.skip('Needs a new selector evaluator');
|
||||||
|
}, async ({page, server}) => {
|
||||||
|
await page.goto(server.PREFIX + '/deep-shadow.html');
|
||||||
|
expect(await page.$$eval(`css=div:is(#root1)`, els => els.length)).toBe(1);
|
||||||
|
expect(await page.$$eval(`css=div:is(#root1, #target)`, els => els.length)).toBe(1);
|
||||||
|
expect(await page.$$eval(`css=div:is(span, #target)`, els => els.length)).toBe(0);
|
||||||
|
expect(await page.$$eval(`css=div:is(span, #root1 > *)`, els => els.length)).toBe(2);
|
||||||
|
expect(await page.$$eval(`css=div:is(section div)`, els => els.length)).toBe(3);
|
||||||
|
expect(await page.$$eval(`css=:is(div, span)`, els => els.length)).toBe(7);
|
||||||
|
expect(await page.$$eval(`css=section:is(section) div:is(section div)`, els => els.length)).toBe(3);
|
||||||
|
expect(await page.$$eval(`css=:is(div, span) > *`, els => els.length)).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with :has', test => {
|
||||||
|
test.skip('Needs a new selector evaluator');
|
||||||
|
}, async ({page, server}) => {
|
||||||
|
await page.goto(server.PREFIX + '/deep-shadow.html');
|
||||||
|
expect(await page.$$eval(`css=div:has(#target)`, els => els.length)).toBe(2);
|
||||||
|
expect(await page.$$eval(`css=div:has([data-testid=foo])`, els => els.length)).toBe(3);
|
||||||
|
expect(await page.$$eval(`css=div:has([attr*=value])`, els => els.length)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with :scope', test => {
|
||||||
|
test.skip('Needs a new selector evaluator');
|
||||||
|
}, async ({page, server}) => {
|
||||||
|
await page.goto(server.PREFIX + '/deep-shadow.html');
|
||||||
|
// 'is' does not change the scope, so it remains 'html'.
|
||||||
|
expect(await page.$$eval(`css=div:is(:scope#root1)`, els => els.length)).toBe(0);
|
||||||
|
expect(await page.$$eval(`css=div:is(:scope #root1)`, els => els.length)).toBe(1);
|
||||||
|
// 'has' does change the scope, so it becomes the 'div' we are querying.
|
||||||
|
expect(await page.$$eval(`css=div:has(:scope > #target)`, els => els.length)).toBe(1);
|
||||||
|
|
||||||
|
const handle = await page.$(`css=span`);
|
||||||
|
for (const scope of [page, handle]) {
|
||||||
|
expect(await scope.$$eval(`css=:scope`, els => els.length)).toBe(1);
|
||||||
|
expect(await scope.$$eval(`css=* :scope`, els => els.length)).toBe(0);
|
||||||
|
expect(await scope.$$eval(`css=* + :scope`, els => els.length)).toBe(0);
|
||||||
|
expect(await scope.$$eval(`css=* > :scope`, els => els.length)).toBe(0);
|
||||||
|
expect(await scope.$$eval(`css=* ~ :scope`, els => els.length)).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue