Tests: a test's history, and a tests check naming what broke (part 3) 3d5c825aebba
/tests/case?classname=&name= shows one test across the bookmark's last 200 runs with its result, time and message; the Tests tab links each failure, slow and flaky test to it. Each JUnit report sets a tests check on its run's state, naming the failures that passed in the same job's report on the default bookmark, so a proposal says what it broke. closes #114
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-06 11:46 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
3d5c825aebbacd0c2f7bcc0712e495f71da664a9d466f74423b4224bab3ab555- Depends on
- 0b203bbf7cc3 36e5a9c15a84 42fa95540468 554ccacfc123 617c2840446c 93be9ce4653e d23464c0ac60
- Depended on by
- 33d98147511b 5d90e0e045e2 c8df33a6829f
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 13 atoms, 1 tree operation, 8 files
Browse the tree at this change
added templates/test_case.html +21 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% import "repo_tabs.html" as m %} |
|
| 3 | + | {% block title %}{{ title }} · Tests · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | + | {% block tabs %}{% call m::tabs(repo, "tests") %}{% endblock %} |
|
| 5 | + | {% block content %} |
|
| 6 | + | <div class="head"><h1><code>{{ title }}</code></h1><p class="mute"><a href="{{ repo.url }}/tests?bookmark={{ bookmark }}">Tests</a> on <strong>{{ bookmark }}</strong> · the last {{ runs.len() }} runs: <span class="cov-good">{{ passed }} passed</span>{% if failed > 0 %}, <span class="cov-poor">{{ failed }} failed</span>{% endif %}</p></div> |
|
| 7 | + | {% if runs.len() > 1 %}<section class="panel"><h2>Time per run</h2>{{ time_chart|safe }}</section>{% endif %} |
|
| 8 | + | <section class="panel"> |
|
| 9 | + | <div class="table"><table class="cov-runs"> |
|
| 10 | + | <tr><th>Run</th><th>Job</th><th>Result</th><th class="num">Time</th><th>Message</th><th>When</th></tr> |
|
| 11 | + | {% for r in runs %}<tr> |
|
| 12 | + | <td><a href="{{ repo.url }}/tests?report={{ r.report_id }}">#{{ r.run_number }}</a></td> |
|
| 13 | + | <td>{{ r.job }}</td> |
|
| 14 | + | <td>{% if r.status == "passed" %}<span class="cov-good">✓ passed</span>{% else if r.status == "skipped" %}<span class="mute">skipped</span>{% else %}<span class="cov-poor">✕ {{ r.status }}</span>{% endif %}</td> |
|
| 15 | + | <td class="num">{{ self.time(r.time_ms) }}</td> |
|
| 16 | + | <td class="small mute">{{ r.message }}</td> |
|
| 17 | + | <td class="small"><time title="{{ r.created_ms|when_i }}">{{ r.created_ms|ago_i }}</time></td> |
|
| 18 | + | </tr>{% endfor %} |
|
| 19 | + | </table></div> |
|
| 20 | + | </section> |
|
| 21 | + | {% endblock %} |
modified crates/hub/src/flow.rs +34 −0
| … | 1004 unchanged lines |
||
| 1005 | 1005 | .into_iter() |
|
| 1006 | 1006 | .find(|r| r.job_id == job.id) |
|
| 1007 | 1007 | .ok_or_else(|| "tests: the report was not kept".to_string())?; |
|
| 1008 | + | // The `tests` check on the run's state: what fails, and which of those |
|
| 1009 | + | // passed in the same job's latest report on the default bookmark, so a |
|
| 1010 | + | // proposal names what it broke. |
|
| 1011 | + | let base = crate::junit::baseline(&app.db, &r, &row.default_bookmark).await.map_err(|e| format!("tests: {e}"))?; |
|
| 1012 | + | let newly = match &base { |
|
| 1013 | + | Some(b) if r.broken() > 0 => crate::junit::newly_failing(&app.db, r.id, b.id).await.map_err(|e| format!("tests: {e}"))?, |
|
| 1014 | + | _ => vec![], |
|
| 1015 | + | }; |
|
| 1016 | + | let (status, summary) = if r.broken() == 0 { |
|
| 1017 | + | ("passed", format!("{} passed", r.passed)) |
|
| 1018 | + | } else if let Some(b) = &base { |
|
| 1019 | + | let names: Vec<&str> = newly.iter().take(3).map(String::as_str).collect(); |
|
| 1020 | + | let more = if newly.len() > 3 { format!(", +{}", newly.len() - 3) } else { String::new() }; |
|
| 1021 | + | let new = if newly.is_empty() { |
|
| 1022 | + | format!("none new against {} run #{}", row.default_bookmark, b.run_number) |
|
| 1023 | + | } else { |
|
| 1024 | + | format!("{} new against {}: {}{more}", newly.len(), row.default_bookmark, names.join(", ")) |
|
| 1025 | + | }; |
|
| 1026 | + | ("failed", format!("{} failing; {new}", r.broken())) |
|
| 1027 | + | } else { |
|
| 1028 | + | ("failed", format!("{} failing", r.broken())) |
|
| 1029 | + | }; |
|
| 1030 | + | let url = format!("{}/{}/{}/tests?report={}", app.config.base_url(), row.owner, row.name, r.id); |
|
| 1031 | + | if let Ok(c) = db::hooks::upsert_check( |
|
| 1032 | + | &app.db, |
|
| 1033 | + | row.id, |
|
| 1034 | + | db::hooks::CheckReport { state: &run.state, name: "tests", status, summary: &summary, url: &url, reporter: None, seal: "" }, |
|
| 1035 | + | ) |
|
| 1036 | + | .await |
|
| 1037 | + | { |
|
| 1038 | + | let api = crate::api::check_of(&c); |
|
| 1039 | + | app.events.proposal(row.id, hub_api::ProposalEvent { number: 0, kind: "check".into(), check: Some(api.clone()) }); |
|
| 1040 | + | crate::hooks::emit(app, row, "check", "flow", serde_json::json!({ "check": api })).await; |
|
| 1041 | + | } |
|
| 1008 | 1042 | Ok(format!("tests: {} in {}", r.summary(), human_ms(r.time_ms))) |
|
| 1009 | 1043 | } |
|
| 1010 | 1044 | ||
| … | 513 unchanged lines |
modified crates/hub/src/junit.rs +60 −0
| … | 427 unchanged lines |
||
| 428 | 428 | Ok(rows.into_iter().map(|(c, n, s)| ((c, n), s)).collect()) |
|
| 429 | 429 | } |
|
| 430 | 430 | ||
| 431 | + | /// One test across a bookmark's reports, newest first. |
|
| 432 | + | #[derive(Clone, Debug, sqlx::FromRow)] |
|
| 433 | + | pub struct Run { |
|
| 434 | + | pub report_id: i64, |
|
| 435 | + | pub run_number: i64, |
|
| 436 | + | pub job: String, |
|
| 437 | + | pub created_ms: i64, |
|
| 438 | + | pub status: String, |
|
| 439 | + | pub time_ms: i64, |
|
| 440 | + | pub message: String, |
|
| 441 | + | } |
|
| 442 | + | ||
| 443 | + | pub async fn history(pool: &Pool, repo_id: i64, bookmark: &str, classname: &str, name: &str, limit: i64) -> sqlx::Result<Vec<Run>> { |
|
| 444 | + | sqlx::query_as( |
|
| 445 | + | "SELECT r.id AS report_id, r.run_number, r.job, r.created_ms, c.status, c.time_ms, c.message \ |
|
| 446 | + | FROM test_cases c JOIN test_reports r ON r.id = c.report_id \ |
|
| 447 | + | WHERE r.repo_id = ? AND r.bookmark = ? AND c.classname = ? AND c.name = ? \ |
|
| 448 | + | ORDER BY r.created_ms DESC, r.id DESC LIMIT ?", |
|
| 449 | + | ) |
|
| 450 | + | .bind(repo_id) |
|
| 451 | + | .bind(bookmark) |
|
| 452 | + | .bind(classname) |
|
| 453 | + | .bind(name) |
|
| 454 | + | .bind(limit) |
|
| 455 | + | .fetch_all(pool) |
|
| 456 | + | .await |
|
| 457 | + | } |
|
| 458 | + | ||
| 459 | + | /// What a report is measured against: the same job's previous report on |
|
| 460 | + | /// the default bookmark for a run on it, its latest for any other. |
|
| 461 | + | pub async fn baseline(pool: &Pool, r: &Report, default_bookmark: &str) -> sqlx::Result<Option<Report>> { |
|
| 462 | + | sqlx::query_as( |
|
| 463 | + | "SELECT * FROM test_reports WHERE repo_id = ? AND bookmark = ? AND job = ? AND id != ? \ |
|
| 464 | + | AND (created_ms < ? OR (created_ms = ? AND id < ?)) ORDER BY created_ms DESC, id DESC LIMIT 1", |
|
| 465 | + | ) |
|
| 466 | + | .bind(r.repo_id) |
|
| 467 | + | .bind(default_bookmark) |
|
| 468 | + | .bind(&r.job) |
|
| 469 | + | .bind(r.id) |
|
| 470 | + | .bind(if r.bookmark == default_bookmark { r.created_ms } else { i64::MAX }) |
|
| 471 | + | .bind(r.created_ms) |
|
| 472 | + | .bind(if r.bookmark == default_bookmark { r.id } else { i64::MAX }) |
|
| 473 | + | .fetch_optional(pool) |
|
| 474 | + | .await |
|
| 475 | + | } |
|
| 476 | + | ||
| 477 | + | /// Tests failing in `report` that passed in `base`, by title. |
|
| 478 | + | pub async fn newly_failing(pool: &Pool, report: i64, base: i64) -> sqlx::Result<Vec<String>> { |
|
| 479 | + | let rows: Vec<(String, String)> = sqlx::query_as( |
|
| 480 | + | "SELECT c.classname, c.name FROM test_cases c JOIN test_cases b \ |
|
| 481 | + | ON b.report_id = ? AND b.classname = c.classname AND b.name = c.name AND b.status = 'passed' \ |
|
| 482 | + | WHERE c.report_id = ? AND c.status IN ('failed', 'error') ORDER BY c.idx", |
|
| 483 | + | ) |
|
| 484 | + | .bind(base) |
|
| 485 | + | .bind(report) |
|
| 486 | + | .fetch_all(pool) |
|
| 487 | + | .await?; |
|
| 488 | + | Ok(rows.into_iter().map(|(c, n)| if c.is_empty() { n } else { format!("{c}::{n}") }).collect()) |
|
| 489 | + | } |
|
| 490 | + | ||
| 431 | 491 | /// A test that both failed and passed across some reports. |
|
| 432 | 492 | #[derive(Clone, Debug, sqlx::FromRow)] |
|
| 433 | 493 | pub struct Flaky { |
|
| … | 86 unchanged lines |
modified crates/hub/src/web/mod.rs +1 −0
| … | 216 unchanged lines |
||
| 217 | 217 | .route("/{owner}/{name}/flow", get(flow::runs)) |
|
| 218 | 218 | .route("/{owner}/{name}/flow/badge.svg", get(flow::badge)) |
|
| 219 | 219 | .route("/{owner}/{name}/tests", get(test_reports::overview)) |
|
| 220 | + | .route("/{owner}/{name}/tests/case", get(test_reports::case)) |
|
| 220 | 221 | .route("/{owner}/{name}/coverage", get(coverage::overview)) |
|
| 221 | 222 | .route("/{owner}/{name}/coverage/badge.svg", get(coverage::badge)) |
|
| 222 | 223 | .route("/{owner}/{name}/coverage/{id}/file/{*path}", get(coverage::file)) |
|
| … | 498 unchanged lines |
modified crates/hub/src/web/test_reports.rs +68 −0
| … | 227 unchanged lines |
||
| 228 | 228 | recent, |
|
| 229 | 229 | })) |
|
| 230 | 230 | } |
|
| 231 | + | ||
| 232 | + | #[derive(Deserialize)] |
|
| 233 | + | pub struct CaseQuery { |
|
| 234 | + | #[serde(default)] |
|
| 235 | + | classname: String, |
|
| 236 | + | name: String, |
|
| 237 | + | bookmark: Option<String>, |
|
| 238 | + | } |
|
| 239 | + | ||
| 240 | + | #[derive(Template)] |
|
| 241 | + | #[template(path = "test_case.html")] |
|
| 242 | + | pub struct TestCasePage { |
|
| 243 | + | shell: Shell, |
|
| 244 | + | repo: RepoShell, |
|
| 245 | + | bookmark: String, |
|
| 246 | + | title: String, |
|
| 247 | + | runs: Vec<junit::Run>, |
|
| 248 | + | passed: usize, |
|
| 249 | + | failed: usize, |
|
| 250 | + | time_chart: String, |
|
| 251 | + | } |
|
| 252 | + | ||
| 253 | + | impl TestCasePage { |
|
| 254 | + | pub fn time(&self, ms: &i64) -> String { |
|
| 255 | + | crate::flow::human_ms(*ms) |
|
| 256 | + | } |
|
| 257 | + | } |
|
| 258 | + | ||
| 259 | + | /// `GET /{owner}/{name}/tests/case?classname=&name=`: one test across the |
|
| 260 | + | /// bookmark's last runs: its result, time and message in each. |
|
| 261 | + | pub async fn case( |
|
| 262 | + | State(app): State<Shared>, |
|
| 263 | + | viewer: Viewer, |
|
| 264 | + | Path((owner, name)): Path<(String, String)>, |
|
| 265 | + | Query(q): Query<CaseQuery>, |
|
| 266 | + | ) -> Result<Page<TestCasePage>, Error> { |
|
| 267 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/tests"), &viewer); |
|
| 268 | + | let (_, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 269 | + | let bookmark = q.bookmark.unwrap_or_else(|| row.default_bookmark.clone()); |
|
| 270 | + | let runs = |
|
| 271 | + | junit::history(&app.db, row.id, &bookmark, &q.classname, &q.name, HISTORY).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 272 | + | if runs.is_empty() { |
|
| 273 | + | return Err(Error::not_found(shell)); |
|
| 274 | + | } |
|
| 275 | + | let repo = RepoShell::new(&app, &row, "tests").with_role(role); |
|
| 276 | + | let passed = runs.iter().filter(|r| r.status == "passed").count(); |
|
| 277 | + | let failed = runs.iter().filter(|r| r.status == "failed" || r.status == "error").count(); |
|
| 278 | + | let oldest: Vec<&junit::Run> = runs.iter().rev().collect(); |
|
| 279 | + | let points: Vec<Point> = oldest |
|
| 280 | + | .iter() |
|
| 281 | + | .map(|r| Point { |
|
| 282 | + | run: r.run_number, |
|
| 283 | + | href: format!("{}/tests?report={}", repo.url, r.report_id), |
|
| 284 | + | tip: format!("Run #{} · {} · {}", r.run_number, r.status, crate::flow::human_ms(r.time_ms)), |
|
| 285 | + | }) |
|
| 286 | + | .collect(); |
|
| 287 | + | let top = oldest.iter().map(|r| r.time_ms).max().unwrap_or(0) as f64; |
|
| 288 | + | let time_chart = chart( |
|
| 289 | + | "Time per run", |
|
| 290 | + | &points, |
|
| 291 | + | &[Series { values: oldest.iter().map(|r| r.time_ms as f64).collect(), class: "cov", floor: None }], |
|
| 292 | + | 0.0, |
|
| 293 | + | (top * 1.1).max(1.0), |
|
| 294 | + | &|v| crate::flow::human_ms(v as i64), |
|
| 295 | + | ); |
|
| 296 | + | let title = if q.classname.is_empty() { q.name.clone() } else { format!("{}::{}", q.classname, q.name) }; |
|
| 297 | + | Ok(Page(TestCasePage { shell, repo, bookmark, title, runs, passed, failed, time_chart })) |
|
| 298 | + | } |
modified crates/hub/tests/flow.rs +22 −0
| … | 2347 unchanged lines |
||
| 2348 | 2348 | let (_, all) = call(&h.app, "GET", &format!("/api/v1/repos/andy/t/tests/{second}"), None, None).await; |
|
| 2349 | 2349 | assert_eq!(serde_json::from_str::<serde_json::Value>(&all).unwrap()["cases"].as_array().unwrap().len(), 4); |
|
| 2350 | 2350 | assert_eq!(call(&h.app, "GET", "/api/v1/repos/andy/t/tests/99999", None, None).await.0, StatusCode::NOT_FOUND); |
|
| 2351 | + | ||
| 2352 | + | // The `tests` check names what broke against main. |
|
| 2353 | + | let reports = oxygenhub::junit::on_bookmark(&h.app.db, row.id, "main", 10).await.unwrap(); |
|
| 2354 | + | let check = |state: String| { |
|
| 2355 | + | let app = h.app.clone(); |
|
| 2356 | + | async move { db::hooks::checks_for_state(&app.db, row.id, &state).await.unwrap().into_iter().find(|c| c.name == "tests").unwrap() } |
|
| 2357 | + | }; |
|
| 2358 | + | let c = check(reports[1].state.clone()).await; |
|
| 2359 | + | assert_eq!((c.status.as_str(), c.summary.as_str()), ("failed", "1 failing; 1 new against main: t.suite::b")); |
|
| 2360 | + | let c = check(reports[0].state.clone()).await; |
|
| 2361 | + | assert_eq!((c.status.as_str(), c.summary.as_str()), ("passed", "4 passed")); |
|
| 2362 | + | ||
| 2363 | + | // One test's history: b passed, failed, passed. |
|
| 2364 | + | let (_, tab) = call(&h.app, "GET", "/andy/t/tests", None, None).await; |
|
| 2365 | + | assert!( |
|
| 2366 | + | tab.contains("/andy/t/tests/case?classname=t.suite&name=b") || tab.contains("/andy/t/tests/case?classname=t.suite&name=b"), |
|
| 2367 | + | "linked" |
|
| 2368 | + | ); |
|
| 2369 | + | let (st, hist) = call(&h.app, "GET", "/andy/t/tests/case?classname=t.suite&name=b", None, None).await; |
|
| 2370 | + | assert_eq!(st, StatusCode::OK, "{hist}"); |
|
| 2371 | + | assert!(hist.contains("2 passed") && hist.contains("1 failed") && hist.contains("b broke"), "{hist}"); |
|
| 2372 | + | assert_eq!(call(&h.app, "GET", "/andy/t/tests/case?classname=t.suite&name=nope", None, None).await.0, StatusCode::NOT_FOUND); |
|
| 2351 | 2373 | } |
modified docs/spec.md +3 −2
| … | 591 unchanged lines |
||
| 592 | 592 | - **Oxygen itself:** its `test` job runs under cargo-llvm-cov in the build image (oxygenflow 7705d699) and keeps its report as the `coverage` artifact; the README carries the badge. |
|
| 593 | 593 | - **API:** `GET /repos/{o}/{n}/coverage` and `/coverage/{id}` (§12.1). |
|
| 594 | 594 | ||
| 595 | - | ### 13.7 Test reports (oxygenhub#114, first two parts done 2026-10-05) |
|
| 595 | + | ### 13.7 Test reports (oxygenhub#114, done 2026-10-06) |
|
| 596 | 596 | ||
| 597 | 597 | - **Reports.** A job's artifact named `junit` in JUnit XML (pytest `--junitxml`, cargo-nextest, Jest's junit reporter, gotestsum) is read when the job uploads it, at most 64 MiB and 50 000 cases. Every `<testcase>` counts, wherever its `<testsuite>` nests; a case is `passed`, `failed` (`<failure>`), `error` (`<error>`) or `skipped`, and a nextest case that failed and then passed on a retry (`flakyFailure`) is a pass. Counts come from the cases, not the suites' attributes. The job's log says what was found (`tests: 630 tests: 2 failed in 3 min 4 s`), or why it could not be read; a bad report never fails the upload. |
|
| 598 | 598 | - **Failing jobs.** Declare the artifact with `when "always"` (OxygenFlow spec §5.2) so a failing suite still sends its report; it is then outside the job's receipt, and the run page says so. |
|
| … | 1 unchanged lines |
||
| 600 | 600 | - **Run page.** Beside each job its counts, linked to a block above its log: passed, failed, errored and skipped, then the first 50 failures, failures first in report order, each collapsible to its message and output. |
|
| 601 | 601 | - **Tests tab.** Shown once a repository has a report: the bookmark's latest pass rate (or how many fail) over a chart of up to 200 runs, the counts and test time against the run before, the tests newly failing and fixed against that job's previous report, tests that both failed and passed in the last 30 reports (flaky), the run's failures, a passed-and-failed chart, the slowest tests and recent runs. `?report=` shows any report, `?bookmark=` another bookmark. |
|
| 602 | 602 | - **API:** `GET /repos/{o}/{n}/tests` and `/tests/{id}` (§12.1). |
|
| 603 | - | - **Next:** a test's history across runs, and proposals naming the tests that pass on the target and fail on the proposal. |
|
| 603 | + | - **A test's history.** `/tests/case?classname=&name=` shows one test across the bookmark's last 200 runs: its result, time (charted) and message in each; the Tests tab links each failure, slow test and flaky one to it. |
|
| 604 | + | - **The check.** Each report sets a `tests` check on its run's state: `630 passed`, or what fails and which of those passed in the same job's report on the default bookmark (its previous one for a run on it, its latest for a run elsewhere), as `2 failing; 1 new against main: tests.test_io::test_write`. A proposal shows it with its other checks, so it names what the proposal broke. |
|
| 604 | 605 | ||
| 605 | 606 | ## 14. Git interoperability |
|
| 606 | 607 | ||
| … | 145 unchanged lines |
modified templates/tests.html +3 −3
| … | 43 unchanged lines |
||
| 44 | 44 | <section class="panel cov-card"> |
|
| 45 | 45 | <h2>Flaky <small class="mute">failed and passed in the last {{ flaky_runs }} runs</small></h2> |
|
| 46 | 46 | {% if flaky.is_empty() %}<p class="mute small">None: every test kept its result.</p>{% else %} |
|
| 47 | - | <table class="cov-moved">{% for f in flaky %}<tr><td><code>{{ f.title() }}</code></td><td class="num"><span class="cov-poor">{{ f.failed }} ✕</span> <span class="cov-good">{{ f.passed }} ✓</span></td></tr>{% endfor %}</table>{% endif %} |
|
| 47 | + | <table class="cov-moved">{% for f in flaky %}<tr><td><a href="{{ repo.url }}/tests/case?classname={{ f.classname|urlencode }}&name={{ f.name|urlencode }}&bookmark={{ bookmark|urlencode }}"><code>{{ f.title() }}</code></a></td><td class="num"><span class="cov-poor">{{ f.failed }} ✕</span> <span class="cov-good">{{ f.passed }} ✓</span></td></tr>{% endfor %}</table>{% endif %} |
|
| 48 | 48 | </section> |
|
| 49 | 49 | </div> |
|
| 50 | 50 | ||
| 51 | 51 | {% if !failures.is_empty() %}<section class="panel"> |
|
| 52 | 52 | <h2>Failures <small class="mute">{{ r.broken() }} in run #{{ r.run_number }}{% if r.broken() > failures.len() as i64 %}, the first {{ failures.len() }} shown{% endif %}</small></h2> |
|
| 53 | 53 | <div class="flow-tests">{% for f in failures %}<details class="tests-failure"> |
|
| 54 | - | <summary><span class="bad">{% if f.status == "error" %}!{% else %}✕{% endif %}</span> <code>{{ f.title() }}</code>{% if !f.message.is_empty() %} <span class="small mute">{{ f.message }}</span>{% endif %}</summary> |
|
| 54 | + | <summary><span class="bad">{% if f.status == "error" %}!{% else %}✕{% endif %}</span> <code>{{ f.title() }}</code> <a class="small" href="{{ repo.url }}/tests/case?classname={{ f.classname|urlencode }}&name={{ f.name|urlencode }}&bookmark={{ bookmark|urlencode }}">history</a>{% if !f.message.is_empty() %} <span class="small mute">{{ f.message }}</span>{% endif %}</summary> |
|
| 55 | 55 | {% if !f.output.is_empty() %}<pre class="flow-log">{{ f.output }}</pre>{% endif %} |
|
| 56 | 56 | </details>{% endfor %}</div> |
|
| 57 | 57 | </section>{% endif %} |
|
| … | 7 unchanged lines |
||
| 65 | 65 | <h2>Slowest tests <small class="mute">in run #{{ r.run_number }}</small></h2> |
|
| 66 | 66 | <div class="table"><table class="cov-files"> |
|
| 67 | 67 | <tr><th>Test</th><th class="num">Time</th></tr> |
|
| 68 | - | {% for c in slowest %}<tr><td><code>{{ c.title() }}</code>{% if c.status != "passed" %} <span class="cov-poor small">{{ c.status }}</span>{% endif %}</td><td class="num">{{ self.time(c.time_ms) }}</td></tr>{% endfor %} |
|
| 68 | + | {% for c in slowest %}<tr><td><a href="{{ repo.url }}/tests/case?classname={{ c.classname|urlencode }}&name={{ c.name|urlencode }}&bookmark={{ bookmark|urlencode }}"><code>{{ c.title() }}</code></a>{% if c.status != "passed" %} <span class="cov-poor small">{{ c.status }}</span>{% endif %}</td><td class="num">{{ self.time(c.time_ms) }}</td></tr>{% endfor %} |
|
| 69 | 69 | </table></div> |
|
| 70 | 70 | </section> |
|
| 71 | 71 | ||
| … | 34 unchanged lines |