跳到正文

Happy DOM 为什么会误判复杂的 not 选择器

一条 CSS 想排除评论区域里的节点,可以写成 .item:not(.comments *)。它在浏览器里只选中区域外的 .item,但放进 Happy DOM 测试,区域里的节点也被选中了。

这次差异不依赖点击、异步渲染或框架。只创建三个元素,第一次调用 matches() 就能复现。顺着源码看,:not() 参数里的完整后代关系在解析结构转换时被截短了,后面的匹配器检查了一个与原规则不同的条件。

本文保留最小输入、控制用例、解析结构和上游记录。采样日期为 2026 年 10 月 28 日,Node.js 26.12.0,Happy DOM 21.0.3、21.0.4;后者是当天核对的 npm latest。浏览器对照为隔离、无扩展的 Edge 156.0.4314.42。两个 Happy DOM 版本都能复现,源码解释统一以 21.0.4 的发布提交为基线。

后半部分继续给出本地源码修复、动态更新与 Shadow DOM 回归,以及可下载补丁。该修复尚未提交上游或进入 npm 发布包;前面的版本对照仍然描述官方版本的行为。

三个元素就能复现

HTML 很短:

<div class="comments">
  <span class="item" id="inside"></span>
</div>
<span class="item" id="outside"></span>

要保护的行为是:选中 .item,但排除 .comments 的所有后代。选择器为:

.item:not(.comments *)

在真实浏览器控制台中,使用 matches() 和 querySelectorAll() 可以分别观察单个候选节点与整份查询结果:

const selector = '.item:not(.comments *)';

document.getElementById('inside').matches(selector);
// false

[...document.querySelectorAll(selector)].map(element => element.id);
// ['outside']

同样的操作放进 Happy DOM:

import { Window } from 'happy-dom';

const window = new Window({ url: 'https://example.test/' });
const document = window.document;
document.body.innerHTML = `
  <div class="comments">
    <span class="item" id="inside"></span>
  </div>
  <span class="item" id="outside"></span>
`;

const selector = '.item:not(.comments *)';
console.log(document.getElementById('inside').matches(selector));
// true,和预期相反

console.log([...document.querySelectorAll(selector)].map(element => element.id));
// ['inside', 'outside']

await window.happyDOM.close();

这里的 Window 是 Happy DOM 在 Node.js 中实现的浏览器式对象,不会启动 Chromium。document、节点查询和样式匹配都走它自己的实现,因此 Node.js 版本相同也不会让它自动获得浏览器的选择器引擎。

观察项 Edge 156.0.4314.42 Happy DOM 21.0.3 Happy DOM 21.0.4
inside.matches(selector) false true true
querySelectorAll(selector) 的 ID outside inside, outside inside, outside
应用这条规则的 display: none 后,inside 的 display inline-block none none

最后一行也做了实际采样:先给 .item 显式设置 display: inline-block,再加被测规则。这样比较的是规则有没有命中,避开不同环境对未设置属性的默认值或序列化差异。

左侧是当前浏览器实时查询,右侧是 Happy DOM 在 Node 中的独立采样记录

交互实验可以切换几个控制选择器。左侧在当前浏览器实时执行,右侧展示独立 Node 实验的固定记录;它没有在浏览器里伪装运行 Happy DOM。

not 排除的是候选元素满足的整个条件

.item:not(.comments *) 里,最终候选元素仍是 .item。对于每个候选元素,:not() 要回答的是:它是否匹配 .comments *?如果匹配,就排除。

.comments * 的右侧是通配选择器 *,左侧 .comments 通过空格建立祖先关系。因此,对 inside 来说,当前节点匹配 *,往上又能找到 .comments,排除条件成立。

如果把参数换成 .comments,含义就变成了「当前候选元素自己没有 comments 类」。inside 只有 item 类,自然不会被排除:

/* 排除 comments 区域的后代。 */
.item:not(.comments *)

/* 只排除自身也带有 comments 类的 item。 */
.item:not(.comments)

Selectors Level 4 允许 :not() 接受复杂选择器;本文不含伪元素的后代选择器属于合法参数。规范的否定伪类章节与 MDN 的复杂选择器示例都能核对这个行为。Edge 提供实际运行对照,预期本身仍来自选择器语义。

复杂选择器和逗号列表是两个维度

这里有几种容易混用的术语:

形态 例子 要表达的内容
简单选择器 .comments 一个类条件
复合选择器 .comments.active 同一个元素同时满足多个条件
复杂选择器 .comments > .item 多个复合选择器之间存在关系
选择器列表 #inside, #outside 逗号分隔的多个选择器分支

一个列表分支可以只有一个简单选择器,也可以包含完整的复杂关系。实现了「检查逗号分开的每一项」,不代表已经实现「正确匹配每一项里面的父子、后代或兄弟关系」。

not 的逗号列表与单项内部后代关系需要保存不同的解析结构

这也是后面判断上游修复范围的依据。标题里都写着 :not(),复现需要的能力却可能不同。

参数解析出来了,完整关系却没有留下来

Happy DOM 有自己的 SelectorParser,负责把字符串拆成可以参与匹配的结构;SelectorItem 表示其中一项,保存 tag、class、属性、伪类,以及和其他项之间的关系。这里的 combinator 就是后代、子元素等组合关系的类型。

在 21.0.4 中,单独解析 .comments *,保留与问题有关的字段后,结果为:

[
  [
    { "tag": null, "classes": ["comments"], "combinator": "none" },
    { "tag": "*", "classes": null, "combinator": "descendant" }
  ]
]

外层数组表示逗号分开的组,每个组里面又是按关系连接的项。这个结果证明,基础解析器能识别本例的两个节点与后代关系;问题出现在把它包装成 :not() 参数的下一步。

每个分组只取 group[0]

SelectorParser.ts 的 not 分支遍历了分组,但每个组只保留第一项:

// happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts:546-579
// 节选 switch 中的 not 分支,省略其他 case。
switch (lowerName) {
  // ...省略其他 case
  case 'not':
    const notSelectorItems = [];
    for (const group of this.getSelectorGroups(args)) {
      if (group[0]) {
        notSelectorItems.push(group[0]);
      }
    }
    return {
      name: lowerName,
      arguments: args,
      selectorItems: notSelectorItems,
      nthFunction: null
    };
  // ...省略其他 case
}

对于 #inside, #outside,每组本来就只有一项,取每组第一项可以保存两个 ID。对于 .comments *,整个表达式在同一个组里,取 group[0] 只留下 .comments,第二项 * 和它携带的后代关系不再进入 selectorItems。

Happy DOM 单独解析时保留了完整后代关系,包装成 not 参数后只保留第一项

原始参数文本仍保存在 arguments 中,所以调试时可能还能看到完整的 ".comments *"。但字符串还在,不代表用于执行匹配的数据结构也完整;后续代码使用的是截短后的 selectorItems。

not 分支只检查留下的单项

SelectorItem.ts 的 not 匹配分支接着执行:

// happy-dom/packages/happy-dom/src/query-selector/SelectorItem.ts:235-305
// 节选 switch 中的 not 分支,省略其他 case。
switch (pseudo.name) {
  // ...省略其他 case
  case 'not':
    for (const selectorItem of pseudo.selectorItems!) {
      if (selectorItem.match(scope, element)) {
        return null;
      }
    }
    return { priorityWeight: 10 };
  // ...省略其他 case
}

element 是当前候选节点,也就是 inside。留下的 .comments 在当前元素上检查,而 inside 自己没有这个类,于是循环没有返回 null,:not() 被判定为通过。

对于本文这份 DOM,.item:not(.comments *) 的观察结果就退化成了 .item:not(.comments)。这是根据当前解析结果和候选节点得出的解释,不意味着所有复杂参数都可以用同一条字符串替换来概括。

普通复杂选择器的完整匹配则会经过 QuerySelector.matchSelector(),由组合关系决定是否继续查父级、祖先或兄弟。当前 :not() 参数路径既没有保存完整组,也没有把完整关系交给这类匹配过程。

普通复杂选择器会按组合关系继续查找,not 当前路径则检查截短后的一项

修复需要同时考虑参数结构和匹配方式。只把某个数组改成保留更多节点,再继续逐项在同一个元素上调用 match(),仍然表达不了祖先与后代关系。完整补丁还需要处理作用域、错误参数、嵌套伪类、缓存和权重;上图只解释本例相关的匹配路径。

控制用例怎样缩小问题范围

同一个 HTML,分别查询下面几条规则:

选择器 Edge 156 返回的 ID Happy DOM 21.0.4 返回的 ID
.comments * inside inside
.item:not(.comments) inside, outside inside, outside
.item:not(.comments *) outside inside, outside
.item:not(.comments > .item) outside inside, outside
.item:not(.comments, #outside) inside inside
.item:not(#inside, #outside) 空 空

第一行说明普通后代关系并没有普遍失效;第二行说明简单排除条件本身能工作;最后两行证明逗号列表已经会检查多个分支。失败集中在复杂关系进入 :not() 参数之后,与前面的源码路径一致。21.0.3 在这组输入上的结果相同。

这些实验每条都创建全新的 Window 和文档,记录第一次 inside.matches() 的结果,然后才执行查询、插入样式。没有先匹配、修改祖先类名、再查询的过程,所以本例无须用「缓存没有失效」解释。

这个区分也能避免误认上游 issue。选择器第一次就算错,和最初算对、DOM 改变后读到旧结果,需要调查的路径不同。清空缓存或多等一帧,不会把已经丢掉的关系节点补回来。

全部控制输入、两版 Happy DOM 输出、浏览器输出和内部解析记录见选择器对照实验弹窗。

找到的 issue 和 PR 分别解决了什么

截至 2026 年 10 月 28 日,在下述检索范围中,没有定位到能直接对应 .item:not(.comments *) 这个最小复现的公开 issue 或已合并 PR。这里保留检索范围和相近记录,方便后续补充;它不等于断言上游从未报告过这个问题。

已修复的是 not 中的多分支列表

issue #990于 2023 年提交,描述的是 ul > li:not(.list-item, .other-item) 没有正确排除所有列表分支。它在 2025 年 3 月 4 日关闭,对应 PR #1751同日合并。

这个 PR 的 diff 与当前结构直接相关。它将解析阶段从读取一项,改成遍历所有分组并取每组第一项;匹配阶段也从检查 selectorItems[0],改成遍历整个 selectorItems。PR 补的用例里,逗号两侧分别都是单个类选择器。

这确实解决了该 issue 的目标,本文最后两条控制用例也证明当前版本已具备多分支排除能力。但每组内部的完整关系仍没有因此保留下来。多检查几个分组,与正确匹配一个复杂分组,是两项不同的支持。

不能仅凭这段历史,把本文的问题认定为 #1751 新引入的回归。要判断首次出现的版本,还需要继续运行合并前后的完整版本矩阵;本次只确认了当前代码路径、关联 diff 和两个实测版本。

复杂括号解析也有过单独修复

issue #1910描述更深的 :not(:has(... :not(...))) 表达式解析异常,表现是参数结构损坏并抛出错误。PR #2010提出改用 parsel-js,该 PR 最终关闭且 没有合并;维护者随后采用自己的解析器改造 PR #2068,该 PR 已合并。

讨论里,维护者提到额外解析器的性能和依赖维护成本,并选择继续改进内部实现。这是该 PR 的评审取舍,不能据此当作本文对两种解析器做过性能测试。

这些修复针对复杂括号和参数解析。本文的 .comments * 已经被基础解析器完整识别,关系是在包装成 :not() 的 selectorItems 时被截短;这次复现需要继续检查该转换过程。

记录 核对状态 与本文的关系
issue #990 closed 多个简单选择器分支,属于相邻能力
PR #1751 merged diff 能解释为何现在遍历分组、但每组只留第一项
issue #1910 closed 深层括号与参数解析异常,复现形态不同
PR #2010 closed,未合并 替换解析器的提案,不能写成当前已采用 parsel-js
PR #2068 merged 内部解析器改造,当前发布版仍能复现本文输入

本次检索使用 Happy DOM 仓库内的 ":not" "descendant"、":not" "combinator"、"group[0]" "not"、"complex" in:title 等查询,并查看相关 PR 的 diff 和交叉链接。状态与查询结果保存在上游检索快照弹窗中。

让 not 使用完整匹配路径

普通查询已经有处理组合关系的匹配器,补丁可以复用它。需要补齐的还有调用上下文:同一条内层选择器,从哪个作用域匹配、依赖哪些节点、使哪份缓存失效,都属于正确结果的一部分。

这份补丁基于本地 fork 的提交 5e100dedb73111a276f75a3daff3a506e2524759,相关选择器代码与 21.0.4 发布提交一致。完整补丁包含五个源码文件和一份新增回归测试,没有增加依赖或修改包版本。

保留完整参数,复用既有匹配器

解析阶段仍调用 getSelectorGroups(args) 检查参数并复用解析缓存,但保存整组结构,不再取每组的 group[0]。参数文本也继续保留,供完整匹配入口使用:

// 本地补丁:SelectorParser.ts,节选 not 分支。
case 'not':
  const notSelectorGroups = this.getSelectorGroups(args);
  return {
    name: lowerName,
    arguments: args,
    selectorItems: null,
    selectorGroups: notSelectorGroups,
    nthFunction: null
  };

匹配阶段通过原有的 globalMatchFunction 回到 QuerySelector.matches()。该入口会调用支持组合关系的 matchSelector(),从右向左检查完整参数,返回匹配信息或 null;:not() 根据有无结果取反:

// 本地补丁:SelectorItem.ts,节选 not 分支。
case 'not':
  return this.globalMatchFunction(element, pseudo.arguments!, {
    scope,
    ignoreErrors,
    cachedItem
  })
    ? null
    : { priorityWeight: 10 };

对于 .comments *,匹配器先检查当前元素是否满足 *,再沿祖先关系查找 .comments。逗号参数继续作为完整列表匹配,任一分支成立都会使 :not() 失败。补丁也为子元素 >、相邻兄弟 +、后续兄弟 ~ 分别保留了预期节点列表,用来检查原有关系处理被正确调用。

这样避免在 SelectorItem 内再实现一套祖先和兄弟遍历。ignoreErrors 继续传递给内层,空参数等错误输入也保留回归检查。

scope 必须沿用调用方

只传入元素和参数文本仍然不够。下面的查询是在 root 的作用域里排除直接子元素:

const selector = '.item:not(:scope > .item)';
root.querySelectorAll(selector);

这里 :scope 代表 root。如果内层调用丢掉 scope,完整匹配入口会把当前候选元素当作默认作用域,排除条件随之改变。

回归夹具在 root 下放一个直接 .item,再放一个包含深层 .item 的 nested。从 root 查询应保留深层节点,从 nested 查询应返回空;对某个节点直接调用 matches(),又应采用该调用自身的作用域。相同选择器文本需要分别得到正确结果,不能让上次查询的上下文泄漏进来。

共享依赖,分开保存内外层结果

Happy DOM 会缓存选择器解析结构,也会缓存节点匹配与查询结果。内层匹配恢复正确后,还要让外层缓存知道:这个结果取决于刚才检查过的祖先或兄弟节点。

not 的内层匹配向外层查询登记依赖,祖先变化后使外层缓存失效

可以用同一个深层节点连续观察三次:

const selector = '.item:not(.comments *)';
const element = document.getElementById('deep');
const ancestor = document.getElementById('comments');

element.matches(selector); // false
ancestor.classList.remove('comments');
element.matches(selector); // true
ancestor.classList.add('comments');
element.matches(selector); // false

如果内层拥有独立缓存,祖先变化可能只使内层结果失效,外层仍直接返回之前缓存的布尔值或节点列表。因此补丁把当前外层查询的 cachedItem 沿 match()、伪类匹配和完整匹配入口传下去,访问到的依赖都登记到这份缓存上。

收到 cachedItem 的嵌套调用,会跳过自己那份按选择器文本保存的匹配结果缓存。内层仍返回参数是否匹配,却不把这个结果写进外层的 result 字段:外层保存的可能是整条规则的结果或节点列表,含义与内层排除条件不同。不同查询的 scope 也不会因此共用一个内层结果。

另一条容易遗漏的路径是「当前父级不匹配,继续向更高祖先查找」。即使最初没有任何祖先满足排除条件,访问过的祖先也必须登记依赖;之后给其中一个祖先增加对应 class,原来的否定结果才会失效。

查询根之外的祖先同样会影响结果。例如在 .comments 内部的 nested 上调用 querySelectorAll(),虽然 .comments 不在被遍历的子树中,它仍然参与 .comments * 的关系判断。补丁为这种情况和前置兄弟的 class 变化分别加入了测试。

解析结构在同一 Window 中按选择器文本复用,因此 cachedItem 作为每次匹配的参数传递。若改成在创建解析节点时,用闭包捕获某一次查询的缓存,后续复用解析节点时就会拿到旧上下文。

复用匹配器时,要保护 Shadow DOM 边界

完整匹配路径还暴露出一个边界:原有 SelectorItem.match() 遇到 ShadowRoot 时,会直接把它换成 host。普通祖先条件因而可能读到外层 host 的 class。

同一个 Shadow DOM 内部节点,普通祖先条件与显式 host 条件应有不同匹配结果

假设 host 自己有 comments 类,Shadow DOM 内放着一个 .item。这个内部节点不应被普通的 .comments * 匹配,所以 .item:not(.comments *) 仍应保留它。若内层完整匹配穿过边界,把 host 当作普通祖先,就会新增误排除。

同时也不能一律禁止 host 匹配。显式的 :host .item 可以匹配该节点,因此 .item:not(:host .item) 应排除它。:is(:host)、:where(:host) 和嵌套否定还需要保留对应行为。

Selectors Level 4 编辑草案的 featureless element 规则限制了从影子树匹配 host 的方式:普通类、ID 等条件不能直接匹配它,显式 host 条件及符合规则的逻辑组合需要单独判断。

补丁增加 canMatchShadowHost() 检查这类资格。判断逻辑组合时查看完整分组中指向当前候选的最后一项,不能只搜索参数里是否出现 :host。例如 :is(.comments, :host(.missing)):后一个分支虽然有 host 语法,但不匹配时,前一个普通类分支仍不能借机读取 host 的 class。

匹配逻辑组合时继续保留 ShadowRoot 上下文;只有直接处理显式 host 条件,才按对应规则解析 host。这样既阻止普通祖先条件穿过边界,也保留显式 host 的匹配。

回归结果与尚未覆盖的能力

新增测试从 matches()、closest()、querySelector()、querySelectorAll() 和 getComputedStyle() 等公开 API 验证,没有只断言内部数组的形状。28 个用例覆盖四种组合关系、多分支和嵌套否定、作用域切换、祖先与兄弟变化、计算样式,以及 8 个 Shadow DOM 边界变体。

验证范围 本地结果
原有 161 项选择器测试与新增 28 项 189 项全部通过
同一份新增断言,在隔离 Edge 中运行 28 项全部通过
Happy DOM 包完整测试 8,100 项通过,12 项失败
TypeScript 编译、ESLint、Prettier、循环依赖检查 通过

那 12 项失败没有被当作通过处理。使用未修改的相同基线,在同一 Windows、Node.js 26.12.0 环境重跑四个失败文件,得到完全相同的 12 个失败用例;它们涉及模块路径、定时器循环、脚本错误事件和文件缓存。完整记录保留了失败名称,便于在其他平台复核。

Edge 验证复用了新增测试的夹具和断言:移除 Happy DOM 专用导入,以浏览器的 Window 替代模拟器 Window,再运行公共 API 检查。它不依赖线上页面或登录态,也没有把手写的预期当作模拟器输出。

这份补丁主要保护匹配范围与更新行为,仍保留原来的 :not() 权重值 10,没有完成 Selectors Level 4 的整套权重计算。普通元素上 :is()、:where() 接收任意复杂参数的既有实现,也没有在这次补丁中整体改造。需要验证复杂层叠或其他选择器组合时,仍应增加对应回归。

补丁、公开 API 快速检查脚本、浏览器用例与完整测试摘要见本地修复与验证材料弹窗。官方 21.0.4 仍保留原问题;本地 fork 的通过结果不代表修复已经进入发布版。

测试该怎样调整

继续使用官方版本时,需要保留的断言是「区域内节点被排除」,可以调整验证这个行为的运行环境。其余普通 DOM 测试继续使用原有环境;采用本地补丁时,再把前面的动态更新和边界用例一并保留。

保留原本要保护的排除行为

如果需求明确要求区域内节点不被隐藏,就不应该为了让 Happy DOM 测试变绿,把预期改成两个节点都命中。那会让测试开始保护错误行为。

同样,也不能把样本缩减到不包含 .comments 祖先的结构,再说测试通过了。这个祖先关系正是缺陷发生的条件,应该成为最小夹具的一部分。

生产页面使用原生 CSS 处理可见性时,可以保留正确的 CSS,把这一小组复杂选择器语义断言放到真实浏览器。其他节点改造、属性恢复、事件和业务分支,仍然由 Happy DOM 单测覆盖。

编译产物、普通 DOM 单测与浏览器语义断言分别保护不同层的事实

浏览器里也可以运行很小的语义测试

真实浏览器并不意味着每个用例都要登录、点击完整页面或走一遍端到端流程。这个问题只需要将最小 HTML 交给浏览器,再检查查询结果和样式:

import assert from 'node:assert/strict';
import { chromium } from 'playwright';

const browser = await chromium.launch({ channel: 'msedge', headless: true });
try {
  const page = await browser.newPage();
  await page.setContent(`
    <style>
      .item { display: inline-block; }
      .item:not(.comments *) { display: none; }
    </style>
    <div class="comments"><span class="item" id="inside"></span></div>
    <span class="item" id="outside"></span>
  `);

  const result = await page.evaluate(() => ({
    ids: [...document.querySelectorAll('.item:not(.comments *)')]
      .map(element => element.id),
    inside: getComputedStyle(document.getElementById('inside')).display,
    outside: getComputedStyle(document.getElementById('outside')).display
  }));

  assert.deepEqual(result, {
    ids: ['outside'],
    inside: 'inline-block',
    outside: 'none'
  });
} finally {
  await browser.close();
}

这个例子要求安装 Playwright 和本机 Edge。本次实验使用 Playwright 1.64.1 调用隔离 Edge,不连接日常浏览器配置;如果改用 Playwright 自带 Chromium,需要安装对应浏览器并修改启动选项,再记录实际版本。

测试范围仍然是一个选择器规则。测试层级由保护的对象与依赖范围决定,运行在浏览器里本身并不会把它变成整页 E2E。

生产 JS 查询需要兼容时再改写

如果业务代码本身也必须在 Happy DOM 中执行相同查询,可以先选中候选,再用明确的祖先判断排除:

const root = document;
const items = [...root.querySelectorAll('.item')].filter(element => {
  // 从父元素开始,排除 comments 的后代;保留自身恰好是 comments 的节点。
  return !element.parentElement?.closest('.comments');
});

这里从 parentElement 开始是为了保持 .comments * 的含义。如果直接使用 element.closest('.comments'),连当前元素自己带有 comments 类的情况也会排除,和原规则不同。

这段改写适用于本文普通 DOM 树中的祖先排除,不是任意 CSS 选择器的通用替代器。涉及 Shadow DOM、显式作用域或其他组合关系时,需要继续定义边界;若生产代码只是给浏览器加载 CSS,就没有必要为了模拟器单独把 CSS 行为搬成 JS。

如何判断问题真正修好了

升级后,重新运行相同 HTML 上的第一次 matches()、完整查询列表和计算样式断言。逗号列表与普通后代选择器也一起检查,确认它们仍返回原来的结果。

还要检查相反方向的误判。给外层容器增加 item 类,它自己没有 .comments 祖先,所以应该保留;它内部的 inside 仍应该被排除:

<div class="comments item" id="self">
  <span class="item" id="inside"></span>
</div>
<span class="item" id="outside"></span>

本次补充采样中,Edge 返回 self, outside,两个 Happy DOM 版本都返回 inside, outside。一个本应保留的节点被漏掉,一个本应排除的节点被选中,正好反映了「检查自身的 comments 类」与「查找 comments 祖先」的差别。本地补丁已把这两个方向放进回归,官方版本的原始结果仍保存在对照记录的 counterexample 中。

这个缺陷也不需要把整个 DOM 测试体系换掉。对于普通 DOM 与组件单测,Happy DOM 仍然是可以使用的默认环境;复杂平台语义则有一组小型浏览器断言作为补充。更完整的环境取舍见Happy DOM 和 jsdom 的对比文章。

如果选择器在到达测试环境之前还经过预处理器,也要确认编译输出。Stylus 的相关排查展示了另一类问题:浏览器执行正确,输入给它的选择器却已经被编译器改了含义。先固定每层的输入与输出,测试失败才有明确的归属。

Happy DOM 与浏览器的选择器对照实验

打开原文件 happy-dom-21.0.4.json

采样日期为 2026-10-28。Node.js 26.12.0;Happy DOM 21.0.3 与 21.0.4;隔离 Edge 156.0.4314.42。所有测试不依赖网络资源,模拟器显式指定 https://example.test/。

在独立实验目录安装版本,下载复现脚本,分别运行:

npm init -y
npm install --save-exact happy-dom@21.0.4
node reproduce.mjs

脚本会为每条选择器创建新的 Window,先记录第一次 matches,再执行查询和样式匹配。需要对照 21.0.3 时,在实验目录切换固定依赖版本后重跑即可。内部结构探针使用未公开的内部模块,只用于理解 21.0.4 的结构,不能作为业务代码依赖。

Happy DOM 21.0.3 原始记录
{
  "node": "v26.12.0",
  "happyDom": "21.0.3",
  "html": "<div class=\"comments\"><span class=\"item\" id=\"inside\"></span></div><span class=\"item\" id=\"outside\"></span>",
  "results": [
    {
      "selector": ".comments *",
      "firstInsideMatch": true,
      "ids": [
        "inside"
      ],
      "insideDisplay": "none"
    },
    {
      "selector": ".item:not(.comments)",
      "firstInsideMatch": true,
      "ids": [
        "inside",
        "outside"
      ],
      "insideDisplay": "none"
    },
    {
      "selector": ".item:not(.comments *)",
      "firstInsideMatch": true,
      "ids": [
        "inside",
        "outside"
      ],
      "insideDisplay": "none"
    },
    {
      "selector": ".item:not(.comments > .item)",
      "firstInsideMatch": true,
      "ids": [
        "inside",
        "outside"
      ],
      "insideDisplay": "none"
    },
    {
      "selector": ".item:not(.comments, #outside)",
      "firstInsideMatch": true,
      "ids": [
        "inside"
      ],
      "insideDisplay": "none"
    },
    {
      "selector": ".item:not(#inside, #outside)",
      "firstInsideMatch": false,
      "ids": [],
      "insideDisplay": "inline-block"
    }
  ],
  "counterexample": {
    "html": "<div class=\"comments item\" id=\"self\"><span class=\"item\" id=\"inside\"></span></div><span class=\"item\" id=\"outside\"></span>",
    "selector": ".item:not(.comments *)",
    "selfMatches": false,
    "ids": [
      "inside",
      "outside"
    ]
  }
}
Happy DOM 21.0.4 原始记录
{
  "node": "v26.12.0",
  "happyDom": "21.0.4",
  "html": "<div class=\"comments\"><span class=\"item\" id=\"inside\"></span></div><span class=\"item\" id=\"outside\"></span>",
  "results": [
    {
      "selector": ".comments *",
      "firstInsideMatch": true,
      "ids": [
        "inside"
      ],
      "insideDisplay": "none"
    },
    {
      "selector": ".item:not(.comments)",
      "firstInsideMatch": true,
      "ids": [
        "inside",
        "outside"
      ],
      "insideDisplay": "none"
    },
    {
      "selector": ".item:not(.comments *)",
      "firstInsideMatch": true,
      "ids": [
        "inside",
        "outside"
      ],
      "insideDisplay": "none"
    },
    {
      "selector": ".item:not(.comments > .item)",
      "firstInsideMatch": true,
      "ids": [
        "inside",
        "outside"
      ],
      "insideDisplay": "none"
    },
    {
      "selector": ".item:not(.comments, #outside)",
      "firstInsideMatch": true,
      "ids": [
        "inside"
      ],
      "insideDisplay": "none"
    },
    {
      "selector": ".item:not(#inside, #outside)",
      "firstInsideMatch": false,
      "ids": [],
      "insideDisplay": "inline-block"
    }
  ],
  "counterexample": {
    "html": "<div class=\"comments item\" id=\"self\"><span class=\"item\" id=\"inside\"></span></div><span class=\"item\" id=\"outside\"></span>",
    "selector": ".item:not(.comments *)",
    "selfMatches": false,
    "ids": [
      "inside",
      "outside"
    ]
  }
}
Edge 查询与计算样式记录
{
  "checkedOn": "2026-10-28",
  "node": "v26.12.0",
  "browser": "156.0.4314.42",
  "playwright": "1.64.1",
  "html": "<div class=\"comments\"><span class=\"item\" id=\"inside\"></span></div><span class=\"item\" id=\"outside\"></span>",
  "results": [
    {
      "selector": ".comments *",
      "firstInsideMatch": true,
      "ids": [
        "inside"
      ],
      "insideDisplay": "none"
    },
    {
      "selector": ".item:not(.comments)",
      "firstInsideMatch": true,
      "ids": [
        "inside",
        "outside"
      ],
      "insideDisplay": "none"
    },
    {
      "selector": ".item:not(.comments *)",
      "firstInsideMatch": false,
      "ids": [
        "outside"
      ],
      "insideDisplay": "inline-block"
    },
    {
      "selector": ".item:not(.comments > .item)",
      "firstInsideMatch": false,
      "ids": [
        "outside"
      ],
      "insideDisplay": "inline-block"
    },
    {
      "selector": ".item:not(.comments, #outside)",
      "firstInsideMatch": true,
      "ids": [
        "inside"
      ],
      "insideDisplay": "none"
    },
    {
      "selector": ".item:not(#inside, #outside)",
      "firstInsideMatch": false,
      "ids": [],
      "insideDisplay": "inline-block"
    }
  ],
  "counterexample": {
    "html": "<div class=\"comments item\" id=\"self\"><span class=\"item\" id=\"inside\"></span></div><span class=\"item\" id=\"outside\"></span>",
    "selector": ".item:not(.comments *)",
    "selfMatches": true,
    "ids": [
      "self",
      "outside"
    ]
  }
}
21.0.4 内部解析结构的相关字段
{
  "happyDom": "21.0.4",
  "chain": [
    [
      {
        "tag": null,
        "classes": [
          "comments"
        ],
        "combinator": "none"
      },
      {
        "tag": "*",
        "classes": null,
        "combinator": "descendant"
      }
    ]
  ],
  "negation": [
    [
      {
        "tag": null,
        "classes": [
          "item"
        ],
        "combinator": "none",
        "pseudos": [
          {
            "name": "not",
            "arguments": ".comments *",
            "items": [
              {
                "tag": null,
                "classes": [
                  "comments"
                ],
                "combinator": "none"
              }
            ]
          }
        ]
      }
    ]
  ]
}
Happy DOM 上游检索与修复记录

打开原文件 upstream.json

核对日期为 2026-10-28。npm 21.0.4 的 gitHead 为 fd163520b74d9fa146be91f7b4e4f46d6cccbe13,正文源码链接固定到该提交。

快照保存关联 issue、PR 的 merged 状态和关键 diff,以及按仓库范围检索得到的结果。未找到直接对应的公开条目属于本次检索结论,不是对所有历史记录的穷尽证明。

上游状态、diff 与检索结果
{
  "checkedOn": "2026-10-28",
  "npm": {
    "latest": "21.0.4",
    "published": "2026-10-20T18:05:53.295Z",
    "gitHead": "fd163520b74d9fa146be91f7b4e4f46d6cccbe13"
  },
  "issues": [
    {
      "url": "https://github.com/capricorn86/happy-dom/issues/990",
      "title": "`querySelectorAll` ignores `:not()` pseudo-class with multiple selectors",
      "state": "closed",
      "state_reason": "completed",
      "created_at": "2023-07-14T07:05:23Z",
      "updated_at": "2025-03-04T22:28:26Z",
      "closed_at": "2025-03-04T22:23:19Z",
      "body": "Enter the following code into https://npm.runkit.com/happy-dom\r\n\r\n```js\r\nvar happyDom = require(\"happy-dom\")\r\n\r\nconst window = new happyDom.Window();\r\nconst document = window.document;\r\ndocument.body.innerHTML = `<ul class=\"list\">\r\n  <li class=\"list-item\"></li>\r\n  <li class=\"list-item\"></li>\r\n  <li class=\"list-item\"></li>\r\n  <li class=\"other-item\"></li>\r\n  <li></li>\r\n</ul>`;\r\n\r\nconst lastItem = document.querySelectorAll('ul > li:not(.list-item, .other-item)');\r\n\r\nconsole.log('lastItem', lastItem.length); // prints 2 but should be 1\r\n```\r\n\r\n`querySelectorAll` will only respect the first selector inside of the `:not` pseudo-class (`.list-item`) and ignores all subsequent selectors (`.other-item`).\r\n\r\n**Expected behavior**\r\nIt respects the complete behaviour, especially comma-separated selector lists, of the [not()](https://developer.mozilla.org/en-US/docs/Web/CSS/:not) pseudo-class.\r\n"
    }
  ],
  "pullRequests": [
    {
      "url": "https://github.com/capricorn86/happy-dom/pull/1751",
      "title": "feat: [#990] Add support for multiple selectors in :not pseudo-class",
      "state": "closed",
      "merged": true,
      "created_at": "2025-02-27T19:12:41Z",
      "updated_at": "2025-03-04T22:23:18Z",
      "closed_at": "2025-03-04T22:23:18Z",
      "merged_at": "2025-03-04T22:23:18Z",
      "base_sha": "6dae37905ad750cb529c44e57beb4320ca06a723",
      "head_sha": "36c2bcbf755f10dd50d6db5a9c49ee042791759a",
      "merge_commit_sha": "6f7057d98db373f22af70045d4551cb8cecd5ad9",
      "diff": "@@ -248,7 +248,12 @@ export default class SelectorItem {\n \t\t\tcase 'root':\n \t\t\t\treturn element[PropertySymbol.tagName] === 'HTML' ? { priorityWeight: 10 } : null;\n \t\t\tcase 'not':\n-\t\t\t\treturn !pseudo.selectorItems[0].match(element) ? { priorityWeight: 10 } : null;\n+\t\t\t\tfor (const selectorItem of pseudo.selectorItems) {\n+\t\t\t\t\tif (selectorItem.match(element)) {\n+\t\t\t\t\t\treturn null;\n+\t\t\t\t\t}\n+\t\t\t\t}\n+\t\t\t\treturn { priorityWeight: 10 };\n \t\t\tcase 'nth-child':\n \t\t\t\tlet nthChildIndex = -1;\n \t\t\t\tif (pseudo.selectorItems[0] && !pseudo.selectorItems[0].match(element)) {\n@@ -316,10 +316,14 @@ export default class SelectorParser {\n \t\t\t\t\tnthFunction: this.getPseudoNthFunction(args)\n \t\t\t\t};\n \t\t\tcase 'not':\n+\t\t\t\tconst notSelectorItems = [];\n+\t\t\t\tfor (const group of this.getSelectorGroups(args, options)) {\n+\t\t\t\t\tnotSelectorItems.push(group[0]);\n+\t\t\t\t}\n \t\t\t\treturn {\n \t\t\t\t\tname: lowerName,\n \t\t\t\t\targuments: args,\n-\t\t\t\t\tselectorItems: [this.getSelectorItem(args, options)],\n+\t\t\t\t\tselectorItems: notSelectorItems,\n \t\t\t\t\tnthFunction: null\n \t\t\t\t};\n \t\t\tcase 'is':\n@@ -901,6 +901,20 @@ describe('QuerySelector', () => {\n \t\t\texpect(elements[0] === container.children[1]).toBe(true);\n \t\t});\n \n+\t\tit('Supports :not with multiple selectors within', () => {\n+\t\t\tconst container = document.createElement('div');\n+\t\t\tcontainer.innerHTML = `<ul class=\"list\">\n+\t\t\t\t<li class=\"list-item\"></li>\n+\t\t\t\t<li class=\"list-item\"></li>\n+\t\t\t\t<li class=\"list-item\"></li>\n+\t\t\t\t<li class=\"other-item\"></li>\n+\t\t\t\t<li></li>\n+\t\t\t</ul>`;\n+\n+\t\t\tconst lastItem = container.querySelectorAll('ul > li:not(.list-item, .other-item)');\n+\t\t\texpect(lastItem.length).toBe(1);\n+\t\t});\n+\n \t\tit('Returns all elements matching \".bar:not(.foo)\".', () => {\n \t\t\tconst container = document.createElement('div');\n \t\t\tcontainer.innerHTML = `"
    },
    {
      "url": "https://github.com/capricorn86/happy-dom/pull/2068",
      "title": "fix: [#1910] Fixes issue when parsing complex query selector with has expression",
      "state": "closed",
      "merged": true,
      "created_at": "2026-02-19T00:26:46Z",
      "updated_at": "2026-02-19T01:02:16Z",
      "closed_at": "2026-02-19T00:45:21Z",
      "merged_at": "2026-02-19T00:45:21Z",
      "base_sha": "b869287d6696147dd8016aa1fd982d725619f876",
      "head_sha": "e2180d59e9baf5af2d8747c0fad259725971f0bb",
      "merge_commit_sha": "e6a64da42384e3dbc74607e4bb1e71b9c3477e74",
      "diff": "@@ -301,11 +301,11 @@ export default class CSSParser {\n \t * @returns True if valid, false otherwise.\n \t */\n \tprivate validateSelectorText(selectorText: string): boolean {\n-\t\ttry {\n-\t\t\tSelectorParser.getSelectorGroups(selectorText);\n-\t\t} catch (e) {\n-\t\t\treturn false;\n-\t\t}\n-\t\treturn true;\n+\t\tconst window = this.#parentStyleSheet[PropertySymbol.window];\n+\t\treturn (\n+\t\t\tnew SelectorParser({ window, scope: window.document, ignoreErrors: true }).getSelectorGroups(\n+\t\t\t\tselectorText\n+\t\t\t).length > 0\n+\t\t);\n \t}\n }\n@@ -122,7 +122,7 @@ export default class QuerySelector {\n \t\t\tnode[PropertySymbol.nodeType] === NodeTypeEnum.documentNode\n \t\t\t\t? (<Document>node).documentElement\n \t\t\t\t: node;\n-\t\tconst groups = SelectorParser.getSelectorGroups(selector, { scope });\n+\t\tconst groups = new SelectorParser({ window, scope }).getSelectorGroups(selector);\n \t\tconst items: Element[] = [];\n \t\tconst nodeList = new NodeList<Element>(PropertySymbol.illegalConstructor, items);\n \t\tconst matchesMap: Map<string, Element> = new Map();\n@@ -267,7 +267,7 @@ export default class QuerySelector {\n \t\t\t\t? (<Document>node).documentElement\n \t\t\t\t: node;\n \n-\t\tfor (const items of SelectorParser.getSelectorGroups(selector, { scope })) {\n+\t\tfor (const items of new SelectorParser({ window, scope }).getSelectorGroups(selector)) {\n \t\t\tconst match =\n \t\t\t\tnode[PropertySymbol.nodeType] === NodeTypeEnum.elementNode\n \t\t\t\t\t? this.findFirst(<Element>node, [<Element>node], items, cachedItem)\n@@ -375,10 +375,11 @@ export default class QuerySelector {\n \t\t\tscopeOrElement[PropertySymbol.nodeType] === NodeTypeEnum.documentNode\n \t\t\t\t? (<Document>scopeOrElement).documentElement\n \t\t\t\t: scopeOrElement;\n-\t\tfor (const items of SelectorParser.getSelectorGroups(selector, {\n-\t\t\t...options,\n+\t\tfor (const items of new SelectorParser({\n+\t\t\tignoreErrors: options?.ignoreErrors,\n+\t\t\twindow,\n \t\t\tscope\n-\t\t})) {\n+\t\t}).getSelectorGroups(selector)) {\n \t\t\tconst result = this.matchSelector(element, items.reverse(), cachedItem);\n \n \t\t\tif (result) {\n@@ -409,6 +410,11 @@ export default class QuerySelector {\n \t\tpriorityWeight = 0\n \t): ISelectorMatch | null {\n \t\tconst selectorItem = selectorItems[0];\n+\n+\t\tif (!selectorItem) {\n+\t\t\treturn null;\n+\t\t}\n+\n \t\tconst result = selectorItem.match(element);\n \n \t\tif (result) {\n@@ -437,6 +443,7 @@ export default class QuerySelector {\n \t\t\t\t\t\t}\n \t\t\t\t\t}\n \t\t\t\t\tbreak;\n+\t\t\t\tcase SelectorCombinatorEnum.none:\n \t\t\t\tcase SelectorCombinatorEnum.child:\n \t\t\t\tcase SelectorCombinatorEnum.descendant:\n \t\t\t\t\tconst parentElement = element.parentElement;\n@@ -486,7 +493,10 @@ export default class QuerySelector {\n \t\t\t}\n \t\t}\n \n-\t\tif (previousSelectorItem?.combinator === SelectorCombinatorEnum.descendant) {\n+\t\tif (\n+\t\t\tpreviousSelectorItem?.combinator === SelectorCombinatorEnum.none ||\n+\t\t\tpreviousSelectorItem?.combinator === SelectorCombinatorEnum.descendant\n+\t\t) {\n \t\t\tconst parentElement = element.parentElement;\n \t\t\tif (parentElement) {\n \t\t\t\treturn this.matchSelector(\n@@ -523,6 +533,10 @@ export default class QuerySelector {\n \t\tconst nextSelectorItem = selectorItems[1];\n \t\tlet matched: DocumentPositionAndElement[] = [];\n \n+\t\tif (!selectorItem) {\n+\t\t\treturn [];\n+\t\t}\n+\n \t\tfor (let i = 0, max = children.length; i < max; i++) {\n \t\t\tconst child = children[i];\n \t\t\tconst childrenOfChild = (<Element>child)[PropertySymbol.elementArray];\n@@ -554,6 +568,7 @@ export default class QuerySelector {\n \t\t\t\t\t\t\t\t);\n \t\t\t\t\t\t\t}\n \t\t\t\t\t\t\tbreak;\n+\t\t\t\t\t\tcase SelectorCombinatorEnum.none:\n \t\t\t\t\t\tcase SelectorCombinatorEnum.descendant:\n \t\t\t\t\t\tcase SelectorCombinatorEnum.child:\n \t\t\t\t\t\t\tmatched = matched.concat(\n@@ -579,7 +594,11 @@ export default class QuerySelector {\n \t\t\t\t}\n \t\t\t}\n \n-\t\t\tif (selectorItem.combinator === SelectorCombinatorEnum.descendant && childrenOfChild.length) {\n+\t\t\tif (\n+\t\t\t\t(selectorItem.combinator === SelectorCombinatorEnum.none ||\n+\t\t\t\t\tselectorItem.combinator === SelectorCombinatorEnum.descendant) &&\n+\t\t\t\tchildrenOfChild.length\n+\t\t\t) {\n \t\t\t\tmatched = matched.concat(\n \t\t\t\t\tthis.findAll(rootElement, childrenOfChild, selectorItems, cachedItem, position)\n \t\t\t\t);\n@@ -609,6 +628,10 @@ export default class QuerySelector {\n \t\tconst selectorItem = selectorItems[0];\n \t\tconst nextSelectorItem = selectorItems[1];\n \n+\t\tif (!selectorItem) {\n+\t\t\treturn null;\n+\t\t}\n+\n \t\tfor (let i = 0, max = children.length; i < max; i++) {\n \t\t\tconst child = children[i];\n \t\t\tconst childrenOfChild = (<Element>child)[PropertySymbol.elementArray];\n@@ -638,6 +661,7 @@ export default class QuerySelector {\n \t\t\t\t\t\t\t\t}\n \t\t\t\t\t\t\t}\n \t\t\t\t\t\t\tbreak;\n+\t\t\t\t\t\tcase SelectorCombinatorEnum.none:\n \t\t\t\t\t\tcase SelectorCombinatorEnum.descendant:\n \t\t\t\t\t\tcase SelectorCombinatorEnum.child:\n \t\t\t\t\t\t\tconst match = this.findFirst(\n@@ -671,7 +695,11 @@ export default class QuerySelector {\n \t\t\t\t}\n \t\t\t}\n \n-\t\t\tif (selectorItem.combinator === SelectorCombinatorEnum.descendant && childrenOfChild.length) {\n+\t\t\tif (\n+\t\t\t\t(selectorItem.combinator === SelectorCombinatorEnum.none ||\n+\t\t\t\t\tselectorItem.combinator === SelectorCombinatorEnum.descendant) &&\n+\t\t\t\tchildrenOfChild.length\n+\t\t\t) {\n \t\t\t\tconst match = this.findFirst(\n \t\t\t\t\trootElement,\n \t\t\t\t\tchildrenOfChild,\n@@ -1,4 +1,5 @@\n enum SelectorCombinatorEnum {\n+\tnone = 'none',\n \tdescendant = 'descendant',\n \tchild = 'child',\n \tadjacentSibling = 'adjacentSibling',\n@@ -47,15 +47,18 @@ export default class SelectorItem {\n \t\tcombinator?: SelectorCombinatorEnum;\n \t\tignoreErrors?: boolean;\n \t}) {\n-\t\tthis.root = options?.scope ? options.scope[PropertySymbol.ownerDocument].documentElement : null;\n+\t\tthis.root =\n+\t\t\toptions?.scope?.[PropertySymbol.ownerDocument]?.documentElement ||\n+\t\t\toptions?.scope?.[PropertySymbol.window].document?.documentElement ||\n+\t\t\tnull;\n \t\tthis.scope = options?.scope || null;\n \t\tthis.tagName = options?.tagName || null;\n \t\tthis.id = options?.id || null;\n \t\tthis.classNames = options?.classNames || null;\n \t\tthis.attributes = options?.attributes || null;\n \t\tthis.pseudos = options?.pseudos || null;\n \t\tthis.isPseudoElement = options?.isPseudoElement || false;\n-\t\tthis.combinator = options?.combinator || SelectorCombinatorEnum.descendant;\n+\t\tthis.combinator = options?.combinator || SelectorCombinatorEnum.none;\n \t\tthis.ignoreErrors = options?.ignoreErrors || false;\n \t}\n \n@@ -1,33 +1,56 @@\n import SelectorItem from './SelectorItem.js';\n import SelectorCombinatorEnum from './SelectorCombinatorEnum.js';\n-import DOMException from '../exception/DOMException.js';\n import type ISelectorPseudo from './ISelectorPseudo.js';\n import type Element from '../nodes/element/Element.js';\n import type DocumentFragment from '../nodes/document-fragment/DocumentFragment.js';\n+import type BrowserWindow from '../window/BrowserWindow.js';\n+import NodeTypeEnum from '../nodes/node/NodeTypeEnum.js';\n+\n+/**\n+ * Selector group RegExp.\n+ *\n+ * Group 1: Combinator (\" \", \",\", \"+\", \">\", \"̣~\")\n+ * Group 2: Parentheses or brackets.\n+ */\n+const SELECTOR_GROUP_REGEXP = /(\\s*[\\s,+>~]\\s*)|([\\[\\]\\(\\)\"'])/gm;\n \n /**\n  * Selector RegExp.\n  *\n  * Group 1: All (e.g. \"*\")\n  * Group 2: Tag name (e.g. \"div\")\n  * Group 3: ID (e.g. \"#id\")\n- * Group 4: Class (e.g. \".class\")\n- * Group 5: Attribute name when no value (e.g. \"attr1\")\n- * Group 6: Attribute name when there is a value using apostrophe (e.g. \"attr1\")\n- * Group 7: Attribute operator when using apostrophe (e.g. \"~\")\n- * Group 8: Attribute value when using apostrophe (e.g. \"value1\")\n- * Group 9: Attribute modifier when using apostrophe (e.g. \"i\" or \"s\")\n- * Group 10: Attribute name when threre is a value not using apostrophe (e.g. \"attr1\")\n- * Group 11: Attribute operator when not using apostrophe (e.g. \"~\")\n- * Group 12: Attribute value when notusing apostrophe (e.g. \"value1\")\n- * Group 13: Pseudo name when arguments (e.g. \"nth-child\")\n- * Group 14: Arguments of pseudo (e.g. \"2n + 1\")\n- * Group 15: Pseudo name when no arguments (e.g. \"empty\")\n- * Group 16: Pseudo element (e.g. \"::after\", \"::-webkit-inner-spin-button\").\n- * Group 17: Combinator.\n+ * Group 4: ID capture characters (e.g. \"\\:\") (should be ignored)\n+ * Group 5: Class (e.g. \".class\")\n+ * Group 6: Class capture characters (e.g. \"r\") (should be ignored)\n+ * Group 7: Attribute name when no value (e.g. \"attr1\")\n+ * Group 8: Attribute name capture characters (e.g. \"t\") (should be ignored)\n+ * Group 9: Attribute name when there is a value using apostrophe (e.g. \"attr1\")\n+ * Group 10: Attribute name capture characters (e.g. \"t\") (should be ignored)\n+ * Group 11: Attribute operator when using apostrophe (e.g. \"~\")\n+ * Group 12: Attribute value including apostrophes (e.g. \"'value1'\")\n+ * Group 13: Attribute value when using double apostrophes (e.g. \"value1\")\n+ * Group 14: Attribute value when using single apostrophes (e.g. \"value1\")\n+ * Group 15: Attribute modifier when using apostrophe (e.g. \"i\" or \"s\")\n+ * Group 16: Attribute name when there is a value not using apostrophe (e.g. \"attr1\")\n+ * Group 17: Attribute name capture characters (e.g. \"t\") (should be ignored)\n+ * Group 18: Attribute operator when not using apostrophe (e.g. \"~\")\n+ * Group 19: Attribute value when not using apostrophe (e.g. \"value1\")\n+ * Group 20: Attribute value capture characters (e.g. \"s\") (should be ignored)\n+ * Group 21: Pseudo name when arguments (e.g. \"nth-child\")\n+ * Group 22: Pseudo name when no arguments (e.g. \"empty\")\n+ * Group 23: Pseudo element (e.g. \"::after\", \"::-webkit-inner-spin-button\").\n  */\n const SELECTOR_REGEXP =\n-\t/(\\*)|([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;\n+\t/(\\*)|([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;\n+\n+/**\n+ * Selector pseudo RegExp.\n+ *\n+ * Group 1: Pseudo name (e.g. \"nth-child\")\n+ * Group 2: Parentheses or brackets.\n+ */\n+const SELECTOR_PSEUDO_REGEXP = /:([a-zA-Z-]+)|([()])/gm;\n \n /**\n  * Escaped Character RegExp.\n@@ -53,206 +76,368 @@ const NTH_FUNCTION = {\n  */\n const SPACE_REGEXP = / /g;\n \n-/**\n- * Simple Selector RegExp.\n- *\n- * Group 1: Tag name (e.g. \"div\")\n- * Group 2: Class (e.g. \".classA.classB\")\n- * Group 3: ID (e.g. \"#id\")\n- */\n-const SIMPLE_SELECTOR_REGEXP =\n-\t/(^[a-zA-Z0-9\\u00A0-\\uFFFF-]+$)|(^\\.[a-zA-Z0-9\\u00A0-\\uFFFF_.]+$)|(^#[a-zA-Z0-9\\u00A0-\\uFFFF_-]+$)/;\n-\n /**\n  * Utility for parsing a selection string.\n  */\n export default class SelectorParser {\n+\tprivate window: BrowserWindow;\n+\tprivate scope: Element | DocumentFragment;\n+\tprivate ignoreErrors: boolean;\n+\n+\t/**\n+\t *\n+\t * @param options\n+\t * @param options.window\n+\t * @param options.scope\n+\t * @param options.ignoreErrors\n+\t */\n+\tconstructor(options: {\n+\t\twindow: BrowserWindow;\n+\t\tscope: Element | DocumentFragment;\n+\t\tignoreErrors?: boolean;\n+\t}) {\n+\t\tthis.window = options.window;\n+\t\tthis.scope = options.scope;\n+\t\tthis.ignoreErrors = options.ignoreErrors ?? false;\n+\t}\n \t/**\n \t * Parses a selector string and returns an instance of SelectorItem.\n \t *\n+\t * @param window Window.\n \t * @param selector Selector.\n \t * @param options Options.\n \t * @param [options.scope] Scope.\n \t * @param [options.ignoreErrors] Ignores errors.\n \t * @returns Selector item.\n \t */\n-\tpublic static getSelectorItem(\n-\t\tselector: string,\n-\t\toptions?: {\n-\t\t\tscope?: Element | DocumentFragment;\n-\t\t\tignoreErrors?: boolean;\n-\t\t}\n-\t): SelectorItem {\n-\t\treturn this.getSelectorGroups(selector, options)[0][0];\n+\tpublic getSelectorItem(selector: string): SelectorItem {\n+\t\treturn this.getSelectorGroups(selector)[0][0];\n \t}\n \n \t/**\n-\t * Parses a selector string and returns groups with SelectorItem instances.\n+\t * Parses a selector string and returns instances of SelectorItem.\n \t *\n+\t * @param window Window.\n \t * @param selector Selector.\n \t * @param options Options.\n \t * @param [options.scope] Scope.\n \t * @param [options.ignoreErrors] Ignores errors.\n \t * @returns Selector groups.\n \t */\n-\tpublic static getSelectorGroups(\n-\t\tselector: string,\n-\t\toptions?: {\n-\t\t\tscope?: Element | DocumentFragment;\n-\t\t\tignoreErrors?: boolean;\n-\t\t}\n-\t): Array<Array<SelectorItem>> {\n+\tpublic getSelectorGroups(selector: string): Array<Array<SelectorItem>> {\n \t\tselector = selector.trim();\n-\t\tconst ignoreErrors = options?.ignoreErrors;\n-\t\tconst scope = options?.scope;\n-\n-\t\tif (selector === '*') {\n-\t\t\treturn [[new SelectorItem({ scope, tagName: '*', ignoreErrors })]];\n-\t\t}\n-\n-\t\tconst simpleMatch = selector.match(SIMPLE_SELECTOR_REGEXP);\n-\n-\t\tif (simpleMatch) {\n-\t\t\tif (simpleMatch[1]) {\n-\t\t\t\treturn [[new SelectorItem({ scope, tagName: selector.toUpperCase(), ignoreErrors })]];\n-\t\t\t} else if (simpleMatch[2]) {\n-\t\t\t\treturn [\n-\t\t\t\t\t[\n-\t\t\t\t\t\tnew SelectorItem({\n-\t\t\t\t\t\t\tscope,\n-\t\t\t\t\t\t\tclassNames: selector.replace('.', '').split('.'),\n-\t\t\t\t\t\t\tignoreErrors\n-\t\t\t\t\t\t})\n-\t\t\t\t\t]\n-\t\t\t\t];\n-\t\t\t} else if (simpleMatch[3]) {\n-\t\t\t\treturn [[new SelectorItem({ scope, id: selector.replace('#', ''), ignoreErrors })]];\n-\t\t\t}\n-\t\t}\n-\n-\t\tconst regexp = new RegExp(SELECTOR_REGEXP);\n-\t\tlet currentSelectorItem: SelectorItem = new SelectorItem({\n-\t\t\tscope,\n-\t\t\tcombinator: SelectorCombinatorEnum.descendant,\n-\t\t\tignoreErrors\n-\t\t});\n-\t\tlet currentGroup: SelectorItem[] = [currentSelectorItem];\n+\t\tlet currentGroup: Array<SelectorItem> = [];\n \t\tconst groups: Array<Array<SelectorItem>> = [currentGroup];\n-\t\tlet isValid = false;\n-\t\tlet match;\n-\n-\t\twhile ((match = regexp.exec(selector))) {\n-\t\t\tif (match[0]) {\n-\t\t\t\tisValid = true;\n-\n-\t\t\t\tif (match[1]) {\n-\t\t\t\t\tcurrentSelectorItem.tagName = '*';\n-\t\t\t\t} else if (match[2]) {\n-\t\t\t\t\tcurrentSelectorItem.tagName = match[2].toUpperCase();\n-\t\t\t\t} else if (match[3]) {\n-\t\t\t\t\tcurrentSelectorItem.id = match[3].replace(ESCAPED_CHARACTER_REGEXP, '');\n-\t\t\t\t} else if (match[4]) {\n-\t\t\t\t\tcurrentSelectorItem.classNames = currentSelectorItem.classNames || [];\n-\t\t\t\t\tcurrentSelectorItem.classNames.push(match[4].replace(ESCAPED_CHARACTER_REGEXP, ''));\n-\t\t\t\t} else if (match[5]) {\n-\t\t\t\t\tcurrentSelectorItem.attributes = currentSelectorItem.attributes || [];\n-\t\t\t\t\tcurrentSelectorItem.attributes.push({\n-\t\t\t\t\t\tname: match[5],\n-\t\t\t\t\t\toperator: null,\n-\t\t\t\t\t\tvalue: null,\n-\t\t\t\t\t\tmodifier: null,\n-\t\t\t\t\t\tregExp: null\n-\t\t\t\t\t});\n-\t\t\t\t} else if (match[6] && match[8] !== undefined) {\n-\t\t\t\t\tcurrentSelectorItem.attributes = currentSelectorItem.attributes || [];\n-\t\t\t\t\tcurrentSelectorItem.attributes.push({\n-\t\t\t\t\t\tname: match[6],\n-\t\t\t\t\t\toperator: match[7] || null,\n-\t\t\t\t\t\tvalue: match[8].replace(ESCAPED_CHARACTER_REGEXP, ''),\n-\t\t\t\t\t\tmodifier: <'s'>match[9] || null,\n-\t\t\t\t\t\tregExp: this.getAttributeRegExp({\n-\t\t\t\t\t\t\toperator: match[7],\n-\t\t\t\t\t\t\tvalue: match[8],\n-\t\t\t\t\t\t\tmodifier: match[9]\n-\t\t\t\t\t\t})\n-\t\t\t\t\t});\n-\t\t\t\t} else if (match[10] && match[12] !== undefined) {\n-\t\t\t\t\tcurrentSelectorItem.attributes = currentSelectorItem.attributes || [];\n-\t\t\t\t\tcurrentSelectorItem.attributes.push({\n-\t\t\t\t\t\tname: match[10],\n-\t\t\t\t\t\toperator: match[11] || null,\n-\t\t\t\t\t\tvalue: match[12].replace(ESCAPED_CHARACTER_REGEXP, ''),\n-\t\t\t\t\t\tmodifier: null,\n-\t\t\t\t\t\tregExp: this.getAttributeRegExp({ operator: match[11], value: match[12] })\n-\t\t\t\t\t});\n-\t\t\t\t} else if (match[13] && match[14]) {\n-\t\t\t\t\tcurrentSelectorItem.pseudos = currentSelectorItem.pseudos || [];\n-\t\t\t\t\tcurrentSelectorItem.pseudos.push(this.getPseudo(match[13], match[14], options));\n-\t\t\t\t} else if (match[15]) {\n-\t\t\t\t\tcurrentSelectorItem.pseudos = currentSelectorItem.pseudos || [];\n-\t\t\t\t\tcurrentSelectorItem.pseudos.push(this.getPseudo(match[15], null, options));\n-\t\t\t\t} else if (match[16]) {\n-\t\t\t\t\tcurrentSelectorItem.isPseudoElement = true;\n-\t\t\t\t} else if (match[17]) {\n-\t\t\t\t\tswitch (match[17].trim()) {\n+\t\tconst regExp = new RegExp(SELECTOR_GROUP_REGEXP);\n+\t\tconst depth = {\n+\t\t\tparentheses: 0,\n+\t\t\tbrackets: 0,\n+\t\t\tdoubleApostrophe: 0,\n+\t\t\tsingleApostrophe: 0\n+\t\t};\n+\t\tconst name = this.scope.nodeType === NodeTypeEnum.documentNode ? 'Document' : 'Element';\n+\t\tconst error = new this.window.SyntaxError(\n+\t\t\t`Failed to execute 'querySelectorAll' on '${name}': '${selector}' is not a valid selector.`\n+\t\t);\n+\t\tlet match: null | RegExpExecArray = null;\n+\t\tlet lastIndex = 0;\n+\t\tlet selectorItem: SelectorItem | null = null;\n+\t\tlet combinator: SelectorCombinatorEnum = SelectorCombinatorEnum.none;\n+\n+\t\twhile ((match = regExp.exec(selector))) {\n+\t\t\tif (match[1]) {\n+\t\t\t\t// Matches combinator (\" \", \",\", \"+\", \">\", \"̣~\")\n+\t\t\t\t// We should ignore combinators that are inside parentheses, brackets or apostrophes\n+\n+\t\t\t\tif (\n+\t\t\t\t\tdepth.parentheses === 0 &&\n+\t\t\t\t\tdepth.brackets === 0 &&\n+\t\t\t\t\tdepth.singleApostrophe === 0 &&\n+\t\t\t\t\tdepth.doubleApostrophe === 0\n+\t\t\t\t) {\n+\t\t\t\t\tconst childSelector = selector.substring(lastIndex, match.index).trim();\n+\n+\t\t\t\t\tswitch (match[1].trim()) {\n \t\t\t\t\t\tcase ',':\n-\t\t\t\t\t\t\tcurrentSelectorItem = new SelectorItem({\n-\t\t\t\t\t\t\t\tscope,\n-\t\t\t\t\t\t\t\tcombinator: SelectorCombinatorEnum.descendant,\n-\t\t\t\t\t\t\t\tignoreErrors\n-\t\t\t\t\t\t\t});\n-\t\t\t\t\t\t\tcurrentGroup = [currentSelectorItem];\n+\t\t\t\t\t\t\tselectorItem = this.getSelectorGroupItem(childSelector, combinator);\n+\t\t\t\t\t\t\tif (!selectorItem) {\n+\t\t\t\t\t\t\t\tif (this.ignoreErrors) {\n+\t\t\t\t\t\t\t\t\treturn [];\n+\t\t\t\t\t\t\t\t}\n+\t\t\t\t\t\t\t\tthrow error;\n+\t\t\t\t\t\t\t}\n+\t\t\t\t\t\t\tcurrentGroup.push(selectorItem);\n+\t\t\t\t\t\t\tcurrentGroup = [];\n \t\t\t\t\t\t\tgroups.push(currentGroup);\n+\t\t\t\t\t\t\tcombinator = SelectorCombinatorEnum.none;\n \t\t\t\t\t\t\tbreak;\n \t\t\t\t\t\tcase '>':\n-\t\t\t\t\t\t\tcurrentSelectorItem = new SelectorItem({\n-\t\t\t\t\t\t\t\tscope,\n-\t\t\t\t\t\t\t\tcombinator: SelectorCombinatorEnum.child,\n-\t\t\t\t\t\t\t\tignoreErrors\n-\t\t\t\t\t\t\t});\n-\t\t\t\t\t\t\tcurrentGroup.push(currentSelectorItem);\n+\t\t\t\t\t\t\tselectorItem = this.getSelectorGroupItem(childSelector, combinator);\n+\t\t\t\t\t\t\tif (!selectorItem) {\n+\t\t\t\t\t\t\t\tif (this.ignoreErrors) {\n+\t\t\t\t\t\t\t\t\treturn [];\n+\t\t\t\t\t\t\t\t}\n+\t\t\t\t\t\t\t\tthrow error;\n+\t\t\t\t\t\t\t}\n+\t\t\t\t\t\t\tcurrentGroup.push(selectorItem);\n+\t\t\t\t\t\t\tcombinator = SelectorCombinatorEnum.child;\n \t\t\t\t\t\t\tbreak;\n \t\t\t\t\t\tcase '+':\n-\t\t\t\t\t\t\tcurrentSelectorItem = new SelectorItem({\n-\t\t\t\t\t\t\t\tscope,\n-\t\t\t\t\t\t\t\tcombinator: SelectorCombinatorEnum.adjacentSibling,\n-\t\t\t\t\t\t\t\tignoreErrors\n-\t\t\t\t\t\t\t});\n-\t\t\t\t\t\t\tcurrentGroup.push(currentSelectorItem);\n+\t\t\t\t\t\t\tselectorItem = this.getSelectorGroupItem(childSelector, combinator);\n+\t\t\t\t\t\t\tif (!selectorItem) {\n+\t\t\t\t\t\t\t\tif (this.ignoreErrors) {\n+\t\t\t\t\t\t\t\t\treturn [];\n+\t\t\t\t\t\t\t\t}\n+\t\t\t\t\t\t\t\tthrow error;\n+\t\t\t\t\t\t\t}\n+\t\t\t\t\t\t\tcurrentGroup.push(selectorItem);\n+\t\t\t\t\t\t\tcombinator = SelectorCombinatorEnum.adjacentSibling;\n \t\t\t\t\t\t\tbreak;\n \t\t\t\t\t\tcase '~':\n-\t\t\t\t\t\t\tcurrentSelectorItem = new SelectorItem({\n-\t\t\t\t\t\t\t\tscope,\n-\t\t\t\t\t\t\t\tcombinator: SelectorCombinatorEnum.subsequentSibling,\n-\t\t\t\t\t\t\t\tignoreErrors\n-\t\t\t\t\t\t\t});\n-\t\t\t\t\t\t\tcurrentGroup.push(currentSelectorItem);\n+\t\t\t\t\t\t\tselectorItem = this.getSelectorGroupItem(childSelector, combinator);\n+\t\t\t\t\t\t\tif (!selectorItem) {\n+\t\t\t\t\t\t\t\tif (this.ignoreErrors) {\n+\t\t\t\t\t\t\t\t\treturn [];\n+\t\t\t\t\t\t\t\t}\n+\t\t\t\t\t\t\t\tthrow error;\n+\t\t\t\t\t\t\t}\n+\t\t\t\t\t\t\tcurrentGroup.push(selectorItem);\n+\t\t\t\t\t\t\tcombinator = SelectorCombinatorEnum.subsequentSibling;\n \t\t\t\t\t\t\tbreak;\n \t\t\t\t\t\tcase '':\n-\t\t\t\t\t\t\tcurrentSelectorItem = new SelectorItem({\n-\t\t\t\t\t\t\t\tscope,\n-\t\t\t\t\t\t\t\tcombinator: SelectorCombinatorEnum.descendant,\n-\t\t\t\t\t\t\t\tignoreErrors\n-\t\t\t\t\t\t\t});\n-\t\t\t\t\t\t\tcurrentGroup.push(currentSelectorItem);\n+\t\t\t\t\t\t\tselectorItem = this.getSelectorGroupItem(childSelector, combinator);\n+\t\t\t\t\t\t\tif (!selectorItem) {\n+\t\t\t\t\t\t\t\tif (this.ignoreErrors) {\n+\t\t\t\t\t\t\t\t\treturn [];\n+\t\t\t\t\t\t\t\t}\n+\t\t\t\t\t\t\t\tthrow error;\n+\t\t\t\t\t\t\t}\n+\t\t\t\t\t\t\tcurrentGroup.push(selectorItem);\n+\t\t\t\t\t\t\tcombinator = SelectorCombinatorEnum.descendant;\n \t\t\t\t\t\t\tbreak;\n \t\t\t\t\t}\n+\t\t\t\t\tlastIndex = regExp.lastIndex;\n \t\t\t\t}\n \t\t\t} else {\n-\t\t\t\tbreak;\n+\t\t\t\t// Matches parentheses or brackets.\n+\n+\t\t\t\tswitch (match[2]) {\n+\t\t\t\t\tcase '(':\n+\t\t\t\t\t\tif (depth.singleApostrophe === 0 && depth.doubleApostrophe === 0) {\n+\t\t\t\t\t\t\tdepth.parentheses++;\n+\t\t\t\t\t\t}\n+\t\t\t\t\t\tbreak;\n+\t\t\t\t\tcase ')':\n+\t\t\t\t\t\tif (depth.singleApostrophe === 0 && depth.doubleApostrophe === 0) {\n+\t\t\t\t\t\t\tdepth.parentheses--;\n+\t\t\t\t\t\t}\n+\t\t\t\t\t\tbreak;\n+\t\t\t\t\tcase '[':\n+\t\t\t\t\t\tif (depth.singleApostrophe === 0 && depth.doubleApostrophe === 0) {\n+\t\t\t\t\t\t\tdepth.brackets++;\n+\t\t\t\t\t\t}\n+\t\t\t\t\t\tbreak;\n+\t\t\t\t\tcase ']':\n+\t\t\t\t\t\tif (depth.singleApostrophe === 0 && depth.doubleApostrophe === 0) {\n+\t\t\t\t\t\t\tdepth.brackets--;\n+\t\t\t\t\t\t}\n+\t\t\t\t\t\tbreak;\n+\t\t\t\t\tcase '\"':\n+\t\t\t\t\t\tif (depth.singleApostrophe === 0) {\n+\t\t\t\t\t\t\tdepth.doubleApostrophe = depth.doubleApostrophe === 1 ? 0 : 1;\n+\t\t\t\t\t\t}\n+\t\t\t\t\t\tbreak;\n+\t\t\t\t\tcase \"'\":\n+\t\t\t\t\t\tif (depth.doubleApostrophe === 0) {\n+\t\t\t\t\t\t\tdepth.singleApostrophe = depth.singleApostrophe === 1 ? 0 : 1;\n+\t\t\t\t\t\t}\n+\t\t\t\t\t\tbreak;\n+\t\t\t\t}\n \t\t\t}\n \t\t}\n \n-\t\tif (!isValid) {\n-\t\t\tif (options?.ignoreErrors) {\n+\t\tselectorItem = this.getSelectorGroupItem(selector.substring(lastIndex), combinator);\n+\n+\t\tif (\n+\t\t\t!selectorItem ||\n+\t\t\tdepth.parentheses !== 0 ||\n+\t\t\tdepth.brackets !== 0 ||\n+\t\t\tdepth.singleApostrophe !== 0 ||\n+\t\t\tdepth.doubleApostrophe !== 0\n+\t\t) {\n+\t\t\tif (this.ignoreErrors) {\n \t\t\t\treturn [];\n \t\t\t}\n-\t\t\tthrow new DOMException(`Invalid selector: \"${selector}\"`);\n+\t\t\tthrow error;\n+\t\t}\n+\n+\t\tif (combinator === SelectorCombinatorEnum.none && currentGroup.length > 0) {\n+\t\t\tgroups.push([selectorItem]);\n+\t\t} else {\n+\t\t\tcurrentGroup.push(selectorItem);\n \t\t}\n \n \t\treturn groups;\n \t}\n \n+\t/**\n+\t * Parses a selector string and returns an SelectorItem.\n+\t *\n+\t * @param selector Selector.\n+\t * @param combinator Combinator.\n+\t * @returns Selector item.\n+\t */\n+\tprivate getSelectorGroupItem(\n+\t\tselector: string,\n+\t\tcombinator: SelectorCombinatorEnum\n+\t): SelectorItem | null {\n+\t\tselector = selector.trim();\n+\t\tconst ignoreErrors = this.ignoreErrors;\n+\t\tconst scope = this.scope;\n+\n+\t\tif (!selector) {\n+\t\t\treturn null;\n+\t\t}\n+\n+\t\tif (selector === '*') {\n+\t\t\treturn new SelectorItem({ scope, tagName: '*', ignoreErrors });\n+\t\t}\n+\n+\t\tconst regexp = new RegExp(SELECTOR_REGEXP);\n+\t\tconst selectorItem: SelectorItem = new SelectorItem({\n+\t\t\tscope,\n+\t\t\tcombinator,\n+\t\t\tignoreErrors\n+\t\t});\n+\t\tlet match;\n+\t\tlet lastIndex = 0;\n+\n+\t\twhile ((match = regexp.exec(selector))) {\n+\t\t\tif (selector.substring(lastIndex, match.index).trim() !== '') {\n+\t\t\t\treturn null;\n+\t\t\t}\n+\n+\t\t\tif (match[1]) {\n+\t\t\t\t// Matches all, e.g. \"*\"\n+\n+\t\t\t\tselectorItem.tagName = '*';\n+\t\t\t} else if (match[2]) {\n+\t\t\t\t// Matches tag name, e.g. \"div\"\n+\n+\t\t\t\tselectorItem.tagName = match[2].toUpperCase();\n+\t\t\t} else if (match[3]) {\n+\t\t\t\t// Matches ID, e.g. \"#id\"\n+\n+\t\t\t\tselectorItem.id = match[3].replace(ESCAPED_CHARACTER_REGEXP, '');\n+\t\t\t} else if (match[5]) {\n+\t\t\t\t// Matches class names, e.g. \".class1\"\n+\n+\t\t\t\tselectorItem.classNames = selectorItem.classNames || [];\n+\t\t\t\tselectorItem.classNames.push(match[5].replace(ESCAPED_CHARACTER_REGEXP, ''));\n+\t\t\t} else if (match[7]) {\n+\t\t\t\t// Matches attributes without value, e.g. [attr]\n+\n+\t\t\t\tselectorItem.attributes = selectorItem.attributes || [];\n+\t\t\t\tselectorItem.attributes.push({\n+\t\t\t\t\tname: match[7].replace(ESCAPED_CHARACTER_REGEXP, ''),\n+\t\t\t\t\toperator: null,\n+\t\t\t\t\tvalue: null,\n+\t\t\t\t\tmodifier: null,\n+\t\t\t\t\tregExp: null\n+\t\t\t\t});\n+\t\t\t} else if (match[9] && (match[13] !== undefined || match[14] !== undefined)) {\n+\t\t\t\t// Matches attributes with apostrophes, e.g. [attr='value'] or [attr=\"value\"] or [attr='value' i]\n+\n+\t\t\t\tconst value = match[13] ?? match[14];\n+\t\t\t\tselectorItem.attributes = selectorItem.attributes || [];\n+\t\t\t\tselectorItem.attributes.push({\n+\t\t\t\t\tname: match[9].replace(ESCAPED_CHARACTER_REGEXP, ''),\n+\t\t\t\t\toperator: match[11] || null,\n+\t\t\t\t\tvalue: value.replace(ESCAPED_CHARACTER_REGEXP, ''),\n+\t\t\t\t\tmodifier: <'s'>match[15] || null,\n+\t\t\t\t\tregExp: this.getAttributeRegExp({\n+\t\t\t\t\t\toperator: match[11],\n+\t\t\t\t\t\tvalue,\n+\t\t\t\t\t\tmodifier: match[15]\n+\t\t\t\t\t})\n+\t\t\t\t});\n+\t\t\t} else if (match[16] && match[19] !== undefined) {\n+\t\t\t\t// Matches attributes without apostrophes, e.g. [attr=value] or [attr=value i]\n+\n+\t\t\t\tselectorItem.attributes = selectorItem.attributes || [];\n+\t\t\t\tselectorItem.attributes.push({\n+\t\t\t\t\tname: match[16].replace(ESCAPED_CHARACTER_REGEXP, ''),\n+\t\t\t\t\toperator: match[18] || null,\n+\t\t\t\t\tvalue: match[19].replace(ESCAPED_CHARACTER_REGEXP, ''),\n+\t\t\t\t\tmodifier: null,\n+\t\t\t\t\tregExp: this.getAttributeRegExp({ operator: match[18], value: match[19] })\n+\t\t\t\t});\n+\t\t\t} else if (match[21]) {\n+\t\t\t\t// Matches pseudo selectors with arguments, e.g. \":nth-child(2n+1)\" or \":not(.class)\"\n+\n+\t\t\t\tconst pseudoRegExp = new RegExp(SELECTOR_PSEUDO_REGEXP);\n+\t\t\t\tlet pseudoMatch: null | RegExpExecArray = null;\n+\t\t\t\tlet name: string | null = null;\n+\t\t\t\tlet depth = 0;\n+\t\t\t\tlet pseudoStartIndex = -1;\n+\n+\t\t\t\twhile ((pseudoMatch = pseudoRegExp.exec(match[0]))) {\n+\t\t\t\t\tif (pseudoMatch[1]) {\n+\t\t\t\t\t\tif (depth === 0) {\n+\t\t\t\t\t\t\tname = pseudoMatch[1];\n+\t\t\t\t\t\t}\n+\t\t\t\t\t} else if (pseudoMatch[2]) {\n+\t\t\t\t\t\tif (pseudoMatch[2] === '(') {\n+\t\t\t\t\t\t\tif (depth === 0) {\n+\t\t\t\t\t\t\t\tpseudoStartIndex = pseudoRegExp.lastIndex;\n+\t\t\t\t\t\t\t}\n+\t\t\t\t\t\t\tdepth++;\n+\t\t\t\t\t\t} else if (pseudoMatch[2] === ')') {\n+\t\t\t\t\t\t\tdepth--;\n+\n+\t\t\t\t\t\t\tif (depth < 0) {\n+\t\t\t\t\t\t\t\t// More closing parentheses than opening parentheses, invalid selector\n+\t\t\t\t\t\t\t\treturn null;\n+\t\t\t\t\t\t\t}\n+\n+\t\t\t\t\t\t\tif (depth === 0) {\n+\t\t\t\t\t\t\t\t// Missing start parenthesis or name for pseudo selector, invalid selector\n+\t\t\t\t\t\t\t\tif (pseudoStartIndex === -1 || !name) {\n+\t\t\t\t\t\t\t\t\treturn null;\n+\t\t\t\t\t\t\t\t}\n+\n+\t\t\t\t\t\t\t\tselectorItem.pseudos = selectorItem.pseudos || [];\n+\t\t\t\t\t\t\t\tselectorItem.pseudos.push(\n+\t\t\t\t\t\t\t\t\tthis.getPseudo(name, match[0].substring(pseudoStartIndex, pseudoMatch.index))\n+\t\t\t\t\t\t\t\t);\n+\t\t\t\t\t\t\t\tname = null;\n+\t\t\t\t\t\t\t\tpseudoStartIndex = -1;\n+\t\t\t\t\t\t\t}\n+\t\t\t\t\t\t}\n+\t\t\t\t\t}\n+\t\t\t\t}\n+\t\t\t} else if (match[22]) {\n+\t\t\t\t// Matches pseudo selectors without arguments, e.g. \":empty\" or \":checked\"\n+\n+\t\t\t\tselectorItem.pseudos = selectorItem.pseudos || [];\n+\t\t\t\tselectorItem.pseudos.push(this.getPseudo(match[22], null));\n+\t\t\t} else if (match[23]) {\n+\t\t\t\t// Matches pseudo elements, e.g. \"::after\" or \"::-webkit-inner-spin-button\"\n+\n+\t\t\t\tselectorItem.isPseudoElement = true;\n+\t\t\t}\n+\n+\t\t\tlastIndex = regexp.lastIndex;\n+\t\t}\n+\n+\t\t// If there are any characters left in the selector that were not matched, the selector is invalid.\n+\t\tif (lastIndex < selector.length) {\n+\t\t\treturn null;\n+\t\t}\n+\n+\t\treturn selectorItem;\n+\t}\n+\n \t/**\n \t * Returns attribute RegExp.\n \t *\n@@ -262,7 +447,7 @@ export default class SelectorParser {\n \t * @param attribute.modifier Attribute modifier.\n \t * @returns Attribute RegExp.\n \t */\n-\tprivate static getAttributeRegExp(attribute: {\n+\tprivate getAttributeRegExp(attribute: {\n \t\tvalue?: string;\n \t\toperator?: string;\n \t\tmodifier?: string;\n@@ -302,19 +487,9 @@ export default class SelectorParser {\n \t *\n \t * @param name Pseudo name.\n \t * @param args Pseudo arguments.\n-\t * @param [options] Options.\n-\t * @param [options.scope] Scope.\n-\t * @param [options.ignoreErrors] Ignores errors.\n \t * @returns Pseudo.\n \t */\n-\tprivate static getPseudo(\n-\t\tname: string,\n-\t\targs: string | null | undefined,\n-\t\toptions?: {\n-\t\t\tscope?: Element | DocumentFragment;\n-\t\t\tignoreErrors?: boolean;\n-\t\t}\n-\t): ISelectorPseudo {\n+\tprivate getPseudo(name: string, args: string | null | undefined): ISelectorPseudo {\n \t\tconst lowerName = name.toLowerCase();\n \n \t\tif (args) {\n@@ -331,9 +506,7 @@ export default class SelectorParser {\n \t\t\t\tconst nthOfIndex = args.indexOf(' of ');\n \t\t\t\tconst nthFunction = nthOfIndex !== -1 ? args.substring(0, nthOfIndex) : args;\n \t\t\t\tconst selectorItem =\n-\t\t\t\t\tnthOfIndex !== -1\n-\t\t\t\t\t\t? this.getSelectorItem(args.substring(nthOfIndex + 4).trim(), options)\n-\t\t\t\t\t\t: null;\n+\t\t\t\t\tnthOfIndex !== -1 ? this.getSelectorItem(args.substring(nthOfIndex + 4).trim()) : null;\n \t\t\t\treturn {\n \t\t\t\t\tname: lowerName,\n \t\t\t\t\targuments: args,\n@@ -350,8 +523,10 @@ export default class SelectorParser {\n \t\t\t\t};\n \t\t\tcase 'not':\n \t\t\t\tconst notSelectorItems = [];\n-\t\t\t\tfor (const group of this.getSelectorGroups(args, options)) {\n-\t\t\t\t\tnotSelectorItems.push(group[0]);\n+\t\t\t\tfor (const group of this.getSelectorGroups(args)) {\n+\t\t\t\t\tif (group[0]) {\n+\t\t\t\t\t\tnotSelectorItems.push(group[0]);\n+\t\t\t\t\t}\n \t\t\t\t}\n \t\t\t\treturn {\n \t\t\t\t\tname: lowerName,\n@@ -362,8 +537,10 @@ export default class SelectorParser {\n \t\t\tcase 'is':\n \t\t\tcase 'where':\n \t\t\t\tconst selectorItems = [];\n-\t\t\t\tfor (const group of this.getSelectorGroups(args, options)) {\n-\t\t\t\t\tselectorItems.push(group[0]);\n+\t\t\t\tfor (const group of this.getSelectorGroups(args)) {\n+\t\t\t\t\tif (group[0]) {\n+\t\t\t\t\t\tselectorItems.push(group[0]);\n+\t\t\t\t\t}\n \t\t\t\t}\n \t\t\t\treturn {\n \t\t\t\t\tname: lowerName,\n@@ -382,8 +559,10 @@ export default class SelectorParser {\n \t\t\t\t\t} else if (args[0] === '>') {\n \t\t\t\t\t\tnewArgs = args.replace('>', '');\n \t\t\t\t\t}\n-\t\t\t\t\tfor (const group of this.getSelectorGroups(newArgs, options)) {\n-\t\t\t\t\t\thasSelectorItems.push(group[0]);\n+\t\t\t\t\tfor (const group of this.getSelectorGroups(newArgs)) {\n+\t\t\t\t\t\tif (group[0]) {\n+\t\t\t\t\t\t\thasSelectorItems.push(group[0]);\n+\t\t\t\t\t\t}\n \t\t\t\t\t}\n \t\t\t\t}\n \n@@ -409,7 +588,7 @@ export default class SelectorParser {\n \t * @param args Pseudo arguments.\n \t * @returns Pseudo nth function.\n \t */\n-\tprivate static getPseudoNthFunction(args?: string): ((n: number) => boolean) | null {\n+\tprivate getPseudoNthFunction(args?: string): ((n: number) => boolean) | null {\n \t\tif (!args) {\n \t\t\treturn null;\n \t\t}\n@@ -1,6 +1,5 @@\n import type ShadowRoot from '../src/nodes/shadow-root/ShadowRoot.js';\n import HTMLElement from '../src/nodes/html-element/HTMLElement.js';\n-import CSSStyleSheet from '../src/css/CSSStyleSheet.js';\n \n /**\n  * CustomElement test class.\n@@ -23,7 +22,7 @@ export default class AdoptedStyleSheetCustomElement extends HTMLElement {\n \t\tthis.internalShadowRoot = this.attachShadow({\n \t\t\tmode: AdoptedStyleSheetCustomElement.shadowRootMode\n \t\t});\n-\t\tconst styleSheet = new CSSStyleSheet();\n+\t\tconst styleSheet = new this.ownerDocument.defaultView!.CSSStyleSheet();\n \t\tstyleSheet.replaceSync(`\n             :host {\n                 display: block;\n@@ -72,7 +71,7 @@ export default class AdoptedStyleSheetCustomElement extends HTMLElement {\n                 </span>\n                 <span class=\"children\">${Array.from(this.childNodes)\n \t\t\t\t\t\t\t\t\t.map(\n-\t\t\t\t\t\t\t\t\t\t(child) =>\n+\t\t\t\t\t\t\t\t\t\t(child: any) =>\n \t\t\t\t\t\t\t\t\t\t\t'#' + child['nodeType'] + (child['tagName'] || '') + child.textContent\n \t\t\t\t\t\t\t\t\t)\n \t\t\t\t\t\t\t\t\t.join(', ')}</span>\n@@ -7,12 +7,19 @@ import type CSSKeyframeRule from '../../src/css/rules/CSSKeyframeRule.js';\n import type CSSKeyframesRule from '../../src/css/rules/CSSKeyframesRule.js';\n import type CSSContainerRule from '../../src/css/rules/CSSContainerRule.js';\n import type CSSSupportsRule from '../../src/css/rules/CSSSupportsRule.js';\n-import { describe, it, expect } from 'vitest';\n+import { describe, it, expect, beforeEach } from 'vitest';\n+import type BrowserWindow from '../../src/window/BrowserWindow.js';\n+import Window from '../../src/window/Window.js';\n \n describe('CSSParser', () => {\n+\tlet window: BrowserWindow;\n+\tbeforeEach(() => {\n+\t\twindow = new Window();\n+\t});\n+\n \tdescribe('parseFromString()', () => {\n \t\tit('Parses CSS into an Array of CSSRule.', () => {\n-\t\t\tconst cssStyleSheet = new CSSStyleSheet();\n+\t\t\tconst cssStyleSheet = new window.CSSStyleSheet();\n \t\t\tconst cssRules = new CSSParser(cssStyleSheet).parseFromString(CSSParserInput);\n \n \t\t\texpect(cssRules.length).toBe(11);\n@@ -227,7 +234,7 @@ describe('CSSParser', () => {\n                     src: url(\"~react-native-vector-icons/Fonts/Ionicons.ttf\");\n                 }\n             `;\n-\t\t\tconst cssStyleSheet = new CSSStyleSheet();\n+\t\t\tconst cssStyleSheet = new window.CSSStyleSheet();\n \t\t\tconst cssRules = new CSSParser(cssStyleSheet).parseFromString(css);\n \n \t\t\texpect(cssRules.length).toBe(1);\n@@ -247,7 +254,7 @@ describe('CSSParser', () => {\n                 }\n             `;\n \n-\t\t\tconst cssStyleSheet = new CSSStyleSheet();\n+\t\t\tconst cssStyleSheet = new window.CSSStyleSheet();\n \t\t\tconst cssRules = new CSSParser(cssStyleSheet).parseFromString(css);\n \n \t\t\texpect(cssRules.length).toBe(1);\n@@ -265,7 +272,7 @@ describe('CSSParser', () => {\n \t\t\tconst css =\n \t\t\t\t'@media (forced-colors: active) { @media screen and (max-width: 36rem) { .foo { height: 0.5rem; } } }';\n \n-\t\t\tconst cssStyleSheet = new CSSStyleSheet();\n+\t\t\tconst cssStyleSheet = new window.CSSStyleSheet();\n \t\t\tconst cssRules = new CSSParser(cssStyleSheet).parseFromString(css);\n \n \t\t\texpect(cssRules.length).toBe(1);\n@@ -277,7 +284,7 @@ describe('CSSParser', () => {\n \t\tit('Supports @media rule inside a @container rule', () => {\n \t\t\tconst css =\n \t\t\t\t'@container (min-width: 36rem) { @media screen and (max-width: 36rem) { .foo { height: 0.5rem; } } }';\n-\t\t\tconst cssStyleSheet = new CSSStyleSheet();\n+\t\t\tconst cssStyleSheet = new window.CSSStyleSheet();\n \t\t\tconst cssRules = new CSSParser(cssStyleSheet).parseFromString(css);\n \n \t\t\texpect(cssRules.length).toBe(1);\n@@ -289,7 +296,7 @@ describe('CSSParser', () => {\n \t\tit('Supports @media rule inside a @supports rule', () => {\n \t\t\tconst css =\n \t\t\t\t'@supports (display: flex) { @media screen and (max-width: 36rem) { .foo { height: 0.5rem; } } }';\n-\t\t\tconst cssStyleSheet = new CSSStyleSheet();\n+\t\t\tconst cssStyleSheet = new window.CSSStyleSheet();\n \t\t\tconst cssRules = new CSSParser(cssStyleSheet).parseFromString(css);\n \n \t\t\texpect(cssRules.length).toBe(1);\n@@ -301,7 +308,7 @@ describe('CSSParser', () => {\n \t\tit('Ignores @media rule inside a @keyframes rule', () => {\n \t\t\tconst css =\n \t\t\t\t'@keyframes keyframes1 { @media screen and (max-width: 36rem) { .foo { height: 0.5rem; } } }';\n-\t\t\tconst cssStyleSheet = new CSSStyleSheet();\n+\t\t\tconst cssStyleSheet = new window.CSSStyleSheet();\n \t\t\tconst cssRules = new CSSParser(cssStyleSheet).parseFromString(css);\n \n \t\t\texpect(cssRules.length).toBe(1);\n@@ -311,7 +318,7 @@ describe('CSSParser', () => {\n \t\tit('Supports @container rule inside a @container rule', () => {\n \t\t\tconst css =\n \t\t\t\t'@container containerName (min-width: 36rem) { @container containerName (min-width: 36rem) { .foo { height: 0.5rem; } } }';\n-\t\t\tconst cssStyleSheet = new CSSStyleSheet();\n+\t\t\tconst cssStyleSheet = new window.CSSStyleSheet();\n \t\t\tconst cssRules = new CSSParser(cssStyleSheet).parseFromString(css);\n \n \t\t\texpect(cssRules.length).toBe(1);\n@@ -323,7 +330,7 @@ describe('CSSParser', () => {\n \t\tit('Supports @container rule inside a @supports rule', () => {\n \t\t\tconst css =\n \t\t\t\t'@supports (display: flex) { @container (min-width: 36rem) { .foo { height: 0.5rem; } } }';\n-\t\t\tconst cssStyleSheet = new CSSStyleSheet();\n+\t\t\tconst cssStyleSheet = new window.CSSStyleSheet();\n \t\t\tconst cssRules = new CSSParser(cssStyleSheet).parseFromString(css);\n \n \t\t\texpect(cssRules.length).toBe(1);\n@@ -335,7 +342,7 @@ describe('CSSParser', () => {\n \t\tit('Supports @container rule inside a @media rule', () => {\n \t\t\tconst css =\n \t\t\t\t'@media screen and (max-width: 36rem) { @container (min-width: 36rem) { .foo { height: 0.5rem; } } }';\n-\t\t\tconst cssStyleSheet = new CSSStyleSheet();\n+\t\t\tconst cssStyleSheet = new window.CSSStyleSheet();\n \t\t\tconst cssRules = new CSSParser(cssStyleSheet).parseFromString(css);\n \n \t\t\texpect(cssRules.length).toBe(1);\n@@ -347,7 +354,7 @@ describe('CSSParser', () => {\n \t\tit('Ignores @container rule inside a @keyframes rule', () => {\n \t\t\tconst css =\n \t\t\t\t'@keyframes keyframes1 { @container (min-width: 36rem) { .foo { height: 0.5rem; } } }';\n-\t\t\tconst cssStyleSheet = new CSSStyleSheet();\n+\t\t\tconst cssStyleSheet = new window.CSSStyleSheet();\n \t\t\tconst cssRules = new CSSParser(cssStyleSheet).parseFromString(css);\n \n \t\t\texpect(cssRules.length).toBe(1);\n@@ -357,7 +364,7 @@ describe('CSSParser', () => {\n \t\tit('Supports @supports rule inside a @supports rule', () => {\n \t\t\tconst css =\n \t\t\t\t'@supports (display: flex) { @supports (display: grid) { .foo { height: 0.5rem; } } }';\n-\t\t\tconst cssStyleSheet = new CSSStyleSheet();\n+\t\t\tconst cssStyleSheet = new window.CSSStyleSheet();\n \t\t\tconst cssRules = new CSSParser(cssStyleSheet).parseFromString(css);\n \n \t\t\texpect(cssRules.length).toBe(1);\n@@ -369,7 +376,7 @@ describe('CSSParser', () => {\n \t\tit('Supports @supports rule inside a @media rule', () => {\n \t\t\tconst css =\n \t\t\t\t'@media screen and (max-width: 36rem) { @supports (display: grid) { .foo { height: 0.5rem; } } }';\n-\t\t\tconst cssStyleSheet = new CSSStyleSheet();\n+\t\t\tconst cssStyleSheet = new window.CSSStyleSheet();\n \t\t\tconst cssRules = new CSSParser(cssStyleSheet).parseFromString(css);\n \n \t\t\texpect(cssRules.length).toBe(1);\n@@ -381,7 +388,7 @@ describe('CSSParser', () => {\n \t\tit('Supports @supports rule inside a @container rule', () => {\n \t\t\tconst css =\n \t\t\t\t'@container (min-width: 36rem) { @supports (display: grid) { .foo { height: 0.5rem; } } }';\n-\t\t\tconst cssStyleSheet = new CSSStyleSheet();\n+\t\t\tconst cssStyleSheet = new window.CSSStyleSheet();\n \t\t\tconst cssRules = new CSSParser(cssStyleSheet).parseFromString(css);\n \n \t\t\texpect(cssRules.length).toBe(1);\n@@ -393,7 +400,7 @@ describe('CSSParser', () => {\n \t\tit('Ignores @supports rule inside a @keyframes rule', () => {\n \t\t\tconst css =\n \t\t\t\t'@keyframes keyframes1 { @supports (display: grid) { .foo { height: 0.5rem; } } }';\n-\t\t\tconst cssStyleSheet = new CSSStyleSheet();\n+\t\t\tconst cssStyleSheet = new window.CSSStyleSheet();\n \t\t\tconst cssRules = new CSSParser(cssStyleSheet).parseFromString(css);\n \n \t\t\texpect(cssRules.length).toBe(1);\n@@ -403,7 +410,7 @@ describe('CSSParser', () => {\n \t\tit('Supports @keyframes rule inside a @supports rule', () => {\n \t\t\tconst css =\n \t\t\t\t'@supports (display: flex) { @keyframes keyframes1 { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } }';\n-\t\t\tconst cssStyleSheet = new CSSStyleSheet();\n+\t\t\tconst cssStyleSheet = new window.CSSStyleSheet();\n \t\t\tconst cssRules = new CSSParser(cssStyleSheet).parseFromString(css);\n \n \t\t\texpect(cssRules.length).toBe(1);\n@@ -415,7 +422,7 @@ describe('CSSParser', () => {\n \t\tit('Supports @keyframes rule inside a @media rule', () => {\n \t\t\tconst css =\n \t\t\t\t'@media screen and (max-width: 36rem) { @keyframes keyframes1 { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } }';\n-\t\t\tconst cssStyleSheet = new CSSStyleSheet();\n+\t\t\tconst cssStyleSheet = new window.CSSStyleSheet();\n \t\t\tconst cssRules = new CSSParser(cssStyleSheet).parseFromString(css);\n \n \t\t\texpect(cssRules.length).toBe(1);\n@@ -427,7 +434,7 @@ describe('CSSParser', () => {\n \t\tit('Supports @keyframes rule inside a @container rule', () => {\n \t\t\tconst css =\n \t\t\t\t'@container (min-width: 36rem) { @keyframes keyframes1 { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } }';\n-\t\t\tconst cssStyleSheet = new CSSStyleSheet();\n+\t\t\tconst cssStyleSheet = new window.CSSStyleSheet();\n \t\t\tconst cssRules = new CSSParser(cssStyleSheet).parseFromString(css);\n \n \t\t\texpect(cssRules.length).toBe(1);\n@@ -439,15 +446,15 @@ describe('CSSParser', () => {\n \t\tit('Ignores @keyframes rule inside a @keyframes rule', () => {\n \t\t\tconst css =\n \t\t\t\t'@keyframes keyframes1 { @keyframes keyframes2 { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } }';\n-\t\t\tconst cssStyleSheet = new CSSStyleSheet();\n+\t\t\tconst cssStyleSheet = new window.CSSStyleSheet();\n \t\t\tconst cssRules = new CSSParser(cssStyleSheet).parseFromString(css);\n \n \t\t\texpect(cssRules.length).toBe(1);\n \t\t\texpect((<CSSKeyframesRule>cssRules[0]).cssText).toBe('@keyframes keyframes1 { \\n}');\n \t\t});\n \n \t\tit('Supports @scope rule', () => {\n-\t\t\tconst cssStyleSheet = new CSSStyleSheet();\n+\t\t\tconst cssStyleSheet = new window.CSSStyleSheet();\n \t\t\tconst cssRules = new CSSParser(cssStyleSheet).parseFromString(`\n                 @scope {\n                     .foo { color: red; }\n@@ -471,7 +478,7 @@ describe('CSSParser', () => {\n \t\t});\n \n \t\tit('Supports @scope rule inside a @scope rule', () => {\n-\t\t\tconst cssStyleSheet = new CSSStyleSheet();\n+\t\t\tconst cssStyleSheet = new window.CSSStyleSheet();\n \t\t\tconst cssRules = new CSSParser(cssStyleSheet).parseFromString(`\n                 @scope {\n                     @scope {\n@@ -487,7 +494,7 @@ describe('CSSParser', () => {\n \t\t});\n \n \t\tit('Supports @scope rule inside a @container rule', () => {\n-\t\t\tconst cssStyleSheet = new CSSStyleSheet();\n+\t\t\tconst cssStyleSheet = new window.CSSStyleSheet();\n \t\t\tconst cssRules = new CSSParser(cssStyleSheet).parseFromString(`\n                 @container (min-width: 36rem) {\n                     @scope {\n@@ -503,7 +510,7 @@ describe('CSSParser', () => {\n \t\t});\n \n \t\tit('Supports @scope rule inside a @media rule', () => {\n-\t\t\tconst cssStyleSheet = new CSSStyleSheet();\n+\t\t\tconst cssStyleSheet = new window.CSSStyleSheet();\n \t\t\tconst cssRules = new CSSParser(cssStyleSheet).parseFromString(`\n                 @media screen and (max-width: 36rem) {\n                     @scope {\n@@ -519,7 +526,7 @@ describe('CSSParser', () => {\n \t\t});\n \n \t\tit('Supports @scope rule inside a @supports rule', () => {\n-\t\t\tconst cssStyleSheet = new CSSStyleSheet();\n+\t\t\tconst cssStyleSheet = new window.CSSStyleSheet();\n \t\t\tconst cssRules = new CSSParser(cssStyleSheet).parseFromString(`\n                 @supports (display: flex) {\n                     @scope {\n@@ -535,7 +542,7 @@ describe('CSSParser', () => {\n \t\t});\n \n \t\tit('Ignores @scope rule inside a @keyframes rule', () => {\n-\t\t\tconst cssStyleSheet = new CSSStyleSheet();\n+\t\t\tconst cssStyleSheet = new window.CSSStyleSheet();\n \t\t\tconst cssRules = new CSSParser(cssStyleSheet).parseFromString(`\n                 @keyframes keyframes1 {\n                     @scope {\n@@ -151,7 +151,7 @@ describe('ShadowRoot', () => {\n \tdescribe('get adoptedStyleSheets()', () => {\n \t\tit('Returns set adopted style sheets.', () => {\n \t\t\tconst shadowRoot = <ShadowRoot>document.createElement('custom-element').shadowRoot;\n-\t\t\tconst styleSheet = new CSSStyleSheet();\n+\t\t\tconst styleSheet = new window.CSSStyleSheet();\n \t\t\tshadowRoot.adoptedStyleSheets = [styleSheet];\n \t\t\texpect(shadowRoot.adoptedStyleSheets).toEqual([styleSheet]);\n \t\t});\n@@ -160,7 +160,7 @@ describe('ShadowRoot', () => {\n \tdescribe('set adoptedStyleSheets()', () => {\n \t\tit('Sets adopted style sheets.', () => {\n \t\t\tconst shadowRoot = <ShadowRoot>document.createElement('custom-element').shadowRoot;\n-\t\t\tconst styleSheet = new CSSStyleSheet();\n+\t\t\tconst styleSheet = new window.CSSStyleSheet();\n \t\t\tshadowRoot.adoptedStyleSheets = [styleSheet];\n \t\t\texpect(shadowRoot.adoptedStyleSheets).toEqual([styleSheet]);\n \t\t});\n@@ -6,7 +6,6 @@ import QuerySelectorNthChildHTML from './data/QuerySelectorNthChildHTML.js';\n import type HTMLInputElement from '../../src/nodes/html-input-element/HTMLInputElement.js';\n import { beforeEach, describe, it, expect } from 'vitest';\n import QuerySelector from '../../src/query-selector/QuerySelector.js';\n-import DOMException from '../../src/exception/DOMException.js';\n \n describe('QuerySelector', () => {\n \tlet window: Window;\n@@ -295,9 +294,14 @@ describe('QuerySelector', () => {\n \t\t\tcontainer.appendChild(element1);\n \t\t\tcontainer.appendChild(element2);\n \n-\t\t\tconst invalidSelectorElements = container.querySelectorAll('.before:');\n+\t\t\t// Invalid selector \".before:\" should throw (colon without pseudo-class name)\n+\t\t\texpect(() => container.querySelectorAll('.before:')).toThrowError(\n+\t\t\t\tnew SyntaxError(\n+\t\t\t\t\t`Failed to execute 'querySelectorAll' on 'Document': '.before:' is not a valid selector.`\n+\t\t\t\t)\n+\t\t\t);\n+\t\t\t// Valid selector with escaped colon should match\n \t\t\tconst validSelectorElements = container.querySelectorAll('.before\\\\:after');\n-\t\t\texpect(invalidSelectorElements.length).toBe(0);\n \t\t\texpect(validSelectorElements.length).toBe(2);\n \t\t\texpect(validSelectorElements[0] === element1).toBe(true);\n \t\t\texpect(validSelectorElements[1] === element2).toBe(true);\n@@ -329,9 +333,10 @@ describe('QuerySelector', () => {\n \t\t\tcontainer.appendChild(element1);\n \t\t\tcontainer.appendChild(element2);\n \n-\t\t\tconst invalidSelectorElements = container.querySelectorAll('.before&after');\n+\t\t\t// Invalid selector \".before&after\" should throw (& is not valid in CSS selectors)\n+\t\t\texpect(() => container.querySelectorAll('.before&after')).toThrow();\n+\t\t\t// Valid selector with escaped ampersand should match\n \t\t\tconst validSelectorElements = container.querySelectorAll('.before\\\\&after');\n-\t\t\texpect(invalidSelectorElements.length).toBe(0);\n \t\t\texpect(validSelectorElements.length).toBe(2);\n \t\t\texpect(validSelectorElements[0] === element1).toBe(true);\n \t\t\texpect(validSelectorElements[1] === element2).toBe(true);\n@@ -502,6 +507,24 @@ describe('QuerySelector', () => {\n \t\t\texpect(elements[1] === container.children[0].children[1].children[1]).toBe(true);\n \t\t});\n \n+\t\tit('Returns elements with attribute values containing apostrophes using double quotes as delimiter.', () => {\n+\t\t\tconst container = document.createElement('div');\n+\t\t\tcontainer.innerHTML = `<div data-value=\"it's a test\">Content</div>`;\n+\n+\t\t\tconst elements = container.querySelectorAll('[data-value=\"it\\'s a test\"]');\n+\t\t\texpect(elements.length).toBe(1);\n+\t\t\texpect(elements[0] === container.children[0]).toBe(true);\n+\t\t});\n+\n+\t\tit('Returns elements with attribute values containing double quotes using apostrophes as delimiter.', () => {\n+\t\t\tconst container = document.createElement('div');\n+\t\t\tcontainer.innerHTML = `<div data-value='say \"hello\"'>Content</div>`;\n+\n+\t\t\tconst elements = container.querySelectorAll('[data-value=\\'say \"hello\"\\']');\n+\t\t\texpect(elements.length).toBe(1);\n+\t\t\texpect(elements[0] === container.children[0]).toBe(true);\n+\t\t});\n+\n \t\tit('Returns all elements with tag name and matching attributes using \"span[_attr1]\".', () => {\n \t\t\tconst container = document.createElement('div');\n \t\t\tcontainer.innerHTML = QuerySelectorHTML.replace(/ attr1/gm, '_attr1');\n@@ -912,26 +935,32 @@ describe('QuerySelector', () => {\n \t\t\texpect(elements3[1] === container.children[0].children[1].children[2]).toBe(true);\n \t\t});\n \n-\t\tit('Returns all elements matching the selector without ending parenthese \"button:not([type]\"', () => {\n+\t\tit('Throws error for selector without ending parenthesis \"button:not([type]\"', () => {\n \t\t\tconst container = document.createElement('div');\n \t\t\tcontainer.innerHTML = `\n \t\t\t\t<button></button>\n                 <button type=\"submit\"></button>\n                 <button></button>\n \t\t\t`;\n-\t\t\tconst elements = container.querySelectorAll('button:not([type]');\n+\t\t\t// Invalid selector (missing closing parenthesis) should throw\n+\t\t\texpect(() => container.querySelectorAll('button:not([type]')).toThrow();\n+\t\t\t// Valid selector should work\n+\t\t\tconst elements = container.querySelectorAll('button:not([type])');\n \t\t\texpect(elements.length).toBe(2);\n \t\t\texpect(elements[0]).toBe(container.children[0]);\n \t\t\texpect(elements[1]).toBe(container.children[2]);\n \t\t});\n \n-\t\tit('Returns all elements matching the invalid selector \"[q\\\\:shadowroot]\"', () => {\n+\t\tit('Returns all elements matching the selector \"[q\\\\:shadowroot]\" with escaped colon', () => {\n \t\t\tconst container = document.createElement('div');\n-\t\t\tcontainer.innerHTML = `\n-\t\t\t\t<span q\\\\:shadowroot></span>\n-                <button></button>\n-                <article></article>\n-\t\t\t`;\n+\t\t\t// Create element with attribute name containing colon\n+\t\t\tconst span = document.createElement('span');\n+\t\t\tspan.setAttribute('q:shadowroot', '');\n+\t\t\tcontainer.appendChild(span);\n+\t\t\tcontainer.appendChild(document.createElement('button'));\n+\t\t\tcontainer.appendChild(document.createElement('article'));\n+\n+\t\t\t// Selector [q\\:shadowroot] looks for attribute \"q:shadowroot\" (colon is escaped)\n \t\t\tconst elements = container.querySelectorAll('[q\\\\:shadowroot]');\n \t\t\texpect(elements.length).toBe(1);\n \t\t\texpect(elements[0]).toBe(container.children[0]);\n@@ -1290,6 +1319,71 @@ describe('QuerySelector', () => {\n \t\t\t]);\n \t\t});\n \n+\t\tit('Correctly parses complex CSS :has() expression with nested :not() (issue #1910)', () => {\n+\t\t\tconst container = document.createElement('div');\n+\t\t\tcontainer.innerHTML = `\n+\t\t\t\t<table class=\"emXRrt\">\n+\t\t\t\t\t<thead>\n+\t\t\t\t\t\t<tr>\n+\t\t\t\t\t\t\t<th>Header 1</th>\n+\t\t\t\t\t\t\t<th>Header 2</th>\n+\t\t\t\t\t\t</tr>\n+\t\t\t\t\t</thead>\n+\t\t\t\t\t<tbody>\n+\t\t\t\t\t\t<tr>\n+\t\t\t\t\t\t\t<td>Cell 1</td>\n+\t\t\t\t\t\t\t<td>Cell 2</td>\n+\t\t\t\t\t\t</tr>\n+\t\t\t\t\t\t<tr data-ignore-row>\n+\t\t\t\t\t\t\t<td>Ignored 1</td>\n+\t\t\t\t\t\t\t<td>Ignored 2</td>\n+\t\t\t\t\t\t</tr>\n+\t\t\t\t\t</tbody>\n+\t\t\t\t</table>\n+\t\t\t`;\n+\n+\t\t\tconst elements = container.querySelectorAll(\n+\t\t\t\t'.emXRrt>tbody>tr>td:nth-child(2),.emXRrt:not(:has(> tbody > tr:not([data-ignore-row])))>thead>tr>th:nth-child(2)'\n+\t\t\t);\n+\n+\t\t\t// Should only match the 2 td elements from the first selector part\n+\t\t\texpect(elements.length).toBe(2);\n+\t\t\texpect(elements[0].textContent).toBe('Cell 2');\n+\t\t\texpect(elements[1].textContent).toBe('Ignored 2');\n+\t\t});\n+\n+\t\tit('Should throw error when ending with \",\"', () => {\n+\t\t\tconst container = document.createElement('div');\n+\t\t\texpect(() => container.querySelectorAll('.test,.test-2,')).toThrowError(\n+\t\t\t\tnew SyntaxError(\n+\t\t\t\t\t\"Failed to execute 'querySelectorAll' on 'Element': '.test,.test-2,' is not a valid selector.\"\n+\t\t\t\t)\n+\t\t\t);\n+\t\t\texpect(() => container.querySelectorAll('.test.,,test-2')).toThrowError(\n+\t\t\t\tnew SyntaxError(\n+\t\t\t\t\t\"Failed to execute 'querySelectorAll' on 'Element': '.test,,.test-2' is not a valid selector.\"\n+\t\t\t\t)\n+\t\t\t);\n+\t\t});\n+\n+\t\tit('Correctly parses :not() with nested :has() containing :not() (issue #1910)', () => {\n+\t\t\tconst container = document.createElement('div');\n+\t\t\tcontainer.innerHTML = `\n+\t\t\t\t<div class=\"parent\">\n+\t\t\t\t\t<div class=\"child\" data-keep></div>\n+\t\t\t\t\t<div class=\"child\"></div>\n+\t\t\t\t</div>\n+\t\t\t\t<div class=\"parent no-match\">\n+\t\t\t\t\t<div class=\"child\" data-keep></div>\n+\t\t\t\t</div>\n+\t\t\t`;\n+\n+\t\t\tconst elements = container.querySelectorAll('.parent:not(:has(> .child:not([data-keep])))');\n+\n+\t\t\texpect(elements.length).toBe(1);\n+\t\t\texpect(elements[0].classList.contains('no-match')).toBe(true);\n+\t\t});\n+\n \t\tit('Returns all elements for subsequent sibling selector using \".a ~ .a\"', () => {\n \t\t\tconst div = document.createElement('div');\n \n@@ -1382,6 +1476,14 @@ describe('QuerySelector', () => {\n \t\t\texpect(root.length).toBe(1);\n \t\t\texpect(root[0]).toBe(document.documentElement);\n \t\t});\n+\n+\t\tit('Matches XML query selector for #1912', () => {\n+\t\t\tconst xml = '<root><child Name=\"John\">Content</child></root>';\n+\t\t\tconst parser = new window.DOMParser();\n+\t\t\tconst xmlDoc = parser.parseFromString(xml, 'application/xml');\n+\n+\t\t\texpect(xmlDoc.querySelectorAll('[Name]').length).toBe(1);\n+\t\t});\n \t});\n \n \tdescribe('querySelector()', () => {\n@@ -2154,5 +2256,22 @@ describe('QuerySelector', () => {\n \n \t\t\texpect(div.matches(':root')).toBe(false);\n \t\t});\n+\n+\t\tit('Matches unicode characters for #2034', () => {\n+\t\t\t// Apostrophe in double-quoted attribute value\n+\t\t\tdocument.body.innerHTML = `\n+            <label id=\"type d'activité-label\">Type d'activité</label>\n+            <input aria-labelledby=\"type d'activité-label\" />\n+            `;\n+\t\t\texpect(!!document.querySelector('[id=\"type d\\'activité-label\"]')).toBe(true);\n+\n+\t\t\t// Test 2: Double quote in single-quoted attribute value\n+\t\t\tdocument.body.innerHTML = `<div id='say \"hello\"'></div>`;\n+\t\t\texpect(!!document.querySelector('[id=\\'say \"hello\"\\']')).toBe(true);\n+\n+\t\t\t// Test 3: Simple ID (control test)\n+\t\t\tdocument.body.innerHTML = `<div id=\"simple\"></div>`;\n+\t\t\texpect(!!document.querySelector('[id=\"simple\"]')).toBe(true);\n+\t\t});\n \t});\n });\n@@ -652,7 +652,7 @@ describe('BrowserWindow', () => {\n \t\t\t);\n \t\t\tconst elementComputedStyle = window.getComputedStyle(element);\n \n-\t\t\tconst styleSheet = new CSSStyleSheet();\n+\t\t\tconst styleSheet = new window.CSSStyleSheet();\n \t\t\tstyleSheet.replaceSync(`\n                 span {\n \t\t\t\t\tcolor: green;"
    },
    {
      "url": "https://github.com/capricorn86/happy-dom/pull/2010",
      "title": "fix: [#1910] Replace custom CSS selector parser with parsel-js",
      "state": "closed",
      "merged": false,
      "created_at": "2026-01-20T14:22:07Z",
      "updated_at": "2026-02-19T00:50:50Z",
      "closed_at": "2026-02-19T00:50:49Z",
      "merged_at": null,
      "base_sha": "aa2dbb813416e54c4b34c77e3834952cdcc8dd6d",
      "head_sha": "8c749acad8c03c398d67cb1696a037c021e1fcd5",
      "merge_commit_sha": null,
      "diff": "@@ -9963,6 +9963,22 @@\n \t\t\t\t\"url\": \"https://github.com/sponsors/sindresorhus\"\n \t\t\t}\n \t\t},\n+\t\t\"node_modules/parsel-js\": {\n+\t\t\t\"version\": \"1.2.2\",\n+\t\t\t\"resolved\": \"https://registry.npmjs.org/parsel-js/-/parsel-js-1.2.2.tgz\",\n+\t\t\t\"integrity\": \"sha512-AVJMlwQ4bL2Y0VvYJGk+Fp7eX4SCH2uFoNApmn4yKWACUewZ+alwW3tyoe1r5Z3aLYQTuAuPZIyGghMfO/Tlxw==\",\n+\t\t\t\"funding\": [\n+\t\t\t\t{\n+\t\t\t\t\t\"type\": \"individual\",\n+\t\t\t\t\t\"url\": \"https://github.com/sponsors/LeaVerou\"\n+\t\t\t\t},\n+\t\t\t\t{\n+\t\t\t\t\t\"type\": \"opencollective\",\n+\t\t\t\t\t\"url\": \"https://opencollective.com/leaverou\"\n+\t\t\t\t}\n+\t\t\t],\n+\t\t\t\"license\": \"MIT\"\n+\t\t},\n \t\t\"node_modules/parseurl\": {\n \t\t\t\"version\": \"1.3.3\",\n \t\t\t\"resolved\": \"https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz\",\n@@ -13855,6 +13871,7 @@\n \t\t\t\t\"@types/whatwg-mimetype\": \"^3.0.2\",\n \t\t\t\t\"@types/ws\": \"^8.18.1\",\n \t\t\t\t\"entities\": \"^4.5.0\",\n+\t\t\t\t\"parsel-js\": \"^1.2.2\",\n \t\t\t\t\"whatwg-mimetype\": \"^3.0.0\",\n \t\t\t\t\"ws\": \"^8.18.3\"\n \t\t\t},\n@@ -33,10 +33,11 @@\n \t\t\"test:debug\": \"vitest run --inspect-brk --no-file-parallelism\"\n \t},\n \t\"dependencies\": {\n-\t\t\"@types/whatwg-mimetype\": \"^3.0.2\",\n \t\t\"@types/node\": \">=20.0.0\",\n+\t\t\"@types/whatwg-mimetype\": \"^3.0.2\",\n \t\t\"@types/ws\": \"^8.18.1\",\n \t\t\"entities\": \"^4.5.0\",\n+\t\t\"parsel-js\": \"^1.2.2\",\n \t\t\"whatwg-mimetype\": \"^3.0.0\",\n \t\t\"ws\": \"^8.18.3\"\n \t},\n@@ -2,37 +2,15 @@ import SelectorItem from './SelectorItem.js';\n import SelectorCombinatorEnum from './SelectorCombinatorEnum.js';\n import DOMException from '../exception/DOMException.js';\n import ISelectorPseudo from './ISelectorPseudo.js';\n+import ISelectorAttribute from './ISelectorAttribute.js';\n import Element from '../nodes/element/Element.js';\n import DocumentFragment from '../nodes/document-fragment/DocumentFragment.js';\n+import { parse, type AST } from 'parsel-js';\n \n /**\n- * Selector RegExp.\n- *\n- * Group 1: All (e.g. \"*\")\n- * Group 2: Tag name (e.g. \"div\")\n- * Group 3: ID (e.g. \"#id\")\n- * Group 4: Class (e.g. \".class\")\n- * Group 5: Attribute name when no value (e.g. \"attr1\")\n- * Group 6: Attribute name when there is a value using apostrophe (e.g. \"attr1\")\n- * Group 7: Attribute operator when using apostrophe (e.g. \"~\")\n- * Group 8: Attribute value when using apostrophe (e.g. \"value1\")\n- * Group 9: Attribute modifier when using apostrophe (e.g. \"i\" or \"s\")\n- * Group 10: Attribute name when threre is a value not using apostrophe (e.g. \"attr1\")\n- * Group 11: Attribute operator when not using apostrophe (e.g. \"~\")\n- * Group 12: Attribute value when notusing apostrophe (e.g. \"value1\")\n- * Group 13: Pseudo name when arguments (e.g. \"nth-child\")\n- * Group 14: Arguments of pseudo (e.g. \"2n + 1\")\n- * Group 15: Pseudo name when no arguments (e.g. \"empty\")\n- * Group 16: Pseudo element (e.g. \"::after\", \"::-webkit-inner-spin-button\").\n- * Group 17: Combinator.\n+ * Escaped Character RegExp - matches backslash followed by any character.\n  */\n-const SELECTOR_REGEXP =\n-\t/(\\*)|([a-zA-Z0-9-]+)|#((?:[a-zA-Z0-9-_«»]|\\\\.)+)|\\.((?:[a-zA-Z0-9-_«»]|\\\\.)+)|\\[([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;\n-\n-/**\n- * Escaped Character RegExp.\n- */\n-const ESCAPED_CHARACTER_REGEXP = /\\\\/g;\n+const ESCAPED_CHARACTER_REGEXP = /\\\\(.)/g;\n \n /**\n  * Attribute Escape RegExp.\n@@ -54,26 +32,28 @@ const NTH_FUNCTION = {\n const SPACE_REGEXP = / /g;\n \n /**\n- * Simple Selector RegExp.\n- *\n- * Group 1: Tag name (e.g. \"div\")\n- * Group 2: Class (e.g. \".classA.classB\")\n- * Group 3: ID (e.g. \"#id\")\n+ * Placeholder for escaped characters that parsel-js can't handle.\n+ * Using a unique string that won't appear in normal selectors.\n  */\n-const SIMPLE_SELECTOR_REGEXP = /(^[a-zA-Z0-9-]+$)|(^\\.[a-zA-Z0-9-_.]+$)|(^#[a-zA-Z0-9-_]+$)/;\n+const ESCAPE_PLACEHOLDER_PREFIX = '__HDOM_ESC_';\n+const ESCAPE_PLACEHOLDER_SUFFIX = '__';\n \n /**\n- * Utility for parsing a selection string.\n+ * RegExp to find escape sequences that need pre-processing.\n+ * Matches backslash followed by special characters that parsel-js can't handle.\n+ */\n+const PROBLEMATIC_ESCAPE_REGEXP = /\\\\([:#&\\[\\]])/g;\n+\n+/**\n+ * Utility for parsing a selection string using parsel-js.\n  */\n export default class SelectorParser {\n \t/**\n \t * Parses a selector string and returns an instance of SelectorItem.\n-\t *\n-\t * @param selector Selector.\n-\t * @param options Options.\n-\t * @param [options.scope] Scope.\n-\t * @param [options.ignoreErrors] Ignores errors.\n-\t * @returns Selector item.\n+\t * @param selector\n+\t * @param options\n+\t * @param options.scope\n+\t * @param options.ignoreErrors\n \t */\n \tpublic static getSelectorItem(\n \t\tselector: string,\n@@ -87,12 +67,10 @@ export default class SelectorParser {\n \n \t/**\n \t * Parses a selector string and returns groups with SelectorItem instances.\n-\t *\n-\t * @param selector Selector.\n-\t * @param options Options.\n-\t * @param [options.scope] Scope.\n-\t * @param [options.ignoreErrors] Ignores errors.\n-\t * @returns Selector groups.\n+\t * @param selector\n+\t * @param options\n+\t * @param options.scope\n+\t * @param options.ignoreErrors\n \t */\n \tpublic static getSelectorGroups(\n \t\tselector: string,\n@@ -105,308 +83,488 @@ export default class SelectorParser {\n \t\tconst ignoreErrors = options?.ignoreErrors;\n \t\tconst scope = options?.scope;\n \n-\t\tif (selector === '*') {\n-\t\t\treturn [[new SelectorItem({ scope, tagName: '*', ignoreErrors })]];\n-\t\t}\n-\n-\t\tconst simpleMatch = selector.match(SIMPLE_SELECTOR_REGEXP);\n-\n-\t\tif (simpleMatch) {\n-\t\t\tif (simpleMatch[1]) {\n-\t\t\t\treturn [[new SelectorItem({ scope, tagName: selector.toUpperCase(), ignoreErrors })]];\n-\t\t\t} else if (simpleMatch[2]) {\n-\t\t\t\treturn [\n-\t\t\t\t\t[\n-\t\t\t\t\t\tnew SelectorItem({\n-\t\t\t\t\t\t\tscope,\n-\t\t\t\t\t\t\tclassNames: selector.replace('.', '').split('.'),\n-\t\t\t\t\t\t\tignoreErrors\n-\t\t\t\t\t\t})\n-\t\t\t\t\t]\n-\t\t\t\t];\n-\t\t\t} else if (simpleMatch[3]) {\n-\t\t\t\treturn [[new SelectorItem({ scope, id: selector.replace('#', ''), ignoreErrors })]];\n+\t\tif (!selector) {\n+\t\t\tif (ignoreErrors) {\n+\t\t\t\treturn [];\n \t\t\t}\n+\t\t\tthrow new DOMException(`Invalid selector: \"${selector}\"`);\n \t\t}\n \n-\t\tconst regexp = new RegExp(SELECTOR_REGEXP);\n-\t\tlet currentSelectorItem: SelectorItem = new SelectorItem({\n-\t\t\tscope,\n-\t\t\tcombinator: SelectorCombinatorEnum.descendant,\n-\t\t\tignoreErrors\n-\t\t});\n-\t\tlet currentGroup: SelectorItem[] = [currentSelectorItem];\n-\t\tconst groups: Array<Array<SelectorItem>> = [currentGroup];\n-\t\tlet isValid = false;\n-\t\tlet match;\n-\n-\t\twhile ((match = regexp.exec(selector))) {\n-\t\t\tif (match[0]) {\n-\t\t\t\tisValid = true;\n-\n-\t\t\t\tif (match[1]) {\n-\t\t\t\t\tcurrentSelectorItem.tagName = '*';\n-\t\t\t\t} else if (match[2]) {\n-\t\t\t\t\tcurrentSelectorItem.tagName = match[2].toUpperCase();\n-\t\t\t\t} else if (match[3]) {\n-\t\t\t\t\tcurrentSelectorItem.id = match[3].replace(ESCAPED_CHARACTER_REGEXP, '');\n-\t\t\t\t} else if (match[4]) {\n-\t\t\t\t\tcurrentSelectorItem.classNames = currentSelectorItem.classNames || [];\n-\t\t\t\t\tcurrentSelectorItem.classNames.push(match[4].replace(ESCAPED_CHARACTER_REGEXP, ''));\n-\t\t\t\t} else if (match[5]) {\n-\t\t\t\t\tcurrentSelectorItem.attributes = currentSelectorItem.attributes || [];\n-\t\t\t\t\tcurrentSelectorItem.attributes.push({\n-\t\t\t\t\t\tname: match[5],\n-\t\t\t\t\t\toperator: null,\n-\t\t\t\t\t\tvalue: null,\n-\t\t\t\t\t\tmodifier: null,\n-\t\t\t\t\t\tregExp: null\n-\t\t\t\t\t});\n-\t\t\t\t} else if (match[6] && match[8] !== undefined) {\n-\t\t\t\t\tcurrentSelectorItem.attributes = currentSelectorItem.attributes || [];\n-\t\t\t\t\tcurrentSelectorItem.attributes.push({\n-\t\t\t\t\t\tname: match[6],\n-\t\t\t\t\t\toperator: match[7] || null,\n-\t\t\t\t\t\tvalue: match[8].replace(ESCAPED_CHARACTER_REGEXP, ''),\n-\t\t\t\t\t\tmodifier: <'s'>match[9] || null,\n-\t\t\t\t\t\tregExp: this.getAttributeRegExp({\n-\t\t\t\t\t\t\toperator: match[7],\n-\t\t\t\t\t\t\tvalue: match[8],\n-\t\t\t\t\t\t\tmodifier: match[9]\n-\t\t\t\t\t\t})\n-\t\t\t\t\t});\n-\t\t\t\t} else if (match[10] && match[12] !== undefined) {\n-\t\t\t\t\tcurrentSelectorItem.attributes = currentSelectorItem.attributes || [];\n-\t\t\t\t\tcurrentSelectorItem.attributes.push({\n-\t\t\t\t\t\tname: match[10],\n-\t\t\t\t\t\toperator: match[11] || null,\n-\t\t\t\t\t\tvalue: match[12].replace(ESCAPED_CHARACTER_REGEXP, ''),\n-\t\t\t\t\t\tmodifier: null,\n-\t\t\t\t\t\tregExp: this.getAttributeRegExp({ operator: match[11], value: match[12] })\n-\t\t\t\t\t});\n-\t\t\t\t} else if (match[13] && match[14]) {\n-\t\t\t\t\tcurrentSelectorItem.pseudos = currentSelectorItem.pseudos || [];\n-\t\t\t\t\tcurrentSelectorItem.pseudos.push(this.getPseudo(match[13], match[14], options));\n-\t\t\t\t} else if (match[15]) {\n-\t\t\t\t\tcurrentSelectorItem.pseudos = currentSelectorItem.pseudos || [];\n-\t\t\t\t\tcurrentSelectorItem.pseudos.push(this.getPseudo(match[15], null, options));\n-\t\t\t\t} else if (match[16]) {\n-\t\t\t\t\tcurrentSelectorItem.isPseudoElement = true;\n-\t\t\t\t} else if (match[17]) {\n-\t\t\t\t\tswitch (match[17].trim()) {\n-\t\t\t\t\t\tcase ',':\n-\t\t\t\t\t\t\tcurrentSelectorItem = new SelectorItem({\n-\t\t\t\t\t\t\t\tscope,\n-\t\t\t\t\t\t\t\tcombinator: SelectorCombinatorEnum.descendant,\n-\t\t\t\t\t\t\t\tignoreErrors\n-\t\t\t\t\t\t\t});\n-\t\t\t\t\t\t\tcurrentGroup = [currentSelectorItem];\n-\t\t\t\t\t\t\tgroups.push(currentGroup);\n-\t\t\t\t\t\t\tbreak;\n-\t\t\t\t\t\tcase '>':\n-\t\t\t\t\t\t\tcurrentSelectorItem = new SelectorItem({\n-\t\t\t\t\t\t\t\tscope,\n-\t\t\t\t\t\t\t\tcombinator: SelectorCombinatorEnum.child,\n-\t\t\t\t\t\t\t\tignoreErrors\n-\t\t\t\t\t\t\t});\n-\t\t\t\t\t\t\tcurrentGroup.push(currentSelectorItem);\n-\t\t\t\t\t\t\tbreak;\n-\t\t\t\t\t\tcase '+':\n-\t\t\t\t\t\t\tcurrentSelectorItem = new SelectorItem({\n-\t\t\t\t\t\t\t\tscope,\n-\t\t\t\t\t\t\t\tcombinator: SelectorCombinatorEnum.adjacentSibling,\n-\t\t\t\t\t\t\t\tignoreErrors\n-\t\t\t\t\t\t\t});\n-\t\t\t\t\t\t\tcurrentGroup.push(currentSelectorItem);\n-\t\t\t\t\t\t\tbreak;\n-\t\t\t\t\t\tcase '~':\n-\t\t\t\t\t\t\tcurrentSelectorItem = new SelectorItem({\n-\t\t\t\t\t\t\t\tscope,\n-\t\t\t\t\t\t\t\tcombinator: SelectorCombinatorEnum.subsequentSibling,\n-\t\t\t\t\t\t\t\tignoreErrors\n-\t\t\t\t\t\t\t});\n-\t\t\t\t\t\t\tcurrentGroup.push(currentSelectorItem);\n-\t\t\t\t\t\t\tbreak;\n-\t\t\t\t\t\tcase '':\n-\t\t\t\t\t\t\tcurrentSelectorItem = new SelectorItem({\n-\t\t\t\t\t\t\t\tscope,\n-\t\t\t\t\t\t\t\tcombinator: SelectorCombinatorEnum.descendant,\n-\t\t\t\t\t\t\t\tignoreErrors\n-\t\t\t\t\t\t\t});\n-\t\t\t\t\t\t\tcurrentGroup.push(currentSelectorItem);\n-\t\t\t\t\t\t\tbreak;\n-\t\t\t\t\t}\n-\t\t\t\t}\n-\t\t\t} else {\n-\t\t\t\tbreak;\n+\t\t// Pre-process selector to handle escaped characters that parsel-js can't handle\n+\t\tconst { processedSelector, escapeMap } = this.preprocessSelector(selector);\n+\n+\t\tlet ast: AST | undefined;\n+\t\ttry {\n+\t\t\tast = parse(processedSelector, { recursive: true, list: true });\n+\t\t} catch {\n+\t\t\tif (ignoreErrors) {\n+\t\t\t\treturn [];\n \t\t\t}\n+\t\t\tthrow new DOMException(`Invalid selector: \"${selector}\"`);\n \t\t}\n \n-\t\tif (!isValid) {\n-\t\t\tif (options?.ignoreErrors) {\n+\t\tif (!ast) {\n+\t\t\tif (ignoreErrors) {\n \t\t\t\treturn [];\n \t\t\t}\n \t\t\tthrow new DOMException(`Invalid selector: \"${selector}\"`);\n \t\t}\n \n-\t\treturn groups;\n+\t\tif (ast.type === 'list') {\n+\t\t\tconst groups: Array<Array<SelectorItem>> = [];\n+\t\t\tfor (const item of ast.list) {\n+\t\t\t\tgroups.push(this.astToSelectorItems(item, scope, ignoreErrors, options, escapeMap));\n+\t\t\t}\n+\t\t\treturn groups;\n+\t\t}\n+\n+\t\treturn [this.astToSelectorItems(ast, scope, ignoreErrors, options, escapeMap)];\n \t}\n \n \t/**\n-\t * Returns attribute RegExp.\n-\t *\n-\t * @param attribute Attribute.\n-\t * @param attribute.value Attribute value.\n-\t * @param attribute.operator Attribute operator.\n-\t * @param attribute.modifier Attribute modifier.\n-\t * @returns Attribute RegExp.\n+\t * Pre-processes a selector to handle escaped characters that parsel-js can't handle.\n+\t * @param selector\n \t */\n-\tprivate static getAttributeRegExp(attribute: {\n-\t\tvalue?: string;\n-\t\toperator?: string;\n-\t\tmodifier?: string;\n-\t}): RegExp | null {\n-\t\tconst modifier = attribute.modifier === 'i' ? 'i' : '';\n+\tprivate static preprocessSelector(selector: string): {\n+\t\tprocessedSelector: string;\n+\t\tescapeMap: Map<string, string>;\n+\t} {\n+\t\tconst escapeMap = new Map<string, string>();\n+\t\tlet counter = 0;\n+\n+\t\tconst processedSelector = selector.replace(PROBLEMATIC_ESCAPE_REGEXP, (_match, char) => {\n+\t\t\tconst placeholder = `${ESCAPE_PLACEHOLDER_PREFIX}${counter}${ESCAPE_PLACEHOLDER_SUFFIX}`;\n+\t\t\tescapeMap.set(placeholder, char);\n+\t\t\tcounter++;\n+\t\t\treturn placeholder;\n+\t\t});\n \n-\t\tif (!attribute.operator || !attribute.value) {\n-\t\t\treturn null;\n+\t\treturn { processedSelector, escapeMap };\n+\t}\n+\n+\t/**\n+\t * Restores escaped characters from placeholders.\n+\t * @param value\n+\t * @param escapeMap\n+\t */\n+\tprivate static restoreEscapedChars(value: string, escapeMap: Map<string, string>): string {\n+\t\tlet result = value;\n+\t\tfor (const [placeholder, char] of escapeMap) {\n+\t\t\tresult = result.split(placeholder).join(char);\n \t\t}\n+\t\treturn result;\n+\t}\n \n-\t\t// Escape special regex characters in the value\n-\t\tconst escapedValue = attribute.value.replace(ATTRIBUTE_ESCAPE_REGEXP, '\\\\$&');\n+\t/**\n+\t * Converts a parsel AST node to an array of SelectorItems.\n+\t * @param ast\n+\t * @param scope\n+\t * @param ignoreErrors\n+\t * @param options\n+\t * @param options.scope\n+\t * @param options.ignoreErrors\n+\t * @param escapeMap\n+\t */\n+\tprivate static astToSelectorItems(\n+\t\tast: AST,\n+\t\tscope: Element | DocumentFragment | undefined,\n+\t\tignoreErrors: boolean | undefined,\n+\t\toptions: { scope?: Element | DocumentFragment; ignoreErrors?: boolean } | undefined,\n+\t\tescapeMap: Map<string, string>\n+\t): SelectorItem[] {\n+\t\tconst items: SelectorItem[] = [];\n+\t\tthis.flattenComplexSelector(\n+\t\t\tast,\n+\t\t\titems,\n+\t\t\tSelectorCombinatorEnum.descendant,\n+\t\t\tscope,\n+\t\t\tignoreErrors,\n+\t\t\toptions,\n+\t\t\tescapeMap\n+\t\t);\n+\t\treturn items;\n+\t}\n \n-\t\tswitch (attribute.operator) {\n-\t\t\t// [attribute~=\"value\"] - Contains a specified word.\n+\t/**\n+\t * Flattens a complex selector AST into an array of SelectorItems.\n+\t * @param ast\n+\t * @param items\n+\t * @param combinator\n+\t * @param scope\n+\t * @param ignoreErrors\n+\t * @param options\n+\t * @param options.scope\n+\t * @param options.ignoreErrors\n+\t * @param escapeMap\n+\t */\n+\tprivate static flattenComplexSelector(\n+\t\tast: AST,\n+\t\titems: SelectorItem[],\n+\t\tcombinator: SelectorCombinatorEnum,\n+\t\tscope: Element | DocumentFragment | undefined,\n+\t\tignoreErrors: boolean | undefined,\n+\t\toptions: { scope?: Element | DocumentFragment; ignoreErrors?: boolean } | undefined,\n+\t\tescapeMap: Map<string, string>\n+\t): void {\n+\t\tif (ast.type === 'complex') {\n+\t\t\tthis.flattenComplexSelector(\n+\t\t\t\tast.left,\n+\t\t\t\titems,\n+\t\t\t\tcombinator,\n+\t\t\t\tscope,\n+\t\t\t\tignoreErrors,\n+\t\t\t\toptions,\n+\t\t\t\tescapeMap\n+\t\t\t);\n+\t\t\tconst rightCombinator = this.parselCombinatorToEnum(ast.combinator);\n+\t\t\tthis.flattenComplexSelector(\n+\t\t\t\tast.right,\n+\t\t\t\titems,\n+\t\t\t\trightCombinator,\n+\t\t\t\tscope,\n+\t\t\t\tignoreErrors,\n+\t\t\t\toptions,\n+\t\t\t\tescapeMap\n+\t\t\t);\n+\t\t} else {\n+\t\t\titems.push(\n+\t\t\t\tthis.astNodeToSelectorItem(ast, combinator, scope, ignoreErrors, options, escapeMap)\n+\t\t\t);\n+\t\t}\n+\t}\n+\n+\t/**\n+\t * Converts a parsel combinator string to SelectorCombinatorEnum.\n+\t * @param combinator\n+\t */\n+\tprivate static parselCombinatorToEnum(combinator: string): SelectorCombinatorEnum {\n+\t\tswitch (combinator.trim()) {\n+\t\t\tcase '>':\n+\t\t\t\treturn SelectorCombinatorEnum.child;\n+\t\t\tcase '+':\n+\t\t\t\treturn SelectorCombinatorEnum.adjacentSibling;\n \t\t\tcase '~':\n-\t\t\t\treturn new RegExp(`[- ]${escapedValue}|${escapedValue}[- ]|^${escapedValue}$`, modifier);\n-\t\t\t// [attribute|=\"value\"] - Starts with the specified word.\n-\t\t\tcase '|':\n-\t\t\t\treturn new RegExp(`^${escapedValue}[- ]|^${escapedValue}$`, modifier);\n-\t\t\t// [attribute^=\"value\"] - Begins with a specified value.\n-\t\t\tcase '^':\n-\t\t\t\treturn new RegExp(`^${escapedValue}`, modifier);\n-\t\t\t// [attribute$=\"value\"] - Ends with a specified value.\n-\t\t\tcase '$':\n-\t\t\t\treturn new RegExp(`${escapedValue}$`, modifier);\n-\t\t\t// [attribute*=\"value\"] - Contains a specified value.\n-\t\t\tcase '*':\n-\t\t\t\treturn new RegExp(`${escapedValue}`, modifier);\n+\t\t\t\treturn SelectorCombinatorEnum.subsequentSibling;\n \t\t\tdefault:\n-\t\t\t\treturn null;\n+\t\t\t\treturn SelectorCombinatorEnum.descendant;\n \t\t}\n \t}\n \n \t/**\n-\t * Returns pseudo.\n-\t *\n-\t * @param name Pseudo name.\n-\t * @param args Pseudo arguments.\n-\t * @param [options] Options.\n-\t * @param [options.scope] Scope.\n-\t * @param [options.ignoreErrors] Ignores errors.\n-\t * @returns Pseudo.\n+\t * Converts a single AST node to a SelectorItem.\n+\t * @param ast\n+\t * @param combinator\n+\t * @param scope\n+\t * @param ignoreErrors\n+\t * @param options\n+\t * @param options.scope\n+\t * @param options.ignoreErrors\n+\t * @param escapeMap\n \t */\n-\tprivate static getPseudo(\n-\t\tname: string,\n-\t\targs: string | null | undefined,\n-\t\toptions?: {\n-\t\t\tscope?: Element | DocumentFragment;\n-\t\t\tignoreErrors?: boolean;\n+\tprivate static astNodeToSelectorItem(\n+\t\tast: AST,\n+\t\tcombinator: SelectorCombinatorEnum,\n+\t\tscope: Element | DocumentFragment | undefined,\n+\t\tignoreErrors: boolean | undefined,\n+\t\toptions: { scope?: Element | DocumentFragment; ignoreErrors?: boolean } | undefined,\n+\t\tescapeMap: Map<string, string>\n+\t): SelectorItem {\n+\t\tconst item = new SelectorItem({ scope, combinator, ignoreErrors });\n+\n+\t\tif (ast.type === 'compound') {\n+\t\t\tfor (const token of ast.list) {\n+\t\t\t\tthis.applyTokenToSelectorItem(token, item, options, escapeMap);\n+\t\t\t}\n+\t\t} else {\n+\t\t\tthis.applyTokenToSelectorItem(ast, item, options, escapeMap);\n+\t\t}\n+\n+\t\treturn item;\n+\t}\n+\n+\t/**\n+\t * Extracts the name from a token, handling escaped characters.\n+\t * parsel-js returns only the part before escaped characters in `name`,\n+\t * but the full content is in `content`. We need to extract the full name.\n+\t * @param token\n+\t * @param prefix The prefix to remove (e.g., '#' for id, '.' for class)\n+\t * @param escapeMap\n+\t */\n+\tprivate static extractNameFromToken(\n+\t\ttoken: AST,\n+\t\tprefix: string,\n+\t\tescapeMap: Map<string, string>\n+\t): string {\n+\t\t// Cast to access content and name properties\n+\t\tconst tokenWithContent = <{ content: string; name: string }>token;\n+\n+\t\t// Get the content and remove the prefix\n+\t\tlet name = tokenWithContent.content.startsWith(prefix)\n+\t\t\t? tokenWithContent.content.slice(prefix.length)\n+\t\t\t: tokenWithContent.name;\n+\n+\t\t// Restore any pre-processed escape placeholders\n+\t\tname = this.restoreEscapedChars(name, escapeMap);\n+\n+\t\t// Remove remaining escape characters (backslash followed by any char becomes just the char)\n+\t\tname = name.replace(ESCAPED_CHARACTER_REGEXP, '$1');\n+\n+\t\treturn name;\n+\t}\n+\n+\t/**\n+\t * Applies a token to a SelectorItem.\n+\t * @param token\n+\t * @param item\n+\t * @param options\n+\t * @param options.scope\n+\t * @param options.ignoreErrors\n+\t * @param escapeMap\n+\t */\n+\tprivate static applyTokenToSelectorItem(\n+\t\ttoken: AST,\n+\t\titem: SelectorItem,\n+\t\toptions: { scope?: Element | DocumentFragment; ignoreErrors?: boolean } | undefined,\n+\t\tescapeMap: Map<string, string>\n+\t): void {\n+\t\tswitch (token.type) {\n+\t\t\tcase 'universal':\n+\t\t\t\titem.tagName = '*';\n+\t\t\t\tbreak;\n+\t\t\tcase 'type':\n+\t\t\t\titem.tagName = token.name.toUpperCase();\n+\t\t\t\tbreak;\n+\t\t\tcase 'id':\n+\t\t\t\titem.id = this.extractNameFromToken(token, '#', escapeMap);\n+\t\t\t\tbreak;\n+\t\t\tcase 'class':\n+\t\t\t\titem.classNames = item.classNames || [];\n+\t\t\t\titem.classNames.push(this.extractNameFromToken(token, '.', escapeMap));\n+\t\t\t\tbreak;\n+\t\t\tcase 'attribute':\n+\t\t\t\titem.attributes = item.attributes || [];\n+\t\t\t\titem.attributes.push(this.parseAttribute(token, escapeMap));\n+\t\t\t\tbreak;\n+\t\t\tcase 'pseudo-class':\n+\t\t\t\titem.pseudos = item.pseudos || [];\n+\t\t\t\titem.pseudos.push(this.parsePseudoClass(token, options, escapeMap));\n+\t\t\t\tbreak;\n+\t\t\tcase 'pseudo-element':\n+\t\t\t\titem.isPseudoElement = true;\n+\t\t\t\tbreak;\n+\t\t}\n+\t}\n+\n+\t/**\n+\t * Parses an attribute token into ISelectorAttribute.\n+\t * @param token\n+\t * @param escapeMap\n+\t */\n+\tprivate static parseAttribute(token: AST, escapeMap: Map<string, string>): ISelectorAttribute {\n+\t\tconst attrToken = <\n+\t\t\t{\n+\t\t\t\tname: string;\n+\t\t\t\tvalue?: string;\n+\t\t\t\toperator?: string;\n+\t\t\t\tcaseSensitive?: string;\n+\t\t\t\tcontent: string;\n+\t\t\t}\n+\t\t>token;\n+\n+\t\t// Extract attribute name, handling escaped characters\n+\t\tlet attrName = this.restoreEscapedChars(attrToken.name, escapeMap);\n+\t\tattrName = attrName.replace(ESCAPED_CHARACTER_REGEXP, '$1');\n+\n+\t\t// Extract attribute value from content if parsel-js parsed it incorrectly\n+\t\tlet value: string | null = null;\n+\t\tif (attrToken.value !== undefined) {\n+\t\t\t// Try to extract value from content for cases where parsel-js fails (e.g., brackets in value)\n+\t\t\tvalue = this.extractAttributeValue(attrToken.content, escapeMap);\n+\t\t\tif (value === null) {\n+\t\t\t\tvalue = attrToken.value;\n+\t\t\t\t// Restore escape placeholders\n+\t\t\t\tvalue = this.restoreEscapedChars(value, escapeMap);\n+\t\t\t\t// Strip quotes if present\n+\t\t\t\tif (\n+\t\t\t\t\t(value.startsWith('\"') && value.endsWith('\"')) ||\n+\t\t\t\t\t(value.startsWith(\"'\") && value.endsWith(\"'\"))\n+\t\t\t\t) {\n+\t\t\t\t\tvalue = value.slice(1, -1);\n+\t\t\t\t}\n+\t\t\t\t// Remove escape characters\n+\t\t\t\tvalue = value.replace(ESCAPED_CHARACTER_REGEXP, '$1');\n+\t\t\t}\n+\t\t}\n+\n+\t\tlet operator: string | null = null;\n+\t\tif (attrToken.operator) {\n+\t\t\toperator = attrToken.operator.replace('=', '');\n+\t\t\tif (operator === '') {\n+\t\t\t\toperator = null;\n+\t\t\t}\n \t\t}\n+\n+\t\tconst modifier = <'s' | 'i'>attrToken.caseSensitive?.toLowerCase() ?? null;\n+\n+\t\treturn {\n+\t\t\tname: attrName,\n+\t\t\toperator,\n+\t\t\tvalue,\n+\t\t\tmodifier,\n+\t\t\tregExp: this.getAttributeRegExp({ operator, value, modifier })\n+\t\t};\n+\t}\n+\n+\t/**\n+\t * Extracts attribute value from the full content string.\n+\t * This handles cases where parsel-js fails to parse values correctly (e.g., brackets).\n+\t * @param content\n+\t * @param escapeMap\n+\t */\n+\tprivate static extractAttributeValue(\n+\t\tcontent: string,\n+\t\tescapeMap: Map<string, string>\n+\t): string | null {\n+\t\t// Match [name=\"value\"] or [name='value'] or [name=value]\n+\t\t// Also handles empty values like [name=\"\"]\n+\t\tconst match = content.match(/\\[[^\\]]*?=\\s*(['\"])?(.*?)\\1\\s*(?:[iIsS]\\s*)?\\]$/);\n+\t\tif (match) {\n+\t\t\tlet value = match[2];\n+\t\t\tvalue = this.restoreEscapedChars(value, escapeMap);\n+\t\t\tvalue = value.replace(ESCAPED_CHARACTER_REGEXP, '$1');\n+\t\t\treturn value;\n+\t\t}\n+\t\treturn null;\n+\t}\n+\n+\t/**\n+\t * Parses a pseudo-class token into ISelectorPseudo.\n+\t * @param token\n+\t * @param options\n+\t * @param options.scope\n+\t * @param options.ignoreErrors\n+\t * @param escapeMap\n+\t */\n+\tprivate static parsePseudoClass(\n+\t\ttoken: AST,\n+\t\toptions: { scope?: Element | DocumentFragment; ignoreErrors?: boolean } | undefined,\n+\t\tescapeMap: Map<string, string>\n \t): ISelectorPseudo {\n-\t\tconst lowerName = name.toLowerCase();\n+\t\tconst pseudoToken = <{ name: string; argument?: string }>token;\n+\t\tconst name = pseudoToken.name.toLowerCase();\n+\t\tlet args = pseudoToken.argument ?? null;\n \n+\t\t// Restore escape placeholders in arguments\n \t\tif (args) {\n-\t\t\targs = args.trim();\n+\t\t\targs = this.restoreEscapedChars(args, escapeMap);\n \t\t}\n \n \t\tif (!args) {\n-\t\t\treturn { name: lowerName, arguments: null, selectorItems: null, nthFunction: null };\n+\t\t\treturn { name, arguments: null, selectorItems: null, nthFunction: null };\n \t\t}\n \n-\t\tswitch (lowerName) {\n+\t\tswitch (name) {\n \t\t\tcase 'nth-last-child':\n-\t\t\tcase 'nth-child':\n+\t\t\tcase 'nth-child': {\n \t\t\t\tconst nthOfIndex = args.indexOf(' of ');\n \t\t\t\tconst nthFunction = nthOfIndex !== -1 ? args.substring(0, nthOfIndex) : args;\n \t\t\t\tconst selectorItem =\n \t\t\t\t\tnthOfIndex !== -1\n \t\t\t\t\t\t? this.getSelectorItem(args.substring(nthOfIndex + 4).trim(), options)\n \t\t\t\t\t\t: null;\n \t\t\t\treturn {\n-\t\t\t\t\tname: lowerName,\n+\t\t\t\t\tname,\n \t\t\t\t\targuments: args,\n \t\t\t\t\tselectorItems: selectorItem ? [selectorItem] : null,\n \t\t\t\t\tnthFunction: this.getPseudoNthFunction(nthFunction)\n \t\t\t\t};\n+\t\t\t}\n \t\t\tcase 'nth-of-type':\n \t\t\tcase 'nth-last-of-type':\n \t\t\t\treturn {\n-\t\t\t\t\tname: lowerName,\n+\t\t\t\t\tname,\n \t\t\t\t\targuments: args,\n \t\t\t\t\tselectorItems: null,\n \t\t\t\t\tnthFunction: this.getPseudoNthFunction(args)\n \t\t\t\t};\n-\t\t\tcase 'not':\n-\t\t\t\tconst notSelectorItems = [];\n+\t\t\tcase 'not': {\n+\t\t\t\tconst notSelectorItems: SelectorItem[] = [];\n \t\t\t\tfor (const group of this.getSelectorGroups(args, options)) {\n \t\t\t\t\tnotSelectorItems.push(group[0]);\n \t\t\t\t}\n-\t\t\t\treturn {\n-\t\t\t\t\tname: lowerName,\n-\t\t\t\t\targuments: args,\n-\t\t\t\t\tselectorItems: notSelectorItems,\n-\t\t\t\t\tnthFunction: null\n-\t\t\t\t};\n+\t\t\t\treturn { name, arguments: args, selectorItems: notSelectorItems, nthFunction: null };\n+\t\t\t}\n \t\t\tcase 'is':\n-\t\t\tcase 'where':\n-\t\t\t\tconst selectorItems = [];\n+\t\t\tcase 'where': {\n+\t\t\t\tconst selectorItems: SelectorItem[] = [];\n \t\t\t\tfor (const group of this.getSelectorGroups(args, options)) {\n \t\t\t\t\tselectorItems.push(group[0]);\n \t\t\t\t}\n-\t\t\t\treturn {\n-\t\t\t\t\tname: lowerName,\n-\t\t\t\t\targuments: args,\n-\t\t\t\t\tselectorItems,\n-\t\t\t\t\tnthFunction: null\n-\t\t\t\t};\n-\t\t\tcase 'has':\n-\t\t\t\tconst hasSelectorItems = [];\n-\n-\t\t\t\t// The \":has()\" pseudo selector doesn't allow for it to be nested inside another \":has()\" pseudo selector, as it can lead to cyclic querying.\n+\t\t\t\treturn { name, arguments: args, selectorItems, nthFunction: null };\n+\t\t\t}\n+\t\t\tcase 'has': {\n+\t\t\t\tconst hasSelectorItems: SelectorItem[] = [];\n+\t\t\t\t// Trim the arguments to handle cases like \":has( +h2)\"\n+\t\t\t\tconst trimmedArgs = args.trim();\n \t\t\t\tif (!args.includes(':has(')) {\n-\t\t\t\t\tlet newArgs = args;\n-\t\t\t\t\tif (args[0] === '+') {\n-\t\t\t\t\t\tnewArgs = args.replace('+', '');\n-\t\t\t\t\t} else if (args[0] === '>') {\n-\t\t\t\t\t\tnewArgs = args.replace('>', '');\n+\t\t\t\t\tlet newArgs = trimmedArgs;\n+\t\t\t\t\t// Check for combinator at the start (after trimming whitespace)\n+\t\t\t\t\tif (newArgs.startsWith('+')) {\n+\t\t\t\t\t\tnewArgs = newArgs.slice(1).trim();\n+\t\t\t\t\t} else if (newArgs.startsWith('>')) {\n+\t\t\t\t\t\tnewArgs = newArgs.slice(1).trim();\n \t\t\t\t\t}\n \t\t\t\t\tfor (const group of this.getSelectorGroups(newArgs, options)) {\n \t\t\t\t\t\thasSelectorItems.push(group[0]);\n \t\t\t\t\t}\n \t\t\t\t}\n+\t\t\t\t// Store trimmed arguments so SelectorItem can check combinator correctly\n+\t\t\t\treturn { name, arguments: trimmedArgs, selectorItems: hasSelectorItems, nthFunction: null };\n+\t\t\t}\n+\t\t\tdefault:\n+\t\t\t\treturn { name, arguments: args, selectorItems: null, nthFunction: null };\n+\t\t}\n+\t}\n \n-\t\t\t\treturn {\n-\t\t\t\t\tname: lowerName,\n-\t\t\t\t\targuments: args || '',\n-\t\t\t\t\tselectorItems: hasSelectorItems,\n-\t\t\t\t\tnthFunction: null\n-\t\t\t\t};\n-\t\t\tcase 'scope':\n-\t\t\tcase 'root':\n+\t/**\n+\t * Returns attribute RegExp.\n+\t * @param attribute\n+\t * @param attribute.value\n+\t * @param attribute.operator\n+\t * @param attribute.modifier\n+\t */\n+\tprivate static getAttributeRegExp(attribute: {\n+\t\tvalue?: string | null;\n+\t\toperator?: string | null;\n+\t\tmodifier?: string | null;\n+\t}): RegExp | null {\n+\t\tconst modifier = attribute.modifier === 'i' ? 'i' : '';\n+\n+\t\tif (!attribute.operator || !attribute.value) {\n+\t\t\treturn null;\n+\t\t}\n+\n+\t\tconst escapedValue = attribute.value.replace(ATTRIBUTE_ESCAPE_REGEXP, '\\\\$&');\n+\n+\t\tswitch (attribute.operator) {\n+\t\t\tcase '~':\n+\t\t\t\treturn new RegExp(`[- ]${escapedValue}|${escapedValue}[- ]|^${escapedValue}$`, modifier);\n+\t\t\tcase '|':\n+\t\t\t\treturn new RegExp(`^${escapedValue}[- ]|^${escapedValue}$`, modifier);\n+\t\t\tcase '^':\n+\t\t\t\treturn new RegExp(`^${escapedValue}`, modifier);\n+\t\t\tcase '$':\n+\t\t\t\treturn new RegExp(`${escapedValue}$`, modifier);\n+\t\t\tcase '*':\n+\t\t\t\treturn new RegExp(`${escapedValue}`, modifier);\n \t\t\tdefault:\n-\t\t\t\treturn { name: lowerName, arguments: args, selectorItems: null, nthFunction: null };\n+\t\t\t\treturn null;\n \t\t}\n \t}\n \n \t/**\n \t * Returns pseudo nth function.\n-\t *\n-\t * Based on:\n-\t * https://github.com/dperini/nwsapi/blob/master/src/nwsapi.js\n-\t *\n-\t * @param args Pseudo arguments.\n-\t * @returns Pseudo nth function.\n+\t * @param args\n \t */\n \tprivate static getPseudoNthFunction(args?: string): ((n: number) => boolean) | null {\n \t\tif (!args) {\n@@ -295,9 +295,10 @@ describe('QuerySelector', () => {\n \t\t\tcontainer.appendChild(element1);\n \t\t\tcontainer.appendChild(element2);\n \n-\t\t\tconst invalidSelectorElements = container.querySelectorAll('.before:');\n+\t\t\t// Invalid selector \".before:\" should throw (colon without pseudo-class name)\n+\t\t\texpect(() => container.querySelectorAll('.before:')).toThrow();\n+\t\t\t// Valid selector with escaped colon should match\n \t\t\tconst validSelectorElements = container.querySelectorAll('.before\\\\:after');\n-\t\t\texpect(invalidSelectorElements.length).toBe(0);\n \t\t\texpect(validSelectorElements.length).toBe(2);\n \t\t\texpect(validSelectorElements[0] === element1).toBe(true);\n \t\t\texpect(validSelectorElements[1] === element2).toBe(true);\n@@ -329,9 +330,10 @@ describe('QuerySelector', () => {\n \t\t\tcontainer.appendChild(element1);\n \t\t\tcontainer.appendChild(element2);\n \n-\t\t\tconst invalidSelectorElements = container.querySelectorAll('.before&after');\n+\t\t\t// Invalid selector \".before&after\" should throw (& is not valid in CSS selectors)\n+\t\t\texpect(() => container.querySelectorAll('.before&after')).toThrow();\n+\t\t\t// Valid selector with escaped ampersand should match\n \t\t\tconst validSelectorElements = container.querySelectorAll('.before\\\\&after');\n-\t\t\texpect(invalidSelectorElements.length).toBe(0);\n \t\t\texpect(validSelectorElements.length).toBe(2);\n \t\t\texpect(validSelectorElements[0] === element1).toBe(true);\n \t\t\texpect(validSelectorElements[1] === element2).toBe(true);\n@@ -502,6 +504,24 @@ describe('QuerySelector', () => {\n \t\t\texpect(elements[1] === container.children[0].children[1].children[1]).toBe(true);\n \t\t});\n \n+\t\tit('Returns elements with attribute values containing apostrophes using double quotes as delimiter.', () => {\n+\t\t\tconst container = document.createElement('div');\n+\t\t\tcontainer.innerHTML = `<div data-value=\"it's a test\">Content</div>`;\n+\n+\t\t\tconst elements = container.querySelectorAll('[data-value=\"it\\'s a test\"]');\n+\t\t\texpect(elements.length).toBe(1);\n+\t\t\texpect(elements[0] === container.children[0]).toBe(true);\n+\t\t});\n+\n+\t\tit('Returns elements with attribute values containing double quotes using apostrophes as delimiter.', () => {\n+\t\t\tconst container = document.createElement('div');\n+\t\t\tcontainer.innerHTML = `<div data-value='say \"hello\"'>Content</div>`;\n+\n+\t\t\tconst elements = container.querySelectorAll('[data-value=\\'say \"hello\"\\']');\n+\t\t\texpect(elements.length).toBe(1);\n+\t\t\texpect(elements[0] === container.children[0]).toBe(true);\n+\t\t});\n+\n \t\tit('Returns all elements with tag name and matching attributes using \"span[_attr1]\".', () => {\n \t\t\tconst container = document.createElement('div');\n \t\t\tcontainer.innerHTML = QuerySelectorHTML.replace(/ attr1/gm, '_attr1');\n@@ -912,26 +932,32 @@ describe('QuerySelector', () => {\n \t\t\texpect(elements3[1] === container.children[0].children[1].children[2]).toBe(true);\n \t\t});\n \n-\t\tit('Returns all elements matching the selector without ending parenthese \"button:not([type]\"', () => {\n+\t\tit('Throws error for selector without ending parenthesis \"button:not([type]\"', () => {\n \t\t\tconst container = document.createElement('div');\n \t\t\tcontainer.innerHTML = `\n \t\t\t\t<button></button>\n                 <button type=\"submit\"></button>\n                 <button></button>\n \t\t\t`;\n-\t\t\tconst elements = container.querySelectorAll('button:not([type]');\n+\t\t\t// Invalid selector (missing closing parenthesis) should throw\n+\t\t\texpect(() => container.querySelectorAll('button:not([type]')).toThrow();\n+\t\t\t// Valid selector should work\n+\t\t\tconst elements = container.querySelectorAll('button:not([type])');\n \t\t\texpect(elements.length).toBe(2);\n \t\t\texpect(elements[0]).toBe(container.children[0]);\n \t\t\texpect(elements[1]).toBe(container.children[2]);\n \t\t});\n \n-\t\tit('Returns all elements matching the invalid selector \"[q\\\\:shadowroot]\"', () => {\n+\t\tit('Returns all elements matching the selector \"[q\\\\:shadowroot]\" with escaped colon', () => {\n \t\t\tconst container = document.createElement('div');\n-\t\t\tcontainer.innerHTML = `\n-\t\t\t\t<span q\\\\:shadowroot></span>\n-                <button></button>\n-                <article></article>\n-\t\t\t`;\n+\t\t\t// Create element with attribute name containing colon\n+\t\t\tconst span = document.createElement('span');\n+\t\t\tspan.setAttribute('q:shadowroot', '');\n+\t\t\tcontainer.appendChild(span);\n+\t\t\tcontainer.appendChild(document.createElement('button'));\n+\t\t\tcontainer.appendChild(document.createElement('article'));\n+\n+\t\t\t// Selector [q\\:shadowroot] looks for attribute \"q:shadowroot\" (colon is escaped)\n \t\t\tconst elements = container.querySelectorAll('[q\\\\:shadowroot]');\n \t\t\texpect(elements.length).toBe(1);\n \t\t\texpect(elements[0]).toBe(container.children[0]);\n@@ -1290,6 +1316,75 @@ describe('QuerySelector', () => {\n \t\t\t]);\n \t\t});\n \n+\t\tit('Does not throw error when parsing complex CSS :has() expression with nested :not() (issue #1910)', () => {\n+\t\t\tconst container = document.createElement('div');\n+\t\t\tcontainer.innerHTML = `\n+\t\t\t\t<table class=\"emXRrt\">\n+\t\t\t\t\t<thead>\n+\t\t\t\t\t\t<tr>\n+\t\t\t\t\t\t\t<th>Header 1</th>\n+\t\t\t\t\t\t\t<th>Header 2</th>\n+\t\t\t\t\t\t</tr>\n+\t\t\t\t\t</thead>\n+\t\t\t\t\t<tbody>\n+\t\t\t\t\t\t<tr>\n+\t\t\t\t\t\t\t<td>Cell 1</td>\n+\t\t\t\t\t\t\t<td>Cell 2</td>\n+\t\t\t\t\t\t</tr>\n+\t\t\t\t\t\t<tr data-ignore-row>\n+\t\t\t\t\t\t\t<td>Ignored 1</td>\n+\t\t\t\t\t\t\t<td>Ignored 2</td>\n+\t\t\t\t\t\t</tr>\n+\t\t\t\t\t</tbody>\n+\t\t\t\t</table>\n+\t\t\t`;\n+\n+\t\t\t// This selector should not throw an error\n+\t\t\t// The bug was that :not(:has(...)) with nested selectors caused a null reference error\n+\t\t\texpect(() => {\n+\t\t\t\tcontainer.querySelectorAll(\n+\t\t\t\t\t'.emXRrt>tbody>tr>td:nth-child(2),.emXRrt:not(:has(> tbody > tr:not([data-ignore-row])))>thead>tr>th:nth-child(2)'\n+\t\t\t\t);\n+\t\t\t}).not.toThrow();\n+\n+\t\t\t// Verify the selector actually works correctly\n+\t\t\t// First part: .emXRrt>tbody>tr>td:nth-child(2) - matches 2nd td in each tbody row (2 elements)\n+\t\t\t// Second part: .emXRrt:not(:has(> tbody > tr:not([data-ignore-row])))>thead>tr>th:nth-child(2)\n+\t\t\t//   - The table HAS a tbody > tr without data-ignore-row, so :not(:has(...)) should NOT match\n+\t\t\t//   - Therefore the second part should match 0 elements\n+\t\t\tconst elements = container.querySelectorAll(\n+\t\t\t\t'.emXRrt>tbody>tr>td:nth-child(2),.emXRrt:not(:has(> tbody > tr:not([data-ignore-row])))>thead>tr>th:nth-child(2)'\n+\t\t\t);\n+\n+\t\t\t// Should only match the 2 td elements from the first selector part\n+\t\t\texpect(elements.length).toBe(2);\n+\t\t\texpect(elements[0].textContent).toBe('Cell 2');\n+\t\t\texpect(elements[1].textContent).toBe('Ignored 2');\n+\t\t});\n+\n+\t\tit('Correctly parses :not() with nested :has() containing :not() (issue #1910)', () => {\n+\t\t\tconst container = document.createElement('div');\n+\t\t\tcontainer.innerHTML = `\n+\t\t\t\t<div class=\"parent\">\n+\t\t\t\t\t<div class=\"child\" data-keep></div>\n+\t\t\t\t\t<div class=\"child\"></div>\n+\t\t\t\t</div>\n+\t\t\t\t<div class=\"parent no-match\">\n+\t\t\t\t\t<div class=\"child\" data-keep></div>\n+\t\t\t\t</div>\n+\t\t\t`;\n+\n+\t\t\t// Test :not(:has(...)) with nested :not() - the regex must handle nested parentheses\n+\t\t\t// .parent:not(:has(> .child:not([data-keep]))) should match parents that do NOT have\n+\t\t\t// a direct child .child without data-keep attribute\n+\t\t\t// First parent has a .child without data-keep, so it should NOT match\n+\t\t\t// Second parent only has .child with data-keep, so it SHOULD match\n+\t\t\tconst elements = container.querySelectorAll('.parent:not(:has(> .child:not([data-keep])))');\n+\n+\t\t\texpect(elements.length).toBe(1);\n+\t\t\texpect(elements[0].classList.contains('no-match')).toBe(true);\n+\t\t});\n+\n \t\tit('Returns all elements for subsequent sibling selector using \".a ~ .a\"', () => {\n \t\t\tconst div = document.createElement('div');\n "
    }
  ],
  "searches": [
    {
      "query": "\":not\" \"descendant\"",
      "result": [
        {
          "number": 2499,
          "title": "matches() and getComputedStyle() keep a stale :has() result after a descendant changes",
          "url": "https://github.com/capricorn86/happy-dom/issues/2499"
        },
        {
          "number": 2488,
          "title": "getComputedStyle() returns '' for properties nothing sets and for inherited custom properties",
          "url": "https://github.com/capricorn86/happy-dom/issues/2488"
        },
        {
          "number": 2489,
          "title": "matches() and getComputedStyle() still keep a stale result after an ancestor above the parent changes (follow-up to #2442)",
          "url": "https://github.com/capricorn86/happy-dom/issues/2489"
        },
        {
          "number": 2170,
          "title": "Regression (20.3.3): Node.contains() returns false for a genuine descendant of a proxied <form>/<select> when the subtree is assembled detached then attached",
          "url": "https://github.com/capricorn86/happy-dom/issues/2170"
        },
        {
          "number": 2230,
          "title": "`<textarea>` `value`/`defaultValue` leak text from nested element children",
          "url": "https://github.com/capricorn86/happy-dom/issues/2230"
        },
        {
          "number": 2156,
          "title": "Bug: `innerHTML` incorrectly parses `<` followed by a digit as an HTML tag",
          "url": "https://github.com/capricorn86/happy-dom/issues/2156"
        },
        {
          "number": 2184,
          "title": "FormData constructor does not exclude disabled <select> and <textarea> elements",
          "url": "https://github.com/capricorn86/happy-dom/issues/2184"
        },
        {
          "number": 2538,
          "title": "Performance: getComputedStyle() runs QuerySelector.matches() for every style rule, even when the element lacks the rule's subject class",
          "url": "https://github.com/capricorn86/happy-dom/issues/2538"
        },
        {
          "number": 2088,
          "title": "querySelector throws SyntaxError on selectors containing CSS hex escapes (e.g. #\\39 7e...)",
          "url": "https://github.com/capricorn86/happy-dom/issues/2088"
        },
        {
          "number": 2081,
          "title": "[Bug]: Click events on nested <span> inside <button type=\"submit\"> fail to trigger form submission (Activation Behavior regression since v15.0.0)",
          "url": "https://github.com/capricorn86/happy-dom/issues/2081"
        },
        {
          "number": 2442,
          "title": "matches() and getComputedStyle() keep a stale result after an attribute changes on an ancestor other than the parent (e.g. html[data-theme=\"dark\"] span)",
          "url": "https://github.com/capricorn86/happy-dom/issues/2442"
        },
        {
          "number": 2253,
          "title": "Inserting a <form>/<select> that already has children re-points the children's parentNode at the raw target instead of the Proxy",
          "url": "https://github.com/capricorn86/happy-dom/issues/2253"
        }
      ]
    },
    {
      "query": "\":not\" \"combinator\"",
      "result": [
        {
          "number": 2489,
          "title": "matches() and getComputedStyle() still keep a stale result after an ancestor above the parent changes (follow-up to #2442)",
          "url": "https://github.com/capricorn86/happy-dom/issues/2489"
        },
        {
          "number": 2518,
          "title": "getComputedStyle() keeps a stale result for siblings matched with + or ~ after a class change (since 20.14.0)",
          "url": "https://github.com/capricorn86/happy-dom/issues/2518"
        },
        {
          "number": 2088,
          "title": "querySelector throws SyntaxError on selectors containing CSS hex escapes (e.g. #\\39 7e...)",
          "url": "https://github.com/capricorn86/happy-dom/issues/2088"
        },
        {
          "number": 2538,
          "title": "Performance: getComputedStyle() runs QuerySelector.matches() for every style rule, even when the element lacks the rule's subject class",
          "url": "https://github.com/capricorn86/happy-dom/issues/2538"
        },
        {
          "number": 2222,
          "title": "Styled width and height not retrieved by getBoundingClientRect and getClientRects",
          "url": "https://github.com/capricorn86/happy-dom/issues/2222"
        },
        {
          "number": 2442,
          "title": "matches() and getComputedStyle() keep a stale result after an attribute changes on an ancestor other than the parent (e.g. html[data-theme=\"dark\"] span)",
          "url": "https://github.com/capricorn86/happy-dom/issues/2442"
        },
        {
          "number": 2078,
          "title": "`querySelectorAll` with child combinator `>` and universal selector `*` matches descendants instead of direct children",
          "url": "https://github.com/capricorn86/happy-dom/issues/2078"
        },
        {
          "number": 1785,
          "title": "React 19.1's new useId with unicode characters `«` and `»` break selectors",
          "url": "https://github.com/capricorn86/happy-dom/issues/1785"
        },
        {
          "number": 1683,
          "title": "getByRole doesn't work - CSS subsequent sibling combinator (~) ",
          "url": "https://github.com/capricorn86/happy-dom/issues/1683"
        },
        {
          "number": 792,
          "title": "Implementing Adjacent Sibling Selectors",
          "url": "https://github.com/capricorn86/happy-dom/issues/792"
        }
      ]
    },
    {
      "query": "\"group[0]\" \"not\"",
      "result": [
        {
          "number": 1927,
          "title": "Jest 30, clearImmediate missing",
          "url": "https://github.com/capricorn86/happy-dom/issues/1927"
        }
      ]
    },
    {
      "query": "\"complex\" in:title",
      "result": [
        {
          "number": 1910,
          "title": "Error when parsing complex CSS has expression",
          "url": "https://github.com/capricorn86/happy-dom/issues/1910"
        }
      ]
    }
  ]
}
复杂括号与属性选择器交叉链接
{
  "checkedOn": "2026-10-28",
  "issues": [
    {
      "url": "https://github.com/capricorn86/happy-dom/issues/1910",
      "title": "Error when parsing complex CSS has expression",
      "state": "closed",
      "created_at": "2025-09-29T11:42:39Z",
      "closed_at": "2026-02-19T00:45:22Z",
      "body": "**Describe the bug**\nCrash when using a `screen.findByRole` command from testing-library/react.\n\n```log\nTypeError: Cannot read properties of null (reading '0')\nat SelectorItem.matchPseudoItem (frontend/node_modules/happy-dom/src/query-selector/SelectorItem.ts:345:26)\nat SelectorItem.matchPseudo (frontend/node_modules/happy-dom/src/query-selector/SelectorItem.ts:175:31)\nat SelectorItem.match (frontend/node_modules/happy-dom/src/query-selector/SelectorItem.ts:107:24)\nat SelectorItem.matchPseudoItem (frontend/node_modules/happy-dom/src/query-selector/SelectorItem.ts:252:23)\nat SelectorItem.matchPseudo (frontend/node_modules/happy-dom/src/query-selector/SelectorItem.ts:175:31)\nat SelectorItem.match (frontend/node_modules/happy-dom/src/query-selector/SelectorItem.ts:107:24)\nat Function.matchSelector (frontend/node_modules/happy-dom/src/query-selector/QuerySelector.ts:383:31)\nat Function.matches (frontend/node_modules/happy-dom/src/query-selector/QuerySelector.ts:353:24)\nat CSSStyleDeclarationComputedStyle.parseCSSRules (frontend/node_modules/happy-dom/src/css/declaration/computed-style/CSSStyleDeclarationComputedStyle.ts:301:36)\nat CSSStyleDeclarationComputedStyle.getComputedStyle \n```\n**To Reproduce**\nThe crash happens with the following CSS string that is part of a reendered \"table\" element:\n```css\n.emXRrt>tbody>tr>td:nth-child(2),.emXRrt:not(:has(> tbody > tr:not([data-ignore-row])))>thead>tr>th:nth-child(2)\n```\n\nWhen calling `SelectorParser.getSelectorGroups`, the broken entry causing the error can be seen nested in the second array entry a bit nested:\n\n```json\n[\n  {\n    \"name\": \"has\",\n    \"arguments\": null,\n    \"selectorItems\": null,\n    \"nthFunction\": null\n  }\n]\n```\n\n**Expected behavior**\nNo parsing error\n**Screenshots**\n\n**Additional context**\nhappy-dom 18.0.1, vitest 3.2.4\n\n\nThank you :)"
    },
    {
      "url": "https://github.com/capricorn86/happy-dom/issues/1912",
      "title": "CSS attribute selector returns no elements",
      "state": "closed",
      "created_at": "2025-09-29T23:49:53Z",
      "closed_at": "2026-02-19T00:52:11Z",
      "body": "**Describe the bug**\nThe CSS attribute selector (`[Name]`) doesn't seem to be working right now in happy-dom.\n\n**To Reproduce**\nSee minimal example in https://github.com/devmattrick/happy-dom-issues\n\n**Expected behavior**\nShould return elements matching the attribute selector.\n\n**Screenshots**\nBrowser\n<img width=\"361\" height=\"166\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/a9acc372-9ee1-402c-bbf3-b9d0cffd56ef\" />\n\njsdom\n```\n$ npm run test:jsdom\n\n> happy-dom-scope@1.0.0 test:jsdom\n> node -r global-jsdom/register test.js\n\nNumber of elements with Name attribute: 1\n```\n\nhappy-dom\n```\n$ npm run test:happy-dom\n\n> happy-dom-scope@1.0.0 test:happy-dom\n> node -r ./happy-dom.js test.js\n\nNumber of elements with Name attribute: 0\n```\n\n**Device:**\n - OS: Windows 11\n - Browser: N/A\n - Version: 19.0.2\n\n**Additional context**\nN/A\n"
    },
    {
      "url": "https://github.com/capricorn86/happy-dom/issues/2034",
      "title": "querySelector fails to match attribute values containing apostrophes",
      "state": "closed",
      "created_at": "2026-01-22T22:56:24Z",
      "closed_at": "2026-02-19T00:53:36Z",
      "body": "## Description\n\n`querySelector` with an attribute selector fails to find elements when the attribute value contains an apostrophe (single quote), even when the selector uses double quotes as delimiters.\n\n## Reproduction\n\nhttps://github.com/JalilArfaoui/happy-dom-apostrophe-bug\n\n```sh\ngit clone git@github.com:JalilArfaoui/happy-dom-apostrophe-bug.git\ncd happy-dom-apostrophe-bug\nnpm install\nnpm test\n```\n\nOutput:\n\nTest 1: Apostrophe in double-quoted attribute value\n  Selector: [id=\"type d'activité-label\"]\n  Result: ❌ NOT FOUND (bug!)\n  Expected: ✅ FOUND\n\nTest 2: Double quote in single-quoted attribute value\n  Selector: [id='say \"hello\"']\n  Result: ❌ NOT FOUND (bug!)\n  Expected: ✅ FOUND\n\nTest 3: Simple ID (control test)\n  Selector: [id=\"simple\"]\n  Result: ✅ FOUND\n  Expected: ✅ FOUND\n\n\nThe selector [id=\"type d'activité-label\"] should match the element, since the value is delimited by double quotes and the apostrophe inside should not interfere.\n\nThis works correctly in all browsers (Chrome, Firefox, Safari).\n\nEnvironment\n- happy-dom version: 15.11.7\n- Node.js version: 22.x\n- Discovered via @testing-library/react with Vitest\n"
    }
  ]
}
Happy DOM 本地修复补丁与验证材料

打开原文件 happy-dom-not-complex-selectors.patch

补丁基于提交 5e100dedb73111a276f75a3daff3a506e2524759,截至 2026-10-28 尚未提交上游或发布到 npm。包内版本字段继续使用仓库本身的 0.0.0,不能据此当作新的发布版本。

在单独、干净的 Happy DOM checkout 中,将补丁下载到仓库根目录后运行:

git switch -c fix-not-complex-selectors 5e100dedb73111a276f75a3daff3a506e2524759
git apply --check happy-dom-not-complex-selectors.patch
git apply happy-dom-not-complex-selectors.patch
npm ci --ignore-scripts
npm run test --workspace happy-dom -- test/query-selector/QuerySelectorNot.test.ts test/query-selector/QuerySelector.test.ts

补丁已在该提交的原始文件副本上完成 git apply --check 和实际应用;应用后六个文件的内容与本地修复逐一对齐。其他提交或已有改动可能产生冲突,需要先检查。

公开 API 快速检查脚本把正文中的匹配、作用域、缓存更新和 Shadow DOM 边界浓缩成四组检查。将它下载到已安装 Happy DOM 的实验目录,执行:

node verify-fix.mjs

脚本默认解析当前目录安装的 happy-dom,也支持将构建后的入口文件路径作为第一个参数。检查官方 21.0.4 时,四组均失败并返回非零退出码;检查本地补丁的构建入口时,四组均通过。它用于快速辨认行为,不能替代补丁自带的 28 个完整回归。

本地源码测试直接读取 TypeScript。需要生成供快速脚本加载的 JS 时,在已安装依赖的仓库根目录运行 npm run compile --workspace happy-dom,再以 packages/happy-dom/lib/index.js 作为脚本参数。本次采样使用了这条构建流程。它还会生成 CSS 属性、下载 Public Suffix List,可能产生对应的生成文件变化;这些步骤属于仓库原有构建流程。

官方版本与本地补丁的四组公开 API 检查
{
  "checkedOn": "2026-10-28",
  "official": {
    "version": "21.0.4",
    "exitCode": 1,
    "node": "v26.12.0",
    "results": [
      {
        "name": "complete selector and computed display",
        "status": "failed",
        "message": "Expected values to be strictly deep-equal:\n+ actual - expected\n\n  {\n    ids: [\n+     'inside',\n      'outside'\n    ],\n+   insideDisplay: 'none',\n+   insideMatches: true\n-   insideDisplay: 'block',\n-   insideMatches: false\n  }\n"
      },
      {
        "name": "scope belongs to the calling query",
        "status": "failed",
        "message": "Expected values to be strictly deep-equal:\n+ actual - expected\n\n  {\n+   nested: [\n+     'deep'\n+   ],\n-   nested: [],\n    root: [\n+     'direct',\n      'deep'\n    ]\n  }\n"
      },
      {
        "name": "ancestor changes invalidate the owning query",
        "status": "failed",
        "message": "Expected values to be strictly deep-equal:\n+ actual - expected\n... Skipped lines\n\n  [\n    {\n+     ids: [\n+       'deep'\n+     ],\n+     matches: true\n-     ids: [],\n-     matches: false\n    },\n    {\n      ids: [\n        'deep'\n      ],\n...\n    {\n+     ids: [\n+       'deep'\n+     ],\n+     matches: true\n-     ids: [],\n-     matches: false\n    }\n  ]\n"
      },
      {
        "name": "ordinary ancestors stop at the shadow boundary",
        "status": "failed",
        "message": "Expected values to be strictly deep-equal:\n+ actual - expected\n\n  {\n+   explicitHost: true,\n+   nestedHost: true,\n-   explicitHost: false,\n-   nestedHost: false,\n    ordinary: true\n  }\n"
      }
    ]
  },
  "patched": {
    "baseCommit": "5e100dedb73111a276f75a3daff3a506e2524759",
    "exitCode": 0,
    "node": "v26.12.0",
    "results": [
      {
        "name": "complete selector and computed display",
        "status": "passed",
        "actual": {
          "ids": [
            "outside"
          ],
          "insideMatches": false,
          "insideDisplay": "block"
        }
      },
      {
        "name": "scope belongs to the calling query",
        "status": "passed",
        "actual": {
          "root": [
            "deep"
          ],
          "nested": []
        }
      },
      {
        "name": "ancestor changes invalidate the owning query",
        "status": "passed",
        "actual": [
          {
            "matches": false,
            "ids": []
          },
          {
            "matches": true,
            "ids": [
              "deep"
            ]
          },
          {
            "matches": false,
            "ids": []
          }
        ]
      },
      {
        "name": "ordinary ancestors stop at the shadow boundary",
        "status": "passed",
        "actual": {
          "ordinary": true,
          "explicitHost": false,
          "nestedHost": false
        }
      }
    ]
  }
}
补丁摘要、28 个浏览器用例和完整测试结果
{
  "checkedOn": "2026-10-28",
  "status": "local patch; not submitted upstream or published to npm",
  "baseCommit": "5e100dedb73111a276f75a3daff3a506e2524759",
  "releaseBaseline": "21.0.4",
  "patch": {
    "path": "patches/happy-dom-not-complex-selectors.patch",
    "sha256": "680cc5c93e49d8a1853d864d51cab30d109c26b24b21ca15781a1a1b6b969a38",
    "applyCheck": "passed against original tracked files; applied content matches all six changed files"
  },
  "tests": {
    "focused": {
      "original": 161,
      "added": 28,
      "total": 189,
      "result": "passed"
    },
    "full": {
      "passed": 8100,
      "failed": 12,
      "total": 8112,
      "baselineFailuresMatch": true,
      "failures": [
        {
          "file": "test/module/ModuleURLUtility.test.ts",
          "name": "ModuleURLUtility getURL() Resolves \"module\" field in Node module URL when \"settings.module.resolveNodeModules\" is enabled"
        },
        {
          "file": "test/module/ModuleURLUtility.test.ts",
          "name": "ModuleURLUtility getURL() Resolves \"main\" field in Node module URL when \"settings.module.resolveNodeModules\" is enabled"
        },
        {
          "file": "test/module/ModuleURLUtility.test.ts",
          "name": "ModuleURLUtility getURL() Resolves export \".\" field in Node module URL when \"settings.module.resolveNodeModules\" is enabled"
        },
        {
          "file": "test/module/ModuleURLUtility.test.ts",
          "name": "ModuleURLUtility getURL() Resolves export \"*.js\" field in Node module URL when \"settings.module.resolveNodeModules\" is enabled"
        },
        {
          "file": "test/module/ModuleURLUtility.test.ts",
          "name": "ModuleURLUtility getURL() Appends \".js\" for Node module URL when \"settings.module.resolveNodeModules\" is enabled"
        },
        {
          "file": "test/module/ModuleURLUtility.test.ts",
          "name": "ModuleURLUtility getURL() Appends \".mjs\" for Node module URL when \"settings.module.resolveNodeModules\" is enabled"
        },
        {
          "file": "test/window/BrowserWindow.test.ts",
          "name": "BrowserWindow setTimeout() Supports preventing timeout loops when the setting \"preventTimerLoops\" is set to \"true\"."
        },
        {
          "file": "test/window/BrowserWindow.test.ts",
          "name": "BrowserWindow setTimeout() Supports preventing timeout loops when the setting \"preventTimerLoops\" is set to an object with a limit."
        },
        {
          "file": "test/nodes/html-script-element/HTMLScriptElement.test.ts",
          "name": "HTMLScriptElement set isConnected() Dispatches \"error\" event on Window when compilation of module failed"
        },
        {
          "file": "test/nodes/html-script-element/HTMLScriptElement.test.ts",
          "name": "HTMLScriptElement set isConnected() Dispatches \"error\" event on Window when evaluation of module failed"
        },
        {
          "file": "test/fetch/cache/response/ResponseCacheFileSystem.test.ts",
          "name": "ResponseCacheFileSystem load() Loads cache from file system."
        },
        {
          "file": "test/fetch/cache/response/ResponseCacheFileSystem.test.ts",
          "name": "ResponseCacheFileSystem save() Saves the cache to file system."
        }
      ]
    },
    "browser": {
      "engine": "Edge 156.0.4314.42",
      "total": 28,
      "passed": 28,
      "cases": [
        {
          "name": "Matches the complete selector \"$selector\" through public APIs. .item:not(.comments *)",
          "status": "passed"
        },
        {
          "name": "Matches the complete selector \"$selector\" through public APIs. .item:not(.comments > .item)",
          "status": "passed"
        },
        {
          "name": "Matches the complete selector \"$selector\" through public APIs. .item:not(.marker + .item)",
          "status": "passed"
        },
        {
          "name": "Matches the complete selector \"$selector\" through public APIs. .item:not(.marker ~ .item)",
          "status": "passed"
        },
        {
          "name": "Preserves an element with the excluded ancestor class on itself.",
          "status": "passed"
        },
        {
          "name": "Checks all complex branches of an exclusion list.",
          "status": "passed"
        },
        {
          "name": "Supports nested negation.",
          "status": "passed"
        },
        {
          "name": "Supports consecutive exclusions.",
          "status": "passed"
        },
        {
          "name": "Supports negation nested inside :%s(). is",
          "status": "passed"
        },
        {
          "name": "Supports negation nested inside :%s(). where",
          "status": "passed"
        },
        {
          "name": "Preserves the query scope when matching an exclusion.",
          "status": "passed"
        },
        {
          "name": "Invalidates a negated match when an ancestor changes.",
          "status": "passed"
        },
        {
          "name": "Tracks ancestors beyond the parent when the argument initially does not match.",
          "status": "passed"
        },
        {
          "name": "Tracks changes to a preceding sibling for %s. +",
          "status": "passed"
        },
        {
          "name": "Tracks changes to a preceding sibling for %s. ~",
          "status": "passed"
        },
        {
          "name": "Invalidates the owning query result when an ancestor outside its root changes.",
          "status": "passed"
        },
        {
          "name": "Propagates cache dependencies through :%s(). is",
          "status": "passed"
        },
        {
          "name": "Propagates cache dependencies through :%s(). where",
          "status": "passed"
        },
        {
          "name": "Applies the rule to computed styles and refreshes it after an ancestor changes.",
          "status": "passed"
        },
        {
          "name": "Still rejects a missing exclusion argument.",
          "status": "passed"
        },
        {
          "name": "Respects the shadow boundary in :not($argument). .comments *",
          "status": "passed"
        },
        {
          "name": "Respects the shadow boundary in :not($argument). :not(.missing) .item",
          "status": "passed"
        },
        {
          "name": "Respects the shadow boundary in :not($argument). :host .item",
          "status": "passed"
        },
        {
          "name": "Respects the shadow boundary in :not($argument). :is(:host) .item",
          "status": "passed"
        },
        {
          "name": "Respects the shadow boundary in :not($argument). :where(:host) .item",
          "status": "passed"
        },
        {
          "name": "Respects the shadow boundary in :not($argument). :not(:not(:host)) .item",
          "status": "passed"
        },
        {
          "name": "Respects the shadow boundary in :not($argument). :is(.comments, :host(.missing)) .item",
          "status": "passed"
        },
        {
          "name": "Respects the shadow boundary in :not($argument). :not(:host(.missing)) .item",
          "status": "passed"
        }
      ]
    },
    "build": "TypeScript compilation and circular dependency check passed",
    "lint": "ESLint and Prettier passed",
    "environment": {
      "os": "Windows",
      "node": "v26.12.0"
    }
  },
  "boundaries": [
    "Keeps the existing :not() priorityWeight of 10; not a complete Selectors Level 4 specificity fix.",
    "General complex :is() and :where() argument support on ordinary elements is outside this patch.",
    "Published npm versions remain unchanged."
  ],
  "checks": {
    "publicSmokeScript": "passed: official 21.0.4 fails all four; local compiled patch passes all four",
    "blogPreview": "passed at 1440px and 390px; seven images loaded; supplementary panels, focus restoration and public downloads verified"
  }
}