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        self.save()
154    }
155
156    /// Save history to disk.
157    fn save(&self) -> crate::error::Result<()> {
158        std::fs::create_dir_all(&self.data_dir).map_err(|e| TestxError::HistoryError {
159            message: format!("Failed to create history dir: {e}"),
160        })?;
161
162        let history_file = self.data_dir.join("history.json");
163        let content =
164            serde_json::to_string_pretty(&self.runs).map_err(|e| TestxError::HistoryError {
165                message: format!("Failed to serialize history: {e}"),
166            })?;
167
168        std::fs::write(&history_file, content).map_err(|e| TestxError::HistoryError {
169            message: format!("Failed to write history: {e}"),
170        })?;
171
172        Ok(())
173    }
174
175    /// Get the number of recorded runs.
176    pub fn run_count(&self) -> usize {
177        self.runs.len()
178    }
179
180    /// Get all run records.
181    pub fn runs(&self) -> &[RunRecord] {
182        &self.runs
183    }
184
185    /// Get the most recent N runs.
186    pub fn recent_runs(&self, n: usize) -> &[RunRecord] {
187        let start = self.runs.len().saturating_sub(n);
188        &self.runs[start..]
189    }
190
191    /// Get trend data for a specific test.
192    pub fn get_trend(&self, test_name: &str, last_n: usize) -> Vec<TestTrend> {
193        let runs = self.recent_runs(last_n);
194        let mut trend = Vec::new();
195
196        for run in runs {
197            if let Some(test) = run.tests.iter().find(|t| t.name == test_name) {
198                trend.push(TestTrend {
199                    timestamp: run.timestamp.clone(),
200                    status: test.status.clone(),
201                    duration_ms: test.duration_ms,
202                });
203            }
204        }
205
206        trend
207    }
208
209    /// Get flaky tests (tests that alternate between pass and fail).
210    pub fn get_flaky_tests(&self, min_runs: usize, max_pass_rate: f64) -> Vec<FlakyTest> {
211        let recent = self.recent_runs(50);
212        let mut test_history: HashMap<String, Vec<bool>> = HashMap::new();
213
214        for run in recent {
215            for test in &run.tests {
216                let passed = test.status == "passed";
217                test_history
218                    .entry(test.name.clone())
219                    .or_default()
220                    .push(passed);
221            }
222        }
223
224        let mut flaky = Vec::new();
225        for (name, results) in &test_history {
226            if results.len() < min_runs {
227                continue;
228            }
229
230            let passes = results.iter().filter(|&&r| r).count();
231            let pass_rate = passes as f64 / results.len() as f64;
232
233            // A test is flaky if it has a pass rate between max_pass_rate and (1 - max_pass_rate)
234            if pass_rate > 0.0 && pass_rate < max_pass_rate {
235                let recent: String = results
236                    .iter()
237                    .rev()
238                    .take(10)
239                    .map(|&r| if r { 'P' } else { 'F' })
240                    .collect();
241
242                flaky.push(FlakyTest {
243                    name: name.clone(),
244                    pass_rate,
245                    total_runs: results.len(),
246                    failures: results.len() - passes,
247                    recent_pattern: recent,
248                });
249            }
250        }
251
252        flaky.sort_by(|a, b| {
253            a.pass_rate
254                .partial_cmp(&b.pass_rate)
255                .unwrap_or(std::cmp::Ordering::Equal)
256        });
257
258        flaky
259    }
260
261    /// Get tests that are getting slower over time.
262    pub fn get_slowest_trending(&self, last_n: usize, min_runs: usize) -> Vec<SlowTest> {
263        let recent = self.recent_runs(last_n);
264        let mut test_durations: HashMap<String, Vec<u64>> = HashMap::new();
265
266        for run in recent {
267            for test in &run.tests {
268                if test.status == "passed" {
269                    test_durations
270                        .entry(test.name.clone())
271                        .or_default()
272                        .push(test.duration_ms);
273                }
274            }
275        }
276
277        let mut slow_tests = Vec::new();
278        for (name, durations) in &test_durations {
279            if durations.len() < min_runs {
280                continue;
281            }
282
283            let avg: u64 = durations.iter().sum::<u64>() / durations.len() as u64;
284            let latest = *durations.last().unwrap_or(&0);
285
286            let change_pct = if avg > 0 {
287                (latest as f64 - avg as f64) / avg as f64 * 100.0
288            } else {
289                0.0
290            };
291
292            let trend = if change_pct > 20.0 {
293                DurationTrend::Slower
294            } else if change_pct < -20.0 {
295                DurationTrend::Faster
296            } else {
297                DurationTrend::Stable
298            };
299
300            slow_tests.push(SlowTest {
301                name: name.clone(),
302                avg_duration: Duration::from_millis(avg),
303                latest_duration: Duration::from_millis(latest),
304                trend,
305                change_pct,
306            });
307        }
308
309        slow_tests.sort_by(|a, b| {
310            b.change_pct
311                .partial_cmp(&a.change_pct)
312                .unwrap_or(std::cmp::Ordering::Equal)
313        });
314
315        slow_tests
316    }
317
318    /// Prune old entries, keeping only the most recent N.
319    pub fn prune(&mut self, keep: usize) -> crate::error::Result<usize> {
320        if self.runs.len() <= keep {
321            return Ok(0);
322        }
323        let removed = self.runs.len() - keep;
324        self.runs.drain(..removed);
325        self.save()?;
326        Ok(removed)
327    }
328
329    /// Get the overall pass rate over recent runs.
330    pub fn pass_rate(&self, last_n: usize) -> f64 {
331        let recent = self.recent_runs(last_n);
332        if recent.is_empty() {
333            return 0.0;
334        }
335
336        let total_passed: usize = recent.iter().map(|r| r.passed).sum();
337        let total_tests: usize = recent.iter().map(|r| r.total).sum();
338
339        if total_tests > 0 {
340            total_passed as f64 / total_tests as f64 * 100.0
341        } else {
342            0.0
343        }
344    }
345
346    /// Get the average duration over recent runs.
347    pub fn avg_duration(&self, last_n: usize) -> Duration {
348        let recent = self.recent_runs(last_n);
349        if recent.is_empty() {
350            return Duration::ZERO;
351        }
352
353        let total_ms: u64 = recent.iter().map(|r| r.duration_ms).sum();
354        Duration::from_millis(total_ms / recent.len() as u64)
355    }
356}
357
358impl RunRecord {
359    /// Create a RunRecord from a TestRunResult.
360    pub fn from_result(result: &TestRunResult) -> Self {
361        let tests: Vec<TestRecord> = result
362            .suites
363            .iter()
364            .flat_map(|suite| {
365                suite.tests.iter().map(|test| {
366                    let status = match test.status {
367                        TestStatus::Passed => "passed",
368                        TestStatus::Failed => "failed",
369                        TestStatus::Skipped => "skipped",
370                    };
371                    TestRecord {
372                        name: format!("{}::{}", suite.name, test.name),
373                        status: status.to_string(),
374                        duration_ms: test.duration.as_millis() as u64,
375                        error: test.error.as_ref().map(|e| e.message.clone()),
376                    }
377                })
378            })
379            .collect();
380
381        Self {
382            timestamp: chrono_now(),
383            total: result.total_tests(),
384            passed: result.total_passed(),
385            failed: result.total_failed(),
386            skipped: result.total_skipped(),
387            duration_ms: result.duration.as_millis() as u64,
388            exit_code: result.raw_exit_code,
389            tests,
390        }
391    }
392}
393
394/// Get current timestamp as ISO 8601 string (without chrono crate).
395fn chrono_now() -> String {
396    let duration = std::time::SystemTime::now()
397        .duration_since(std::time::UNIX_EPOCH)
398        .unwrap_or_default();
399    let secs = duration.as_secs();
400
401    // Simple UTC timestamp calculation
402    let days = secs / 86400;
403    let time_secs = secs % 86400;
404    let hours = time_secs / 3600;
405    let minutes = (time_secs % 3600) / 60;
406    let seconds = time_secs % 60;
407
408    // Days since Unix epoch to year/month/day (simplified)
409    let (year, month, day) = days_to_date(days);
410
411    format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
412}
413
414/// Convert days since Unix epoch to (year, month, day).
415fn days_to_date(mut days: u64) -> (u64, u64, u64) {
416    let mut year = 1970;
417
418    loop {
419        let days_in_year = if is_leap_year(year) { 366 } else { 365 };
420        if days < days_in_year {
421            break;
422        }
423        days -= days_in_year;
424        year += 1;
425    }
426
427    let month_days = if is_leap_year(year) {
428        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
429    } else {
430        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
431    };
432
433    let mut month = 1;
434    for &md in &month_days {
435        if days < md {
436            break;
437        }
438        days -= md;
439        month += 1;
440    }
441
442    (year, month, days + 1)
443}
444
445fn is_leap_year(year: u64) -> bool {
446    (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use crate::adapters::{TestCase, TestError, TestSuite};
453
454    fn make_test(name: &str, status: TestStatus, ms: u64) -> TestCase {
455        TestCase {
456            name: name.into(),
457            status,
458            duration: Duration::from_millis(ms),
459            error: None,
460        }
461    }
462
463    fn make_failed_test(name: &str, ms: u64, msg: &str) -> TestCase {
464        TestCase {
465            name: name.into(),
466            status: TestStatus::Failed,
467            duration: Duration::from_millis(ms),
468            error: Some(TestError {
469                message: msg.into(),
470                location: None,
471            }),
472        }
473    }
474
475    fn make_result(passed: usize, failed: usize, skipped: usize) -> TestRunResult {
476        let mut tests = Vec::new();
477        for i in 0..passed {
478            tests.push(make_test(
479                &format!("pass_{i}"),
480                TestStatus::Passed,
481                10 + i as u64,
482            ));
483        }
484        for i in 0..failed {
485            tests.push(make_failed_test(
486                &format!("fail_{i}"),
487                5,
488                "assertion failed",
489            ));
490        }
491        for i in 0..skipped {
492            tests.push(make_test(&format!("skip_{i}"), TestStatus::Skipped, 0));
493        }
494
495        TestRunResult {
496            suites: vec![TestSuite {
497                name: "suite".into(),
498                tests,
499            }],
500            duration: Duration::from_millis(100),
501            raw_exit_code: if failed > 0 { 1 } else { 0 },
502        }
503    }
504
505    #[test]
506    fn new_in_memory() {
507        let history = TestHistory::new_in_memory();
508        assert_eq!(history.run_count(), 0);
509    }
510
511    #[test]
512    fn record_run() {
513        let mut history = TestHistory::new_in_memory();
514        // Don't save to disk in tests
515        history
516            .runs
517            .push(RunRecord::from_result(&make_result(5, 1, 0)));
518        assert_eq!(history.run_count(), 1);
519    }
520
521    #[test]
522    fn run_record_from_result() {
523        let result = make_result(3, 1, 1);
524        let record = RunRecord::from_result(&result);
525        assert_eq!(record.total, 5);
526        assert_eq!(record.passed, 3);
527        assert_eq!(record.failed, 1);
528        assert_eq!(record.skipped, 1);
529        assert_eq!(record.tests.len(), 5);
530    }
531
532    #[test]
533    fn run_record_test_names() {
534        let result = make_result(2, 0, 0);
535        let record = RunRecord::from_result(&result);
536        assert_eq!(record.tests[0].name, "suite::pass_0");
537        assert_eq!(record.tests[1].name, "suite::pass_1");
538    }
539
540    #[test]
541    fn run_record_error_captured() {
542        let result = make_result(0, 1, 0);
543        let record = RunRecord::from_result(&result);
544        assert_eq!(record.tests[0].error.as_deref(), Some("assertion failed"));
545    }
546
547    #[test]
548    fn recent_runs() {
549        let mut history = TestHistory::new_in_memory();
550        for _ in 0..10 {
551            history
552                .runs
553                .push(RunRecord::from_result(&make_result(5, 0, 0)));
554        }
555        assert_eq!(history.recent_runs(3).len(), 3);
556        assert_eq!(history.recent_runs(20).len(), 10);
557    }
558
559    #[test]
560    fn get_trend() {
561        let mut history = TestHistory::new_in_memory();
562        for i in 0..5 {
563            let mut record = RunRecord::from_result(&make_result(3, 0, 0));
564            record.tests[0].duration_ms = 10 + i * 5;
565            history.runs.push(record);
566        }
567
568        let trend = history.get_trend("suite::pass_0", 10);
569        assert_eq!(trend.len(), 5);
570        assert_eq!(trend[0].duration_ms, 10);
571        assert_eq!(trend[4].duration_ms, 30);
572    }
573
574    #[test]
575    fn get_flaky_tests() {
576        let mut history = TestHistory::new_in_memory();
577
578        // Create alternating pass/fail for the same test name
579        for i in 0..10 {
580            let status = if i % 2 == 0 {
581                TestStatus::Passed
582            } else {
583                TestStatus::Failed
584            };
585            let result = TestRunResult {
586                suites: vec![TestSuite {
587                    name: "suite".into(),
588                    tests: vec![TestCase {
589                        name: "flaky_test".into(),
590                        status,
591                        duration: Duration::from_millis(10),
592                        error: None,
593                    }],
594                }],
595                duration: Duration::from_millis(50),
596                raw_exit_code: 0,
597            };
598            history.runs.push(RunRecord::from_result(&result));
599        }
600
601        let flaky = history.get_flaky_tests(5, 0.95);
602        // The test should appear as flaky (50% pass rate)
603        assert!(!flaky.is_empty());
604    }
605
606    #[test]
607    fn get_flaky_no_flaky() {
608        let mut history = TestHistory::new_in_memory();
609        for _ in 0..10 {
610            history
611                .runs
612                .push(RunRecord::from_result(&make_result(5, 0, 0)));
613        }
614
615        let flaky = history.get_flaky_tests(5, 0.95);
616        assert!(flaky.is_empty());
617    }
618
619    #[test]
620    fn get_slowest_trending() {
621        let mut history = TestHistory::new_in_memory();
622
623        for i in 0..10 {
624            let mut record = RunRecord::from_result(&make_result(2, 0, 0));
625            // Make test progressively slower
626            record.tests[0].duration_ms = 100 + i * 50;
627            record.tests[1].duration_ms = 50; // stable
628            history.runs.push(record);
629        }
630
631        let slow = history.get_slowest_trending(10, 5);
632        assert!(!slow.is_empty());
633        // First test should be trending slower
634        let first = slow.iter().find(|s| s.name.contains("pass_0"));
635        assert!(first.is_some());
636    }
637
638    #[test]
639    fn pass_rate_all_pass() {
640        let mut history = TestHistory::new_in_memory();
641        for _ in 0..5 {
642            history
643                .runs
644                .push(RunRecord::from_result(&make_result(10, 0, 0)));
645        }
646        assert_eq!(history.pass_rate(10), 100.0);
647    }
648
649    #[test]
650    fn pass_rate_mixed() {
651        let mut history = TestHistory::new_in_memory();
652        history
653            .runs
654            .push(RunRecord::from_result(&make_result(8, 2, 0)));
655        assert!((history.pass_rate(10) - 80.0).abs() < 0.1);
656    }
657
658    #[test]
659    fn pass_rate_empty() {
660        let history = TestHistory::new_in_memory();
661        assert_eq!(history.pass_rate(10), 0.0);
662    }
663
664    #[test]
665    fn avg_duration() {
666        let mut history = TestHistory::new_in_memory();
667        for _ in 0..4 {
668            let mut record = RunRecord::from_result(&make_result(1, 0, 0));
669            record.duration_ms = 100;
670            history.runs.push(record);
671        }
672        assert_eq!(history.avg_duration(10), Duration::from_millis(100));
673    }
674
675    #[test]
676    fn prune_runs() {
677        let mut history = TestHistory::new_in_memory();
678        for _ in 0..20 {
679            history
680                .runs
681                .push(RunRecord::from_result(&make_result(1, 0, 0)));
682        }
683        // Can't save to real path, just test the pruning logic
684        let before = history.run_count();
685        history.runs.drain(..10);
686        assert_eq!(history.run_count(), before - 10);
687    }
688
689    #[test]
690    fn days_to_date_epoch() {
691        let (y, m, d) = days_to_date(0);
692        assert_eq!((y, m, d), (1970, 1, 1));
693    }
694
695    #[test]
696    fn days_to_date_known() {
697        // 2024-01-01 is 19723 days from epoch
698        let (y, m, d) = days_to_date(19723);
699        assert_eq!(y, 2024);
700        assert_eq!(m, 1);
701        assert_eq!(d, 1);
702    }
703
704    #[test]
705    fn leap_year() {
706        assert!(is_leap_year(2000));
707        assert!(is_leap_year(2024));
708        assert!(!is_leap_year(1900));
709        assert!(!is_leap_year(2023));
710    }
711
712    #[test]
713    fn chrono_now_format() {
714        let ts = chrono_now();
715        assert!(ts.contains('T'));
716        assert!(ts.ends_with('Z'));
717        assert_eq!(ts.len(), 20);
718    }
719
720    #[test]
721    fn duration_trend_variants() {
722        assert_eq!(DurationTrend::Faster, DurationTrend::Faster);
723        assert_ne!(DurationTrend::Faster, DurationTrend::Slower);
724    }
725}