Stable per-branch report URLs and a reachable live view (v0.9.8)

A report directory holding a single page is now linked and served as a
directory, so Gradle's --profile report has a stable permanent URL although
its file name carries the build timestamp.

The permanent link moves to the build it resolves to — the branch's latest
green build — and appears on every build table instead of only the branches
view. The Current tab gave way to a link in the artifacts column, shown
while a build runs; /current itself stays routable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-10 21:27:53 +02:00
co-authored by Claude Fable 5
parent c1869ea427
commit 962836beaf
15 changed files with 207 additions and 56 deletions
@@ -150,6 +150,30 @@ class ArtifactFileControllerTest : FunSpec() {
.andExpect(header().string("Cache-Control", "no-store, max-age=0"))
}
test("directory URL serves the single page of an index-less report directory") {
val profileDir = Files.createDirectories(artifactDir.resolve("reports/profile"))
Files.writeString(profileDir.resolve("profile-2026-08-10-18-36-12.html"), "<html>profile</html>")
mockMvc
.perform(get("/branches/main/reports/profile/"))
.andExpect(status().isOk)
.andExpect(content().string("<html>profile</html>"))
mockMvc
.perform(get("/artifacts/known-key/reports/profile/"))
.andExpect(status().isOk)
.andExpect(content().string("<html>profile</html>"))
}
test("directory URL of an index-less report directory holding several pages answers 404") {
val pmdDir = Files.createDirectories(artifactDir.resolve("reports/pmd"))
Files.writeString(pmdDir.resolve("main.html"), "<html>main</html>")
Files.writeString(pmdDir.resolve("test.html"), "<html>test</html>")
mockMvc
.perform(get("/branches/main/reports/pmd/"))
.andExpect(status().isNotFound)
}
test("permanent URL with a bare trailing slash redirects to the artifact index page") {
mockMvc
.perform(get("/branches/main/"))
@@ -66,16 +66,17 @@ class BranchListingTest : FunSpec() {
branches[1].latestGreenUrl shouldBe null
}
test("a failed latest build still links the older green build's permanent URL") {
test("a failed latest build carries no permanent URL — it belongs to the older green build") {
every { gitService.originBranchHeads(any()) } returns mapOf("feature/x" to "aaa")
every { repository.latestFor("feature/x") } returns
mainResult.copy(branch = "feature/x", status = BuildStatus.FAILED)
every { repository.latestGreenFor("feature/x") } returns mainResult.copy(branch = "feature/x")
mainResult.copy(branch = "feature/x", status = BuildStatus.FAILED, artifactKey = "failed-key")
every { repository.latestGreenFor("feature/x") } returns
mainResult.copy(branch = "feature/x", artifactKey = "green-key")
val branches = listing.branches()
branches[0].status shouldBe "failed"
branches[0].latestGreenUrl shouldBe "/branches/feature_x"
branches[0].latestGreenUrl shouldBe null
}
}
}
@@ -76,6 +76,7 @@ class BuildsApiControllerTest : FunSpec() {
beforeEach {
clearMocks(repository, buildExecutor, artifactStore, controlTokens, gitService, branchListing)
every { controlTokens.matches(any()) } answers { firstArg<String?>() == "secret" }
every { repository.latestGreenFor(any()) } returns null
}
test("latest answers one entry per branch with lowercase status and duration in seconds") {
@@ -100,6 +101,18 @@ class BuildsApiControllerTest : FunSpec() {
.andExpect(jsonPath("$[1].status").value("failed"))
}
test("history carries the permanent URL on the branch's latest green build only") {
val older = successResult.copy(artifactKey = "older-key")
every { repository.history() } returns listOf(successResult, older)
every { repository.latestGreenFor("main") } returns successResult
mockMvc
.perform(get("/api/builds/history"))
.andExpect(status().isOk)
.andExpect(jsonPath("$[0].latestGreenUrl").value("/branches/main"))
.andExpect(jsonPath("$[1].latestGreenUrl").doesNotExist())
}
test("current answers the running builds with live status and log size") {
val liveLogFile = Files.writeString(tempDir.resolve("build.log"), "12345")
val build = runningBuild(liveLogFile)
@@ -113,9 +113,10 @@ class UiControllerTest : FunSpec() {
gitea = GiteaConfig(baseUrl = "https://git.example.org", owner = "acme", repo = "widget"),
)
every { controlTokens.token() } returns "test-token"
every { repository.latestGreenFor(any()) } returns null
}
test("latest view renders the empty state") {
test("latest view renders the empty state, and the nav no longer offers the current view") {
every { repository.latestPerBranch() } returns emptyList()
mockMvc
@@ -124,6 +125,7 @@ class UiControllerTest : FunSpec() {
.andExpect(content().string(containsString("No builds recorded yet.")))
.andExpect(content().string(containsString("""data-api="/api/builds/latest"""")))
.andExpect(content().string(containsString("""id="reload-button"""")))
.andExpect(content().string(not(containsString("""href="/current""""))))
}
test("latest view renders rows with badge, Gitea links, artifact link, actions, and token") {
@@ -147,7 +149,7 @@ class UiControllerTest : FunSpec() {
test("branches view renders built and never-built branches with restart actions") {
every { branchListing.branches(any()) } returns
listOf(
BranchDto.from("main", "ignored-head", successResult, hasGreenBuild = true),
BranchDto.from("main", "ignored-head", successResult, isLatestGreen = true),
BranchDto.from("feature/x", "fedcba9876543210fedcba9876543210fedcba98", null),
)
@@ -164,6 +166,23 @@ class UiControllerTest : FunSpec() {
.andExpect(content().string(containsString("Permanent link")))
}
test("the permanent link shows on the branch's latest green build only, the live link while it runs") {
val running = successResult.copy(status = BuildStatus.RUNNING, duration = null, artifactKey = "running-key")
every { repository.history() } returns listOf(running, successResult)
every { repository.latestGreenFor("main") } returns successResult
val page =
mockMvc
.perform(get("/history"))
.andExpect(status().isOk)
.andReturn()
.response.contentAsString
Regex("""href="/branches/main"""").findAll(page).count() shouldBe 1
Regex("""href="/current"""").findAll(page).count() shouldBe 1
page shouldContain "Watch this build live"
}
test("history view renders mixed history without restart actions") {
every { repository.history() } returns
listOf(
@@ -237,7 +256,7 @@ class UiControllerTest : FunSpec() {
).andExpect(content().string(not(containsString("reports/tests/test/packages/index.html"))))
}
test("artifact index links report pages of directories without an index, but not their inner pages") {
test("artifact index links a single index-less report page as a directory, keeping the URL stable") {
val artifactDir = Files.createDirectories(tempDir.resolve("main-abc123-key"))
Files.createDirectories(artifactDir.resolve("reports/profile"))
Files.writeString(artifactDir.resolve("reports/profile/profile-2026-08-10-18-36-12.html"), "<html></html>")
@@ -255,11 +274,31 @@ class UiControllerTest : FunSpec() {
.andReturn()
.response.contentAsString
page shouldContain "reports/profile/profile-2026-08-10-18-36-12.html"
page shouldContain "reports/profile/"
page shouldNotContain "profile-2026-08-10-18-36-12.html"
page shouldContain "reports/tests/test/index.html"
page shouldNotContain "SomeTest.html"
}
test("artifact index links the pages of an index-less report directory holding several") {
val artifactDir = Files.createDirectories(tempDir.resolve("main-abc123-key"))
Files.createDirectories(artifactDir.resolve("reports/pmd"))
Files.writeString(artifactDir.resolve("reports/pmd/main.html"), "<html></html>")
Files.writeString(artifactDir.resolve("reports/pmd/test.html"), "<html></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 "reports/pmd/main.html"
page shouldContain "reports/pmd/test.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"))