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(
180 config.fixture_path.clone(),
181 ));
182 }
183
184 let mut backend = ReplayBackend::from_file(&config.fixture_path)?;
186
187 let mut iterations = 0u32;
189 let mut events_parsed = 0usize;
190 let mut output_bytes = 0usize;
191
192 let start_time = std::time::Instant::now();
193
194 while let Some(chunk) = backend.next_output() {
196 if start_time.elapsed() > config.timeout {
198 return Ok(SmokeTestResult {
199 iterations,
200 events_parsed,
201 termination_reason: TerminationReason::Timeout,
202 output_bytes,
203 });
204 }
205
206 output_bytes += chunk.len();
207
208 if let Ok(output) = String::from_utf8(chunk) {
210 let parser = crate::EventParser::new();
211 let events = parser.parse(&output);
212 events_parsed += events.len();
213
214 if crate::EventParser::contains_promise(&output, "LOOP_COMPLETE") {
216 return Ok(SmokeTestResult {
217 iterations,
218 events_parsed,
219 termination_reason: TerminationReason::Completed,
220 output_bytes,
221 });
222 }
223 }
224
225 iterations += 1;
226 }
227
228 Ok(SmokeTestResult {
230 iterations,
231 events_parsed,
232 termination_reason: TerminationReason::FixtureExhausted,
233 output_bytes,
234 })
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use tempfile::TempDir;
242 use std::io::Write;
243
244 fn create_fixture(dir: &Path, name: &str, content: &str) -> PathBuf {
246 let path = dir.join(name);
247 let mut file = std::fs::File::create(&path).unwrap();
248 file.write_all(content.as_bytes()).unwrap();
249 path
250 }
251
252 fn make_write_line(text: &str, offset_ms: u64) -> String {
254 use crate::session_recorder::Record;
255 use ralph_proto::TerminalWrite;
256
257 let write = TerminalWrite::new(text.as_bytes(), true, offset_ms);
258 let record = Record {
259 ts: 1000 + offset_ms,
260 event: "ux.terminal.write".to_string(),
261 data: serde_json::to_value(&write).unwrap(),
262 };
263 serde_json::to_string(&record).unwrap()
264 }
265
266 #[test]
271 fn test_fixture_not_found_returns_error() {
272 let config = SmokeTestConfig::new("/nonexistent/path/to/fixture.jsonl");
273 let result = SmokeRunner::run(&config);
274
275 assert!(result.is_err());
276 let err = result.unwrap_err();
277 assert!(matches!(err, SmokeTestError::FixtureNotFound(_)));
278 }
279
280 #[test]
285 fn test_run_fixture_through_event_loop() {
286 let temp_dir = TempDir::new().unwrap();
287
288 let line1 = make_write_line("Starting task...", 0);
290 let line2 = make_write_line("Working on implementation...", 100);
291 let line3 = make_write_line("Task complete!", 200);
292 let content = format!("{}\n{}\n{}\n", line1, line2, line3);
293
294 let fixture_path = create_fixture(temp_dir.path(), "basic.jsonl", &content);
295
296 let config = SmokeTestConfig::new(&fixture_path);
297 let result = SmokeRunner::run(&config).unwrap();
298
299 assert!(result.iterations_run() > 0);
301 assert!(result.output_bytes() > 0);
302 }
303
304 #[test]
309 fn test_captures_completion_termination() {
310 let temp_dir = TempDir::new().unwrap();
311
312 let line1 = make_write_line("Working...", 0);
314 let line2 = make_write_line("LOOP_COMPLETE", 100);
315 let content = format!("{}\n{}\n", line1, line2);
316
317 let fixture_path = create_fixture(temp_dir.path(), "completion.jsonl", &content);
318
319 let config = SmokeTestConfig::new(&fixture_path);
320 let result = SmokeRunner::run(&config).unwrap();
321
322 assert_eq!(*result.termination_reason(), TerminationReason::Completed);
323 assert!(result.completed_successfully());
324 }
325
326 #[test]
327 fn test_captures_fixture_exhausted_termination() {
328 let temp_dir = TempDir::new().unwrap();
329
330 let line1 = make_write_line("Some output", 0);
332 let line2 = make_write_line("More output", 100);
333 let content = format!("{}\n{}\n", line1, line2);
334
335 let fixture_path = create_fixture(temp_dir.path(), "no_completion.jsonl", &content);
336
337 let config = SmokeTestConfig::new(&fixture_path);
338 let result = SmokeRunner::run(&config).unwrap();
339
340 assert_eq!(*result.termination_reason(), TerminationReason::FixtureExhausted);
341 assert!(result.completed_successfully()); }
343
344 #[test]
349 fn test_event_counting() {
350 let temp_dir = TempDir::new().unwrap();
351
352 let output_with_events = r#"Some preamble
354<event topic="build.task">Task 1</event>
355Working on task...
356<event topic="build.done">
357tests: pass
358lint: pass
359typecheck: pass
360</event>"#;
361
362 let line1 = make_write_line(output_with_events, 0);
363 let content = format!("{}\n", line1);
364
365 let fixture_path = create_fixture(temp_dir.path(), "with_events.jsonl", &content);
366
367 let config = SmokeTestConfig::new(&fixture_path);
368 let result = SmokeRunner::run(&config).unwrap();
369
370 assert_eq!(result.event_count(), 2);
372 }
373
374 #[test]
379 fn test_timeout_handling() {
380 let temp_dir = TempDir::new().unwrap();
381
382 let line1 = make_write_line("Output 1", 0);
384 let content = format!("{}\n", line1);
385
386 let fixture_path = create_fixture(temp_dir.path(), "timeout_test.jsonl", &content);
387
388 let config = SmokeTestConfig::new(&fixture_path)
391 .with_timeout(Duration::from_millis(1)); let result = SmokeRunner::run(&config).unwrap();
394
395 assert!(result.completed_successfully() ||
398 *result.termination_reason() == TerminationReason::Timeout);
399 }
400
401 #[test]
406 fn test_list_fixtures_empty_directory() {
407 let temp_dir = TempDir::new().unwrap();
408
409 let fixtures = list_fixtures(temp_dir.path()).unwrap();
410 assert!(fixtures.is_empty());
411 }
412
413 #[test]
414 fn test_list_fixtures_finds_jsonl_files() {
415 let temp_dir = TempDir::new().unwrap();
416
417 create_fixture(temp_dir.path(), "test1.jsonl", "{}");
419 create_fixture(temp_dir.path(), "test2.jsonl", "{}");
420 create_fixture(temp_dir.path(), "not_a_fixture.txt", "text");
421
422 let fixtures = list_fixtures(temp_dir.path()).unwrap();
423
424 assert_eq!(fixtures.len(), 2);
425 assert!(fixtures.iter().all(|p| p.extension().unwrap() == "jsonl"));
426 }
427
428 #[test]
429 fn test_list_fixtures_nonexistent_directory() {
430 let fixtures = list_fixtures("/nonexistent/path").unwrap();
431 assert!(fixtures.is_empty());
432 }
433
434 #[test]
439 fn test_empty_fixture_completes() {
440 let temp_dir = TempDir::new().unwrap();
441
442 let fixture_path = create_fixture(temp_dir.path(), "empty.jsonl", "");
443
444 let config = SmokeTestConfig::new(&fixture_path);
445 let result = SmokeRunner::run(&config).unwrap();
446
447 assert_eq!(result.iterations_run(), 0);
448 assert_eq!(result.event_count(), 0);
449 assert_eq!(*result.termination_reason(), TerminationReason::FixtureExhausted);
450 }
451
452 #[test]
453 fn test_config_builder_pattern() {
454 let config = SmokeTestConfig::new("test.jsonl")
455 .with_timeout(Duration::from_secs(60))
456 .with_expected_iterations(5)
457 .with_expected_termination("Completed");
458
459 assert_eq!(config.fixture_path, PathBuf::from("test.jsonl"));
460 assert_eq!(config.timeout, Duration::from_secs(60));
461 assert_eq!(config.expected_iterations, Some(5));
462 assert_eq!(config.expected_termination, Some("Completed".to_string()));
463 }
464
465 #[test]
466 fn test_result_accessors() {
467 let result = SmokeTestResult {
468 iterations: 5,
469 events_parsed: 3,
470 termination_reason: TerminationReason::Completed,
471 output_bytes: 1024,
472 };
473
474 assert_eq!(result.iterations_run(), 5);
475 assert_eq!(result.event_count(), 3);
476 assert_eq!(*result.termination_reason(), TerminationReason::Completed);
477 assert_eq!(result.output_bytes(), 1024);
478 assert!(result.completed_successfully());
479 }
480}