use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::playwright::{TestRun, TestStatus};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum HistOutcome {
Pass,
Fail,
Flaky,
}
const KEEP: usize = 10;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TestHistory {
by_test: HashMap<String, Vec<HistOutcome>>,
#[serde(default)]
last_line: HashMap<String, u32>,
}
#[derive(Debug, Clone)]
pub struct WobblyRow {
pub file: String,
pub suite_path: String,
pub title: String,
pub outcomes: Vec<HistOutcome>,
pub line: u32,
}
impl TestHistory {
pub fn load(workspace: &Path) -> Self {
let path = Self::path(workspace);
let Ok(s) = std::fs::read_to_string(&path) else {
return Self::default();
};
serde_json::from_str(&s).unwrap_or_default()
}
pub fn save(&self, workspace: &Path) {
let path = Self::path(workspace);
let Some(parent) = path.parent() else { return };
if std::fs::create_dir_all(parent).is_err() {
return;
}
if let Ok(s) = serde_json::to_string(self) {
let _ = std::fs::write(&path, s);
}
}
fn path(workspace: &Path) -> PathBuf {
workspace.join(".mnml").join("test-history.json")
}
pub fn record_run(&mut self, run: &TestRun) {
for tc in &run.tests {
let outcome = match tc.status {
TestStatus::Passed => HistOutcome::Pass,
TestStatus::Failed => HistOutcome::Fail,
TestStatus::Flaky => HistOutcome::Flaky,
TestStatus::Skipped => continue,
};
let key = Self::key(&tc.file, &tc.suite_path, &tc.title);
let v = self.by_test.entry(key.clone()).or_default();
v.push(outcome);
if v.len() > KEEP {
let drop_n = v.len() - KEEP;
v.drain(..drop_n);
}
self.last_line.insert(key, tc.line);
}
}
pub fn wobbly_tests(&self) -> Vec<WobblyRow> {
let mut rows: Vec<WobblyRow> = self
.by_test
.iter()
.filter_map(|(k, outcomes)| {
let mut parts = k.splitn(3, '\t');
let file = parts.next()?.to_string();
let suite_path = parts.next()?.to_string();
let title = parts.next()?.to_string();
let pass = outcomes.contains(&HistOutcome::Pass);
let other = outcomes.iter().any(|o| *o != HistOutcome::Pass);
if !(pass && other) {
return None;
}
let line = self.last_line.get(k).copied().unwrap_or(0);
Some(WobblyRow {
file,
suite_path,
title,
outcomes: outcomes.clone(),
line,
})
})
.collect();
rows.sort_by(|a, b| a.file.cmp(&b.file).then(a.title.cmp(&b.title)));
rows
}
pub fn is_wobbly(&self, file: &str, suite_path: &str, title: &str) -> bool {
let Some(v) = self.by_test.get(&Self::key(file, suite_path, title)) else {
return false;
};
let pass = v.contains(&HistOutcome::Pass);
let other = v.iter().any(|o| *o != HistOutcome::Pass);
pass && other
}
pub fn wobbly_count(&self, run: &TestRun) -> usize {
run.tests
.iter()
.filter(|tc| self.is_wobbly(&tc.file, &tc.suite_path, &tc.title))
.count()
}
fn key(file: &str, suite_path: &str, title: &str) -> String {
format!("{file}\t{suite_path}\t{title}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::playwright::TestCase;
fn case(title: &str, status: TestStatus) -> TestCase {
TestCase {
title: title.into(),
suite_path: "S".into(),
file: "x.spec.ts".into(),
line: 1,
status,
duration_ms: 1,
error: None,
trace_path: None,
}
}
#[test]
fn records_caps_and_classifies() {
let mut h = TestHistory::default();
let run = TestRun {
command: String::new(),
global_errors: Vec::new(),
tests: vec![
case("flips", TestStatus::Passed),
case("solid", TestStatus::Passed),
case("dead", TestStatus::Failed),
case("skipme", TestStatus::Skipped),
],
};
h.record_run(&run);
assert!(!h.is_wobbly("x.spec.ts", "S", "flips"));
assert!(!h.is_wobbly("x.spec.ts", "S", "solid"));
assert!(!h.is_wobbly("x.spec.ts", "S", "skipme"));
let run2 = TestRun {
command: String::new(),
global_errors: Vec::new(),
tests: vec![
case("flips", TestStatus::Failed),
case("solid", TestStatus::Passed),
],
};
h.record_run(&run2);
assert!(h.is_wobbly("x.spec.ts", "S", "flips"));
assert!(!h.is_wobbly("x.spec.ts", "S", "solid"));
for _ in 0..12 {
let r = TestRun {
command: String::new(),
global_errors: Vec::new(),
tests: vec![case("flips", TestStatus::Passed)],
};
h.record_run(&r);
}
assert!(!h.is_wobbly("x.spec.ts", "S", "flips"));
}
#[test]
fn round_trips_through_disk() {
let d = tempfile::tempdir().unwrap();
let mut h = TestHistory::default();
h.record_run(&TestRun {
command: String::new(),
global_errors: Vec::new(),
tests: vec![case("a", TestStatus::Passed), case("a", TestStatus::Failed)],
});
h.save(d.path());
let h2 = TestHistory::load(d.path());
assert!(h2.is_wobbly("x.spec.ts", "S", "a"));
}
#[test]
fn missing_file_loads_empty() {
let d = tempfile::tempdir().unwrap();
let h = TestHistory::load(d.path());
assert!(!h.is_wobbly("x.spec.ts", "S", "anything"));
}
}