Files
werkator/src/test/kotlin/de/hoennig/gittally/server/UiControllerTest.kt
T
mhoennigandClaude Opus 5 1a84b1fc1b Show the command a build actually runs on its artifact page
The artifact page read `branches.<branch>.buildCommand` from the primary
config only, so for a named build on a branch with its own config it showed
a command that build never ran — on vm4006 it showed the host config's
command for a build that ran the branch definition's `pitestFull`.

Resolving "what does this build run" now has one implementation,
`GitTallyConfig.buildSettings(branch, build)`: the branch entry with the
build definition's overrides applied last. The executor uses it, and the
page resolves it against the branch layer committed at the build's own
commit — the same inputs the executor had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 07:52:22 +02:00

529 lines
25 KiB
Kotlin

package de.hoennig.gittally.server
import com.ninjasquad.springmockk.MockkBean
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.build.RunningBuild
import de.hoennig.gittally.config.BranchConfig
import de.hoennig.gittally.config.BuildDefinition
import de.hoennig.gittally.config.ConfigLoader
import de.hoennig.gittally.config.GitTallyConfig
import de.hoennig.gittally.config.GiteaConfig
import de.hoennig.gittally.config.ServerConfig
import de.hoennig.gittally.git.GitService
import de.hoennig.gittally.metrics.MetricAggregate
import de.hoennig.gittally.metrics.SystemMetrics
import de.hoennig.gittally.metrics.SystemMetricsCollector
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.every
import org.hamcrest.Matchers.containsString
import org.hamcrest.Matchers.not
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest
import org.springframework.http.HttpStatus
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.web.server.ResponseStatusException
import java.nio.file.Files
import java.nio.file.Path
import java.time.Duration
import java.time.Instant
@WebMvcTest(UiController::class, properties = ["spring.main.web-application-type=servlet"])
class UiControllerTest : FunSpec() {
private val tempDir: Path = Files.createTempDirectory("gittally-ui-test")
@Autowired
lateinit var mockMvc: MockMvc
@MockkBean
lateinit var repository: BuildResultRepository
@MockkBean
lateinit var buildExecutor: BuildExecutor
@MockkBean
lateinit var artifactStore: ArtifactStore
@MockkBean
lateinit var controlTokens: ControlTokenService
@MockkBean
lateinit var configLoader: ConfigLoader
@MockkBean
lateinit var gitService: GitService
@MockkBean
lateinit var metricsCollector: SystemMetricsCollector
@MockkBean
lateinit var branchListing: BranchListing
@MockkBean
lateinit var branchPermalinks: BranchPermalinks
private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
private val emptySystemMetrics =
SystemMetrics(
timestamp = null,
sampleCount = 0,
cpuCount = 8,
ramTotalGib = null,
diskTotalGib = null,
cpuUsed = null,
cpuIdle = null,
ramUsedGib = null,
ramFreeGib = null,
diskUsedGib = null,
diskFreeGib = null,
repoSizeGib = null,
)
private val successResult =
BuildResult(
branch = "main",
commit = "0123456789abcdef0123456789abcdef01234567",
status = BuildStatus.SUCCESS,
startedAt = startedAt,
duration = Duration.ofSeconds(83),
artifactKey = "main-abc123-key",
)
init {
beforeEach {
clearMocks(
repository,
buildExecutor,
artifactStore,
controlTokens,
configLoader,
gitService,
metricsCollector,
branchListing,
branchPermalinks,
)
every { configLoader.load(any()) } returns
GitTallyConfig(
server = ServerConfig(impressumUrl = "https://example.org/imprint"),
gitea = GiteaConfig(baseUrl = "https://git.example.org", owner = "acme", repo = "widget"),
)
every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns GitTallyConfig()
every { gitService.showFileAtCommit(any(), any(), any()) } returns null
every { controlTokens.token() } returns "test-token"
every { repository.latestGreenFor(any()) } returns null
}
test("latest view renders the empty state, and the nav no longer offers the current view") {
every { repository.latestPerName() } returns emptyList()
mockMvc
.perform(get("/"))
.andExpect(status().isOk)
.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") {
every { repository.latestPerName() } returns listOf(successResult)
mockMvc
.perform(get("/"))
.andExpect(status().isOk)
.andExpect(content().string(containsString("""status status-success""")))
.andExpect(content().string(containsString("https://git.example.org/acme/widget/src/branch/main")))
.andExpect(
content().string(containsString("https://git.example.org/acme/widget/commit/0123456789abcdef0123456789abcdef01234567")),
).andExpect(content().string(containsString("/builds/main-abc123-key")))
.andExpect(content().string(containsString("""data-action="restart"""")))
.andExpect(content().string(containsString("""data-action="delete"""")))
// the control token must never reach the browser: reading a page is unauthenticated
.andExpect(content().string(not(containsString("gittally-control-token"))))
.andExpect(content().string(not(containsString("test-token"))))
.andExpect(content().string(containsString("https://example.org/imprint")))
.andExpect(content().string(containsString("1:23")))
}
test("branches view renders built and never-built branches with restart actions") {
every { branchListing.branches(any()) } returns
listOf(
BranchDto.from("main", "ignored-head", successResult, isLatestGreen = true),
BranchDto.from("feature/x", "fedcba9876543210fedcba9876543210fedcba98", null),
)
mockMvc
.perform(get("/branches"))
.andExpect(status().isOk)
.andExpect(content().string(containsString("status status-success")))
.andExpect(content().string(containsString("status status-unknown")))
.andExpect(content().string(containsString("feature/x")))
.andExpect(content().string(containsString("fedcba987654")))
.andExpect(content().string(containsString("""data-api="/api/branches"""")))
.andExpect(content().string(containsString("""data-action="restart"""")))
.andExpect(content().string(containsString("""href="/branches/main"""")))
.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(
successResult.copy(
branch = "main",
name = "main",
status = BuildStatus.RUNNING,
duration = null,
artifactKey = "run-key",
),
successResult,
successResult.copy(branch = "feature/x", name = "feature/x", status = BuildStatus.FAILED, artifactKey = "failed-key"),
)
mockMvc
.perform(get("/history"))
.andExpect(status().isOk)
.andExpect(content().string(containsString("status status-running")))
.andExpect(content().string(containsString("status status-success")))
.andExpect(content().string(containsString("status status-failed")))
.andExpect(content().string(containsString("feature/x")))
.andExpect(content().string(not(containsString("""data-action="restart""""))))
}
test("current view renders a card per running build with cancel button and started-at attribute") {
val build =
RunningBuild(
branch = "main",
commit = successResult.commit,
artifactKey = "main-abc123-running",
startedAt = startedAt,
stagingDir = tempDir,
liveLogFile = tempDir.resolve("build.log"),
)
every { buildExecutor.currentBuilds() } returns listOf(build)
every { repository.history() } returns
listOf(successResult.copy(status = BuildStatus.RUNNING, artifactKey = build.artifactKey, duration = null))
mockMvc
.perform(get("/current"))
.andExpect(status().isOk)
.andExpect(content().string(containsString("build-card")))
.andExpect(content().string(containsString("""data-started-at="2026-07-07T10:00:00Z"""")))
.andExpect(content().string(containsString("""data-action="cancel"""")))
.andExpect(content().string(containsString("status status-running")))
}
test("current view renders a clear no-build state") {
every { buildExecutor.currentBuilds() } returns emptyList()
every { repository.history() } returns emptyList()
mockMvc
.perform(get("/current"))
.andExpect(status().isOk)
.andExpect(content().string(containsString("No build is currently running.")))
}
test("artifact index renders build command, log links, and topmost report index pages") {
val artifactDir = Files.createDirectories(tempDir.resolve("main-abc123-key"))
Files.writeString(artifactDir.resolve("build.stdout.log"), "out")
Files.writeString(artifactDir.resolve("build.stderr.log"), "err")
Files.createDirectories(artifactDir.resolve("reports/tests/test"))
Files.writeString(artifactDir.resolve("reports/tests/test/index.html"), "<html></html>")
Files.createDirectories(artifactDir.resolve("reports/tests/test/packages"))
Files.writeString(artifactDir.resolve("reports/tests/test/packages/index.html"), "<html></html>")
every { repository.history() } returns listOf(successResult)
every { artifactStore.artifactDir("main-abc123-key") } returns artifactDir
mockMvc
.perform(get("/builds/main-abc123-key"))
.andExpect(status().isOk)
.andExpect(content().string(containsString("./gradlew --console=plain --no-daemon test")))
.andExpect(content().string(containsString("""/artifacts/main-abc123-key/build.stdout.log" target="_blank"""")))
.andExpect(content().string(containsString("/artifacts/main-abc123-key/build.stderr.log")))
.andExpect(
content().string(containsString("""/artifacts/main-abc123-key/reports/tests/test/index.html" target="_blank"""")),
).andExpect(content().string(not(containsString("reports/tests/test/packages/index.html"))))
}
test("the artifact page shows the command of the build's own definition, not the plain branch command") {
val pitestResult =
successResult.copy(
build = "pitest",
name = "main@pitest",
artifactKey = "main-pitest-key",
)
every { repository.history() } returns listOf(pitestResult)
every { artifactStore.artifactDir("main-pitest-key") } returns null
every { configLoader.loadWithBranchLayer(any(), anyNullable()) } returns
GitTallyConfig(
branches = mapOf("default" to BranchConfig(buildCommand = "./gradlew quick-check")),
buildDefinitions = mapOf("pitest" to BuildDefinition(buildCommand = "./gradlew pitestFull")),
)
mockMvc
.perform(get("/builds/main-pitest-key"))
.andExpect(status().isOk)
.andExpect(content().string(containsString("./gradlew pitestFull")))
.andExpect(content().string(not(containsString("./gradlew quick-check"))))
}
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>")
Files.createDirectories(artifactDir.resolve("reports/tests/test"))
Files.writeString(artifactDir.resolve("reports/tests/test/index.html"), "<html></html>")
Files.createDirectories(artifactDir.resolve("reports/tests/test/classes"))
Files.writeString(artifactDir.resolve("reports/tests/test/classes/SomeTest.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/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"))
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("of a failed build, only the logs carrying a failure line get a failed-badge") {
val artifactDir = Files.createDirectories(tempDir.resolve("failed-key"))
Files.writeString(artifactDir.resolve("build.log"), "compiling\nBUILD FAILED in 20s\n")
// a failed test, far away from any BUILD FAILED line
Files.writeString(artifactDir.resolve("build.stdout.log"), "SomeTest > works() FAILED\ncompiling\n")
Files.writeString(artifactDir.resolve("build.stderr.log"), "warning: this test has failed before\n")
every { repository.history() } returns
listOf(successResult.copy(status = BuildStatus.FAILED, artifactKey = "failed-key"))
every { artifactStore.artifactDir("failed-key") } returns artifactDir
val page =
mockMvc
.perform(get("/builds/failed-key"))
.andExpect(status().isOk)
.andReturn()
.response.contentAsString
// two badges for the logs, plus the one of the build status itself
Regex(""">failed</span>""").findAll(page).count() shouldBe 3
// lower-case prose about failing is not a failure line
page.substringAfter("build.stderr.log</a>").substringBefore("</li>") shouldNotContain "status-failed"
}
test("logs of a successful build are not scanned for failure lines") {
val artifactDir = Files.createDirectories(tempDir.resolve("green-key"))
Files.writeString(artifactDir.resolve("build.log"), "BUILD FAILED in a nested build\nBUILD SUCCESSFUL\n")
every { repository.history() } returns listOf(successResult.copy(artifactKey = "green-key"))
every { artifactStore.artifactDir("green-key") } returns artifactDir
val page =
mockMvc
.perform(get("/builds/green-key"))
.andExpect(status().isOk)
.andReturn()
.response.contentAsString
page shouldContain "build.log"
page shouldNotContain "status-failed"
}
test("legacy page names redirect permanently to the new routes") {
mockMvc
.perform(get("/index.html"))
.andExpect(status().isMovedPermanently)
.andExpect(header().string("Location", "/"))
mockMvc
.perform(get("/branches.html"))
.andExpect(status().isMovedPermanently)
.andExpect(header().string("Location", "/branches"))
mockMvc
.perform(get("/history.html"))
.andExpect(status().isMovedPermanently)
.andExpect(header().string("Location", "/history"))
}
test("release notes page renders the version history and the footer links to it") {
mockMvc
.perform(get("/releases"))
.andExpect(status().isOk)
.andExpect(content().string(containsString("Release Notes")))
.andExpect(content().string(containsString("v0.9.0")))
.andExpect(content().string(containsString("Kotlin/Spring Boot")))
// the footer version (rendered on every page) is the link to this page
.andExpect(content().string(containsString("""href="/releases"""")))
}
test("artifact index of a pruned build explains the missing artifacts") {
every { repository.history() } returns listOf(successResult)
every { artifactStore.artifactDir("main-abc123-key") } returns null
mockMvc
.perform(get("/builds/main-abc123-key"))
.andExpect(status().isOk)
.andExpect(content().string(containsString("No log files are stored for this build")))
}
test("permanent artifact index renders the latest green build with permanent file links") {
val artifactDir = Files.createDirectories(tempDir.resolve("permanent-main-key"))
Files.writeString(artifactDir.resolve("build.stdout.log"), "out")
Files.createDirectories(artifactDir.resolve("reports/tests/test"))
Files.writeString(artifactDir.resolve("reports/tests/test/index.html"), "<html></html>")
every { branchPermalinks.latestGreenBuild("main") } returns successResult
every { artifactStore.artifactDir("main-abc123-key") } returns artifactDir
mockMvc
.perform(get("/branches/main"))
.andExpect(status().isOk)
.andExpect(content().string(containsString("latest green build of branch")))
.andExpect(content().string(containsString("""/branches/main/build.stdout.log" target="_blank"""")))
.andExpect(content().string(containsString("""/branches/main/reports/tests/test/index.html"""")))
.andExpect(content().string(containsString("/builds/main-abc123-key")))
.andExpect(content().string(not(containsString("/artifacts/main-abc123-key"))))
}
test("permanent artifact index of a branch without a green build answers 404") {
every { branchPermalinks.latestGreenBuild("main") } throws
ResponseStatusException(HttpStatus.NOT_FOUND, "branch 'main' has no successful build")
mockMvc
.perform(get("/branches/main"))
.andExpect(status().isNotFound)
}
test("artifact index of an unknown key answers 404") {
every { repository.history() } returns emptyList()
every { artifactStore.artifactDir("no-such-key") } returns null
mockMvc
.perform(get("/builds/no-such-key"))
.andExpect(status().isNotFound)
}
test("system view renders metric rows, totals, and the polling hook") {
every { metricsCollector.snapshot() } returns
emptySystemMetrics.copy(
timestamp = Instant.parse("2026-07-07T10:00:00Z"),
sampleCount = 5,
ramTotalGib = 32.0,
cpuUsed = MetricAggregate(current = 1.0, min = 0.5, max = 2.0, avg = 1.25),
)
mockMvc
.perform(get("/system"))
.andExpect(status().isOk)
.andExpect(content().string(containsString("""data-api="/api/system"""")))
.andExpect(content().string(containsString("""data-metric="cpuUsed"""")))
.andExpect(content().string(containsString("CPU used (cores)")))
.andExpect(content().string(containsString("1.25")))
.andExpect(content().string(containsString("8 cores")))
.andExpect(content().string(containsString("32.00 GiB")))
}
test("system view renders n/a for unavailable metrics") {
every { metricsCollector.snapshot() } returns emptySystemMetrics
mockMvc
.perform(get("/system"))
.andExpect(status().isOk)
.andExpect(content().string(containsString("Repo size (GiB)")))
.andExpect(content().string(containsString("n/a")))
}
test("branch names with HTML metacharacters render escaped") {
val nasty = "feat/<script>alert('x')</script>"
every { repository.latestPerName() } returns listOf(successResult.copy(branch = nasty, name = nasty))
mockMvc
.perform(get("/"))
.andExpect(status().isOk)
.andExpect(content().string(not(containsString("<script>alert"))))
.andExpect(content().string(containsString("&lt;script&gt;")))
}
}
}