viewpoint_core/context/trace/
tracing_manager.rs

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