OXYGENHUB

Coverage / crates/hub/src/junit.rs 96.1%

396 of 412 lines run, 71 of 122 functions · run #494 on main · run missed

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.
4115×
    pub fn title(&self) -> String {
4215×
        if self.classname.is_empty() {
430×
            self.name.clone()
44
        } else {
4515×
            format!("{}::{}", self.classname, self.name)
46
        }
4715×
    }
48
}
49
50
/// Cut `s` to at most `max` bytes on a character boundary, saying so.
5113×
fn cut(mut s: String, max: usize) -> String {
5213×
    if s.len() > max {
532×
        let mut i = max;
542×
        while !s.is_char_boundary(i) {
550×
            i -= 1;
560×
        }
572×
        s.truncate(i);
582×
        s.push_str("\n… (cut)");
5911×
    }
6013×
    s
6113×
}
62
6378×
fn attr(e: &BytesStart, name: &str) -> String {
6478×
    e.try_get_attribute(name)
6578×
        .ok()
6678×
        .flatten()
6778×
        .and_then(|a| a.normalized_value(XmlVersion::Implicit1_0).ok().map(|v| v.into_owned()))
6878×
        .unwrap_or_default()
6978×
}
70
71
/// Seconds as JUnit writes them (`1.25`, sometimes `1,250.5`) to ms.
7221×
fn secs_ms(s: &str) -> i64 {
7321×
    s.replace(',', "").trim().parse::<f64>().map(|v| (v * 1000.0).round().max(0.0) as i64).unwrap_or(0)
7421×
}
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.
8611×
pub fn parse(text: &str) -> Result<Vec<Case>, String> {
8711×
    let mut r = Reader::from_str(text);
8811×
    r.config_mut().trim_text(false);
8911×
    let mut suites: Vec<String> = Vec::new();
9011×
    let mut cases = Vec::new();
9111×
    let mut case: Option<Case> = None;
9211×
    let mut detail = String::new();
9311×
    let mut out = String::new();
9411×
    let mut into = Collect::Nothing;
9511×
    let mut seen_root = false;
96
    loop {
97107×
        let ev = r.read_event().map_err(|e| format!("not JUnit XML: {e} (at byte {})", r.error_position()))?;
987×
        match ev {
9929×
            Event::Start(ref e) | Event::Empty(ref e)
10029×
                if e.local_name().as_ref() == "testsuites" || e.local_name().as_ref() == "testsuite" =>
101
            {
10216×
                seen_root = true;
10316×
                if e.local_name().as_ref() == "testsuite" {
1049×
                    suites.push(attr(e, "name"));
1059×
                }
106
                // An empty `<testsuite/>` has no end event.
10716×
                if matches!(ev, Event::Empty(_)) && e.local_name().as_ref() == "testsuite" {
1080×
                    suites.pop();
10916×
                }
110
            }
11116×
            Event::Start(ref e) | Event::Empty(ref e) if e.local_name().as_ref() == "testcase" => {
11221×
                seen_root = true;
11321×
                let c = Case {
11422×
                    suite: suites.iter().filter(|s| !s.is_empty()).cloned().collect::<Vec<_>>().join("/"),
11521×
                    classname: attr(e, "classname"),
11621×
                    name: attr(e, "name"),
11721×
                    status: "passed".into(),
11821×
                    time_ms: secs_ms(&attr(e, "time")),
11921×
                    message: String::new(),
12021×
                    output: String::new(),
121
                };
12221×
                if matches!(ev, Event::Empty(_)) {
12314×
                    cases.push(c);
12414×
                } else {
1257×
                    case = Some(c);
1267×
                    detail.clear();
1277×
                    out.clear();
1287×
                }
129
            }
1307×
            Event::Start(ref e) | Event::Empty(ref e) if case.is_some() => {
1318×
                let tag = e.local_name().as_ref().to_string();
1328×
                let c = case.as_mut().unwrap();
1338×
                let status = match tag.as_str() {
1348×
                    "failure" | "flakyFailure" if c.status == "passed" => Some("failed"),
1353×
                    "error" | "flakyError" if c.status == "passed" => Some("error"),
1362×
                    "skipped" if c.status == "passed" => Some("skipped"),
1371×
                    _ => None,
138
                };
139
                // nextest's flaky retries passed in the end: the case passed.
1408×
                let flaky = tag.starts_with("flaky") || tag.starts_with("rerun");
1418×
                if let Some(s) = status.filter(|_| !flaky) {
1426×
                    c.status = s.into();
1436×
                    c.message = attr(e, "message");
1446×
                    if matches!(ev, Event::Start(_)) {
1455×
                        into = Collect::Detail;
1465×
                    }
1472×
                } else if matches!(ev, Event::Start(_)) && (tag == "system-out" || tag == "system-err") {
1481×
                    into = Collect::Out;
1491×
                }
150
            }
15117×
            Event::Text(t) => push_text(&mut into, &mut detail, &mut out, &t.xml10_content()),
1521×
            Event::CData(t) => push_text(&mut into, &mut detail, &mut out, &t.xml10_content()),
1532×
            Event::GeneralRef(g) => {
1542×
                let s = match g.resolve_char_ref() {
1550×
                    Ok(Some(ch)) => ch.to_string(),
156
                    _ => {
1572×
                        let name = g.xml10_content();
1582×
                        quick_xml::escape::resolve_predefined_entity(&name).map(str::to_string).unwrap_or_else(|| format!("&{name};"))
159
                    }
160
                };
1612×
                push_text(&mut into, &mut detail, &mut out, &s);
162
            }
16329×
            Event::End(e) => match e.local_name().as_ref() {
16429×
                "testsuite" => {
1659×
                    suites.pop();
1669×
                }
16720×
                "testcase" => {
1687×
                    if let Some(mut c) = case.take() {
1697×
                        if c.status != "passed" {
1706×
                            if c.message.is_empty() {
1711×
                                c.message = detail.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("").to_string();
1725×
                            }
1736×
                            let mut o = detail.trim().to_string();
1746×
                            if !out.trim().is_empty() && c.status != "skipped" {
1751×
                                if !o.is_empty() {
1761×
                                    o.push_str("\n\n");
1771×
                                }
1781×
                                o.push_str(out.trim());
1795×
                            }
1806×
                            c.output = cut(o, MAX_OUTPUT);
1811×
                        }
1827×
                        c.message = cut(c.message, MAX_MESSAGE);
1837×
                        cases.push(c);
1847×
                        if cases.len() > MAX_CASES {
1850×
                            return Err(format!("more than {MAX_CASES} test cases"));
1867×
                        }
1870×
                    }
1887×
                    into = Collect::Nothing;
189
                }
19013×
                "failure" | "error" | "skipped" | "system-out" | "system-err" | "flakyFailure" | "flakyError" | "rerunFailure"
1917×
                | "rerunError" => into = Collect::Nothing,
1926×
                _ => {}
193
            },
19410×
            Event::Eof => break,
1952×
            _ => {}
196
        }
197
    }
19810×
    if !seen_root {
1991×
        return Err("not JUnit XML: no <testsuites>, <testsuite> or <testcase>".into());
2009×
    }
2019×
    Ok(cases)
20211×
}
203
20420×
fn push_text(into: &mut Collect, detail: &mut String, out: &mut String, s: &str) {
20520×
    let (buf, max) = match into {
2067×
        Collect::Detail => (detail, MAX_OUTPUT),
2073×
        Collect::Out => (out, MAX_OUTPUT),
20810×
        Collect::Nothing => return,
209
    };
210
    // Keep a little past the cut so `cut` can say it cut.
21110×
    if buf.len() <= max {
21210×
        buf.push_str(s);
21310×
    }
21420×
}
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".
23914×
    pub fn summary(&self) -> String {
24014×
        let mut bad = vec![];
24114×
        if self.failed > 0 {
2426×
            bad.push(format!("{} failed", self.failed));
2438×
        }
24414×
        if self.errors > 0 {
2450×
            bad.push(format!("{} errored", self.errors));
24614×
        }
24714×
        if self.skipped > 0 {
2480×
            bad.push(format!("{} skipped", self.skipped));
24914×
        }
25014×
        let tests = if self.tests == 1 { "1 test".to_string() } else { format!("{} tests", self.tests) };
25114×
        if bad.is_empty() {
2528×
            format!("{tests}, all passed")
253
        } else {
2546×
            format!("{tests}: {}", bad.join(", "))
255
        }
25614×
    }
257
25847×
    pub fn broken(&self) -> i64 {
25947×
        self.failed + self.errors
26047×
    }
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.
2764×
pub async fn keep(app: &Shared, o: &Origin<'_>, cases: &[Case]) -> sqlx::Result<i64> {
27752×
    let count = |s: &str| cases.iter().filter(|c| c.status == s).count() as i64;
2784×
    let mut tx = crate::db::begin_write(&app.db).await?;
2794×
    sqlx::query("DELETE FROM test_reports WHERE job_id = $1").bind(o.job_id).execute(&mut *tx).await?;
2804×
    let id: i64 = sqlx::query_scalar(
2814×
        "INSERT INTO test_reports (repo_id, run_id, job_id, job, run_number, bookmark, state, blob, \
2824×
         tests, passed, failed, errors, skipped, time_ms, created_ms) \
2834×
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id",
284
    )
2854×
    .bind(o.repo_id)
2864×
    .bind(o.run_id)
2874×
    .bind(o.job_id)
2884×
    .bind(o.job)
2894×
    .bind(o.run_number)
2904×
    .bind(o.bookmark)
2914×
    .bind(o.state)
2924×
    .bind(o.blob)
2934×
    .bind(cases.len() as i64)
2944×
    .bind(count("passed"))
2954×
    .bind(count("failed"))
2964×
    .bind(count("error"))
2974×
    .bind(count("skipped"))
2984×
    .bind(cases.iter().map(|c| c.time_ms).sum::<i64>())
2994×
    .bind(now_ms())
3004×
    .fetch_one(&mut *tx)
3014×
    .await?;
30213×
    for (i, c) in cases.iter().enumerate() {
30313×
        sqlx::query(
30413×
            "INSERT INTO test_cases (report_id, idx, suite, classname, name, status, time_ms, message, output) \
30513×
             VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
30613×
        )
30713×
        .bind(id)
30813×
        .bind(i as i64)
30913×
        .bind(&c.suite)
31013×
        .bind(&c.classname)
31113×
        .bind(&c.name)
31213×
        .bind(&c.status)
31313×
        .bind(c.time_ms)
31413×
        .bind(&c.message)
31513×
        .bind(&c.output)
31613×
        .execute(&mut *tx)
31713×
        .await?;
318
    }
3194×
    tx.commit().await?;
3204×
    mark(app, o.repo_id);
3214×
    Ok(id)
3224×
}
323
324
/// A run's reports, one per job that kept one.
32526×
pub async fn for_run(pool: &Pool, run_id: i64) -> sqlx::Result<Vec<Report>> {
32626×
    sqlx::query_as("SELECT * FROM test_reports WHERE run_id = $1 ORDER BY job").bind(run_id).fetch_all(pool).await
32726×
}
328
329
/// A report's cases in report order, all or only those with a status
330
/// among `statuses`.
3312×
pub async fn cases(pool: &Pool, report_id: i64, statuses: &[&str], limit: i64) -> sqlx::Result<Vec<Case>> {
3322×
    let filter = if statuses.is_empty() {
3331×
        String::new()
334
    } else {
335
        // $1 is the report; the statuses follow, then the limit.
3362×
        format!(" AND status IN ({})", (2..2 + statuses.len()).map(|i| format!("${i}")).collect::<Vec<_>>().join(", "))
337
    };
3382×
    let limit_at = 2 + statuses.len();
3392×
    let sql = format!(
340
        "SELECT suite, classname, name, status, time_ms, message, output FROM test_cases WHERE report_id = $1{filter} ORDER BY idx LIMIT ${limit_at}"
341
    );
3422×
    let mut q = sqlx::query_as(&sql).bind(report_id);
3432×
    for s in statuses {
3442×
        q = q.bind(*s);
3452×
    }
3462×
    q.bind(limit).fetch_all(pool).await
3472×
}
348
349
/// A report's failed and errored cases, in report order.
3504×
pub async fn failures(pool: &Pool, report_id: i64, limit: i64) -> sqlx::Result<Vec<Case>> {
3514×
    sqlx::query_as(
3524×
        "SELECT suite, classname, name, status, time_ms, message, output FROM test_cases \
3534×
         WHERE report_id = $1 AND status IN ('failed', 'error') ORDER BY idx LIMIT $2",
3544×
    )
3554×
    .bind(report_id)
3564×
    .bind(limit)
3574×
    .fetch_all(pool)
3584×
    .await
3594×
}
360
361
/// Which repositories have test reports, for the Tests tab; keyed by data
362
/// directory as well, for tests running several hubs in one process.
363
static HAS: std::sync::Mutex<Option<std::collections::HashSet<String>>> = std::sync::Mutex::new(None);
364
3659×
fn key(app: &Shared, repo_id: i64) -> String {
3669×
    format!("{}#{repo_id}", app.repos.root().display())
3679×
}
368
369523×
pub fn has_reports(app: &Shared, repo_id: i64) -> bool {
370523×
    HAS.lock().unwrap_or_else(|p| p.into_inner()).as_ref().is_some_and(|s| s.contains(&key(app, repo_id)))
371523×
}
372
3734×
fn mark(app: &Shared, repo_id: i64) {
3744×
    HAS.lock().unwrap_or_else(|p| p.into_inner()).get_or_insert_with(Default::default).insert(key(app, repo_id));
3754×
}
376
377
/// At start: which repositories have reports.
378193×
pub async fn load(app: &Shared) -> sqlx::Result<()> {
379193×
    let ids: Vec<(i64,)> = sqlx::query_as("SELECT DISTINCT repo_id FROM test_reports").fetch_all(&app.db).await?;
380193×
    for (id,) in ids {
3810×
        mark(app, id);
3820×
    }
383193×
    Ok(())
384193×
}
385
3864×
pub async fn report(pool: &Pool, repo_id: i64, id: i64) -> sqlx::Result<Option<Report>> {
3874×
    sqlx::query_as("SELECT * FROM test_reports WHERE id = $1 AND repo_id = $2").bind(id).bind(repo_id).fetch_optional(pool).await
3884×
}
389
390
/// The newest reports on a bookmark, newest first.
3916×
pub async fn on_bookmark(pool: &Pool, repo_id: i64, bookmark: &str, limit: i64) -> sqlx::Result<Vec<Report>> {
3926×
    sqlx::query_as("SELECT * FROM test_reports WHERE repo_id = $1 AND bookmark = $2 ORDER BY created_ms DESC, id DESC LIMIT $3")
3936×
        .bind(repo_id)
3946×
        .bind(bookmark)
3956×
        .bind(limit)
3966×
        .fetch_all(pool)
3976×
        .await
3986×
}
399
400
/// Bookmarks with reports, the most recently reported first.
4015×
pub async fn bookmarks(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<String>> {
4025×
    let rows: Vec<(String,)> =
4035×
        sqlx::query_as("SELECT bookmark FROM test_reports WHERE repo_id = $1 GROUP BY bookmark ORDER BY MAX(created_ms) DESC")
4045×
            .bind(repo_id)
4055×
            .fetch_all(pool)
4065×
            .await?;
4075×
    Ok(rows.into_iter().map(|(b,)| b).collect())
4085×
}
409
4103×
pub async fn first_ms(pool: &Pool, repo_id: i64) -> sqlx::Result<Option<i64>> {
4113×
    sqlx::query_scalar("SELECT MIN(created_ms) FROM test_reports WHERE repo_id = $1").bind(repo_id).fetch_one(pool).await
4123×
}
413
414
/// A report's slowest cases.
4153×
pub async fn slowest(pool: &Pool, report_id: i64, limit: i64) -> sqlx::Result<Vec<Case>> {
4163×
    sqlx::query_as(
4173×
        "SELECT suite, classname, name, status, time_ms, message, '' AS output FROM test_cases \
4183×
         WHERE report_id = $1 AND status != 'skipped' ORDER BY time_ms DESC, idx LIMIT $2",
4193×
    )
4203×
    .bind(report_id)
4213×
    .bind(limit)
4223×
    .fetch_all(pool)
4233×
    .await
4243×
}
425
426
/// Every case of a report as `(classname, name) -> status`.
4276×
pub async fn statuses(pool: &Pool, report_id: i64) -> sqlx::Result<std::collections::HashMap<(String, String), String>> {
4286×
    let rows: Vec<(String, String, String)> =
4296×
        sqlx::query_as("SELECT classname, name, status FROM test_cases WHERE report_id = $1").bind(report_id).fetch_all(pool).await?;
43023×
    Ok(rows.into_iter().map(|(c, n, s)| ((c, n), s)).collect())
4316×
}
432
433
/// One test across a bookmark's reports, newest first.
434
#[derive(Clone, Debug, sqlx::FromRow)]
435
pub struct Run {
436
    pub report_id: i64,
437
    pub run_number: i64,
438
    pub job: String,
439
    pub created_ms: i64,
440
    pub status: String,
441
    pub time_ms: i64,
442
    pub message: String,
443
}
444
4452×
pub async fn history(pool: &Pool, repo_id: i64, bookmark: &str, classname: &str, name: &str, limit: i64) -> sqlx::Result<Vec<Run>> {
4462×
    sqlx::query_as(
4472×
        "SELECT r.id AS report_id, r.run_number, r.job, r.created_ms, c.status, c.time_ms, c.message \
4482×
         FROM test_cases c JOIN test_reports r ON r.id = c.report_id \
4492×
         WHERE r.repo_id = $1 AND r.bookmark = $2 AND c.classname = $3 AND c.name = $4 \
4502×
         ORDER BY r.created_ms DESC, r.id DESC LIMIT $5",
4512×
    )
4522×
    .bind(repo_id)
4532×
    .bind(bookmark)
4542×
    .bind(classname)
4552×
    .bind(name)
4562×
    .bind(limit)
4572×
    .fetch_all(pool)
4582×
    .await
4592×
}
460
461
/// What a report is measured against: the same job's previous report on
462
/// the default bookmark for a run on it, its latest for any other.
4634×
pub async fn baseline(pool: &Pool, r: &Report, default_bookmark: &str) -> sqlx::Result<Option<Report>> {
4644×
    sqlx::query_as(
4654×
        "SELECT * FROM test_reports WHERE repo_id = $1 AND bookmark = $2 AND job = $3 AND id != $4 \
4664×
         AND (created_ms < $5 OR (created_ms = $6 AND id < $7)) ORDER BY created_ms DESC, id DESC LIMIT 1",
467
    )
4684×
    .bind(r.repo_id)
4694×
    .bind(default_bookmark)
4704×
    .bind(&r.job)
4714×
    .bind(r.id)
4724×
    .bind(if r.bookmark == default_bookmark { r.created_ms } else { i64::MAX })
4734×
    .bind(r.created_ms)
4744×
    .bind(if r.bookmark == default_bookmark { r.id } else { i64::MAX })
4754×
    .fetch_optional(pool)
4764×
    .await
4774×
}
478
479
/// Tests failing in `report` that passed in `base`, by title.
4801×
pub async fn newly_failing(pool: &Pool, report: i64, base: i64) -> sqlx::Result<Vec<String>> {
4811×
    let rows: Vec<(String, String)> = sqlx::query_as(
4821×
        "SELECT c.classname, c.name FROM test_cases c JOIN test_cases b \
4831×
         ON b.report_id = $1 AND b.classname = c.classname AND b.name = c.name AND b.status = 'passed' \
4841×
         WHERE c.report_id = $2 AND c.status IN ('failed', 'error') ORDER BY c.idx",
4851×
    )
4861×
    .bind(base)
4871×
    .bind(report)
4881×
    .fetch_all(pool)
4891×
    .await?;
4901×
    Ok(rows.into_iter().map(|(c, n)| if c.is_empty() { n } else { format!("{c}::{n}") }).collect())
4911×
}
492
493
/// A test that both failed and passed across some reports.
494
#[derive(Clone, Debug, sqlx::FromRow)]
495
pub struct Flaky {
496
    pub classname: String,
497
    pub name: String,
498
    pub failed: i64,
499
    pub passed: i64,
500
}
501
502
impl Flaky {
5033×
    pub fn title(&self) -> String {
5043×
        if self.classname.is_empty() {
5050×
            self.name.clone()
506
        } else {
5073×
            format!("{}::{}", self.classname, self.name)
508
        }
5093×
    }
510
}
511
512
/// Tests that failed in some of `reports` and passed in others, the most
513
/// often failing first: flaky, or broken and fixed again.
5143×
pub async fn flaky(pool: &Pool, reports: &[i64], limit: i64) -> sqlx::Result<Vec<Flaky>> {
5153×
    if reports.is_empty() {
5160×
        return Ok(vec![]);
5173×
    }
5189×
    let ids = reports.iter().map(|i| i.to_string()).collect::<Vec<_>>().join(",");
5193×
    sqlx::query_as(&format!(
5203×
        "SELECT classname, name, failed, passed FROM (\
5213×
           SELECT classname, name, SUM(CASE WHEN status IN ('failed', 'error') THEN 1 ELSE 0 END) AS failed, \
5223×
             SUM(CASE WHEN status = 'passed' THEN 1 ELSE 0 END) AS passed \
5233×
           FROM test_cases WHERE report_id IN ({ids}) GROUP BY classname, name) t \
5243×
         WHERE failed > 0 AND passed > 0 ORDER BY failed DESC, classname, name LIMIT $1"
5253×
    ))
5263×
    .bind(limit)
5273×
    .fetch_all(pool)
5283×
    .await
5293×
}
530
531
#[cfg(test)]
532
mod tests {
533
    use super::*;
534
535
    #[test]
5361×
    fn pytest_nextest_and_jest_shapes() {
537
        // pytest: one suite, a failure with a message and text, a skip, an error.
5381×
        let py = r#"<?xml version="1.0" encoding="utf-8"?>
5391×
<testsuites><testsuite name="pytest" errors="1" failures="1" skipped="1" tests="4" time="3.5">
5401×
<testcase classname="tests.test_a" name="test_ok" time="0.010"/>
5411×
<testcase classname="tests.test_a" name="test_bad" time="1.25"><failure message="assert 1 == 2">def test_bad():
5421×
&gt;       assert 1 == 2
5431×
E       assert 1 == 2</failure><system-out>printed &amp; kept</system-out></testcase>
5441×
<testcase classname="tests.test_b" name="test_skip" time="0"><skipped type="pytest.skip" message="no emulator"/></testcase>
5451×
<testcase classname="tests.test_b" name="test_err" time="0.5"><error message="fixture failed"><![CDATA[Traceback <here>]]></error></testcase>
5461×
</testsuite></testsuites>"#;
5471×
        let c = parse(py).unwrap();
5484×
        let st: Vec<(&str, &str)> = c.iter().map(|c| (c.name.as_str(), c.status.as_str())).collect();
5491×
        assert_eq!(st, [("test_ok", "passed"), ("test_bad", "failed"), ("test_skip", "skipped"), ("test_err", "error")]);
5501×
        assert_eq!(c[1].message, "assert 1 == 2");
5511×
        assert!(c[1].output.contains(">       assert 1 == 2") && c[1].output.contains("printed & kept"), "{}", c[1].output);
5521×
        assert_eq!((c[1].time_ms, c[0].time_ms), (1250, 10));
5531×
        assert_eq!(c[2].message, "no emulator");
5541×
        assert_eq!(c[3].output, "Traceback <here>");
5551×
        assert_eq!(c[1].suite, "pytest");
5561×
        assert_eq!(c[1].title(), "tests.test_a::test_bad");
5571×
        assert!(c[0].output.is_empty());
558
559
        // nextest: a flaky test that passed on retry is a pass.
5601×
        let nx = r#"<testsuites name="nextest-run"><testsuite name="oxy-core" tests="2">
5611×
<testcase name="repo::tests::a" classname="oxy-core::lib" time="0.2"><flakyFailure message="first try" type="test failure">boom</flakyFailure></testcase>
5621×
<testcase name="repo::tests::b" classname="oxy-core::lib" time="0.1"/></testsuite></testsuites>"#;
5631×
        let c = parse(nx).unwrap();
5642×
        assert_eq!(c.iter().filter(|c| c.status == "passed").count(), 2, "{c:?}");
565
566
        // Jest: nested suites, no wrapper on a single suite is fine too.
5671×
        let js = r#"<testsuite name="ui"><testsuite name="Button"><testcase classname="Button renders" name="renders" time="0.003"/></testsuite></testsuite>"#;
5681×
        let c = parse(js).unwrap();
5691×
        assert_eq!(c[0].suite, "ui/Button");
570
5711×
        assert!(parse("<html/>").is_err());
5721×
        assert!(parse("not xml <<<").is_err());
5731×
        assert!(parse("<testsuites/>").unwrap().is_empty());
5741×
    }
575
576
    #[test]
5771×
    fn long_output_is_cut() {
5781×
        let big = "x".repeat(MAX_OUTPUT * 3);
5791×
        let xml = format!(r#"<testsuite><testcase name="t"><failure>{big}</failure></testcase></testsuite>"#);
5801×
        let c = parse(&xml).unwrap();
5811×
        assert!(c[0].output.len() < MAX_OUTPUT + 32 && c[0].output.ends_with("(cut)"));
5821×
    }
583
}