OXYGENHUB

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; }