use std::fmt::Display;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use super::bundle::{TraceProblem, TraceRecordError};
use super::page::TracePage;
use super::recorder::{
start_trace, TraceCheckpointOptions, TraceFailure, TraceOptions, TraceRecorder, TraceResult,
TraceScreenshots, TraceStopOptions,
};
use super::schema::{TraceCheckpointReason, TraceMode};
use super::viewer::write_trace_viewer;
pub const TEST_TRACE_MODES: [&str; 4] = ["off", "on", "retain-on-failure", "on-first-retry"];
pub const TRACE_BUNDLE_SUFFIX: &str = ".bc-trace";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TraceSetting {
pub recorder_mode: &'static str,
pub retain_on_failure: bool,
}
pub fn resolve_trace_setting(
trace: &str,
attempt: u32,
) -> Result<Option<TraceSetting>, TraceRecordError> {
if !TEST_TRACE_MODES.contains(&trace) {
return Err(TraceRecordError::Invalid(format!(
"trace must be one of {}",
TEST_TRACE_MODES.join(", ")
)));
}
if trace == "off" || (trace == "on-first-retry" && attempt < 2) {
return Ok(None);
}
Ok(Some(TraceSetting {
recorder_mode: if trace == "on" {
TraceMode::CONTINUOUS
} else {
TraceMode::RETAIN_ON_FAILURE
},
retain_on_failure: trace != "on",
}))
}
pub fn trace_output_path(
artifacts_dir: impl AsRef<Path>,
safe_name: &str,
attempt: u32,
) -> PathBuf {
let suffix = if attempt > 1 {
format!(".attempt-{attempt}")
} else {
String::new()
};
artifacts_dir
.as_ref()
.join(format!("{safe_name}{suffix}{TRACE_BUNDLE_SUFFIX}"))
}
pub fn scenario_trace_options(output: impl Into<PathBuf>) -> TraceOptions {
let mut options = TraceOptions::new(output);
options.dom.mutations = Some(true);
options.screenshots = TraceScreenshots::OnlyOnFailure;
options
}
#[derive(Debug, Clone)]
pub struct ScenarioTrace {
pub recorder: TraceRecorder,
pub retain_on_failure: bool,
}
pub async fn start_scenario_trace(
page: Arc<dyn TracePage>,
trace: &str,
attempt: u32,
mut options: TraceOptions,
) -> Result<Option<ScenarioTrace>, TraceRecordError> {
let Some(setting) = resolve_trace_setting(trace, attempt)? else {
return Ok(None);
};
options.mode = setting.recorder_mode.to_string();
let recorder = start_trace(page, options).await?;
Ok(Some(ScenarioTrace {
recorder,
retain_on_failure: setting.retain_on_failure,
}))
}
pub async fn finish_scenario_trace(
started: Option<ScenarioTrace>,
error: Option<TraceFailure>,
) -> Result<Option<TraceResult>, TraceRecordError> {
let Some(started) = started else {
return Ok(None);
};
let reason = if error.is_some() {
TraceCheckpointReason::FAILURE
} else {
"final"
};
let checkpoint = started
.recorder
.checkpoint_with(
reason,
TraceCheckpointOptions {
actor: Some("runner".to_string()),
reason: Some(reason.to_string()),
},
)
.await;
let discard = started.retain_on_failure && error.is_none();
let mut stopped = started
.recorder
.stop_with(TraceStopOptions { discard, error })
.await?;
if let Err(checkpoint_error) = checkpoint {
stopped.problems.push(problem(format!(
"could not capture the {reason} checkpoint: {checkpoint_error}"
)));
}
if !discard {
if let Err(viewer_error) = write_trace_viewer(&stopped.path) {
stopped.problems.push(problem(format!(
"could not write the viewer: {viewer_error}"
)));
}
}
Ok(Some(stopped))
}
fn problem(detail: String) -> TraceProblem {
TraceProblem {
reason: None,
member: None,
detail: Some(detail),
}
}
#[derive(Debug)]
pub struct ScenarioRun<T, E> {
pub outcome: Result<T, E>,
pub trace: Result<Option<TraceResult>, TraceRecordError>,
}
pub async fn record_scenario<T, E, F, Fut>(
page: Arc<dyn TracePage>,
trace: &str,
attempt: u32,
options: TraceOptions,
work: F,
) -> Result<ScenarioRun<T, E>, TraceRecordError>
where
E: Display,
F: FnOnce(Option<TraceRecorder>) -> Fut,
Fut: Future<Output = Result<T, E>>,
{
let started = start_scenario_trace(page, trace, attempt, options).await?;
let outcome = work(started.as_ref().map(|started| started.recorder.clone())).await;
let error = outcome
.as_ref()
.err()
.map(|error| TraceFailure::new(error.to_string()));
let trace = finish_scenario_trace(started, error).await;
Ok(ScenarioRun { outcome, trace })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolves_settings_like_the_javascript_runner() {
assert_eq!(resolve_trace_setting("off", 1).unwrap(), None);
assert_eq!(resolve_trace_setting("on-first-retry", 1).unwrap(), None);
assert_eq!(
resolve_trace_setting("on-first-retry", 2).unwrap(),
Some(TraceSetting {
recorder_mode: TraceMode::RETAIN_ON_FAILURE,
retain_on_failure: true,
})
);
assert_eq!(
resolve_trace_setting("on", 1).unwrap(),
Some(TraceSetting {
recorder_mode: TraceMode::CONTINUOUS,
retain_on_failure: false,
})
);
assert!(resolve_trace_setting("sometimes", 1).is_err());
}
#[test]
fn names_retries_apart() {
assert_eq!(
trace_output_path("out", "login", 1),
Path::new("out").join("login.bc-trace")
);
assert_eq!(
trace_output_path("out", "login", 2),
Path::new("out").join("login.attempt-2.bc-trace")
);
}
}