From 1a1d05ba6a36fda2ff599ef95f9393b6e9312d02 Mon Sep 17 00:00:00 2001 From: David Ortner Date: Sun, 24 Mar 2024 13:04:07 +0100 Subject: [PATCH] fix: [#1352] Ignores invalid selectors when parsing CSS in Window.getComputedStyle() --- .../CSSStyleDeclarationElementStyle.ts | 8 ++- .../happy-dom/src/nodes/element/Element.ts | 4 +- .../src/query-selector/QuerySelector.ts | 19 ++++-- .../src/query-selector/SelectorItem.ts | 19 ++++-- .../src/query-selector/SelectorParser.ts | 67 ++++++++++++++----- .../test/query-selector/QuerySelector.test.ts | 29 +++++++- .../test/window/BrowserWindow.test.ts | 32 +++++++++ .../testing-library/TestingLibrary.test.tsx | 10 +++ 8 files changed, 156 insertions(+), 32 deletions(-) diff --git a/packages/happy-dom/src/css/declaration/element-style/CSSStyleDeclarationElementStyle.ts b/packages/happy-dom/src/css/declaration/element-style/CSSStyleDeclarationElementStyle.ts index e01d394b0..9bd3080f3 100644 --- a/packages/happy-dom/src/css/declaration/element-style/CSSStyleDeclarationElementStyle.ts +++ b/packages/happy-dom/src/css/declaration/element-style/CSSStyleDeclarationElementStyle.ts @@ -328,11 +328,13 @@ export default class CSSStyleDeclarationElementStyle { } } else { for (const element of options.elements) { - const matchResult = QuerySelector.match(element.element, selectorText); - if (matchResult) { + const match = QuerySelector.matches(element.element, selectorText, { + ignoreErrors: true + }); + if (match) { element.cssTexts.push({ cssText: (rule)[PropertySymbol.cssText], - priorityWeight: matchResult.priorityWeight + priorityWeight: match.priorityWeight }); } } diff --git a/packages/happy-dom/src/nodes/element/Element.ts b/packages/happy-dom/src/nodes/element/Element.ts index 852bf4cea..a1843c469 100644 --- a/packages/happy-dom/src/nodes/element/Element.ts +++ b/packages/happy-dom/src/nodes/element/Element.ts @@ -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); } /** @@ -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; diff --git a/packages/happy-dom/src/query-selector/QuerySelector.ts b/packages/happy-dom/src/query-selector/QuerySelector.ts index 718ea3651..b4e42f885 100644 --- a/packages/happy-dom/src/query-selector/QuerySelector.ts +++ b/packages/happy-dom/src/query-selector/QuerySelector.ts @@ -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; } @@ -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; diff --git a/packages/happy-dom/src/query-selector/SelectorItem.ts b/packages/happy-dom/src/query-selector/SelectorItem.ts index b63e7908f..0e3e155b1 100644 --- a/packages/happy-dom/src/query-selector/SelectorItem.ts +++ b/packages/happy-dom/src/query-selector/SelectorItem.ts @@ -18,6 +18,7 @@ export default class SelectorItem { public pseudos: ISelectorPseudo[] | null; public isPseudoElement: boolean; public combinator: SelectorCombinatorEnum; + public ignoreErrors: boolean; /** * Constructor. @@ -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; @@ -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; @@ -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; } /** @@ -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; } @@ -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[PropertySymbol.parentNode]; const parentChildren = element[PropertySymbol.parentNode] ? (element[PropertySymbol.parentNode])[PropertySymbol.children] @@ -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; } @@ -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 diff --git a/packages/happy-dom/src/query-selector/SelectorParser.ts b/packages/happy-dom/src/query-selector/SelectorParser.ts index 360e96d5e..6e62fb46b 100644 --- a/packages/happy-dom/src/query-selector/SelectorParser.ts +++ b/packages/happy-dom/src/query-selector/SelectorParser.ts @@ -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> { + public static getSelectorGroups( + selector: string, + options?: { ignoreErrors?: boolean } + ): Array> { + 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> = [currentGroup]; @@ -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; @@ -186,6 +206,9 @@ export default class SelectorParser { } if (!isValid) { + if (options?.ignoreErrors) { + return []; + } throw new DOMException(`Invalid selector: "${selector}"`); } @@ -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) { @@ -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, @@ -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]); diff --git a/packages/happy-dom/test/query-selector/QuerySelector.test.ts b/packages/happy-dom/test/query-selector/QuerySelector.test.ts index c76a18e3c..4805be222 100644 --- a/packages/happy-dom/test/query-selector/QuerySelector.test.ts +++ b/packages/happy-dom/test/query-selector/QuerySelector.test.ts @@ -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; @@ -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 = '
'; @@ -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 = '
'; + 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 = '
'; + 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); + }); }); }); diff --git a/packages/happy-dom/test/window/BrowserWindow.test.ts b/packages/happy-dom/test/window/BrowserWindow.test.ts index 98efe0c2e..2fe889f05 100644 --- a/packages/happy-dom/test/window/BrowserWindow.test.ts +++ b/packages/happy-dom/test/window/BrowserWindow.test.ts @@ -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' }, diff --git a/packages/jest-environment/test/testing-library/TestingLibrary.test.tsx b/packages/jest-environment/test/testing-library/TestingLibrary.test.tsx index d179929f1..1c530ad74 100644 --- a/packages/jest-environment/test/testing-library/TestingLibrary.test.tsx +++ b/packages/jest-environment/test/testing-library/TestingLibrary.test.tsx @@ -68,4 +68,14 @@ describe('TestingLibrary', () => { expect(element).toBeInstanceOf(HTMLDialogElement); }); + + it('Can use attribute.not.toMatch().', async () => { + render(); + + const checkbox = screen.getByRole('checkbox'); + const attribute = checkbox.getAttribute('value'); + + expect(attribute).toMatch('test'); + expect(attribute).not.toMatch('hello'); + }); });