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 | + | > assert 1 == 2 |
|
| 352 | + | E assert 1 == 2</failure><system-out>printed & 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 "always": 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 |