diff --git a/packages/happy-dom/src/css/utilities/CSSParser.ts b/packages/happy-dom/src/css/utilities/CSSParser.ts index a397075e3..b8485aca8 100644 --- a/packages/happy-dom/src/css/utilities/CSSParser.ts +++ b/packages/happy-dom/src/css/utilities/CSSParser.ts @@ -301,11 +301,11 @@ export default class CSSParser { * @returns True if valid, false otherwise. */ private validateSelectorText(selectorText: string): boolean { - try { - SelectorParser.getSelectorGroups(selectorText); - } catch (e) { - return false; - } - return true; + const window = this.#parentStyleSheet[PropertySymbol.window]; + return ( + new SelectorParser({ window, scope: window.document, ignoreErrors: true }).getSelectorGroups( + selectorText + ).length > 0 + ); } } diff --git a/packages/happy-dom/src/query-selector/QuerySelector.ts b/packages/happy-dom/src/query-selector/QuerySelector.ts index 924f1faf9..5f402c6ae 100644 --- a/packages/happy-dom/src/query-selector/QuerySelector.ts +++ b/packages/happy-dom/src/query-selector/QuerySelector.ts @@ -122,7 +122,7 @@ export default class QuerySelector { node[PropertySymbol.nodeType] === NodeTypeEnum.documentNode ? (node).documentElement : node; - const groups = SelectorParser.getSelectorGroups(selector, { scope }); + const groups = new SelectorParser({ window, scope }).getSelectorGroups(selector); const items: Element[] = []; const nodeList = new NodeList(PropertySymbol.illegalConstructor, items); const matchesMap: Map = new Map(); @@ -267,7 +267,7 @@ export default class QuerySelector { ? (node).documentElement : node; - for (const items of SelectorParser.getSelectorGroups(selector, { scope })) { + for (const items of new SelectorParser({ window, scope }).getSelectorGroups(selector)) { const match = node[PropertySymbol.nodeType] === NodeTypeEnum.elementNode ? this.findFirst(node, [node], items, cachedItem) @@ -375,10 +375,11 @@ export default class QuerySelector { scopeOrElement[PropertySymbol.nodeType] === NodeTypeEnum.documentNode ? (scopeOrElement).documentElement : scopeOrElement; - for (const items of SelectorParser.getSelectorGroups(selector, { - ...options, + for (const items of new SelectorParser({ + ignoreErrors: options?.ignoreErrors, + window, scope - })) { + }).getSelectorGroups(selector)) { const result = this.matchSelector(element, items.reverse(), cachedItem); if (result) { @@ -409,6 +410,11 @@ export default class QuerySelector { priorityWeight = 0 ): ISelectorMatch | null { const selectorItem = selectorItems[0]; + + if (!selectorItem) { + return null; + } + const result = selectorItem.match(element); if (result) { @@ -437,6 +443,7 @@ export default class QuerySelector { } } break; + case SelectorCombinatorEnum.none: case SelectorCombinatorEnum.child: case SelectorCombinatorEnum.descendant: const parentElement = element.parentElement; @@ -486,7 +493,10 @@ export default class QuerySelector { } } - if (previousSelectorItem?.combinator === SelectorCombinatorEnum.descendant) { + if ( + previousSelectorItem?.combinator === SelectorCombinatorEnum.none || + previousSelectorItem?.combinator === SelectorCombinatorEnum.descendant + ) { const parentElement = element.parentElement; if (parentElement) { return this.matchSelector( @@ -523,6 +533,10 @@ export default class QuerySelector { const nextSelectorItem = selectorItems[1]; let matched: DocumentPositionAndElement[] = []; + if (!selectorItem) { + return []; + } + for (let i = 0, max = children.length; i < max; i++) { const child = children[i]; const childrenOfChild = (child)[PropertySymbol.elementArray]; @@ -554,6 +568,7 @@ export default class QuerySelector { ); } break; + case SelectorCombinatorEnum.none: case SelectorCombinatorEnum.descendant: case SelectorCombinatorEnum.child: matched = matched.concat( @@ -579,7 +594,11 @@ export default class QuerySelector { } } - if (selectorItem.combinator === SelectorCombinatorEnum.descendant && childrenOfChild.length) { + if ( + (selectorItem.combinator === SelectorCombinatorEnum.none || + selectorItem.combinator === SelectorCombinatorEnum.descendant) && + childrenOfChild.length + ) { matched = matched.concat( this.findAll(rootElement, childrenOfChild, selectorItems, cachedItem, position) ); @@ -609,6 +628,10 @@ export default class QuerySelector { const selectorItem = selectorItems[0]; const nextSelectorItem = selectorItems[1]; + if (!selectorItem) { + return null; + } + for (let i = 0, max = children.length; i < max; i++) { const child = children[i]; const childrenOfChild = (child)[PropertySymbol.elementArray]; @@ -638,6 +661,7 @@ export default class QuerySelector { } } break; + case SelectorCombinatorEnum.none: case SelectorCombinatorEnum.descendant: case SelectorCombinatorEnum.child: const match = this.findFirst( @@ -671,7 +695,11 @@ export default class QuerySelector { } } - if (selectorItem.combinator === SelectorCombinatorEnum.descendant && childrenOfChild.length) { + if ( + (selectorItem.combinator === SelectorCombinatorEnum.none || + selectorItem.combinator === SelectorCombinatorEnum.descendant) && + childrenOfChild.length + ) { const match = this.findFirst( rootElement, childrenOfChild, diff --git a/packages/happy-dom/src/query-selector/SelectorCombinatorEnum.ts b/packages/happy-dom/src/query-selector/SelectorCombinatorEnum.ts index af5de8b90..38b122e2a 100644 --- a/packages/happy-dom/src/query-selector/SelectorCombinatorEnum.ts +++ b/packages/happy-dom/src/query-selector/SelectorCombinatorEnum.ts @@ -1,4 +1,5 @@ enum SelectorCombinatorEnum { + none = 'none', descendant = 'descendant', child = 'child', adjacentSibling = 'adjacentSibling', diff --git a/packages/happy-dom/src/query-selector/SelectorItem.ts b/packages/happy-dom/src/query-selector/SelectorItem.ts index 882064abc..9dcddd5d1 100644 --- a/packages/happy-dom/src/query-selector/SelectorItem.ts +++ b/packages/happy-dom/src/query-selector/SelectorItem.ts @@ -47,7 +47,10 @@ export default class SelectorItem { combinator?: SelectorCombinatorEnum; ignoreErrors?: boolean; }) { - this.root = options?.scope ? options.scope[PropertySymbol.ownerDocument].documentElement : null; + this.root = + options?.scope?.[PropertySymbol.ownerDocument]?.documentElement || + options?.scope?.[PropertySymbol.window].document?.documentElement || + null; this.scope = options?.scope || null; this.tagName = options?.tagName || null; this.id = options?.id || null; @@ -55,7 +58,7 @@ export default class SelectorItem { this.attributes = options?.attributes || null; this.pseudos = options?.pseudos || null; this.isPseudoElement = options?.isPseudoElement || false; - this.combinator = options?.combinator || SelectorCombinatorEnum.descendant; + this.combinator = options?.combinator || SelectorCombinatorEnum.none; this.ignoreErrors = options?.ignoreErrors || false; } diff --git a/packages/happy-dom/src/query-selector/SelectorParser.ts b/packages/happy-dom/src/query-selector/SelectorParser.ts index c8a553b3e..cbd622b02 100644 --- a/packages/happy-dom/src/query-selector/SelectorParser.ts +++ b/packages/happy-dom/src/query-selector/SelectorParser.ts @@ -1,9 +1,18 @@ import SelectorItem from './SelectorItem.js'; import SelectorCombinatorEnum from './SelectorCombinatorEnum.js'; -import DOMException from '../exception/DOMException.js'; import type ISelectorPseudo from './ISelectorPseudo.js'; import type Element from '../nodes/element/Element.js'; import type DocumentFragment from '../nodes/document-fragment/DocumentFragment.js'; +import type BrowserWindow from '../window/BrowserWindow.js'; +import NodeTypeEnum from '../nodes/node/NodeTypeEnum.js'; + +/** + * Selector group RegExp. + * + * Group 1: Combinator (" ", ",", "+", ">", "̣~") + * Group 2: Parentheses or brackets. + */ +const SELECTOR_GROUP_REGEXP = /(\s*[\s,+>~]\s*)|([\[\]\(\)"'])/gm; /** * Selector RegExp. @@ -11,23 +20,37 @@ import type DocumentFragment from '../nodes/document-fragment/DocumentFragment.j * Group 1: All (e.g. "*") * Group 2: Tag name (e.g. "div") * Group 3: ID (e.g. "#id") - * Group 4: Class (e.g. ".class") - * Group 5: Attribute name when no value (e.g. "attr1") - * Group 6: Attribute name when there is a value using apostrophe (e.g. "attr1") - * Group 7: Attribute operator when using apostrophe (e.g. "~") - * Group 8: Attribute value when using apostrophe (e.g. "value1") - * Group 9: Attribute modifier when using apostrophe (e.g. "i" or "s") - * Group 10: Attribute name when threre is a value not using apostrophe (e.g. "attr1") - * Group 11: Attribute operator when not using apostrophe (e.g. "~") - * Group 12: Attribute value when notusing apostrophe (e.g. "value1") - * Group 13: Pseudo name when arguments (e.g. "nth-child") - * Group 14: Arguments of pseudo (e.g. "2n + 1") - * Group 15: Pseudo name when no arguments (e.g. "empty") - * Group 16: Pseudo element (e.g. "::after", "::-webkit-inner-spin-button"). - * Group 17: Combinator. + * Group 4: ID capture characters (e.g. "\:") (should be ignored) + * Group 5: Class (e.g. ".class") + * Group 6: Class capture characters (e.g. "r") (should be ignored) + * Group 7: Attribute name when no value (e.g. "attr1") + * Group 8: Attribute name capture characters (e.g. "t") (should be ignored) + * Group 9: Attribute name when there is a value using apostrophe (e.g. "attr1") + * Group 10: Attribute name capture characters (e.g. "t") (should be ignored) + * Group 11: Attribute operator when using apostrophe (e.g. "~") + * Group 12: Attribute value including apostrophes (e.g. "'value1'") + * Group 13: Attribute value when using double apostrophes (e.g. "value1") + * Group 14: Attribute value when using single apostrophes (e.g. "value1") + * Group 15: Attribute modifier when using apostrophe (e.g. "i" or "s") + * Group 16: Attribute name when there is a value not using apostrophe (e.g. "attr1") + * Group 17: Attribute name capture characters (e.g. "t") (should be ignored) + * Group 18: Attribute operator when not using apostrophe (e.g. "~") + * Group 19: Attribute value when not using apostrophe (e.g. "value1") + * Group 20: Attribute value capture characters (e.g. "s") (should be ignored) + * Group 21: Pseudo name when arguments (e.g. "nth-child") + * Group 22: Pseudo name when no arguments (e.g. "empty") + * Group 23: Pseudo element (e.g. "::after", "::-webkit-inner-spin-button"). */ const SELECTOR_REGEXP = - /(\*)|([a-zA-Z0-9\u00A0-\uFFFF-]+)|#((?:[a-zA-Z0-9\u00A0-\uFFFF_-]|\\.)+)|\.((?:[a-zA-Z0-9\u00A0-\uFFFF_-]|\\.)+)|\[([a-zA-Z0-9-_\\:]+)\]|\[([a-zA-Z0-9-_\\:]+)\s*([~|^$*]{0,1})\s*=\s*["']{1}([^"']*)["']{1}\s*(s|i){0,1}\]|\[([a-zA-Z0-9-_]+)\s*([~|^$*]{0,1})\s*=\s*([^\]]*)\]|:([a-zA-Z-]+)\s*\(((?:[^()]|\[[^\]]*\]|\([^()]*\))*)\){0,1}|:([a-zA-Z-]+)|::([a-zA-Z-]+)|([\s,+>~]*)/gm; + /(\*)|([a-zA-Z0-9\u00A0-\uFFFF-]+)|#(([a-zA-Z0-9\u00A0-\uFFFF_-]|\\.)+)|\.(([a-zA-Z0-9\u00A0-\uFFFF_-]|\\.)+)|\[(([a-zA-Z0-9-_]|\\.)+)\]|\[(([a-zA-Z0-9-_]|\\.)+)\s*([~|^$*]{0,1})\s*=\s*("([^"]*)"|'([^']*)')\s*(s|i){0,1}\]|\[(([a-zA-Z0-9-_]|\\.)+)\s*([~|^$*]{0,1})\s*=\s*(([a-zA-Z0-9\u00A0-\uFFFF_¤£-]|\\.)+)\]|:([a-zA-Z-]+)\s*\(.+\)|:([a-zA-Z-]+)|::([a-zA-Z-]+)/gm; + +/** + * Selector pseudo RegExp. + * + * Group 1: Pseudo name (e.g. "nth-child") + * Group 2: Parentheses or brackets. + */ +const SELECTOR_PSEUDO_REGEXP = /:([a-zA-Z-]+)|([()])/gm; /** * Escaped Character RegExp. @@ -53,206 +76,368 @@ const NTH_FUNCTION = { */ const SPACE_REGEXP = / /g; -/** - * Simple Selector RegExp. - * - * Group 1: Tag name (e.g. "div") - * Group 2: Class (e.g. ".classA.classB") - * Group 3: ID (e.g. "#id") - */ -const SIMPLE_SELECTOR_REGEXP = - /(^[a-zA-Z0-9\u00A0-\uFFFF-]+$)|(^\.[a-zA-Z0-9\u00A0-\uFFFF_.]+$)|(^#[a-zA-Z0-9\u00A0-\uFFFF_-]+$)/; - /** * Utility for parsing a selection string. */ export default class SelectorParser { + private window: BrowserWindow; + private scope: Element | DocumentFragment; + private ignoreErrors: boolean; + + /** + * + * @param options + * @param options.window + * @param options.scope + * @param options.ignoreErrors + */ + constructor(options: { + window: BrowserWindow; + scope: Element | DocumentFragment; + ignoreErrors?: boolean; + }) { + this.window = options.window; + this.scope = options.scope; + this.ignoreErrors = options.ignoreErrors ?? false; + } /** * Parses a selector string and returns an instance of SelectorItem. * + * @param window Window. * @param selector Selector. * @param options Options. * @param [options.scope] Scope. * @param [options.ignoreErrors] Ignores errors. * @returns Selector item. */ - public static getSelectorItem( - selector: string, - options?: { - scope?: Element | DocumentFragment; - ignoreErrors?: boolean; - } - ): SelectorItem { - return this.getSelectorGroups(selector, options)[0][0]; + public getSelectorItem(selector: string): SelectorItem { + return this.getSelectorGroups(selector)[0][0]; } /** - * Parses a selector string and returns groups with SelectorItem instances. + * Parses a selector string and returns instances of SelectorItem. * + * @param window Window. * @param selector Selector. * @param options Options. * @param [options.scope] Scope. * @param [options.ignoreErrors] Ignores errors. * @returns Selector groups. */ - public static getSelectorGroups( - selector: string, - options?: { - scope?: Element | DocumentFragment; - ignoreErrors?: boolean; - } - ): Array> { + public getSelectorGroups(selector: string): Array> { selector = selector.trim(); - const ignoreErrors = options?.ignoreErrors; - const scope = options?.scope; - - if (selector === '*') { - return [[new SelectorItem({ scope, tagName: '*', ignoreErrors })]]; - } - - const simpleMatch = selector.match(SIMPLE_SELECTOR_REGEXP); - - if (simpleMatch) { - if (simpleMatch[1]) { - return [[new SelectorItem({ scope, tagName: selector.toUpperCase(), ignoreErrors })]]; - } else if (simpleMatch[2]) { - return [ - [ - new SelectorItem({ - scope, - classNames: selector.replace('.', '').split('.'), - ignoreErrors - }) - ] - ]; - } else if (simpleMatch[3]) { - return [[new SelectorItem({ scope, id: selector.replace('#', ''), ignoreErrors })]]; - } - } - - const regexp = new RegExp(SELECTOR_REGEXP); - let currentSelectorItem: SelectorItem = new SelectorItem({ - scope, - combinator: SelectorCombinatorEnum.descendant, - ignoreErrors - }); - let currentGroup: SelectorItem[] = [currentSelectorItem]; + let currentGroup: Array = []; const groups: Array> = [currentGroup]; - let isValid = false; - let match; - - while ((match = regexp.exec(selector))) { - if (match[0]) { - isValid = true; - - if (match[1]) { - currentSelectorItem.tagName = '*'; - } else if (match[2]) { - currentSelectorItem.tagName = match[2].toUpperCase(); - } else if (match[3]) { - currentSelectorItem.id = match[3].replace(ESCAPED_CHARACTER_REGEXP, ''); - } else if (match[4]) { - currentSelectorItem.classNames = currentSelectorItem.classNames || []; - currentSelectorItem.classNames.push(match[4].replace(ESCAPED_CHARACTER_REGEXP, '')); - } else if (match[5]) { - currentSelectorItem.attributes = currentSelectorItem.attributes || []; - currentSelectorItem.attributes.push({ - name: match[5], - operator: null, - value: null, - modifier: null, - regExp: null - }); - } else if (match[6] && match[8] !== undefined) { - currentSelectorItem.attributes = currentSelectorItem.attributes || []; - currentSelectorItem.attributes.push({ - name: match[6], - operator: match[7] || null, - value: match[8].replace(ESCAPED_CHARACTER_REGEXP, ''), - modifier: <'s'>match[9] || null, - regExp: this.getAttributeRegExp({ - operator: match[7], - value: match[8], - modifier: match[9] - }) - }); - } else if (match[10] && match[12] !== undefined) { - currentSelectorItem.attributes = currentSelectorItem.attributes || []; - currentSelectorItem.attributes.push({ - name: match[10], - operator: match[11] || null, - value: match[12].replace(ESCAPED_CHARACTER_REGEXP, ''), - modifier: null, - regExp: this.getAttributeRegExp({ operator: match[11], value: match[12] }) - }); - } else if (match[13] && match[14]) { - currentSelectorItem.pseudos = currentSelectorItem.pseudos || []; - 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], null, options)); - } else if (match[16]) { - currentSelectorItem.isPseudoElement = true; - } else if (match[17]) { - switch (match[17].trim()) { + const regExp = new RegExp(SELECTOR_GROUP_REGEXP); + const depth = { + parentheses: 0, + brackets: 0, + doubleApostrophe: 0, + singleApostrophe: 0 + }; + const name = this.scope.nodeType === NodeTypeEnum.documentNode ? 'Document' : 'Element'; + const error = new this.window.SyntaxError( + `Failed to execute 'querySelectorAll' on '${name}': '${selector}' is not a valid selector.` + ); + let match: null | RegExpExecArray = null; + let lastIndex = 0; + let selectorItem: SelectorItem | null = null; + let combinator: SelectorCombinatorEnum = SelectorCombinatorEnum.none; + + while ((match = regExp.exec(selector))) { + if (match[1]) { + // Matches combinator (" ", ",", "+", ">", "̣~") + // We should ignore combinators that are inside parentheses, brackets or apostrophes + + if ( + depth.parentheses === 0 && + depth.brackets === 0 && + depth.singleApostrophe === 0 && + depth.doubleApostrophe === 0 + ) { + const childSelector = selector.substring(lastIndex, match.index).trim(); + + switch (match[1].trim()) { case ',': - currentSelectorItem = new SelectorItem({ - scope, - combinator: SelectorCombinatorEnum.descendant, - ignoreErrors - }); - currentGroup = [currentSelectorItem]; + selectorItem = this.getSelectorGroupItem(childSelector, combinator); + if (!selectorItem) { + if (this.ignoreErrors) { + return []; + } + throw error; + } + currentGroup.push(selectorItem); + currentGroup = []; groups.push(currentGroup); + combinator = SelectorCombinatorEnum.none; break; case '>': - currentSelectorItem = new SelectorItem({ - scope, - combinator: SelectorCombinatorEnum.child, - ignoreErrors - }); - currentGroup.push(currentSelectorItem); + selectorItem = this.getSelectorGroupItem(childSelector, combinator); + if (!selectorItem) { + if (this.ignoreErrors) { + return []; + } + throw error; + } + currentGroup.push(selectorItem); + combinator = SelectorCombinatorEnum.child; break; case '+': - currentSelectorItem = new SelectorItem({ - scope, - combinator: SelectorCombinatorEnum.adjacentSibling, - ignoreErrors - }); - currentGroup.push(currentSelectorItem); + selectorItem = this.getSelectorGroupItem(childSelector, combinator); + if (!selectorItem) { + if (this.ignoreErrors) { + return []; + } + throw error; + } + currentGroup.push(selectorItem); + combinator = SelectorCombinatorEnum.adjacentSibling; break; case '~': - currentSelectorItem = new SelectorItem({ - scope, - combinator: SelectorCombinatorEnum.subsequentSibling, - ignoreErrors - }); - currentGroup.push(currentSelectorItem); + selectorItem = this.getSelectorGroupItem(childSelector, combinator); + if (!selectorItem) { + if (this.ignoreErrors) { + return []; + } + throw error; + } + currentGroup.push(selectorItem); + combinator = SelectorCombinatorEnum.subsequentSibling; break; case '': - currentSelectorItem = new SelectorItem({ - scope, - combinator: SelectorCombinatorEnum.descendant, - ignoreErrors - }); - currentGroup.push(currentSelectorItem); + selectorItem = this.getSelectorGroupItem(childSelector, combinator); + if (!selectorItem) { + if (this.ignoreErrors) { + return []; + } + throw error; + } + currentGroup.push(selectorItem); + combinator = SelectorCombinatorEnum.descendant; break; } + lastIndex = regExp.lastIndex; } } else { - break; + // Matches parentheses or brackets. + + switch (match[2]) { + case '(': + if (depth.singleApostrophe === 0 && depth.doubleApostrophe === 0) { + depth.parentheses++; + } + break; + case ')': + if (depth.singleApostrophe === 0 && depth.doubleApostrophe === 0) { + depth.parentheses--; + } + break; + case '[': + if (depth.singleApostrophe === 0 && depth.doubleApostrophe === 0) { + depth.brackets++; + } + break; + case ']': + if (depth.singleApostrophe === 0 && depth.doubleApostrophe === 0) { + depth.brackets--; + } + break; + case '"': + if (depth.singleApostrophe === 0) { + depth.doubleApostrophe = depth.doubleApostrophe === 1 ? 0 : 1; + } + break; + case "'": + if (depth.doubleApostrophe === 0) { + depth.singleApostrophe = depth.singleApostrophe === 1 ? 0 : 1; + } + break; + } } } - if (!isValid) { - if (options?.ignoreErrors) { + selectorItem = this.getSelectorGroupItem(selector.substring(lastIndex), combinator); + + if ( + !selectorItem || + depth.parentheses !== 0 || + depth.brackets !== 0 || + depth.singleApostrophe !== 0 || + depth.doubleApostrophe !== 0 + ) { + if (this.ignoreErrors) { return []; } - throw new DOMException(`Invalid selector: "${selector}"`); + throw error; + } + + if (combinator === SelectorCombinatorEnum.none && currentGroup.length > 0) { + groups.push([selectorItem]); + } else { + currentGroup.push(selectorItem); } return groups; } + /** + * Parses a selector string and returns an SelectorItem. + * + * @param selector Selector. + * @param combinator Combinator. + * @returns Selector item. + */ + private getSelectorGroupItem( + selector: string, + combinator: SelectorCombinatorEnum + ): SelectorItem | null { + selector = selector.trim(); + const ignoreErrors = this.ignoreErrors; + const scope = this.scope; + + if (!selector) { + return null; + } + + if (selector === '*') { + return new SelectorItem({ scope, tagName: '*', ignoreErrors }); + } + + const regexp = new RegExp(SELECTOR_REGEXP); + const selectorItem: SelectorItem = new SelectorItem({ + scope, + combinator, + ignoreErrors + }); + let match; + let lastIndex = 0; + + while ((match = regexp.exec(selector))) { + if (selector.substring(lastIndex, match.index).trim() !== '') { + return null; + } + + if (match[1]) { + // Matches all, e.g. "*" + + selectorItem.tagName = '*'; + } else if (match[2]) { + // Matches tag name, e.g. "div" + + selectorItem.tagName = match[2].toUpperCase(); + } else if (match[3]) { + // Matches ID, e.g. "#id" + + selectorItem.id = match[3].replace(ESCAPED_CHARACTER_REGEXP, ''); + } else if (match[5]) { + // Matches class names, e.g. ".class1" + + selectorItem.classNames = selectorItem.classNames || []; + selectorItem.classNames.push(match[5].replace(ESCAPED_CHARACTER_REGEXP, '')); + } else if (match[7]) { + // Matches attributes without value, e.g. [attr] + + selectorItem.attributes = selectorItem.attributes || []; + selectorItem.attributes.push({ + name: match[7].replace(ESCAPED_CHARACTER_REGEXP, ''), + operator: null, + value: null, + modifier: null, + regExp: null + }); + } else if (match[9] && (match[13] !== undefined || match[14] !== undefined)) { + // Matches attributes with apostrophes, e.g. [attr='value'] or [attr="value"] or [attr='value' i] + + const value = match[13] ?? match[14]; + selectorItem.attributes = selectorItem.attributes || []; + selectorItem.attributes.push({ + name: match[9].replace(ESCAPED_CHARACTER_REGEXP, ''), + operator: match[11] || null, + value: value.replace(ESCAPED_CHARACTER_REGEXP, ''), + modifier: <'s'>match[15] || null, + regExp: this.getAttributeRegExp({ + operator: match[11], + value, + modifier: match[15] + }) + }); + } else if (match[16] && match[19] !== undefined) { + // Matches attributes without apostrophes, e.g. [attr=value] or [attr=value i] + + selectorItem.attributes = selectorItem.attributes || []; + selectorItem.attributes.push({ + name: match[16].replace(ESCAPED_CHARACTER_REGEXP, ''), + operator: match[18] || null, + value: match[19].replace(ESCAPED_CHARACTER_REGEXP, ''), + modifier: null, + regExp: this.getAttributeRegExp({ operator: match[18], value: match[19] }) + }); + } else if (match[21]) { + // Matches pseudo selectors with arguments, e.g. ":nth-child(2n+1)" or ":not(.class)" + + const pseudoRegExp = new RegExp(SELECTOR_PSEUDO_REGEXP); + let pseudoMatch: null | RegExpExecArray = null; + let name: string | null = null; + let depth = 0; + let pseudoStartIndex = -1; + + while ((pseudoMatch = pseudoRegExp.exec(match[0]))) { + if (pseudoMatch[1]) { + if (depth === 0) { + name = pseudoMatch[1]; + } + } else if (pseudoMatch[2]) { + if (pseudoMatch[2] === '(') { + if (depth === 0) { + pseudoStartIndex = pseudoRegExp.lastIndex; + } + depth++; + } else if (pseudoMatch[2] === ')') { + depth--; + + if (depth < 0) { + // More closing parentheses than opening parentheses, invalid selector + return null; + } + + if (depth === 0) { + // Missing start parenthesis or name for pseudo selector, invalid selector + if (pseudoStartIndex === -1 || !name) { + return null; + } + + selectorItem.pseudos = selectorItem.pseudos || []; + selectorItem.pseudos.push( + this.getPseudo(name, match[0].substring(pseudoStartIndex, pseudoMatch.index)) + ); + name = null; + pseudoStartIndex = -1; + } + } + } + } + } else if (match[22]) { + // Matches pseudo selectors without arguments, e.g. ":empty" or ":checked" + + selectorItem.pseudos = selectorItem.pseudos || []; + selectorItem.pseudos.push(this.getPseudo(match[22], null)); + } else if (match[23]) { + // Matches pseudo elements, e.g. "::after" or "::-webkit-inner-spin-button" + + selectorItem.isPseudoElement = true; + } + + lastIndex = regexp.lastIndex; + } + + // If there are any characters left in the selector that were not matched, the selector is invalid. + if (lastIndex < selector.length) { + return null; + } + + return selectorItem; + } + /** * Returns attribute RegExp. * @@ -262,7 +447,7 @@ export default class SelectorParser { * @param attribute.modifier Attribute modifier. * @returns Attribute RegExp. */ - private static getAttributeRegExp(attribute: { + private getAttributeRegExp(attribute: { value?: string; operator?: string; modifier?: string; @@ -302,19 +487,9 @@ export default class SelectorParser { * * @param name Pseudo name. * @param args Pseudo arguments. - * @param [options] Options. - * @param [options.scope] Scope. - * @param [options.ignoreErrors] Ignores errors. * @returns Pseudo. */ - private static getPseudo( - name: string, - args: string | null | undefined, - options?: { - scope?: Element | DocumentFragment; - ignoreErrors?: boolean; - } - ): ISelectorPseudo { + private getPseudo(name: string, args: string | null | undefined): ISelectorPseudo { const lowerName = name.toLowerCase(); if (args) { @@ -331,9 +506,7 @@ 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(), options) - : null; + nthOfIndex !== -1 ? this.getSelectorItem(args.substring(nthOfIndex + 4).trim()) : null; return { name: lowerName, arguments: args, @@ -350,8 +523,10 @@ export default class SelectorParser { }; case 'not': const notSelectorItems = []; - for (const group of this.getSelectorGroups(args, options)) { - notSelectorItems.push(group[0]); + for (const group of this.getSelectorGroups(args)) { + if (group[0]) { + notSelectorItems.push(group[0]); + } } return { name: lowerName, @@ -362,8 +537,10 @@ export default class SelectorParser { case 'is': case 'where': const selectorItems = []; - for (const group of this.getSelectorGroups(args, options)) { - selectorItems.push(group[0]); + for (const group of this.getSelectorGroups(args)) { + if (group[0]) { + selectorItems.push(group[0]); + } } return { name: lowerName, @@ -382,8 +559,10 @@ export default class SelectorParser { } else if (args[0] === '>') { newArgs = args.replace('>', ''); } - for (const group of this.getSelectorGroups(newArgs, options)) { - hasSelectorItems.push(group[0]); + for (const group of this.getSelectorGroups(newArgs)) { + if (group[0]) { + hasSelectorItems.push(group[0]); + } } } @@ -409,7 +588,7 @@ export default class SelectorParser { * @param args Pseudo arguments. * @returns Pseudo nth function. */ - private static getPseudoNthFunction(args?: string): ((n: number) => boolean) | null { + private getPseudoNthFunction(args?: string): ((n: number) => boolean) | null { if (!args) { return null; } diff --git a/packages/happy-dom/test/AdoptedStyleSheetCustomElement.ts b/packages/happy-dom/test/AdoptedStyleSheetCustomElement.ts index b9ddd01d8..418673737 100644 --- a/packages/happy-dom/test/AdoptedStyleSheetCustomElement.ts +++ b/packages/happy-dom/test/AdoptedStyleSheetCustomElement.ts @@ -1,6 +1,5 @@ import type ShadowRoot from '../src/nodes/shadow-root/ShadowRoot.js'; import HTMLElement from '../src/nodes/html-element/HTMLElement.js'; -import CSSStyleSheet from '../src/css/CSSStyleSheet.js'; /** * CustomElement test class. @@ -23,7 +22,7 @@ export default class AdoptedStyleSheetCustomElement extends HTMLElement { this.internalShadowRoot = this.attachShadow({ mode: AdoptedStyleSheetCustomElement.shadowRootMode }); - const styleSheet = new CSSStyleSheet(); + const styleSheet = new this.ownerDocument.defaultView!.CSSStyleSheet(); styleSheet.replaceSync(` :host { display: block; @@ -72,7 +71,7 @@ export default class AdoptedStyleSheetCustomElement extends HTMLElement { ${Array.from(this.childNodes) .map( - (child) => + (child: any) => '#' + child['nodeType'] + (child['tagName'] || '') + child.textContent ) .join(', ')} diff --git a/packages/happy-dom/test/css/CSSParser.test.ts b/packages/happy-dom/test/css/CSSParser.test.ts index 01edf9555..e93dda994 100644 --- a/packages/happy-dom/test/css/CSSParser.test.ts +++ b/packages/happy-dom/test/css/CSSParser.test.ts @@ -7,12 +7,19 @@ import type CSSKeyframeRule from '../../src/css/rules/CSSKeyframeRule.js'; import type CSSKeyframesRule from '../../src/css/rules/CSSKeyframesRule.js'; import type CSSContainerRule from '../../src/css/rules/CSSContainerRule.js'; import type CSSSupportsRule from '../../src/css/rules/CSSSupportsRule.js'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; +import type BrowserWindow from '../../src/window/BrowserWindow.js'; +import Window from '../../src/window/Window.js'; describe('CSSParser', () => { + let window: BrowserWindow; + beforeEach(() => { + window = new Window(); + }); + describe('parseFromString()', () => { it('Parses CSS into an Array of CSSRule.', () => { - const cssStyleSheet = new CSSStyleSheet(); + const cssStyleSheet = new window.CSSStyleSheet(); const cssRules = new CSSParser(cssStyleSheet).parseFromString(CSSParserInput); expect(cssRules.length).toBe(11); @@ -227,7 +234,7 @@ describe('CSSParser', () => { src: url("~react-native-vector-icons/Fonts/Ionicons.ttf"); } `; - const cssStyleSheet = new CSSStyleSheet(); + const cssStyleSheet = new window.CSSStyleSheet(); const cssRules = new CSSParser(cssStyleSheet).parseFromString(css); expect(cssRules.length).toBe(1); @@ -247,7 +254,7 @@ describe('CSSParser', () => { } `; - const cssStyleSheet = new CSSStyleSheet(); + const cssStyleSheet = new window.CSSStyleSheet(); const cssRules = new CSSParser(cssStyleSheet).parseFromString(css); expect(cssRules.length).toBe(1); @@ -265,7 +272,7 @@ describe('CSSParser', () => { const css = '@media (forced-colors: active) { @media screen and (max-width: 36rem) { .foo { height: 0.5rem; } } }'; - const cssStyleSheet = new CSSStyleSheet(); + const cssStyleSheet = new window.CSSStyleSheet(); const cssRules = new CSSParser(cssStyleSheet).parseFromString(css); expect(cssRules.length).toBe(1); @@ -277,7 +284,7 @@ describe('CSSParser', () => { it('Supports @media rule inside a @container rule', () => { const css = '@container (min-width: 36rem) { @media screen and (max-width: 36rem) { .foo { height: 0.5rem; } } }'; - const cssStyleSheet = new CSSStyleSheet(); + const cssStyleSheet = new window.CSSStyleSheet(); const cssRules = new CSSParser(cssStyleSheet).parseFromString(css); expect(cssRules.length).toBe(1); @@ -289,7 +296,7 @@ describe('CSSParser', () => { it('Supports @media rule inside a @supports rule', () => { const css = '@supports (display: flex) { @media screen and (max-width: 36rem) { .foo { height: 0.5rem; } } }'; - const cssStyleSheet = new CSSStyleSheet(); + const cssStyleSheet = new window.CSSStyleSheet(); const cssRules = new CSSParser(cssStyleSheet).parseFromString(css); expect(cssRules.length).toBe(1); @@ -301,7 +308,7 @@ describe('CSSParser', () => { it('Ignores @media rule inside a @keyframes rule', () => { const css = '@keyframes keyframes1 { @media screen and (max-width: 36rem) { .foo { height: 0.5rem; } } }'; - const cssStyleSheet = new CSSStyleSheet(); + const cssStyleSheet = new window.CSSStyleSheet(); const cssRules = new CSSParser(cssStyleSheet).parseFromString(css); expect(cssRules.length).toBe(1); @@ -311,7 +318,7 @@ describe('CSSParser', () => { it('Supports @container rule inside a @container rule', () => { const css = '@container containerName (min-width: 36rem) { @container containerName (min-width: 36rem) { .foo { height: 0.5rem; } } }'; - const cssStyleSheet = new CSSStyleSheet(); + const cssStyleSheet = new window.CSSStyleSheet(); const cssRules = new CSSParser(cssStyleSheet).parseFromString(css); expect(cssRules.length).toBe(1); @@ -323,7 +330,7 @@ describe('CSSParser', () => { it('Supports @container rule inside a @supports rule', () => { const css = '@supports (display: flex) { @container (min-width: 36rem) { .foo { height: 0.5rem; } } }'; - const cssStyleSheet = new CSSStyleSheet(); + const cssStyleSheet = new window.CSSStyleSheet(); const cssRules = new CSSParser(cssStyleSheet).parseFromString(css); expect(cssRules.length).toBe(1); @@ -335,7 +342,7 @@ describe('CSSParser', () => { it('Supports @container rule inside a @media rule', () => { const css = '@media screen and (max-width: 36rem) { @container (min-width: 36rem) { .foo { height: 0.5rem; } } }'; - const cssStyleSheet = new CSSStyleSheet(); + const cssStyleSheet = new window.CSSStyleSheet(); const cssRules = new CSSParser(cssStyleSheet).parseFromString(css); expect(cssRules.length).toBe(1); @@ -347,7 +354,7 @@ describe('CSSParser', () => { it('Ignores @container rule inside a @keyframes rule', () => { const css = '@keyframes keyframes1 { @container (min-width: 36rem) { .foo { height: 0.5rem; } } }'; - const cssStyleSheet = new CSSStyleSheet(); + const cssStyleSheet = new window.CSSStyleSheet(); const cssRules = new CSSParser(cssStyleSheet).parseFromString(css); expect(cssRules.length).toBe(1); @@ -357,7 +364,7 @@ describe('CSSParser', () => { it('Supports @supports rule inside a @supports rule', () => { const css = '@supports (display: flex) { @supports (display: grid) { .foo { height: 0.5rem; } } }'; - const cssStyleSheet = new CSSStyleSheet(); + const cssStyleSheet = new window.CSSStyleSheet(); const cssRules = new CSSParser(cssStyleSheet).parseFromString(css); expect(cssRules.length).toBe(1); @@ -369,7 +376,7 @@ describe('CSSParser', () => { it('Supports @supports rule inside a @media rule', () => { const css = '@media screen and (max-width: 36rem) { @supports (display: grid) { .foo { height: 0.5rem; } } }'; - const cssStyleSheet = new CSSStyleSheet(); + const cssStyleSheet = new window.CSSStyleSheet(); const cssRules = new CSSParser(cssStyleSheet).parseFromString(css); expect(cssRules.length).toBe(1); @@ -381,7 +388,7 @@ describe('CSSParser', () => { it('Supports @supports rule inside a @container rule', () => { const css = '@container (min-width: 36rem) { @supports (display: grid) { .foo { height: 0.5rem; } } }'; - const cssStyleSheet = new CSSStyleSheet(); + const cssStyleSheet = new window.CSSStyleSheet(); const cssRules = new CSSParser(cssStyleSheet).parseFromString(css); expect(cssRules.length).toBe(1); @@ -393,7 +400,7 @@ describe('CSSParser', () => { it('Ignores @supports rule inside a @keyframes rule', () => { const css = '@keyframes keyframes1 { @supports (display: grid) { .foo { height: 0.5rem; } } }'; - const cssStyleSheet = new CSSStyleSheet(); + const cssStyleSheet = new window.CSSStyleSheet(); const cssRules = new CSSParser(cssStyleSheet).parseFromString(css); expect(cssRules.length).toBe(1); @@ -403,7 +410,7 @@ describe('CSSParser', () => { it('Supports @keyframes rule inside a @supports rule', () => { const css = '@supports (display: flex) { @keyframes keyframes1 { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } }'; - const cssStyleSheet = new CSSStyleSheet(); + const cssStyleSheet = new window.CSSStyleSheet(); const cssRules = new CSSParser(cssStyleSheet).parseFromString(css); expect(cssRules.length).toBe(1); @@ -415,7 +422,7 @@ describe('CSSParser', () => { it('Supports @keyframes rule inside a @media rule', () => { const css = '@media screen and (max-width: 36rem) { @keyframes keyframes1 { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } }'; - const cssStyleSheet = new CSSStyleSheet(); + const cssStyleSheet = new window.CSSStyleSheet(); const cssRules = new CSSParser(cssStyleSheet).parseFromString(css); expect(cssRules.length).toBe(1); @@ -427,7 +434,7 @@ describe('CSSParser', () => { it('Supports @keyframes rule inside a @container rule', () => { const css = '@container (min-width: 36rem) { @keyframes keyframes1 { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } }'; - const cssStyleSheet = new CSSStyleSheet(); + const cssStyleSheet = new window.CSSStyleSheet(); const cssRules = new CSSParser(cssStyleSheet).parseFromString(css); expect(cssRules.length).toBe(1); @@ -439,7 +446,7 @@ describe('CSSParser', () => { it('Ignores @keyframes rule inside a @keyframes rule', () => { const css = '@keyframes keyframes1 { @keyframes keyframes2 { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } }'; - const cssStyleSheet = new CSSStyleSheet(); + const cssStyleSheet = new window.CSSStyleSheet(); const cssRules = new CSSParser(cssStyleSheet).parseFromString(css); expect(cssRules.length).toBe(1); @@ -447,7 +454,7 @@ describe('CSSParser', () => { }); it('Supports @scope rule', () => { - const cssStyleSheet = new CSSStyleSheet(); + const cssStyleSheet = new window.CSSStyleSheet(); const cssRules = new CSSParser(cssStyleSheet).parseFromString(` @scope { .foo { color: red; } @@ -471,7 +478,7 @@ describe('CSSParser', () => { }); it('Supports @scope rule inside a @scope rule', () => { - const cssStyleSheet = new CSSStyleSheet(); + const cssStyleSheet = new window.CSSStyleSheet(); const cssRules = new CSSParser(cssStyleSheet).parseFromString(` @scope { @scope { @@ -487,7 +494,7 @@ describe('CSSParser', () => { }); it('Supports @scope rule inside a @container rule', () => { - const cssStyleSheet = new CSSStyleSheet(); + const cssStyleSheet = new window.CSSStyleSheet(); const cssRules = new CSSParser(cssStyleSheet).parseFromString(` @container (min-width: 36rem) { @scope { @@ -503,7 +510,7 @@ describe('CSSParser', () => { }); it('Supports @scope rule inside a @media rule', () => { - const cssStyleSheet = new CSSStyleSheet(); + const cssStyleSheet = new window.CSSStyleSheet(); const cssRules = new CSSParser(cssStyleSheet).parseFromString(` @media screen and (max-width: 36rem) { @scope { @@ -519,7 +526,7 @@ describe('CSSParser', () => { }); it('Supports @scope rule inside a @supports rule', () => { - const cssStyleSheet = new CSSStyleSheet(); + const cssStyleSheet = new window.CSSStyleSheet(); const cssRules = new CSSParser(cssStyleSheet).parseFromString(` @supports (display: flex) { @scope { @@ -535,7 +542,7 @@ describe('CSSParser', () => { }); it('Ignores @scope rule inside a @keyframes rule', () => { - const cssStyleSheet = new CSSStyleSheet(); + const cssStyleSheet = new window.CSSStyleSheet(); const cssRules = new CSSParser(cssStyleSheet).parseFromString(` @keyframes keyframes1 { @scope { diff --git a/packages/happy-dom/test/nodes/shadow-root/ShadowRoot.test.ts b/packages/happy-dom/test/nodes/shadow-root/ShadowRoot.test.ts index f05888212..ce79d2b67 100644 --- a/packages/happy-dom/test/nodes/shadow-root/ShadowRoot.test.ts +++ b/packages/happy-dom/test/nodes/shadow-root/ShadowRoot.test.ts @@ -151,7 +151,7 @@ describe('ShadowRoot', () => { describe('get adoptedStyleSheets()', () => { it('Returns set adopted style sheets.', () => { const shadowRoot = document.createElement('custom-element').shadowRoot; - const styleSheet = new CSSStyleSheet(); + const styleSheet = new window.CSSStyleSheet(); shadowRoot.adoptedStyleSheets = [styleSheet]; expect(shadowRoot.adoptedStyleSheets).toEqual([styleSheet]); }); @@ -160,7 +160,7 @@ describe('ShadowRoot', () => { describe('set adoptedStyleSheets()', () => { it('Sets adopted style sheets.', () => { const shadowRoot = document.createElement('custom-element').shadowRoot; - const styleSheet = new CSSStyleSheet(); + const styleSheet = new window.CSSStyleSheet(); shadowRoot.adoptedStyleSheets = [styleSheet]; expect(shadowRoot.adoptedStyleSheets).toEqual([styleSheet]); }); diff --git a/packages/happy-dom/test/query-selector/QuerySelector.test.ts b/packages/happy-dom/test/query-selector/QuerySelector.test.ts index 6a9fcfa5f..209bcdad9 100644 --- a/packages/happy-dom/test/query-selector/QuerySelector.test.ts +++ b/packages/happy-dom/test/query-selector/QuerySelector.test.ts @@ -6,7 +6,6 @@ import QuerySelectorNthChildHTML from './data/QuerySelectorNthChildHTML.js'; import type HTMLInputElement from '../../src/nodes/html-input-element/HTMLInputElement.js'; import { beforeEach, describe, it, expect } from 'vitest'; import QuerySelector from '../../src/query-selector/QuerySelector.js'; -import DOMException from '../../src/exception/DOMException.js'; describe('QuerySelector', () => { let window: Window; @@ -295,9 +294,14 @@ describe('QuerySelector', () => { container.appendChild(element1); container.appendChild(element2); - const invalidSelectorElements = container.querySelectorAll('.before:'); + // Invalid selector ".before:" should throw (colon without pseudo-class name) + expect(() => container.querySelectorAll('.before:')).toThrowError( + new SyntaxError( + `Failed to execute 'querySelectorAll' on 'Document': '.before:' is not a valid selector.` + ) + ); + // Valid selector with escaped colon should match const validSelectorElements = container.querySelectorAll('.before\\:after'); - expect(invalidSelectorElements.length).toBe(0); expect(validSelectorElements.length).toBe(2); expect(validSelectorElements[0] === element1).toBe(true); expect(validSelectorElements[1] === element2).toBe(true); @@ -329,9 +333,10 @@ describe('QuerySelector', () => { container.appendChild(element1); container.appendChild(element2); - const invalidSelectorElements = container.querySelectorAll('.before&after'); + // Invalid selector ".before&after" should throw (& is not valid in CSS selectors) + expect(() => container.querySelectorAll('.before&after')).toThrow(); + // Valid selector with escaped ampersand should match const validSelectorElements = container.querySelectorAll('.before\\&after'); - expect(invalidSelectorElements.length).toBe(0); expect(validSelectorElements.length).toBe(2); expect(validSelectorElements[0] === element1).toBe(true); expect(validSelectorElements[1] === element2).toBe(true); @@ -502,6 +507,24 @@ describe('QuerySelector', () => { expect(elements[1] === container.children[0].children[1].children[1]).toBe(true); }); + it('Returns elements with attribute values containing apostrophes using double quotes as delimiter.', () => { + const container = document.createElement('div'); + container.innerHTML = `
Content
`; + + const elements = container.querySelectorAll('[data-value="it\'s a test"]'); + expect(elements.length).toBe(1); + expect(elements[0] === container.children[0]).toBe(true); + }); + + it('Returns elements with attribute values containing double quotes using apostrophes as delimiter.', () => { + const container = document.createElement('div'); + container.innerHTML = `
Content
`; + + const elements = container.querySelectorAll('[data-value=\'say "hello"\']'); + expect(elements.length).toBe(1); + expect(elements[0] === container.children[0]).toBe(true); + }); + it('Returns all elements with tag name and matching attributes using "span[_attr1]".', () => { const container = document.createElement('div'); container.innerHTML = QuerySelectorHTML.replace(/ attr1/gm, '_attr1'); @@ -912,26 +935,32 @@ describe('QuerySelector', () => { expect(elements3[1] === container.children[0].children[1].children[2]).toBe(true); }); - it('Returns all elements matching the selector without ending parenthese "button:not([type]"', () => { + it('Throws error for selector without ending parenthesis "button:not([type]"', () => { const container = document.createElement('div'); container.innerHTML = ` `; - const elements = container.querySelectorAll('button:not([type]'); + // Invalid selector (missing closing parenthesis) should throw + expect(() => container.querySelectorAll('button:not([type]')).toThrow(); + // Valid selector should work + const elements = container.querySelectorAll('button:not([type])'); expect(elements.length).toBe(2); expect(elements[0]).toBe(container.children[0]); expect(elements[1]).toBe(container.children[2]); }); - it('Returns all elements matching the invalid selector "[q\\:shadowroot]"', () => { + it('Returns all elements matching the selector "[q\\:shadowroot]" with escaped colon', () => { const container = document.createElement('div'); - container.innerHTML = ` - - -
- `; + // Create element with attribute name containing colon + const span = document.createElement('span'); + span.setAttribute('q:shadowroot', ''); + container.appendChild(span); + container.appendChild(document.createElement('button')); + container.appendChild(document.createElement('article')); + + // Selector [q\:shadowroot] looks for attribute "q:shadowroot" (colon is escaped) const elements = container.querySelectorAll('[q\\:shadowroot]'); expect(elements.length).toBe(1); expect(elements[0]).toBe(container.children[0]); @@ -1290,6 +1319,71 @@ describe('QuerySelector', () => { ]); }); + it('Correctly parses complex CSS :has() expression with nested :not() (issue #1910)', () => { + const container = document.createElement('div'); + container.innerHTML = ` + + + + + + + + + + + + + + + + + +
Header 1Header 2
Cell 1Cell 2
Ignored 1Ignored 2
+ `; + + const elements = container.querySelectorAll( + '.emXRrt>tbody>tr>td:nth-child(2),.emXRrt:not(:has(> tbody > tr:not([data-ignore-row])))>thead>tr>th:nth-child(2)' + ); + + // Should only match the 2 td elements from the first selector part + expect(elements.length).toBe(2); + expect(elements[0].textContent).toBe('Cell 2'); + expect(elements[1].textContent).toBe('Ignored 2'); + }); + + it('Should throw error when ending with ","', () => { + const container = document.createElement('div'); + expect(() => container.querySelectorAll('.test,.test-2,')).toThrowError( + new SyntaxError( + "Failed to execute 'querySelectorAll' on 'Element': '.test,.test-2,' is not a valid selector." + ) + ); + expect(() => container.querySelectorAll('.test.,,test-2')).toThrowError( + new SyntaxError( + "Failed to execute 'querySelectorAll' on 'Element': '.test,,.test-2' is not a valid selector." + ) + ); + }); + + it('Correctly parses :not() with nested :has() containing :not() (issue #1910)', () => { + const container = document.createElement('div'); + container.innerHTML = ` +
+
+
+
+
+
+
+ `; + + const elements = container.querySelectorAll('.parent:not(:has(> .child:not([data-keep])))'); + + expect(elements.length).toBe(1); + expect(elements[0].classList.contains('no-match')).toBe(true); + }); + it('Returns all elements for subsequent sibling selector using ".a ~ .a"', () => { const div = document.createElement('div'); @@ -1382,6 +1476,14 @@ describe('QuerySelector', () => { expect(root.length).toBe(1); expect(root[0]).toBe(document.documentElement); }); + + it('Matches XML query selector for #1912', () => { + const xml = 'Content'; + const parser = new window.DOMParser(); + const xmlDoc = parser.parseFromString(xml, 'application/xml'); + + expect(xmlDoc.querySelectorAll('[Name]').length).toBe(1); + }); }); describe('querySelector()', () => { @@ -2154,5 +2256,22 @@ describe('QuerySelector', () => { expect(div.matches(':root')).toBe(false); }); + + it('Matches unicode characters for #2034', () => { + // Apostrophe in double-quoted attribute value + document.body.innerHTML = ` + + + `; + expect(!!document.querySelector('[id="type d\'activité-label"]')).toBe(true); + + // Test 2: Double quote in single-quoted attribute value + document.body.innerHTML = `
`; + expect(!!document.querySelector('[id=\'say "hello"\']')).toBe(true); + + // Test 3: Simple ID (control test) + document.body.innerHTML = `
`; + expect(!!document.querySelector('[id="simple"]')).toBe(true); + }); }); }); diff --git a/packages/happy-dom/test/window/BrowserWindow.test.ts b/packages/happy-dom/test/window/BrowserWindow.test.ts index 5793630bf..f8bdfd064 100644 --- a/packages/happy-dom/test/window/BrowserWindow.test.ts +++ b/packages/happy-dom/test/window/BrowserWindow.test.ts @@ -652,7 +652,7 @@ describe('BrowserWindow', () => { ); const elementComputedStyle = window.getComputedStyle(element); - const styleSheet = new CSSStyleSheet(); + const styleSheet = new window.CSSStyleSheet(); styleSheet.replaceSync(` span { color: green;