OXYGENHUB

Test reports from a job's junit artifact (part 1) 0b203bbf7cc3

A job's artifact named junit, in JUnit XML as pytest, cargo-nextest,
Jest and gotestsum write it, is parsed when uploaded: every test case is
kept with its status and time, and a failure's message and output cut
to a size a page can show (migration 0048). The job's log says what was
found. The run page shows each job's counts beside it and its first
failures, each collapsible to its message and output.

refs #114
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-05 19:11 UTC
Signature
✔ signed by Andy McCall
Hash
0b203bbf7cc3d20edc8ac79b9f13ded17ca92a9b896924fc982c3ee75442741c
Depends on
10708ab8f54d 19bb31ff1f5c 42fa95540468 554ccacfc123 65be9f9c3074 6f52a0419331 93be9ce4653e a3c8288b0141 b64e2537d702 d23464c0ac60 d29070faef66
Depended on by
e97500b70f9a 026a76d251ce 3b1bc27fe805 5d90e0e045e2 d9b3b84605a4 501d8b268dd4 92a797b20912 67040b158ae2 a36bab6b9ffc 3d5c825aebba 617c2840446c 36e5a9c15a84
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
21 atoms, 2 tree operations, 11 files

Browse the tree at this change

added crates/hub/migrations/0048_test_reports.sql +36 −0
1 +
-- Test reports from OxygenFlow jobs (oxygenhub#114): a job's artifact
2 +
-- named `junit` (JUnit XML) is parsed when uploaded. The counts are kept
3 +
-- per report and every case with its status and time; a failure's message
4 +
-- and output are kept cut to a size a page can show.
5 +
CREATE TABLE test_reports (
6 +
    id          INTEGER PRIMARY KEY,
7 +
    repo_id     INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
8 +
    run_id      INTEGER NOT NULL REFERENCES flow_runs(id) ON DELETE CASCADE,
9 +
    job_id      INTEGER NOT NULL UNIQUE,
10 +
    job         TEXT NOT NULL,
11 +
    run_number  INTEGER NOT NULL,
12 +
    bookmark    TEXT NOT NULL,
13 +
    state       TEXT NOT NULL,
14 +
    blob        TEXT NOT NULL,
15 +
    tests       INTEGER NOT NULL,
16 +
    passed      INTEGER NOT NULL,
17 +
    failed      INTEGER NOT NULL,
18 +
    errors      INTEGER NOT NULL,
19 +
    skipped     INTEGER NOT NULL,
20 +
    time_ms     INTEGER NOT NULL,
21 +
    created_ms  INTEGER NOT NULL
22 +
);
23 +
CREATE INDEX test_reports_bookmark ON test_reports (repo_id, bookmark, created_ms);
24 +
CREATE TABLE test_cases (
25 +
    report_id   INTEGER NOT NULL REFERENCES test_reports(id) ON DELETE CASCADE,
26 +
    idx         INTEGER NOT NULL,
27 +
    suite       TEXT NOT NULL,
28 +
    classname   TEXT NOT NULL,
29 +
    name        TEXT NOT NULL,
30 +
    status      TEXT NOT NULL,
31 +
    time_ms     INTEGER NOT NULL,
32 +
    message     TEXT NOT NULL,
33 +
    output      TEXT NOT NULL,
34 +
    PRIMARY KEY (report_id, idx)
35 +
);
36 +
CREATE INDEX test_cases_name ON test_cases (classname, name);
added crates/hub/src/junit.rs +392 −0
1 +
//! Test reports from OxygenFlow jobs (oxygenhub#114): a job's artifact
2 +
//! named `junit`, in JUnit XML as pytest `--junitxml`, cargo-nextest, Jest,
3 +
//! gotestsum and most runners write it, is parsed when it is uploaded and
4 +
//! kept per run: the counts, and every test case with its status and time.
5 +
//! A failure's message and output are kept, cut to a size a page can show.
6 +
7 +
use crate::db::{now_ms, Pool};
8 +
use crate::Shared;
9 +
use quick_xml::events::{BytesStart, Event};
10 +
use quick_xml::{Reader, XmlVersion};
11 +
12 +
/// The artifact name a job gives its JUnit report.
13 +
pub const ARTIFACT: &str = "junit";
14 +
/// Largest report read; a bigger one is noted in the job's log and skipped.
15 +
pub const MAX_REPORT: usize = 64 << 20;
16 +
/// Test cases kept from one report.
17 +
pub const MAX_CASES: usize = 50_000;
18 +
/// A failure's message, and its output, are cut to these many bytes.
19 +
const MAX_MESSAGE: usize = 2 << 10;
20 +
const MAX_OUTPUT: usize = 16 << 10;
21 +
22 +
/// One test case as the report gives it.
23 +
#[derive(Clone, Debug, PartialEq, Eq, sqlx::FromRow)]
24 +
pub struct Case {
25 +
    /// The enclosing `<testsuite>`'s name, `/`-joined when suites nest.
26 +
    pub suite: String,
27 +
    pub classname: String,
28 +
    pub name: String,
29 +
    /// `passed`, `failed`, `error` or `skipped`.
30 +
    pub status: String,
31 +
    pub time_ms: i64,
32 +
    /// The failure's, error's or skip's `message`, else its first line.
33 +
    pub message: String,
34 +
    /// The failure's or error's text, then the case's stdout and stderr;
35 +
    /// empty for a passing case.
36 +
    pub output: String,
37 +
}
38 +
39 +
impl Case {
40 +
    /// `classname::name`, or the name alone.
41 +
    pub fn title(&self) -> String {
42 +
        if self.classname.is_empty() {
43 +
            self.name.clone()
44 +
        } else {
45 +
            format!("{}::{}", self.classname, self.name)
46 +
        }
47 +
    }
48 +
}
49 +
50 +
/// Cut `s` to at most `max` bytes on a character boundary, saying so.
51 +
fn cut(mut s: String, max: usize) -> String {
52 +
    if s.len() > max {
53 +
        let mut i = max;
54 +
        while !s.is_char_boundary(i) {
55 +
            i -= 1;
56 +
        }
57 +
        s.truncate(i);
58 +
        s.push_str("\n… (cut)");
59 +
    }
60 +
    s
61 +
}
62 +
63 +
fn attr(e: &BytesStart, name: &str) -> String {
64 +
    e.try_get_attribute(name)
65 +
        .ok()
66 +
        .flatten()
67 +
        .and_then(|a| a.normalized_value(XmlVersion::Implicit1_0).ok().map(|v| v.into_owned()))
68 +
        .unwrap_or_default()
69 +
}
70 +
71 +
/// Seconds as JUnit writes them (`1.25`, sometimes `1,250.5`) to ms.
72 +
fn secs_ms(s: &str) -> i64 {
73 +
    s.replace(',', "").trim().parse::<f64>().map(|v| (v * 1000.0).round().max(0.0) as i64).unwrap_or(0)
74 +
}
75 +
76 +
/// What the parser is collecting text for.
77 +
#[derive(PartialEq)]
78 +
enum Collect {
79 +
    Nothing,
80 +
    Detail,
81 +
    Out,
82 +
}
83 +
84 +
/// Parse a JUnit XML report: every `<testcase>`, in order, wherever its
85 +
/// `<testsuite>` nests. Counts come from the cases, not the attributes.
86 +
pub fn parse(text: &str) -> Result<Vec<Case>, String> {
87 +
    let mut r = Reader::from_str(text);
88 +
    r.config_mut().trim_text(false);
89 +
    let mut suites: Vec<String> = Vec::new();
90 +
    let mut cases = Vec::new();
91 +
    let mut case: Option<Case> = None;
92 +
    let mut detail = String::new();
93 +
    let mut out = String::new();
94 +
    let mut into = Collect::Nothing;
95 +
    let mut seen_root = false;
96 +
    loop {
97 +
        let ev = r.read_event().map_err(|e| format!("not JUnit XML: {e} (at byte {})", r.error_position()))?;
98 +
        match ev {
99 +
            Event::Start(ref e) | Event::Empty(ref e)
100 +
                if e.local_name().as_ref() == "testsuites" || e.local_name().as_ref() == "testsuite" =>
101 +
            {
102 +
                seen_root = true;
103 +
                if e.local_name().as_ref() == "testsuite" {
104 +
                    suites.push(attr(e, "name"));
105 +
                }
106 +
                // An empty `<testsuite/>` has no end event.
107 +
                if matches!(ev, Event::Empty(_)) && e.local_name().as_ref() == "testsuite" {
108 +
                    suites.pop();
109 +
                }
110 +
            }
111 +
            Event::Start(ref e) | Event::Empty(ref e) if e.local_name().as_ref() == "testcase" => {
112 +
                seen_root = true;
113 +
                let c = Case {
114 +
                    suite: suites.iter().filter(|s| !s.is_empty()).cloned().collect::<Vec<_>>().join("/"),
115 +
                    classname: attr(e, "classname"),
116 +
                    name: attr(e, "name"),
117 +
                    status: "passed".into(),
118 +
                    time_ms: secs_ms(&attr(e, "time")),
119 +
                    message: String::new(),
120 +
                    output: String::new(),
121 +
                };
122 +
                if matches!(ev, Event::Empty(_)) {
123 +
                    cases.push(c);
124 +
                } else {
125 +
                    case = Some(c);
126 +
                    detail.clear();
127 +
                    out.clear();
128 +
                }
129 +
            }
130 +
            Event::Start(ref e) | Event::Empty(ref e) if case.is_some() => {
131 +
                let tag = e.local_name().as_ref().to_string();
132 +
                let c = case.as_mut().unwrap();
133 +
                let status = match tag.as_str() {
134 +
                    "failure" | "flakyFailure" if c.status == "passed" => Some("failed"),
135 +
                    "error" | "flakyError" if c.status == "passed" => Some("error"),
136 +
                    "skipped" if c.status == "passed" => Some("skipped"),
137 +
                    _ => None,
138 +
                };
139 +
                // nextest's flaky retries passed in the end: the case passed.
140 +
                let flaky = tag.starts_with("flaky") || tag.starts_with("rerun");
141 +
                if let Some(s) = status.filter(|_| !flaky) {
142 +
                    c.status = s.into();
143 +
                    c.message = attr(e, "message");
144 +
                    if matches!(ev, Event::Start(_)) {
145 +
                        into = Collect::Detail;
146 +
                    }
147 +
                } else if matches!(ev, Event::Start(_)) && (tag == "system-out" || tag == "system-err") {
148 +
                    into = Collect::Out;
149 +
                }
150 +
            }
151 +
            Event::Text(t) => push_text(&mut into, &mut detail, &mut out, &t.xml10_content()),
152 +
            Event::CData(t) => push_text(&mut into, &mut detail, &mut out, &t.xml10_content()),
153 +
            Event::GeneralRef(g) => {
154 +
                let s = match g.resolve_char_ref() {
155 +
                    Ok(Some(ch)) => ch.to_string(),
156 +
                    _ => {
157 +
                        let name = g.xml10_content();
158 +
                        quick_xml::escape::resolve_predefined_entity(&name).map(str::to_string).unwrap_or_else(|| format!("&{name};"))
159 +
                    }
160 +
                };
161 +
                push_text(&mut into, &mut detail, &mut out, &s);
162 +
            }
163 +
            Event::End(e) => match e.local_name().as_ref() {
164 +
                "testsuite" => {
165 +
                    suites.pop();
166 +
                }
167 +
                "testcase" => {
168 +
                    if let Some(mut c) = case.take() {
169 +
                        if c.status != "passed" {
170 +
                            if c.message.is_empty() {
171 +
                                c.message = detail.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("").to_string();
172 +
                            }
173 +
                            let mut o = detail.trim().to_string();
174 +
                            if !out.trim().is_empty() && c.status != "skipped" {
175 +
                                if !o.is_empty() {
176 +
                                    o.push_str("\n\n");
177 +
                                }
178 +
                                o.push_str(out.trim());
179 +
                            }
180 +
                            c.output = cut(o, MAX_OUTPUT);
181 +
                        }
182 +
                        c.message = cut(c.message, MAX_MESSAGE);
183 +
                        cases.push(c);
184 +
                        if cases.len() > MAX_CASES {
185 +
                            return Err(format!("more than {MAX_CASES} test cases"));
186 +
                        }
187 +
                    }
188 +
                    into = Collect::Nothing;
189 +
                }
190 +
                "failure" | "error" | "skipped" | "system-out" | "system-err" | "flakyFailure" | "flakyError" | "rerunFailure"
191 +
                | "rerunError" => into = Collect::Nothing,
192 +
                _ => {}
193 +
            },
194 +
            Event::Eof => break,
195 +
            _ => {}
196 +
        }
197 +
    }
198 +
    if !seen_root {
199 +
        return Err("not JUnit XML: no <testsuites>, <testsuite> or <testcase>".into());
200 +
    }
201 +
    Ok(cases)
202 +
}
203 +
204 +
fn push_text(into: &mut Collect, detail: &mut String, out: &mut String, s: &str) {
205 +
    let (buf, max) = match into {
206 +
        Collect::Detail => (detail, MAX_OUTPUT),
207 +
        Collect::Out => (out, MAX_OUTPUT),
208 +
        Collect::Nothing => return,
209 +
    };
210 +
    // Keep a little past the cut so `cut` can say it cut.
211 +
    if buf.len() <= max {
212 +
        buf.push_str(s);
213 +
    }
214 +
}
215 +
216 +
/// A run's kept report.
217 +
#[derive(Clone, Debug, sqlx::FromRow)]
218 +
pub struct Report {
219 +
    pub id: i64,
220 +
    pub repo_id: i64,
221 +
    pub run_id: i64,
222 +
    pub job_id: i64,
223 +
    pub job: String,
224 +
    pub run_number: i64,
225 +
    pub bookmark: String,
226 +
    pub state: String,
227 +
    pub blob: String,
228 +
    pub tests: i64,
229 +
    pub passed: i64,
230 +
    pub failed: i64,
231 +
    pub errors: i64,
232 +
    pub skipped: i64,
233 +
    pub time_ms: i64,
234 +
    pub created_ms: i64,
235 +
}
236 +
237 +
impl Report {
238 +
    /// "630 tests: 2 failed, 3 skipped", or "630 passed".
239 +
    pub fn summary(&self) -> String {
240 +
        let mut bad = vec![];
241 +
        if self.failed > 0 {
242 +
            bad.push(format!("{} failed", self.failed));
243 +
        }
244 +
        if self.errors > 0 {
245 +
            bad.push(format!("{} errored", self.errors));
246 +
        }
247 +
        if self.skipped > 0 {
248 +
            bad.push(format!("{} skipped", self.skipped));
249 +
        }
250 +
        let tests = if self.tests == 1 { "1 test".to_string() } else { format!("{} tests", self.tests) };
251 +
        if bad.is_empty() {
252 +
            format!("{tests}, all passed")
253 +
        } else {
254 +
            format!("{tests}: {}", bad.join(", "))
255 +
        }
256 +
    }
257 +
258 +
    pub fn broken(&self) -> i64 {
259 +
        self.failed + self.errors
260 +
    }
261 +
}
262 +
263 +
/// What a run's report is kept with.
264 +
pub struct Origin<'a> {
265 +
    pub repo_id: i64,
266 +
    pub run_id: i64,
267 +
    pub run_number: i64,
268 +
    pub job_id: i64,
269 +
    pub job: &'a str,
270 +
    pub bookmark: &'a str,
271 +
    pub state: &'a str,
272 +
    pub blob: &'a str,
273 +
}
274 +
275 +
/// Keep a parsed report, replacing an earlier one of the same job.
276 +
pub async fn keep(app: &Shared, o: &Origin<'_>, cases: &[Case]) -> sqlx::Result<i64> {
277 +
    let count = |s: &str| cases.iter().filter(|c| c.status == s).count() as i64;
278 +
    let mut tx = crate::db::begin_write(&app.db).await?;
279 +
    sqlx::query("DELETE FROM test_reports WHERE job_id = ?").bind(o.job_id).execute(&mut *tx).await?;
280 +
    let id: i64 = sqlx::query_scalar(
281 +
        "INSERT INTO test_reports (repo_id, run_id, job_id, job, run_number, bookmark, state, blob, \
282 +
         tests, passed, failed, errors, skipped, time_ms, created_ms) \
283 +
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id",
284 +
    )
285 +
    .bind(o.repo_id)
286 +
    .bind(o.run_id)
287 +
    .bind(o.job_id)
288 +
    .bind(o.job)
289 +
    .bind(o.run_number)
290 +
    .bind(o.bookmark)
291 +
    .bind(o.state)
292 +
    .bind(o.blob)
293 +
    .bind(cases.len() as i64)
294 +
    .bind(count("passed"))
295 +
    .bind(count("failed"))
296 +
    .bind(count("error"))
297 +
    .bind(count("skipped"))
298 +
    .bind(cases.iter().map(|c| c.time_ms).sum::<i64>())
299 +
    .bind(now_ms())
300 +
    .fetch_one(&mut *tx)
301 +
    .await?;
302 +
    for (i, c) in cases.iter().enumerate() {
303 +
        sqlx::query(
304 +
            "INSERT INTO test_cases (report_id, idx, suite, classname, name, status, time_ms, message, output) \
305 +
             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
306 +
        )
307 +
        .bind(id)
308 +
        .bind(i as i64)
309 +
        .bind(&c.suite)
310 +
        .bind(&c.classname)
311 +
        .bind(&c.name)
312 +
        .bind(&c.status)
313 +
        .bind(c.time_ms)
314 +
        .bind(&c.message)
315 +
        .bind(&c.output)
316 +
        .execute(&mut *tx)
317 +
        .await?;
318 +
    }
319 +
    tx.commit().await?;
320 +
    Ok(id)
321 +
}
322 +
323 +
/// A run's reports, one per job that kept one.
324 +
pub async fn for_run(pool: &Pool, run_id: i64) -> sqlx::Result<Vec<Report>> {
325 +
    sqlx::query_as("SELECT * FROM test_reports WHERE run_id = ? ORDER BY job").bind(run_id).fetch_all(pool).await
326 +
}
327 +
328 +
/// A report's failed and errored cases, in report order.
329 +
pub async fn failures(pool: &Pool, report_id: i64, limit: i64) -> sqlx::Result<Vec<Case>> {
330 +
    sqlx::query_as(
331 +
        "SELECT suite, classname, name, status, time_ms, message, output FROM test_cases \
332 +
         WHERE report_id = ? AND status IN ('failed', 'error') ORDER BY idx LIMIT ?",
333 +
    )
334 +
    .bind(report_id)
335 +
    .bind(limit)
336 +
    .fetch_all(pool)
337 +
    .await
338 +
}
339 +
340 +
#[cfg(test)]
341 +
mod tests {
342 +
    use super::*;
343 +
344 +
    #[test]
345 +
    fn pytest_nextest_and_jest_shapes() {
346 +
        // pytest: one suite, a failure with a message and text, a skip, an error.
347 +
        let py = r#"<?xml version="1.0" encoding="utf-8"?>
348 +
<testsuites><testsuite name="pytest" errors="1" failures="1" skipped="1" tests="4" time="3.5">
349 +
<testcase classname="tests.test_a" name="test_ok" time="0.010"/>
350 +
<testcase classname="tests.test_a" name="test_bad" time="1.25"><failure message="assert 1 == 2">def test_bad():
351 +
&gt;       assert 1 == 2
352 +
E       assert 1 == 2</failure><system-out>printed &amp; kept</system-out></testcase>
353 +
<testcase classname="tests.test_b" name="test_skip" time="0"><skipped type="pytest.skip" message="no emulator"/></testcase>
354 +
<testcase classname="tests.test_b" name="test_err" time="0.5"><error message="fixture failed"><![CDATA[Traceback <here>]]></error></testcase>
355 +
</testsuite></testsuites>"#;
356 +
        let c = parse(py).unwrap();
357 +
        let st: Vec<(&str, &str)> = c.iter().map(|c| (c.name.as_str(), c.status.as_str())).collect();
358 +
        assert_eq!(st, [("test_ok", "passed"), ("test_bad", "failed"), ("test_skip", "skipped"), ("test_err", "error")]);
359 +
        assert_eq!(c[1].message, "assert 1 == 2");
360 +
        assert!(c[1].output.contains(">       assert 1 == 2") && c[1].output.contains("printed & kept"), "{}", c[1].output);
361 +
        assert_eq!((c[1].time_ms, c[0].time_ms), (1250, 10));
362 +
        assert_eq!(c[2].message, "no emulator");
363 +
        assert_eq!(c[3].output, "Traceback <here>");
364 +
        assert_eq!(c[1].suite, "pytest");
365 +
        assert_eq!(c[1].title(), "tests.test_a::test_bad");
366 +
        assert!(c[0].output.is_empty());
367 +
368 +
        // nextest: a flaky test that passed on retry is a pass.
369 +
        let nx = r#"<testsuites name="nextest-run"><testsuite name="oxy-core" tests="2">
370 +
<testcase name="repo::tests::a" classname="oxy-core::lib" time="0.2"><flakyFailure message="first try" type="test failure">boom</flakyFailure></testcase>
371 +
<testcase name="repo::tests::b" classname="oxy-core::lib" time="0.1"/></testsuite></testsuites>"#;
372 +
        let c = parse(nx).unwrap();
373 +
        assert_eq!(c.iter().filter(|c| c.status == "passed").count(), 2, "{c:?}");
374 +
375 +
        // Jest: nested suites, no wrapper on a single suite is fine too.
376 +
        let js = r#"<testsuite name="ui"><testsuite name="Button"><testcase classname="Button renders" name="renders" time="0.003"/></testsuite></testsuite>"#;
377 +
        let c = parse(js).unwrap();
378 +
        assert_eq!(c[0].suite, "ui/Button");
379 +
380 +
        assert!(parse("<html/>").is_err());
381 +
        assert!(parse("not xml <<<").is_err());
382 +
        assert!(parse("<testsuites/>").unwrap().is_empty());
383 +
    }
384 +
385 +
    #[test]
386 +
    fn long_output_is_cut() {
387 +
        let big = "x".repeat(MAX_OUTPUT * 3);
388 +
        let xml = format!(r#"<testsuite><testcase name="t"><failure>{big}</failure></testcase></testsuite>"#);
389 +
        let c = parse(&xml).unwrap();
390 +
        assert!(c[0].output.len() < MAX_OUTPUT + 32 && c[0].output.ends_with("(cut)"));
391 +
    }
392 +
}
modified Cargo.lock +1 −0
…
2368 unchanged lines
2369 2369
 "oxy-strata",
2370 2370
 "pulldown-cmark",
2371 2371
 "qrcode",
2372 +
 "quick-xml 0.42.0",
2372 2373
 "rand 0.8.8",
2373 2374
 "rcgen",
2374 2375
 "regex",
…
2480 unchanged lines
modified crates/hub/Cargo.toml +2 −0
…
11 unchanged lines
12 12
path = "src/main.rs"
13 13
14 14
[dependencies]
15 +
# JUnit XML test reports from Flow jobs (oxygenhub#114).
16 +
quick-xml = "0.42"
15 17
# Function boundaries on file pages (hub spec §6.6): the grammars of the
16 18
# languages the Oxygen projects are written in.
17 19
tree-sitter = "0.27"
…
65 unchanged lines
modified crates/hub/src/flow.rs +46 −0
…
942 unchanged lines
943 943
    let content = blake3::hash(&body).to_hex().to_string();
944 944
    let size = body.len() as i64;
945 945
    let report_bytes = (name == crate::coverage::ARTIFACT).then(|| body.clone());
946 +
    let junit_bytes = (name == crate::junit::ARTIFACT).then(|| body.clone());
946 947
    let blob = app
947 948
        .repos
948 949
        .write(&repo, move |r, _| Ok(r.store_blob(&body)?))
…
10 unchanged lines
959 960
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
960 961
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
961 962
    }
963 +
    // A test report (#114) likewise.
964 +
    if let Some(bytes) = junit_bytes {
965 +
        let said = match read_junit(&app, &row, &run, &job, &a.blob, bytes).await {
966 +
            Ok(text) | Err(text) => text,
967 +
        };
968 +
        let record = flow_core::api::Record { t: 0, stream: "flow".into(), text: said };
969 +
        fdb::append_log(&app.db, job.id, &[record], MAX_RECORDS as i64).await.ok();
970 +
    }
962 971
    Ok(Json(serde_json::json!({ "blob": a.blob })).into_response())
972 +
}
973 +
974 +
/// Parse and keep a job's JUnit report: what it says, or why not.
975 +
async fn read_junit(
976 +
    app: &Shared,
977 +
    row: &db::repos::RepoRow,
978 +
    run: &fdb::Run,
979 +
    job: &fdb::Job,
980 +
    blob: &str,
981 +
    bytes: axum::body::Bytes,
982 +
) -> Result<String, String> {
983 +
    if bytes.len() > crate::junit::MAX_REPORT {
984 +
        return Err(format!("tests: the report is {} MiB; at most {} MiB is read", bytes.len() >> 20, crate::junit::MAX_REPORT >> 20));
985 +
    }
986 +
    let text = String::from_utf8(bytes.to_vec()).map_err(|_| "tests: the report is not UTF-8 text (JUnit XML is expected)".to_string())?;
987 +
    let cases = tokio::task::spawn_blocking(move || crate::junit::parse(&text))
988 +
        .await
989 +
        .map_err(|e| format!("tests: {e}"))?
990 +
        .map_err(|e| format!("tests: {e}"))?;
991 +
    let origin = crate::junit::Origin {
992 +
        repo_id: row.id,
993 +
        run_id: run.id,
994 +
        run_number: run.number,
995 +
        job_id: job.id,
996 +
        job: &job.name,
997 +
        bookmark: &run.bookmark,
998 +
        state: &run.state,
999 +
        blob,
1000 +
    };
1001 +
    crate::junit::keep(app, &origin, &cases).await.map_err(|e| format!("tests: {e}"))?;
1002 +
    let r = crate::junit::for_run(&app.db, run.id)
1003 +
        .await
1004 +
        .map_err(|e| format!("tests: {e}"))?
1005 +
        .into_iter()
1006 +
        .find(|r| r.job_id == job.id)
1007 +
        .ok_or_else(|| "tests: the report was not kept".to_string())?;
1008 +
    Ok(format!("tests: {} in {}", r.summary(), human_ms(r.time_ms)))
963 1009
}
964 1010
965 1011
/// Parse and keep a job's coverage report: what it says, or why not.
…
493 unchanged lines
modified crates/hub/src/lib.rs +1 −0
…
12 unchanged lines
13 13
pub mod events;
14 14
pub mod flow;
15 15
pub mod hooks;
16 +
pub mod junit;
16 17
pub mod keys;
17 18
pub mod levels;
18 19
pub mod live;
…
148 unchanged lines
modified crates/hub/src/web/flow.rs +14 −1
…
279 unchanged lines
280 280
    }
281 281
}
282 282
283 +
/// Failed tests a run page lists per job; the rest are counted.
284 +
const SHOWN_FAILURES: i64 = 50;
285 +
283 286
pub struct JobLog {
284 287
    pub j: fdb::Job,
285 288
    pub artifacts: Vec<fdb::Artifact>,
…
3 unchanged lines
289 292
    pub last_seq: i64,
290 293
    /// The job's coverage report, when it uploaded one (#113): its id and percentage.
291 294
    pub coverage: Option<(i64, String)>,
295 +
    /// The job's test report, when it uploaded one (#114), and its first
296 +
    /// failures.
297 +
    pub tests: Option<crate::junit::Report>,
298 +
    pub failures: Vec<crate::junit::Case>,
292 299
}
293 300
294 301
#[derive(Template)]
…
31 unchanged lines
326 333
        .ok_or_else(|| Error::not_found(shell.clone()))?;
327 334
    let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
328 335
    let reports = crate::coverage::for_run(&app.db, run.id).await.unwrap_or_default();
336 +
    let test_reports = crate::junit::for_run(&app.db, run.id).await.unwrap_or_default();
329 337
    let mut logs = Vec::new();
330 338
    for j in &jobs {
331 339
        let lines = fdb::log(&app.db, j.id, 0, PAGE_LINES).await.unwrap_or_default();
…
3 unchanged lines
335 343
            .iter()
336 344
            .find(|r| r.job_id == j.id)
337 345
            .map(|r| (r.id, r.percent().map(|p| format!("{p:.1}%")).unwrap_or_else(|| "no lines".into())));
338 -
        logs.push(JobLog { duration: crate::flow::duration(j), j: j.clone(), artifacts, lines, last_seq, coverage });
346 +
        let tests = test_reports.iter().find(|r| r.job_id == j.id).cloned();
347 +
        let failures = match &tests {
348 +
            Some(r) if r.broken() > 0 => crate::junit::failures(&app.db, r.id, SHOWN_FAILURES).await.unwrap_or_default(),
349 +
            _ => vec![],
350 +
        };
351 +
        logs.push(JobLog { duration: crate::flow::duration(j), j: j.clone(), artifacts, lines, last_seq, coverage, tests, failures });
339 352
    }
340 353
    let duration = match run.finished_ms {
341 354
        Some(f) => crate::flow::human_ms(f - run.created_ms),
…
393 unchanged lines
modified crates/hub/tests/flow.rs +6 −1
…
958 unchanged lines
959 959
    let run = runs_of(&h.app, "andy", "demo").await.remove(0);
960 960
    let a = claim(&h.app).await.unwrap();
961 961
    let id = a["job_id"].as_i64().unwrap();
962 -
    let (st, text) = upload(&h.app, id, "junit", b"<testsuites/>").await;
962 +
    // A JUnit report (#114): parsed on upload, counted, failures listed.
963 +
    let xml = br#"<testsuites><testsuite name="pytest"><testcase classname="tests.test_io" name="test_read" time="0.5"/><testcase classname="tests.test_io" name="test_write" time="1.5"><failure message="disk full">E   OSError: disk full</failure></testcase></testsuite></testsuites>"#;
964 +
    let (st, text) = upload(&h.app, id, "junit", xml).await;
963 965
    assert_eq!(st, StatusCode::OK, "{text}");
964 966
    let blob = serde_json::from_str::<serde_json::Value>(&text).unwrap()["blob"].as_str().unwrap().to_string();
965 967
    let mut r = receipt("test", a["pipeline_hash"].as_str().unwrap(), 1);
…
8 unchanged lines
974 976
    let (_, page) = call(&h.app, "GET", &format!("/andy/demo/flow/{}", run.number), None, None).await;
975 977
    assert!(page.contains(&format!("/andy/demo/flow/{}/artifacts/test/junit", run.number)), "listed");
976 978
    assert!(page.contains("not in its receipt"), "marked");
979 +
    assert!(page.contains("2 tests: 1 failed"), "counts beside the job");
980 +
    assert!(page.contains("tests.test_io::test_write") && page.contains("disk full") && page.contains("OSError"), "the failure");
981 +
    assert!(page.contains("tests: 2 tests: 1 failed in 2.0 s"), "said in the job's log");
977 982
}
978 983
979 984
/// A release's trust report (hub spec §9.1): a signed tag, an asset built
…
1247 unchanged lines
modified docs/spec.md +8 −0
…
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, a trend of the last 30 runs, 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)
594 +
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 +
- **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 +
- **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 +
- **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.
600 +
593 601
## 14. Git interoperability
594 602
595 603
A repository may enable a **Git mirror**: on every landing on selected bookmarks the hub runs `oxy git export` into `<data>/mirrors/<owner>/<name>.git` and serves it read-only over Git's smart HTTP protocol at `/{owner}/{name}.git` (implemented with the `git` binary's `http-backend` behind the hub's authorization, since reimplementing pack negotiation is not the product). This lets GitHub Actions runners, IDEs and tooling that only speak Git read hosted Oxygen repositories. Pushing to a mirror is refused with a message pointing at `oxy`.
…
144 unchanged lines
modified templates/flow_run.html +9 −1
…
31 unchanged lines
32 32
</section>
33 33
{% for l in logs %}
34 34
<details class="panel flow-job st-{{ l.j.status }}" id="job-{{ l.j.id }}" data-job="{{ l.j.id }}"{% if l.j.status == "failed" || l.j.status == "error" || l.j.status == "running" %} open{% endif %}>
35 -
  <summary><span class="flow-glyph st-{{ l.j.status }}" data-glyph>{{ crate::web::flow::glyph(l.j.status) }}</span> <strong>{{ l.j.name }}</strong> <span class="small mute" data-meta>{{ l.j.status }}{% if let Some(c) = l.j.exit_code %} · exit {{ c }}{% endif %} · {{ l.duration }}{% if !l.j.runner.is_empty() %} · on {{ l.j.runner }}{% endif %}</span>{% if !l.j.receipt_attest.is_empty() %} <span class="small mute">· <a href="{{ repo.url }}/receipts/{{ l.j.receipt_attest }}" title="Receipt {{ l.j.receipt_hash }}, kept in the repository; check it with oxy receipt verify">signed receipt</a> by {{ l.j.receipt_signer }}</span>{% else if !l.j.receipt_note.is_empty() %} <span class="small warn" title="{{ l.j.receipt_note }}">· receipt not kept: {{ l.j.receipt_note }}</span>{% else if !l.j.receipt_hash.is_empty() %} <span class="small mute">· unsigned receipt <code title="{{ l.j.receipt_hash }}">{{ l.j.receipt_hash|short }}</code></span>{% endif %}{% if !l.artifacts.is_empty() %} <span class="small mute">· {{ l.artifacts.len()|plural("artifact", "artifacts") }}</span>{% endif %}{% if let Some((cid, pct)) = l.coverage %} <span class="small">· <a href="{{ repo.url }}/coverage?report={{ cid }}">coverage {{ pct }}</a></span>{% endif %}</summary>
35 +
  <summary><span class="flow-glyph st-{{ l.j.status }}" data-glyph>{{ crate::web::flow::glyph(l.j.status) }}</span> <strong>{{ l.j.name }}</strong> <span class="small mute" data-meta>{{ l.j.status }}{% if let Some(c) = l.j.exit_code %} · exit {{ c }}{% endif %} · {{ l.duration }}{% if !l.j.runner.is_empty() %} · on {{ l.j.runner }}{% endif %}</span>{% if !l.j.receipt_attest.is_empty() %} <span class="small mute">· <a href="{{ repo.url }}/receipts/{{ l.j.receipt_attest }}" title="Receipt {{ l.j.receipt_hash }}, kept in the repository; check it with oxy receipt verify">signed receipt</a> by {{ l.j.receipt_signer }}</span>{% else if !l.j.receipt_note.is_empty() %} <span class="small warn" title="{{ l.j.receipt_note }}">· receipt not kept: {{ l.j.receipt_note }}</span>{% else if !l.j.receipt_hash.is_empty() %} <span class="small mute">· unsigned receipt <code title="{{ l.j.receipt_hash }}">{{ l.j.receipt_hash|short }}</code></span>{% endif %}{% if !l.artifacts.is_empty() %} <span class="small mute">· {{ l.artifacts.len()|plural("artifact", "artifacts") }}</span>{% endif %}{% if let Some(t) = l.tests %} <span class="small{% if t.broken() > 0 %} warn{% endif %}">· <a href="#tests-{{ l.j.id }}">{{ t.summary() }}</a></span>{% endif %}{% if let Some((cid, pct)) = l.coverage %} <span class="small">· <a href="{{ repo.url }}/coverage?report={{ cid }}">coverage {{ pct }}</a></span>{% endif %}</summary>
36 36
  {% if !l.j.error.is_empty() %}<div class="strip bad">{{ l.j.error }}</div>{% endif %}
37 37
  {% if !l.artifacts.is_empty() %}<ul class="flow-artifacts small">{% for a in l.artifacts %}<li><a href="{{ repo.url }}/flow/{{ run.number }}/artifacts/{{ l.j.name }}/{{ a.name }}">{{ a.name }}</a> <span class="mute">{{ a.path }} · {{ a.size|bytes }} · blob <code title="{{ a.blob }}">{{ a.blob|short }}</code></span>{% if l.j.status != "passed" %} <span class="warn" title="Kept with when &quot;always&quot;: the job did not pass, so its receipt does not name this file">· from a job that did not pass, not in its receipt</span>{% endif %}{% if !releases.is_empty() && l.j.status == "passed" %} <form class="tiny inline" method="post" action="{{ repo.url }}/flow/{{ run.number }}/artifacts/{{ l.j.name }}/{{ a.name }}/attach"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><select name="release" aria-label="Release to attach {{ a.name }} to">{% for r in releases %}<option value="{{ r.id }}">{{ r.tag }}</option>{% endfor %}</select> <button>Attach to release</button></form>{% endif %}</li>{% endfor %}</ul>{% endif %}
38 +
  {% if let Some(t) = l.tests %}<div class="flow-tests" id="tests-{{ l.j.id }}">
39 +
    <p class="small"><span class="tests-count ok">{{ t.passed }} passed</span>{% if t.failed > 0 %} · <span class="tests-count bad">{{ t.failed }} failed</span>{% endif %}{% if t.errors > 0 %} · <span class="tests-count bad">{{ t.errors }} errored</span>{% endif %}{% if t.skipped > 0 %} · <span class="tests-count mute">{{ t.skipped }} skipped</span>{% endif %} <span class="mute">· {{ crate::flow::human_ms(*t.time_ms) }} of test time</span></p>
40 +
    {% for f in l.failures %}<details class="tests-failure">
41 +
      <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>
42 +
      {% if !f.output.is_empty() %}<pre class="flow-log">{{ f.output }}</pre>{% endif %}
43 +
    </details>{% endfor %}
44 +
    {% if t.broken() > l.failures.len() as i64 %}<p class="small mute">and {{ t.broken() - l.failures.len() as i64 }} more failures; the junit artifact has them all.</p>{% endif %}
45 +
  </div>{% endif %}
38 46
  <pre class="flow-log" data-log data-seq="{{ l.last_seq }}">{% for line in l.lines %}<span class="ln s-{{ line.stream }}"><span class="t">{{ line.t / 1000 }}.{{ "{:01}"|format(line.t % 1000 / 100) }}s</span>{{ line.text }}</span>
39 47
{% endfor %}</pre>
40 48
  {% if l.j.status == "queued" && !l.j.waiting.is_empty() %}<p class="small warn pad" data-waiting>Waiting: {{ l.j.waiting }}.</p>{% else if l.lines.is_empty() && l.j.status == "queued" %}<p class="small mute pad">Waiting for a runner{% if !l.j.depends_on().is_empty() %} and for {{ l.j.depends_on().join(", ") }}{% endif %}.</p>{% endif %}
…
5 unchanged lines
modified templates/style.css +7 −0
…
475 unchanged lines
476 476
ul.flow-artifacts { list-style: none; margin: 0; padding: 6px 12px; border-top: 1px solid var(--line); }
477 477
ul.flow-artifacts li { padding: 2px 0; overflow-wrap: anywhere; }
478 478
ul.flow-artifacts select { font-size: 11px; }
479 +
.flow-tests { padding: 6px 12px; border-top: 1px solid var(--line); }
480 +
.flow-tests > p { margin: 0 0 4px; }
481 +
.flow-tests .tests-count.ok { color: var(--ok); }
482 +
.flow-tests .tests-count.bad, .flow-tests .bad { color: var(--bad); font-weight: 600; }
483 +
.tests-failure { border-left: 3px solid var(--bad); margin: 4px 0; padding: 2px 8px; }
484 +
.tests-failure summary { cursor: pointer; overflow-wrap: anywhere; }
485 +
.tests-failure pre.flow-log { padding: 6px 8px; margin: 4px 0; white-space: pre-wrap; word-break: break-word; max-height: 24em; overflow: auto; }
479 486
pre.flow-log .ln { display: block; padding: 0 12px; white-space: pre-wrap; word-break: break-word; }
480 487
pre.flow-log .t { display: inline-block; min-width: 5.5em; color: var(--mute); user-select: none; }
481 488
pre.flow-log .s-err { color: color-mix(in srgb, var(--bad) 75%, var(--ink)); }
…
347 unchanged lines