diff --git a/docs/plan/08-web-ui.md b/docs/plan/08-web-ui.md index c1c0c00..dfb97ec 100644 --- a/docs/plan/08-web-ui.md +++ b/docs/plan/08-web-ui.md @@ -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"`). 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. + +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. diff --git a/src/main/kotlin/de/hoennig/gittally/server/ApiDtos.kt b/src/main/kotlin/de/hoennig/gittally/server/ApiDtos.kt index be9b71b..eda1488 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/ApiDtos.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/ApiDtos.kt @@ -17,18 +17,23 @@ data class BuildResultDto( val runningSince: Instant? = null, val durationSeconds: Long?, 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 { - fun from(result: BuildResult) = - BuildResultDto( - branch = result.branch, - commit = result.commit, - status = result.status.jsonName, - startedAt = result.startedAt, - runningSince = result.runningSince, - durationSeconds = result.duration?.seconds, - artifactKey = result.artifactKey, - ) + fun from( + result: BuildResult, + isLatestGreen: Boolean = false, + ) = BuildResultDto( + branch = result.branch, + commit = result.commit, + status = result.status.jsonName, + startedAt = result.startedAt, + runningSince = result.runningSince, + durationSeconds = result.duration?.seconds, + 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 * branch with its latest build, or an `unknown` placeholder when never built. * [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( val branch: String, @@ -53,7 +59,7 @@ data class BranchDto( branch: String, headCommit: String, latest: BuildResult?, - hasGreenBuild: Boolean = false, + isLatestGreen: Boolean = false, ) = if (latest == null) { BranchDto( branch = branch, @@ -72,7 +78,7 @@ data class BranchDto( runningSince = latest.runningSince, durationSeconds = latest.duration?.seconds, artifactKey = latest.artifactKey, - latestGreenUrl = if (hasGreenBuild) BranchPermalinks.permanentUrl(branch) else null, + latestGreenUrl = if (isLatestGreen) BranchPermalinks.permanentUrl(branch) else null, ) } } diff --git a/src/main/kotlin/de/hoennig/gittally/server/ArtifactFileController.kt b/src/main/kotlin/de/hoennig/gittally/server/ArtifactFileController.kt index b473d17..62555ac 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/ArtifactFileController.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/ArtifactFileController.kt @@ -17,6 +17,7 @@ import java.net.URI import java.nio.file.Files import java.nio.file.LinkOption import java.nio.file.Path +import kotlin.streams.asSequence /** * Streams stored build artifacts. Status pages, JSON, and logs are served with @@ -31,12 +32,15 @@ class ArtifactFileController( fun serve( @PathVariable artifactKey: String, @PathVariable path: String, + request: HttpServletRequest, ): ResponseEntity { val artifactDir = artifactStore.artifactDir(artifactKey) ?: return ResponseEntity.notFound().build() + val relativePath = path.removePrefix("/").removeSuffix("/") + directoryResponse(artifactDir, relativePath, request, noStore = true)?.let { return it } val file = - resolveFile(artifactDir, path.removePrefix("/")) + resolveFile(artifactDir, relativePath) ?: return ResponseEntity.notFound().build() 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 return redirect(request.requestURI.trimEnd('/')) } - val target = artifactDir.resolve(relativePath).normalize() - 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 + "/") - } - } + directoryResponse(artifactDir, relativePath, request, noStore = true)?.let { return it } val file = resolveFile(artifactDir, relativePath) ?: return ResponseEntity.notFound().build() 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? { + 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. */ private fun resolveFile( artifactDir: Path, diff --git a/src/main/kotlin/de/hoennig/gittally/server/BranchListing.kt b/src/main/kotlin/de/hoennig/gittally/server/BranchListing.kt index 4a50a59..aa2ec4f 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/BranchListing.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/BranchListing.kt @@ -24,11 +24,13 @@ class BranchListing( .entries .sortedWith(compareBy({ sortGroup(it.key) }, { it.key })) .map { (branch, headCommit) -> + val latest = repository.latestFor(branch) BranchDto.from( branch, headCommit, - repository.latestFor(branch), - hasGreenBuild = repository.latestGreenFor(branch) != null, + latest, + // 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, ) } diff --git a/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt b/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt index aeff035..53ea946 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/BuildsApiController.kt @@ -2,6 +2,7 @@ package de.hoennig.gittally.server import de.hoennig.gittally.build.ArtifactStore import de.hoennig.gittally.build.BuildExecutor +import de.hoennig.gittally.build.BuildResult import de.hoennig.gittally.build.BuildResultRepository import de.hoennig.gittally.build.BuildStatus import de.hoennig.gittally.git.GitService @@ -38,14 +39,16 @@ class BuildsApiController( var workingDir: Path = Paths.get(".") @GetMapping("/api/builds/latest") - fun latest(): List = repository.latestPerBranch().map { BuildResultDto.from(it) } + fun latest(): List = repository.latestPerBranch().map { BuildResultDto.from(it, it.isLatestGreen()) } /** The legacy branches view: every origin branch with its latest build or `unknown`. */ @GetMapping("/api/branches") fun branches(): List = branchListing.branches(workingDir) @GetMapping("/api/builds/history") - fun history(): List = repository.history().map { BuildResultDto.from(it) } + fun history(): List = 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`. */ @GetMapping("/api/builds/current") diff --git a/src/main/kotlin/de/hoennig/gittally/server/UiController.kt b/src/main/kotlin/de/hoennig/gittally/server/UiController.kt index a3f954f..7536304 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/UiController.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/UiController.kt @@ -56,7 +56,7 @@ class UiController( @GetMapping("/") fun latest(model: Model): String { 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("allowRestart", true) model.addAttribute("emptyMessage", "No builds recorded yet.") @@ -77,13 +77,21 @@ class UiController( @GetMapping("/history") fun history(model: Model): String { 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("allowRestart", false) model.addAttribute("emptyMessage", "No builds archived yet.") 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") fun current(model: Model): String { 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/") } /** - * 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 - * scanned, so that a report tree cannot flood the artifact index with its inner pages. + * Report pages of directories without an `index.html`, such as Gradle's `--profile` report. + * A directory holding a single page is linked as a directory, so that a timestamped file name + * 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( reportsDir: Path, @@ -280,13 +289,16 @@ class UiController( return candidateDirs .filterNot { reportsDir.relativize(it).toString().isCoveredBy(knownDirs) } .flatMap { dir -> - Files.list(dir).use { pages -> - pages - .asSequence() - .filter { Files.isRegularFile(it) && it.name.endsWith(".html") } - .map { reportsDir.relativize(it).toString() } - .toList() - } + val pages = + Files.list(dir).use { entries -> + entries + .asSequence() + .filter { Files.isRegularFile(it) && it.name.endsWith(".html") } + .map { reportsDir.relativize(it).toString() } + .toList() + } + val dirPath = reportsDir.relativize(dir).toString() + if (pages.size == 1 && dirPath.isNotEmpty()) listOf("$dirPath/") else pages }.sorted() } diff --git a/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt b/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt index 9a744ca..3a164af 100644 --- a/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt +++ b/src/main/kotlin/de/hoennig/gittally/server/UiViews.kt @@ -90,7 +90,7 @@ object UiFormats { 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( val branch: String, val commit: String, @@ -112,6 +112,7 @@ data class BuildRowView( fun from( result: BuildResult, links: GiteaWebLinks, + latestGreenUrl: String? = null, ) = BuildRowView( branch = result.branch, commit = result.commit, @@ -124,6 +125,7 @@ data class BuildRowView( artifactKey = result.artifactKey, branchUrl = links.branchUrl(result.branch), commitUrl = links.commitUrl(result.commit), + latestGreenUrl = latestGreenUrl, inProgress = !result.status.isTerminal, ) diff --git a/src/main/resources/static/gittally.js b/src/main/resources/static/gittally.js index dc4366e..502c2d9 100644 --- a/src/main/resources/static/gittally.js +++ b/src/main/resources/static/gittally.js @@ -250,8 +250,8 @@ function renderBuildRow(build, allowRestart) { const artifactsCell = elem("td"); artifactsCell.dataset.label = "Artifacts"; + const inProgress = build.status === "running" || build.status === "pending"; if (build.artifactKey) { - const inProgress = build.status === "running" || build.status === "pending"; const artifactLink = elem("a", "artifact-link", inProgress ? "⏳" : "📄"); artifactLink.href = "/builds/" + encodeURIComponent(build.artifactKey); 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"; 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"; } row.appendChild(artifactsCell); diff --git a/src/main/resources/templates/builds.html b/src/main/resources/templates/builds.html index 449a9c3..a830e5c 100644 --- a/src/main/resources/templates/builds.html +++ b/src/main/resources/templates/builds.html @@ -58,6 +58,8 @@ 🔗 + 📡 n/a diff --git a/src/main/resources/templates/fragments.html b/src/main/resources/templates/fragments.html index b9c42ac..8339644 100644 --- a/src/main/resources/templates/fragments.html +++ b/src/main/resources/templates/fragments.html @@ -26,7 +26,6 @@ History History Current - Current System System diff --git a/src/main/resources/templates/releases.html b/src/main/resources/templates/releases.html index 3107ca0..8b29e88 100644 --- a/src/main/resources/templates/releases.html +++ b/src/main/resources/templates/releases.html @@ -9,8 +9,13 @@

v0.9.8 — 2026-08-10

    -
  • The artifact index also links report pages of directories without an index.html, - such as Gradle's --profile report with its timestamped file name.
  • +
  • The artifact index also links report pages of directories without an index.html. + A directory holding a single page is linked as a directory, so Gradle's --profile + report keeps a stable URL although its file name carries the build timestamp.
  • +
  • The permanent 🔗 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.
  • +
  • The Current tab gave way to a 📡 link in the artifacts column, + shown while a build runs.

v0.9.7 — 2026-08-10

diff --git a/src/test/kotlin/de/hoennig/gittally/server/ArtifactFileControllerTest.kt b/src/test/kotlin/de/hoennig/gittally/server/ArtifactFileControllerTest.kt index 5bd2686..7c4b9f9 100644 --- a/src/test/kotlin/de/hoennig/gittally/server/ArtifactFileControllerTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/server/ArtifactFileControllerTest.kt @@ -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"), "profile") + + mockMvc + .perform(get("/branches/main/reports/profile/")) + .andExpect(status().isOk) + .andExpect(content().string("profile")) + mockMvc + .perform(get("/artifacts/known-key/reports/profile/")) + .andExpect(status().isOk) + .andExpect(content().string("profile")) + } + + 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"), "main") + Files.writeString(pmdDir.resolve("test.html"), "test") + + 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/")) diff --git a/src/test/kotlin/de/hoennig/gittally/server/BranchListingTest.kt b/src/test/kotlin/de/hoennig/gittally/server/BranchListingTest.kt index a1763bc..621a282 100644 --- a/src/test/kotlin/de/hoennig/gittally/server/BranchListingTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/server/BranchListingTest.kt @@ -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 } } } diff --git a/src/test/kotlin/de/hoennig/gittally/server/BuildsApiControllerTest.kt b/src/test/kotlin/de/hoennig/gittally/server/BuildsApiControllerTest.kt index 461964e..c8c996e 100644 --- a/src/test/kotlin/de/hoennig/gittally/server/BuildsApiControllerTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/server/BuildsApiControllerTest.kt @@ -76,6 +76,7 @@ class BuildsApiControllerTest : FunSpec() { beforeEach { clearMocks(repository, buildExecutor, artifactStore, controlTokens, gitService, branchListing) every { controlTokens.matches(any()) } answers { firstArg() == "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) diff --git a/src/test/kotlin/de/hoennig/gittally/server/UiControllerTest.kt b/src/test/kotlin/de/hoennig/gittally/server/UiControllerTest.kt index 93eb871..d46e9c1 100644 --- a/src/test/kotlin/de/hoennig/gittally/server/UiControllerTest.kt +++ b/src/test/kotlin/de/hoennig/gittally/server/UiControllerTest.kt @@ -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"), "") @@ -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"), "") + Files.writeString(artifactDir.resolve("reports/pmd/test.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"))