use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::{Path, PathBuf};
use std::time::Duration;
use assert_cmd::cargo::cargo_bin;
use testty::feature::{FeatureDemo, GifStatus};
use testty::frame::TerminalFrame;
use testty::journey::Journey;
use testty::proof::report::{ProofCapture, ProofReport};
use testty::scenario::Scenario;
use testty::session::PtySessionBuilder;
use testty::step::Step;
use testty::vhs::{VhsTape, VhsTapeSettings, check_vhs_installed};
pub(crate) struct BuilderEnv {
pub(crate) agentty_root: 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 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(&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,
stub_bin,
workdir,
})
}
pub(crate) fn builder(&self) -> PtySessionBuilder {
let path_with_stub_bin = self.path_with_stub_bin();
PtySessionBuilder::new(cargo_bin("agentty"))
.size(80, 24)
.env("AGENTTY_ROOT", self.agentty_root.to_string_lossy())
.env("PATH", path_with_stub_bin)
.workdir(&self.workdir)
}
pub(crate) fn as_vhs_env_pairs(&self) -> Vec<(String, String)> {
vec![
(
"AGENTTY_ROOT".to_string(),
self.agentty_root.to_string_lossy().into_owned(),
),
("PATH".to_string(), self.path_with_stub_bin()),
]
}
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));
std::env::join_paths(paths)
.expect("valid PATH")
.to_string_lossy()
.into_owned()
}
}
fn feature_output_dir() -> PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let output_dir = Path::new(manifest_dir).join("../../docs/site/static/features");
std::fs::create_dir_all(&output_dir).expect("failed to create feature output dir");
output_dir
}
fn compute_frame_hash(report: &ProofReport) -> u64 {
let mut hasher = DefaultHasher::new();
for capture in &report.captures {
capture.frame_bytes.hash(&mut hasher);
}
hasher.finish()
}
pub(crate) fn save_feature_gif(
scenario: &Scenario,
report: &ProofReport,
env: &BuilderEnv,
name: &str,
) {
if check_vhs_installed().is_err() {
println!("VHS not installed — skipping feature GIF for {name}");
return;
}
let output_dir = feature_output_dir();
let hash_path = output_dir.join(format!(".{name}.hash"));
let gif_path = output_dir.join(format!("{name}.gif"));
let current_hash = compute_frame_hash(report);
let hash_string = current_hash.to_string();
if gif_path.exists()
&& let Ok(cached) = std::fs::read_to_string(&hash_path)
&& cached.trim() == hash_string
{
println!("Feature GIF unchanged — skipping {name}");
return;
}
let screenshot_path = output_dir.join(format!("{name}.png"));
let owned_pairs = env.as_vhs_env_pairs();
let env_pairs: Vec<(&str, &str)> = owned_pairs
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
let tape = VhsTape::from_scenario_with_settings(
scenario,
&cargo_bin("agentty"),
&screenshot_path,
&env_pairs,
&VhsTapeSettings::feature_demo(),
);
let tape_path = output_dir.join(format!("{name}.tape"));
match tape.execute(&tape_path) {
Ok(_) => {
std::fs::write(&hash_path, &hash_string).expect("failed to write hash sidecar");
let _ = std::fs::remove_file(&tape_path);
let _ = std::fs::remove_file(&screenshot_path);
println!("Feature GIF saved to {}", gif_path.display());
}
Err(error) => {
let _ = std::fs::remove_file(&tape_path);
println!("VHS execution failed for {name}: {error}");
}
}
}
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");
std::fs::create_dir_all(&content_dir).expect("failed to create feature 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,
);
std::fs::write(&page_path, content).expect("failed to write Zola feature page");
println!("Zola feature page created at {}", page_path.display());
}
}
pub(crate) struct FeatureTest {
name: String,
with_git: bool,
zola_page: Option<ZolaFeaturePage>,
}
impl FeatureTest {
pub(crate) fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
with_git: false,
zola_page: None,
}
}
pub(crate) fn with_git(mut self) -> Self {
self.with_git = true;
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),
) {
let temp = tempfile::TempDir::new().expect("failed to create temp dir");
let env = BuilderEnv::new(temp.path()).expect("failed to create builder env");
if self.with_git {
env.init_git().expect("failed to init git");
}
let scenario = build_scenario(Scenario::new(&self.name));
let owned_pairs = env.as_vhs_env_pairs();
let env_pairs: Vec<(&str, &str)> = owned_pairs
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
let result = FeatureDemo::new(&self.name)
.gif_output_dir(feature_output_dir())
.run(&scenario, env.builder(), &cargo_bin("agentty"), &env_pairs)
.expect("feature demo execution failed");
match &result.gif_status {
GifStatus::Generated(path) => {
println!("Feature GIF saved to {}", path.display());
}
GifStatus::CacheHit(path) => {
println!("Feature GIF unchanged — skipping {}", path.display());
}
GifStatus::VhsNotInstalled => {
println!("VHS not installed — skipping feature GIF for {}", self.name);
}
GifStatus::NoOutputDir => {
println!("No GIF output dir — skipping GIF for {}", self.name);
}
GifStatus::DirCreateFailed(err) => {
panic!("Feature GIF dir creation failed for {}: {err}", self.name);
}
GifStatus::TapeExecutionFailed(err) => {
panic!("VHS tape execution failed for {}: {err}", self.name);
}
}
assert(&result.frame, &result.report);
if let Some(zola_page) = self.zola_page {
zola_page.ensure(&self.name);
}
}
}
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))
}
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 session via a, type {prompt}, submit, return to list"
))
.step(Step::press_key("a"))
.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(Step::sleep(Duration::from_secs(2)))
.step(Step::press_key("q"))
.step(Step::sleep(Duration::from_secs(1)))
}