browser_commander/traces/
retention.rs1use std::fmt::Display;
36use std::future::Future;
37use std::path::{Path, PathBuf};
38use std::sync::Arc;
39
40use super::bundle::{TraceProblem, TraceRecordError};
41use super::page::TracePage;
42use super::recorder::{
43 start_trace, TraceCheckpointOptions, TraceFailure, TraceOptions, TraceRecorder, TraceResult,
44 TraceScreenshots, TraceStopOptions,
45};
46use super::schema::{TraceCheckpointReason, TraceMode};
47use super::viewer::write_trace_viewer;
48
49pub const TEST_TRACE_MODES: [&str; 4] = ["off", "on", "retain-on-failure", "on-first-retry"];
51
52pub const TRACE_BUNDLE_SUFFIX: &str = ".bc-trace";
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct TraceSetting {
58 pub recorder_mode: &'static str,
60 pub retain_on_failure: bool,
62}
63
64pub fn resolve_trace_setting(
72 trace: &str,
73 attempt: u32,
74) -> Result<Option<TraceSetting>, TraceRecordError> {
75 if !TEST_TRACE_MODES.contains(&trace) {
76 return Err(TraceRecordError::Invalid(format!(
77 "trace must be one of {}",
78 TEST_TRACE_MODES.join(", ")
79 )));
80 }
81 if trace == "off" || (trace == "on-first-retry" && attempt < 2) {
82 return Ok(None);
83 }
84 Ok(Some(TraceSetting {
85 recorder_mode: if trace == "on" {
86 TraceMode::CONTINUOUS
87 } else {
88 TraceMode::RETAIN_ON_FAILURE
89 },
90 retain_on_failure: trace != "on",
91 }))
92}
93
94pub fn trace_output_path(
96 artifacts_dir: impl AsRef<Path>,
97 safe_name: &str,
98 attempt: u32,
99) -> PathBuf {
100 let suffix = if attempt > 1 {
101 format!(".attempt-{attempt}")
102 } else {
103 String::new()
104 };
105 artifacts_dir
106 .as_ref()
107 .join(format!("{safe_name}{suffix}{TRACE_BUNDLE_SUFFIX}"))
108}
109
110pub fn scenario_trace_options(output: impl Into<PathBuf>) -> TraceOptions {
115 let mut options = TraceOptions::new(output);
116 options.dom.mutations = Some(true);
117 options.screenshots = TraceScreenshots::OnlyOnFailure;
118 options
119}
120
121#[derive(Debug, Clone)]
123pub struct ScenarioTrace {
124 pub recorder: TraceRecorder,
126 pub retain_on_failure: bool,
128}
129
130pub async fn start_scenario_trace(
138 page: Arc<dyn TracePage>,
139 trace: &str,
140 attempt: u32,
141 mut options: TraceOptions,
142) -> Result<Option<ScenarioTrace>, TraceRecordError> {
143 let Some(setting) = resolve_trace_setting(trace, attempt)? else {
144 return Ok(None);
145 };
146 options.mode = setting.recorder_mode.to_string();
147 let recorder = start_trace(page, options).await?;
148 Ok(Some(ScenarioTrace {
149 recorder,
150 retain_on_failure: setting.retain_on_failure,
151 }))
152}
153
154pub async fn finish_scenario_trace(
164 started: Option<ScenarioTrace>,
165 error: Option<TraceFailure>,
166) -> Result<Option<TraceResult>, TraceRecordError> {
167 let Some(started) = started else {
168 return Ok(None);
169 };
170 let reason = if error.is_some() {
171 TraceCheckpointReason::FAILURE
172 } else {
173 "final"
174 };
175 let checkpoint = started
176 .recorder
177 .checkpoint_with(
178 reason,
179 TraceCheckpointOptions {
180 actor: Some("runner".to_string()),
181 reason: Some(reason.to_string()),
182 },
183 )
184 .await;
185
186 let discard = started.retain_on_failure && error.is_none();
187 let mut stopped = started
188 .recorder
189 .stop_with(TraceStopOptions { discard, error })
190 .await?;
191 if let Err(checkpoint_error) = checkpoint {
192 stopped.problems.push(problem(format!(
193 "could not capture the {reason} checkpoint: {checkpoint_error}"
194 )));
195 }
196 if !discard {
197 if let Err(viewer_error) = write_trace_viewer(&stopped.path) {
198 stopped.problems.push(problem(format!(
199 "could not write the viewer: {viewer_error}"
200 )));
201 }
202 }
203 Ok(Some(stopped))
204}
205
206fn problem(detail: String) -> TraceProblem {
207 TraceProblem {
208 reason: None,
209 member: None,
210 detail: Some(detail),
211 }
212}
213
214#[derive(Debug)]
216pub struct ScenarioRun<T, E> {
217 pub outcome: Result<T, E>,
219 pub trace: Result<Option<TraceResult>, TraceRecordError>,
222}
223
224pub async fn record_scenario<T, E, F, Fut>(
237 page: Arc<dyn TracePage>,
238 trace: &str,
239 attempt: u32,
240 options: TraceOptions,
241 work: F,
242) -> Result<ScenarioRun<T, E>, TraceRecordError>
243where
244 E: Display,
245 F: FnOnce(Option<TraceRecorder>) -> Fut,
246 Fut: Future<Output = Result<T, E>>,
247{
248 let started = start_scenario_trace(page, trace, attempt, options).await?;
249 let outcome = work(started.as_ref().map(|started| started.recorder.clone())).await;
250 let error = outcome
251 .as_ref()
252 .err()
253 .map(|error| TraceFailure::new(error.to_string()));
254 let trace = finish_scenario_trace(started, error).await;
255 Ok(ScenarioRun { outcome, trace })
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 #[test]
263 fn resolves_settings_like_the_javascript_runner() {
264 assert_eq!(resolve_trace_setting("off", 1).unwrap(), None);
265 assert_eq!(resolve_trace_setting("on-first-retry", 1).unwrap(), None);
266 assert_eq!(
267 resolve_trace_setting("on-first-retry", 2).unwrap(),
268 Some(TraceSetting {
269 recorder_mode: TraceMode::RETAIN_ON_FAILURE,
270 retain_on_failure: true,
271 })
272 );
273 assert_eq!(
274 resolve_trace_setting("on", 1).unwrap(),
275 Some(TraceSetting {
276 recorder_mode: TraceMode::CONTINUOUS,
277 retain_on_failure: false,
278 })
279 );
280 assert!(resolve_trace_setting("sometimes", 1).is_err());
281 }
282
283 #[test]
284 fn names_retries_apart() {
285 assert_eq!(
286 trace_output_path("out", "login", 1),
287 Path::new("out").join("login.bc-trace")
288 );
289 assert_eq!(
290 trace_output_path("out", "login", 2),
291 Path::new("out").join("login.attempt-2.bc-trace")
292 );
293 }
294}