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
+4
View File
@@ -84,3 +84,7 @@ On polling pages it triggers an immediate data refresh via the page's poller; pa
Addendum (2026-07-07): all links that leave the GitTally UI open in a new tab (`target="_blank" rel="noopener noreferrer"`). Addendum (2026-07-07): all links that leave the GitTally UI open in a new tab (`target="_blank" rel="noopener noreferrer"`).
This already held for Gitea branch/commit links and the footer; it was added for the artifact page's log and report links, whose targets have no navigation. This already held for Gitea branch/commit links and the footer; it was added for the artifact page's log and report links, whose targets have no navigation.
Links between GitTally pages (nav, artifact index) stay in the same tab. Links between GitTally pages (nav, artifact index) stay in the same tab.
Addendum (2026-08-10): the artifacts column carries the whole build-reachability logic, and the nav lost its `Current` entry.
The permanent `🔗` link is rendered on the build it resolves to — the branch's latest green build — on every build table, instead of on each row of a branch with any green build.
A `📡` link to `/current` appears while a build runs; `/current` itself stays a routable page, it just has no tab of its own anymore.
@@ -17,10 +17,14 @@ data class BuildResultDto(
val runningSince: Instant? = null, val runningSince: Instant? = null,
val durationSeconds: Long?, val durationSeconds: Long?,
val artifactKey: String, val artifactKey: String,
/** The permanent branch URL, set only on the build it resolves to — the branch's latest green build. */
val latestGreenUrl: String? = null,
) { ) {
companion object { companion object {
fun from(result: BuildResult) = fun from(
BuildResultDto( result: BuildResult,
isLatestGreen: Boolean = false,
) = BuildResultDto(
branch = result.branch, branch = result.branch,
commit = result.commit, commit = result.commit,
status = result.status.jsonName, status = result.status.jsonName,
@@ -28,6 +32,7 @@ data class BuildResultDto(
runningSince = result.runningSince, runningSince = result.runningSince,
durationSeconds = result.duration?.seconds, durationSeconds = result.duration?.seconds,
artifactKey = result.artifactKey, artifactKey = result.artifactKey,
latestGreenUrl = if (isLatestGreen) BranchPermalinks.permanentUrl(result.branch) else null,
) )
} }
} }
@@ -36,7 +41,8 @@ data class BuildResultDto(
* One entry of `GET /api/branches`, like a legacy branches-view row: an origin * One entry of `GET /api/branches`, like a legacy branches-view row: an origin
* branch with its latest build, or an `unknown` placeholder when never built. * branch with its latest build, or an `unknown` placeholder when never built.
* [latestGreenUrl] is the permanent artifact URL of the branch's latest green * [latestGreenUrl] is the permanent artifact URL of the branch's latest green
* build; null while the branch has never built successfully. * build; set only when this row's build is that green build, so the link appears
* where it resolves to.
*/ */
data class BranchDto( data class BranchDto(
val branch: String, val branch: String,
@@ -53,7 +59,7 @@ data class BranchDto(
branch: String, branch: String,
headCommit: String, headCommit: String,
latest: BuildResult?, latest: BuildResult?,
hasGreenBuild: Boolean = false, isLatestGreen: Boolean = false,
) = if (latest == null) { ) = if (latest == null) {
BranchDto( BranchDto(
branch = branch, branch = branch,
@@ -72,7 +78,7 @@ data class BranchDto(
runningSince = latest.runningSince, runningSince = latest.runningSince,
durationSeconds = latest.duration?.seconds, durationSeconds = latest.duration?.seconds,
artifactKey = latest.artifactKey, artifactKey = latest.artifactKey,
latestGreenUrl = if (hasGreenBuild) BranchPermalinks.permanentUrl(branch) else null, latestGreenUrl = if (isLatestGreen) BranchPermalinks.permanentUrl(branch) else null,
) )
} }
} }
@@ -17,6 +17,7 @@ import java.net.URI
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.LinkOption import java.nio.file.LinkOption
import java.nio.file.Path import java.nio.file.Path
import kotlin.streams.asSequence
/** /**
* Streams stored build artifacts. Status pages, JSON, and logs are served with * Streams stored build artifacts. Status pages, JSON, and logs are served with
@@ -31,12 +32,15 @@ class ArtifactFileController(
fun serve( fun serve(
@PathVariable artifactKey: String, @PathVariable artifactKey: String,
@PathVariable path: String, @PathVariable path: String,
request: HttpServletRequest,
): ResponseEntity<Resource> { ): ResponseEntity<Resource> {
val artifactDir = val artifactDir =
artifactStore.artifactDir(artifactKey) artifactStore.artifactDir(artifactKey)
?: return ResponseEntity.notFound().build() ?: return ResponseEntity.notFound().build()
val relativePath = path.removePrefix("/").removeSuffix("/")
directoryResponse(artifactDir, relativePath, request, noStore = true)?.let { return it }
val file = val file =
resolveFile(artifactDir, path.removePrefix("/")) resolveFile(artifactDir, relativePath)
?: return ResponseEntity.notFound().build() ?: return ResponseEntity.notFound().build()
return respond(file, noStore = file.extension() in NO_CACHE_EXTENSIONS) return respond(file, noStore = file.extension() in NO_CACHE_EXTENSIONS)
} }
@@ -66,24 +70,53 @@ class ArtifactFileController(
// the bare permanent URL is the artifact-index page rendered by the UI controller // the bare permanent URL is the artifact-index page rendered by the UI controller
return redirect(request.requestURI.trimEnd('/')) return redirect(request.requestURI.trimEnd('/'))
} }
val target = artifactDir.resolve(relativePath).normalize() directoryResponse(artifactDir, relativePath, request, noStore = true)?.let { return it }
if (target.startsWith(artifactDir) &&
Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS) &&
Files.isRegularFile(target.resolve(INDEX_FILE), LinkOption.NOFOLLOW_LINKS)
) {
// relative links inside a report only resolve correctly under a trailing-slash URL
return if (request.requestURI.endsWith("/")) {
respond(target.resolve(INDEX_FILE), noStore = true)
} else {
redirect(request.requestURI + "/")
}
}
val file = val file =
resolveFile(artifactDir, relativePath) resolveFile(artifactDir, relativePath)
?: return ResponseEntity.notFound().build() ?: return ResponseEntity.notFound().build()
return respond(file, noStore = true) return respond(file, noStore = true)
} }
/**
* The response for a directory URL, or null when [relativePath] is no servable directory.
* A directory serves its `index.html`, or the single HTML page of a report directory without
* one — that keeps Gradle's `--profile` report, whose file name carries the build timestamp,
* reachable under a stable URL.
*/
private fun directoryResponse(
artifactDir: Path,
relativePath: String,
request: HttpServletRequest,
noStore: Boolean,
): ResponseEntity<Resource>? {
val target = artifactDir.resolve(relativePath).normalize()
if (relativePath.isBlank() || !target.startsWith(artifactDir) || !Files.isDirectory(target, LinkOption.NOFOLLOW_LINKS)) {
return null
}
val page = directoryPage(target) ?: return null
// relative links inside a report only resolve correctly under a trailing-slash URL
return if (request.requestURI.endsWith("/")) {
respond(page, noStore = noStore)
} else {
redirect(request.requestURI + "/")
}
}
private fun directoryPage(dir: Path): Path? {
val index = dir.resolve(INDEX_FILE)
if (Files.isRegularFile(index, LinkOption.NOFOLLOW_LINKS)) {
return index
}
return Files
.list(dir)
.use { entries ->
entries
.asSequence()
.filter { Files.isRegularFile(it, LinkOption.NOFOLLOW_LINKS) && it.extension() == "html" }
.toList()
}.singleOrNull()
}
/** Resolves [relativePath] inside [artifactDir]; null when it escapes the directory or is no regular file. */ /** Resolves [relativePath] inside [artifactDir]; null when it escapes the directory or is no regular file. */
private fun resolveFile( private fun resolveFile(
artifactDir: Path, artifactDir: Path,
@@ -24,11 +24,13 @@ class BranchListing(
.entries .entries
.sortedWith(compareBy({ sortGroup(it.key) }, { it.key })) .sortedWith(compareBy({ sortGroup(it.key) }, { it.key }))
.map { (branch, headCommit) -> .map { (branch, headCommit) ->
val latest = repository.latestFor(branch)
BranchDto.from( BranchDto.from(
branch, branch,
headCommit, headCommit,
repository.latestFor(branch), latest,
hasGreenBuild = repository.latestGreenFor(branch) != null, // the permanent link belongs to the build it resolves to, not to every build of the branch
isLatestGreen = latest != null && latest.artifactKey == repository.latestGreenFor(branch)?.artifactKey,
) )
} }
@@ -2,6 +2,7 @@ package de.hoennig.gittally.server
import de.hoennig.gittally.build.ArtifactStore import de.hoennig.gittally.build.ArtifactStore
import de.hoennig.gittally.build.BuildExecutor import de.hoennig.gittally.build.BuildExecutor
import de.hoennig.gittally.build.BuildResult
import de.hoennig.gittally.build.BuildResultRepository import de.hoennig.gittally.build.BuildResultRepository
import de.hoennig.gittally.build.BuildStatus import de.hoennig.gittally.build.BuildStatus
import de.hoennig.gittally.git.GitService import de.hoennig.gittally.git.GitService
@@ -38,14 +39,16 @@ class BuildsApiController(
var workingDir: Path = Paths.get(".") var workingDir: Path = Paths.get(".")
@GetMapping("/api/builds/latest") @GetMapping("/api/builds/latest")
fun latest(): List<BuildResultDto> = repository.latestPerBranch().map { BuildResultDto.from(it) } fun latest(): List<BuildResultDto> = repository.latestPerBranch().map { BuildResultDto.from(it, it.isLatestGreen()) }
/** The legacy branches view: every origin branch with its latest build or `unknown`. */ /** The legacy branches view: every origin branch with its latest build or `unknown`. */
@GetMapping("/api/branches") @GetMapping("/api/branches")
fun branches(): List<BranchDto> = branchListing.branches(workingDir) fun branches(): List<BranchDto> = branchListing.branches(workingDir)
@GetMapping("/api/builds/history") @GetMapping("/api/builds/history")
fun history(): List<BuildResultDto> = repository.history().map { BuildResultDto.from(it) } fun history(): List<BuildResultDto> = repository.history().map { BuildResultDto.from(it, it.isLatestGreen()) }
private fun BuildResult.isLatestGreen(): Boolean = repository.latestGreenFor(branch)?.artifactKey == artifactKey
/** The currently executing builds — several are possible, up to `builds.maxConcurrent`. */ /** The currently executing builds — several are possible, up to `builds.maxConcurrent`. */
@GetMapping("/api/builds/current") @GetMapping("/api/builds/current")
@@ -56,7 +56,7 @@ class UiController(
@GetMapping("/") @GetMapping("/")
fun latest(model: Model): String { fun latest(model: Model): String {
val links = baseModel(model, view = "latest", pageTitle = "Latest Builds") val links = baseModel(model, view = "latest", pageTitle = "Latest Builds")
model.addAttribute("rows", repository.latestPerBranch().map { BuildRowView.from(it, links) }) model.addAttribute("rows", repository.latestPerBranch().map { BuildRowView.from(it, links, permanentUrlOf(it)) })
model.addAttribute("apiPath", "/api/builds/latest") model.addAttribute("apiPath", "/api/builds/latest")
model.addAttribute("allowRestart", true) model.addAttribute("allowRestart", true)
model.addAttribute("emptyMessage", "No builds recorded yet.") model.addAttribute("emptyMessage", "No builds recorded yet.")
@@ -77,13 +77,21 @@ class UiController(
@GetMapping("/history") @GetMapping("/history")
fun history(model: Model): String { fun history(model: Model): String {
val links = baseModel(model, view = "history", pageTitle = "Build History") val links = baseModel(model, view = "history", pageTitle = "Build History")
model.addAttribute("rows", repository.history().map { BuildRowView.from(it, links) }) model.addAttribute("rows", repository.history().map { BuildRowView.from(it, links, permanentUrlOf(it)) })
model.addAttribute("apiPath", "/api/builds/history") model.addAttribute("apiPath", "/api/builds/history")
model.addAttribute("allowRestart", false) model.addAttribute("allowRestart", false)
model.addAttribute("emptyMessage", "No builds archived yet.") model.addAttribute("emptyMessage", "No builds archived yet.")
return "builds" return "builds"
} }
/** The permanent branch URL belongs to the build it resolves to — the branch's latest green build. */
private fun permanentUrlOf(result: BuildResult): String? =
if (repository.latestGreenFor(result.branch)?.artifactKey == result.artifactKey) {
BranchPermalinks.permanentUrl(result.branch)
} else {
null
}
@GetMapping("/current") @GetMapping("/current")
fun current(model: Model): String { fun current(model: Model): String {
val links = baseModel(model, view = "current", pageTitle = "Current Builds") val links = baseModel(model, view = "current", pageTitle = "Current Builds")
@@ -262,9 +270,10 @@ class UiController(
knownDirs.any { known -> known.isEmpty() || this == known || this.startsWith("$known/") } knownDirs.any { known -> known.isEmpty() || this == known || this.startsWith("$known/") }
/** /**
* Report pages of directories without an `index.html`, such as Gradle's `--profile` report * Report pages of directories without an `index.html`, such as Gradle's `--profile` report.
* with its timestamped file name. Only `reports/` itself and its direct sub-directories are * A directory holding a single page is linked as a directory, so that a timestamped file name
* scanned, so that a report tree cannot flood the artifact index with its inner pages. * does not leak into the permanent `/branches/…` URLs. Only `reports/` itself and its direct
* sub-directories are scanned, so that a report tree cannot flood the artifact index.
*/ */
private fun indexLessReportPages( private fun indexLessReportPages(
reportsDir: Path, reportsDir: Path,
@@ -280,13 +289,16 @@ class UiController(
return candidateDirs return candidateDirs
.filterNot { reportsDir.relativize(it).toString().isCoveredBy(knownDirs) } .filterNot { reportsDir.relativize(it).toString().isCoveredBy(knownDirs) }
.flatMap { dir -> .flatMap { dir ->
Files.list(dir).use { pages -> val pages =
pages Files.list(dir).use { entries ->
entries
.asSequence() .asSequence()
.filter { Files.isRegularFile(it) && it.name.endsWith(".html") } .filter { Files.isRegularFile(it) && it.name.endsWith(".html") }
.map { reportsDir.relativize(it).toString() } .map { reportsDir.relativize(it).toString() }
.toList() .toList()
} }
val dirPath = reportsDir.relativize(dir).toString()
if (pages.size == 1 && dirPath.isNotEmpty()) listOf("$dirPath/") else pages
}.sorted() }.sorted()
} }
@@ -90,7 +90,7 @@ object UiFormats {
private const val UTILIZATION_CRIT = 0.90 private const val UTILIZATION_CRIT = 0.90
} }
/** One row of the latest/history build tables; [latestGreenUrl] only on the branches view. */ /** One row of the build tables; [latestGreenUrl] only on the build that permanent link resolves to. */
data class BuildRowView( data class BuildRowView(
val branch: String, val branch: String,
val commit: String, val commit: String,
@@ -112,6 +112,7 @@ data class BuildRowView(
fun from( fun from(
result: BuildResult, result: BuildResult,
links: GiteaWebLinks, links: GiteaWebLinks,
latestGreenUrl: String? = null,
) = BuildRowView( ) = BuildRowView(
branch = result.branch, branch = result.branch,
commit = result.commit, commit = result.commit,
@@ -124,6 +125,7 @@ data class BuildRowView(
artifactKey = result.artifactKey, artifactKey = result.artifactKey,
branchUrl = links.branchUrl(result.branch), branchUrl = links.branchUrl(result.branch),
commitUrl = links.commitUrl(result.commit), commitUrl = links.commitUrl(result.commit),
latestGreenUrl = latestGreenUrl,
inProgress = !result.status.isTerminal, inProgress = !result.status.isTerminal,
) )
+8 -2
View File
@@ -250,8 +250,8 @@ function renderBuildRow(build, allowRestart) {
const artifactsCell = elem("td"); const artifactsCell = elem("td");
artifactsCell.dataset.label = "Artifacts"; artifactsCell.dataset.label = "Artifacts";
if (build.artifactKey) {
const inProgress = build.status === "running" || build.status === "pending"; const inProgress = build.status === "running" || build.status === "pending";
if (build.artifactKey) {
const artifactLink = elem("a", "artifact-link", inProgress ? "⏳" : "📄"); const artifactLink = elem("a", "artifact-link", inProgress ? "⏳" : "📄");
artifactLink.href = "/builds/" + encodeURIComponent(build.artifactKey); artifactLink.href = "/builds/" + encodeURIComponent(build.artifactKey);
artifactLink.title = inProgress ? "Open build log — no artifacts yet" : "Open artifacts"; artifactLink.title = inProgress ? "Open build log — no artifacts yet" : "Open artifacts";
@@ -263,7 +263,13 @@ function renderBuildRow(build, allowRestart) {
permanentLink.title = "Permanent link: artifacts of the latest green build"; permanentLink.title = "Permanent link: artifacts of the latest green build";
artifactsCell.appendChild(permanentLink); artifactsCell.appendChild(permanentLink);
} }
if (!build.artifactKey && !build.latestGreenUrl) { if (inProgress) {
const liveLink = elem("a", "artifact-link", "📡");
liveLink.href = "/current";
liveLink.title = "Watch this build live";
artifactsCell.appendChild(liveLink);
}
if (!build.artifactKey && !build.latestGreenUrl && !inProgress) {
artifactsCell.textContent = "n/a"; artifactsCell.textContent = "n/a";
} }
row.appendChild(artifactsCell); row.appendChild(artifactsCell);
+2
View File
@@ -58,6 +58,8 @@
<a th:if="${row.latestGreenUrl != null}" class="artifact-link" <a th:if="${row.latestGreenUrl != null}" class="artifact-link"
th:href="${row.latestGreenUrl}" th:href="${row.latestGreenUrl}"
title="Permanent link: artifacts of the latest green build">🔗</a> title="Permanent link: artifacts of the latest green build">🔗</a>
<a th:if="${row.inProgress}" class="artifact-link" href="/current"
title="Watch this build live">📡</a>
<span th:if="${row.artifactKey == ''}">n/a</span> <span th:if="${row.artifactKey == ''}">n/a</span>
</td> </td>
<td class="actions-cell"> <td class="actions-cell">
@@ -26,7 +26,6 @@
<span th:if="${view == 'history'}">History</span> <span th:if="${view == 'history'}">History</span>
<a th:unless="${view == 'history'}" href="/history">History</a> <a th:unless="${view == 'history'}" href="/history">History</a>
<span th:if="${view == 'current'}">Current</span> <span th:if="${view == 'current'}">Current</span>
<a th:unless="${view == 'current'}" href="/current">Current</a>
<span th:if="${view == 'system'}">System</span> <span th:if="${view == 'system'}">System</span>
<a th:unless="${view == 'system'}" href="/system">System</a> <a th:unless="${view == 'system'}" href="/system">System</a>
</nav> </nav>
+7 -2
View File
@@ -9,8 +9,13 @@
<h2>v0.9.8 <span class="muted">— 2026-08-10</span></h2> <h2>v0.9.8 <span class="muted">— 2026-08-10</span></h2>
<ul> <ul>
<li>The artifact index also links report pages of directories without an <code>index.html</code>, <li>The artifact index also links report pages of directories without an <code>index.html</code>.
such as Gradle's <code>--profile</code> report with its timestamped file name.</li> A directory holding a single page is linked as a directory, so Gradle's <code>--profile</code>
report keeps a stable URL although its file name carries the build timestamp.</li>
<li>The permanent <code>🔗</code> link now appears on the build it resolves to — the branch's
latest green build — instead of on every build of that branch, and on all build tables.</li>
<li>The <em>Current</em> tab gave way to a <code>📡</code> link in the artifacts column,
shown while a build runs.</li>
</ul> </ul>
<h2>v0.9.7 <span class="muted">— 2026-08-10</span></h2> <h2>v0.9.7 <span class="muted">— 2026-08-10</span></h2>
@@ -150,6 +150,30 @@ class ArtifactFileControllerTest : FunSpec() {
.andExpect(header().string("Cache-Control", "no-store, max-age=0")) .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") { test("permanent URL with a bare trailing slash redirects to the artifact index page") {
mockMvc mockMvc
.perform(get("/branches/main/")) .perform(get("/branches/main/"))
@@ -66,16 +66,17 @@ class BranchListingTest : FunSpec() {
branches[1].latestGreenUrl shouldBe null 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 { gitService.originBranchHeads(any()) } returns mapOf("feature/x" to "aaa")
every { repository.latestFor("feature/x") } returns every { repository.latestFor("feature/x") } returns
mainResult.copy(branch = "feature/x", status = BuildStatus.FAILED) mainResult.copy(branch = "feature/x", status = BuildStatus.FAILED, artifactKey = "failed-key")
every { repository.latestGreenFor("feature/x") } returns mainResult.copy(branch = "feature/x") every { repository.latestGreenFor("feature/x") } returns
mainResult.copy(branch = "feature/x", artifactKey = "green-key")
val branches = listing.branches() val branches = listing.branches()
branches[0].status shouldBe "failed" 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 { beforeEach {
clearMocks(repository, buildExecutor, artifactStore, controlTokens, gitService, branchListing) clearMocks(repository, buildExecutor, artifactStore, controlTokens, gitService, branchListing)
every { controlTokens.matches(any()) } answers { firstArg<String?>() == "secret" } 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") { 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")) .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") { test("current answers the running builds with live status and log size") {
val liveLogFile = Files.writeString(tempDir.resolve("build.log"), "12345") val liveLogFile = Files.writeString(tempDir.resolve("build.log"), "12345")
val build = runningBuild(liveLogFile) val build = runningBuild(liveLogFile)
@@ -113,9 +113,10 @@ class UiControllerTest : FunSpec() {
gitea = GiteaConfig(baseUrl = "https://git.example.org", owner = "acme", repo = "widget"), gitea = GiteaConfig(baseUrl = "https://git.example.org", owner = "acme", repo = "widget"),
) )
every { controlTokens.token() } returns "test-token" 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() every { repository.latestPerBranch() } returns emptyList()
mockMvc mockMvc
@@ -124,6 +125,7 @@ class UiControllerTest : FunSpec() {
.andExpect(content().string(containsString("No builds recorded yet."))) .andExpect(content().string(containsString("No builds recorded yet.")))
.andExpect(content().string(containsString("""data-api="/api/builds/latest""""))) .andExpect(content().string(containsString("""data-api="/api/builds/latest"""")))
.andExpect(content().string(containsString("""id="reload-button""""))) .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") { 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") { test("branches view renders built and never-built branches with restart actions") {
every { branchListing.branches(any()) } returns every { branchListing.branches(any()) } returns
listOf( listOf(
BranchDto.from("main", "ignored-head", successResult, hasGreenBuild = true), BranchDto.from("main", "ignored-head", successResult, isLatestGreen = true),
BranchDto.from("feature/x", "fedcba9876543210fedcba9876543210fedcba98", null), BranchDto.from("feature/x", "fedcba9876543210fedcba9876543210fedcba98", null),
) )
@@ -164,6 +166,23 @@ class UiControllerTest : FunSpec() {
.andExpect(content().string(containsString("Permanent link"))) .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") { test("history view renders mixed history without restart actions") {
every { repository.history() } returns every { repository.history() } returns
listOf( listOf(
@@ -237,7 +256,7 @@ 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("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")) val artifactDir = Files.createDirectories(tempDir.resolve("main-abc123-key"))
Files.createDirectories(artifactDir.resolve("reports/profile")) Files.createDirectories(artifactDir.resolve("reports/profile"))
Files.writeString(artifactDir.resolve("reports/profile/profile-2026-08-10-18-36-12.html"), "<html></html>") Files.writeString(artifactDir.resolve("reports/profile/profile-2026-08-10-18-36-12.html"), "<html></html>")
@@ -255,11 +274,31 @@ class UiControllerTest : FunSpec() {
.andReturn() .andReturn()
.response.contentAsString .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 shouldContain "reports/tests/test/index.html"
page shouldNotContain "SomeTest.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") { test("report links carry a failed-badge from the report's failures counter") {
val artifactDir = Files.createDirectories(tempDir.resolve("main-abc123-key")) val artifactDir = Files.createDirectories(tempDir.resolve("main-abc123-key"))
Files.createDirectories(artifactDir.resolve("reports/tests/test")) Files.createDirectories(artifactDir.resolve("reports/tests/test"))