From b15f4b089e88ae69c40a78a8f9f7edafce5bd1ec Mon Sep 17 00:00:00 2001 From: David Ortner Date: Thu, 14 Mar 2024 19:09:04 +0100 Subject: [PATCH] fix: [#1122] Fixes problem related to invalid pseudo query selectors matching elements --- .../src/query-selector/SelectorItem.ts | 177 ++++++++++-------- .../test/query-selector/QuerySelector.test.ts | 82 ++++++++ 2 files changed, 179 insertions(+), 80 deletions(-) diff --git a/packages/happy-dom/src/query-selector/SelectorItem.ts b/packages/happy-dom/src/query-selector/SelectorItem.ts index 07bfc1d27..c1915be52 100644 --- a/packages/happy-dom/src/query-selector/SelectorItem.ts +++ b/packages/happy-dom/src/query-selector/SelectorItem.ts @@ -106,7 +106,7 @@ export default class SelectorItem { } /** - * Matches a psuedo selector. + * Matches a pseudo selector. * * @param element Element. * @returns Result. @@ -121,15 +121,15 @@ export default class SelectorItem { return true; } - for (const psuedo of this.pseudos) { + for (const pseudo of this.pseudos) { // Validation - switch (psuedo.name) { + switch (pseudo.name) { case 'not': case 'nth-child': case 'nth-of-type': case 'nth-last-child': case 'nth-last-of-type': - if (!psuedo.arguments) { + if (!pseudo.arguments) { throw new DOMException(`The selector "${this.getSelectorString()}" is not valid.`); } break; @@ -137,7 +137,7 @@ export default class SelectorItem { // Check if parent exists if (!parent) { - switch (psuedo.name) { + switch (pseudo.name) { case 'first-child': case 'last-child': case 'only-child': @@ -152,86 +152,103 @@ export default class SelectorItem { } } - switch (psuedo.name) { - case 'first-child': - return parentChildren[0] === element; - case 'last-child': - return parentChildren.length && parentChildren[parentChildren.length - 1] === element; - case 'only-child': - return parentChildren.length === 1 && parentChildren[0] === element; - case 'first-of-type': - for (const child of parentChildren) { - if (child[PropertySymbol.tagName] === element[PropertySymbol.tagName]) { - return child === element; - } + if (!this.matchPseudoItem(element, parentChildren, pseudo)) { + return false; + } + } + + return true; + } + + /** + * Matches a pseudo selector. + * + * @param element Element. + * @param parentChildren Parent children. + * @param pseudo Pseudo. + */ + private matchPseudoItem( + element: IElement, + parentChildren: IElement[], + pseudo: ISelectorPseudo + ): boolean { + switch (pseudo.name) { + case 'first-child': + return parentChildren[0] === element; + case 'last-child': + return parentChildren.length && parentChildren[parentChildren.length - 1] === element; + case 'only-child': + return parentChildren.length === 1 && parentChildren[0] === element; + case 'first-of-type': + for (const child of parentChildren) { + if (child[PropertySymbol.tagName] === element[PropertySymbol.tagName]) { + return child === element; } - return false; - case 'last-of-type': - for (let i = parentChildren.length - 1; i >= 0; i--) { - const child = parentChildren[i]; - if (child[PropertySymbol.tagName] === element[PropertySymbol.tagName]) { - return child === element; - } + } + return false; + case 'last-of-type': + for (let i = parentChildren.length - 1; i >= 0; i--) { + const child = parentChildren[i]; + if (child[PropertySymbol.tagName] === element[PropertySymbol.tagName]) { + return child === element; } - return false; - case 'only-of-type': - let isFound = false; - for (const child of parentChildren) { - if (child[PropertySymbol.tagName] === element[PropertySymbol.tagName]) { - if (isFound || child !== element) { - return false; - } - isFound = true; + } + return false; + case 'only-of-type': + let isFound = false; + for (const child of parentChildren) { + if (child[PropertySymbol.tagName] === element[PropertySymbol.tagName]) { + if (isFound || child !== element) { + return false; } + isFound = true; } - return isFound; - case 'checked': - return ( - element[PropertySymbol.tagName] === 'INPUT' && (element).checked - ); - case 'empty': - return !(element)[PropertySymbol.children].length; - case 'root': - return element[PropertySymbol.tagName] === 'HTML'; - case 'not': - return !psuedo.selectorItem.match(element); - case 'nth-child': - const nthChildIndex = psuedo.selectorItem - ? parentChildren.filter((child) => psuedo.selectorItem.match(child)).indexOf(element) - : parentChildren.indexOf(element); - return nthChildIndex !== -1 && psuedo.nthFunction(nthChildIndex + 1); - case 'nth-of-type': - if (!element[PropertySymbol.parentNode]) { - return false; - } - const nthOfTypeIndex = parentChildren - .filter((child) => child[PropertySymbol.tagName] === element[PropertySymbol.tagName]) - .indexOf(element); - return nthOfTypeIndex !== -1 && psuedo.nthFunction(nthOfTypeIndex + 1); - case 'nth-last-child': - const nthLastChildIndex = psuedo.selectorItem - ? parentChildren - .filter((child) => psuedo.selectorItem.match(child)) - .reverse() - .indexOf(element) - : parentChildren.reverse().indexOf(element); - return nthLastChildIndex !== -1 && psuedo.nthFunction(nthLastChildIndex + 1); - case 'nth-last-of-type': - const nthLastOfTypeIndex = parentChildren - .filter((child) => child[PropertySymbol.tagName] === element[PropertySymbol.tagName]) - .reverse() - .indexOf(element); - return nthLastOfTypeIndex !== -1 && psuedo.nthFunction(nthLastOfTypeIndex + 1); - case 'target': - const hash = element[PropertySymbol.ownerDocument].location.hash; - if (!hash) { - return false; - } - return element.isConnected && element.id === hash.slice(1); - } + } + return isFound; + case 'checked': + return element[PropertySymbol.tagName] === 'INPUT' && (element).checked; + case 'empty': + return !(element)[PropertySymbol.children].length; + case 'root': + return element[PropertySymbol.tagName] === 'HTML'; + case 'not': + return !pseudo.selectorItem.match(element); + case 'nth-child': + const nthChildIndex = pseudo.selectorItem + ? parentChildren.filter((child) => pseudo.selectorItem.match(child)).indexOf(element) + : parentChildren.indexOf(element); + return nthChildIndex !== -1 && pseudo.nthFunction(nthChildIndex + 1); + case 'nth-of-type': + if (!element[PropertySymbol.parentNode]) { + return false; + } + const nthOfTypeIndex = parentChildren + .filter((child) => child[PropertySymbol.tagName] === element[PropertySymbol.tagName]) + .indexOf(element); + return nthOfTypeIndex !== -1 && pseudo.nthFunction(nthOfTypeIndex + 1); + case 'nth-last-child': + const nthLastChildIndex = pseudo.selectorItem + ? parentChildren + .filter((child) => pseudo.selectorItem.match(child)) + .reverse() + .indexOf(element) + : parentChildren.reverse().indexOf(element); + return nthLastChildIndex !== -1 && pseudo.nthFunction(nthLastChildIndex + 1); + case 'nth-last-of-type': + const nthLastOfTypeIndex = parentChildren + .filter((child) => child[PropertySymbol.tagName] === element[PropertySymbol.tagName]) + .reverse() + .indexOf(element); + return nthLastOfTypeIndex !== -1 && pseudo.nthFunction(nthLastOfTypeIndex + 1); + case 'target': + const hash = element[PropertySymbol.ownerDocument].location.hash; + if (!hash) { + return false; + } + return element.isConnected && element.id === hash.slice(1); + default: + return false; } - - return true; } /** diff --git a/packages/happy-dom/test/query-selector/QuerySelector.test.ts b/packages/happy-dom/test/query-selector/QuerySelector.test.ts index 98df5e9bc..9df87f251 100644 --- a/packages/happy-dom/test/query-selector/QuerySelector.test.ts +++ b/packages/happy-dom/test/query-selector/QuerySelector.test.ts @@ -654,6 +654,16 @@ describe('QuerySelector', () => { expect(elements[0] === container.children[0].children[1].children[0]).toBe(true); }); + it('Returns all span elements matching "span:first-of-type:last-of-type".', () => { + const container = document.createElement('div'); + container.innerHTML = QuerySelectorHTML; + const elements = container.querySelectorAll('h1:first-of-type:last-of-type'); + + expect(elements.length).toBe(2); + expect(elements[0] === container.children[0].children[0]).toBe(true); + expect(elements[1] === container.children[1].children[0]).toBe(true); + }); + it('Returns all span elements matching ":last-of-type".', () => { const container = document.createElement('div'); container.innerHTML = QuerySelectorHTML; @@ -1227,5 +1237,77 @@ describe('QuerySelector', () => { expect(div.querySelector(':not(:nth-child(1))')).toBe(child2); }); + + it('Returns false for selector with CSS pseado element ":before".', () => { + const container = document.createElement('div'); + container.innerHTML = QuerySelectorHTML; + expect( + container.querySelector('span.class1') === container.children[0].children[1].children[0] + ).toBe(true); + expect( + container.querySelector('span.class1:first-of-type') === + container.children[0].children[1].children[0] + ).toBe(true); + expect(container.querySelector('span.class1:before') === null).toBe(true); + expect(container.querySelector('span.class1:first-of-type:before') === null).toBe(true); + }); + + it('Returns false for selector with CSS pseado element ":after".', () => { + const container = document.createElement('div'); + container.innerHTML = QuerySelectorHTML; + expect( + container.querySelector('span.class1') === container.children[0].children[1].children[0] + ).toBe(true); + expect( + container.querySelector('span.class1:first-of-type') === + container.children[0].children[1].children[0] + ).toBe(true); + expect(container.querySelector('span.class1:after') === null).toBe(true); + expect(container.querySelector('span.class1:first-of-type:after') === null).toBe(true); + }); + }); + + describe('match()', () => { + it('Returns true when the element matches the selector', () => { + const div = document.createElement('div'); + div.innerHTML = '
'; + const element = div.children[0]; + expect(element.matches('.foo')).toBe(true); + }); + + it('Returns false when the element does not match the selector', () => { + const div = document.createElement('div'); + div.innerHTML = '
'; + const element = div.children[0]; + expect(element.matches('.bar')).toBe(false); + }); + + it('Returns true for the selector "div.class1 .class2 span"', () => { + const container = document.createElement('div'); + container.innerHTML = QuerySelectorHTML; + const element = container.children[0].children[1].children[0]; + expect(element.matches('div.class1 .class2 span')).toBe(true); + expect(element.matches('div.class1 .class3 span')).toBe(false); + }); + + it('Returns false for selector with CSS pseado element ":before"', () => { + const container = document.createElement('div'); + container.innerHTML = QuerySelectorHTML; + const element = container.children[0].children[1].children[0]; + expect(element.matches('span.class1')).toBe(true); + expect(element.matches('span.class1:first-of-type')).toBe(true); + expect(element.matches('span.class1:before')).toBe(false); + expect(element.matches('span.class1:first-of-type:before')).toBe(false); + }); + + it('Returns false for selector with CSS pseado element ":after"', () => { + const container = document.createElement('div'); + container.innerHTML = QuerySelectorHTML; + const element = container.children[0].children[1].children[0]; + expect(element.matches('span.class1')).toBe(true); + expect(element.matches('span.class1:first-of-type')).toBe(true); + expect(element.matches('span.class1:after')).toBe(false); + expect(element.matches('span.class1:first-of-type:after')).toBe(false); + }); }); });