Tests tab: a bookmark's test results over its runs (part 2) 36e5a9c15a84
Shown once a repository has a JUnit report: the latest pass rate, or how many fail, over a chart of up to 200 runs; counts and test time against the run before; tests newly failing and fixed against the job's previous report; tests that failed and passed in the last 30 reports; the run's failures; a passed-and-failed chart; the slowest tests; recent runs. The coverage chart now takes plain points so both pages draw it. refs #114
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-06 09:01 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
36e5a9c15a841b3c83b70fa1077f2cc00a562d01776f24d6c824a2da019de3ff- Depends on
- 0b203bbf7cc3 2a83f5303458 42fa95540468 554ccacfc123 756fe44a0f4d 8933faff2043 93be9ce4653e b64e2537d702 c987c86513df d23464c0ac60
- Depended on by
- a22e4aec2e28 c26267b3aba6 a7aa76ac52d7 3b1bc27fe805 5d90e0e045e2 c8df33a6829f 67040b158ae2 3d5c825aebba 617c2840446c
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 27 atoms, 2 tree operations, 10 files
Browse the tree at this change
added crates/hub/src/web/test_reports.rs +230 −0
| 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 | + | } |
added templates/tests.html +105 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% import "repo_tabs.html" as m %} |
|
| 3 | + | {% block title %}Tests · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | + | {% block tabs %}{% call m::tabs(repo, "tests") %}{% endblock %} |
|
| 5 | + | {% block content %} |
|
| 6 | + | {% if let Some(r) = report %} |
|
| 7 | + | <section class="cov-hero"> |
|
| 8 | + | <div class="cov-hero-head"> |
|
| 9 | + | <div> |
|
| 10 | + | <h1>Tests</h1> |
|
| 11 | + | <div class="cov-sub">{% if bookmarks.len() > 1 %}<form class="inline" method="get" action="{{ repo.url }}/tests"><label for="t-bm" class="mute">bookmark</label> <select id="t-bm" name="bookmark" onchange="this.form.submit()">{% for b in bookmarks %}<option value="{{ b }}"{% if b.as_str() == bookmark.as_str() %} selected{% endif %}>{{ b }}</option>{% endfor %}</select> <noscript><button>Show</button></noscript></form>{% else %}<span class="mute">on</span> <strong>{{ bookmark }}</strong>{% endif %} |
|
| 12 | + | · <a href="{{ repo.url }}/flow/{{ r.run_number }}">run #{{ r.run_number }}</a> · job {{ r.job }} · <time title="{{ r.created_ms|when_i }}">{{ r.created_ms|ago_i }}</time></div> |
|
| 13 | + | </div> |
|
| 14 | + | <div class="cov-score {{ self.class_of(r) }}"> |
|
| 15 | + | <div class="cov-big">{% if r.broken() > 0 %}{{ r.broken() }} failing{% else %}{{ self.rate(r) }}{% endif %}</div> |
|
| 16 | + | <div class="cov-change same">{% if r.broken() > 0 %}{{ self.rate(r) }} of {{ r.tests - r.skipped }} passed{% else %}of {{ r.tests - r.skipped }} tests passed{% endif %}</div> |
|
| 17 | + | </div> |
|
| 18 | + | </div> |
|
| 19 | + | {% if runs > 1 %}<div class="cov-hero-chart">{{ rate_chart|safe }}</div>{% endif %} |
|
| 20 | + | </section> |
|
| 21 | + | ||
| 22 | + | <section class="cov-facts"> |
|
| 23 | + | <div><span class="k">Tests</span><span class="v">{{ r.tests }}{% if let Some(d) = self.count_delta() %} <small>{{ self.signed(d) }}</small>{% endif %}</span></div> |
|
| 24 | + | <div><span class="k">Passed</span><span class="v cov-good">{{ r.passed }}</span></div> |
|
| 25 | + | <div><span class="k">Failed</span><span class="v{% if r.failed > 0 %} cov-poor{% endif %}">{{ r.failed }}</span></div> |
|
| 26 | + | {% if r.errors > 0 %}<div><span class="k">Errored</span><span class="v cov-poor">{{ r.errors }}</span></div>{% endif %} |
|
| 27 | + | <div><span class="k">Skipped</span><span class="v">{{ r.skipped }}</span></div> |
|
| 28 | + | <div><span class="k">Test time</span><span class="v">{{ self.time(r.time_ms) }}{% if let Some(d) = self.time_delta() %} <small>{{ d }}</small>{% endif %}</span></div> |
|
| 29 | + | {% if let Some(t) = first_ms %}<div><span class="k">Reporting since</span><span class="v small"><time title="{{ t|when_i }}">{{ t|ago_i }}</time></span></div>{% endif %} |
|
| 30 | + | </section> |
|
| 31 | + | ||
| 32 | + | <div class="cov-grid"> |
|
| 33 | + | <section class="panel cov-card"> |
|
| 34 | + | <h2>This run</h2> |
|
| 35 | + | <ul class="cov-stats"> |
|
| 36 | + | <li><strong>{{ r.passed }}</strong> of <strong>{{ r.tests - r.skipped }}</strong> tests passed <span class="{{ self.class_of(r) }}">({{ self.rate(r) }})</span>{% if r.skipped > 0 %}, {{ r.skipped }} skipped{% endif %}</li> |
|
| 37 | + | {% if let Some(p) = previous %}<li>Against <a href="{{ repo.url }}/tests?report={{ p.id }}">run #{{ p.run_number }}</a>: {{ broke.len() }} newly failing, {{ fixed.len() }} fixed{% if added > 0 %}, {{ added }} new tests{% endif %}</li>{% else %}<li>The first report of job {{ r.job }} here</li>{% endif %} |
|
| 38 | + | </ul> |
|
| 39 | + | {% if !broke.is_empty() %}<h3>Newly failing</h3> |
|
| 40 | + | <ul class="t-list">{% for c in broke %}<li><span class="cov-poor">✕</span> <code>{{ c.title }}</code></li>{% endfor %}</ul>{% endif %} |
|
| 41 | + | {% if !fixed.is_empty() %}<h3>Fixed</h3> |
|
| 42 | + | <ul class="t-list">{% for c in fixed %}<li><span class="cov-good">✓</span> <code>{{ c.title }}</code></li>{% endfor %}</ul>{% endif %} |
|
| 43 | + | </section> |
|
| 44 | + | <section class="panel cov-card"> |
|
| 45 | + | <h2>Flaky <small class="mute">failed and passed in the last {{ flaky_runs }} runs</small></h2> |
|
| 46 | + | {% if flaky.is_empty() %}<p class="mute small">None: every test kept its result.</p>{% else %} |
|
| 47 | + | <table class="cov-moved">{% for f in flaky %}<tr><td><code>{{ f.title() }}</code></td><td class="num"><span class="cov-poor">{{ f.failed }} ✕</span> <span class="cov-good">{{ f.passed }} ✓</span></td></tr>{% endfor %}</table>{% endif %} |
|
| 48 | + | </section> |
|
| 49 | + | </div> |
|
| 50 | + | ||
| 51 | + | {% if !failures.is_empty() %}<section class="panel"> |
|
| 52 | + | <h2>Failures <small class="mute">{{ r.broken() }} in run #{{ r.run_number }}{% if r.broken() > failures.len() as i64 %}, the first {{ failures.len() }} shown{% endif %}</small></h2> |
|
| 53 | + | <div class="flow-tests">{% for f in failures %}<details class="tests-failure"> |
|
| 54 | + | <summary><span class="bad">{% if f.status == "error" %}!{% else %}✕{% endif %}</span> <code>{{ f.title() }}</code>{% if !f.message.is_empty() %} <span class="small mute">{{ f.message }}</span>{% endif %}</summary> |
|
| 55 | + | {% if !f.output.is_empty() %}<pre class="flow-log">{{ f.output }}</pre>{% endif %} |
|
| 56 | + | </details>{% endfor %}</div> |
|
| 57 | + | </section>{% endif %} |
|
| 58 | + | ||
| 59 | + | {% if runs > 1 %}<section class="panel"> |
|
| 60 | + | <h2>Tests run <small class="mute"><span class="cov-key covered">passed</span> <span class="cov-key relevant">failed</span></small></h2> |
|
| 61 | + | {{ count_chart|safe }} |
|
| 62 | + | </section>{% endif %} |
|
| 63 | + | ||
| 64 | + | <section class="panel"> |
|
| 65 | + | <h2>Slowest tests <small class="mute">in run #{{ r.run_number }}</small></h2> |
|
| 66 | + | <div class="table"><table class="cov-files"> |
|
| 67 | + | <tr><th>Test</th><th class="num">Time</th></tr> |
|
| 68 | + | {% for c in slowest %}<tr><td><code>{{ c.title() }}</code>{% if c.status != "passed" %} <span class="cov-poor small">{{ c.status }}</span>{% endif %}</td><td class="num">{{ self.time(c.time_ms) }}</td></tr>{% endfor %} |
|
| 69 | + | </table></div> |
|
| 70 | + | </section> |
|
| 71 | + | ||
| 72 | + | {% if recent.len() > 1 %}<section class="panel"> |
|
| 73 | + | <h2>Recent runs on {{ bookmark }}</h2> |
|
| 74 | + | <div class="table"><table class="cov-runs"> |
|
| 75 | + | <tr><th>Run</th><th>Job</th><th>Passing</th><th class="num">Tests</th><th class="num">Failed</th><th class="num">Time</th><th>When</th></tr> |
|
| 76 | + | {% for x in recent %}<tr{% if x.report.id == r.id %} class="on"{% endif %}> |
|
| 77 | + | <td><a href="{{ repo.url }}/tests?report={{ x.report.id }}">#{{ x.report.run_number }}</a></td> |
|
| 78 | + | <td>{{ x.report.job }}</td> |
|
| 79 | + | <td><span class="cov-bar {{ self.class_of(x.report) }}"><span style="width: {% if let Some(p) = crate::web::test_reports::pass_rate(x.report) %}{{ p }}{% else %}0{% endif %}%"></span></span> {{ self.rate(x.report) }}</td> |
|
| 80 | + | <td class="num">{{ x.report.tests }}{% if let Some(d) = x.delta %}{% if *d != 0 %} <small class="mute">{{ self.signed(d) }}</small>{% endif %}{% endif %}</td> |
|
| 81 | + | <td class="num">{% if x.report.broken() > 0 %}<span class="cov-poor">{{ x.report.broken() }}</span>{% else %}0{% endif %}</td> |
|
| 82 | + | <td class="num">{{ self.time(x.report.time_ms) }}</td> |
|
| 83 | + | <td class="small"><time title="{{ x.report.created_ms|when_i }}">{{ x.report.created_ms|ago_i }}</time></td> |
|
| 84 | + | </tr>{% endfor %} |
|
| 85 | + | </table></div> |
|
| 86 | + | </section>{% endif %} |
|
| 87 | + | {% else %} |
|
| 88 | + | <div class="head"><h1>Tests <small>of {{ bookmark }}</small></h1></div> |
|
| 89 | + | <section class="panel"> |
|
| 90 | + | <p>No test report for <strong>{{ bookmark }}</strong> yet.</p> |
|
| 91 | + | <p>Give a job in <code>.flow/pipeline.kdl</code> an artifact named <code>junit</code> holding a JUnit XML report, kept with <code>when "always"</code> so a failing run still sends it. For pytest:</p> |
|
| 92 | + | <pre>job "test" { |
|
| 93 | + | sandbox "python" |
|
| 94 | + | exec "pytest" "--junitxml=/src/out/junit.xml" |
|
| 95 | + | output { |
|
| 96 | + | artifact "junit" { |
|
| 97 | + | path "/src/out/junit.xml" |
|
| 98 | + | when "always" |
|
| 99 | + | } |
|
| 100 | + | } |
|
| 101 | + | }</pre> |
|
| 102 | + | <p class="small mute">cargo-nextest (a <code>junit</code> profile), Jest's junit reporter and gotestsum write JUnit XML too.</p> |
|
| 103 | + | </section> |
|
| 104 | + | {% endif %} |
|
| 105 | + | {% endblock %} |
modified crates/hub/src/junit.rs +109 −0
| … | 316 unchanged lines |
||
| 317 | 317 | .await?; |
|
| 318 | 318 | } |
|
| 319 | 319 | tx.commit().await?; |
|
| 320 | + | mark(app, o.repo_id); |
|
| 320 | 321 | Ok(id) |
|
| 321 | 322 | } |
|
| 322 | 323 | ||
| … | 14 unchanged lines |
||
| 337 | 338 | .await |
|
| 338 | 339 | } |
|
| 339 | 340 | ||
| 341 | + | /// Which repositories have test reports, for the Tests tab; keyed by data |
|
| 342 | + | /// directory as well, for tests running several hubs in one process. |
|
| 343 | + | static HAS: std::sync::Mutex<Option<std::collections::HashSet<String>>> = std::sync::Mutex::new(None); |
|
| 344 | + | ||
| 345 | + | fn key(app: &Shared, repo_id: i64) -> String { |
|
| 346 | + | format!("{}#{repo_id}", app.repos.root().display()) |
|
| 347 | + | } |
|
| 348 | + | ||
| 349 | + | pub fn has_reports(app: &Shared, repo_id: i64) -> bool { |
|
| 350 | + | HAS.lock().unwrap_or_else(|p| p.into_inner()).as_ref().is_some_and(|s| s.contains(&key(app, repo_id))) |
|
| 351 | + | } |
|
| 352 | + | ||
| 353 | + | fn mark(app: &Shared, repo_id: i64) { |
|
| 354 | + | HAS.lock().unwrap_or_else(|p| p.into_inner()).get_or_insert_with(Default::default).insert(key(app, repo_id)); |
|
| 355 | + | } |
|
| 356 | + | ||
| 357 | + | /// At start: which repositories have reports. |
|
| 358 | + | pub async fn load(app: &Shared) -> sqlx::Result<()> { |
|
| 359 | + | let ids: Vec<(i64,)> = sqlx::query_as("SELECT DISTINCT repo_id FROM test_reports").fetch_all(&app.db).await?; |
|
| 360 | + | for (id,) in ids { |
|
| 361 | + | mark(app, id); |
|
| 362 | + | } |
|
| 363 | + | Ok(()) |
|
| 364 | + | } |
|
| 365 | + | ||
| 366 | + | pub async fn report(pool: &Pool, repo_id: i64, id: i64) -> sqlx::Result<Option<Report>> { |
|
| 367 | + | sqlx::query_as("SELECT * FROM test_reports WHERE id = ? AND repo_id = ?").bind(id).bind(repo_id).fetch_optional(pool).await |
|
| 368 | + | } |
|
| 369 | + | ||
| 370 | + | /// The newest reports on a bookmark, newest first. |
|
| 371 | + | pub async fn on_bookmark(pool: &Pool, repo_id: i64, bookmark: &str, limit: i64) -> sqlx::Result<Vec<Report>> { |
|
| 372 | + | sqlx::query_as("SELECT * FROM test_reports WHERE repo_id = ? AND bookmark = ? ORDER BY created_ms DESC, id DESC LIMIT ?") |
|
| 373 | + | .bind(repo_id) |
|
| 374 | + | .bind(bookmark) |
|
| 375 | + | .bind(limit) |
|
| 376 | + | .fetch_all(pool) |
|
| 377 | + | .await |
|
| 378 | + | } |
|
| 379 | + | ||
| 380 | + | /// Bookmarks with reports, the most recently reported first. |
|
| 381 | + | pub async fn bookmarks(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<String>> { |
|
| 382 | + | let rows: Vec<(String,)> = |
|
| 383 | + | sqlx::query_as("SELECT bookmark FROM test_reports WHERE repo_id = ? GROUP BY bookmark ORDER BY MAX(created_ms) DESC") |
|
| 384 | + | .bind(repo_id) |
|
| 385 | + | .fetch_all(pool) |
|
| 386 | + | .await?; |
|
| 387 | + | Ok(rows.into_iter().map(|(b,)| b).collect()) |
|
| 388 | + | } |
|
| 389 | + | ||
| 390 | + | pub async fn first_ms(pool: &Pool, repo_id: i64) -> sqlx::Result<Option<i64>> { |
|
| 391 | + | sqlx::query_scalar("SELECT MIN(created_ms) FROM test_reports WHERE repo_id = ?").bind(repo_id).fetch_one(pool).await |
|
| 392 | + | } |
|
| 393 | + | ||
| 394 | + | /// A report's slowest cases. |
|
| 395 | + | pub async fn slowest(pool: &Pool, report_id: i64, limit: i64) -> sqlx::Result<Vec<Case>> { |
|
| 396 | + | sqlx::query_as( |
|
| 397 | + | "SELECT suite, classname, name, status, time_ms, message, '' AS output FROM test_cases \ |
|
| 398 | + | WHERE report_id = ? AND status != 'skipped' ORDER BY time_ms DESC, idx LIMIT ?", |
|
| 399 | + | ) |
|
| 400 | + | .bind(report_id) |
|
| 401 | + | .bind(limit) |
|
| 402 | + | .fetch_all(pool) |
|
| 403 | + | .await |
|
| 404 | + | } |
|
| 405 | + | ||
| 406 | + | /// Every case of a report as `(classname, name) -> status`. |
|
| 407 | + | pub async fn statuses(pool: &Pool, report_id: i64) -> sqlx::Result<std::collections::HashMap<(String, String), String>> { |
|
| 408 | + | let rows: Vec<(String, String, String)> = |
|
| 409 | + | sqlx::query_as("SELECT classname, name, status FROM test_cases WHERE report_id = ?").bind(report_id).fetch_all(pool).await?; |
|
| 410 | + | Ok(rows.into_iter().map(|(c, n, s)| ((c, n), s)).collect()) |
|
| 411 | + | } |
|
| 412 | + | ||
| 413 | + | /// A test that both failed and passed across some reports. |
|
| 414 | + | #[derive(Clone, Debug, sqlx::FromRow)] |
|
| 415 | + | pub struct Flaky { |
|
| 416 | + | pub classname: String, |
|
| 417 | + | pub name: String, |
|
| 418 | + | pub failed: i64, |
|
| 419 | + | pub passed: i64, |
|
| 420 | + | } |
|
| 421 | + | ||
| 422 | + | impl Flaky { |
|
| 423 | + | pub fn title(&self) -> String { |
|
| 424 | + | if self.classname.is_empty() { |
|
| 425 | + | self.name.clone() |
|
| 426 | + | } else { |
|
| 427 | + | format!("{}::{}", self.classname, self.name) |
|
| 428 | + | } |
|
| 429 | + | } |
|
| 430 | + | } |
|
| 431 | + | ||
| 432 | + | /// Tests that failed in some of `reports` and passed in others, the most |
|
| 433 | + | /// often failing first: flaky, or broken and fixed again. |
|
| 434 | + | pub async fn flaky(pool: &Pool, reports: &[i64], limit: i64) -> sqlx::Result<Vec<Flaky>> { |
|
| 435 | + | if reports.is_empty() { |
|
| 436 | + | return Ok(vec![]); |
|
| 437 | + | } |
|
| 438 | + | let ids = reports.iter().map(|i| i.to_string()).collect::<Vec<_>>().join(","); |
|
| 439 | + | sqlx::query_as(&format!( |
|
| 440 | + | "SELECT classname, name, SUM(status IN ('failed', 'error')) AS failed, SUM(status = 'passed') AS passed \ |
|
| 441 | + | FROM test_cases WHERE report_id IN ({ids}) GROUP BY classname, name \ |
|
| 442 | + | HAVING failed > 0 AND passed > 0 ORDER BY failed DESC, classname, name LIMIT ?" |
|
| 443 | + | )) |
|
| 444 | + | .bind(limit) |
|
| 445 | + | .fetch_all(pool) |
|
| 446 | + | .await |
|
| 447 | + | } |
|
| 448 | + | ||
| 340 | 449 | #[cfg(test)] |
|
| 341 | 450 | mod tests { |
|
| 342 | 451 | use super::*; |
|
| … | 50 unchanged lines |
modified crates/hub/src/lib.rs +1 −0
| … | 115 unchanged lines |
||
| 116 | 116 | *slot.lock().unwrap() = Some(Arc::downgrade(&app)); |
|
| 117 | 117 | storage::load(&app).await?; |
|
| 118 | 118 | coverage::load(&app).await?; |
|
| 119 | + | junit::load(&app).await?; |
|
| 119 | 120 | tokio::spawn(hooks::worker(app.clone())); |
|
| 120 | 121 | tokio::spawn(flow::reaper(app.clone())); |
|
| 121 | 122 | tokio::spawn(flow::scheduler(app.clone())); |
|
| … | 46 unchanged lines |
modified crates/hub/src/web/coverage.rs +24 −27
| … | 119 unchanged lines |
||
| 120 | 120 | } |
|
| 121 | 121 | ||
| 122 | 122 | /// A count, short: 9.4k, 147.7k, 2.3M. |
|
| 123 | - | fn compact(v: f64) -> String { |
|
| 123 | + | pub(super) fn compact(v: f64) -> String { |
|
| 124 | 124 | match v { |
|
| 125 | 125 | v if v >= 1e9 => format!("{:.1}G", v / 1e9), |
|
| 126 | 126 | v if v >= 1e6 => format!("{:.1}M", v / 1e6), |
|
| … | 14 unchanged lines |
||
| 141 | 141 | } |
|
| 142 | 142 | ||
| 143 | 143 | /// A line of values across runs, for [`chart`]. |
|
| 144 | - | struct Series<'a> { |
|
| 145 | - | values: Vec<f64>, |
|
| 146 | - | class: &'a str, |
|
| 144 | + | pub(super) struct Series<'a> { |
|
| 145 | + | pub values: Vec<f64>, |
|
| 146 | + | pub class: &'a str, |
|
| 147 | 147 | /// Shade down to these values instead of to the axis: a band. |
|
| 148 | - | floor: Option<Vec<f64>>, |
|
| 148 | + | pub floor: Option<Vec<f64>>, |
|
| 149 | 149 | } |
|
| 150 | 150 | ||
| 151 | + | /// A run on a chart: its number, where a click goes, what a hover says. |
|
| 152 | + | pub(super) struct Point { |
|
| 153 | + | pub run: i64, |
|
| 154 | + | pub href: String, |
|
| 155 | + | pub tip: String, |
|
| 156 | + | } |
|
| 157 | + | ||
| 151 | 158 | /// An SVG chart of one or more series over runs, oldest on the left, with |
|
| 152 | 159 | /// a light grid and labels, and a column per run that names it on hover |
|
| 153 | 160 | /// and links to its report. `fmt` labels the y axis. |
|
| 154 | - | #[allow(clippy::too_many_arguments)] // one chart, described in full by its caller |
|
| 155 | - | fn chart( |
|
| 156 | - | label: &str, |
|
| 157 | - | runs: &[&Report], |
|
| 158 | - | series: &[Series], |
|
| 159 | - | lo: f64, |
|
| 160 | - | hi: f64, |
|
| 161 | - | fmt: &dyn Fn(f64) -> String, |
|
| 162 | - | url: &str, |
|
| 163 | - | tip: &dyn Fn(&Report) -> String, |
|
| 164 | - | ) -> String { |
|
| 161 | + | pub(super) fn chart(label: &str, runs: &[Point], series: &[Series], lo: f64, hi: f64, fmt: &dyn Fn(f64) -> String) -> String { |
|
| 165 | 162 | let (w, h) = (800.0, 180.0); |
|
| 166 | 163 | let (left, right, top, bottom) = (52.0, 10.0, 12.0, 24.0); |
|
| 167 | 164 | let (pw, ph) = (w - left - right, h - top - bottom); |
|
| … | 40 unchanged lines |
||
| 208 | 205 | let col = if n == 1 { pw } else { pw / (n - 1) as f64 }; |
|
| 209 | 206 | for (i, r) in runs.iter().enumerate() { |
|
| 210 | 207 | svg.push_str(&format!( |
|
| 211 | - | r#"<a href="{url}/coverage?report={id}"><rect class="hit" x="{rx:.1}" y="{top}" width="{col:.1}" height="{ph}"><title>{t}</title></rect></a>"#, |
|
| 212 | - | id = r.id, |
|
| 208 | + | r#"<a href="{href}"><rect class="hit" x="{rx:.1}" y="{top}" width="{col:.1}" height="{ph}"><title>{t}</title></rect></a>"#, |
|
| 209 | + | href = r.href, |
|
| 213 | 210 | rx = x(i) - col / 2.0, |
|
| 214 | - | t = tip(r), |
|
| 211 | + | t = r.tip, |
|
| 215 | 212 | )); |
|
| 216 | 213 | } |
|
| 217 | 214 | if n > 1 { |
|
| … | 1 unchanged lines |
||
| 219 | 216 | r#"<text class="axis" x="{left}" y="{yb}">run #{first}</text><text class="axis" x="{xr}" y="{yb}" text-anchor="end">run #{last}</text>"#, |
|
| 220 | 217 | yb = h - 6.0, |
|
| 221 | 218 | xr = w - right, |
|
| 222 | - | first = runs[0].run_number, |
|
| 223 | - | last = runs[n - 1].run_number, |
|
| 219 | + | first = runs[0].run, |
|
| 220 | + | last = runs[n - 1].run, |
|
| 224 | 221 | )); |
|
| 225 | 222 | } |
|
| 226 | 223 | svg.push_str("</svg>"); |
|
| … | 89 unchanged lines |
||
| 316 | 313 | r.lines_found |
|
| 317 | 314 | ) |
|
| 318 | 315 | }; |
|
| 316 | + | let points: Vec<Point> = oldest_first |
|
| 317 | + | .iter() |
|
| 318 | + | .map(|r| Point { run: r.run_number, href: format!("{}/coverage?report={}", repo.url, r.id), tip: tip(r) }) |
|
| 319 | + | .collect(); |
|
| 319 | 320 | let coverage_chart = chart( |
|
| 320 | 321 | &format!("Coverage of {bookmark} over the last {} runs", oldest_first.len()), |
|
| 321 | - | &oldest_first, |
|
| 322 | + | &points, |
|
| 322 | 323 | &[Series { values: pcts, class: "cov", floor: None }], |
|
| 323 | 324 | lo, |
|
| 324 | 325 | hi, |
|
| 325 | 326 | &|v| format!("{v:.0}%"), |
|
| 326 | - | &repo.url, |
|
| 327 | - | &tip, |
|
| 328 | 327 | ); |
|
| 329 | 328 | let top = oldest_first.iter().map(|r| r.lines_found).max().unwrap_or(0) as f64; |
|
| 330 | 329 | let covered: Vec<f64> = oldest_first.iter().map(|r| r.lines_hit as f64).collect(); |
|
| 331 | 330 | let lines_chart = chart( |
|
| 332 | 331 | &format!("Relevant and covered lines of {bookmark}"), |
|
| 333 | - | &oldest_first, |
|
| 332 | + | &points, |
|
| 334 | 333 | &[ |
|
| 335 | 334 | Series { values: covered.clone(), class: "covered", floor: None }, |
|
| 336 | 335 | Series { values: oldest_first.iter().map(|r| r.lines_found as f64).collect(), class: "relevant", floor: Some(covered) }, |
|
| … | 1 unchanged lines |
||
| 338 | 337 | 0.0, |
|
| 339 | 338 | (top * 1.08).max(1.0), |
|
| 340 | 339 | &compact, |
|
| 341 | - | &repo.url, |
|
| 342 | - | &tip, |
|
| 343 | 340 | ); |
|
| 344 | 341 | let recent = history |
|
| 345 | 342 | .iter() |
|
| … | 153 unchanged lines |
modified crates/hub/src/web/mod.rs +5 −0
| … | 33 unchanged lines |
||
| 34 | 34 | pub(crate) mod settings; |
|
| 35 | 35 | mod signin; |
|
| 36 | 36 | mod statics; |
|
| 37 | + | pub mod test_reports; |
|
| 37 | 38 | mod variables; |
|
| 38 | 39 | mod webhooks; |
|
| 39 | 40 | mod wiki; |
|
| … | 175 unchanged lines |
||
| 215 | 216 | .route("/{owner}/{name}/newfile/{r}", get(edit::new_form)) |
|
| 216 | 217 | .route("/{owner}/{name}/flow", get(flow::runs)) |
|
| 217 | 218 | .route("/{owner}/{name}/flow/badge.svg", get(flow::badge)) |
|
| 219 | + | .route("/{owner}/{name}/tests", get(test_reports::overview)) |
|
| 218 | 220 | .route("/{owner}/{name}/coverage", get(coverage::overview)) |
|
| 219 | 221 | .route("/{owner}/{name}/coverage/badge.svg", get(coverage::badge)) |
|
| 220 | 222 | .route("/{owner}/{name}/coverage/{id}/file/{*path}", get(coverage::file)) |
|
| … | 192 unchanged lines |
||
| 413 | 415 | pub stored_in: Option<String>, |
|
| 414 | 416 | /// Whether it has coverage reports, for the Coverage tab (#113). |
|
| 415 | 417 | pub coverage: bool, |
|
| 418 | + | /// Whether it has test reports, for the Tests tab (#114). |
|
| 419 | + | pub tests: bool, |
|
| 416 | 420 | } |
|
| 417 | 421 | ||
| 418 | 422 | impl RepoShell { |
|
| … | 14 unchanged lines |
||
| 433 | 437 | wiki: crate::repos::RepoName::new(&row.owner, &row.name).is_some_and(|n| app.wikis.exists(&n)), |
|
| 434 | 438 | stored_in: crate::storage::location(app, &row.owner, &row.name), |
|
| 435 | 439 | coverage: crate::coverage::has_reports(app, row.id), |
|
| 440 | + | tests: crate::junit::has_reports(app, row.id), |
|
| 436 | 441 | } |
|
| 437 | 442 | } |
|
| 438 | 443 | ||
| … | 276 unchanged lines |
modified crates/hub/tests/flow.rs +81 −0
| … | 2233 unchanged lines |
||
| 2234 | 2234 | async fn crate_report(app: &Shared, run_id: i64) -> i64 { |
|
| 2235 | 2235 | oxygenhub::coverage::for_run(&app.db, run_id).await.unwrap()[0].id |
|
| 2236 | 2236 | } |
|
| 2237 | + | ||
| 2238 | + | /// The Tests tab (#114): three runs' JUnit reports on a bookmark; a test |
|
| 2239 | + | /// breaks in the second and is fixed in the third. |
|
| 2240 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 2241 | + | async fn junit_reports_make_a_tests_tab() { |
|
| 2242 | + | let h = hub().await; |
|
| 2243 | + | create(&h.app, "andy", "t").await; |
|
| 2244 | + | let url = format!("{}/andy/t", h.base); |
|
| 2245 | + | let work = tempfile::tempdir().unwrap(); |
|
| 2246 | + | let c = client(&url, work.path()); |
|
| 2247 | + | write( |
|
| 2248 | + | work.path(), |
|
| 2249 | + | ".flow/pipeline.kdl", |
|
| 2250 | + | &format!( |
|
| 2251 | + | "schema-version 1\nsandbox \"alpine\" {{\n rootfs \"cas://blob/{ROOTFS}\"\n}}\njob \"test\" {{\n sandbox \"alpine\"\n exec \"make\" \"test\"\n output {{\n artifact \"junit\" {{\n path \"/src/junit.xml\"\n }}\n }}\n}}\n" |
|
| 2252 | + | ), |
|
| 2253 | + | ); |
|
| 2254 | + | assert!(!call(&h.app, "GET", "/andy/t", None, None).await.1.contains(">Tests</a>")); |
|
| 2255 | + | let case = |name: &str, ok: bool, secs: &str| { |
|
| 2256 | + | if ok { |
|
| 2257 | + | format!(r#"<testcase classname="t.suite" name="{name}" time="{secs}"/>"#) |
|
| 2258 | + | } else { |
|
| 2259 | + | format!( |
|
| 2260 | + | r#"<testcase classname="t.suite" name="{name}" time="{secs}"><failure message="{name} broke">AssertionError</failure></testcase>"# |
|
| 2261 | + | ) |
|
| 2262 | + | } |
|
| 2263 | + | }; |
|
| 2264 | + | let runs = [ |
|
| 2265 | + | vec![case("a", true, "0.1"), case("b", true, "2.5"), case("c", true, "0.2")], |
|
| 2266 | + | vec![case("a", true, "0.1"), case("b", false, "2.6"), case("c", true, "0.2"), case("d", true, "0.3")], |
|
| 2267 | + | vec![case("a", true, "0.1"), case("b", true, "2.4"), case("c", true, "0.2"), case("d", true, "0.3")], |
|
| 2268 | + | ]; |
|
| 2269 | + | for (i, cases) in runs.iter().enumerate() { |
|
| 2270 | + | write(work.path(), "n.txt", &i.to_string()); |
|
| 2271 | + | c.snapshot("test").unwrap(); |
|
| 2272 | + | c.commit(&format!("Run {i}"), &[]).unwrap(); |
|
| 2273 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 2274 | + | // The run is made after the push answers. |
|
| 2275 | + | let mut a = None; |
|
| 2276 | + | for _ in 0..100 { |
|
| 2277 | + | a = claim(&h.app).await; |
|
| 2278 | + | if a.is_some() { |
|
| 2279 | + | break; |
|
| 2280 | + | } |
|
| 2281 | + | tokio::time::sleep(std::time::Duration::from_millis(50)).await; |
|
| 2282 | + | } |
|
| 2283 | + | let a = a.expect("a job to claim"); |
|
| 2284 | + | let id = a["job_id"].as_i64().unwrap(); |
|
| 2285 | + | let xml = format!("<testsuites><testsuite name=\"t\">{}</testsuite></testsuites>", cases.concat()); |
|
| 2286 | + | assert_eq!(upload(&h.app, id, "junit", xml.as_bytes()).await.0, StatusCode::OK); |
|
| 2287 | + | let failed = cases.iter().any(|c| c.contains("<failure")); |
|
| 2288 | + | let mut r = receipt("test", a["pipeline_hash"].as_str().unwrap(), failed as u32); |
|
| 2289 | + | if !failed { |
|
| 2290 | + | r["outputs"] = serde_json::json!([oxy_core::repo::Repo::blob_ref(xml.as_bytes()).to_hex()]); |
|
| 2291 | + | } |
|
| 2292 | + | let code = failed as i32; |
|
| 2293 | + | assert_eq!( |
|
| 2294 | + | finish(&h.app, id, serde_json::json!({ "exit_code": code, "duration_ms": 700, "receipt": r })).await, |
|
| 2295 | + | StatusCode::NO_CONTENT |
|
| 2296 | + | ); |
|
| 2297 | + | } |
|
| 2298 | + | let (st, page) = call(&h.app, "GET", "/andy/t/tests", None, None).await; |
|
| 2299 | + | assert_eq!(st, StatusCode::OK, "{page}"); |
|
| 2300 | + | if let Some(out) = std::env::var_os("OXYHUB_DUMP_TESTS_PAGE") { |
|
| 2301 | + | std::fs::write(out, &page).unwrap(); |
|
| 2302 | + | } |
|
| 2303 | + | assert!(page.contains(">Tests</a>"), "the tab"); |
|
| 2304 | + | assert!(page.contains("100%") && page.contains("of 4 tests passed"), "the latest run passes"); |
|
| 2305 | + | assert!(page.contains("1 fixed") && page.contains("t.suite::b"), "b fixed against the run before"); |
|
| 2306 | + | assert!(page.contains("1 ✕") && page.contains("2 ✓"), "b is flaky: {page}"); |
|
| 2307 | + | assert!(page.contains("<svg class=\"cov-chart\""), "charts"); |
|
| 2308 | + | // The slowest first. |
|
| 2309 | + | let slow = page.find("Slowest tests").unwrap(); |
|
| 2310 | + | assert!(page[slow..].find("t.suite::b").unwrap() < page[slow..].find("t.suite::d").unwrap()); |
|
| 2311 | + | // The second run, picked: b newly failing, its failure listed. |
|
| 2312 | + | let row = db::repos::get(&h.app.db, "andy", "t").await.unwrap().unwrap(); |
|
| 2313 | + | let second = oxygenhub::junit::on_bookmark(&h.app.db, row.id, "main", 10).await.unwrap()[1].id; |
|
| 2314 | + | let (_, page) = call(&h.app, "GET", &format!("/andy/t/tests?report={second}"), None, None).await; |
|
| 2315 | + | assert!(page.contains("1 failing") && page.contains("Newly failing") && page.contains("b broke"), "{page}"); |
|
| 2316 | + | assert!(page.contains("1 new tests"), "d is new"); |
|
| 2317 | + | } |
modified docs/spec.md +3 −2
| … | 589 unchanged lines |
||
| 590 | 590 | - **Pages.** A Coverage tab, shown once a repository has a report: the bookmark's latest percentage with its change against the report before over a chart of up to 200 runs (each point names its run and opens its report), a strip of facts (lines covered, hits per line, functions, branches, files, the first report, the badge with its Markdown), this run's changes with the files whose coverage moved most, files by coverage band, a chart of covered and missed lines, the recent runs, a bookmark picker, every file with a bar (sorted by lines missed, lowest coverage, or path), and a file view with each line run or missed and how often it ran. `/OWNER/NAME/coverage/badge.svg` is a badge of the default bookmark's latest coverage. The run page shows each job's coverage beside its artifacts. |
|
| 591 | 591 | - **Next:** coverage of a proposal's projected state against its target, file by file, and a `coverage` check with a minimum and a largest drop set in the repository's settings; Cobertura XML; the API; and a coverage job in Oxygen's own pipeline (cargo-llvm-cov in the Flow build image). |
|
| 592 | 592 | ||
| 593 | - | ### 13.7 Test reports (oxygenhub#114, first part done 2026-10-05) |
|
| 593 | + | ### 13.7 Test reports (oxygenhub#114, first two parts done 2026-10-05) |
|
| 594 | 594 | ||
| 595 | 595 | - **Reports.** A job's artifact named `junit` in JUnit XML (pytest `--junitxml`, cargo-nextest, Jest's junit reporter, gotestsum) is read when the job uploads it, at most 64 MiB and 50 000 cases. Every `<testcase>` counts, wherever its `<testsuite>` nests; a case is `passed`, `failed` (`<failure>`), `error` (`<error>`) or `skipped`, and a nextest case that failed and then passed on a retry (`flakyFailure`) is a pass. Counts come from the cases, not the suites' attributes. The job's log says what was found (`tests: 630 tests: 2 failed in 3 min 4 s`), or why it could not be read; a bad report never fails the upload. |
|
| 596 | 596 | - **Failing jobs.** Declare the artifact with `when "always"` (OxygenFlow spec §5.2) so a failing suite still sends its report; it is then outside the job's receipt, and the run page says so. |
|
| 597 | 597 | - **Stored.** `test_reports` keeps the counts and summed test time per report with its run, job, bookmark and state; `test_cases` every case with its status and time, and for a failure or error its message (2 KiB) and its text with the case's output (16 KiB). |
|
| 598 | 598 | - **Run page.** Beside each job its counts, linked to a block above its log: passed, failed, errored and skipped, then the first 50 failures, failures first in report order, each collapsible to its message and output. |
|
| 599 | - | - **Next:** a Tests tab (the default bookmark's counts over time, the slowest tests, flaky ones, a test's history), and proposals naming the tests that pass on the target and fail on the proposal. |
|
| 599 | + | - **Tests tab.** Shown once a repository has a report: the bookmark's latest pass rate (or how many fail) over a chart of up to 200 runs, the counts and test time against the run before, the tests newly failing and fixed against that job's previous report, tests that both failed and passed in the last 30 reports (flaky), the run's failures, a passed-and-failed chart, the slowest tests and recent runs. `?report=` shows any report, `?bookmark=` another bookmark. |
|
| 600 | + | - **Next:** a test's history across runs, and proposals naming the tests that pass on the target and fail on the proposal. |
|
| 600 | 601 | ||
| 601 | 602 | ## 14. Git interoperability |
|
| 602 | 603 | ||
| … | 145 unchanged lines |
modified templates/repo_tabs.html +1 −0
| … | 7 unchanged lines |
||
| 8 | 8 | <a href="{{ repo.url }}/issues"{% if tab == "issues" %} class="on" aria-current="page"{% endif %}>Issues{% if repo.open_issues > 0 %} <span class="count-chip">{{ repo.open_issues }}</span>{% endif %}</a> |
|
| 9 | 9 | {% if repo.wiki || repo.can(crate::auth::Access::Write) %}<a href="{{ repo.url }}/wiki"{% if tab == "wiki" %} class="on" aria-current="page"{% endif %}>Wiki</a>{% endif %} |
|
| 10 | 10 | <a href="{{ repo.url }}/flow"{% if tab == "flow" %} class="on" aria-current="page"{% endif %}>Flow</a> |
|
| 11 | + | {% if repo.tests %}<a href="{{ repo.url }}/tests"{% if tab == "tests" %} class="on" aria-current="page"{% endif %}>Tests</a>{% endif %} |
|
| 11 | 12 | {% if repo.coverage %}<a href="{{ repo.url }}/coverage"{% if tab == "coverage" %} class="on" aria-current="page"{% endif %}>Coverage</a>{% endif %} |
|
| 12 | 13 | <a href="{{ repo.url }}/bookmarks"{% if tab == "bookmarks" %} class="on" aria-current="page"{% endif %}>Bookmarks</a> |
|
| 13 | 14 | <a href="{{ repo.url }}/releases"{% if tab == "releases" %} class="on" aria-current="page"{% endif %}>Releases</a> |
|
| … | 63 unchanged lines |
modified templates/style.css +1 −0
| … | 862 unchanged lines |
||
| 863 | 863 | .cov-key.relevant { background: color-mix(in srgb, var(--bad) 18%, transparent); } .cov-key.covered { background: color-mix(in srgb, var(--ok) 22%, transparent); } |
|
| 864 | 864 | .cov-code tr.cov-hit td.t, .cov-key.cov-hit { background: color-mix(in srgb, var(--ok) 16%, transparent); } |
|
| 865 | 865 | .cov-code tr.cov-miss td.t, .cov-key.cov-miss { background: color-mix(in srgb, var(--bad) 16%, transparent); } |
|
| 866 | + | .t-list { list-style: none; padding: 0; margin: 0; line-height: 1.7; overflow-wrap: anywhere; } |
|
| 866 | 867 | .cov-code td.cov-hits { text-align: right; padding: 0 .5em; color: var(--mute); font-size: .85em; white-space: nowrap; user-select: none; } |