viewpoint_core/context/trace/
mod.rs

1//! Tracing implementation for recording test execution traces.
2//!
3//! Traces capture screenshots, DOM snapshots, and network activity
4//! for debugging test failures. Traces are compatible with Playwright's
5//! Trace Viewer.
6
7// Allow dead code for tracing scaffolding (spec: tracing)
8
9mod capture;
10mod network;
11mod sources;
12mod types;
13mod writer;
14
15use std::sync::Arc;
16
17use chrono::Utc;
18use tokio::sync::RwLock;
19use tracing::{debug, info, instrument};
20
21use viewpoint_cdp::protocol::tracing as cdp_tracing;
22use viewpoint_cdp::CdpConnection;
23
24use crate::context::PageInfo;
25use crate::error::ContextError;
26use crate::network::har::HarPage;
27
28pub use types::{ActionEntry, TracingOptions};
29pub(crate) use types::TracingState;
30use types::SourceFileEntry;
31
32/// Tracing manager for recording test execution traces.
33///
34/// Traces record screenshots, DOM snapshots, network activity, and action
35/// history. They can be viewed using Playwright's Trace Viewer.
36///
37/// **Note:** At least one page must exist in the context before starting tracing.
38/// The tracing state is shared across all `context.tracing()` calls within the
39/// same context, so you can call `start()` and `stop()` from separate `tracing()`
40/// invocations.
41///
42/// # Example
43///
44/// ```
45/// # #[cfg(feature = "integration")]
46/// # tokio_test::block_on(async {
47/// # use viewpoint_core::Browser;
48/// use viewpoint_core::context::TracingOptions;
49/// # let browser = Browser::launch().headless(true).launch().await.unwrap();
50/// # let context = browser.new_context().await.unwrap();
51///
52/// // Create a page first (required before starting tracing)
53/// let page = context.new_page().await.unwrap();
54///
55/// // Start tracing with screenshots
56/// context.tracing().start(
57///     TracingOptions::new()
58///         .name("my-test")
59///         .screenshots(true)
60///         .snapshots(true)
61/// ).await.unwrap();
62///
63/// // Perform test actions...
64/// page.goto("https://example.com").goto().await.unwrap();
65///
66/// // Stop and save trace (state persists across tracing() calls)
67/// context.tracing().stop("/tmp/trace.zip").await.unwrap();
68/// # });
69/// ```
70pub struct Tracing {
71    /// CDP connection.
72    connection: Arc<CdpConnection>,
73    /// Browser context ID.
74    context_id: String,
75    /// Pages in this context (used to get session IDs).
76    pages: Arc<RwLock<Vec<PageInfo>>>,
77    /// Tracing state.
78    state: Arc<RwLock<TracingState>>,
79}
80
81impl std::fmt::Debug for Tracing {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        f.debug_struct("Tracing")
84            .field("context_id", &self.context_id)
85            .finish_non_exhaustive()
86    }
87}
88
89impl Tracing {
90    /// Create a new Tracing instance with shared state.
91    pub(crate) fn new(
92        connection: Arc<CdpConnection>,
93        context_id: String,
94        pages: Arc<RwLock<Vec<PageInfo>>>,
95        state: Arc<RwLock<TracingState>>,
96    ) -> Self {
97        Self {
98            connection,
99            context_id,
100            pages,
101            state,
102        }
103    }
104
105    /// Get session IDs from pages.
106    async fn get_session_ids(&self) -> Vec<String> {
107        let pages = self.pages.read().await;
108        pages
109            .iter()
110            .filter(|p| !p.session_id.is_empty())
111            .map(|p| p.session_id.clone())
112            .collect()
113    }
114
115    /// Start recording a trace.
116    ///
117    /// # Requirements
118    ///
119    /// At least one page must exist in the context before starting tracing.
120    /// Create a page with `context.new_page().await` first.
121    ///
122    /// # Errors
123    ///
124    /// Returns an error if:
125    /// - Tracing is already active
126    /// - No pages exist in the context
127    /// - CDP commands fail
128    ///
129    /// # Example
130    ///
131    /// ```ignore
132    /// // Create a page first
133    /// let page = context.new_page().await?;
134    ///
135    /// // Then start tracing
136    /// context.tracing().start(
137    ///     TracingOptions::new()
138    ///         .screenshots(true)
139    ///         .snapshots(true)
140    /// ).await?;
141    /// ```
142    #[instrument(level = "info", skip(self, options))]
143    pub async fn start(&self, options: TracingOptions) -> Result<(), ContextError> {
144        let mut state = self.state.write().await;
145
146        if state.is_recording {
147            return Err(ContextError::Internal(
148                "Tracing is already active".to_string(),
149            ));
150        }
151
152        // Validate that at least one page exists
153        let session_ids = self.get_session_ids().await;
154        if session_ids.is_empty() {
155            return Err(ContextError::Internal(
156                "Cannot start tracing: no pages in context. Create a page first.".to_string(),
157            ));
158        }
159
160        info!(
161            screenshots = options.screenshots,
162            snapshots = options.snapshots,
163            "Starting trace"
164        );
165
166        // Build categories for Chrome tracing
167        let categories = [
168            "devtools.timeline",
169            "disabled-by-default-devtools.timeline",
170            "disabled-by-default-devtools.timeline.frame",
171        ];
172
173        // Start tracing on all sessions
174        for session_id in session_ids {
175            let params = cdp_tracing::StartParams {
176                categories: Some(categories.join(",")),
177                transfer_mode: Some(cdp_tracing::TransferMode::ReturnAsStream),
178                ..Default::default()
179            };
180
181            self.connection
182                .send_command::<_, serde_json::Value>(
183                    "Tracing.start",
184                    Some(params),
185                    Some(&session_id),
186                )
187                .await?;
188
189            // Enable network tracking
190            self.connection
191                .send_command::<_, serde_json::Value>(
192                    "Network.enable",
193                    Some(serde_json::json!({})),
194                    Some(&session_id),
195                )
196                .await?;
197        }
198
199        // Initialize state
200        state.is_recording = true;
201        state.options = options;
202        state.actions.clear();
203        state.events.clear();
204        state.screenshots.clear();
205        state.snapshots.clear();
206        state.pending_requests.clear();
207        state.network_entries.clear();
208        state.har_pages.clear();
209        state.source_files.clear();
210
211        // Start network listener
212        drop(state); // Release lock before spawning
213        network::start_network_listener(
214            self.connection.clone(),
215            self.state.clone(),
216            self.pages.clone(),
217        );
218
219        Ok(())
220    }
221
222    /// Stop tracing and save the trace to a file.
223    ///
224    /// The trace is saved as a zip file containing:
225    /// - trace.json: The trace data
226    /// - resources/: Screenshots and other resources
227    /// - network.har: Network activity in HAR format
228    ///
229    /// # Errors
230    ///
231    /// Returns an error if tracing is not active or saving the trace fails.
232    ///
233    /// # Example
234    ///
235    /// ```ignore
236    /// context.tracing().stop("trace.zip").await?;
237    /// ```
238    #[instrument(level = "info", skip(self), fields(path = %path.as_ref().display()))]
239    pub async fn stop(&self, path: impl AsRef<std::path::Path>) -> Result<(), ContextError> {
240        let path = path.as_ref();
241        let mut state = self.state.write().await;
242
243        if !state.is_recording {
244            return Err(ContextError::Internal("Tracing is not active".to_string()));
245        }
246
247        info!("Stopping trace and saving");
248
249        // Stop tracing on all sessions
250        for session_id in self.get_session_ids().await {
251            let _ = self
252                .connection
253                .send_command::<_, serde_json::Value>("Tracing.end", None::<()>, Some(&session_id))
254                .await;
255        }
256
257        state.is_recording = false;
258
259        // Write trace file
260        writer::write_trace_zip(path, &state)?;
261
262        Ok(())
263    }
264
265    /// Stop tracing and discard the trace data.
266    ///
267    /// Use this when you don't need to save the trace (e.g., test passed).
268    ///
269    /// # Errors
270    ///
271    /// Returns an error if tracing is not active.
272    #[instrument(level = "info", skip(self))]
273    pub async fn stop_discard(&self) -> Result<(), ContextError> {
274        let mut state = self.state.write().await;
275
276        if !state.is_recording {
277            return Err(ContextError::Internal("Tracing is not active".to_string()));
278        }
279
280        info!("Stopping trace and discarding");
281
282        // Stop tracing on all sessions
283        for session_id in self.get_session_ids().await {
284            let _ = self
285                .connection
286                .send_command::<_, serde_json::Value>("Tracing.end", None::<()>, Some(&session_id))
287                .await;
288        }
289
290        // Clear state
291        state.is_recording = false;
292        state.actions.clear();
293        state.events.clear();
294        state.screenshots.clear();
295        state.snapshots.clear();
296        state.pending_requests.clear();
297        state.network_entries.clear();
298        state.har_pages.clear();
299        state.source_files.clear();
300
301        Ok(())
302    }
303
304    /// Start a new trace chunk.
305    ///
306    /// This is useful for long-running tests where you want to save
307    /// periodic snapshots.
308    ///
309    /// # Errors
310    ///
311    /// Returns an error if tracing is not active.
312    #[instrument(level = "debug", skip(self))]
313    pub async fn start_chunk(&self) -> Result<(), ContextError> {
314        let state = self.state.read().await;
315
316        if !state.is_recording {
317            return Err(ContextError::Internal("Tracing is not active".to_string()));
318        }
319
320        debug!("Starting new trace chunk");
321
322        // In a full implementation, this would rotate the trace data
323        // For now, we just continue recording
324
325        Ok(())
326    }
327
328    /// Stop the current trace chunk and save it.
329    ///
330    /// # Errors
331    ///
332    /// Returns an error if tracing is not active or saving fails.
333    #[instrument(level = "debug", skip(self), fields(path = %path.as_ref().display()))]
334    pub async fn stop_chunk(&self, path: impl AsRef<std::path::Path>) -> Result<(), ContextError> {
335        let path = path.as_ref();
336        let state = self.state.read().await;
337
338        if !state.is_recording {
339            return Err(ContextError::Internal("Tracing is not active".to_string()));
340        }
341
342        debug!("Stopping trace chunk and saving");
343
344        // Write current state to file
345        writer::write_trace_zip(path, &state)?;
346
347        // Note: In a full implementation, we would clear the current chunk
348        // and continue recording for the next chunk
349
350        Ok(())
351    }
352
353    /// Check if tracing is currently active.
354    pub async fn is_recording(&self) -> bool {
355        self.state.read().await.is_recording
356    }
357
358    /// Add a source file to include in the trace.
359    ///
360    /// Source files are shown in the Trace Viewer for debugging.
361    ///
362    /// # Example
363    ///
364    /// ```ignore
365    /// context.tracing().add_source_file(
366    ///     "tests/my_test.rs",
367    ///     include_str!("tests/my_test.rs")
368    /// ).await;
369    /// ```
370    pub async fn add_source_file(&self, path: impl Into<String>, content: impl Into<String>) {
371        let mut state = self.state.write().await;
372        state.source_files.push(SourceFileEntry {
373            path: path.into(),
374            content: content.into(),
375        });
376    }
377
378    /// Collect source files from a directory.
379    ///
380    /// This recursively adds all matching files from the directory.
381    ///
382    /// # Arguments
383    ///
384    /// * `dir` - Directory to scan
385    /// * `extensions` - File extensions to include (e.g., `["rs", "ts"]`)
386    ///
387    /// # Errors
388    ///
389    /// Returns an error if reading files fails.
390    pub async fn collect_sources(
391        &self,
392        dir: impl AsRef<std::path::Path>,
393        extensions: &[&str],
394    ) -> Result<(), ContextError> {
395        let files = sources::collect_sources_from_dir(dir.as_ref(), extensions)?;
396
397        let mut state = self.state.write().await;
398        for (path, content) in files {
399            state.source_files.push(SourceFileEntry { path, content });
400        }
401
402        Ok(())
403    }
404
405    /// Record an action in the trace.
406    ///
407    /// Returns a handle that must be used to complete or fail the action.
408    pub(crate) async fn record_action(
409        &self,
410        action_type: &str,
411        selector: Option<&str>,
412        page_id: Option<&str>,
413    ) -> ActionHandle<'_> {
414        let start_time = std::time::SystemTime::now()
415            .duration_since(std::time::UNIX_EPOCH)
416            .unwrap_or_default()
417            .as_secs_f64()
418            * 1000.0;
419
420        let action = ActionEntry {
421            action_type: action_type.to_string(),
422            selector: selector.map(ToString::to_string),
423            page_id: page_id.map(ToString::to_string),
424            start_time,
425            end_time: None,
426            result: None,
427            value: None,
428            url: None,
429            screenshot: None,
430            snapshot: None,
431        };
432
433        let mut state = self.state.write().await;
434        let index = state.actions.len();
435        state.actions.push(action);
436
437        ActionHandle {
438            tracing: self,
439            index,
440        }
441    }
442
443    /// Record a page being created.
444    pub(crate) async fn record_page(&self, page_id: &str, title: &str) {
445        let mut state = self.state.write().await;
446        let started_date_time = Utc::now().to_rfc3339();
447        let page = HarPage::new(page_id, title, &started_date_time);
448        state.har_pages.push(page);
449        state.current_page_id = Some(page_id.to_string());
450    }
451
452    /// Capture a screenshot and add it to the trace.
453    pub(crate) async fn capture_screenshot(
454        &self,
455        session_id: &str,
456        name: Option<&str>,
457    ) -> Result<(), ContextError> {
458        capture::capture_screenshot(&self.connection, &self.state, session_id, name).await
459    }
460
461    /// Capture a DOM snapshot and add it to the trace.
462    pub(crate) async fn capture_dom_snapshot(
463        &self,
464        session_id: &str,
465    ) -> Result<(), ContextError> {
466        capture::capture_dom_snapshot(&self.connection, &self.state, session_id).await
467    }
468
469    /// Capture action context (screenshot + snapshot) if enabled.
470    pub(crate) async fn capture_action_context(
471        &self,
472        session_id: &str,
473        action_name: Option<&str>,
474    ) -> Result<(), ContextError> {
475        capture::capture_action_context(&self.connection, &self.state, session_id, action_name).await
476    }
477}
478
479/// Handle for tracking an action's duration in the trace.
480pub struct ActionHandle<'a> {
481    tracing: &'a Tracing,
482    index: usize,
483}
484
485impl ActionHandle<'_> {
486    /// Complete the action with success.
487    pub async fn complete(self, result: Option<serde_json::Value>) {
488        let end_time = std::time::SystemTime::now()
489            .duration_since(std::time::UNIX_EPOCH)
490            .unwrap_or_default()
491            .as_secs_f64()
492            * 1000.0;
493
494        let mut state = self.tracing.state.write().await;
495        if let Some(action) = state.actions.get_mut(self.index) {
496            action.end_time = Some(end_time);
497            action.result = result;
498        }
499    }
500
501    /// Complete the action with an error.
502    pub async fn fail(self, error: &str) {
503        let end_time = std::time::SystemTime::now()
504            .duration_since(std::time::UNIX_EPOCH)
505            .unwrap_or_default()
506            .as_secs_f64()
507            * 1000.0;
508
509        let mut state = self.tracing.state.write().await;
510        if let Some(action) = state.actions.get_mut(self.index) {
511            action.end_time = Some(end_time);
512            action.result = Some(serde_json::json!({ "error": error }));
513        }
514    }
515}