Skip to main content

ralph_core/testing/
smoke_runner.rs

1//! Smoke test replay runner for CI-friendly testing.
2//!
3//! Loads JSONL session fixtures and runs them through the event loop with `ReplayBackend`,
4//! enabling deterministic testing without live API calls.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use ralph_core::testing::{SmokeRunner, SmokeTestConfig};
10//!
11//! let config = SmokeTestConfig::new("tests/fixtures/basic_session.jsonl");
12//! let result = SmokeRunner::run(&config)?;
13//!
14//! assert!(result.completed_successfully());
15//! assert_eq!(result.iterations_run(), 3);
16//! ```
17
18use std::path::{Path, PathBuf};
19use std::time::Duration;
20
21use super::ReplayBackend;
22
23/// Configuration for a smoke test run.
24#[derive(Debug, Clone)]
25pub struct SmokeTestConfig {
26    /// Path to the JSONL fixture file.
27    pub fixture_path: PathBuf,
28    /// Maximum time to run before timing out.
29    pub timeout: Duration,
30    /// Expected number of iterations (for validation, optional).
31    pub expected_iterations: Option<u32>,
32    /// Expected termination reason (for validation, optional).
33    pub expected_termination: Option<String>,
34}
35
36impl SmokeTestConfig {
37    /// Creates a new smoke test configuration.
38    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    /// Sets the timeout for this smoke test.
48    pub fn with_timeout(mut self, timeout: Duration) -> Self {
49        self.timeout = timeout;
50        self
51    }
52
53    /// Sets expected iterations for validation.
54    pub fn with_expected_iterations(mut self, iterations: u32) -> Self {
55        self.expected_iterations = Some(iterations);
56        self
57    }
58
59    /// Sets expected termination reason for validation.
60    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/// Result of a smoke test run.
67#[derive(Debug, Clone)]
68pub struct SmokeTestResult {
69    /// Number of event loop iterations executed.
70    iterations: u32,
71    /// Number of events parsed from the fixture.
72    events_parsed: usize,
73    /// Reason the test terminated.
74    termination_reason: TerminationReason,
75    /// Total output bytes processed.
76    output_bytes: usize,
77}
78
79/// Reason the smoke test terminated.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum TerminationReason {
82    /// Completed successfully (completion promise detected).
83    Completed,
84    /// Fixture exhausted (all output consumed).
85    FixtureExhausted,
86    /// Timeout reached.
87    Timeout,
88    /// Maximum iterations reached.
89    MaxIterations,
90    /// Error during execution.
91    Error(String),
92}
93
94impl SmokeTestResult {
95    /// Returns true if the test completed successfully.
96    pub fn completed_successfully(&self) -> bool {
97        matches!(
98            self.termination_reason,
99            TerminationReason::Completed | TerminationReason::FixtureExhausted
100        )
101    }
102
103    /// Returns the number of iterations executed.
104    pub fn iterations_run(&self) -> u32 {
105        self.iterations
106    }
107
108    /// Returns the number of events parsed.
109    pub fn event_count(&self) -> usize {
110        self.events_parsed
111    }
112
113    /// Returns the termination reason.
114    pub fn termination_reason(&self) -> &TerminationReason {
115        &self.termination_reason
116    }
117
118    /// Returns the total output bytes processed.
119    pub fn output_bytes(&self) -> usize {
120        self.output_bytes
121    }
122}
123
124/// Error types for smoke test operations.
125#[derive(Debug, thiserror::Error)]
126pub enum SmokeTestError {
127    /// Fixture file not found.
128    #[error("Fixture not found: {0}")]
129    FixtureNotFound(PathBuf),
130
131    /// IO error reading fixture.
132    #[error("IO error: {0}")]
133    Io(#[from] std::io::Error),
134
135    /// Invalid fixture format.
136    #[error("Invalid fixture format: {0}")]
137    InvalidFixture(String),
138
139    /// Timeout during execution.
140    #[error("Timeout after {0:?}")]
141    Timeout(Duration),
142}
143
144/// Lists available fixtures in a directory.
145pub 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
164/// The smoke test runner.
165pub struct SmokeRunner;
166
167impl SmokeRunner {
168    /// Runs a smoke test with the given configuration.
169    ///
170    /// # Errors
171    ///
172    /// Returns an error if:
173    /// - The fixture file is not found
174    /// - The fixture file cannot be read
175    /// - The fixture format is invalid
176    pub fn run(config: &SmokeTestConfig) -> Result<SmokeTestResult, SmokeTestError> {
177        // Validate fixture exists
178        if !config.fixture_path.exists() {
179            return Err(SmokeTestError::FixtureNotFound(config.fixture_path.clone()));
180        }
181
182        // Load the replay backend
183        let mut backend = ReplayBackend::from_file(&config.fixture_path)?;
184
185        // Track metrics
186        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        // Process all output chunks
193        while let Some(chunk) = backend.next_output() {
194            // Check timeout
195            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            // Convert chunk to string and parse events
207            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                // Check for completion promise
213                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        // Fixture exhausted
227        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    /// Helper to create a JSONL fixture file.
243    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    /// Creates a terminal write JSONL line.
251    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    // ─────────────────────────────────────────────────────────────────────────
265    // Acceptance Criteria #5: Fixture Not Found
266    // ─────────────────────────────────────────────────────────────────────────
267
268    #[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    // ─────────────────────────────────────────────────────────────────────────
279    // Acceptance Criteria #1: Run Fixture Through Event Loop
280    // ─────────────────────────────────────────────────────────────────────────
281
282    #[test]
283    fn test_run_fixture_through_event_loop() {
284        let temp_dir = TempDir::new().unwrap();
285
286        // Create a simple fixture with some output
287        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        // Verify the fixture was processed
298        assert!(result.iterations_run() > 0);
299        assert!(result.output_bytes() > 0);
300    }
301
302    // ─────────────────────────────────────────────────────────────────────────
303    // Acceptance Criteria #2: Capture Termination Reason
304    // ─────────────────────────────────────────────────────────────────────────
305
306    #[test]
307    fn test_captures_completion_termination() {
308        let temp_dir = TempDir::new().unwrap();
309
310        // Create a fixture with completion promise
311        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        // Create a fixture WITHOUT completion promise
329        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()); // FixtureExhausted is considered success
343    }
344
345    // ─────────────────────────────────────────────────────────────────────────
346    // Acceptance Criteria #3: Event Counting
347    // ─────────────────────────────────────────────────────────────────────────
348
349    #[test]
350    fn test_event_counting() {
351        let temp_dir = TempDir::new().unwrap();
352
353        // Create a fixture with events
354        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        // Should have parsed 2 events
372        assert_eq!(result.event_count(), 2);
373    }
374
375    // ─────────────────────────────────────────────────────────────────────────
376    // Acceptance Criteria #4: Timeout Handling
377    // ─────────────────────────────────────────────────────────────────────────
378
379    #[test]
380    fn test_timeout_handling() {
381        let temp_dir = TempDir::new().unwrap();
382
383        // Create a fixture - we'll use a very short timeout
384        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        // Note: This test verifies timeout handling works, but won't actually timeout
390        // since the fixture is small. A real timeout test would need realistic timing.
391        let config = SmokeTestConfig::new(&fixture_path).with_timeout(Duration::from_millis(1)); // Very short timeout
392
393        let result = SmokeRunner::run(&config).unwrap();
394
395        // The test should complete quickly so won't actually timeout,
396        // but the timeout mechanism is in place
397        assert!(
398            result.completed_successfully()
399                || *result.termination_reason() == TerminationReason::Timeout
400        );
401    }
402
403    // ─────────────────────────────────────────────────────────────────────────
404    // Acceptance Criteria #6: Fixture Discovery
405    // ─────────────────────────────────────────────────────────────────────────
406
407    #[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 some fixture files
420        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    // ─────────────────────────────────────────────────────────────────────────
437    // Additional edge cases
438    // ─────────────────────────────────────────────────────────────────────────
439
440    #[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}