OXYGENHUB

Coverage / crates/hub/src/web/test_reports.rs 95.0%

191 of 201 lines run, 37 of 84 functions · run #529 on main · run missed

1
//! The Tests tab (oxygenhub#114): a bookmark's test results from its runs'
2
//! JUnit reports, their trend, what changed against the run before, the
3
//! slowest tests, and those that failed and passed across recent runs.
4
5
use super::coverage::{chart, compact, Point, Series};
6
use super::{filters, lookup, Error, Page, RepoShell, Shell};
7
use crate::auth::Viewer;
8
use crate::junit::{self, Case, Flaky, Report};
9
use crate::Shared;
10
use askama::Template;
11
use axum::extract::{Path, Query, State};
12
use serde::Deserialize;
13
14
/// Reports the charts and the recent list draw on.
15
const HISTORY: i64 = 200;
16
/// Runs listed under Recent runs.
17
const RECENT: usize = 10;
18
/// Runs a test must have failed and passed in to be listed as flaky.
19
const FLAKY_RUNS: usize = 30;
20
/// Rows of the slowest and flaky lists, and of the changed lists.
21
const LISTED: i64 = 15;
22
23
#[derive(Deserialize, Default)]
24
pub struct TestsQuery {
25
    /// A bookmark other than the default.
26
    bookmark: Option<String>,
27
    /// One report, by id.
28
    report: Option<i64>,
29
}
30
31
/// A run in the Recent runs list.
32
pub struct RecentRow {
33
    pub report: Report,
34
    /// Tests added against the report before it.
35
    pub delta: Option<i64>,
36
}
37
38
/// A test whose result changed against the run before.
39
pub struct Changed {
40
    pub title: String,
41
    pub status: String,
42
}
43
44
#[derive(Template)]
45
#[template(path = "tests.html")]
46
pub struct TestsPage {
47
    shell: Shell,
48
    repo: RepoShell,
49
    bookmark: String,
50
    bookmarks: Vec<String>,
51
    report: Option<Report>,
52
    previous: Option<Report>,
53
    /// Tests that fail now and passed in the run before.
54
    broke: Vec<Changed>,
55
    /// Tests that pass now and failed in the run before.
56
    fixed: Vec<Changed>,
57
    /// Tests new since the run before.
58
    added: usize,
59
    failures: Vec<Case>,
60
    slowest: Vec<Case>,
61
    flaky: Vec<Flaky>,
62
    flaky_runs: usize,
63
    rate_chart: String,
64
    count_chart: String,
65
    runs: usize,
66
    first_ms: Option<i64>,
67
    recent: Vec<RecentRow>,
68
}
69
70
/// Passed of the tests that ran (skipped ones left out), as a percentage.
7148×
pub fn pass_rate(r: &Report) -> Option<f64> {
7248×
    let ran = r.tests - r.skipped;
7348×
    (ran > 0).then(|| r.passed as f64 * 100.0 / ran as f64)
7448×
}
75
76
impl TestsPage {
7715×
    pub fn rate(&self, r: &Report) -> String {
7815×
        match pass_rate(r) {
7915×
            Some(p) if p >= 99.95 => "100%".into(),
805×
            Some(p) => format!("{p:.1}%"),
810×
            None => "–".into(),
82
        }
8315×
    }
84
85
    /// A colour class: all passing, a few failing, many.
8615×
    pub fn class_of(&self, r: &Report) -> &'static str {
8715×
        match pass_rate(r) {
880×
            None => "cov-none",
8915×
            Some(_) if r.broken() == 0 => "cov-good",
905×
            Some(p) if p >= 95.0 => "cov-low",
915×
            Some(_) => "cov-poor",
92
        }
9315×
    }
94
9524×
    pub fn time(&self, ms: &i64) -> String {
9624×
        crate::flow::human_ms(*ms)
9724×
    }
98
996×
    pub fn signed(&self, d: &i64) -> String {
1006×
        if *d == 0 {
1012×
            "±0".into()
102
        } else {
1034×
            format!("{d:+}")
104
        }
1056×
    }
106
1073×
    pub fn count_delta(&self) -> Option<i64> {
1083×
        Some(self.report.as_ref()?.tests - self.previous.as_ref()?.tests)
1093×
    }
110
1113×
    pub fn time_delta(&self) -> Option<String> {
1123×
        let (r, p) = (self.report.as_ref()?, self.previous.as_ref()?);
1133×
        if p.time_ms == 0 {
1140×
            return None;
1153×
        }
1163×
        let d = (r.time_ms - p.time_ms) as f64 * 100.0 / p.time_ms as f64;
1173×
        (d.abs() >= 1.0).then(|| format!("{d:+.0}%"))
1183×
    }
119
}
120
121
/// `GET /{owner}/{name}/tests`.
1223×
pub async fn overview(
1233×
    State(app): State<Shared>,
1243×
    viewer: Viewer,
1253×
    Path((owner, name)): Path<(String, String)>,
1263×
    Query(q): Query<TestsQuery>,
1273×
) -> Result<Page<TestsPage>, Error> {
1283×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/tests"), &viewer);
1293×
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
1303×
    let e = |e| Error::from_db(shell.clone(), e);
1313×
    let picked = match q.report {
1321×
        Some(id) => Some(junit::report(&app.db, row.id, id).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?),
1332×
        None => None,
134
    };
1353×
    let bookmark = picked.as_ref().map(|r| r.bookmark.clone()).or(q.bookmark.clone()).unwrap_or_else(|| row.default_bookmark.clone());
1363×
    let bookmarks = junit::bookmarks(&app.db, row.id).await.map_err(e)?;
1373×
    let history = junit::on_bookmark(&app.db, row.id, &bookmark, HISTORY).await.map_err(e)?;
1383×
    let report = picked.or_else(|| history.first().cloned());
139
    // The report before the one shown, of the same job on the bookmark.
1403×
    let previous = report.as_ref().and_then(|r| {
1417×
        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()
1423×
    });
1433×
    let (mut broke, mut fixed, mut added) = (vec![], vec![], 0);
1443×
    let (mut failures, mut slowest) = (vec![], vec![]);
1453×
    if let Some(r) = &report {
1463×
        failures = junit::failures(&app.db, r.id, 50).await.map_err(e)?;
1473×
        slowest = junit::slowest(&app.db, r.id, LISTED).await.map_err(e)?;
1483×
        if let Some(p) = &previous {
1493×
            let now = junit::statuses(&app.db, r.id).await.map_err(e)?;
1503×
            let before = junit::statuses(&app.db, p.id).await.map_err(e)?;
15122×
            let bad = |s: &str| s == "failed" || s == "error";
1523×
            let title = |(c, n): &(String, String)| if c.is_empty() { n.clone() } else { format!("{c}::{n}") };
15312×
            for (k, s) in &now {
15412×
                match before.get(k) {
1551×
                    None => added += 1,
15611×
                    Some(b) if bad(s) && !bad(b) => broke.push(Changed { title: title(k), status: s.clone() }),
15710×
                    Some(b) if s == "passed" && bad(b) => fixed.push(Changed { title: title(k), status: s.clone() }),
1588×
                    _ => {}
159
                }
160
            }
1613×
            broke.sort_by(|a, b| a.title.cmp(&b.title));
1623×
            fixed.sort_by(|a, b| a.title.cmp(&b.title));
1633×
            broke.truncate(LISTED as usize);
1643×
            fixed.truncate(LISTED as usize);
1650×
        }
1660×
    }
1673×
    let recent_ids: Vec<i64> = history.iter().take(FLAKY_RUNS).map(|r| r.id).collect();
1683×
    let flaky = junit::flaky(&app.db, &recent_ids, LISTED).await.map_err(e)?;
1693×
    let repo = RepoShell::new(&app, &row, "tests").with_role(role);
170
    // Charts: oldest on the left.
1719×
    let oldest_first: Vec<&Report> = history.iter().rev().filter(|r| r.tests > 0).collect();
1723×
    let points: Vec<Point> = oldest_first
1733×
        .iter()
1743×
        .map(|r| Point {
1759×
            run: r.run_number,
1769×
            href: format!("{}/tests?report={}", repo.url, r.id),
1779×
            tip: format!("Run #{} · {} · {}", r.run_number, r.summary(), crate::flow::human_ms(r.time_ms)),
1789×
        })
1793×
        .collect();
1809×
    let rates: Vec<f64> = oldest_first.iter().map(|r| pass_rate(r).unwrap_or(0.0)).collect();
1813×
    let lo = (rates.iter().copied().fold(100.0, f64::min) - 2.0).floor().max(0.0);
1823×
    let rate_chart = chart(
1833×
        &format!("Tests passing on {bookmark} over the last {} runs", oldest_first.len()),
1843×
        &points,
1853×
        &[Series { values: rates, class: "cov", floor: None }],
1863×
        lo,
187
        100.0,
18815×
        &|v| format!("{v:.0}%"),
189
    );
1909×
    let passed: Vec<f64> = oldest_first.iter().map(|r| r.passed as f64).collect();
1919×
    let top = oldest_first.iter().map(|r| r.tests - r.skipped).max().unwrap_or(0) as f64;
1923×
    let count_chart = chart(
1933×
        &format!("Tests passing and failing on {bookmark}"),
1943×
        &points,
195
        &[
1963×
            Series { values: passed.clone(), class: "covered", floor: None },
1979×
            Series { values: oldest_first.iter().map(|r| (r.tests - r.skipped) as f64).collect(), class: "relevant", floor: Some(passed) },
198
        ],
199
        0.0,
2003×
        (top * 1.08).max(1.0),
2013×
        &compact,
202
    );
2033×
    let recent = history
2043×
        .iter()
2053×
        .enumerate()
2063×
        .take(RECENT)
2079×
        .map(|(i, r)| RecentRow { report: r.clone(), delta: history.get(i + 1).filter(|p| p.job == r.job).map(|p| r.tests - p.tests) })
2083×
        .collect();
2093×
    let first_ms = junit::first_ms(&app.db, row.id).await.map_err(e)?;
2103×
    Ok(Page(TestsPage {
2113×
        shell,
2123×
        repo,
2133×
        bookmark,
2143×
        bookmarks,
2153×
        report,
2163×
        previous,
2173×
        broke,
2183×
        fixed,
2193×
        added,
2203×
        failures,
2213×
        slowest,
2223×
        flaky,
2233×
        flaky_runs: recent_ids.len(),
2243×
        rate_chart,
2253×
        count_chart,
2263×
        runs: oldest_first.len(),
2273×
        first_ms,
2283×
        recent,
2293×
    }))
2303×
}
231
232
#[derive(Deserialize)]
233
pub struct CaseQuery {
234
    #[serde(default)]
235
    classname: String,
236
    name: String,
237
    bookmark: Option<String>,
238
}
239
240
#[derive(Template)]
241
#[template(path = "test_case.html")]
242
pub struct TestCasePage {
243
    shell: Shell,
244
    repo: RepoShell,
245
    bookmark: String,
246
    title: String,
247
    runs: Vec<junit::Run>,
248
    passed: usize,
249
    failed: usize,
250
    time_chart: String,
251
}
252
253
impl TestCasePage {
2543×
    pub fn time(&self, ms: &i64) -> String {
2553×
        crate::flow::human_ms(*ms)
2563×
    }
257
}
258
259
/// `GET /{owner}/{name}/tests/case?classname=&name=`: one test across the
260
/// bookmark's last runs: its result, time and message in each.
2612×
pub async fn case(
2622×
    State(app): State<Shared>,
2632×
    viewer: Viewer,
2642×
    Path((owner, name)): Path<(String, String)>,
2652×
    Query(q): Query<CaseQuery>,
2662×
) -> Result<Page<TestCasePage>, Error> {
2672×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/tests"), &viewer);
2682×
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
2692×
    let bookmark = q.bookmark.unwrap_or_else(|| row.default_bookmark.clone());
2702×
    let runs =
2712×
        junit::history(&app.db, row.id, &bookmark, &q.classname, &q.name, HISTORY).await.map_err(|e| Error::from_db(shell.clone(), e))?;
2722×
    if runs.is_empty() {
2731×
        return Err(Error::not_found(shell));
2741×
    }
2751×
    let repo = RepoShell::new(&app, &row, "tests").with_role(role);
2763×
    let passed = runs.iter().filter(|r| r.status == "passed").count();
2773×
    let failed = runs.iter().filter(|r| r.status == "failed" || r.status == "error").count();
2781×
    let oldest: Vec<&junit::Run> = runs.iter().rev().collect();
2791×
    let points: Vec<Point> = oldest
2801×
        .iter()
2811×
        .map(|r| Point {
2823×
            run: r.run_number,
2833×
            href: format!("{}/tests?report={}", repo.url, r.report_id),
2843×
            tip: format!("Run #{} · {} · {}", r.run_number, r.status, crate::flow::human_ms(r.time_ms)),
2853×
        })
2861×
        .collect();
2871×
    let top = oldest.iter().map(|r| r.time_ms).max().unwrap_or(0) as f64;
2881×
    let time_chart = chart(
2891×
        "Time per run",
2901×
        &points,
2913×
        &[Series { values: oldest.iter().map(|r| r.time_ms as f64).collect(), class: "cov", floor: None }],
292
        0.0,
2931×
        (top * 1.1).max(1.0),
2945×
        &|v| crate::flow::human_ms(v as i64),
295
    );
2961×
    let title = if q.classname.is_empty() { q.name.clone() } else { format!("{}::{}", q.classname, q.name) };
2971×
    Ok(Page(TestCasePage { shell, repo, bookmark, title, runs, passed, failed, time_chart }))
2982×
}