use std::collections::HashMap;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use clap::Command;
use standout::cli::{
App, ArtifactDestination, ArtifactRun, ExitStatus, RunErrorKind, RunResult, SuccessKind,
};
use standout_input::env::{MockClipboard, MockStdin};
use standout_input::{
reset_default_clipboard_reader, reset_default_prompt_responder, reset_default_stdin_reader,
set_default_clipboard_reader, set_default_prompt_responder, set_default_stdin_reader,
PromptResponder,
};
use standout_render::{
reset_environment_detectors, set_ambiguous_width_detector, set_color_capability_detector,
set_terminal_width_detector, set_tty_detector, AmbiguousWidth, OutputMode,
};
use tempfile::TempDir;
pub use serial_test::serial;
#[derive(Debug, Clone)]
enum StdinMode {
Inherit,
Piped(String),
Interactive,
}
#[must_use = "TestHarness is inert until you call run(...)"]
pub struct TestHarness {
env_set: HashMap<String, String>,
env_remove: Vec<String>,
cwd: Option<PathBuf>,
tempdir: Option<TempDir>,
fixtures: Vec<(PathBuf, Vec<u8>)>,
terminal_width: Option<Option<usize>>,
ambiguous_width: Option<AmbiguousWidth>,
is_tty: Option<bool>,
color_capable: Option<bool>,
output_mode: Option<OutputMode>,
output_flag_name: String,
stdin: StdinMode,
clipboard: Option<String>,
prompts: Option<Arc<dyn PromptResponder>>,
}
impl TestHarness {
pub fn new() -> Self {
Self {
env_set: HashMap::new(),
env_remove: Vec::new(),
cwd: None,
tempdir: None,
fixtures: Vec::new(),
terminal_width: None,
ambiguous_width: None,
is_tty: None,
color_capable: None,
output_mode: None,
output_flag_name: "output".to_string(),
stdin: StdinMode::Inherit,
clipboard: None,
prompts: None,
}
}
pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.env_set.insert(key.into(), value.into());
self
}
pub fn env_remove(mut self, key: impl Into<String>) -> Self {
self.env_remove.push(key.into());
self
}
pub fn terminal_width(mut self, cols: usize) -> Self {
self.terminal_width = Some(Some(cols));
self
}
pub fn no_terminal_width(mut self) -> Self {
self.terminal_width = Some(None);
self
}
pub fn ambiguous_width(mut self, policy: AmbiguousWidth) -> Self {
self.ambiguous_width = Some(policy);
self
}
pub fn is_tty(mut self) -> Self {
self.is_tty = Some(true);
self
}
pub fn no_tty(mut self) -> Self {
self.is_tty = Some(false);
self
}
pub fn with_color(mut self) -> Self {
self.color_capable = Some(true);
self
}
pub fn no_color(mut self) -> Self {
self.color_capable = Some(false);
self
}
pub fn output_mode(mut self, mode: OutputMode) -> Self {
self.output_mode = Some(mode);
self
}
pub fn output_flag_name(mut self, name: impl Into<String>) -> Self {
self.output_flag_name = name.into();
self
}
pub fn text_output(self) -> Self {
self.output_mode(OutputMode::Text)
}
pub fn piped_stdin(mut self, content: impl Into<String>) -> Self {
self.stdin = StdinMode::Piped(content.into());
self
}
pub fn interactive_stdin(mut self) -> Self {
self.stdin = StdinMode::Interactive;
self
}
pub fn clipboard(mut self, content: impl Into<String>) -> Self {
self.clipboard = Some(content.into());
self
}
pub fn prompts(mut self, responder: Arc<dyn PromptResponder>) -> Self {
self.prompts = Some(responder);
self
}
pub fn cwd(mut self, path: impl Into<PathBuf>) -> Self {
self.cwd = Some(path.into());
self
}
pub fn fixture(mut self, path: impl AsRef<Path>, content: impl Into<String>) -> Self {
let path = validate_fixture_path(path.as_ref());
self.fixtures.push((path, content.into().into_bytes()));
self.ensure_tempdir();
self
}
pub fn fixture_bytes(mut self, path: impl AsRef<Path>, content: impl Into<Vec<u8>>) -> Self {
let path = validate_fixture_path(path.as_ref());
self.fixtures.push((path, content.into()));
self.ensure_tempdir();
self
}
pub fn tempdir(&self) -> Option<&Path> {
self.tempdir.as_ref().map(|t| t.path())
}
fn ensure_tempdir(&mut self) {
if self.tempdir.is_none() {
self.tempdir =
Some(TempDir::new().expect("TestHarness: failed to create tempdir for fixtures"));
}
}
pub fn run<I, T>(mut self, app: &App, cmd: Command, args: I) -> TestResult
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
let mut restore = RestoreState::default();
if let Some(dir) = self.tempdir.as_ref() {
for (rel, content) in &self.fixtures {
let abs = dir.path().join(rel);
if let Some(parent) = abs.parent() {
std::fs::create_dir_all(parent)
.expect("TestHarness: failed to create fixture parent dir");
}
std::fs::write(&abs, content).expect("TestHarness: failed to write fixture file");
}
}
let cwd_target = self
.cwd
.clone()
.or_else(|| self.tempdir.as_ref().map(|d| d.path().to_path_buf()));
if let Some(target) = cwd_target {
restore.original_cwd = std::env::current_dir().ok();
std::env::set_current_dir(&target)
.expect("TestHarness: failed to change working directory");
}
for (k, v) in &self.env_set {
restore
.env_originals
.entry(k.clone())
.or_insert_with(|| std::env::var(k).ok());
std::env::set_var(k, v);
}
for k in &self.env_remove {
restore
.env_originals
.entry(k.clone())
.or_insert_with(|| std::env::var(k).ok());
std::env::remove_var(k);
}
if let Some(w) = self.terminal_width {
static WIDTH_SLOT: std::sync::OnceLock<std::sync::Mutex<Option<usize>>> =
std::sync::OnceLock::new();
let slot = WIDTH_SLOT.get_or_init(|| std::sync::Mutex::new(None));
*slot.lock().unwrap() = w;
set_terminal_width_detector(|| {
*WIDTH_SLOT
.get()
.expect("width slot initialized above")
.lock()
.unwrap()
});
restore.reset_env_detectors = true;
}
if let Some(policy) = self.ambiguous_width {
static POLICY_SLOT: std::sync::OnceLock<std::sync::Mutex<Option<AmbiguousWidth>>> =
std::sync::OnceLock::new();
let slot = POLICY_SLOT.get_or_init(|| std::sync::Mutex::new(None));
*slot.lock().unwrap() = Some(policy);
set_ambiguous_width_detector(|| {
*POLICY_SLOT
.get()
.expect("ambiguous width slot initialized above")
.lock()
.unwrap()
});
restore.reset_env_detectors = true;
}
if let Some(flag) = self.is_tty {
static TTY_SLOT: std::sync::OnceLock<std::sync::Mutex<bool>> =
std::sync::OnceLock::new();
let slot = TTY_SLOT.get_or_init(|| std::sync::Mutex::new(false));
*slot.lock().unwrap() = flag;
set_tty_detector(|| {
*TTY_SLOT
.get()
.expect("tty slot initialized above")
.lock()
.unwrap()
});
restore.reset_env_detectors = true;
}
if let Some(flag) = self.color_capable {
static COLOR_SLOT: std::sync::OnceLock<std::sync::Mutex<bool>> =
std::sync::OnceLock::new();
let slot = COLOR_SLOT.get_or_init(|| std::sync::Mutex::new(false));
*slot.lock().unwrap() = flag;
set_color_capability_detector(|| {
*COLOR_SLOT
.get()
.expect("color slot initialized above")
.lock()
.unwrap()
});
restore.reset_env_detectors = true;
}
match std::mem::replace(&mut self.stdin, StdinMode::Inherit) {
StdinMode::Inherit => {}
StdinMode::Piped(content) => {
set_default_stdin_reader(Arc::new(MockStdin::piped(content)));
restore.reset_stdin = true;
}
StdinMode::Interactive => {
set_default_stdin_reader(Arc::new(MockStdin::terminal()));
restore.reset_stdin = true;
}
}
if let Some(content) = self.clipboard.take() {
set_default_clipboard_reader(Arc::new(MockClipboard::with_content(content)));
restore.reset_clipboard = true;
}
if let Some(responder) = self.prompts.take() {
set_default_prompt_responder(responder);
restore.reset_prompts = true;
}
let mut argv: Vec<OsString> = args.into_iter().map(|a| a.into()).collect();
if let Some(mode) = self.output_mode {
argv.push(format!("--{}={}", self.output_flag_name, output_mode_flag(mode)).into());
}
let outcome = app.run_to_string(cmd, argv);
TestResult {
outcome,
_tempdir: self.tempdir.take(),
_restore: restore,
}
}
}
impl Default for TestHarness {
fn default() -> Self {
Self::new()
}
}
fn validate_fixture_path(path: &Path) -> PathBuf {
use std::path::Component;
if path.is_absolute() {
panic!(
"TestHarness::fixture: path {:?} is absolute; only relative paths are allowed so \
the fixture is confined to the harness tempdir",
path
);
}
for component in path.components() {
match component {
Component::ParentDir => panic!(
"TestHarness::fixture: path {:?} contains a `..` component; only relative \
paths that stay inside the tempdir are allowed",
path
),
Component::Prefix(_) | Component::RootDir => panic!(
"TestHarness::fixture: path {:?} has a root or prefix component; only \
relative paths inside the tempdir are allowed",
path
),
_ => {}
}
}
path.to_path_buf()
}
fn output_mode_flag(mode: OutputMode) -> &'static str {
match mode {
OutputMode::Auto => "auto",
OutputMode::Term => "term",
OutputMode::Text => "text",
OutputMode::TermDebug => "term-debug",
OutputMode::Json => "json",
OutputMode::Yaml => "yaml",
OutputMode::Xml => "xml",
OutputMode::Csv => "csv",
}
}
#[derive(Default)]
struct RestoreState {
env_originals: HashMap<String, Option<String>>,
original_cwd: Option<PathBuf>,
reset_env_detectors: bool,
reset_stdin: bool,
reset_clipboard: bool,
reset_prompts: bool,
}
impl Drop for RestoreState {
fn drop(&mut self) {
for (k, original) in self.env_originals.drain() {
match original {
Some(v) => std::env::set_var(&k, v),
None => std::env::remove_var(&k),
}
}
if let Some(cwd) = self.original_cwd.take() {
let _ = std::env::set_current_dir(cwd);
}
if self.reset_env_detectors {
reset_environment_detectors();
}
if self.reset_stdin {
reset_default_stdin_reader();
}
if self.reset_clipboard {
reset_default_clipboard_reader();
}
if self.reset_prompts {
reset_default_prompt_responder();
}
}
}
pub struct TestResult {
outcome: RunResult,
_tempdir: Option<TempDir>,
_restore: RestoreState,
}
impl TestResult {
pub fn outcome(&self) -> &RunResult {
&self.outcome
}
pub fn exit_status(&self) -> Option<ExitStatus> {
self.outcome.exit_status()
}
pub fn success_kind(&self) -> Option<SuccessKind> {
self.outcome.success_kind()
}
pub fn error_kind(&self) -> Option<RunErrorKind> {
self.outcome.error_kind()
}
pub fn stdout(&self) -> &str {
match &self.outcome {
RunResult::Handled(s) => s.as_str(),
_ => "",
}
}
pub fn is_handled(&self) -> bool {
matches!(self.outcome, RunResult::Handled(_))
}
pub fn is_no_match(&self) -> bool {
matches!(self.outcome, RunResult::NoMatch(_))
}
pub fn binary(&self) -> Option<(&[u8], &str)> {
match &self.outcome {
RunResult::Binary(bytes, filename) => Some((bytes.as_slice(), filename.as_str())),
_ => None,
}
}
pub fn artifact(&self) -> Option<&ArtifactRun> {
self.outcome.artifact()
}
pub fn artifact_bytes(&self) -> Option<&[u8]> {
self.artifact().map(ArtifactRun::bytes)
}
pub fn artifact_destination(&self) -> Option<&ArtifactDestination> {
self.artifact().map(ArtifactRun::destination)
}
pub fn artifact_report(&self) -> Option<&str> {
self.artifact().and_then(ArtifactRun::report)
}
#[track_caller]
pub fn expect_artifact(&self) -> &ArtifactRun {
match self.artifact() {
Some(run) => run,
None => panic!(
"expected a completed artifact, got: {:?}",
describe_outcome(&self.outcome)
),
}
}
#[track_caller]
pub fn assert_artifact_bytes(&self, expected: &[u8]) {
let actual = self.expect_artifact().bytes();
if actual != expected {
panic!(
"artifact bytes mismatch\n--- expected ({} bytes) ---\n{:?}\n--- actual ({} bytes) ---\n{:?}",
expected.len(),
expected,
actual.len(),
actual
);
}
}
#[track_caller]
pub fn assert_artifact_suggested_destination(&self, expected: impl AsRef<Path>) {
let actual = self.expect_artifact().suggested_destination();
assert_eq!(
actual,
Some(expected.as_ref()),
"unexpected suggested artifact destination"
);
}
#[track_caller]
pub fn assert_artifact_written_to(&self, expected: impl AsRef<Path>) {
let receipt = self.expect_artifact().receipt();
assert_eq!(
receipt.path(),
Some(expected.as_ref()),
"unexpected artifact destination"
);
}
#[track_caller]
pub fn assert_artifact_to_stdout(&self) {
let receipt = self.expect_artifact().receipt();
assert!(
receipt.is_stdout(),
"expected the artifact to go to stdout, but it went to {}",
receipt.destination().label()
);
}
#[track_caller]
pub fn assert_artifact_report_contains(&self, needle: &str) {
match self.expect_artifact().report() {
Some(report) if report.contains(needle) => {}
Some(report) => panic!(
"artifact report did not contain {:?}\n--- report ---\n{}\n--------------",
needle, report
),
None => panic!("expected an artifact report, but the artifact carried none"),
}
}
#[track_caller]
pub fn assert_success(&self) {
match &self.outcome {
RunResult::Handled(_)
| RunResult::Silent
| RunResult::Binary(_, _)
| RunResult::Artifact(_) => {}
RunResult::NoMatch(_) => {
panic!("expected successful dispatch but no handler matched; stdout was empty")
}
RunResult::Error(msg) => {
panic!("expected successful dispatch, got error: {}", msg)
}
_ => panic!(
"expected successful dispatch, got: {:?}",
describe_outcome(&self.outcome)
),
}
}
#[track_caller]
pub fn assert_exit_status(&self, expected: ExitStatus) {
assert_eq!(
self.exit_status(),
Some(expected),
"unexpected exit status for {}",
describe_outcome(&self.outcome)
);
}
#[track_caller]
pub fn assert_error_kind(&self, expected: RunErrorKind) {
assert_eq!(
self.error_kind(),
Some(expected),
"unexpected error kind for {}",
describe_outcome(&self.outcome)
);
}
pub fn is_error(&self) -> bool {
matches!(self.outcome, RunResult::Error(_))
}
pub fn error(&self) -> Option<&str> {
match &self.outcome {
RunResult::Error(s) => Some(s.as_str()),
_ => None,
}
}
#[track_caller]
pub fn assert_error(&self) {
if !self.is_error() {
panic!(
"expected RunResult::Error, got: {:?}",
describe_outcome(&self.outcome)
);
}
}
#[track_caller]
pub fn assert_error_contains(&self, needle: &str) {
match self.error() {
Some(msg) if msg.contains(needle) => {}
Some(msg) => panic!(
"error did not contain {:?}\n--- error ---\n{}\n-------------",
needle, msg
),
None => panic!(
"expected RunResult::Error, got: {:?}",
describe_outcome(&self.outcome)
),
}
}
#[track_caller]
pub fn assert_no_match(&self) {
if !self.is_no_match() {
panic!(
"expected no handler match, got: {:?}",
describe_outcome(&self.outcome)
);
}
}
#[track_caller]
pub fn assert_stdout_contains(&self, needle: &str) {
let out = self.stdout();
if !out.contains(needle) {
panic!(
"stdout did not contain {:?}\n--- stdout ---\n{}\n--------------",
needle, out
);
}
}
#[track_caller]
pub fn assert_stdout_eq(&self, expected: &str) {
let out = self.stdout();
if out != expected {
panic!(
"stdout mismatch\n--- expected ---\n{}\n--- actual -----\n{}\n----------------",
expected, out
);
}
}
}
fn describe_outcome(o: &RunResult) -> String {
match o {
RunResult::Handled(s) => format!("Handled({:?})", s),
RunResult::Silent => "Silent".into(),
RunResult::Binary(b, f) => format!("Binary(len={}, {:?})", b.len(), f),
RunResult::Artifact(run) => format!(
"Artifact(len={}, destination={:?})",
run.bytes().len(),
run.destination().label()
),
RunResult::Error(s) => format!("Error({:?})", s),
RunResult::NoMatch(_) => "NoMatch".into(),
_ => "Unknown".into(),
}
}