Skip to main content

browser_commander/traces/
retention.rs

1//! Keep a run's trace only when the run failed (issue #108).
2//!
3//! JavaScript decides this in its test runner (`js/src/tests/tracing.js`):
4//! `trace: 'retain-on-failure'` records every attempt, and a passing attempt's
5//! bundle is removed when it stops. Python offers the same decision as the
6//! `traced()` context manager. Rust has no runner of its own either, so
7//! [`record_scenario`] wraps the work a caller wants explained:
8//!
9//! ```rust,no_run
10//! use std::sync::Arc;
11//!
12//! use browser_commander::traces::{record_scenario, scenario_trace_options, TracePage};
13//!
14//! # async fn run(page: Arc<dyn TracePage>) -> Result<(), Box<dyn std::error::Error>> {
15//! let run = record_scenario(
16//!     page,
17//!     "retain-on-failure",
18//!     1,
19//!     scenario_trace_options("artifacts/login.bc-trace"),
20//!     |_recorder| async { Ok::<_, std::io::Error>(()) },
21//! )
22//! .await?;
23//! // A passing run leaves no bundle; a failing one leaves it with its viewer.
24//! if let Some(trace) = run.trace? {
25//!     assert!(trace.discarded);
26//! }
27//! run.outcome?;
28//! # Ok(())
29//! # }
30//! ```
31//!
32//! The settings use the JavaScript runner's vocabulary, which is Playwright's,
33//! and a kept bundle gets the offline viewer written next to it.
34
35use 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
49/// Trace settings a run may ask for, in Playwright's vocabulary.
50pub const TEST_TRACE_MODES: [&str; 4] = ["off", "on", "retain-on-failure", "on-first-retry"];
51
52/// Suffix that marks a trace bundle directory.
53pub const TRACE_BUNDLE_SUFFIX: &str = ".bc-trace";
54
55/// How one attempt records, when it records at all.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct TraceSetting {
58    /// The [`TraceMode`] the recorder runs in.
59    pub recorder_mode: &'static str,
60    /// Whether a passing attempt's bundle is discarded.
61    pub retain_on_failure: bool,
62}
63
64/// Decide whether this attempt records, and in which recorder mode.
65///
66/// `attempt` is 1 for the first run and 2 for the first retry.
67///
68/// # Errors
69///
70/// Fails for a setting outside [`TEST_TRACE_MODES`].
71pub 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
94/// Where one attempt's trace bundle lives.
95pub 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
110/// The options a run is recorded with unless the caller changes them.
111///
112/// A run that fails halfway is exactly where ordered DOM mutations pay for
113/// themselves, so they are on; screenshots are taken only at the failure.
114pub 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/// A running attempt's trace.
122#[derive(Debug, Clone)]
123pub struct ScenarioTrace {
124    /// The recorder.
125    pub recorder: TraceRecorder,
126    /// Whether a passing attempt's bundle is discarded.
127    pub retain_on_failure: bool,
128}
129
130/// Start a trace for one attempt; `None` when this attempt does not record.
131///
132/// `options.mode` is replaced by the mode `trace` asks for.
133///
134/// # Errors
135///
136/// Fails for an unknown `trace` setting, or when the trace cannot start.
137pub 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
154/// Stop an attempt's trace, keeping it only when it is worth keeping.
155///
156/// It ends with a `failure` checkpoint when `error` is set and a `final` one
157/// otherwise. A kept bundle gets the offline viewer; a problem writing either
158/// is listed in the result rather than raised.
159///
160/// # Errors
161///
162/// Fails when the trace cannot be stopped.
163pub 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/// What [`record_scenario`] ran and recorded.
215#[derive(Debug)]
216pub struct ScenarioRun<T, E> {
217    /// The work's own result, unchanged.
218    pub outcome: Result<T, E>,
219    /// The stopped trace (discarded or kept), `None` when the attempt did not
220    /// record, or the error stopping it.
221    pub trace: Result<Option<TraceResult>, TraceRecordError>,
222}
223
224/// Record `work`, keeping the trace as the `trace` setting asks.
225///
226/// With `retain-on-failure` a failing `work` leaves a bundle (ending in a
227/// `failure` checkpoint, with its error recorded and the offline viewer
228/// written) and a passing one leaves nothing. `work` receives the recorder,
229/// or `None` when this attempt does not record, and its result is returned
230/// unchanged, as is the outcome of stopping the trace.
231///
232/// # Errors
233///
234/// Fails, without running `work`, for an unknown `trace` setting or when the
235/// trace cannot start.
236pub 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}