use std::path::{Path, PathBuf};
use std::sync::{Mutex, MutexGuard, OnceLock};
use std::time::Duration;
use assert_cmd::cargo::cargo_bin;
use testty::assertion;
use testty::feature::{FeatureDemo, GifMode, GifStatus};
use testty::frame::TerminalFrame;
use testty::journey::Journey;
use testty::proof::report::{ProofCapture, ProofReport};
use testty::region::Region;
use testty::scenario::Scenario;
use testty::session::PtySessionBuilder;
use testty::step::Step;
pub(crate) struct BuilderEnv {
pub(crate) agentty_root: PathBuf,
pub(crate) home_dir: PathBuf,
pub(crate) stub_bin: PathBuf,
pub(crate) workdir: PathBuf,
}
impl BuilderEnv {
pub(crate) fn new(temp_root: &Path) -> std::io::Result<Self> {
let agentty_root = temp_root.join("agentty_root");
let home_dir = temp_root.join("home");
let workdir = temp_root.join("test-project");
let stub_bin = temp_root.join("stub-bin");
std::fs::create_dir_all(&agentty_root)?;
std::fs::create_dir_all(&home_dir)?;
std::fs::create_dir_all(&workdir)?;
std::fs::create_dir_all(&stub_bin)?;
let stub_agent_path = stub_bin.join("claude");
std::fs::write(&stub_agent_path, "#!/bin/sh\nexit 1\n")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&stub_agent_path, std::fs::Permissions::from_mode(0o755))?;
}
Ok(Self {
agentty_root,
home_dir,
stub_bin,
workdir,
})
}
pub(crate) fn builder(&self) -> PtySessionBuilder {
let path_with_stub_bin = self.path_with_stub_bin();
self.builder_with_path_and_size(
path_with_stub_bin,
DEFAULT_TERMINAL_COLS,
DEFAULT_TERMINAL_ROWS,
)
}
fn builder_with_path_and_size(
&self,
path_env: String,
terminal_cols: u16,
terminal_rows: u16,
) -> PtySessionBuilder {
PtySessionBuilder::new(cargo_bin("agentty"))
.size(terminal_cols, terminal_rows)
.env("AGENTTY_ROOT", self.agentty_root.to_string_lossy())
.env("HOME", self.home_dir.to_string_lossy())
.env("PATH", path_env)
.workdir(&self.workdir)
}
pub(crate) fn as_vhs_env_pairs(&self) -> Vec<(String, String)> {
self.as_vhs_env_pairs_with_path(self.path_with_stub_bin())
}
fn as_vhs_env_pairs_with_path(&self, path_env: String) -> Vec<(String, String)> {
vec![
(
"AGENTTY_ROOT".to_string(),
self.agentty_root.to_string_lossy().into_owned(),
),
(
"HOME".to_string(),
self.home_dir.to_string_lossy().into_owned(),
),
("PATH".to_string(), path_env),
]
}
fn path_with_stub_bin(&self) -> String {
let system_path = std::env::var("PATH").unwrap_or_default();
let mut paths = vec![self.stub_bin.clone()];
paths.extend(std::env::split_paths(&system_path));
match std::env::join_paths(paths) {
Ok(path) => path.to_string_lossy().into_owned(),
Err(_) => self.stub_bin.to_string_lossy().into_owned(),
}
}
fn stub_only_path(&self) -> String {
self.stub_bin.to_string_lossy().into_owned()
}
}
pub(crate) fn acquire_e2e_test_lock() -> MutexGuard<'static, ()> {
static E2E_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
E2E_TEST_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn feature_output_dir() -> PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
Path::new(manifest_dir).join("../../docs/site/static/features")
}
pub(crate) const TESTTY_GIF_MODE_ENV_VAR: &str = "TESTTY_GIF_MODE";
const DEFAULT_TERMINAL_COLS: u16 = 80;
const DEFAULT_TERMINAL_ROWS: u16 = 24;
fn resolve_gif_mode() -> GifMode {
let Ok(raw) = std::env::var(TESTTY_GIF_MODE_ENV_VAR) else {
return GifMode::GenerateIfStale;
};
parse_gif_mode(&raw)
}
fn parse_gif_mode(raw: &str) -> GifMode {
match raw.trim().to_ascii_lowercase().as_str() {
"check" | "check-only" => GifMode::CheckOnly,
"force" | "always" | "always-generate" => GifMode::AlwaysGenerate,
_ => GifMode::GenerateIfStale,
}
}
pub(crate) fn frame_from_capture(capture: &ProofCapture) -> TerminalFrame {
TerminalFrame::new(capture.cols, capture.rows, &capture.frame_bytes)
}
fn feature_content_dir() -> PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let content_dir = Path::new(manifest_dir).join("../../docs/site/content/features");
let _ = std::fs::create_dir_all(&content_dir);
content_dir
}
pub(crate) struct ZolaFeaturePage {
pub(crate) title: String,
pub(crate) description: String,
pub(crate) weight: u32,
}
impl ZolaFeaturePage {
fn ensure(&self, name: &str) {
let content_dir = feature_content_dir();
let page_path = content_dir.join(format!("{name}.md"));
if page_path.exists() {
return;
}
let content = format!(
"+++\ntitle = \"{title}\"\ndescription = \"{description}\"\nweight = \
{weight}\n\n[extra]\ngif = \"{name}.gif\"\n+++\n",
title = self.title,
description = self.description,
weight = self.weight,
);
let _ = std::fs::write(&page_path, content);
}
}
pub(crate) struct FeatureTest {
inherit_system_path: bool,
name: String,
setup: Option<FeatureSetupHook>,
terminal_cols: u16,
terminal_rows: u16,
with_git: bool,
zola_page: Option<ZolaFeaturePage>,
}
type FeatureSetupHook = Box<dyn Fn(&BuilderEnv) -> Result<(), Box<dyn std::error::Error>>>;
impl FeatureTest {
pub(crate) fn new(name: impl Into<String>) -> Self {
Self {
inherit_system_path: true,
name: name.into(),
setup: None,
terminal_cols: DEFAULT_TERMINAL_COLS,
terminal_rows: DEFAULT_TERMINAL_ROWS,
with_git: false,
zola_page: None,
}
}
pub(crate) fn setup(
mut self,
setup: impl Fn(&BuilderEnv) -> Result<(), Box<dyn std::error::Error>> + 'static,
) -> Self {
self.setup = Some(Box::new(setup));
self
}
pub(crate) fn with_git(mut self) -> Self {
self.with_git = true;
self
}
pub(crate) fn with_stub_path_only(mut self) -> Self {
self.inherit_system_path = false;
self
}
pub(crate) fn with_terminal_size(mut self, cols: u16, rows: u16) -> Self {
self.terminal_cols = cols;
self.terminal_rows = rows;
self
}
pub(crate) fn zola(mut self, title: &str, description: &str, weight: u32) -> Self {
self.zola_page = Some(ZolaFeaturePage {
title: title.to_string(),
description: description.to_string(),
weight,
});
self
}
pub(crate) fn run(
self,
build_scenario: impl FnOnce(Scenario) -> Scenario,
assert: impl FnOnce(&TerminalFrame, &ProofReport),
) -> Result<(), Box<dyn std::error::Error>> {
let _test_guard = acquire_e2e_test_lock();
let temp = tempfile::TempDir::new()?;
let env = BuilderEnv::new(temp.path())?;
if self.with_git {
env.init_git()?;
}
if let Some(setup) = &self.setup {
setup(&env)?;
}
let scenario = build_scenario(Scenario::new(&self.name));
let terminal_cols = self.terminal_cols;
let terminal_rows = self.terminal_rows;
let uses_default_terminal_size =
terminal_cols == DEFAULT_TERMINAL_COLS && terminal_rows == DEFAULT_TERMINAL_ROWS;
let (builder, owned_pairs) = if self.inherit_system_path && uses_default_terminal_size {
(env.builder(), env.as_vhs_env_pairs())
} else if self.inherit_system_path {
(
env.builder_with_path_and_size(
env.path_with_stub_bin(),
terminal_cols,
terminal_rows,
),
env.as_vhs_env_pairs(),
)
} else {
let path_env = env.stub_only_path();
(
env.builder_with_path_and_size(path_env.clone(), terminal_cols, terminal_rows),
env.as_vhs_env_pairs_with_path(path_env),
)
};
let env_pairs: Vec<(&str, &str)> = owned_pairs
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
let gif_mode = resolve_gif_mode();
let result = FeatureDemo::new(&self.name)
.gif_output_dir(feature_output_dir())
.gif_mode(gif_mode)
.run(&scenario, builder, &cargo_bin("agentty"), &env_pairs)
.map_err(|error| std::io::Error::other(format!("feature demo failed: {error}")))?;
match &result.gif_status {
GifStatus::Generated(_)
| GifStatus::CacheHit(_)
| GifStatus::Fresh { .. }
| GifStatus::VhsNotInstalled
| GifStatus::NoOutputDir => {}
GifStatus::DirCreateFailed(err) => {
return Err(std::io::Error::other(format!(
"Feature GIF dir creation failed for {}: {err}",
self.name
))
.into());
}
GifStatus::TapeExecutionFailed(err) => {
return Err(std::io::Error::other(format!(
"VHS tape execution failed for {}: {err}",
self.name
))
.into());
}
GifStatus::Stale {
gif_path,
current,
committed,
} => {
return Err(std::io::Error::other(format!(
"Feature GIF is stale for {} (gif: {}, current hash: {current}, committed \
hash: {committed:?}). Re-run with `{TESTTY_GIF_MODE_ENV_VAR}=force` (or \
unset to default) to regenerate.",
self.name,
gif_path.display(),
))
.into());
}
other => {
return Err(std::io::Error::other(format!(
"Feature GIF generation returned an unrecognized status for {}: {other:?}. \
Update the FeatureTest harness to handle the new GifStatus variant.",
self.name,
))
.into());
}
}
assert(&result.frame, &result.report);
if let Some(zola_page) = self.zola_page {
zola_page.ensure(&self.name);
}
Ok(())
}
}
impl BuilderEnv {
pub(crate) fn init_git(&self) -> std::io::Result<()> {
let run = |args: &[&str]| -> std::io::Result<()> {
let output = std::process::Command::new("git")
.args(args)
.current_dir(&self.workdir)
.output()?;
if !output.status.success() {
return Err(std::io::Error::other(format!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr)
)));
}
Ok(())
};
run(&["init", "-b", "main"])?;
run(&["config", "user.email", "test@test.com"])?;
run(&["config", "user.name", "Test"])?;
run(&["commit", "--allow-empty", "-m", "init"])
}
}
pub(crate) fn wait_for_agentty_startup() -> Journey {
Journey::new("agentty_startup")
.with_description("Wait for agentty startup and initial render")
.step(Step::wait_for_text("Agentty", 30000))
.step(Step::wait_for_stable_frame(300, 5000))
}
pub(crate) fn switch_to_tab(tab_name: &str) -> Journey {
Journey::new(format!("switch_to_{tab_name}"))
.with_description(format!("Press Tab and wait for '{tab_name}' to appear"))
.step(Step::press_key("Tab"))
.step(Step::wait_for_stable_frame(300, 3000))
}
pub(crate) fn switch_to_tab_reverse(tab_name: &str) -> Journey {
Journey::new(format!("switch_back_to_{tab_name}"))
.with_description(format!("Press BackTab and wait for '{tab_name}' to appear"))
.step(Step::press_key("BackTab"))
.step(Step::wait_for_stable_frame(300, 3000))
}
pub(crate) fn open_quit_dialog() -> Journey {
Journey::new("open_quit_dialog")
.with_description("Press q and wait for quit confirmation dialog")
.step(Step::press_key("q"))
.step(Step::wait_for_stable_frame(300, 3000))
}
pub(crate) fn open_help_overlay() -> Journey {
Journey::new("open_help_overlay")
.with_description("Press ? and wait for help overlay")
.step(Step::press_key("?"))
.step(Step::wait_for_stable_frame(300, 3000))
}
const SESSION_VIEW_FOOTER_MARKER: &str = "q: back";
const SESSION_LIST_FOOTER_MARKER: &str = "new session";
const SESSION_TRANSITION_TIMEOUT: Duration = Duration::from_secs(5);
const SESSION_TRANSITION_POLL: Duration = Duration::from_millis(50);
const SESSION_TRANSITION_FOOTER_ROWS: u16 = 3;
fn eventually_footer_text_visible(marker: &'static str) -> Step {
Step::eventually(
SESSION_TRANSITION_TIMEOUT,
SESSION_TRANSITION_POLL,
move |frame| {
let rows = frame.rows();
let height = SESSION_TRANSITION_FOOTER_ROWS.min(rows);
let region = Region::new(0, rows.saturating_sub(height), frame.cols(), height);
assertion::match_text_in_region(frame, marker, ®ion)
},
)
}
pub(crate) fn create_session_and_return_to_list() -> Journey {
create_session_with_prompt_and_return_to_list("test")
}
pub(crate) fn create_session_with_prompt_and_return_to_list(prompt: &str) -> Journey {
Journey::new("create_session")
.with_description(format!(
"Create regular session via a, type {prompt}, submit, return to list"
))
.step(Step::press_key("a"))
.step(Step::press_key("Enter"))
.step(Step::wait_for_stable_frame(300, 5000))
.step(Step::write_text(prompt))
.step(Step::wait_for_text(prompt, 3000))
.step(Step::press_key("Enter"))
.step(eventually_footer_text_visible(SESSION_VIEW_FOOTER_MARKER))
.step(Step::press_key("q"))
.step(eventually_footer_text_visible(SESSION_LIST_FOOTER_MARKER))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_gif_mode_empty_falls_back_to_default() {
assert_eq!(parse_gif_mode(""), GifMode::GenerateIfStale);
}
#[test]
fn parse_gif_mode_recognizes_check_only_aliases() {
assert_eq!(parse_gif_mode("check"), GifMode::CheckOnly);
assert_eq!(parse_gif_mode("check-only"), GifMode::CheckOnly);
assert_eq!(parse_gif_mode("CHECK"), GifMode::CheckOnly);
assert_eq!(parse_gif_mode(" check "), GifMode::CheckOnly);
}
#[test]
fn parse_gif_mode_recognizes_always_generate_aliases() {
assert_eq!(parse_gif_mode("force"), GifMode::AlwaysGenerate);
assert_eq!(parse_gif_mode("always"), GifMode::AlwaysGenerate);
assert_eq!(parse_gif_mode("always-generate"), GifMode::AlwaysGenerate);
assert_eq!(parse_gif_mode("Force"), GifMode::AlwaysGenerate);
}
#[test]
fn parse_gif_mode_recognizes_generate_if_stale_aliases() {
assert_eq!(parse_gif_mode("generate"), GifMode::GenerateIfStale);
assert_eq!(
parse_gif_mode("generate-if-stale"),
GifMode::GenerateIfStale,
);
}
#[test]
fn parse_gif_mode_unknown_value_falls_back_to_default() {
assert_eq!(parse_gif_mode("nonsense"), GifMode::GenerateIfStale);
assert_eq!(parse_gif_mode("checkk"), GifMode::GenerateIfStale);
}
}