use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use serde::{Deserialize, Serialize};
use crate::command::Command;
use crate::doubles::Invocation;
use crate::error::{Error, Result};
use crate::result::{Outcome, ProcessResult};
use crate::runner::{JobRunner, ProcessRunner};
const CASSETTE_VERSION: u32 = 1;
#[derive(Debug, Serialize, Deserialize)]
struct Cassette {
version: u32,
entries: Vec<Entry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Entry {
program: String,
args: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
cwd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
stdin_digest: Option<u64>,
#[serde(default, skip_serializing_if = "is_false")]
has_stdin: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
env_names: Vec<String>,
stdout: String,
stderr: String,
code: Option<i32>,
#[serde(default, skip_serializing_if = "is_false")]
timed_out: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
signal: Option<i32>,
}
#[allow(clippy::trivially_copy_pass_by_ref)] fn is_false(b: &bool) -> bool {
!*b
}
fn write_cassette(path: &Path, json: &str) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW)
.open(path)?;
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
file.write_all(json.as_bytes())?;
Ok(())
}
#[cfg(not(unix))]
{
std::fs::write(path, json)
}
}
impl Entry {
fn from_parts(
invocation: &Invocation,
result: &ProcessResult<String>,
stdin_digest: Option<u64>,
) -> Self {
let mut env_names: Vec<String> = invocation
.envs
.iter()
.map(|(name, _value)| name.to_string_lossy().into_owned())
.collect();
env_names.sort();
env_names.dedup();
Self {
program: invocation.program.to_string_lossy().into_owned(),
args: invocation
.args
.iter()
.map(|a| a.to_string_lossy().into_owned())
.collect(),
cwd: invocation
.cwd
.as_ref()
.map(|c| c.to_string_lossy().into_owned()),
stdin_digest,
has_stdin: invocation.has_stdin,
env_names,
stdout: result.stdout().clone(),
stderr: result.stderr().to_owned(),
code: result.code(),
timed_out: result.timed_out(),
signal: match result.outcome() {
Outcome::Signalled(s) => s,
_ => None,
},
}
}
}
type Key = (String, Vec<String>, Option<String>, bool, Option<u64>);
fn stdin_digest_of(command: &Command) -> Option<u64> {
command
.stdin_source()
.filter(|s| !s.is_empty())
.map(|s| s.content_digest())
}
fn reject_unrecordable_stdin(command: &Command) -> Result<()> {
if command.stdin_source().is_some_and(|s| s.is_one_shot()) {
return Err(Error::Unsupported {
operation: "cassette record/replay with one-shot streaming stdin \
(from_reader/from_lines); use from_bytes/from_string/from_file"
.to_string(),
});
}
Ok(())
}
fn key_of(invocation: &Invocation, stdin_digest: Option<u64>) -> Key {
(
invocation.program.to_string_lossy().into_owned(),
invocation
.args
.iter()
.map(|a| a.to_string_lossy().into_owned())
.collect(),
invocation
.cwd
.as_ref()
.map(|c| c.to_string_lossy().into_owned()),
invocation.has_stdin,
stdin_digest,
)
}
fn key_of_entry(entry: &Entry) -> Key {
(
entry.program.clone(),
entry.args.clone(),
entry.cwd.clone(),
entry.has_stdin,
entry.stdin_digest,
)
}
#[derive(Debug)]
struct ReplaySlot {
entries: Vec<Entry>,
next: usize,
}
impl ReplaySlot {
fn play(&mut self) -> &Entry {
let index = self.next.min(self.entries.len() - 1);
self.next = self.next.saturating_add(1);
&self.entries[index]
}
}
enum Mode<R> {
Record {
inner: R,
path: PathBuf,
recorded: Mutex<Vec<Entry>>,
dirty: AtomicBool,
},
Replay {
slots: Mutex<HashMap<Key, ReplaySlot>>,
},
}
pub struct RecordReplayRunner<R: ProcessRunner = JobRunner> {
mode: Mode<R>,
}
impl<R: ProcessRunner> RecordReplayRunner<R> {
pub fn record(path: impl Into<PathBuf>, inner: R) -> Self {
Self {
mode: Mode::Record {
inner,
path: path.into(),
recorded: Mutex::new(Vec::new()),
dirty: AtomicBool::new(false),
},
}
}
pub fn save(&self) -> Result<()> {
let Mode::Record {
path,
recorded,
dirty,
..
} = &self.mode
else {
return Ok(());
};
let entries = recorded.lock().expect("cassette mutex poisoned");
let cassette = Cassette {
version: CASSETTE_VERSION,
entries: entries.clone(),
};
let json = serde_json::to_string_pretty(&cassette)
.map_err(|e| Error::Io(std::io::Error::from(e)))?;
write_cassette(path, &json).map_err(Error::Io)?;
dirty.store(false, Ordering::SeqCst);
Ok(())
}
}
fn validate_entry_outcome(entry: &Entry) -> Result<()> {
let indicators = usize::from(entry.code.is_some())
+ usize::from(entry.timed_out)
+ usize::from(entry.signal.is_some());
if indicators > 1 {
return Err(Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"cassette entry for `{}` has a contradictory outcome: at most one of \
`code` (exited), `timed_out`, or `signal` (signalled) may be set — found {indicators}",
entry.program
),
)));
}
Ok(())
}
impl RecordReplayRunner<JobRunner> {
pub fn replay(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
const MAX_CASSETTE_BYTES: u64 = 64 << 20; if let Ok(meta) = std::fs::metadata(path)
&& meta.len() > MAX_CASSETTE_BYTES
{
return Err(Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"cassette is {} bytes, over the {MAX_CASSETTE_BYTES}-byte limit",
meta.len()
),
)));
}
let text = std::fs::read_to_string(path).map_err(Error::Io)?;
let cassette: Cassette =
serde_json::from_str(&text).map_err(|e| Error::Io(std::io::Error::from(e)))?;
if cassette.version != CASSETTE_VERSION {
return Err(Error::Io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"cassette version {} is not supported (this build reads version {CASSETTE_VERSION})",
cassette.version
),
)));
}
let mut slots: HashMap<Key, ReplaySlot> = HashMap::new();
for entry in cassette.entries {
validate_entry_outcome(&entry)?;
slots
.entry(key_of_entry(&entry))
.or_insert_with(|| ReplaySlot {
entries: Vec::new(),
next: 0,
})
.entries
.push(entry);
}
Ok(Self {
mode: Mode::Replay {
slots: Mutex::new(slots),
},
})
}
}
#[async_trait::async_trait]
impl<R: ProcessRunner> ProcessRunner for RecordReplayRunner<R> {
async fn output_string(&self, command: &Command) -> Result<ProcessResult<String>> {
reject_unrecordable_stdin(command)?;
match &self.mode {
Mode::Record {
inner,
recorded,
dirty,
..
} => {
let result = inner.output_string(command).await?;
let invocation = Invocation::from_command(command);
let stdin_digest = stdin_digest_of(command);
let mut entries = recorded.lock().expect("cassette mutex poisoned");
entries.push(Entry::from_parts(&invocation, &result, stdin_digest));
dirty.store(true, Ordering::SeqCst);
Ok(result)
}
Mode::Replay { slots } => {
let invocation = Invocation::from_command(command);
let stdin_digest = stdin_digest_of(command);
let entry = {
let mut slots = slots.lock().expect("cassette mutex poisoned");
let Some(slot) = slots.get_mut(&key_of(&invocation, stdin_digest)) else {
return Err(Error::CassetteMiss {
program: command.program_name(),
});
};
slot.play().clone()
};
crate::doubles::replay_line_handlers(command, &entry.stdout, &entry.stderr);
let outcome = match (entry.code, entry.timed_out) {
(_, true) => Outcome::TimedOut,
(Some(code), false) => Outcome::Exited(code),
(None, false) => Outcome::Signalled(entry.signal),
};
Ok(ProcessResult::new(
entry.program,
entry.stdout,
entry.stderr,
outcome,
command.configured_timeout(),
)
.with_ok_codes(command.ok_codes_vec()))
}
}
}
}
impl<R: ProcessRunner> std::fmt::Debug for RecordReplayRunner<R> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.mode {
Mode::Record {
path,
recorded,
dirty,
..
} => f
.debug_struct("RecordReplayRunner::Record")
.field("path", path)
.field(
"recorded",
&recorded.lock().expect("cassette mutex poisoned").len(),
)
.field("dirty", &dirty.load(Ordering::SeqCst))
.finish_non_exhaustive(),
Mode::Replay { slots } => f
.debug_struct("RecordReplayRunner::Replay")
.field(
"keys",
&slots.lock().expect("cassette mutex poisoned").len(),
)
.finish_non_exhaustive(),
}
}
}
impl<R: ProcessRunner> Drop for RecordReplayRunner<R> {
fn drop(&mut self) {
if let Mode::Record { dirty, .. } = &self.mode
&& dirty.load(Ordering::SeqCst)
&& !std::thread::panicking()
{
let _ = self.save();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::doubles::{Reply, ScriptedRunner};
use crate::result::Outcome;
use crate::runner::ProcessRunnerExt;
use std::time::Duration;
fn scripted() -> ScriptedRunner {
ScriptedRunner::new()
.on(["tool", "--version"], Reply::ok("tool 1.2.3\n"))
.on(["tool", "fail"], Reply::fail(7, "boom"))
}
fn temp_cassette() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("create temp dir");
let path = dir.path().join("cassette.json");
(dir, path)
}
#[cfg(unix)]
#[test]
fn write_cassette_refuses_to_follow_a_symlink() {
let dir = tempfile::tempdir().expect("temp dir");
let target = dir.path().join("victim.txt");
std::fs::write(&target, "original").expect("seed victim");
let link = dir.path().join("cassette.json");
std::os::unix::fs::symlink(&target, &link).expect("create symlink");
let err = write_cassette(&link, "{\"secret\":true}")
.expect_err("writing through a symlink must fail (O_NOFOLLOW)");
assert_eq!(
err.raw_os_error(),
Some(libc::ELOOP),
"O_NOFOLLOW on a symlink yields ELOOP, got {err:?}"
);
assert_eq!(
std::fs::read_to_string(&target).expect("read victim"),
"original",
"the victim file must be untouched"
);
}
#[tokio::test]
async fn round_trip_is_identical() {
let (_dir, path) = temp_cassette();
let recorder = RecordReplayRunner::record(&path, scripted());
let ok = recorder
.output_string(&Command::new("tool").arg("--version"))
.await
.expect("record ok run");
let fail = recorder
.output_string(&Command::new("tool").arg("fail"))
.await
.expect("record failing run (non-zero exit is a result, not Err)");
recorder.save().expect("save cassette");
let replayer = RecordReplayRunner::replay(&path).expect("load cassette");
let ok2 = replayer
.output_string(&Command::new("tool").arg("--version"))
.await
.expect("replay ok run");
let fail2 = replayer
.output_string(&Command::new("tool").arg("fail"))
.await
.expect("replay failing run");
assert_eq!(ok, ok2, "replay must be identical to the recording");
assert_eq!(fail, fail2);
assert_eq!(fail2.code(), Some(7));
assert_eq!(fail2.stderr(), "boom");
}
#[tokio::test]
async fn duplicate_key_plays_in_order_then_repeats_last() {
let (_dir, path) = temp_cassette();
let json = serde_json::json!({
"version": 1,
"entries": [
{
"program": "git", "args": ["head"],
"stdout": "aaa", "stderr": "", "code": 0
},
{
"program": "git", "args": ["head"],
"stdout": "bbb", "stderr": "", "code": 0
}
]
});
std::fs::write(&path, serde_json::to_string_pretty(&json).unwrap()).unwrap();
let cmd = Command::new("git").arg("head");
let replayer = RecordReplayRunner::replay(&path).expect("load cassette");
let first = replayer.run(&cmd).await.expect("first replay");
let second = replayer.run(&cmd).await.expect("second replay");
let third = replayer.run(&cmd).await.expect("third replay repeats last");
assert_eq!(first, "aaa");
assert_eq!(second, "bbb");
assert_eq!(third, "bbb", "exhausted key must repeat the last entry");
}
#[tokio::test]
async fn replay_rejects_an_entry_with_contradictory_outcome() {
let (_dir, path) = temp_cassette();
let json = serde_json::json!({
"version": 1,
"entries": [
{ "program": "x", "args": [], "stdout": "", "stderr": "", "code": 0, "signal": 9 }
]
});
std::fs::write(&path, serde_json::to_string_pretty(&json).unwrap()).unwrap();
let err = RecordReplayRunner::replay(&path)
.expect_err("a contradictory outcome must be rejected");
assert!(
matches!(&err, Error::Io(e) if e.kind() == std::io::ErrorKind::InvalidData),
"got {err:?}"
);
}
#[tokio::test]
async fn replay_miss_is_a_distinct_cassette_miss_error() {
let (_dir, path) = temp_cassette();
let recorder = RecordReplayRunner::record(&path, scripted());
let _ = recorder
.output_string(&Command::new("tool").arg("--version"))
.await
.expect("record");
recorder.save().expect("save");
let replayer = RecordReplayRunner::replay(&path).expect("load");
let err = replayer
.output_string(&Command::new("tool").arg("--other"))
.await
.expect_err("an unrecorded invocation must not be served");
match &err {
Error::CassetteMiss { program } => assert_eq!(program, "tool"),
other => panic!("expected Error::CassetteMiss, got {other:?}"),
}
assert!(
!err.is_not_found(),
"a cassette miss must not read as not-found: {err:?}"
);
}
#[tokio::test]
async fn replay_invokes_line_handlers() {
let (_dir, path) = temp_cassette();
let recorder = RecordReplayRunner::record(&path, scripted());
let _ = recorder
.output_string(&Command::new("tool").arg("--version"))
.await
.expect("record");
recorder.save().expect("save");
let seen = std::sync::Arc::new(Mutex::new(Vec::new()));
let replayer = RecordReplayRunner::replay(&path).expect("load");
let cmd = Command::new("tool").arg("--version").on_stdout_line({
let seen = seen.clone();
move |l| seen.lock().unwrap().push(l.to_owned())
});
let _ = replayer.output_string(&cmd).await.expect("replay");
assert_eq!(
*seen.lock().unwrap(),
["tool 1.2.3"],
"replay must invoke the command's line handler"
);
}
#[tokio::test]
async fn stdin_content_is_part_of_the_match_key() {
let (_dir, path) = temp_cassette();
let inner = ScriptedRunner::new()
.on_sequence(["tool"], [Reply::ok("out-A\n"), Reply::ok("out-B\n")]);
let recorder = RecordReplayRunner::record(&path, inner);
let _ = recorder
.output_string(&Command::new("tool").stdin(crate::Stdin::from_string("A")))
.await
.expect("record A");
let _ = recorder
.output_string(&Command::new("tool").stdin(crate::Stdin::from_string("B")))
.await
.expect("record B");
recorder.save().expect("save");
let replayer = RecordReplayRunner::replay(&path).expect("load");
let b = replayer
.output_string(&Command::new("tool").stdin(crate::Stdin::from_string("B")))
.await
.expect("replay B");
assert_eq!(
b.stdout(),
"out-B\n",
"stdin B must replay its own recording"
);
let a = replayer
.output_string(&Command::new("tool").stdin(crate::Stdin::from_string("A")))
.await
.expect("replay A");
assert_eq!(
a.stdout(),
"out-A\n",
"stdin A must replay its own recording"
);
}
#[tokio::test]
async fn one_shot_streaming_stdin_is_rejected_in_both_modes() {
let (_dir, path) = temp_cassette();
let inner = ScriptedRunner::new().fallback(Reply::ok("out\n"));
let recorder = RecordReplayRunner::record(&path, inner);
let err = recorder
.output_string(&Command::new("tool").stdin(crate::Stdin::from_reader(&b"payload"[..])))
.await
.expect_err("record must reject a one-shot streaming stdin");
assert!(matches!(err, Error::Unsupported { .. }), "got {err:?}");
let _ = recorder
.output_string(&Command::new("tool"))
.await
.expect("record a replayable entry");
recorder.save().expect("save");
let replayer = RecordReplayRunner::replay(&path).expect("load");
let err = replayer
.output_string(&Command::new("tool").stdin(crate::Stdin::from_reader(&b"payload"[..])))
.await
.expect_err("replay must reject a one-shot streaming stdin");
assert!(matches!(err, Error::Unsupported { .. }), "got {err:?}");
}
#[tokio::test]
async fn no_stdin_replay_does_not_match_a_stdin_recorded_entry() {
let (_dir, path) = temp_cassette();
let recorder =
RecordReplayRunner::record(&path, ScriptedRunner::new().fallback(Reply::ok("out\n")));
let _ = recorder
.output_string(&Command::new("tool").stdin(crate::Stdin::from_string("input")))
.await
.expect("record with stdin");
recorder.save().expect("save");
let replayer = RecordReplayRunner::replay(&path).expect("load");
let err = replayer
.output_string(&Command::new("tool"))
.await
.expect_err("a no-stdin call must not match a stdin-recorded entry");
assert!(matches!(err, Error::CassetteMiss { .. }), "got {err:?}");
}
#[tokio::test]
async fn replayed_timeout_carries_the_commands_deadline() {
let (_dir, path) = temp_cassette();
let recorder = RecordReplayRunner::record(
&path,
ScriptedRunner::new().on(["tool", "slow"], Reply::timeout()),
);
let _ = recorder
.output_string(&Command::new("tool").arg("slow"))
.await
.expect("a captured timeout is a result, not an Err");
recorder.save().expect("save");
let replayer = RecordReplayRunner::replay(&path).expect("load");
let err = replayer
.run(
&Command::new("tool")
.arg("slow")
.timeout(Duration::from_secs(7)),
)
.await
.expect_err("run() raises the captured timeout");
match err {
Error::Timeout { timeout, .. } => assert_eq!(timeout, Duration::from_secs(7)),
other => panic!("expected Error::Timeout, got {other:?}"),
}
}
#[tokio::test]
async fn env_values_never_reach_the_file() {
let (_dir, path) = temp_cassette();
let recorder =
RecordReplayRunner::record(&path, ScriptedRunner::new().fallback(Reply::ok("done")));
let _ = recorder
.output_string(
&Command::new("tool")
.env("API_TOKEN", "hunter2-very-secret")
.env("MODE", "fast"),
)
.await
.expect("record");
recorder.save().expect("save");
let json = std::fs::read_to_string(&path).expect("read cassette");
assert!(json.contains("API_TOKEN"), "names are stored: {json}");
assert!(json.contains("MODE"));
assert!(
!json.contains("hunter2-very-secret") && !json.contains("fast"),
"values must never be written: {json}"
);
let replayer = RecordReplayRunner::replay(&path).expect("load");
let out = replayer
.run(&Command::new("tool"))
.await
.expect("env is not part of the match key");
assert_eq!(out, "done");
}
#[tokio::test]
async fn signal_number_survives_round_trip() {
let (_dir, path) = temp_cassette();
let json = r#"{"version":1,"entries":[{"program":"tool","args":[],"stdout":"","stderr":"","code":null,"signal":9}]}"#;
std::fs::write(&path, json).expect("write cassette");
let replayer = RecordReplayRunner::replay(&path).expect("load cassette");
let result = replayer
.output_string(&Command::new("tool"))
.await
.expect("replay");
assert_eq!(result.outcome(), Outcome::Signalled(Some(9)));
}
#[tokio::test]
async fn cassette_without_signal_field_loads_as_signalled_none() {
let (_dir, path) = temp_cassette();
let json = r#"{"version":1,"entries":[{"program":"tool","args":[],"stdout":"","stderr":"","code":null}]}"#;
std::fs::write(&path, json).expect("write cassette");
let replayer = RecordReplayRunner::replay(&path).expect("load cassette");
let result = replayer
.output_string(&Command::new("tool"))
.await
.expect("replay");
assert_eq!(result.outcome(), Outcome::Signalled(None));
}
#[tokio::test]
async fn load_errors_are_typed_io() {
let (_dir, path) = temp_cassette();
match RecordReplayRunner::replay(&path) {
Err(Error::Io(e)) => assert_eq!(e.kind(), std::io::ErrorKind::NotFound),
other => panic!("expected Io(NotFound), got {other:?}"),
}
std::fs::write(&path, "{ not json").unwrap();
match RecordReplayRunner::replay(&path) {
Err(Error::Io(e)) => assert_eq!(e.kind(), std::io::ErrorKind::InvalidData),
other => panic!("expected Io(InvalidData), got {other:?}"),
}
std::fs::write(&path, r#"{ "version": 99, "entries": [] }"#).unwrap();
match RecordReplayRunner::replay(&path) {
Err(Error::Io(e)) => {
assert_eq!(e.kind(), std::io::ErrorKind::InvalidData);
assert!(e.to_string().contains("version 99"), "got: {e}");
}
other => panic!("expected Io(InvalidData), got {other:?}"),
}
}
#[tokio::test]
async fn drop_without_save_flushes_best_effort() {
let (_dir, path) = temp_cassette();
{
let recorder = RecordReplayRunner::record(&path, scripted());
let _ = recorder
.output_string(&Command::new("tool").arg("--version"))
.await
.expect("record");
}
let replayer = RecordReplayRunner::replay(&path).expect("dropped recorder left a cassette");
let out = replayer
.run(&Command::new("tool").arg("--version"))
.await
.expect("replay after drop-flush");
assert_eq!(out, "tool 1.2.3");
}
#[tokio::test]
async fn cwd_is_part_of_the_match_key() {
let (_dir, path) = temp_cassette();
let recorder =
RecordReplayRunner::record(&path, ScriptedRunner::new().fallback(Reply::ok("from-a")));
let _ = recorder
.output_string(&Command::new("tool").current_dir("dir-a"))
.await
.expect("record in dir-a");
recorder.save().expect("save");
let replayer = RecordReplayRunner::replay(&path).expect("load");
let err = replayer
.output_string(&Command::new("tool").current_dir("dir-b"))
.await
.expect_err("a different cwd is a different invocation");
assert!(matches!(err, Error::CassetteMiss { .. }), "got {err:?}");
let err = replayer
.output_string(&Command::new("tool"))
.await
.expect_err("a missing cwd is a different invocation too");
assert!(matches!(err, Error::CassetteMiss { .. }), "got {err:?}");
let out = replayer
.run(&Command::new("tool").current_dir("dir-a"))
.await
.expect("the recorded cwd matches");
assert_eq!(out, "from-a");
}
#[cfg(unix)]
#[tokio::test]
async fn cassette_file_is_written_owner_only() {
use std::os::unix::fs::PermissionsExt;
let (_dir, path) = temp_cassette();
let recorder = RecordReplayRunner::record(&path, scripted());
let _ = recorder
.output_string(&Command::new("tool").arg("--version"))
.await
.expect("record");
recorder.save().expect("save");
let mode = std::fs::metadata(&path)
.expect("stat cassette")
.permissions()
.mode();
assert_eq!(
mode & 0o777,
0o600,
"cassette must be owner-only, got {:o}",
mode & 0o777
);
}
#[tokio::test]
async fn drop_while_unwinding_does_not_persist_a_surprise_cassette() {
let (_dir, path) = temp_cassette();
let recorder = RecordReplayRunner::record(&path, scripted());
let _ = recorder
.output_string(&Command::new("tool").arg("--version"))
.await
.expect("record (now dirty, unsaved)");
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
let _hold = recorder;
panic!("boom mid-recording");
}));
assert!(outcome.is_err(), "the scope must have panicked");
assert!(
!path.exists(),
"a recorder dropped during unwind must not persist a cassette: {path:?}"
);
}
#[tokio::test]
async fn save_then_record_more_then_drop_flushes_the_late_runs() {
let (_dir, path) = temp_cassette();
{
let recorder = RecordReplayRunner::record(&path, scripted());
let _ = recorder
.output_string(&Command::new("tool").arg("--version"))
.await
.expect("record first");
recorder.save().expect("first save");
let _ = recorder
.output_string(&Command::new("tool").arg("fail"))
.await
.expect("record second");
}
let replayer = RecordReplayRunner::replay(&path).expect("load");
let result = replayer
.output_string(&Command::new("tool").arg("fail"))
.await
.expect("the post-save run was flushed by drop");
assert_eq!(result.code(), Some(7));
}
#[tokio::test]
async fn non_utf8_args_are_recorded_lossily_not_fatally() {
#[cfg(unix)]
let bad = {
use std::os::unix::ffi::OsStringExt;
std::ffi::OsString::from_vec(vec![b'a', 0xFF, b'b'])
};
#[cfg(windows)]
let bad = {
use std::os::windows::ffi::OsStringExt;
std::ffi::OsString::from_wide(&[0x61, 0xD800, 0x62])
};
let (_dir, path) = temp_cassette();
let recorder =
RecordReplayRunner::record(&path, ScriptedRunner::new().fallback(Reply::ok("ok")));
let cmd = Command::new("tool").arg(&bad);
let _ = recorder.output_string(&cmd).await.expect("record lossily");
recorder.save().expect("save");
let replayer = RecordReplayRunner::replay(&path).expect("load");
let out = replayer.run(&cmd).await.expect("replay matches lossily");
assert_eq!(out, "ok");
}
}