implemented 08-web-ui.md: added Thymeleaf-based web UI: templates for builds, artifacts, current builds, and fragments; static assets for favicon, CSS, and JavaScript
This commit is contained in:
@@ -129,32 +129,39 @@ class BuildsApiControllerTest : FunSpec() {
|
||||
.andExpect(jsonPath("$.error").exists())
|
||||
}
|
||||
|
||||
test("restart enqueues the branch's last recorded commit") {
|
||||
test("restart enqueues the branch's last recorded commit, also for branch names with slashes") {
|
||||
val liveLogFile = tempDir.resolve("restart.log")
|
||||
every { repository.latestFor("main") } returns successResult
|
||||
every { buildExecutor.startBuild("main", successResult.commit) } returns runningBuild(liveLogFile)
|
||||
every { repository.latestFor("feature/topic") } returns successResult.copy(branch = "feature/topic")
|
||||
every { buildExecutor.startBuild("feature/topic", successResult.commit) } returns
|
||||
runningBuild(liveLogFile).copy(branch = "feature/topic")
|
||||
|
||||
mockMvc
|
||||
.perform(post("/api/builds/main/restart").header(BuildsApiController.TOKEN_HEADER, "secret"))
|
||||
.andExpect(status().isAccepted)
|
||||
.perform(
|
||||
post("/api/builds/restart")
|
||||
.param("branch", "feature/topic")
|
||||
.header(BuildsApiController.TOKEN_HEADER, "secret"),
|
||||
).andExpect(status().isAccepted)
|
||||
.andExpect(jsonPath("$.status").value("pending"))
|
||||
.andExpect(jsonPath("$.artifactKey").value("main-abc123-running"))
|
||||
|
||||
verify { buildExecutor.startBuild("main", successResult.commit) }
|
||||
verify { buildExecutor.startBuild("feature/topic", successResult.commit) }
|
||||
}
|
||||
|
||||
test("restart of a branch without recorded builds answers 404") {
|
||||
every { repository.latestFor("gone") } returns null
|
||||
|
||||
mockMvc
|
||||
.perform(post("/api/builds/gone/restart").param("token", "secret"))
|
||||
.perform(post("/api/builds/restart").param("branch", "gone").param("token", "secret"))
|
||||
.andExpect(status().isNotFound)
|
||||
}
|
||||
|
||||
test("restart with a wrong token answers 403 and does not build") {
|
||||
mockMvc
|
||||
.perform(post("/api/builds/main/restart").header(BuildsApiController.TOKEN_HEADER, "wrong"))
|
||||
.andExpect(status().isForbidden)
|
||||
.perform(
|
||||
post("/api/builds/restart")
|
||||
.param("branch", "main")
|
||||
.header(BuildsApiController.TOKEN_HEADER, "wrong"),
|
||||
).andExpect(status().isForbidden)
|
||||
|
||||
verify(exactly = 0) { buildExecutor.startBuild(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
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.ConfigLoader
|
||||
import de.hoennig.gittally.config.GitTallyConfig
|
||||
import de.hoennig.gittally.config.GiteaConfig
|
||||
import de.hoennig.gittally.config.ServerConfig
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
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.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.status
|
||||
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
|
||||
|
||||
private val startedAt = Instant.parse("2026-07-07T10:00:00Z")
|
||||
|
||||
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)
|
||||
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 { controlTokens.token() } returns "test-token"
|
||||
}
|
||||
|
||||
test("latest view renders the empty state") {
|
||||
every { repository.latestPerBranch() } 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"""")))
|
||||
}
|
||||
|
||||
test("latest view renders rows with badge, Gitea links, artifact link, actions, and token") {
|
||||
every { repository.latestPerBranch() } 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"""")))
|
||||
.andExpect(content().string(containsString("""name="gittally-control-token" content="test-token"""")))
|
||||
.andExpect(content().string(containsString("https://example.org/imprint")))
|
||||
.andExpect(content().string(containsString("1:23")))
|
||||
}
|
||||
|
||||
test("history view renders mixed history without restart actions") {
|
||||
every { repository.history() } returns
|
||||
listOf(
|
||||
successResult.copy(branch = "main", status = BuildStatus.RUNNING, duration = null, artifactKey = "run-key"),
|
||||
successResult,
|
||||
successResult.copy(branch = "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")))
|
||||
.andExpect(content().string(containsString("/artifacts/main-abc123-key/build.stderr.log")))
|
||||
.andExpect(content().string(containsString("reports/tests/test/index.html")))
|
||||
.andExpect(content().string(not(containsString("reports/tests/test/packages/index.html"))))
|
||||
}
|
||||
|
||||
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("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("branch names with HTML metacharacters render escaped") {
|
||||
val nasty = "feat/<script>alert('x')</script>"
|
||||
every { repository.latestPerBranch() } returns listOf(successResult.copy(branch = nasty))
|
||||
|
||||
mockMvc
|
||||
.perform(get("/"))
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(content().string(not(containsString("<script>alert"))))
|
||||
.andExpect(content().string(containsString("<script>")))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.hoennig.gittally.server
|
||||
|
||||
import de.hoennig.gittally.config.GiteaConfig
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import java.time.Duration
|
||||
|
||||
class UiViewsTest : FunSpec() {
|
||||
init {
|
||||
test("durations format as m:ss and h:mm:ss like the legacy duration column") {
|
||||
UiFormats.duration(null) shouldBe ""
|
||||
UiFormats.duration(Duration.ofSeconds(0)) shouldBe "0:00"
|
||||
UiFormats.duration(Duration.ofSeconds(83)) shouldBe "1:23"
|
||||
UiFormats.duration(Duration.ofSeconds(3600 + 62)) shouldBe "1:01:02"
|
||||
}
|
||||
|
||||
test("Gitea web links escape branch segments but keep slashes") {
|
||||
val links =
|
||||
GiteaWebLinks(GiteaConfig(baseUrl = "https://git.example.org/", owner = "acme", repo = "widget"))
|
||||
|
||||
links.repoUrl shouldBe "https://git.example.org/acme/widget"
|
||||
links.branchUrl("feature/some topic") shouldBe
|
||||
"https://git.example.org/acme/widget/src/branch/feature/some%20topic"
|
||||
links.commitUrl("0123abc") shouldBe "https://git.example.org/acme/widget/commit/0123abc"
|
||||
}
|
||||
|
||||
test("Gitea web links are null when Gitea is not configured") {
|
||||
val links = GiteaWebLinks(GiteaConfig())
|
||||
|
||||
links.repoUrl shouldBe null
|
||||
links.branchUrl("main") shouldBe null
|
||||
links.commitUrl("0123abc") shouldBe null
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user