1use std::path::{Path, PathBuf};
19use std::time::Duration;
20
21use super::ReplayBackend;
22
23#[derive(Debug, Clone)]
25pub struct SmokeTestConfig {
26 pub fixture_path: PathBuf,
28 pub timeout: Duration,
30 pub expected_iterations: Option<u32>,
32 pub expected_termination: Option<String>,
34}
35
36impl SmokeTestConfig {
37 pub fn new(fixture_path: impl AsRef<Path>) -> Self {
39 Self {
40 fixture_path: fixture_path.as_ref().to_path_buf(),
41 timeout: Duration::from_secs(30),
42 expected_iterations: None,
43 expected_termination: None,
44 }
45 }
46
47 pub fn with_timeout(mut self, timeout: Duration) -> Self {
49 self.timeout = timeout;
50 self
51 }
52
53 pub fn with_expected_iterations(mut self, iterations: u32) -> Self {
55 self.expected_iterations = Some(iterations);
56 self
57 }
58
59 pub fn with_expected_termination(mut self, reason: impl Into<String>) -> Self {
61 self.expected_termination = Some(reason.into());
62 self
63 }
64}
65
66#[derive(Debug, Clone)]
68pub struct SmokeTestResult {
69 iterations: u32,
71 events_parsed: usize,
73 termination_reason: TerminationReason,
75 output_bytes: usize,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum TerminationReason {
82 Completed,
84 FixtureExhausted,
86 Timeout,
88 MaxIterations,
90 Error(String),
92}
93
94impl SmokeTestResult {
95 pub fn completed_successfully(&self) -> bool {
97 matches!(
98 self.termination_reason,
99 TerminationReason::Completed | TerminationReason::FixtureExhausted
100 )
101 }
102
103 pub fn iterations_run(&self) -> u32 {
105 self.iterations
106 }
107
108 pub fn event_count(&self) -> usize {
110 self.events_parsed
111 }
112
113 pub fn termination_reason(&self) -> &TerminationReason {
115 &self.termination_reason
116 }
117
118 pub fn output_bytes(&self) -> usize {
120 self.output_bytes
121 }
122}
123
124#[derive(Debug, thiserror::Error)]
126pub enum SmokeTestError {
127 #[error("Fixture not found: {0}")]
129 FixtureNotFound(PathBuf),
130
131 #[error("IO error: {0}")]
133 Io(#[from] std::io::Error),
134
135 #[error("Invalid fixture format: {0}")]
137 InvalidFixture(String),
138
139 #[error("Timeout after {0:?}")]
141 Timeout(Duration),
142}
143
144pub fn list_fixtures(dir: impl AsRef<Path>) -> std::io::Result<Vec<PathBuf>> {
146 let dir = dir.as_ref();
147 if !dir.exists() {
148 return Ok(Vec::new());
149 }
150
151 let mut fixtures = Vec::new();
152 for entry in std::fs::read_dir(dir)? {
153 let entry = entry?;
154 let path = entry.path();
155 if path.extension().is_some_and(|ext| ext == "jsonl") {
156 fixtures.push(path);
157 }
158 }
159
160 fixtures.sort();
161 Ok(fixtures)
162}
163
164pub struct SmokeRunner;
166
167impl SmokeRunner {
168 pub fn run(config: &SmokeTestConfig) -> Result<SmokeTestResult, SmokeTestError> {
177 if !config.fixture_path.exists() {
179 return Err(SmokeTestError::FixtureNotFound(config.fixture_path.clone()));
180 }
181
182 let mut backend = ReplayBackend::from_file(&config.fixture_path)?;
184
185 let mut iterations = 0u32;
187 let mut events_parsed = 0usize;
188 let mut output_bytes = 0usize;
189
190 let start_time = std::time::Instant::now();
191
192 while let Some(chunk) = backend.next_output() {
194 if start_time.elapsed() > config.timeout {
196 return Ok(SmokeTestResult {
197 iterations,
198 events_parsed,
199 termination_reason: TerminationReason::Timeout,
200 output_bytes,
201 });
202 }
203
204 output_bytes += chunk.len();
205
206 if let Ok(output) = String::from_utf8(chunk) {
208 let parser = crate::EventParser::new();
209 let events = parser.parse(&output);
210 events_parsed += events.len();
211
212 if crate::EventParser::contains_promise(&output, "LOOP_COMPLETE") {
214 return Ok(SmokeTestResult {
215 iterations,
216 events_parsed,
217 termination_reason: TerminationReason::Completed,
218 output_bytes,
219 });
220 }
221 }
222
223 iterations += 1;
224 }
225
226 Ok(SmokeTestResult {
228 iterations,
229 events_parsed,
230 termination_reason: TerminationReason::FixtureExhausted,
231 output_bytes,
232 })
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use std::io::Write;
240 use tempfile::TempDir;
241
242 fn create_fixture(dir: &Path, name: &str, content: &str) -> PathBuf {
244 let path = dir.join(name);
245 let mut file = std::fs::File::create(&path).unwrap();
246 file.write_all(content.as_bytes()).unwrap();
247 path
248 }
249
250 fn make_write_line(text: &str, offset_ms: u64) -> String {
252 use crate::session_recorder::Record;
253 use ralph_proto::TerminalWrite;
254
255 let write = TerminalWrite::new(text.as_bytes(), true, offset_ms);
256 let record = Record {
257 ts: 1000 + offset_ms,
258 event: "ux.terminal.write".to_string(),
259 data: serde_json::to_value(&write).unwrap(),
260 };
261 serde_json::to_string(&record).unwrap()
262 }
263
264 #[test]
269 fn test_fixture_not_found_returns_error() {
270 let config = SmokeTestConfig::new("/nonexistent/path/to/fixture.jsonl");
271 let result = SmokeRunner::run(&config);
272
273 assert!(result.is_err());
274 let err = result.unwrap_err();
275 assert!(matches!(err, SmokeTestError::FixtureNotFound(_)));
276 }
277
278 #[test]
283 fn test_run_fixture_through_event_loop() {
284 let temp_dir = TempDir::new().unwrap();
285
286 let line1 = make_write_line("Starting task...", 0);
288 let line2 = make_write_line("Working on implementation...", 100);
289 let line3 = make_write_line("Task complete!", 200);
290 let content = format!("{}\n{}\n{}\n", line1, line2, line3);
291
292 let fixture_path = create_fixture(temp_dir.path(), "basic.jsonl", &content);
293
294 let config = SmokeTestConfig::new(&fixture_path);
295 let result = SmokeRunner::run(&config).unwrap();
296
297 assert!(result.iterations_run() > 0);
299 assert!(result.output_bytes() > 0);
300 }
301
302 #[test]
307 fn test_captures_completion_termination() {
308 let temp_dir = TempDir::new().unwrap();
309
310 let line1 = make_write_line("Working...", 0);
312 let line2 = make_write_line("LOOP_COMPLETE", 100);
313 let content = format!("{}\n{}\n", line1, line2);
314
315 let fixture_path = create_fixture(temp_dir.path(), "completion.jsonl", &content);
316
317 let config = SmokeTestConfig::new(&fixture_path);
318 let result = SmokeRunner::run(&config).unwrap();
319
320 assert_eq!(*result.termination_reason(), TerminationReason::Completed);
321 assert!(result.completed_successfully());
322 }
323
324 #[test]
325 fn test_captures_fixture_exhausted_termination() {
326 let temp_dir = TempDir::new().unwrap();
327
328 let line1 = make_write_line("Some output", 0);
330 let line2 = make_write_line("More output", 100);
331 let content = format!("{}\n{}\n", line1, line2);
332
333 let fixture_path = create_fixture(temp_dir.path(), "no_completion.jsonl", &content);
334
335 let config = SmokeTestConfig::new(&fixture_path);
336 let result = SmokeRunner::run(&config).unwrap();
337
338 assert_eq!(
339 *result.termination_reason(),
340 TerminationReason::FixtureExhausted
341 );
342 assert!(result.completed_successfully()); }
344
345 #[test]
350 fn test_event_counting() {
351 let temp_dir = TempDir::new().unwrap();
352
353 let output_with_events = r#"Some preamble
355<event topic="build.task">Task 1</event>
356Working on task...
357<event topic="build.done">
358tests: pass
359lint: pass
360typecheck: pass
361</event>"#;
362
363 let line1 = make_write_line(output_with_events, 0);
364 let content = format!("{}\n", line1);
365
366 let fixture_path = create_fixture(temp_dir.path(), "with_events.jsonl", &content);
367
368 let config = SmokeTestConfig::new(&fixture_path);
369 let result = SmokeRunner::run(&config).unwrap();
370
371 assert_eq!(result.event_count(), 2);
373 }
374
375 #[test]
380 fn test_timeout_handling() {
381 let temp_dir = TempDir::new().unwrap();
382
383 let line1 = make_write_line("Output 1", 0);
385 let content = format!("{}\n", line1);
386
387 let fixture_path = create_fixture(temp_dir.path(), "timeout_test.jsonl", &content);
388
389 let config = SmokeTestConfig::new(&fixture_path).with_timeout(Duration::from_millis(1)); let result = SmokeRunner::run(&config).unwrap();
394
395 assert!(
398 result.completed_successfully()
399 || *result.termination_reason() == TerminationReason::Timeout
400 );
401 }
402
403 #[test]
408 fn test_list_fixtures_empty_directory() {
409 let temp_dir = TempDir::new().unwrap();
410
411 let fixtures = list_fixtures(temp_dir.path()).unwrap();
412 assert!(fixtures.is_empty());
413 }
414
415 #[test]
416 fn test_list_fixtures_finds_jsonl_files() {
417 let temp_dir = TempDir::new().unwrap();
418
419 create_fixture(temp_dir.path(), "test1.jsonl", "{}");
421 create_fixture(temp_dir.path(), "test2.jsonl", "{}");
422 create_fixture(temp_dir.path(), "not_a_fixture.txt", "text");
423
424 let fixtures = list_fixtures(temp_dir.path()).unwrap();
425
426 assert_eq!(fixtures.len(), 2);
427 assert!(fixtures.iter().all(|p| p.extension().unwrap() == "jsonl"));
428 }
429
430 #[test]
431 fn test_list_fixtures_nonexistent_directory() {
432 let fixtures = list_fixtures("/nonexistent/path").unwrap();
433 assert!(fixtures.is_empty());
434 }
435
436 #[test]
441 fn test_empty_fixture_completes() {
442 let temp_dir = TempDir::new().unwrap();
443
444 let fixture_path = create_fixture(temp_dir.path(), "empty.jsonl", "");
445
446 let config = SmokeTestConfig::new(&fixture_path);
447 let result = SmokeRunner::run(&config).unwrap();
448
449 assert_eq!(result.iterations_run(), 0);
450 assert_eq!(result.event_count(), 0);
451 assert_eq!(
452 *result.termination_reason(),
453 TerminationReason::FixtureExhausted
454 );
455 }
456
457 #[test]
458 fn test_config_builder_pattern() {
459 let config = SmokeTestConfig::new("test.jsonl")
460 .with_timeout(Duration::from_secs(60))
461 .with_expected_iterations(5)
462 .with_expected_termination("Completed");
463
464 assert_eq!(config.fixture_path, PathBuf::from("test.jsonl"));
465 assert_eq!(config.timeout, Duration::from_secs(60));
466 assert_eq!(config.expected_iterations, Some(5));
467 assert_eq!(config.expected_termination, Some("Completed".to_string()));
468 }
469
470 #[test]
471 fn test_result_accessors() {
472 let result = SmokeTestResult {
473 iterations: 5,
474 events_parsed: 3,
475 termination_reason: TerminationReason::Completed,
476 output_bytes: 1024,
477 };
478
479 assert_eq!(result.iterations_run(), 5);
480 assert_eq!(result.event_count(), 3);
481 assert_eq!(*result.termination_reason(), TerminationReason::Completed);
482 assert_eq!(result.output_bytes(), 1024);
483 assert!(result.completed_successfully());
484 }
485}