package httpd
import (
"gitbay.org/gitbay/internal/control"
"strings"
"testing"
"gitbay.org/gitbay/internal/store"
"gitbay.org/gitbay/internal/web"
)
func testRepoPage() repoPage {
var p repoPage
p.Repo = store.Repo{OwnerName: "krz", Name: "gitbay", DefaultBranch: "main"}
p.Host = "gitbay.org"
return p
}
// The build pages read their data from the build commands' JSON now, not
// from store.Build. A renamed field would be a blank cell rather than a
// compile error, so render both pages and look for the values.
func TestBuildsPageRendersCommandOutput(t *testing.T) {
var sb strings.Builder
builds := []control.BuildOut{{
Number: 60, Job: "build", Status: "success",
SHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b",
Ref: "cli-coverage", CreatedAt: "2026-08-28T04:42:54Z",
}}
jobs := []control.JobOut{{Name: "build"}, {Name: "nightly", Schedule: "0 3 * * *"}}
filter := buildFilter{}
err := web.Render(&sb, "builds.html", struct {
repoPage
Builds []control.BuildOut
Jobs []control.JobOut
Runs []buildRun
Filter buildFilter
Facets []facetGroup
Refs []string
Older string
CanWrite bool
Notice string
}{
testRepoPage(), builds, jobs, groupRuns(builds), filter, nil,
distinctRefs(builds, filter.Ref), "", true, "",
})
if err != nil {
t.Fatalf("render: %v", err)
}
out := sb.String()
for _, want := range []string{
"build", "success", "cli-coverage", "ff6271a9d4",
`value="build"`, `value="nightly"`, "schedule 0 3 * * *",
} {
if !strings.Contains(out, want) {
t.Errorf("builds.html missing %q", want)
}
}
}
// A writer sees the instruction to add CI; a reader without push access
// sees only the fact, since the instruction is not theirs to act on
// (#270).
func TestBuildsEmptyStateOmitsInstructionForReaders(t *testing.T) {
var sb strings.Builder
filter := buildFilter{}
err := web.Render(&sb, "builds.html", struct {
repoPage
Builds []control.BuildOut
Jobs []control.JobOut
Runs []buildRun
Filter buildFilter
Facets []facetGroup
Refs []string
Older string
CanWrite bool
Notice string
}{
testRepoPage(), nil, nil, nil, filter, nil, nil, "", false, "",
})
if err != nil {
t.Fatalf("render: %v", err)
}
out := sb.String()
if !strings.Contains(out, "no builds") {
t.Error(`missing "no builds"`)
}
if strings.Contains(out, "ci.yml") {
t.Error("a reader without push access should not see the push instruction")
}
}
// A writer with zero builds gets the one instruction the page can give
// them (#270).
func TestBuildsEmptyStateShowsInstructionForWriters(t *testing.T) {
var sb strings.Builder
filter := buildFilter{}
err := web.Render(&sb, "builds.html", struct {
repoPage
Builds []control.BuildOut
Jobs []control.JobOut
Runs []buildRun
Filter buildFilter
Facets []facetGroup
Refs []string
Older string
CanWrite bool
Notice string
}{
testRepoPage(), nil, nil, nil, filter, nil, nil, "", true, "",
})
if err != nil {
t.Fatalf("render: %v", err)
}
out := sb.String()
if !strings.Contains(out, "no builds") {
t.Error(`missing "no builds"`)
}
if !strings.Contains(out, "push a commit with a .gitbay/ci.yml") {
t.Error("a writer with no builds should see the push instruction")
}
}
func TestBuildPageRendersCommandOutput(t *testing.T) {
var sb strings.Builder
err := web.Render(&sb, "build.html", buildView{
repoPage: testRepoPage(),
Build: control.BuildOut{
Number: 60, Job: "build", Status: "success",
SHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b", Ref: "cli-coverage",
CreatedAt: "2026-08-28T04:42:54Z", FinishedAt: "2026-08-28T04:43:06Z",
},
Log: "step 1 ok",
CanWrite: true,
})
if err != nil {
t.Fatalf("render: %v", err)
}
out := sb.String()
for _, want := range []string{
"Build 60", "success", "build on cli-coverage", "ff6271a9d4",
"2026-08-28 04:42", "2026-08-28 04:43", "step 1 ok",
} {
if !strings.Contains(out, want) {
t.Errorf("build.html missing %q", want)
}
}
// A finished build offers no cancel control, even to a writer.
if strings.Contains(out, "/cancel") {
t.Error("build.html offers cancel on a finished build")
}
}
// The count under the heading said "15 runs" while listing 23 builds
// (#240). It names both numbers now, so neither is mistaken for the other.
func TestBuildsPageCountsBuildsAndRuns(t *testing.T) {
builds := []control.BuildOut{
{Number: 4, Job: "instances", Status: "success", SHA: "aaa", Ref: "main", CreatedAt: "2026-09-20T06:00:00Z"},
{Number: 3, Job: "instances", Status: "success", SHA: "aaa", Ref: "main", CreatedAt: "2026-09-19T06:00:00Z"},
{Number: 2, Job: "lint", Status: "success", SHA: "aaa", Ref: "main", CreatedAt: "2026-09-12T11:20:03Z"},
{Number: 1, Job: "unit", Status: "success", SHA: "aaa", Ref: "main", CreatedAt: "2026-09-12T11:20:03Z"},
}
var sb strings.Builder
filter := buildFilter{}
err := web.Render(&sb, "builds.html", struct {
repoPage
Builds []control.BuildOut
Jobs []control.JobOut
Runs []buildRun
Filter buildFilter
Facets []facetGroup
Refs []string
Older string
CanWrite bool
Notice string
}{
testRepoPage(), builds, nil, groupRuns(builds), filter, nil,
distinctRefs(builds, filter.Ref), "", true, "",
})
if err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(sb.String(), "4 builds in 3 runs") {
t.Errorf("builds.html count line: %q", sb.String())
}
}
// A run row led with a ten-character sha and nothing said what the commit
// was (#241). The subject leads now, the sha follows as metadata, and a
// build whose commit is gone falls back to the sha alone.
func TestBuildsPageLeadsWithTheSubject(t *testing.T) {
builds := []control.BuildOut{
{Number: 2, Job: "unit", Status: "success", SHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b",
Ref: "main", CreatedAt: "2026-09-20T06:00:00Z", Subject: "runner: cap a build's container"},
{Number: 1, Job: "unit", Status: "failure", SHA: "aa11bb22cc33dd44ee55ff6677889900aabbccdd",
Ref: "main", CreatedAt: "2026-09-19T06:00:00Z"},
}
out := renderBuilds(t, builds, buildFilter{}, "?cursor=abc")
if !strings.Contains(out, "runner: cap a build's container") {
t.Errorf("builds.html does not lead with the subject:\n%s", out)
}
if !strings.Contains(out, "ff6271a9d4") {
t.Errorf("builds.html drops the sha from the metadata line:\n%s", out)
}
// The commit is gone, so the sha is all there is to name the row by.
if !strings.Contains(out, ">aa11bb22cc") {
t.Errorf("a subjectless run does not fall back to its sha:\n%s", out)
}
}
// Filtering appeared to raise the run count because the list was capped
// at 50 with the window unstated (#244). The page is paged now: the count
// says it counts this page, and the link to the next one carries every
// filter.
func TestBuildsPagePagesAndSaysSo(t *testing.T) {
builds := []control.BuildOut{{Number: 1, Job: "unit", Status: "failure", SHA: "aaa", Ref: "main", CreatedAt: "2026-09-19T06:00:00Z"}}
out := renderBuilds(t, builds, buildFilter{Status: "failure"}, "?cursor=abc&status=failure")
if !strings.Contains(out, "1 build in 1 run on this page") {
t.Errorf("count line does not name the page:\n%s", out)
}
if !strings.Contains(out, `href="?cursor=abc&status=failure"`) {
t.Errorf("pager link missing or drops the filter:\n%s", out)
}
// With everything on one page the count is the whole count.
if out := renderBuilds(t, builds, buildFilter{}, ""); strings.Contains(out, "on this page") {
t.Errorf("an unpaged listing still hedges the count:\n%s", out)
}
}
func TestOlderBuildsCarriesFilters(t *testing.T) {
if got := olderBuilds(buildFilter{Ref: "main"}, ""); got != "" {
t.Errorf("no next cursor should mean no link, got %q", got)
}
got := olderBuilds(buildFilter{Ref: "feature/x", Status: "failure", Job: "unit"}, "c1")
for _, want := range []string{"cursor=c1", "ref=feature%2Fx", "status=failure", "job=unit"} {
if !strings.Contains(got, want) {
t.Errorf("olderBuilds = %q, missing %q", got, want)
}
}
}
func renderBuilds(t *testing.T, builds []control.BuildOut, filter buildFilter, older string) string {
t.Helper()
var sb strings.Builder
err := web.Render(&sb, "builds.html", struct {
repoPage
Builds []control.BuildOut
Jobs []control.JobOut
Runs []buildRun
Filter buildFilter
Facets []facetGroup
Refs []string
Older string
CanWrite bool
Notice string
}{
testRepoPage(), builds, nil, groupRuns(builds), filter, nil,
distinctRefs(builds, filter.Ref), older, true, "",
})
if err != nil {
t.Fatalf("render: %v", err)
}
return sb.String()
}
// A failed build's page folds its log by step, opens the step that
// failed and links to it; no JavaScript (#266).
func TestBuildPageFoldsStepsAndOpensFailure(t *testing.T) {
b := control.BuildOut{Number: 61, Job: "test", Status: "failure",
SHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b", Ref: "main",
CreatedAt: "2026-08-28T04:42:54Z", FinishedAt: "2026-08-28T04:53:50Z", DurationS: 656,
Steps: []string{"go build ./...", "go test ./..."}, FailedStep: 2, FailedReason: "exit 1"}
log := "$ git clone x (ff6271a9d4)\n$ go build ./...\n$ go test ./...\n--- FAIL: TestCLI\nstep 2/2 failed: exit 1\n"
v := buildView{repoPage: testRepoPage(), Build: b, Log: log, Duration: "10m56s"}
v.Steps, v.Failed = logSteps(log, b)
var sb strings.Builder
if err := web.Render(&sb, "build.html", v); err != nil {
t.Fatalf("render: %v", err)
}
out := sb.String()
for _, want := range []string{
``,
"step 2/2", "go test ./...", `href="#failed"`, "Jump to failure",
"ran 10m56s", "--- FAIL: TestCLI",
} {
if !strings.Contains(out, want) {
t.Errorf("build.html missing %q", want)
}
}
if n := strings.Count(out, `class="difffold buildstep"`); n != 3 {
t.Errorf("%d step folds, want 3 (setup and two steps)", n)
}
if n := strings.Count(out, `id="failed"`); n != 1 {
t.Errorf("%d failed anchors, want 1", n)
}
}
// A multi-line step's fold summary shows only its first line, as build
// show does; the whole step is in the log (#266).
func TestBuildPageStepSummaryIsFirstLine(t *testing.T) {
step := "set -e\ngo test ./..."
b := control.BuildOut{Number: 62, Job: "test", Status: "failure",
SHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b", Ref: "main",
CreatedAt: "2026-08-28T04:42:54Z", FinishedAt: "2026-08-28T04:43:54Z", DurationS: 60,
Steps: []string{step}, FailedStep: 1, FailedReason: "exit 1"}
log := "$ git clone x (ff6271a9d4)\n$ " + step + "\n--- FAIL: TestCLI\nstep 1/1 failed: exit 1\n"
v := buildView{repoPage: testRepoPage(), Build: b, Log: log, Duration: "1m0s"}
v.Steps, v.Failed = logSteps(log, b)
var sb strings.Builder
if err := web.Render(&sb, "build.html", v); err != nil {
t.Fatalf("render: %v", err)
}
out := sb.String()
if !strings.Contains(out, "set -e") {
t.Errorf("summary does not show the step's first line:\n%s", out)
}
if strings.Contains(out, "set -e\ngo test") {
t.Errorf("summary shows the whole multi-line step")
}
}
// The Live note says the page updates itself, in plain words, rather
// than the more technical "streams here" (#271).
func TestBuildPageLiveNoteSaysItUpdatesItself(t *testing.T) {
var sb strings.Builder
if err := web.Render(&sb, "build.html", buildView{
repoPage: testRepoPage(), Live: true,
}); err != nil {
t.Fatalf("render: %v", err)
}
if !strings.Contains(sb.String(), "This page updates itself") {
t.Error(`Live note does not say the page updates itself`)
}
}