use std::path::{Path, PathBuf};
use std::time::Duration;
use super::ReplayBackend;
#[derive(Debug, Clone)]
pub struct SmokeTestConfig {
pub fixture_path: PathBuf,
pub timeout: Duration,
pub expected_iterations: Option<u32>,
pub expected_termination: Option<String>,
}
impl SmokeTestConfig {
pub fn new(fixture_path: impl AsRef<Path>) -> Self {
Self {
fixture_path: fixture_path.as_ref().to_path_buf(),
timeout: Duration::from_secs(30),
expected_iterations: None,
expected_termination: None,
}
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn with_expected_iterations(mut self, iterations: u32) -> Self {
self.expected_iterations = Some(iterations);
self
}
pub fn with_expected_termination(mut self, reason: impl Into<String>) -> Self {
self.expected_termination = Some(reason.into());
self
}
}
#[derive(Debug, Clone)]
pub struct SmokeTestResult {
iterations: u32,
events_parsed: usize,
termination_reason: TerminationReason,
output_bytes: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TerminationReason {
Completed,
FixtureExhausted,
Timeout,
MaxIterations,
Error(String),
}
impl SmokeTestResult {
pub fn completed_successfully(&self) -> bool {
matches!(
self.termination_reason,
TerminationReason::Completed | TerminationReason::FixtureExhausted
)
}
pub fn iterations_run(&self) -> u32 {
self.iterations
}
pub fn event_count(&self) -> usize {
self.events_parsed
}
pub fn termination_reason(&self) -> &TerminationReason {
&self.termination_reason
}
pub fn output_bytes(&self) -> usize {
self.output_bytes
}
}
#[derive(Debug, thiserror::Error)]
pub enum SmokeTestError {
#[error("Fixture not found: {0}")]
FixtureNotFound(PathBuf),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Invalid fixture format: {0}")]
InvalidFixture(String),
#[error("Timeout after {0:?}")]
Timeout(Duration),
}
pub fn list_fixtures(dir: impl AsRef<Path>) -> std::io::Result<Vec<PathBuf>> {
let dir = dir.as_ref();
if !dir.exists() {
return Ok(Vec::new());
}
let mut fixtures = Vec::new();
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "jsonl") {
fixtures.push(path);
}
}
fixtures.sort();
Ok(fixtures)
}
pub struct SmokeRunner;
impl SmokeRunner {
pub fn run(config: &SmokeTestConfig) -> Result<SmokeTestResult, SmokeTestError> {
if !config.fixture_path.exists() {
return Err(SmokeTestError::FixtureNotFound(config.fixture_path.clone()));
}
let mut backend = ReplayBackend::from_file(&config.fixture_path)?;
let mut iterations = 0u32;
let mut events_parsed = 0usize;
let mut output_bytes = 0usize;
let start_time = std::time::Instant::now();
while let Some(chunk) = backend.next_output() {
if start_time.elapsed() > config.timeout {
return Ok(SmokeTestResult {
iterations,
events_parsed,
termination_reason: TerminationReason::Timeout,
output_bytes,
});
}
output_bytes += chunk.len();
if let Ok(output) = String::from_utf8(chunk) {
let parser = crate::EventParser::new();
let events = parser.parse(&output);
events_parsed += events.len();
if events
.iter()
.any(|event| event.topic.as_str() == "LOOP_COMPLETE")
{
return Ok(SmokeTestResult {
iterations,
events_parsed,
termination_reason: TerminationReason::Completed,
output_bytes,
});
}
}
iterations += 1;
}
Ok(SmokeTestResult {
iterations,
events_parsed,
termination_reason: TerminationReason::FixtureExhausted,
output_bytes,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::TempDir;
fn create_fixture(dir: &Path, name: &str, content: &str) -> PathBuf {
let path = dir.join(name);
let mut file = std::fs::File::create(&path).unwrap();
file.write_all(content.as_bytes()).unwrap();
path
}
fn make_write_line(text: &str, offset_ms: u64) -> String {
use crate::session_recorder::Record;
use ralph_proto::TerminalWrite;
let write = TerminalWrite::new(text.as_bytes(), true, offset_ms);
let record = Record {
ts: 1000 + offset_ms,
event: "ux.terminal.write".to_string(),
data: serde_json::to_value(&write).unwrap(),
};
serde_json::to_string(&record).unwrap()
}
#[test]
fn test_fixture_not_found_returns_error() {
let config = SmokeTestConfig::new("/nonexistent/path/to/fixture.jsonl");
let result = SmokeRunner::run(&config);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, SmokeTestError::FixtureNotFound(_)));
}
#[test]
fn test_run_fixture_through_event_loop() {
let temp_dir = TempDir::new().unwrap();
let line1 = make_write_line("Starting task...", 0);
let line2 = make_write_line("Working on implementation...", 100);
let line3 = make_write_line("Task complete!", 200);
let content = format!("{}\n{}\n{}\n", line1, line2, line3);
let fixture_path = create_fixture(temp_dir.path(), "basic.jsonl", &content);
let config = SmokeTestConfig::new(&fixture_path);
let result = SmokeRunner::run(&config).unwrap();
assert!(result.iterations_run() > 0);
assert!(result.output_bytes() > 0);
}
#[test]
fn test_captures_completion_termination() {
let temp_dir = TempDir::new().unwrap();
let line1 = make_write_line("Working...", 0);
let line2 = make_write_line(r#"<event topic="LOOP_COMPLETE">done</event>"#, 100);
let content = format!("{}\n{}\n", line1, line2);
let fixture_path = create_fixture(temp_dir.path(), "completion.jsonl", &content);
let config = SmokeTestConfig::new(&fixture_path);
let result = SmokeRunner::run(&config).unwrap();
assert_eq!(*result.termination_reason(), TerminationReason::Completed);
assert!(result.completed_successfully());
}
#[test]
fn test_captures_fixture_exhausted_termination() {
let temp_dir = TempDir::new().unwrap();
let line1 = make_write_line("Some output", 0);
let line2 = make_write_line("More output", 100);
let content = format!("{}\n{}\n", line1, line2);
let fixture_path = create_fixture(temp_dir.path(), "no_completion.jsonl", &content);
let config = SmokeTestConfig::new(&fixture_path);
let result = SmokeRunner::run(&config).unwrap();
assert_eq!(
*result.termination_reason(),
TerminationReason::FixtureExhausted
);
assert!(result.completed_successfully()); }
#[test]
fn test_event_counting() {
let temp_dir = TempDir::new().unwrap();
let output_with_events = r#"Some preamble
<event topic="build.task">Task 1</event>
Working on task...
<event topic="build.done">
tests: pass
lint: pass
typecheck: pass
audit: pass
coverage: pass
</event>"#;
let line1 = make_write_line(output_with_events, 0);
let content = format!("{}\n", line1);
let fixture_path = create_fixture(temp_dir.path(), "with_events.jsonl", &content);
let config = SmokeTestConfig::new(&fixture_path);
let result = SmokeRunner::run(&config).unwrap();
assert_eq!(result.event_count(), 2);
}
#[test]
fn test_timeout_handling() {
let temp_dir = TempDir::new().unwrap();
let line1 = make_write_line("Output 1", 0);
let content = format!("{}\n", line1);
let fixture_path = create_fixture(temp_dir.path(), "timeout_test.jsonl", &content);
let config = SmokeTestConfig::new(&fixture_path).with_timeout(Duration::from_millis(1));
let result = SmokeRunner::run(&config).unwrap();
assert!(
result.completed_successfully()
|| *result.termination_reason() == TerminationReason::Timeout
);
}
#[test]
fn test_list_fixtures_empty_directory() {
let temp_dir = TempDir::new().unwrap();
let fixtures = list_fixtures(temp_dir.path()).unwrap();
assert!(fixtures.is_empty());
}
#[test]
fn test_list_fixtures_finds_jsonl_files() {
let temp_dir = TempDir::new().unwrap();
create_fixture(temp_dir.path(), "test1.jsonl", "{}");
create_fixture(temp_dir.path(), "test2.jsonl", "{}");
create_fixture(temp_dir.path(), "not_a_fixture.txt", "text");
let fixtures = list_fixtures(temp_dir.path()).unwrap();
assert_eq!(fixtures.len(), 2);
assert!(fixtures.iter().all(|p| p.extension().unwrap() == "jsonl"));
}
#[test]
fn test_list_fixtures_nonexistent_directory() {
let fixtures = list_fixtures("/nonexistent/path").unwrap();
assert!(fixtures.is_empty());
}
#[test]
fn test_empty_fixture_completes() {
let temp_dir = TempDir::new().unwrap();
let fixture_path = create_fixture(temp_dir.path(), "empty.jsonl", "");
let config = SmokeTestConfig::new(&fixture_path);
let result = SmokeRunner::run(&config).unwrap();
assert_eq!(result.iterations_run(), 0);
assert_eq!(result.event_count(), 0);
assert_eq!(
*result.termination_reason(),
TerminationReason::FixtureExhausted
);
}
#[test]
fn test_config_builder_pattern() {
let config = SmokeTestConfig::new("test.jsonl")
.with_timeout(Duration::from_mins(1))
.with_expected_iterations(5)
.with_expected_termination("Completed");
assert_eq!(config.fixture_path, PathBuf::from("test.jsonl"));
assert_eq!(config.timeout, Duration::from_mins(1));
assert_eq!(config.expected_iterations, Some(5));
assert_eq!(config.expected_termination, Some("Completed".to_string()));
}
#[test]
fn test_result_accessors() {
let result = SmokeTestResult {
iterations: 5,
events_parsed: 3,
termination_reason: TerminationReason::Completed,
output_bytes: 1024,
};
assert_eq!(result.iterations_run(), 5);
assert_eq!(result.event_count(), 3);
assert_eq!(*result.termination_reason(), TerminationReason::Completed);
assert_eq!(result.output_bytes(), 1024);
assert!(result.completed_successfully());
}
}