Skip to content

Commit

Permalink
fix: [#1352] Ignores invalid selectors when parsing CSS in Window.get…
Browse files Browse the repository at this point in the history
…ComputedStyle()
  • Loading branch information
capricorn86 committed Mar 24, 2024
1 parent 6cbe8c0 commit 1a1d05b
Show file tree
Hide file tree
Showing 8 changed files with 156 additions and 32 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -328,11 +328,13 @@ export default class CSSStyleDeclarationElementStyle {
}
} else {
for (const element of options.elements) {
const matchResult = QuerySelector.match(<Element>element.element, selectorText);
if (matchResult) {
const match = QuerySelector.matches(<Element>element.element, selectorText, {
ignoreErrors: true
});
if (match) {
element.cssTexts.push({
cssText: (<CSSStyleRule>rule)[PropertySymbol.cssText],
priorityWeight: matchResult.priorityWeight
priorityWeight: match.priorityWeight
});
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/happy-dom/src/nodes/element/Element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -851,7 +851,7 @@ export default class Element
* @returns "true" if matching.
*/
public matches(selector: string): boolean {
return !!QuerySelector.match(this, selector);
return !!QuerySelector.matches(this, selector);
}

/**
Expand All @@ -865,7 +865,7 @@ export default class Element
let parent: Element = this;

while (parent) {
if (QuerySelector.match(parent, selector)) {
if (QuerySelector.matches(parent, selector)) {
return parent;
}
parent = parent.parentElement;
Expand Down
19 changes: 15 additions & 4 deletions packages/happy-dom/src/query-selector/QuerySelector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,9 +198,15 @@ export default class QuerySelector {
*
* @param element Element to match.
* @param selector Selector to match with.
* @param [options] Options.
* @param [options.ignoreErrors] Ignores errors.
* @returns Result.
*/
public static match(element: Element, selector: string): ISelectorMatch | null {
public static matches(
element: Element,
selector: string,
options?: { ignoreErrors?: boolean }
): ISelectorMatch | null {
if (!selector) {
return null;
}
Expand All @@ -211,14 +217,19 @@ export default class QuerySelector {
};
}

const ignoreErrors = options?.ignoreErrors;

if (INVALID_SELECTOR_REGEXP.test(selector)) {
if (ignoreErrors) {
return null;
}
throw new Error(
`Failed to execute 'match' on '${element.constructor.name}': '${selector}' is not a valid selector.`
`Failed to execute 'matches' on '${element.constructor.name}': '${selector}' is not a valid selector.`
);
}

for (const items of SelectorParser.getSelectorGroups(selector)) {
const result = this.matchSelector(element, element, items.reverse());
for (const items of SelectorParser.getSelectorGroups(selector, options)) {
const result = this.matchSelector(element, element, items.reverse(), 0);

if (result) {
return result;
Expand Down
19 changes: 15 additions & 4 deletions packages/happy-dom/src/query-selector/SelectorItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default class SelectorItem {
public pseudos: ISelectorPseudo[] | null;
public isPseudoElement: boolean;
public combinator: SelectorCombinatorEnum;
public ignoreErrors: boolean;

/**
* Constructor.
Expand All @@ -30,6 +31,7 @@ export default class SelectorItem {
* @param [options.attributes] Attributes.
* @param [options.pseudos] Pseudos.
* @param [options.isPseudoElement] Is pseudo element.
* @param [options.ignoreErrors] Ignore errors.
*/
constructor(options?: {
tagName?: string;
Expand All @@ -39,6 +41,7 @@ export default class SelectorItem {
pseudos?: ISelectorPseudo[];
isPseudoElement?: boolean;
combinator?: SelectorCombinatorEnum;
ignoreErrors?: boolean;
}) {
this.tagName = options?.tagName || null;
this.id = options?.id || null;
Expand All @@ -47,6 +50,7 @@ export default class SelectorItem {
this.pseudos = options?.pseudos || null;
this.isPseudoElement = options?.isPseudoElement || false;
this.combinator = options?.combinator || SelectorCombinatorEnum.descendant;
this.ignoreErrors = options?.ignoreErrors || false;
}

/**
Expand Down Expand Up @@ -98,7 +102,7 @@ export default class SelectorItem {

// Pseudo match
if (this.pseudos) {
const result = this.matchPsuedo(element);
const result = this.matchPseudo(element);
if (!result) {
return null;
}
Expand All @@ -114,7 +118,7 @@ export default class SelectorItem {
* @param element Element.
* @returns Result.
*/
private matchPsuedo(element: Element): ISelectorMatch | null {
private matchPseudo(element: Element): ISelectorMatch | null {
const parent = <Element>element[PropertySymbol.parentNode];
const parentChildren = element[PropertySymbol.parentNode]
? (<Element>element[PropertySymbol.parentNode])[PropertySymbol.children]
Expand All @@ -135,7 +139,14 @@ export default class SelectorItem {
case 'nth-last-child':
case 'nth-last-of-type':
if (!pseudo.arguments) {
throw new DOMException(`The selector "${this.getSelectorString()}" is not valid.`);
if (this.ignoreErrors) {
return null;
}
throw new DOMException(
`Failed to execute 'matches' on '${
element.constructor.name
}': '${this.getSelectorString()}' is not a valid selector.`
);
}
break;
}
Expand Down Expand Up @@ -357,7 +368,7 @@ export default class SelectorItem {
* @returns Selector string.
*/
private getSelectorString(): string {
return `${this.tagName || ''}${this.id ? `#${this.id}` : ''}${
return `${this.tagName ? this.tagName.toLowerCase() : ''}${this.id ? `#${this.id}` : ''}${
this.classNames ? `.${this.classNames.join('.')}` : ''
}${
this.attributes
Expand Down
67 changes: 49 additions & 18 deletions packages/happy-dom/src/query-selector/SelectorParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,38 +63,52 @@ export default class SelectorParser {
* Parses a selector string and returns an instance of SelectorItem.
*
* @param selector Selector.
* @param [options] Options.
* @param [options.ignoreErrors] Ignores errors.
* @returns Selector item.
*/
public static getSelectorItem(selector: string): SelectorItem {
return this.getSelectorGroups(selector)[0][0];
public static getSelectorItem(
selector: string,
options?: { ignoreErrors?: boolean }
): SelectorItem {
return this.getSelectorGroups(selector, options)[0][0];
}

/**
* Parses a selector string and returns groups with SelectorItem instances.
*
* @param selector Selector.
* @param [options] Options.
* @param [options.ignoreErrors] Ignores errors.
* @returns Selector groups.
*/
public static getSelectorGroups(selector: string): Array<Array<SelectorItem>> {
public static getSelectorGroups(
selector: string,
options?: { ignoreErrors?: boolean }
): Array<Array<SelectorItem>> {
const ignoreErrors = options?.ignoreErrors;
if (selector === '*') {
return [[new SelectorItem({ tagName: '*' })]];
return [[new SelectorItem({ tagName: '*', ignoreErrors })]];
}

const simpleMatch = selector.match(SIMPLE_SELECTOR_REGEXP);

if (simpleMatch) {
if (simpleMatch[1]) {
return [[new SelectorItem({ tagName: selector.toUpperCase() })]];
return [[new SelectorItem({ tagName: selector.toUpperCase(), ignoreErrors })]];
} else if (simpleMatch[2]) {
return [[new SelectorItem({ classNames: selector.replace('.', '').split('.') })]];
return [
[new SelectorItem({ classNames: selector.replace('.', '').split('.'), ignoreErrors })]
];
} else if (simpleMatch[3]) {
return [[new SelectorItem({ id: selector.replace('#', '') })]];
return [[new SelectorItem({ id: selector.replace('#', ''), ignoreErrors })]];
}
}

const regexp = new RegExp(SELECTOR_REGEXP);
let currentSelectorItem: SelectorItem = new SelectorItem({
combinator: SelectorCombinatorEnum.descendant
combinator: SelectorCombinatorEnum.descendant,
ignoreErrors
});
let currentGroup: SelectorItem[] = [currentSelectorItem];
const groups: Array<Array<SelectorItem>> = [currentGroup];
Expand Down Expand Up @@ -147,34 +161,40 @@ export default class SelectorParser {
});
} else if (match[13] && match[14]) {
currentSelectorItem.pseudos = currentSelectorItem.pseudos || [];
currentSelectorItem.pseudos.push(this.getPseudo(match[13], match[14]));
currentSelectorItem.pseudos.push(this.getPseudo(match[13], match[14], options));
} else if (match[15]) {
currentSelectorItem.pseudos = currentSelectorItem.pseudos || [];
currentSelectorItem.pseudos.push(this.getPseudo(match[15]));
currentSelectorItem.pseudos.push(this.getPseudo(match[15], null, options));
} else if (match[16]) {
currentSelectorItem.isPseudoElement = true;
} else if (match[17]) {
switch (match[17].trim()) {
case ',':
currentSelectorItem = new SelectorItem({
combinator: SelectorCombinatorEnum.descendant
combinator: SelectorCombinatorEnum.descendant,
ignoreErrors
});
currentGroup = [currentSelectorItem];
groups.push(currentGroup);
break;
case '>':
currentSelectorItem = new SelectorItem({ combinator: SelectorCombinatorEnum.child });
currentSelectorItem = new SelectorItem({
combinator: SelectorCombinatorEnum.child,
ignoreErrors
});
currentGroup.push(currentSelectorItem);
break;
case '+':
currentSelectorItem = new SelectorItem({
combinator: SelectorCombinatorEnum.adjacentSibling
combinator: SelectorCombinatorEnum.adjacentSibling,
ignoreErrors
});
currentGroup.push(currentSelectorItem);
break;
case '':
currentSelectorItem = new SelectorItem({
combinator: SelectorCombinatorEnum.descendant
combinator: SelectorCombinatorEnum.descendant,
ignoreErrors
});
currentGroup.push(currentSelectorItem);
break;
Expand All @@ -186,6 +206,9 @@ export default class SelectorParser {
}

if (!isValid) {
if (options?.ignoreErrors) {
return [];
}
throw new DOMException(`Invalid selector: "${selector}"`);
}

Expand Down Expand Up @@ -241,9 +264,15 @@ export default class SelectorParser {
*
* @param name Pseudo name.
* @param args Pseudo arguments.
* @param [options] Options.
* @param [options.ignoreErrors] Ignores errors.
* @returns Pseudo.
*/
private static getPseudo(name: string, args?: string): ISelectorPseudo {
private static getPseudo(
name: string,
args?: string,
options?: { ignoreErrors?: boolean }
): ISelectorPseudo {
const lowerName = name.toLowerCase();

if (!args) {
Expand All @@ -256,7 +285,9 @@ export default class SelectorParser {
const nthOfIndex = args.indexOf(' of ');
const nthFunction = nthOfIndex !== -1 ? args.substring(0, nthOfIndex) : args;
const selectorItem =
nthOfIndex !== -1 ? this.getSelectorItem(args.substring(nthOfIndex + 4).trim()) : null;
nthOfIndex !== -1
? this.getSelectorItem(args.substring(nthOfIndex + 4).trim(), options)
: null;
return {
name: lowerName,
arguments: args,
Expand All @@ -275,12 +306,12 @@ export default class SelectorParser {
return {
name: lowerName,
arguments: args,
selectorItems: [this.getSelectorItem(args)],
selectorItems: [this.getSelectorItem(args, options)],
nthFunction: null
};
case 'is':
case 'where':
const selectorGroups = this.getSelectorGroups(args);
const selectorGroups = this.getSelectorGroups(args, options);
const selectorItems = [];
for (const group of selectorGroups) {
selectorItems.push(group[0]);
Expand Down
29 changes: 28 additions & 1 deletion packages/happy-dom/test/query-selector/QuerySelector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import QuerySelectorHTML from './data/QuerySelectorHTML.js';
import QuerySelectorNthChildHTML from './data/QuerySelectorNthChildHTML.js';
import HTMLInputElement from '../../src/nodes/html-input-element/HTMLInputElement.js';
import { beforeEach, describe, it, expect } from 'vitest';
import QuerySelector from '../../src/query-selector/QuerySelector.js';

describe('QuerySelector', () => {
let window: Window;
Expand Down Expand Up @@ -1298,7 +1299,7 @@ describe('QuerySelector', () => {
});
});

describe('match()', () => {
describe('matches()', () => {
it('Returns true when the element matches the selector', () => {
const div = document.createElement('div');
div.innerHTML = '<div class="foo"></div>';
Expand Down Expand Up @@ -1364,5 +1365,31 @@ describe('QuerySelector', () => {
expect(element.matches(':where(span[attr1="val,ue1"], span[attr1="value1"])')).toBe(true);
expect(element.matches(':where(div)')).toBe(false);
});

it('Throws an error when providing an invalid selector', () => {
const div = document.createElement('div');
div.innerHTML = '<div class="foo"></div>';
const element = div.children[0];
expect(() => element.matches('1')).toThrow(
new Error(`Failed to execute 'matches' on 'HTMLElement': '1' is not a valid selector.`)
);
expect(() => element.matches(':not')).toThrow(
new Error(`Failed to execute 'matches' on 'HTMLElement': ':not' is not a valid selector.`)
);
expect(() => element.matches('div:not')).toThrow(
new Error(
`Failed to execute 'matches' on 'HTMLElement': 'div:not' is not a valid selector.`
)
);
});

it('Ignores invalid selectors if option "ignoreErrors" is set to true', () => {
const div = document.createElement('div');
div.innerHTML = '<div class="foo"></div>';
const element = div.children[0];
expect(QuerySelector.matches(element, '1', { ignoreErrors: true })).toBe(null);
expect(QuerySelector.matches(element, ':not', { ignoreErrors: true })).toBe(null);
expect(QuerySelector.matches(element, 'div:not', { ignoreErrors: true })).toBe(null);
});
});
});
32 changes: 32 additions & 0 deletions packages/happy-dom/test/window/BrowserWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,38 @@ describe('BrowserWindow', () => {
expect(computedStyle.color).toBe('green');
});

it('Ingores invalid selectors in parsed CSS.', () => {
const parent = document.createElement('div');
const element = document.createElement('span');
const computedStyle = window.getComputedStyle(element);
const elementStyle = document.createElement('style');

elementStyle.innerHTML = `
span {
color: green;
}
:not {
color: red;
}
%test {
color: red;
}
span:not {
color: red;
}
`;

parent.appendChild(elementStyle);
parent.appendChild(element);

document.body.appendChild(parent);

expect(computedStyle.color).toBe('green');
});

for (const measurement of [
{ value: '100vw', result: '1024px' },
{ value: '100vh', result: '768px' },
Expand Down

0 comments on commit 1a1d05b

Please sign in to comment.