Skip to main content

playwright_cdp/
coverage.rs

1//! `Coverage` — JS and CSS code-coverage collection for a page.
2//!
3//! Speaks CDP's coverage domains directly on the page session:
4//! - **JS coverage** via the `Profiler` domain:
5//!   `Profiler.startPreciseCoverage` (with `callCount` + `detailed`) on start,
6//!   `Profiler.takePreciseCoverage` then `Profiler.stopPreciseCoverage` on stop.
7//! - **CSS coverage** via the `CSS` domain:
8//!   `CSS.enable` + `CSS.startRuleUsageTracking` on start, and
9//!   `CSS.stopRuleUsageTracking` (which returns rule usage) on stop.
10//!
11//! Mirrors the shape of Playwright's `page.coverage()`.
12
13use crate::cdp::session::CdpSession;
14use crate::error::Result;
15use serde_json::{json, Value};
16use std::sync::Arc;
17
18/// A code-coverage handle for a [`Page`](crate::Page).
19///
20/// Cheaply cloneable; all clones share the same page session.
21#[derive(Clone)]
22pub struct Coverage {
23    inner: Arc<CoverageInner>,
24}
25
26struct CoverageInner {
27    /// The page-level CDP session (Profiler/CSS live here).
28    session: CdpSession,
29}
30
31/// Options for [`Coverage::start_js_coverage`].
32#[derive(Debug, Clone, Default)]
33#[non_exhaustive]
34pub struct JSCoverageStartOptions {
35    /// Report anonymous (non-covered vs covered byte ranges) as well as
36    /// call counts. Maps to CDP `detailed`. Defaults to `true`.
37    pub report_anonymous: Option<bool>,
38    /// Collect per-block coverage (true) rather than per-function (false).
39    /// Maps to CDP `detailed`. Defaults to `true`.
40    pub include_block_coverage: Option<bool>,
41}
42
43impl JSCoverageStartOptions {
44    pub fn report_anonymous(mut self, v: bool) -> Self {
45        self.report_anonymous = Some(v);
46        self
47    }
48    pub fn include_block_coverage(mut self, v: bool) -> Self {
49        self.include_block_coverage = Some(v);
50        self
51    }
52}
53
54/// A single script reported by JS coverage.
55#[derive(Debug, Clone, Default)]
56#[non_exhaustive]
57pub struct JSCoverageEntry {
58    /// The script's URL (may be empty for inline/eval'd scripts).
59    pub url: String,
60    /// The CDP script id.
61    pub script_id: String,
62    /// Per-function coverage records.
63    pub functions: Vec<JSCoverageFunction>,
64}
65
66/// A single function within a JS coverage entry.
67#[derive(Debug, Clone, Default)]
68#[non_exhaustive]
69pub struct JSCoverageFunction {
70    /// Function name (may be empty for anonymous functions).
71    pub function_name: String,
72    /// Whether block-level coverage was collected for this function.
73    pub is_block_coverage: bool,
74    /// Covered ranges: byte offsets and call counts.
75    pub ranges: Vec<JSCoverageRange>,
76}
77
78/// A covered byte range inside a JS function.
79#[derive(Debug, Clone, Default)]
80#[non_exhaustive]
81pub struct JSCoverageRange {
82    /// Inclusive start byte offset within the script source.
83    pub start_offset: i64,
84    /// Exclusive end byte offset within the script source.
85    pub end_offset: i64,
86    /// Number of times this range was executed.
87    pub call_count: i64,
88}
89
90/// The result of [`Coverage::stop_js_coverage`]: one entry per script.
91#[derive(Debug, Clone, Default)]
92#[non_exhaustive]
93pub struct JSCoverageResult {
94    /// One entry per covered script.
95    pub entries: Vec<JSCoverageEntry>,
96}
97
98/// A single CSS style sheet reported by CSS coverage.
99#[derive(Debug, Clone, Default)]
100#[non_exhaustive]
101pub struct CSSCoverageEntry {
102    /// The style sheet id.
103    pub style_sheet_id: String,
104    /// Per-rule usage records.
105    pub rules: Vec<CSSCoverageRule>,
106}
107
108/// A single CSS rule's coverage record.
109#[derive(Debug, Clone, Default)]
110#[non_exhaustive]
111pub struct CSSCoverageRule {
112    /// The owning style sheet id.
113    pub style_sheet_id: String,
114    /// Inclusive start byte offset within the sheet source.
115    pub start_offset: i64,
116    /// Exclusive end byte offset within the sheet source.
117    pub end_offset: i64,
118    /// Whether the rule was used during the capture.
119    pub used: bool,
120}
121
122/// The result of [`Coverage::stop_css_coverage`]: rule usage grouped by sheet.
123#[derive(Debug, Clone, Default)]
124#[non_exhaustive]
125pub struct CSSCoverageResult {
126    /// One entry per style sheet with usage data.
127    pub entries: Vec<CSSCoverageEntry>,
128}
129
130impl Coverage {
131    pub(crate) fn new(session: CdpSession) -> Self {
132        Self {
133            inner: Arc::new(CoverageInner { session }),
134        }
135    }
136
137    // --- JS coverage (Profiler domain) -------------------------------------
138
139    /// Begin collecting precise JS coverage. Enables the `Profiler` domain,
140    /// then issues `Profiler.startPreciseCoverage { callCount: true,
141    /// detailed: <opts> }`.
142    ///
143    /// `Profiler.enable` must precede `startPreciseCoverage` — CDP rejects
144    /// the latter with `Profiler is not enabled` otherwise.
145    pub async fn start_js_coverage(
146        &self,
147        options: Option<JSCoverageStartOptions>,
148    ) -> Result<()> {
149        self.inner.session.send("Profiler.enable", json!({})).await?;
150        let opts = options.unwrap_or_default();
151        let detailed = opts.include_block_coverage.unwrap_or(true);
152        self.inner
153            .session
154            .send(
155                "Profiler.startPreciseCoverage",
156                json!({ "callCount": true, "detailed": detailed }),
157            )
158            .await?;
159        Ok(())
160    }
161
162    /// Stop collecting JS coverage and return the accumulated report. Issues
163    /// `Profiler.takePreciseCoverage` (the result) then
164    /// `Profiler.stopPreciseCoverage` (teardown).
165    pub async fn stop_js_coverage(&self) -> Result<JSCoverageResult> {
166        let resp: Value = self
167            .inner
168            .session
169            .send("Profiler.takePreciseCoverage", json!({}))
170            .await?;
171
172        // Tear down precise coverage and disable the Profiler domain to restore
173        // baseline state, regardless of parse success.
174        let _ = self
175            .inner
176            .session
177            .send("Profiler.stopPreciseCoverage", json!({}))
178            .await;
179        let _ = self.inner.session.send("Profiler.disable", json!({})).await;
180
181        let entries = resp
182            .get("result")
183            .and_then(|v| v.as_array())
184            .map(|a| parse_js_entries(a))
185            .unwrap_or_default();
186
187        Ok(JSCoverageResult { entries })
188    }
189
190    // --- CSS coverage (CSS domain) -----------------------------------------
191
192    /// Begin collecting CSS rule usage tracking. Enables the `CSS` domain and
193    /// issues `CSS.startRuleUsageTracking`.
194    pub async fn start_css_coverage(&self) -> Result<()> {
195        self.inner.session.send("CSS.enable", json!({})).await?;
196        self.inner
197            .session
198            .send("CSS.startRuleUsageTracking", json!({}))
199            .await?;
200        Ok(())
201    }
202
203    /// Stop collecting CSS coverage and return rule usage. Issues
204    /// `CSS.stopRuleUsageTracking` (which returns the usage list), then
205    /// disables the `CSS` domain.
206    pub async fn stop_css_coverage(&self) -> Result<CSSCoverageResult> {
207        let resp: Value = self
208            .inner
209            .session
210            .send("CSS.stopRuleUsageTracking", json!({}))
211            .await?;
212
213        // Disable CSS domain to restore baseline state.
214        let _ = self.inner.session.send("CSS.disable", json!({})).await;
215
216        let rules = resp
217            .get("ruleUsage")
218            .and_then(|v| v.as_array())
219            .map(|a| parse_css_rules(a))
220            .unwrap_or_default();
221
222        // Group the flat rule-usage list by style sheet id.
223        let mut entries: Vec<CSSCoverageEntry> = Vec::new();
224        for rule in rules {
225            if let Some(e) = entries.iter_mut().find(|e| e.style_sheet_id == rule.style_sheet_id)
226            {
227                e.rules.push(rule);
228            } else {
229                entries.push(CSSCoverageEntry {
230                    style_sheet_id: rule.style_sheet_id.clone(),
231                    rules: vec![rule],
232                });
233            }
234        }
235
236        Ok(CSSCoverageResult { entries })
237    }
238}
239
240/// Parse the CDP `Profiler.takePreciseCoverage.result` array into entries.
241fn parse_js_entries(arr: &[Value]) -> Vec<JSCoverageEntry> {
242    arr.iter()
243        .filter_map(|s| {
244            let url = s.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
245            let script_id = s
246                .get("scriptId")
247                .and_then(|v| v.as_str())
248                .unwrap_or("")
249                .to_string();
250            let functions = s
251                .get("functions")
252                .and_then(|v| v.as_array())
253                .map(|fs| fs.iter().filter_map(parse_js_function).collect())
254                .unwrap_or_default();
255            Some(JSCoverageEntry {
256                url,
257                script_id,
258                functions,
259            })
260        })
261        .collect()
262}
263
264/// Parse one CDP coverage function record.
265fn parse_js_function(f: &Value) -> Option<JSCoverageFunction> {
266    let function_name = f
267        .get("functionName")
268        .and_then(|v| v.as_str())
269        .unwrap_or("")
270        .to_string();
271    let is_block_coverage = f
272        .get("isBlockCoverage")
273        .and_then(|v| v.as_bool())
274        .unwrap_or(false);
275    let ranges = f
276        .get("ranges")
277        .and_then(|v| v.as_array())
278        .map(|rs| {
279            rs.iter()
280                .filter_map(|r| {
281                    Some(JSCoverageRange {
282                        start_offset: r.get("startOffset").and_then(|v| v.as_i64())?,
283                        end_offset: r.get("endOffset").and_then(|v| v.as_i64())?,
284                        call_count: r.get("callCount").and_then(|v| v.as_i64()).unwrap_or(0),
285                    })
286                })
287                .collect()
288        })
289        .unwrap_or_default();
290    Some(JSCoverageFunction {
291        function_name,
292        is_block_coverage,
293        ranges,
294    })
295}
296
297/// Parse the CDP `CSS.stopRuleUsageTracking.ruleUsage` array into rule records.
298fn parse_css_rules(arr: &[Value]) -> Vec<CSSCoverageRule> {
299    arr.iter()
300        .filter_map(|r| {
301            Some(CSSCoverageRule {
302                style_sheet_id: r.get("styleSheetId").and_then(|v| v.as_str())?.to_string(),
303                start_offset: r.get("startOffset").and_then(|v| v.as_i64())?,
304                end_offset: r.get("endOffset").and_then(|v| v.as_i64())?,
305                used: r.get("used").and_then(|v| v.as_bool()).unwrap_or(false),
306            })
307        })
308        .collect()
309}