Puppeteer Coverage.stopCSSCoverage() 深度解析:如何获取页面样式的精确使用范围
本文围绕 Puppeteer API 文档中的 Coverage.stopCSSCoverage() 方法展开,它是 Puppeteer 代码覆盖率(Code Coverage)能力的核心终点:调用它即可拿到所有样式表的 CSS 覆盖率报告数组。读完本文,你将掌握该方法的签名与返回值结构、典型调用流程(与 startCSSCoverage 配对使用)、基于 CDP 协议(CSS.startRuleUsageTracking 等)的底层实现原理,以及如何用测试用例验证 ranges 的准确语义。
方法签名与返回值
官方 API 文档(见 stopCSSCoverage 文档页)给出的签名为:
class Coverage {
stopCSSCoverage(): Promise<CoverageEntry[]>;
}
- 无参数:该方法不接受任何配置,配置项全部在配套的
startCSSCoverage(options)中传入; - 返回值:一个 Promise,解析为
CoverageEntry[],即所有样式表(stylesheets)的覆盖率报告数组; - 文档备注(Remarks):CSS Coverage doesn't include dynamically injected style tags without sourceURLs(CSS 覆盖率不包含没有 sourceURL 的动态注入 style 标签)。这一限制在源码中有直接体现,下文会展开。
CoverageEntry 的完整结构定义在 Coverage 类实现,文档见 CoverageEntry 接口页:
export interface CoverageEntry {
/** 样式表或脚本的 URL */
url: string;
/** 样式表或脚本的完整文本内容 */
text: string;
/** 被覆盖的范围,表示为起止偏移位置 */
ranges: Array<{start: number; end: number}>;
}
三个字段组合起来的语义是:在 text 这个 CSS 源码字符串中,ranges 里每个 {start, end} 区间(半开区间)标记的是实际被浏览器应用过的 CSS 片段(例如某条完整规则)。text.substring(start, end) 可以直接截取出"被使用"的 CSS 片段。
典型使用流程:start 与 stop 配对
stopCSSCoverage() 必须与 startCSSCoverage() 成对使用:先开启追踪,触发页面行为(导航、交互、样式重计算),再调用 stop 收集结果并自动关闭追踪。来自 Coverage 类文档的官方示例,展示了同时收集 JS 与 CSS 覆盖率并计算"初始执行代码字节占比"的完整流程:
// 同时开启 JavaScript 和 CSS 覆盖率
await Promise.all([
page.coverage.startJSCoverage(),
page.coverage.startCSSCoverage(),
]);
// 导航到页面
await page.goto('https://example.com');
// 同时关闭 JavaScript 和 CSS 覆盖率
const [jsCoverage, cssCoverage] = await Promise.all([
page.coverage.stopJSCoverage(),
page.coverage.stopCSSCoverage(),
]);
let totalBytes = 0;
let usedBytes = 0;
const coverage = [...jsCoverage, ...cssCoverage];
for (const entry of coverage) {
totalBytes += entry.text.length;
for (const range of entry.ranges) usedBytes += range.end - range.start - 1;
}
console.log(`Bytes used: ${(usedBytes / totalBytes) * 100}%`);
需要留意官方示例中的细节:统计 CSS 使用字节时用的是 range.end - range.start - 1(JS 与 CSS 条目统一处理),而 ranges 本身按半开区间理解(substring(start, end) 截取即可),两者相差 1 个字节属于示例的口径处理。
开启阶段的可配置项
startCSSCoverage 的完整签名与参数(见 startCSSCoverage 文档页):
class Coverage {
startCSSCoverage(options?: CSSCoverageOptions): Promise<void>;
}
CSSCoverageOptions 只有一个选项(见 CSSCoverageOptions 定义):
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
resetOnNavigation |
boolean |
true |
是否在每次导航后重置覆盖率。true 时只统计导航之后的样式表;false 时累积跨导航的样式表记录 |
源码中 CSSCoverage.start() 的默认值解构 const {resetOnNavigation = true} = options 印证了该默认值(CSSCoverage.start)。此外 start 内部会先断言 assert(!this.#enabled, 'CSSCoverage is already enabled'),即同一 Page 上不能重复开启 CSS 追踪,重复调用会直接抛出断言错误。
源码实现:CDP 协议如何支撑 stopCSSCoverage
stopCSSCoverage() 在 Coverage 类 中只是一层薄薄的封装:
async stopCSSCoverage(): Promise<CoverageEntry[]> {
return await this.#cssCoverage.stop();
}
真正的逻辑全部位于 CSSCoverage 类中,其生命周期由三个 CDP 命令驱动:
1. start 阶段(开启追踪)(CSSCoverage.start):
await Promise.all([
this.#client.send('DOM.enable'),
this.#client.send('CSS.enable'),
this.#client.send('CSS.startRuleUsageTracking'),
]);
同时注册两个事件监听:CSS.styleSheetAdded 和 Runtime.executionContextsCleared。前者负责在每张样式表加载时记录其 URL 与全文(为 stop 阶段的报告做准备);后者在导航清空执行上下文时,按 resetOnNavigation 决定是否清空已收集的样式表映射。
2. 样式表收集:sourceURL 是关键过滤条件(CSSCoverage.#onStyleSheet):
async #onStyleSheet(event: Protocol.CSS.StyleSheetAddedEvent): Promise<void> {
const header = event.header;
// Ignore anonymous scripts
if (!header.sourceURL) {
return;
}
const response = await this.#client.send('CSS.getStyleSheetText', {
styleSheetId: header.styleSheetId,
});
this.#stylesheetURLs.set(header.styleSheetId, header.sourceURL);
this.#stylesheetSources.set(header.styleSheetId, response.text);
}
这里 if (!header.sourceURL) return; 正是文档 Remarks 中"不包含没有 sourceURL 的动态注入 style 标签"这一限制的根源——通过 page.addStyleTag({content: ...}) 之类方式注入、且没有 /*# sourceURL=xxx.css */ 注释的样式表,从一开始就不会进入收集映射,stop 时自然也不会出现在结果数组里。
3. stop 阶段(收集与聚合)(CSSCoverage.stop):
async stop(): Promise<CoverageEntry[]> {
assert(this.#enabled, 'CSSCoverage is not enabled');
this.#enabled = false;
const ruleTrackingResponse = await this.#client.send(
'CSS.stopRuleUsageTracking',
);
await Promise.all([
this.#client.send('CSS.disable'),
this.#client.send('DOM.disable'),
]);
// ... 按 styleSheetId 聚合 ruleUsage,产出 CoverageEntry[]
}
从源码结构看,stop() 的完整链路是:
- 断言追踪必须处于开启状态(未 start 就 stop 会抛出
CSSCoverage is not enabled); - 发送
CSS.stopRuleUsageTracking,拿到浏览器返回的ruleUsage原始列表,每条含styleSheetId、startOffset、endOffset、used; - 关闭
CSS与DOM域,释放事件订阅; - 按
styleSheetId聚合原始区间,未使用的规则以count: 0记录,已使用的以count: 1记录; - 遍历 start 阶段收集的
#stylesheetURLs映射,将原始区间交给convertToDisjointRanges转换为不相交区间后,组装成{url, ranges, text}条目返回。
值得注意的语义:结果是"按已收集的样式表"生成的——即使某张样式表没有任何规则被使用,它依然会出现在返回数组中,只是 ranges 为空数组(详见后文测试用例)。
区间算法:convertToDisjointRanges
CSS 规则追踪返回的原始区间是嵌套的(一条规则的区间包含其声明、选择器等子区间),直接输出会大量重叠。convertToDisjointRanges 用一个经典的"扫描线 + 命中计数栈"算法把嵌套区间化简为不相交区间:
- 为每个区间生成 start/end 两个端点,按偏移排序(同偏移时 end 点优先、长区间 start 优先),保证端点序列构成合法的"括号序列";
- 用
hitCountStack维护当前命中计数,只有栈顶计数> 0的区段才被认为是"被覆盖"; - 相邻的命中区段会合并(
lastResult.end === lastOffset时直接延长),最后过滤掉空区间。
这也解释了测试中断言的精确偏移:对于 div { color: green; } 这样的源码,返回的是 {start: 1, end: 22} 这类可直接 substring 取用的边界。该算法在 JS 覆盖率(stopJSCoverage)与 CSS 覆盖率中是共用的。
测试用例验证:ranges 的准确语义
仓库的 CSS 覆盖率测试 对 stopCSSCoverage() 的边界行为做了系统验证,值得逐条对照理解:
| 测试场景 | 验证点 | 源码位置 |
|---|---|---|
should work |
单张内联样式表,ranges 精确等于 [{start: 1, end: 22}],且 text.substring(1, 22) 恰为 div { color: green; } |
test/src/coverage.test.ts#L188-L201 |
should report sourceURLs |
带 /*# sourceURL=nicename.css */ 的样式表,其 url 字段即 sourceURL 而非页面 URL |
test/src/coverage.test.ts#L202-L210 |
should report multiple stylesheets |
多张外部样式表各自独立成条 | test/src/coverage.test.ts#L211-L223 |
should report stylesheets that have no coverage |
无规则被使用的样式表仍在结果中,ranges 长度为 0 |
test/src/coverage.test.ts#L224-L233 |
should work with media queries |
媒体查询产生多个不相交区间,如 [{start: 8, end: 15}, {start: 17, end: 38}] |
test/src/coverage.test.ts#L234-L246 |
should work with empty stylesheets |
空样式表返回 text: '' 的条目,不报错 |
test/src/coverage.test.ts#L257-L265 |
should ignore injected stylesheets |
page.addStyleTag({content}) 注入且无 sourceURL 的样式,最终 coverage 长度为 0,与文档 Remarks 完全对应 |
test/src/coverage.test.ts#L266-L278 |
resetOnNavigation(两个方向) |
false 时跨导航累计 2 张样式表;默认 true 时导航后清空、最终为空数组 |
test/src/coverage.test.ts#L297-L316 |
此外 should work with complicated usecases 用 golden 文件 csscoverage-involved.txt 锁定了复杂用例(嵌套媒体查询 + 外部样式表)的完整输出,作为回归基准。
实用建议与使用限制
综合文档与源码,使用 stopCSSCoverage() 时建议关注以下几点:
- 配对调用与幂等性:
startCSSCoverage与stopCSSCoverage各调用一次即可;未 start 就 stop 会触发断言异常,重复 start 同样会抛错。 - 动态样式要"可追溯":想让运行时注入的样式进入报告,需在 CSS 文本中写入
/*# sourceURL=xxx.css */注释(测试should report sourceURLs已验证该机制);否则该样式表会被静默忽略。 - 跨导航统计:若需要统计 SPA 多路由或多次
page.goto之后所有样式表的累计使用情况,请以startCSSCoverage({resetOnNavigation: false})开启。 - 与 JS 覆盖率组合:
page.coverage同时提供 JS 侧的startJSCoverage/stopJSCoverage,官方推荐的写法是Promise.all成对启停(见上文示例)。JS 侧的JSCoverageEntry还额外携带可选的rawScriptCoverage(原始 V8 数据),而 CSS 侧的返回始终是基础CoverageEntry三字段结构。 - 下游工具:如需把结果转成 Istanbul 格式的覆盖率报告,官方文档推荐使用配套工具 puppeteer-to-istanbul(Coverage 类文档 的 Remarks 中有说明),它消费的就是
stopCSSCoverage/stopJSCoverage产出的{url, ranges, text}结构。
Coverage 类通过 page.coverage 属性暴露(定义见 Page 抽象),构造器标记为 @internal,第三方代码应始终经由 page.coverage 访问,而不是直接实例化——这与 API 文档页 Coverage 类说明 的约束一致。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00