OXYGENHUB
◆ v0.5.1 oxygenhub / crates / hub / src / web / test_reports.rs
10.6 KiB · Rust · last changed Tests: a test's history, and a tests check naming what broke (part 3) 29 days ago source blame history raw
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.
71
pub fn pass_rate(r: &Report) -> Option<f64> {
72
    let ran = r.tests - r.skipped;
73
    (ran > 0).then(|| r.passed as f64 * 100.0 / ran as f64)
74
}
75
76
impl TestsPage {
77
    pub fn rate(&self, r: &Report) -> String {
78
        match pass_rate(r) {
79
            Some(p) if p >= 99.95 => "100%".into(),
80
            Some(p) => format!("{p:.1}%"),
81
            None => "–".into(),
82
        }
83
    }
84
85
    /// A colour class: all passing, a few failing, many.
86
    pub fn class_of(&self, r: &Report) -> &'static str {
87
        match pass_rate(r) {
88
            None => "cov-none",
89
            Some(_) if r.broken() == 0 => "cov-good",
90
            Some(p) if p >= 95.0 => "cov-low",
91
            Some(_) => "cov-poor",
92
        }
93
    }
94
95
    pub fn time(&self, ms: &i64) -> String {
96
        crate::flow::human_ms(*ms)
97
    }
98
99
    pub fn signed(&self, d: &i64) -> String {
100
        if *d == 0 {
101
            "±0".into()
102
        } else {
103
            format!("{d:+}")
104
        }
105
    }
106
107
    pub fn count_delta(&self) -> Option<i64> {
108
        Some(self.report.as_ref()?.tests - self.previous.as_ref()?.tests)
109
    }
110
111
    pub fn time_delta(&self) -> Option<String> {
112
        let (r, p) = (self.report.as_ref()?, self.previous.as_ref()?);
113
        if p.time_ms == 0 {
114
            return None;
115
        }
116
        let d = (r.time_ms - p.time_ms) as f64 * 100.0 / p.time_ms as f64;
117
        (d.abs() >= 1.0).then(|| format!("{d:+.0}%"))
118
    }
119
}
120
121
/// `GET /{owner}/{name}/tests`.
122
pub async fn overview(
123
    State(app): State<Shared>,
124
    viewer: Viewer,
125
    Path((owner, name)): Path<(String, String)>,
126
    Query(q): Query<TestsQuery>,
127
) -> Result<Page<TestsPage>, Error> {
128
    let shell = Shell::new(&app, &format!("/{owner}/{name}/tests"), &viewer);
129
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
130
    let e = |e| Error::from_db(shell.clone(), e);
131
    let picked = match q.report {
132
        Some(id) => Some(junit::report(&app.db, row.id, id).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?),
133
        None => None,
134
    };
135
    let bookmark = picked.as_ref().map(|r| r.bookmark.clone()).or(q.bookmark.clone()).unwrap_or_else(|| row.default_bookmark.clone());
136
    let bookmarks = junit::bookmarks(&app.db, row.id).await.map_err(e)?;
137
    let history = junit::on_bookmark(&app.db, row.id, &bookmark, HISTORY).await.map_err(e)?;
138
    let report = picked.or_else(|| history.first().cloned());
139
    // The report before the one shown, of the same job on the bookmark.
140
    let previous = report.as_ref().and_then(|r| {
141
        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()
142
    });
143
    let (mut broke, mut fixed, mut added) = (vec![], vec![], 0);
144
    let (mut failures, mut slowest) = (vec![], vec![]);
145
    if let Some(r) = &report {
146
        failures = junit::failures(&app.db, r.id, 50).await.map_err(e)?;
147
        slowest = junit::slowest(&app.db, r.id, LISTED).await.map_err(e)?;
148
        if let Some(p) = &previous {
149
            let now = junit::statuses(&app.db, r.id).await.map_err(e)?;
150
            let before = junit::statuses(&app.db, p.id).await.map_err(e)?;
151
            let bad = |s: &str| s == "failed" || s == "error";
152
            let title = |(c, n): &(String, String)| if c.is_empty() { n.clone() } else { format!("{c}::{n}") };
153
            for (k, s) in &now {
154
                match before.get(k) {
155
                    None => added += 1,
156
                    Some(b) if bad(s) && !bad(b) => broke.push(Changed { title: title(k), status: s.clone() }),
157
                    Some(b) if s == "passed" && bad(b) => fixed.push(Changed { title: title(k), status: s.clone() }),
158
                    _ => {}
159
                }
160
            }
161
            broke.sort_by(|a, b| a.title.cmp(&b.title));
162
            fixed.sort_by(|a, b| a.title.cmp(&b.title));
163
            broke.truncate(LISTED as usize);
164
            fixed.truncate(LISTED as usize);
165
        }
166
    }
167
    let recent_ids: Vec<i64> = history.iter().take(FLAKY_RUNS).map(|r| r.id).collect();
168
    let flaky = junit::flaky(&app.db, &recent_ids, LISTED).await.map_err(e)?;
169
    let repo = RepoShell::new(&app, &row, "tests").with_role(role);
170
    // Charts: oldest on the left.
171
    let oldest_first: Vec<&Report> = history.iter().rev().filter(|r| r.tests > 0).collect();
172
    let points: Vec<Point> = oldest_first
173
        .iter()
174
        .map(|r| Point {
175
            run: r.run_number,
176
            href: format!("{}/tests?report={}", repo.url, r.id),
177
            tip: format!("Run #{} · {} · {}", r.run_number, r.summary(), crate::flow::human_ms(r.time_ms)),
178
        })
179
        .collect();
180
    let rates: Vec<f64> = oldest_first.iter().map(|r| pass_rate(r).unwrap_or(0.0)).collect();
181
    let lo = (rates.iter().copied().fold(100.0, f64::min) - 2.0).floor().max(0.0);
182
    let rate_chart = chart(
183
        &format!("Tests passing on {bookmark} over the last {} runs", oldest_first.len()),
184
        &points,
185
        &[Series { values: rates, class: "cov", floor: None }],
186
        lo,
187
        100.0,
188
        &|v| format!("{v:.0}%"),
189
    );
190
    let passed: Vec<f64> = oldest_first.iter().map(|r| r.passed as f64).collect();
191
    let top = oldest_first.iter().map(|r| r.tests - r.skipped).max().unwrap_or(0) as f64;
192
    let count_chart = chart(
193
        &format!("Tests passing and failing on {bookmark}"),
194
        &points,
195
        &[
196
            Series { values: passed.clone(), class: "covered", floor: None },
197
            Series { values: oldest_first.iter().map(|r| (r.tests - r.skipped) as f64).collect(), class: "relevant", floor: Some(passed) },
198
        ],
199
        0.0,
200
        (top * 1.08).max(1.0),
201
        &compact,
202
    );
203
    let recent = history
204
        .iter()
205
        .enumerate()
206
        .take(RECENT)
207
        .map(|(i, r)| RecentRow { report: r.clone(), delta: history.get(i + 1).filter(|p| p.job == r.job).map(|p| r.tests - p.tests) })
208
        .collect();
209
    let first_ms = junit::first_ms(&app.db, row.id).await.map_err(e)?;
210
    Ok(Page(TestsPage {
211
        shell,
212
        repo,
213
        bookmark,
214
        bookmarks,
215
        report,
216
        previous,
217
        broke,
218
        fixed,
219
        added,
220
        failures,
221
        slowest,
222
        flaky,
223
        flaky_runs: recent_ids.len(),
224
        rate_chart,
225
        count_chart,
226
        runs: oldest_first.len(),
227
        first_ms,
228
        recent,
229
    }))
230
}
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 {
254
    pub fn time(&self, ms: &i64) -> String {
255
        crate::flow::human_ms(*ms)
256
    }
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.
261
pub async fn case(
262
    State(app): State<Shared>,
263
    viewer: Viewer,
264
    Path((owner, name)): Path<(String, String)>,
265
    Query(q): Query<CaseQuery>,
266
) -> Result<Page<TestCasePage>, Error> {
267
    let shell = Shell::new(&app, &format!("/{owner}/{name}/tests"), &viewer);
268
    let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
269
    let bookmark = q.bookmark.unwrap_or_else(|| row.default_bookmark.clone());
270
    let runs =
271
        junit::history(&app.db, row.id, &bookmark, &q.classname, &q.name, HISTORY).await.map_err(|e| Error::from_db(shell.clone(), e))?;
272
    if runs.is_empty() {
273
        return Err(Error::not_found(shell));
274
    }
275
    let repo = RepoShell::new(&app, &row, "tests").with_role(role);
276
    let passed = runs.iter().filter(|r| r.status == "passed").count();
277
    let failed = runs.iter().filter(|r| r.status == "failed" || r.status == "error").count();
278
    let oldest: Vec<&junit::Run> = runs.iter().rev().collect();
279
    let points: Vec<Point> = oldest
280
        .iter()
281
        .map(|r| Point {
282
            run: r.run_number,
283
            href: format!("{}/tests?report={}", repo.url, r.report_id),
284
            tip: format!("Run #{} · {} · {}", r.run_number, r.status, crate::flow::human_ms(r.time_ms)),
285
        })
286
        .collect();
287
    let top = oldest.iter().map(|r| r.time_ms).max().unwrap_or(0) as f64;
288
    let time_chart = chart(
289
        "Time per run",
290
        &points,
291
        &[Series { values: oldest.iter().map(|r| r.time_ms as f64).collect(), class: "cov", floor: None }],
292
        0.0,
293
        (top * 1.1).max(1.0),
294
        &|v| crate::flow::human_ms(v as i64),
295
    );
296
    let title = if q.classname.is_empty() { q.name.clone() } else { format!("{}::{}", q.classname, q.name) };
297
    Ok(Page(TestCasePage { shell, repo, bookmark, title, runs, passed, failed, time_chart }))
298
}