Skip to main content

testx/history/
mod.rs

1//! Test history and trend tracking.
2//!
3//! Stores test run results over time for trend analysis,
4//! flaky test detection, and performance monitoring.
5//! Uses a simple JSON-based file store (no external DB dependency).
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::time::Duration;
10
11use crate::adapters::{TestRunResult, TestStatus};
12use crate::error::TestxError;
13
14pub mod analytics;
15pub mod display;
16
17/// Test history store backed by JSON files.
18pub struct TestHistory {
19    /// Directory where history files are stored
20    data_dir: PathBuf,
21    /// In-memory run records
22    runs: Vec<RunRecord>,
23    /// Maximum number of runs to keep
24    max_runs: usize,
25}
26
27/// A single recorded test run.
28#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
29pub struct RunRecord {
30    /// ISO 8601 timestamp
31    pub timestamp: String,
32    /// Total number of tests
33    pub total: usize,
34    /// Number of passed tests
35    pub passed: usize,
36    /// Number of failed tests
37    pub failed: usize,
38    /// Number of skipped tests
39    pub skipped: usize,
40    /// Total duration in milliseconds
41    pub duration_ms: u64,
42    /// Exit code
43    pub exit_code: i32,
44    /// Individual test results (name -> status + duration_ms)
45    pub tests: Vec<TestRecord>,
46}
47
48/// A single test case record.
49#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
50pub struct TestRecord {
51    /// Full test name (suite::test)
52    pub name: String,
53    /// Test status
54    pub status: String,
55    /// Duration in milliseconds
56    pub duration_ms: u64,
57    /// Error message if failed
58    pub error: Option<String>,
59}
60
61/// A test that has been detected as flaky.
62#[derive(Debug, Clone)]
63pub struct FlakyTest {
64    /// Test name
65    pub name: String,
66    /// Pass rate (0.0 - 1.0)
67    pub pass_rate: f64,
68    /// Number of runs analyzed
69    pub total_runs: usize,
70    /// Number of failures
71    pub failures: usize,
72    /// Number of recent consecutive results (P/F)
73    pub recent_pattern: String,
74}
75
76/// A test that is getting slower over time.
77#[derive(Debug, Clone)]
78pub struct SlowTest {
79    /// Test name
80    pub name: String,
81    /// Average duration over the period
82    pub avg_duration: Duration,
83    /// Most recent duration
84    pub latest_duration: Duration,
85    /// Duration trend
86    pub trend: DurationTrend,
87    /// Percentage change from average
88    pub change_pct: f64,
89}
90
91/// Duration trend direction.
92#[derive(Debug, Clone, PartialEq)]
93pub enum DurationTrend {
94    Faster,
95    Slower,
96    Stable,
97}
98
99/// Trend data point for a specific test.
100#[derive(Debug, Clone)]
101pub struct TestTrend {
102    /// Timestamp
103    pub timestamp: String,
104    /// Status at this point
105    pub status: String,
106    /// Duration in milliseconds
107    pub duration_ms: u64,
108}
109
110impl TestHistory {
111    /// Open or create a history store in the given directory.
112    pub fn open(dir: &Path) -> crate::error::Result<Self> {
113        let data_dir = dir.join(".testx");
114        let history_file = data_dir.join("history.json");
115
116        let runs = if history_file.exists() {
117            let content =
118                std::fs::read_to_string(&history_file).map_err(|e| TestxError::HistoryError {
119                    message: format!("Failed to read history: {e}"),
120                })?;
121            serde_json::from_str(&content).unwrap_or_default()
122        } else {
123            Vec::new()
124        };
125
126        Ok(Self {
127            data_dir,
128            runs,
129            max_runs: 500,
130        })
131    }
132
133    /// Create a new in-memory history (for testing).
134    pub fn new_in_memory() -> Self {
135        Self {
136            data_dir: PathBuf::from("/tmp/testx-history"),
137            runs: Vec::new(),
138            max_runs: 500,
139        }
140    }
141
142    /// Record a test run result.
143    pub fn record(&mut self, result: &TestRunResult) -> crate::error::Result<()> {
144        let record = RunRecord::from_result(result);
145        self.runs.push(record);
146
147        // Prune if over limit
148        if self.runs.len() > self.max_runs {
149            let excess = self.runs.len() - self.max_runs;
150            self.runs.drain(..excess);
151        }
152
153        // Save to disk; if it fails, roll back the in-memory change
154        if let Err(e) = self.save() {
155            self.runs.pop();
156            return Err(e);
157        }
158
159        Ok(())
160    }
161
162    /// Save history to disk.
163    fn save(&self) -> crate::error::Result<()> {
164        std::fs::create_dir_all(&self.data_dir).map_err(|e| TestxError::HistoryError {
165            message: format!("Failed to create history dir: {e}"),
166        })?;
167
168        let history_file = self.data_dir.join("history.json");
169        let content =
170            serde_json::to_string_pretty(&self.runs).map_err(|e| TestxError::HistoryError {
171                message: format!("Failed to serialize history: {e}"),
172            })?;
173
174        std::fs::write(&history_file, content).map_err(|e| TestxError::HistoryError {
175            message: format!("Failed to write history: {e}"),
176        })?;
177
178        Ok(())
179    }
180
181    /// Get the number of recorded runs.
182    pub fn run_count(&self) -> usize {
183        self.runs.len()
184    }
185
186    /// Get all run records.
187    pub fn runs(&self) -> &[RunRecord] {
188        &self.runs
189    }
190
191    /// Get the most recent N runs.
192    pub fn recent_runs(&self, n: usize) -> &[RunRecord] {
193        let start = self.runs.len().saturating_sub(n);
194        &self.runs[start..]
195    }
196
197    /// Get trend data for a specific test.
198    pub fn get_trend(&self, test_name: &str, last_n: usize) -> Vec<TestTrend> {
199        let runs = self.recent_runs(last_n);
200        let mut trend = Vec::new();
201
202        for run in runs {
203            if let Some(test) = run.tests.iter().find(|t| t.name == test_name) {
204                trend.push(TestTrend {
205                    timestamp: run.timestamp.clone(),
206                    status: test.status.clone(),
207                    duration_ms: test.duration_ms,
208                });
209            }
210        }
211
212        trend
213    }
214
215    /// Get flaky tests (tests that alternate between pass and fail).
216    pub fn get_flaky_tests(&self, min_runs: usize, max_pass_rate: f64) -> Vec<FlakyTest> {
217        let recent = self.recent_runs(50);
218        let mut test_history: HashMap<String, Vec<bool>> = HashMap::new();
219
220        for run in recent {
221            for test in &run.tests {
222                let passed = test.status == "passed";
223                test_history
224                    .entry(test.name.clone())
225                    .or_default()
226                    .push(passed);
227            }
228        }
229
230        let mut flaky = Vec::new();
231        for (name, results) in &test_history {
232            if results.len() < min_runs {
233                continue;
234            }
235
236            let passes = results.iter().filter(|&&r| r).count();
237            let pass_rate = passes as f64 / results.len() as f64;
238
239            // A test is flaky if it has a pass rate between (1 - max_pass_rate) and max_pass_rate
240            if pass_rate > 0.0 && pass_rate < max_pass_rate && pass_rate > (1.0 - max_pass_rate) {
241                let recent: String = results
242                    .iter()
243                    .rev()
244                    .take(10)
245                    .map(|&r| if r { 'P' } else { 'F' })
246                    .collect();
247
248                flaky.push(FlakyTest {
249                    name: name.clone(),
250                    pass_rate,
251                    total_runs: results.len(),
252                    failures: results.len() - passes,
253                    recent_pattern: recent,
254                });
255            }
256        }
257
258        flaky.sort_by(|a, b| {
259            a.pass_rate
260                .partial_cmp(&b.pass_rate)
261                .unwrap_or(std::cmp::Ordering::Equal)
262        });
263
264        flaky
265    }
266
267    /// Get tests that are getting slower over time.
268    pub fn get_slowest_trending(&self, last_n: usize, min_runs: usize) -> Vec<SlowTest> {
269        let recent = self.recent_runs(last_n);
270        let mut test_durations: HashMap<String, Vec<u64>> = HashMap::new();
271
272        for run in recent {
273            for test in &run.tests {
274                if test.status == "passed" {
275                    test_durations
276                        .entry(test.name.clone())
277                        .or_default()
278                        .push(test.duration_ms);
279                }
280            }
281        }
282
283        let mut slow_tests = Vec::new();
284        for (name, durations) in &test_durations {
285            if durations.len() < min_runs {
286                continue;
287            }
288
289            let avg: u64 = durations.iter().sum::<u64>() / durations.len() as u64;
290            let latest = *durations.last().unwrap_or(&0);
291
292            let change_pct = if avg > 0 {
293                (latest as f64 - avg as f64) / avg as f64 * 100.0
294            } else {
295                0.0
296            };
297
298            let trend = if change_pct > 20.0 {
299                DurationTrend::Slower
300            } else if change_pct < -20.0 {
301                DurationTrend::Faster
302            } else {
303                DurationTrend::Stable
304            };
305
306            slow_tests.push(SlowTest {
307                name: name.clone(),
308                avg_duration: Duration::from_millis(avg),
309                latest_duration: Duration::from_millis(latest),
310                trend,
311                change_pct,
312            });
313        }
314
315        slow_tests.sort_by(|a, b| {
316            b.change_pct
317                .partial_cmp(&a.change_pct)
318                .unwrap_or(std::cmp::Ordering::Equal)
319        });
320
321        slow_tests
322    }
323
324    /// Prune old entries, keeping only the most recent N.
325    pub fn prune(&mut self, keep: usize) -> crate::error::Result<usize> {
326        if self.runs.len() <= keep {
327            return Ok(0);
328        }
329        let removed = self.runs.len() - keep;
330        self.runs.drain(..removed);
331        self.save()?;
332        Ok(removed)
333    }
334
335    /// Get the overall pass rate over recent runs.
336    pub fn pass_rate(&self, last_n: usize) -> f64 {
337        let recent = self.recent_runs(last_n);
338        if recent.is_empty() {
339            return 0.0;
340        }
341
342        let total_passed: usize = recent.iter().map(|r| r.passed).sum();
343        let total_tests: usize = recent.iter().map(|r| r.total).sum();
344
345        if total_tests > 0 {
346            total_passed as f64 / total_tests as f64 * 100.0
347        } else {
348            0.0
349        }
350    }
351
352    /// Get the average duration over recent runs.
353    pub fn avg_duration(&self, last_n: usize) -> Duration {
354        let recent = self.recent_runs(last_n);
355        if recent.is_empty() {
356            return Duration::ZERO;
357        }
358
359        let total_ms: u64 = recent.iter().map(|r| r.duration_ms).sum();
360        Duration::from_millis(total_ms / recent.len() as u64)
361    }
362}
363
364impl RunRecord {
365    /// Create a RunRecord from a TestRunResult.
366    pub fn from_result(result: &TestRunResult) -> Self {
367        let tests: Vec<TestRecord> = result
368            .suites
369            .iter()
370            .flat_map(|suite| {
371                suite.tests.iter().map(|test| {
372                    let status = match test.status {
373                        TestStatus::Passed => "passed",
374                        TestStatus::Failed => "failed",
375                        TestStatus::Skipped => "skipped",
376                    };
377                    TestRecord {
378                        name: format!("{}::{}", suite.name, test.name),
379                        status: status.to_string(),
380                        duration_ms: test.duration.as_millis() as u64,
381                        error: test.error.as_ref().map(|e| e.message.clone()),
382                    }
383                })
384            })
385            .collect();
386
387        Self {
388            timestamp: chrono_now(),
389            total: result.total_tests(),
390            passed: result.total_passed(),
391            failed: result.total_failed(),
392            skipped: result.total_skipped(),
393            duration_ms: result.duration.as_millis() as u64,
394            exit_code: result.raw_exit_code,
395            tests,
396        }
397    }
398}
399
400/// Get current timestamp as ISO 8601 string (without chrono crate).
401fn chrono_now() -> String {
402    let duration = std::time::SystemTime::now()
403        .duration_since(std::time::UNIX_EPOCH)
404        .unwrap_or_default();
405    let secs = duration.as_secs();
406
407    // Simple UTC timestamp calculation
408    let days = secs / 86400;
409    let time_secs = secs % 86400;
410    let hours = time_secs / 3600;
411    let minutes = (time_secs % 3600) / 60;
412    let seconds = time_secs % 60;
413
414    // Days since Unix epoch to year/month/day (simplified)
415    let (year, month, day) = days_to_date(days);
416
417    format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
418}
419
420/// Convert days since Unix epoch to (year, month, day).
421fn days_to_date(mut days: u64) -> (u64, u64, u64) {
422    let mut year = 1970;
423
424    loop {
425        let days_in_year = if is_leap_year(year) { 366 } else { 365 };
426        if days < days_in_year {
427            break;
428        }
429        days -= days_in_year;
430        year += 1;
431    }
432
433    let month_days = if is_leap_year(year) {
434        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
435    } else {
436        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
437    };
438
439    let mut month = 1;
440    for &md in &month_days {
441        if days < md {
442            break;
443        }
444        days -= md;
445        month += 1;
446    }
447
448    (year, month, days + 1)
449}
450
451fn is_leap_year(year: u64) -> bool {
452    (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458    use crate::adapters::{TestCase, TestError, TestSuite};
459
460    fn make_test(name: &str, status: TestStatus, ms: u64) -> TestCase {
461        TestCase {
462            name: name.into(),
463            status,
464            duration: Duration::from_millis(ms),
465            error: None,
466        }
467    }
468
469    fn make_failed_test(name: &str, ms: u64, msg: &str) -> TestCase {
470        TestCase {
471            name: name.into(),
472            status: TestStatus::Failed,
473            duration: Duration::from_millis(ms),
474            error: Some(TestError {
475                message: msg.into(),
476                location: None,
477            }),
478        }
479    }
480
481    fn make_result(passed: usize, failed: usize, skipped: usize) -> TestRunResult {
482        let mut tests = Vec::new();
483        for i in 0..passed {
484            tests.push(make_test(
485                &format!("pass_{i}"),
486                TestStatus::Passed,
487                10 + i as u64,
488            ));
489        }
490        for i in 0..failed {
491            tests.push(make_failed_test(
492                &format!("fail_{i}"),
493                5,
494                "assertion failed",
495            ));
496        }
497        for i in 0..skipped {
498            tests.push(make_test(&format!("skip_{i}"), TestStatus::Skipped, 0));
499        }
500
501        TestRunResult {
502            suites: vec![TestSuite {
503                name: "suite".into(),
504                tests,
505            }],
506            duration: Duration::from_millis(100),
507            raw_exit_code: if failed > 0 { 1 } else { 0 },
508        }
509    }
510
511    #[test]
512    fn new_in_memory() {
513        let history = TestHistory::new_in_memory();
514        assert_eq!(history.run_count(), 0);
515    }
516
517    #[test]
518    fn record_run() {
519        let mut history = TestHistory::new_in_memory();
520        // Don't save to disk in tests
521        history
522            .runs
523            .push(RunRecord::from_result(&make_result(5, 1, 0)));
524        assert_eq!(history.run_count(), 1);
525    }
526
527    #[test]
528    fn run_record_from_result() {
529        let result = make_result(3, 1, 1);
530        let record = RunRecord::from_result(&result);
531        assert_eq!(record.total, 5);
532        assert_eq!(record.passed, 3);
533        assert_eq!(record.failed, 1);
534        assert_eq!(record.skipped, 1);
535        assert_eq!(record.tests.len(), 5);
536    }
537
538    #[test]
539    fn run_record_test_names() {
540        let result = make_result(2, 0, 0);
541        let record = RunRecord::from_result(&result);
542        assert_eq!(record.tests[0].name, "suite::pass_0");
543        assert_eq!(record.tests[1].name, "suite::pass_1");
544    }
545
546    #[test]
547    fn run_record_error_captured() {
548        let result = make_result(0, 1, 0);
549        let record = RunRecord::from_result(&result);
550        assert_eq!(record.tests[0].error.as_deref(), Some("assertion failed"));
551    }
552
553    #[test]
554    fn recent_runs() {
555        let mut history = TestHistory::new_in_memory();
556        for _ in 0..10 {
557            history
558                .runs
559                .push(RunRecord::from_result(&make_result(5, 0, 0)));
560        }
561        assert_eq!(history.recent_runs(3).len(), 3);
562        assert_eq!(history.recent_runs(20).len(), 10);
563    }
564
565    #[test]
566    fn get_trend() {
567        let mut history = TestHistory::new_in_memory();
568        for i in 0..5 {
569            let mut record = RunRecord::from_result(&make_result(3, 0, 0));
570            record.tests[0].duration_ms = 10 + i * 5;
571            history.runs.push(record);
572        }
573
574        let trend = history.get_trend("suite::pass_0", 10);
575        assert_eq!(trend.len(), 5);
576        assert_eq!(trend[0].duration_ms, 10);
577        assert_eq!(trend[4].duration_ms, 30);
578    }
579
580    #[test]
581    fn get_flaky_tests() {
582        let mut history = TestHistory::new_in_memory();
583
584        // Create alternating pass/fail for the same test name
585        for i in 0..10 {
586            let status = if i % 2 == 0 {
587                TestStatus::Passed
588            } else {
589                TestStatus::Failed
590            };
591            let result = TestRunResult {
592                suites: vec![TestSuite {
593                    name: "suite".into(),
594                    tests: vec![TestCase {
595                        name: "flaky_test".into(),
596                        status,
597                        duration: Duration::from_millis(10),
598                        error: None,
599                    }],
600                }],
601                duration: Duration::from_millis(50),
602                raw_exit_code: 0,
603            };
604            history.runs.push(RunRecord::from_result(&result));
605        }
606
607        let flaky = history.get_flaky_tests(5, 0.95);
608        // The test should appear as flaky (50% pass rate)
609        assert!(!flaky.is_empty());
610    }
611
612    #[test]
613    fn get_flaky_no_flaky() {
614        let mut history = TestHistory::new_in_memory();
615        for _ in 0..10 {
616            history
617                .runs
618                .push(RunRecord::from_result(&make_result(5, 0, 0)));
619        }
620
621        let flaky = history.get_flaky_tests(5, 0.95);
622        assert!(flaky.is_empty());
623    }
624
625    #[test]
626    fn get_slowest_trending() {
627        let mut history = TestHistory::new_in_memory();
628
629        for i in 0..10 {
630            let mut record = RunRecord::from_result(&make_result(2, 0, 0));
631            // Make test progressively slower
632            record.tests[0].duration_ms = 100 + i * 50;
633            record.tests[1].duration_ms = 50; // stable
634            history.runs.push(record);
635        }
636
637        let slow = history.get_slowest_trending(10, 5);
638        assert!(!slow.is_empty());
639        // First test should be trending slower
640        let first = slow.iter().find(|s| s.name.contains("pass_0"));
641        assert!(first.is_some());
642    }
643
644    #[test]
645    fn pass_rate_all_pass() {
646        let mut history = TestHistory::new_in_memory();
647        for _ in 0..5 {
648            history
649                .runs
650                .push(RunRecord::from_result(&make_result(10, 0, 0)));
651        }
652        assert_eq!(history.pass_rate(10), 100.0);
653    }
654
655    #[test]
656    fn pass_rate_mixed() {
657        let mut history = TestHistory::new_in_memory();
658        history
659            .runs
660            .push(RunRecord::from_result(&make_result(8, 2, 0)));
661        assert!((history.pass_rate(10) - 80.0).abs() < 0.1);
662    }
663
664    #[test]
665    fn pass_rate_empty() {
666        let history = TestHistory::new_in_memory();
667        assert_eq!(history.pass_rate(10), 0.0);
668    }
669
670    #[test]
671    fn avg_duration() {
672        let mut history = TestHistory::new_in_memory();
673        for _ in 0..4 {
674            let mut record = RunRecord::from_result(&make_result(1, 0, 0));
675            record.duration_ms = 100;
676            history.runs.push(record);
677        }
678        assert_eq!(history.avg_duration(10), Duration::from_millis(100));
679    }
680
681    #[test]
682    fn prune_runs() {
683        let mut history = TestHistory::new_in_memory();
684        for _ in 0..20 {
685            history
686                .runs
687                .push(RunRecord::from_result(&make_result(1, 0, 0)));
688        }
689        // Can't save to real path, just test the pruning logic
690        let before = history.run_count();
691        history.runs.drain(..10);
692        assert_eq!(history.run_count(), before - 10);
693    }
694
695    #[test]
696    fn days_to_date_epoch() {
697        let (y, m, d) = days_to_date(0);
698        assert_eq!((y, m, d), (1970, 1, 1));
699    }
700
701    #[test]
702    fn days_to_date_known() {
703        // 2024-01-01 is 19723 days from epoch
704        let (y, m, d) = days_to_date(19723);
705        assert_eq!(y, 2024);
706        assert_eq!(m, 1);
707        assert_eq!(d, 1);
708    }
709
710    #[test]
711    fn leap_year() {
712        assert!(is_leap_year(2000));
713        assert!(is_leap_year(2024));
714        assert!(!is_leap_year(1900));
715        assert!(!is_leap_year(2023));
716    }
717
718    #[test]
719    fn chrono_now_format() {
720        let ts = chrono_now();
721        assert!(ts.contains('T'));
722        assert!(ts.ends_with('Z'));
723        assert_eq!(ts.len(), 20);
724    }
725
726    #[test]
727    fn duration_trend_variants() {
728        assert_eq!(DurationTrend::Faster, DurationTrend::Faster);
729        assert_ne!(DurationTrend::Faster, DurationTrend::Slower);
730    }
731}