Skip to content

Commit

Permalink
fix: [#1122] Fixes problem related to invalid pseudo query selectors …
Browse files Browse the repository at this point in the history
…matching elements
  • Loading branch information
capricorn86 committed Mar 14, 2024
1 parent 0c015f5 commit b15f4b0
Show file tree
Hide file tree
Showing 2 changed files with 179 additions and 80 deletions.
177 changes: 97 additions & 80 deletions packages/happy-dom/src/query-selector/SelectorItem.ts
Expand Up @@ -106,7 +106,7 @@ export default class SelectorItem {
}

/**
* Matches a psuedo selector.
* Matches a pseudo selector.
*
* @param element Element.
* @returns Result.
Expand All @@ -121,23 +121,23 @@ 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;
}

// Check if parent exists
if (!parent) {
switch (psuedo.name) {
switch (pseudo.name) {
case 'first-child':
case 'last-child':
case 'only-child':
Expand All @@ -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' && (<IHTMLInputElement>element).checked
);
case 'empty':
return !(<Element>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' && (<IHTMLInputElement>element).checked;
case 'empty':
return !(<Element>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;
}

/**
Expand Down
82 changes: 82 additions & 0 deletions packages/happy-dom/test/query-selector/QuerySelector.test.ts
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = '<div class="foo"></div>';
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 = '<div class="foo"></div>';
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);
});
});
});

0 comments on commit b15f4b0

Please sign in to comment.