OXYGENHUB

Tests: a test's history, and a tests check naming what broke (part 3) 3d5c825aebba

/tests/case?classname=&name= shows one test across the bookmark's last
200 runs with its result, time and message; the Tests tab links each
failure, slow and flaky test to it. Each JUnit report sets a tests check
on its run's state, naming the failures that passed in the same job's
report on the default bookmark, so a proposal says what it broke.

closes #114
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-06 11:46 UTC
Signature
✔ signed by Andy McCall
Hash
3d5c825aebbacd0c2f7bcc0712e495f71da664a9d466f74423b4224bab3ab555
Depends on
0b203bbf7cc3 36e5a9c15a84 42fa95540468 554ccacfc123 617c2840446c 93be9ce4653e d23464c0ac60
Depended on by
33d98147511b 5d90e0e045e2 c8df33a6829f
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
13 atoms, 1 tree operation, 8 files

Browse the tree at this change

added templates/test_case.html +21 −0
1 +
{% extends "base.html" %}
2 +
{% import "repo_tabs.html" as m %}
3 +
{% block title %}{{ title }} · Tests · {{ repo.owner }}/{{ repo.name }}{% endblock %}
4 +
{% block tabs %}{% call m::tabs(repo, "tests") %}{% endblock %}
5 +
{% block content %}
6 +
<div class="head"><h1><code>{{ title }}</code></h1><p class="mute"><a href="{{ repo.url }}/tests?bookmark={{ bookmark }}">Tests</a> on <strong>{{ bookmark }}</strong> · the last {{ runs.len() }} runs: <span class="cov-good">{{ passed }} passed</span>{% if failed > 0 %}, <span class="cov-poor">{{ failed }} failed</span>{% endif %}</p></div>
7 +
{% if runs.len() > 1 %}<section class="panel"><h2>Time per run</h2>{{ time_chart|safe }}</section>{% endif %}
8 +
<section class="panel">
9 +
  <div class="table"><table class="cov-runs">
10 +
  <tr><th>Run</th><th>Job</th><th>Result</th><th class="num">Time</th><th>Message</th><th>When</th></tr>
11 +
  {% for r in runs %}<tr>
12 +
    <td><a href="{{ repo.url }}/tests?report={{ r.report_id }}">#{{ r.run_number }}</a></td>
13 +
    <td>{{ r.job }}</td>
14 +
    <td>{% if r.status == "passed" %}<span class="cov-good">✓ passed</span>{% else if r.status == "skipped" %}<span class="mute">skipped</span>{% else %}<span class="cov-poor">✕ {{ r.status }}</span>{% endif %}</td>
15 +
    <td class="num">{{ self.time(r.time_ms) }}</td>
16 +
    <td class="small mute">{{ r.message }}</td>
17 +
    <td class="small"><time title="{{ r.created_ms|when_i }}">{{ r.created_ms|ago_i }}</time></td>
18 +
  </tr>{% endfor %}
19 +
  </table></div>
20 +
</section>
21 +
{% endblock %}
modified crates/hub/src/flow.rs +34 −0
…
1004 unchanged lines
1005 1005
        .into_iter()
1006 1006
        .find(|r| r.job_id == job.id)
1007 1007
        .ok_or_else(|| "tests: the report was not kept".to_string())?;
1008 +
    // The `tests` check on the run's state: what fails, and which of those
1009 +
    // passed in the same job's latest report on the default bookmark, so a
1010 +
    // proposal names what it broke.
1011 +
    let base = crate::junit::baseline(&app.db, &r, &row.default_bookmark).await.map_err(|e| format!("tests: {e}"))?;
1012 +
    let newly = match &base {
1013 +
        Some(b) if r.broken() > 0 => crate::junit::newly_failing(&app.db, r.id, b.id).await.map_err(|e| format!("tests: {e}"))?,
1014 +
        _ => vec![],
1015 +
    };
1016 +
    let (status, summary) = if r.broken() == 0 {
1017 +
        ("passed", format!("{} passed", r.passed))
1018 +
    } else if let Some(b) = &base {
1019 +
        let names: Vec<&str> = newly.iter().take(3).map(String::as_str).collect();
1020 +
        let more = if newly.len() > 3 { format!(", +{}", newly.len() - 3) } else { String::new() };
1021 +
        let new = if newly.is_empty() {
1022 +
            format!("none new against {} run #{}", row.default_bookmark, b.run_number)
1023 +
        } else {
1024 +
            format!("{} new against {}: {}{more}", newly.len(), row.default_bookmark, names.join(", "))
1025 +
        };
1026 +
        ("failed", format!("{} failing; {new}", r.broken()))
1027 +
    } else {
1028 +
        ("failed", format!("{} failing", r.broken()))
1029 +
    };
1030 +
    let url = format!("{}/{}/{}/tests?report={}", app.config.base_url(), row.owner, row.name, r.id);
1031 +
    if let Ok(c) = db::hooks::upsert_check(
1032 +
        &app.db,
1033 +
        row.id,
1034 +
        db::hooks::CheckReport { state: &run.state, name: "tests", status, summary: &summary, url: &url, reporter: None, seal: "" },
1035 +
    )
1036 +
    .await
1037 +
    {
1038 +
        let api = crate::api::check_of(&c);
1039 +
        app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) });
1040 +
        crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await;
1041 +
    }
1008 1042
    Ok(format!("tests: {} in {}", r.summary(), human_ms(r.time_ms)))
1009 1043
}
1010 1044
…
513 unchanged lines
modified crates/hub/src/junit.rs +60 −0
…
427 unchanged lines
428 428
    Ok(rows.into_iter().map(|(c, n, s)| ((c, n), s)).collect())
429 429
}
430 430
431 +
/// One test across a bookmark's reports, newest first.
432 +
#[derive(Clone, Debug, sqlx::FromRow)]
433 +
pub struct Run {
434 +
    pub report_id: i64,
435 +
    pub run_number: i64,
436 +
    pub job: String,
437 +
    pub created_ms: i64,
438 +
    pub status: String,
439 +
    pub time_ms: i64,
440 +
    pub message: String,
441 +
}
442 +
443 +
pub async fn history(pool: &Pool, repo_id: i64, bookmark: &str, classname: &str, name: &str, limit: i64) -> sqlx::Result<Vec<Run>> {
444 +
    sqlx::query_as(
445 +
        "SELECT r.id AS report_id, r.run_number, r.job, r.created_ms, c.status, c.time_ms, c.message \
446 +
         FROM test_cases c JOIN test_reports r ON r.id = c.report_id \
447 +
         WHERE r.repo_id = ? AND r.bookmark = ? AND c.classname = ? AND c.name = ? \
448 +
         ORDER BY r.created_ms DESC, r.id DESC LIMIT ?",
449 +
    )
450 +
    .bind(repo_id)
451 +
    .bind(bookmark)
452 +
    .bind(classname)
453 +
    .bind(name)
454 +
    .bind(limit)
455 +
    .fetch_all(pool)
456 +
    .await
457 +
}
458 +
459 +
/// What a report is measured against: the same job's previous report on
460 +
/// the default bookmark for a run on it, its latest for any other.
461 +
pub async fn baseline(pool: &Pool, r: &Report, default_bookmark: &str) -> sqlx::Result<Option<Report>> {
462 +
    sqlx::query_as(
463 +
        "SELECT * FROM test_reports WHERE repo_id = ? AND bookmark = ? AND job = ? AND id != ? \
464 +
         AND (created_ms < ? OR (created_ms = ? AND id < ?)) ORDER BY created_ms DESC, id DESC LIMIT 1",
465 +
    )
466 +
    .bind(r.repo_id)
467 +
    .bind(default_bookmark)
468 +
    .bind(&r.job)
469 +
    .bind(r.id)
470 +
    .bind(if r.bookmark == default_bookmark { r.created_ms } else { i64::MAX })
471 +
    .bind(r.created_ms)
472 +
    .bind(if r.bookmark == default_bookmark { r.id } else { i64::MAX })
473 +
    .fetch_optional(pool)
474 +
    .await
475 +
}
476 +
477 +
/// Tests failing in `report` that passed in `base`, by title.
478 +
pub async fn newly_failing(pool: &Pool, report: i64, base: i64) -> sqlx::Result<Vec<String>> {
479 +
    let rows: Vec<(String, String)> = sqlx::query_as(
480 +
        "SELECT c.classname, c.name FROM test_cases c JOIN test_cases b \
481 +
         ON b.report_id = ? AND b.classname = c.classname AND b.name = c.name AND b.status = 'passed' \
482 +
         WHERE c.report_id = ? AND c.status IN ('failed', 'error') ORDER BY c.idx",
483 +
    )
484 +
    .bind(base)
485 +
    .bind(report)
486 +
    .fetch_all(pool)
487 +
    .await?;
488 +
    Ok(rows.into_iter().map(|(c, n)| if c.is_empty() { n } else { format!("{c}::{n}") }).collect())
489 +
}
490 +
431 491
/// A test that both failed and passed across some reports.
432 492
#[derive(Clone, Debug, sqlx::FromRow)]
433 493
pub struct Flaky {
…
86 unchanged lines
modified crates/hub/src/web/mod.rs +1 −0
…
216 unchanged lines
217 217
        .route("/{owner}/{name}/flow", get(flow::runs))
218 218
        .route("/{owner}/{name}/flow/badge.svg", get(flow::badge))
219 219
        .route("/{owner}/{name}/tests", get(test_reports::overview))
220 +
        .route("/{owner}/{name}/tests/case", get(test_reports::case))
220 221
        .route("/{owner}/{name}/coverage", get(coverage::overview))
221 222
        .route("/{owner}/{name}/coverage/badge.svg", get(coverage::badge))
222 223
        .route("/{owner}/{name}/coverage/{id}/file/{*path}", get(coverage::file))
…
498 unchanged lines
modified crates/hub/src/web/test_reports.rs +68 −0
…
227 unchanged lines
228 228
        recent,
229 229
    }))
230 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 +
}
modified crates/hub/tests/flow.rs +22 −0
…
2347 unchanged lines
2348 2348
    let (_, all) = call(&h.app, "GET", &format!("/api/v1/repos/andy/t/tests/{second}"), None, None).await;
2349 2349
    assert_eq!(serde_json::from_str::<serde_json::Value>(&all).unwrap()["cases"].as_array().unwrap().len(), 4);
2350 2350
    assert_eq!(call(&h.app, "GET", "/api/v1/repos/andy/t/tests/99999", None, None).await.0, StatusCode::NOT_FOUND);
2351 +
2352 +
    // The `tests` check names what broke against main.
2353 +
    let reports = oxygenhub::junit::on_bookmark(&h.app.db, row.id, "main", 10).await.unwrap();
2354 +
    let check = |state: String| {
2355 +
        let app = h.app.clone();
2356 +
        async move { db::hooks::checks_for_state(&app.db, row.id, &state).await.unwrap().into_iter().find(|c| c.name == "tests").unwrap() }
2357 +
    };
2358 +
    let c = check(reports[1].state.clone()).await;
2359 +
    assert_eq!((c.status.as_str(), c.summary.as_str()), ("failed", "1 failing; 1 new against main: t.suite::b"));
2360 +
    let c = check(reports[0].state.clone()).await;
2361 +
    assert_eq!((c.status.as_str(), c.summary.as_str()), ("passed", "4 passed"));
2362 +
2363 +
    // One test's history: b passed, failed, passed.
2364 +
    let (_, tab) = call(&h.app, "GET", "/andy/t/tests", None, None).await;
2365 +
    assert!(
2366 +
        tab.contains("/andy/t/tests/case?classname=t.suite&amp;name=b") || tab.contains("/andy/t/tests/case?classname=t.suite&name=b"),
2367 +
        "linked"
2368 +
    );
2369 +
    let (st, hist) = call(&h.app, "GET", "/andy/t/tests/case?classname=t.suite&name=b", None, None).await;
2370 +
    assert_eq!(st, StatusCode::OK, "{hist}");
2371 +
    assert!(hist.contains("2 passed") && hist.contains("1 failed") && hist.contains("b broke"), "{hist}");
2372 +
    assert_eq!(call(&h.app, "GET", "/andy/t/tests/case?classname=t.suite&name=nope", None, None).await.0, StatusCode::NOT_FOUND);
2351 2373
}
modified docs/spec.md +3 −2
…
591 unchanged lines
592 592
- **Oxygen itself:** its `test` job runs under cargo-llvm-cov in the build image (oxygenflow 7705d699) and keeps its report as the `coverage` artifact; the README carries the badge.
593 593
- **API:** `GET /repos/{o}/{n}/coverage` and `/coverage/{id}` (§12.1).
594 594
595 -
### 13.7 Test reports (oxygenhub#114, first two parts done 2026-10-05)
595 +
### 13.7 Test reports (oxygenhub#114, done 2026-10-06)
596 596
597 597
- **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.
598 598
- **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.
…
1 unchanged lines
600 600
- **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.
601 601
- **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.
602 602
- **API:** `GET /repos/{o}/{n}/tests` and `/tests/{id}` (§12.1).
603 -
- **Next:** a test's history across runs, and proposals naming the tests that pass on the target and fail on the proposal.
603 +
- **A test's history.** `/tests/case?classname=&name=` shows one test across the bookmark's last 200 runs: its result, time (charted) and message in each; the Tests tab links each failure, slow test and flaky one to it.
604 +
- **The check.** Each report sets a `tests` check on its run's state: `630 passed`, or what fails and which of those passed in the same job's report on the default bookmark (its previous one for a run on it, its latest for a run elsewhere), as `2 failing; 1 new against main: tests.test_io::test_write`. A proposal shows it with its other checks, so it names what the proposal broke.
604 605
605 606
## 14. Git interoperability
606 607
…
145 unchanged lines
modified templates/tests.html +3 −3
…
43 unchanged lines
44 44
  <section class="panel cov-card">
45 45
    <h2>Flaky <small class="mute">failed and passed in the last {{ flaky_runs }} runs</small></h2>
46 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 %}
47 +
    <table class="cov-moved">{% for f in flaky %}<tr><td><a href="{{ repo.url }}/tests/case?classname={{ f.classname|urlencode }}&name={{ f.name|urlencode }}&bookmark={{ bookmark|urlencode }}"><code>{{ f.title() }}</code></a></td><td class="num"><span class="cov-poor">{{ f.failed }} ✕</span> <span class="cov-good">{{ f.passed }} ✓</span></td></tr>{% endfor %}</table>{% endif %}
48 48
  </section>
49 49
</div>
50 50
51 51
{% if !failures.is_empty() %}<section class="panel">
52 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 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>
54 +
    <summary><span class="bad">{% if f.status == "error" %}!{% else %}✕{% endif %}</span> <code>{{ f.title() }}</code> <a class="small" href="{{ repo.url }}/tests/case?classname={{ f.classname|urlencode }}&name={{ f.name|urlencode }}&bookmark={{ bookmark|urlencode }}">history</a>{% if !f.message.is_empty() %} <span class="small mute">{{ f.message }}</span>{% endif %}</summary>
55 55
    {% if !f.output.is_empty() %}<pre class="flow-log">{{ f.output }}</pre>{% endif %}
56 56
  </details>{% endfor %}</div>
57 57
</section>{% endif %}
…
7 unchanged lines
65 65
  <h2>Slowest tests <small class="mute">in run #{{ r.run_number }}</small></h2>
66 66
  <div class="table"><table class="cov-files">
67 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 %}
68 +
  {% for c in slowest %}<tr><td><a href="{{ repo.url }}/tests/case?classname={{ c.classname|urlencode }}&name={{ c.name|urlencode }}&bookmark={{ bookmark|urlencode }}"><code>{{ c.title() }}</code></a>{% 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 69
  </table></div>
70 70
</section>
71 71
…
34 unchanged lines