Show a failed-badge behind report links on the artifact index

Each listed report link now carries the failures counter parsed from the
report's Gradle-style index.html (id="failures" info box); reports with
failures get a red "N failed" badge, so a red build reveals which report to
open without clicking through all of them. Pages without such a counter
(Jacoco, profile, documentation) stay unmarked. A full-text FAILED scan was
deliberately not used: even green hsadmin-ng builds contain the word in
embedded test output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-10 16:44:12 +02:00
co-authored by Claude Fable 5
parent 4ca077da80
commit b351ab33c5
4 changed files with 74 additions and 4 deletions
@@ -209,8 +209,9 @@ class UiController(
/** /**
* The browsable `index.html` pages under `reports/`, shallowest first; pages nested * The browsable `index.html` pages under `reports/`, shallowest first; pages nested
* below an already-listed report index are skipped — like the legacy artifact index. * below an already-listed report index are skipped — like the legacy artifact index.
* Each entry carries the report's failures counter for the failed-badge.
*/ */
private fun reportIndexes(artifactDir: Path): List<String> { private fun reportIndexes(artifactDir: Path): List<ReportIndexView> {
val reportsDir = artifactDir.resolve("reports") val reportsDir = artifactDir.resolve("reports")
if (!Files.isDirectory(reportsDir)) { if (!Files.isDirectory(reportsDir)) {
return emptyList() return emptyList()
@@ -234,6 +235,26 @@ class UiController(
knownDirs += dir knownDirs += dir
topmost += relativeIndex topmost += relativeIndex
} }
return topmost return topmost.map { ReportIndexView(path = it, failures = reportFailures(reportsDir.resolve(it))) }
}
/**
* The failures counter of a Gradle-style HTML test report index
* (`<div class="infoBox" id="failures"><div class="counter">N</div>…`);
* null for report pages without one, e.g. Jacoco or profile reports.
*/
private fun reportFailures(indexFile: Path): Int? =
try {
FAILURES_COUNTER
.find(Files.readString(indexFile))
?.groupValues
?.get(1)
?.toIntOrNull()
} catch (_: Exception) {
null
}
companion object {
private val FAILURES_COUNTER = Regex("""id="failures">\s*<div class="counter">(\d+)""")
} }
} }
@@ -126,6 +126,16 @@ data class BuildRowView(
} }
} }
/**
* One report link of the artifact index. [failures] is the failures counter of a
* Gradle-style HTML report index page; null when the page declares none (e.g. Jacoco),
* so only real test reports get a failed-badge.
*/
data class ReportIndexView(
val path: String,
val failures: Int?,
)
/** One card of the current-builds view; the live log is fetched by `gittally.js`. */ /** One card of the current-builds view; the live log is fetched by `gittally.js`. */
data class CurrentBuildView( data class CurrentBuildView(
val branch: String, val branch: String,
+4 -2
View File
@@ -62,8 +62,10 @@
<h2>Build Artifacts</h2> <h2>Build Artifacts</h2>
<ul th:if="${!#lists.isEmpty(reportIndexes)}"> <ul th:if="${!#lists.isEmpty(reportIndexes)}">
<li th:each="report : ${reportIndexes}"> <li th:each="report : ${reportIndexes}">
<a th:href="${filesBase} + '/reports/' + ${report}" target="_blank" <a th:href="${filesBase} + '/reports/' + ${report.path}" target="_blank"
rel="noopener noreferrer" th:text="'reports/' + ${report}">reports/tests/index.html</a> rel="noopener noreferrer" th:text="'reports/' + ${report.path}">reports/tests/index.html</a>
<span th:if="${report.failures != null and report.failures > 0}" class="status status-failed"
th:text="${report.failures} + ' failed'">2 failed</span>
</li> </li>
</ul> </ul>
<p th:if="${#lists.isEmpty(reportIndexes)}" class="muted"> <p th:if="${#lists.isEmpty(reportIndexes)}" class="muted">
@@ -15,6 +15,9 @@ import de.hoennig.gittally.metrics.MetricAggregate
import de.hoennig.gittally.metrics.SystemMetrics import de.hoennig.gittally.metrics.SystemMetrics
import de.hoennig.gittally.metrics.SystemMetricsCollector import de.hoennig.gittally.metrics.SystemMetricsCollector
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import io.mockk.clearMocks import io.mockk.clearMocks
import io.mockk.every import io.mockk.every
import org.hamcrest.Matchers.containsString import org.hamcrest.Matchers.containsString
@@ -233,6 +236,40 @@ class UiControllerTest : FunSpec() {
).andExpect(content().string(not(containsString("reports/tests/test/packages/index.html")))) ).andExpect(content().string(not(containsString("reports/tests/test/packages/index.html"))))
} }
test("report links carry a failed-badge from the report's failures counter") {
val artifactDir = Files.createDirectories(tempDir.resolve("main-abc123-key"))
Files.createDirectories(artifactDir.resolve("reports/tests/test"))
Files.writeString(
artifactDir.resolve("reports/tests/test/index.html"),
"""<div class="infoBox" id="failures">
<div class="counter">2</div>
<p>failures</p></div>""",
)
Files.createDirectories(artifactDir.resolve("reports/tests/unitTest"))
Files.writeString(
artifactDir.resolve("reports/tests/unitTest/index.html"),
"""<div class="infoBox" id="failures">
<div class="counter">0</div>
<p>failures</p></div>""",
)
Files.createDirectories(artifactDir.resolve("reports/jacoco"))
Files.writeString(artifactDir.resolve("reports/jacoco/index.html"), "<html>no counter</html>")
every { repository.history() } returns listOf(successResult)
every { artifactStore.artifactDir("main-abc123-key") } returns artifactDir
val page =
mockMvc
.perform(get("/builds/main-abc123-key"))
.andExpect(status().isOk)
.andReturn()
.response.contentAsString
page shouldContain "2 failed"
page shouldNotContain "0 failed"
// exactly one badge: the report with failures, none for the clean or counter-less reports
Regex(""" failed</span>""").findAll(page).count() shouldBe 1
}
test("artifact index of a pruned build explains the missing artifacts") { test("artifact index of a pruned build explains the missing artifacts") {
every { repository.history() } returns listOf(successResult) every { repository.history() } returns listOf(successResult)
every { artifactStore.artifactDir("main-abc123-key") } returns null every { artifactStore.artifactDir("main-abc123-key") } returns null