OXYGENHUB
# 617c2840446c oxygenhub / crates / hub / src / junit.rs
19.9 KiB · Rust · last changed API: coverage and test reports 1 month ago source blame history raw
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
    mark(app, o.repo_id);
321
    Ok(id)
322
}
323
324
/// A run's reports, one per job that kept one.
325
pub async fn for_run(pool: &Pool, run_id: i64) -> sqlx::Result<Vec<Report>> {
326
    sqlx::query_as("SELECT * FROM test_reports WHERE run_id = ? ORDER BY job").bind(run_id).fetch_all(pool).await
327
}
328
329
/// A report's cases in report order, all or only those with a status
330
/// among `statuses`.
331
pub async fn cases(pool: &Pool, report_id: i64, statuses: &[&str], limit: i64) -> sqlx::Result<Vec<Case>> {
332
    let filter = if statuses.is_empty() {
333
        String::new()
334
    } else {
335
        format!(" AND status IN ({})", statuses.iter().map(|_| "?").collect::<Vec<_>>().join(", "))
336
    };
337
    let sql = format!(
338
        "SELECT suite, classname, name, status, time_ms, message, output FROM test_cases WHERE report_id = ?{filter} ORDER BY idx LIMIT ?"
339
    );
340
    let mut q = sqlx::query_as(&sql).bind(report_id);
341
    for s in statuses {
342
        q = q.bind(*s);
343
    }
344
    q.bind(limit).fetch_all(pool).await
345
}
346
347
/// A report's failed and errored cases, in report order.
348
pub async fn failures(pool: &Pool, report_id: i64, limit: i64) -> sqlx::Result<Vec<Case>> {
349
    sqlx::query_as(
350
        "SELECT suite, classname, name, status, time_ms, message, output FROM test_cases \
351
         WHERE report_id = ? AND status IN ('failed', 'error') ORDER BY idx LIMIT ?",
352
    )
353
    .bind(report_id)
354
    .bind(limit)
355
    .fetch_all(pool)
356
    .await
357
}
358
359
/// Which repositories have test reports, for the Tests tab; keyed by data
360
/// directory as well, for tests running several hubs in one process.
361
static HAS: std::sync::Mutex<Option<std::collections::HashSet<String>>> = std::sync::Mutex::new(None);
362
363
fn key(app: &Shared, repo_id: i64) -> String {
364
    format!("{}#{repo_id}", app.repos.root().display())
365
}
366
367
pub fn has_reports(app: &Shared, repo_id: i64) -> bool {
368
    HAS.lock().unwrap_or_else(|p| p.into_inner()).as_ref().is_some_and(|s| s.contains(&key(app, repo_id)))
369
}
370
371
fn mark(app: &Shared, repo_id: i64) {
372
    HAS.lock().unwrap_or_else(|p| p.into_inner()).get_or_insert_with(Default::default).insert(key(app, repo_id));
373
}
374
375
/// At start: which repositories have reports.
376
pub async fn load(app: &Shared) -> sqlx::Result<()> {
377
    let ids: Vec<(i64,)> = sqlx::query_as("SELECT DISTINCT repo_id FROM test_reports").fetch_all(&app.db).await?;
378
    for (id,) in ids {
379
        mark(app, id);
380
    }
381
    Ok(())
382
}
383
384
pub async fn report(pool: &Pool, repo_id: i64, id: i64) -> sqlx::Result<Option<Report>> {
385
    sqlx::query_as("SELECT * FROM test_reports WHERE id = ? AND repo_id = ?").bind(id).bind(repo_id).fetch_optional(pool).await
386
}
387
388
/// The newest reports on a bookmark, newest first.
389
pub async fn on_bookmark(pool: &Pool, repo_id: i64, bookmark: &str, limit: i64) -> sqlx::Result<Vec<Report>> {
390
    sqlx::query_as("SELECT * FROM test_reports WHERE repo_id = ? AND bookmark = ? ORDER BY created_ms DESC, id DESC LIMIT ?")
391
        .bind(repo_id)
392
        .bind(bookmark)
393
        .bind(limit)
394
        .fetch_all(pool)
395
        .await
396
}
397
398
/// Bookmarks with reports, the most recently reported first.
399
pub async fn bookmarks(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<String>> {
400
    let rows: Vec<(String,)> =
401
        sqlx::query_as("SELECT bookmark FROM test_reports WHERE repo_id = ? GROUP BY bookmark ORDER BY MAX(created_ms) DESC")
402
            .bind(repo_id)
403
            .fetch_all(pool)
404
            .await?;
405
    Ok(rows.into_iter().map(|(b,)| b).collect())
406
}
407
408
pub async fn first_ms(pool: &Pool, repo_id: i64) -> sqlx::Result<Option<i64>> {
409
    sqlx::query_scalar("SELECT MIN(created_ms) FROM test_reports WHERE repo_id = ?").bind(repo_id).fetch_one(pool).await
410
}
411
412
/// A report's slowest cases.
413
pub async fn slowest(pool: &Pool, report_id: i64, limit: i64) -> sqlx::Result<Vec<Case>> {
414
    sqlx::query_as(
415
        "SELECT suite, classname, name, status, time_ms, message, '' AS output FROM test_cases \
416
         WHERE report_id = ? AND status != 'skipped' ORDER BY time_ms DESC, idx LIMIT ?",
417
    )
418
    .bind(report_id)
419
    .bind(limit)
420
    .fetch_all(pool)
421
    .await
422
}
423
424
/// Every case of a report as `(classname, name) -> status`.
425
pub async fn statuses(pool: &Pool, report_id: i64) -> sqlx::Result<std::collections::HashMap<(String, String), String>> {
426
    let rows: Vec<(String, String, String)> =
427
        sqlx::query_as("SELECT classname, name, status FROM test_cases WHERE report_id = ?").bind(report_id).fetch_all(pool).await?;
428
    Ok(rows.into_iter().map(|(c, n, s)| ((c, n), s)).collect())
429
}
430
431
/// A test that both failed and passed across some reports.
432
#[derive(Clone, Debug, sqlx::FromRow)]
433
pub struct Flaky {
434
    pub classname: String,
435
    pub name: String,
436
    pub failed: i64,
437
    pub passed: i64,
438
}
439
440
impl Flaky {
441
    pub fn title(&self) -> String {
442
        if self.classname.is_empty() {
443
            self.name.clone()
444
        } else {
445
            format!("{}::{}", self.classname, self.name)
446
        }
447
    }
448
}
449
450
/// Tests that failed in some of `reports` and passed in others, the most
451
/// often failing first: flaky, or broken and fixed again.
452
pub async fn flaky(pool: &Pool, reports: &[i64], limit: i64) -> sqlx::Result<Vec<Flaky>> {
453
    if reports.is_empty() {
454
        return Ok(vec![]);
455
    }
456
    let ids = reports.iter().map(|i| i.to_string()).collect::<Vec<_>>().join(",");
457
    sqlx::query_as(&format!(
458
        "SELECT classname, name, SUM(status IN ('failed', 'error')) AS failed, SUM(status = 'passed') AS passed \
459
         FROM test_cases WHERE report_id IN ({ids}) GROUP BY classname, name \
460
         HAVING failed > 0 AND passed > 0 ORDER BY failed DESC, classname, name LIMIT ?"
461
    ))
462
    .bind(limit)
463
    .fetch_all(pool)
464
    .await
465
}
466
467
#[cfg(test)]
468
mod tests {
469
    use super::*;
470
471
    #[test]
472
    fn pytest_nextest_and_jest_shapes() {
473
        // pytest: one suite, a failure with a message and text, a skip, an error.
474
        let py = r#"<?xml version="1.0" encoding="utf-8"?>
475
<testsuites><testsuite name="pytest" errors="1" failures="1" skipped="1" tests="4" time="3.5">
476
<testcase classname="tests.test_a" name="test_ok" time="0.010"/>
477
<testcase classname="tests.test_a" name="test_bad" time="1.25"><failure message="assert 1 == 2">def test_bad():
478
&gt;       assert 1 == 2
479
E       assert 1 == 2</failure><system-out>printed &amp; kept</system-out></testcase>
480
<testcase classname="tests.test_b" name="test_skip" time="0"><skipped type="pytest.skip" message="no emulator"/></testcase>
481
<testcase classname="tests.test_b" name="test_err" time="0.5"><error message="fixture failed"><![CDATA[Traceback <here>]]></error></testcase>
482
</testsuite></testsuites>"#;
483
        let c = parse(py).unwrap();
484
        let st: Vec<(&str, &str)> = c.iter().map(|c| (c.name.as_str(), c.status.as_str())).collect();
485
        assert_eq!(st, [("test_ok", "passed"), ("test_bad", "failed"), ("test_skip", "skipped"), ("test_err", "error")]);
486
        assert_eq!(c[1].message, "assert 1 == 2");
487
        assert!(c[1].output.contains(">       assert 1 == 2") && c[1].output.contains("printed & kept"), "{}", c[1].output);
488
        assert_eq!((c[1].time_ms, c[0].time_ms), (1250, 10));
489
        assert_eq!(c[2].message, "no emulator");
490
        assert_eq!(c[3].output, "Traceback <here>");
491
        assert_eq!(c[1].suite, "pytest");
492
        assert_eq!(c[1].title(), "tests.test_a::test_bad");
493
        assert!(c[0].output.is_empty());
494
495
        // nextest: a flaky test that passed on retry is a pass.
496
        let nx = r#"<testsuites name="nextest-run"><testsuite name="oxy-core" tests="2">
497
<testcase name="repo::tests::a" classname="oxy-core::lib" time="0.2"><flakyFailure message="first try" type="test failure">boom</flakyFailure></testcase>
498
<testcase name="repo::tests::b" classname="oxy-core::lib" time="0.1"/></testsuite></testsuites>"#;
499
        let c = parse(nx).unwrap();
500
        assert_eq!(c.iter().filter(|c| c.status == "passed").count(), 2, "{c:?}");
501
502
        // Jest: nested suites, no wrapper on a single suite is fine too.
503
        let js = r#"<testsuite name="ui"><testsuite name="Button"><testcase classname="Button renders" name="renders" time="0.003"/></testsuite></testsuite>"#;
504
        let c = parse(js).unwrap();
505
        assert_eq!(c[0].suite, "ui/Button");
506
507
        assert!(parse("<html/>").is_err());
508
        assert!(parse("not xml <<<").is_err());
509
        assert!(parse("<testsuites/>").unwrap().is_empty());
510
    }
511
512
    #[test]
513
    fn long_output_is_cut() {
514
        let big = "x".repeat(MAX_OUTPUT * 3);
515
        let xml = format!(r#"<testsuite><testcase name="t"><failure>{big}</failure></testcase></testsuite>"#);
516
        let c = parse(&xml).unwrap();
517
        assert!(c[0].output.len() < MAX_OUTPUT + 32 && c[0].output.ends_with("(cut)"));
518
    }
519
}