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}