mirror of
https://github.com/microsoft/playwright.git
synced 2024-12-14 21:53:35 +03:00
feat(selectors): explicit list of custom functions (#4629)
When parsing CSS, we assume everything is a valid CSS function, unless it is in the list of custom functions. This way we'll parse future CSS functions automatically.
This commit is contained in:
parent
be16ce4bd2
commit
1060fce005
@ -14,7 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { parseSelector } from '../../server/common/selectorParser';
|
||||
import type InjectedScript from '../../server/injected/injectedScript';
|
||||
|
||||
export class ConsoleAPI {
|
||||
@ -32,14 +31,14 @@ export class ConsoleAPI {
|
||||
_querySelector(selector: string): (Element | undefined) {
|
||||
if (typeof selector !== 'string')
|
||||
throw new Error(`Usage: playwright.query('Playwright >> selector').`);
|
||||
const parsed = parseSelector(selector);
|
||||
const parsed = this._injectedScript.parseSelector(selector);
|
||||
return this._injectedScript.querySelector(parsed, document);
|
||||
}
|
||||
|
||||
_querySelectorAll(selector: string): Element[] {
|
||||
if (typeof selector !== 'string')
|
||||
throw new Error(`Usage: playwright.$$('Playwright >> selector').`);
|
||||
const parsed = parseSelector(selector);
|
||||
const parsed = this._injectedScript.parseSelector(selector);
|
||||
return this._injectedScript.querySelectorAll(parsed, document);
|
||||
}
|
||||
|
||||
|
@ -21,13 +21,14 @@ type ClauseCombinator = '' | '>' | '+' | '~';
|
||||
// - key=value
|
||||
// - operators like `=`, `|=`, `~=`, `*=`, `/`
|
||||
// - <empty>~=value
|
||||
// - argument modes: "parse all", "parse commas", "just a string"
|
||||
export type CSSFunctionArgument = CSSComplexSelector | number | string;
|
||||
export type CSSFunction = { name: string, args: CSSFunctionArgument[] };
|
||||
export type CSSSimpleSelector = { css?: string, functions: CSSFunction[] };
|
||||
export type CSSComplexSelector = { simples: { selector: CSSSimpleSelector, combinator: ClauseCombinator }[] };
|
||||
export type CSSComplexSelectorList = CSSComplexSelector[];
|
||||
|
||||
export function parseCSS(selector: string): { selector: CSSComplexSelectorList, names: string[] } {
|
||||
export function parseCSS(selector: string, customNames: Set<string>): { selector: CSSComplexSelectorList, names: string[] } {
|
||||
let tokens: css.CSSTokenInterface[];
|
||||
try {
|
||||
tokens = css.tokenize(selector);
|
||||
@ -164,7 +165,7 @@ export function parseCSS(selector: string): { selector: CSSComplexSelectorList,
|
||||
} else if (tokens[pos] instanceof css.ColonToken) {
|
||||
pos++;
|
||||
if (isIdent()) {
|
||||
if (builtinCSSFilters.has(tokens[pos].value.toLowerCase())) {
|
||||
if (!customNames.has(tokens[pos].value.toLowerCase())) {
|
||||
rawCSSString += ':' + tokens[pos++].toSource();
|
||||
} else {
|
||||
const name = tokens[pos++].value.toLowerCase();
|
||||
@ -173,7 +174,7 @@ export function parseCSS(selector: string): { selector: CSSComplexSelectorList,
|
||||
}
|
||||
} else if (tokens[pos] instanceof css.FunctionToken) {
|
||||
const name = tokens[pos++].value.toLowerCase();
|
||||
if (builtinCSSFunctions.has(name)) {
|
||||
if (!customNames.has(name)) {
|
||||
rawCSSString += `:${name}(${consumeBuiltinFunctionArguments()})`;
|
||||
} else {
|
||||
functions.push({ name, args: consumeFunctionArguments() });
|
||||
@ -234,16 +235,3 @@ export function serializeSelector(args: CSSFunctionArgument[]) {
|
||||
}).join(' ');
|
||||
}).join(', ');
|
||||
}
|
||||
|
||||
const builtinCSSFilters = new Set([
|
||||
'active', 'any-link', 'checked', 'blank', 'default', 'defined',
|
||||
'disabled', 'empty', 'enabled', 'first', 'first-child', 'first-of-type',
|
||||
'fullscreen', 'focus', 'focus-visible', 'focus-within', 'hover',
|
||||
'indeterminate', 'in-range', 'invalid', 'last-child', 'last-of-type',
|
||||
'link', 'only-child', 'only-of-type', 'optional', 'out-of-range', 'placeholder-shown',
|
||||
'read-only', 'read-write', 'required', 'root', 'target', 'valid', 'visited',
|
||||
]);
|
||||
|
||||
const builtinCSSFunctions = new Set([
|
||||
'dir', 'lang', 'nth-child', 'nth-last-child', 'nth-last-of-type', 'nth-of-type',
|
||||
]);
|
||||
|
@ -34,9 +34,18 @@ export function selectorsV2Enabled() {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function parseSelector(selector: string): ParsedSelector {
|
||||
export function selectorsV2EngineNames() {
|
||||
return ['not', 'is', 'where', 'has', 'scope', 'light', 'index', 'visible', 'matches-text', 'above', 'below', 'right-of', 'left-of', 'near', 'within'];
|
||||
}
|
||||
|
||||
export function parseSelector(selector: string, customNames: Set<string>): ParsedSelector {
|
||||
const v1 = parseSelectorV1(selector);
|
||||
const names = new Set<string>(v1.parts.map(part => part.name));
|
||||
const names = new Set<string>();
|
||||
for (const { name } of v1.parts) {
|
||||
names.add(name);
|
||||
if (!customNames.has(name))
|
||||
throw new Error(`Unknown engine "${name}" while parsing selector ${selector}`);
|
||||
}
|
||||
|
||||
if (!selectorsV2Enabled()) {
|
||||
return {
|
||||
@ -56,7 +65,7 @@ export function parseSelector(selector: string): ParsedSelector {
|
||||
}
|
||||
let simple: CSSSimpleSelector;
|
||||
if (name === 'css') {
|
||||
const parsed = parseCSS(part.body);
|
||||
const parsed = parseCSS(part.body, customNames);
|
||||
parsed.names.forEach(name => names.add(name));
|
||||
simple = callWith('is', parsed.selector);
|
||||
} else if (name === 'text') {
|
||||
|
@ -19,7 +19,7 @@ import { createCSSEngine } from './cssSelectorEngine';
|
||||
import { SelectorEngine, SelectorRoot } from './selectorEngine';
|
||||
import { createTextSelector } from './textSelectorEngine';
|
||||
import { XPathEngine } from './xpathSelectorEngine';
|
||||
import { ParsedSelector, ParsedSelectorV1, parseSelector } from '../common/selectorParser';
|
||||
import { ParsedSelector, ParsedSelectorV1, parseSelector, selectorsV2Enabled, selectorsV2EngineNames } from '../common/selectorParser';
|
||||
import { FatalDOMError } from '../common/domErrors';
|
||||
import { SelectorEvaluatorImpl, SelectorEngine as SelectorEngineV2, QueryContext, isVisible, parentElementOrShadowHost } from './selectorEvaluator';
|
||||
|
||||
@ -43,6 +43,7 @@ export type InjectedScriptPoll<T> = {
|
||||
export class InjectedScript {
|
||||
private _enginesV1: Map<string, SelectorEngine>;
|
||||
private _evaluator: SelectorEvaluatorImpl;
|
||||
private _engineNames: Set<string>;
|
||||
|
||||
constructor(customEngines: { name: string, engine: SelectorEngine}[]) {
|
||||
this._enginesV1 = new Map();
|
||||
@ -67,10 +68,16 @@ export class InjectedScript {
|
||||
for (const { name, engine } of customEngines)
|
||||
wrapped.set(name, wrapV2(name, engine));
|
||||
this._evaluator = new SelectorEvaluatorImpl(wrapped);
|
||||
|
||||
this._engineNames = new Set(this._enginesV1.keys());
|
||||
if (selectorsV2Enabled()) {
|
||||
for (const name of selectorsV2EngineNames())
|
||||
this._engineNames.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
parseSelector(selector: string): ParsedSelector {
|
||||
return parseSelector(selector);
|
||||
return parseSelector(selector, this._engineNames);
|
||||
}
|
||||
|
||||
querySelector(selector: ParsedSelector, root: Node): Element | undefined {
|
||||
|
@ -18,7 +18,7 @@ import * as dom from './dom';
|
||||
import * as frames from './frames';
|
||||
import * as js from './javascript';
|
||||
import * as types from './types';
|
||||
import { ParsedSelector, parseSelector, selectorsV2Enabled } from './common/selectorParser';
|
||||
import { ParsedSelector, parseSelector, selectorsV2Enabled, selectorsV2EngineNames } from './common/selectorParser';
|
||||
|
||||
export type SelectorInfo = {
|
||||
parsed: ParsedSelector,
|
||||
@ -29,6 +29,7 @@ export type SelectorInfo = {
|
||||
export class Selectors {
|
||||
readonly _builtinEngines: Set<string>;
|
||||
readonly _engines: Map<string, { source: string, contentScript: boolean }>;
|
||||
readonly _engineNames: Set<string>;
|
||||
|
||||
constructor() {
|
||||
// Note: keep in sync with SelectorEvaluator class.
|
||||
@ -42,10 +43,11 @@ export class Selectors {
|
||||
'data-test', 'data-test:light',
|
||||
]);
|
||||
if (selectorsV2Enabled()) {
|
||||
for (const name of ['not', 'is', 'where', 'has', 'scope', 'light', 'index', 'visible', 'matches-text', 'above', 'below', 'right-of', 'left-of', 'near', 'within'])
|
||||
for (const name of selectorsV2EngineNames())
|
||||
this._builtinEngines.add(name);
|
||||
}
|
||||
this._engines = new Map();
|
||||
this._engineNames = new Set(this._builtinEngines);
|
||||
}
|
||||
|
||||
async register(name: string, source: string, contentScript: boolean = false): Promise<void> {
|
||||
@ -57,6 +59,7 @@ export class Selectors {
|
||||
if (this._engines.has(name))
|
||||
throw new Error(`"${name}" selector engine has been already registered`);
|
||||
this._engines.set(name, { source, contentScript });
|
||||
this._engineNames.add(name);
|
||||
}
|
||||
|
||||
async _query(frame: frames.Frame, selector: string, scope?: dom.ElementHandle): Promise<dom.ElementHandle<Element> | null> {
|
||||
@ -119,11 +122,7 @@ export class Selectors {
|
||||
}
|
||||
|
||||
_parseSelector(selector: string): SelectorInfo {
|
||||
const parsed = parseSelector(selector);
|
||||
for (const name of parsed.names) {
|
||||
if (!this._builtinEngines.has(name) && !this._engines.has(name))
|
||||
throw new Error(`Unknown engine "${name}" while parsing selector ${selector}`);
|
||||
}
|
||||
const parsed = parseSelector(selector, this._engineNames);
|
||||
const needsMainWorld = parsed.names.some(name => {
|
||||
const custom = this._engines.get(name);
|
||||
return custom ? !custom.contentScript : false;
|
||||
|
@ -20,58 +20,62 @@ import * as path from 'path';
|
||||
const { parseCSS, serializeSelector: serialize } =
|
||||
require(path.join(__dirname, '..', 'lib', 'server', 'common', 'cssParser'));
|
||||
|
||||
const parse = (selector: string) => {
|
||||
return parseCSS(selector, new Set(['text', 'not', 'has', 'react', 'scope', 'right-of', 'scope', 'is'])).selector;
|
||||
};
|
||||
|
||||
it('should parse css', async () => {
|
||||
expect(serialize(parseCSS('div').selector)).toBe('div');
|
||||
expect(serialize(parseCSS('div.class').selector)).toBe('div.class');
|
||||
expect(serialize(parseCSS('.class').selector)).toBe('.class');
|
||||
expect(serialize(parseCSS('#id').selector)).toBe('#id');
|
||||
expect(serialize(parseCSS('.class#id').selector)).toBe('.class#id');
|
||||
expect(serialize(parseCSS('div#id.class').selector)).toBe('div#id.class');
|
||||
expect(serialize(parseCSS('*').selector)).toBe('*');
|
||||
expect(serialize(parseCSS('*div').selector)).toBe('*div');
|
||||
expect(serialize(parseCSS('div[attr *= foo i]').selector)).toBe('div[attr *= foo i]');
|
||||
expect(serialize(parseCSS('div[attr~="Bar baz" ]').selector)).toBe('div[attr~="Bar baz" ]');
|
||||
expect(serialize(parseCSS(`div [ foo = 'bar' s]`).selector)).toBe(`div [ foo = "bar" s]`);
|
||||
expect(serialize(parse('div'))).toBe('div');
|
||||
expect(serialize(parse('div.class'))).toBe('div.class');
|
||||
expect(serialize(parse('.class'))).toBe('.class');
|
||||
expect(serialize(parse('#id'))).toBe('#id');
|
||||
expect(serialize(parse('.class#id'))).toBe('.class#id');
|
||||
expect(serialize(parse('div#id.class'))).toBe('div#id.class');
|
||||
expect(serialize(parse('*'))).toBe('*');
|
||||
expect(serialize(parse('*div'))).toBe('*div');
|
||||
expect(serialize(parse('div[attr *= foo i]'))).toBe('div[attr *= foo i]');
|
||||
expect(serialize(parse('div[attr~="Bar baz" ]'))).toBe('div[attr~="Bar baz" ]');
|
||||
expect(serialize(parse(`div [ foo = 'bar' s]`))).toBe(`div [ foo = "bar" s]`);
|
||||
|
||||
expect(serialize(parseCSS(':hover').selector)).toBe(':hover');
|
||||
expect(serialize(parseCSS('div:hover').selector)).toBe('div:hover');
|
||||
expect(serialize(parseCSS('#id:active:hover').selector)).toBe('#id:active:hover');
|
||||
expect(serialize(parseCSS(':dir(ltr)').selector)).toBe(':dir(ltr)');
|
||||
expect(serialize(parseCSS('#foo-bar.cls:nth-child(3n + 10)').selector)).toBe('#foo-bar.cls:nth-child(3n + 10)');
|
||||
expect(serialize(parseCSS(':lang(en)').selector)).toBe(':lang(en)');
|
||||
expect(serialize(parseCSS('*:hover').selector)).toBe('*:hover');
|
||||
expect(serialize(parse(':hover'))).toBe(':hover');
|
||||
expect(serialize(parse('div:hover'))).toBe('div:hover');
|
||||
expect(serialize(parse('#id:active:hover'))).toBe('#id:active:hover');
|
||||
expect(serialize(parse(':dir(ltr)'))).toBe(':dir(ltr)');
|
||||
expect(serialize(parse('#foo-bar.cls:nth-child(3n + 10)'))).toBe('#foo-bar.cls:nth-child(3n + 10)');
|
||||
expect(serialize(parse(':lang(en)'))).toBe(':lang(en)');
|
||||
expect(serialize(parse('*:hover'))).toBe('*:hover');
|
||||
|
||||
expect(serialize(parseCSS('div span').selector)).toBe('div span');
|
||||
expect(serialize(parseCSS('div>span').selector)).toBe('div > span');
|
||||
expect(serialize(parseCSS('div +span').selector)).toBe('div + span');
|
||||
expect(serialize(parseCSS('div~ span').selector)).toBe('div ~ span');
|
||||
expect(serialize(parseCSS('div >.class #id+ span').selector)).toBe('div > .class #id + span');
|
||||
expect(serialize(parseCSS('div>span+.class').selector)).toBe('div > span + .class');
|
||||
expect(serialize(parse('div span'))).toBe('div span');
|
||||
expect(serialize(parse('div>span'))).toBe('div > span');
|
||||
expect(serialize(parse('div +span'))).toBe('div + span');
|
||||
expect(serialize(parse('div~ span'))).toBe('div ~ span');
|
||||
expect(serialize(parse('div >.class #id+ span'))).toBe('div > .class #id + span');
|
||||
expect(serialize(parse('div>span+.class'))).toBe('div > span + .class');
|
||||
|
||||
expect(serialize(parseCSS('div:not(span)').selector)).toBe('div:not(span)');
|
||||
expect(serialize(parseCSS(':not(span)#id').selector)).toBe('#id:not(span)');
|
||||
expect(serialize(parseCSS('div:not(span):hover').selector)).toBe('div:hover:not(span)');
|
||||
expect(serialize(parseCSS('div:has(span):hover').selector)).toBe('div:hover:has(span)');
|
||||
expect(serialize(parseCSS('div:right-of(span):hover').selector)).toBe('div:hover:right-of(span)');
|
||||
expect(serialize(parseCSS(':right-of(span):react(foobar)').selector)).toBe(':right-of(span):react(foobar)');
|
||||
expect(serialize(parseCSS('div:is(span):hover').selector)).toBe('div:hover:is(span)');
|
||||
expect(serialize(parseCSS('div:scope:hover').selector)).toBe('div:hover:scope()');
|
||||
expect(serialize(parseCSS('div:sCOpe:HOVER').selector)).toBe('div:HOVER:scope()');
|
||||
expect(serialize(parseCSS('div:NOT(span):hoVER').selector)).toBe('div:hoVER:not(span)');
|
||||
expect(serialize(parse('div:not(span)'))).toBe('div:not(span)');
|
||||
expect(serialize(parse(':not(span)#id'))).toBe('#id:not(span)');
|
||||
expect(serialize(parse('div:not(span):hover'))).toBe('div:hover:not(span)');
|
||||
expect(serialize(parse('div:has(span):hover'))).toBe('div:hover:has(span)');
|
||||
expect(serialize(parse('div:right-of(span):hover'))).toBe('div:hover:right-of(span)');
|
||||
expect(serialize(parse(':right-of(span):react(foobar)'))).toBe(':right-of(span):react(foobar)');
|
||||
expect(serialize(parse('div:is(span):hover'))).toBe('div:hover:is(span)');
|
||||
expect(serialize(parse('div:scope:hover'))).toBe('div:hover:scope()');
|
||||
expect(serialize(parse('div:sCOpe:HOVER'))).toBe('div:HOVER:scope()');
|
||||
expect(serialize(parse('div:NOT(span):hoVER'))).toBe('div:hoVER:not(span)');
|
||||
|
||||
expect(serialize(parseCSS(':text("foo")').selector)).toBe(':text("foo")');
|
||||
expect(serialize(parseCSS(':text("*")').selector)).toBe(':text("*")');
|
||||
expect(serialize(parseCSS(':text(*)').selector)).toBe(':text(*)');
|
||||
expect(serialize(parseCSS(':text("foo", normalize-space)').selector)).toBe(':text("foo", normalize-space)');
|
||||
expect(serialize(parseCSS(':index(3, div span)').selector)).toBe(':index(3, div span)');
|
||||
expect(serialize(parseCSS(':is(foo, bar>baz.cls+:not(qux))').selector)).toBe(':is(foo, bar > baz.cls + :not(qux))');
|
||||
expect(serialize(parse(':text("foo")'))).toBe(':text("foo")');
|
||||
expect(serialize(parse(':text("*")'))).toBe(':text("*")');
|
||||
expect(serialize(parse(':text(*)'))).toBe(':text(*)');
|
||||
expect(serialize(parse(':text("foo", normalize-space)'))).toBe(':text("foo", normalize-space)');
|
||||
expect(serialize(parse(':index(3, div span)'))).toBe(':index(3, div span)');
|
||||
expect(serialize(parse(':is(foo, bar>baz.cls+:not(qux))'))).toBe(':is(foo, bar > baz.cls + :not(qux))');
|
||||
});
|
||||
|
||||
it('should throw on malformed css', async () => {
|
||||
function expectError(selector: string) {
|
||||
let error = { message: '' };
|
||||
try {
|
||||
parseCSS(selector);
|
||||
parse(selector);
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user