//! The Tests tab (oxygenhub#114): a bookmark's test results from its runs' //! JUnit reports, their trend, what changed against the run before, the //! slowest tests, and those that failed and passed across recent runs. use super::coverage::{chart, compact, Point, Series}; use super::{filters, lookup, Error, Page, RepoShell, Shell}; use crate::auth::Viewer; use crate::junit::{self, Case, Flaky, Report}; use crate::Shared; use askama::Template; use axum::extract::{Path, Query, State}; use serde::Deserialize; /// Reports the charts and the recent list draw on. const HISTORY: i64 = 200; /// Runs listed under Recent runs. const RECENT: usize = 10; /// Runs a test must have failed and passed in to be listed as flaky. const FLAKY_RUNS: usize = 30; /// Rows of the slowest and flaky lists, and of the changed lists. const LISTED: i64 = 15; #[derive(Deserialize, Default)] pub struct TestsQuery { /// A bookmark other than the default. bookmark: Option, /// One report, by id. report: Option, } /// A run in the Recent runs list. pub struct RecentRow { pub report: Report, /// Tests added against the report before it. pub delta: Option, } /// A test whose result changed against the run before. pub struct Changed { pub title: String, pub status: String, } #[derive(Template)] #[template(path = "tests.html")] pub struct TestsPage { shell: Shell, repo: RepoShell, bookmark: String, bookmarks: Vec, report: Option, previous: Option, /// Tests that fail now and passed in the run before. broke: Vec, /// Tests that pass now and failed in the run before. fixed: Vec, /// Tests new since the run before. added: usize, failures: Vec, slowest: Vec, flaky: Vec, flaky_runs: usize, rate_chart: String, count_chart: String, runs: usize, first_ms: Option, recent: Vec, } /// Passed of the tests that ran (skipped ones left out), as a percentage. pub fn pass_rate(r: &Report) -> Option { let ran = r.tests - r.skipped; (ran > 0).then(|| r.passed as f64 * 100.0 / ran as f64) } impl TestsPage { pub fn rate(&self, r: &Report) -> String { match pass_rate(r) { Some(p) if p >= 99.95 => "100%".into(), Some(p) => format!("{p:.1}%"), None => "–".into(), } } /// A colour class: all passing, a few failing, many. pub fn class_of(&self, r: &Report) -> &'static str { match pass_rate(r) { None => "cov-none", Some(_) if r.broken() == 0 => "cov-good", Some(p) if p >= 95.0 => "cov-low", Some(_) => "cov-poor", } } pub fn time(&self, ms: &i64) -> String { crate::flow::human_ms(*ms) } pub fn signed(&self, d: &i64) -> String { if *d == 0 { "±0".into() } else { format!("{d:+}") } } pub fn count_delta(&self) -> Option { Some(self.report.as_ref()?.tests - self.previous.as_ref()?.tests) } pub fn time_delta(&self) -> Option { let (r, p) = (self.report.as_ref()?, self.previous.as_ref()?); if p.time_ms == 0 { return None; } let d = (r.time_ms - p.time_ms) as f64 * 100.0 / p.time_ms as f64; (d.abs() >= 1.0).then(|| format!("{d:+.0}%")) } } /// `GET /{owner}/{name}/tests`. pub async fn overview( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Query(q): Query, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/tests"), &viewer); let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let e = |e| Error::from_db(shell.clone(), e); let picked = match q.report { Some(id) => Some(junit::report(&app.db, row.id, id).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?), None => None, }; let bookmark = picked.as_ref().map(|r| r.bookmark.clone()).or(q.bookmark.clone()).unwrap_or_else(|| row.default_bookmark.clone()); let bookmarks = junit::bookmarks(&app.db, row.id).await.map_err(e)?; let history = junit::on_bookmark(&app.db, row.id, &bookmark, HISTORY).await.map_err(e)?; let report = picked.or_else(|| history.first().cloned()); // The report before the one shown, of the same job on the bookmark. let previous = report.as_ref().and_then(|r| { history.iter().find(|h| h.job == r.job && (h.created_ms < r.created_ms || (h.created_ms == r.created_ms && h.id < r.id))).cloned() }); let (mut broke, mut fixed, mut added) = (vec![], vec![], 0); let (mut failures, mut slowest) = (vec![], vec![]); if let Some(r) = &report { failures = junit::failures(&app.db, r.id, 50).await.map_err(e)?; slowest = junit::slowest(&app.db, r.id, LISTED).await.map_err(e)?; if let Some(p) = &previous { let now = junit::statuses(&app.db, r.id).await.map_err(e)?; let before = junit::statuses(&app.db, p.id).await.map_err(e)?; let bad = |s: &str| s == "failed" || s == "error"; let title = |(c, n): &(String, String)| if c.is_empty() { n.clone() } else { format!("{c}::{n}") }; for (k, s) in &now { match before.get(k) { None => added += 1, Some(b) if bad(s) && !bad(b) => broke.push(Changed { title: title(k), status: s.clone() }), Some(b) if s == "passed" && bad(b) => fixed.push(Changed { title: title(k), status: s.clone() }), _ => {} } } broke.sort_by(|a, b| a.title.cmp(&b.title)); fixed.sort_by(|a, b| a.title.cmp(&b.title)); broke.truncate(LISTED as usize); fixed.truncate(LISTED as usize); } } let recent_ids: Vec = history.iter().take(FLAKY_RUNS).map(|r| r.id).collect(); let flaky = junit::flaky(&app.db, &recent_ids, LISTED).await.map_err(e)?; let repo = RepoShell::new(&app, &row, "tests").with_role(role); // Charts: oldest on the left. let oldest_first: Vec<&Report> = history.iter().rev().filter(|r| r.tests > 0).collect(); let points: Vec = oldest_first .iter() .map(|r| Point { run: r.run_number, href: format!("{}/tests?report={}", repo.url, r.id), tip: format!("Run #{} · {} · {}", r.run_number, r.summary(), crate::flow::human_ms(r.time_ms)), }) .collect(); let rates: Vec = oldest_first.iter().map(|r| pass_rate(r).unwrap_or(0.0)).collect(); let lo = (rates.iter().copied().fold(100.0, f64::min) - 2.0).floor().max(0.0); let rate_chart = chart( &format!("Tests passing on {bookmark} over the last {} runs", oldest_first.len()), &points, &[Series { values: rates, class: "cov", floor: None }], lo, 100.0, &|v| format!("{v:.0}%"), ); let passed: Vec = oldest_first.iter().map(|r| r.passed as f64).collect(); let top = oldest_first.iter().map(|r| r.tests - r.skipped).max().unwrap_or(0) as f64; let count_chart = chart( &format!("Tests passing and failing on {bookmark}"), &points, &[ Series { values: passed.clone(), class: "covered", floor: None }, Series { values: oldest_first.iter().map(|r| (r.tests - r.skipped) as f64).collect(), class: "relevant", floor: Some(passed) }, ], 0.0, (top * 1.08).max(1.0), &compact, ); let recent = history .iter() .enumerate() .take(RECENT) .map(|(i, r)| RecentRow { report: r.clone(), delta: history.get(i + 1).filter(|p| p.job == r.job).map(|p| r.tests - p.tests) }) .collect(); let first_ms = junit::first_ms(&app.db, row.id).await.map_err(e)?; Ok(Page(TestsPage { shell, repo, bookmark, bookmarks, report, previous, broke, fixed, added, failures, slowest, flaky, flaky_runs: recent_ids.len(), rate_chart, count_chart, runs: oldest_first.len(), first_ms, recent, })) }