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::CdpConnection;
22use viewpoint_cdp::protocol::tracing as cdp_tracing;
23
24use crate::context::PageInfo;
25use crate::error::ContextError;
26use crate::network::har::HarPage;
27
28use types::SourceFileEntry;
29pub(crate) use types::TracingState;
30pub use types::{ActionEntry, TracingOptions};
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    /// ```no_run
132    /// use viewpoint_core::{Browser, TracingOptions};
133    ///
134    /// # async fn example() -> Result<(), viewpoint_core::CoreError> {
135    /// # let browser = Browser::launch().headless(true).launch().await?;
136    /// # let context = browser.new_context().await?;
137    /// // Create a page first
138    /// let page = context.new_page().await?;
139    ///
140    /// // Then start tracing
141    /// context.tracing().start(
142    ///     TracingOptions::new()
143    ///         .screenshots(true)
144    ///         .snapshots(true)
145    /// ).await?;
146    /// # Ok(())
147    /// # }
148    #[instrument(level = "info", skip(self, options))]
149    pub async fn start(&self, options: TracingOptions) -> Result<(), ContextError> {
150        let mut state = self.state.write().await;
151
152        if state.is_recording {
153            return Err(ContextError::Internal(
154                "Tracing is already active".to_string(),
155            ));
156        }
157
158        // Validate that at least one page exists
159        let session_ids = self.get_session_ids().await;
160        if session_ids.is_empty() {
161            return Err(ContextError::Internal(
162                "Cannot start tracing: no pages in context. Create a page first.".to_string(),
163            ));
164        }
165
166        info!(
167            screenshots = options.screenshots,
168            snapshots = options.snapshots,
169            "Starting trace"
170        );
171
172        // Build categories for Chrome tracing
173        let categories = [
174            "devtools.timeline",
175            "disabled-by-default-devtools.timeline",
176            "disabled-by-default-devtools.timeline.frame",
177        ];
178
179        // Start tracing on all sessions
180        for session_id in session_ids {
181            let params = cdp_tracing::StartParams {
182                categories: Some(categories.join(",")),
183                transfer_mode: Some(cdp_tracing::TransferMode::ReturnAsStream),
184                ..Default::default()
185            };
186
187            self.connection
188                .send_command::<_, serde_json::Value>(
189                    "Tracing.start",
190                    Some(params),
191                    Some(&session_id),
192                )
193                .await?;
194
195            // Enable network tracking
196            self.connection
197                .send_command::<_, serde_json::Value>(
198                    "Network.enable",
199                    Some(serde_json::json!({})),
200                    Some(&session_id),
201                )
202                .await?;
203        }
204
205        // Initialize state
206        state.is_recording = true;
207        state.options = options;
208        state.actions.clear();
209        state.events.clear();
210        state.screenshots.clear();
211        state.snapshots.clear();
212        state.pending_requests.clear();
213        state.network_entries.clear();
214        state.har_pages.clear();
215        state.source_files.clear();
216
217        // Start network listener
218        drop(state); // Release lock before spawning
219        network::start_network_listener(
220            self.connection.clone(),
221            self.state.clone(),
222            self.pages.clone(),
223        );
224
225        Ok(())
226    }
227
228    /// Stop tracing and save the trace to a file.
229    ///
230    /// The trace is saved as a zip file containing:
231    /// - trace.json: The trace data
232    /// - resources/: Screenshots and other resources
233    /// - network.har: Network activity in HAR format
234    ///
235    /// # Errors
236    ///
237    /// Returns an error if tracing is not active or saving the trace fails.
238    ///
239    /// # Example
240    ///
241    /// ```no_run
242    /// use viewpoint_core::Browser;
243    ///
244    /// # async fn example() -> Result<(), viewpoint_core::CoreError> {
245    /// # let browser = Browser::launch().headless(true).launch().await?;
246    /// # let context = browser.new_context().await?;
247    /// context.tracing().stop("trace.zip").await?;
248    /// # Ok(())
249    /// # }
250    #[instrument(level = "info", skip(self), fields(path = %path.as_ref().display()))]
251    pub async fn stop(&self, path: impl AsRef<std::path::Path>) -> Result<(), ContextError> {
252        let path = path.as_ref();
253        let mut state = self.state.write().await;
254
255        if !state.is_recording {
256            return Err(ContextError::Internal("Tracing is not active".to_string()));
257        }
258
259        info!("Stopping trace and saving");
260
261        // Stop tracing on all sessions
262        for session_id in self.get_session_ids().await {
263            let _ = self
264                .connection
265                .send_command::<_, serde_json::Value>("Tracing.end", None::<()>, Some(&session_id))
266                .await;
267        }
268
269        state.is_recording = false;
270
271        // Write trace file
272        writer::write_trace_zip(path, &state)?;
273
274        Ok(())
275    }
276
277    /// Stop tracing and discard the trace data.
278    ///
279    /// Use this when you don't need to save the trace (e.g., test passed).
280    ///
281    /// # Errors
282    ///
283    /// Returns an error if tracing is not active.
284    #[instrument(level = "info", skip(self))]
285    pub async fn stop_discard(&self) -> Result<(), ContextError> {
286        let mut state = self.state.write().await;
287
288        if !state.is_recording {
289            return Err(ContextError::Internal("Tracing is not active".to_string()));
290        }
291
292        info!("Stopping trace and discarding");
293
294        // Stop tracing on all sessions
295        for session_id in self.get_session_ids().await {
296            let _ = self
297                .connection
298                .send_command::<_, serde_json::Value>("Tracing.end", None::<()>, Some(&session_id))
299                .await;
300        }
301
302        // Clear state
303        state.is_recording = false;
304        state.actions.clear();
305        state.events.clear();
306        state.screenshots.clear();
307        state.snapshots.clear();
308        state.pending_requests.clear();
309        state.network_entries.clear();
310        state.har_pages.clear();
311        state.source_files.clear();
312
313        Ok(())
314    }
315
316    /// Start a new trace chunk.
317    ///
318    /// This is useful for long-running tests where you want to save
319    /// periodic snapshots.
320    ///
321    /// # Errors
322    ///
323    /// Returns an error if tracing is not active.
324    #[instrument(level = "debug", skip(self))]
325    pub async fn start_chunk(&self) -> Result<(), ContextError> {
326        let state = self.state.read().await;
327
328        if !state.is_recording {
329            return Err(ContextError::Internal("Tracing is not active".to_string()));
330        }
331
332        debug!("Starting new trace chunk");
333
334        // In a full implementation, this would rotate the trace data
335        // For now, we just continue recording
336
337        Ok(())
338    }
339
340    /// Stop the current trace chunk and save it.
341    ///
342    /// # Errors
343    ///
344    /// Returns an error if tracing is not active or saving fails.
345    #[instrument(level = "debug", skip(self), fields(path = %path.as_ref().display()))]
346    pub async fn stop_chunk(&self, path: impl AsRef<std::path::Path>) -> Result<(), ContextError> {
347        let path = path.as_ref();
348        let state = self.state.read().await;
349
350        if !state.is_recording {
351            return Err(ContextError::Internal("Tracing is not active".to_string()));
352        }
353
354        debug!("Stopping trace chunk and saving");
355
356        // Write current state to file
357        writer::write_trace_zip(path, &state)?;
358
359        // Note: In a full implementation, we would clear the current chunk
360        // and continue recording for the next chunk
361
362        Ok(())
363    }
364
365    /// Check if tracing is currently active.
366    pub async fn is_recording(&self) -> bool {
367        self.state.read().await.is_recording
368    }
369
370    /// Add a source file to include in the trace.
371    ///
372    /// Source files are shown in the Trace Viewer for debugging.
373    ///
374    /// # Example
375    ///
376    /// ```no_run
377    /// use viewpoint_core::Browser;
378    ///
379    /// # async fn example() -> Result<(), viewpoint_core::CoreError> {
380    /// # let browser = Browser::launch().headless(true).launch().await?;
381    /// # let context = browser.new_context().await?;
382    /// context.tracing().add_source_file(
383    ///     "tests/my_test.rs",
384    ///     "// test source code"
385    /// ).await;
386    /// # Ok(())
387    /// # }
388    pub async fn add_source_file(&self, path: impl Into<String>, content: impl Into<String>) {
389        let mut state = self.state.write().await;
390        state.source_files.push(SourceFileEntry {
391            path: path.into(),
392            content: content.into(),
393        });
394    }
395
396    /// Collect source files from a directory.
397    ///
398    /// This recursively adds all matching files from the directory.
399    ///
400    /// # Arguments
401    ///
402    /// * `dir` - Directory to scan
403    /// * `extensions` - File extensions to include (e.g., `["rs", "ts"]`)
404    ///
405    /// # Errors
406    ///
407    /// Returns an error if reading files fails.
408    pub async fn collect_sources(
409        &self,
410        dir: impl AsRef<std::path::Path>,
411        extensions: &[&str],
412    ) -> Result<(), ContextError> {
413        let files = sources::collect_sources_from_dir(dir.as_ref(), extensions)?;
414
415        let mut state = self.state.write().await;
416        for (path, content) in files {
417            state.source_files.push(SourceFileEntry { path, content });
418        }
419
420        Ok(())
421    }
422
423    /// Record an action in the trace.
424    ///
425    /// Returns a handle that must be used to complete or fail the action.
426    pub(crate) async fn record_action(
427        &self,
428        action_type: &str,
429        selector: Option<&str>,
430        page_id: Option<&str>,
431    ) -> ActionHandle<'_> {
432        let start_time = std::time::SystemTime::now()
433            .duration_since(std::time::UNIX_EPOCH)
434            .unwrap_or_default()
435            .as_secs_f64()
436            * 1000.0;
437
438        let action = ActionEntry {
439            action_type: action_type.to_string(),
440            selector: selector.map(ToString::to_string),
441            page_id: page_id.map(ToString::to_string),
442            start_time,
443            end_time: None,
444            result: None,
445            value: None,
446            url: None,
447            screenshot: None,
448            snapshot: None,
449        };
450
451        let mut state = self.state.write().await;
452        let index = state.actions.len();
453        state.actions.push(action);
454
455        ActionHandle {
456            tracing: self,
457            index,
458        }
459    }
460
461    /// Record a page being created.
462    pub(crate) async fn record_page(&self, page_id: &str, title: &str) {
463        let mut state = self.state.write().await;
464        let started_date_time = Utc::now().to_rfc3339();
465        let page = HarPage::new(page_id, title, &started_date_time);
466        state.har_pages.push(page);
467        state.current_page_id = Some(page_id.to_string());
468    }
469
470    /// Capture a screenshot and add it to the trace.
471    pub(crate) async fn capture_screenshot(
472        &self,
473        session_id: &str,
474        name: Option<&str>,
475    ) -> Result<(), ContextError> {
476        capture::capture_screenshot(&self.connection, &self.state, session_id, name).await
477    }
478
479    /// Capture a DOM snapshot and add it to the trace.
480    pub(crate) async fn capture_dom_snapshot(&self, session_id: &str) -> Result<(), ContextError> {
481        capture::capture_dom_snapshot(&self.connection, &self.state, session_id).await
482    }
483
484    /// Capture action context (screenshot + snapshot) if enabled.
485    pub(crate) async fn capture_action_context(
486        &self,
487        session_id: &str,
488        action_name: Option<&str>,
489    ) -> Result<(), ContextError> {
490        capture::capture_action_context(&self.connection, &self.state, session_id, action_name)
491            .await
492    }
493}
494
495/// Handle for tracking an action's duration in the trace.
496pub struct ActionHandle<'a> {
497    tracing: &'a Tracing,
498    index: usize,
499}
500
501impl ActionHandle<'_> {
502    /// Complete the action with success.
503    pub async fn complete(self, result: Option<serde_json::Value>) {
504        let end_time = std::time::SystemTime::now()
505            .duration_since(std::time::UNIX_EPOCH)
506            .unwrap_or_default()
507            .as_secs_f64()
508            * 1000.0;
509
510        let mut state = self.tracing.state.write().await;
511        if let Some(action) = state.actions.get_mut(self.index) {
512            action.end_time = Some(end_time);
513            action.result = result;
514        }
515    }
516
517    /// Complete the action with an error.
518    pub async fn fail(self, error: &str) {
519        let end_time = std::time::SystemTime::now()
520            .duration_since(std::time::UNIX_EPOCH)
521            .unwrap_or_default()
522            .as_secs_f64()
523            * 1000.0;
524
525        let mut state = self.tracing.state.write().await;
526        if let Some(action) = state.actions.get_mut(self.index) {
527            action.end_time = Some(end_time);
528            action.result = Some(serde_json::json!({ "error": error }));
529        }
530    }
531}