internal/httpd/buildpages_test.go

v1.43.1
gitbay/internal/httpd/buildpages_test.go history · blame · raw

365 lines · 12559 bytes

  1package httpd
  2
  3import (
  4	"gitbay.org/gitbay/internal/control"
  5	"strings"
  6	"testing"
  7
  8	"gitbay.org/gitbay/internal/store"
  9	"gitbay.org/gitbay/internal/web"
 10)
 11
 12func testRepoPage() repoPage {
 13	var p repoPage
 14	p.Repo = store.Repo{OwnerName: "krz", Name: "gitbay", DefaultBranch: "main"}
 15	p.Host = "gitbay.org"
 16	return p
 17}
 18
 19// The build pages read their data from the build commands' JSON now, not
 20// from store.Build. A renamed field would be a blank cell rather than a
 21// compile error, so render both pages and look for the values.
 22func TestBuildsPageRendersCommandOutput(t *testing.T) {
 23	var sb strings.Builder
 24	builds := []control.BuildOut{{
 25		Number: 60, Job: "build", Status: "success",
 26		SHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b",
 27		Ref: "cli-coverage", CreatedAt: "2026-08-28T04:42:54Z",
 28	}}
 29	jobs := []control.JobOut{{Name: "build"}, {Name: "nightly", Schedule: "0 3 * * *"}}
 30	filter := buildFilter{}
 31	err := web.Render(&sb, "builds.html", struct {
 32		repoPage
 33		Builds   []control.BuildOut
 34		Jobs     []control.JobOut
 35		Runs     []buildRun
 36		Filter   buildFilter
 37		Facets   []facetGroup
 38		Refs     []string
 39		Older    string
 40		CanWrite bool
 41		Notice   string
 42	}{
 43		testRepoPage(), builds, jobs, groupRuns(builds), filter, nil,
 44		distinctRefs(builds, filter.Ref), "", true, "",
 45	})
 46	if err != nil {
 47		t.Fatalf("render: %v", err)
 48	}
 49	out := sb.String()
 50	for _, want := range []string{
 51		"build", "success", "cli-coverage", "ff6271a9d4",
 52		`value="build"`, `value="nightly"`, "schedule 0 3 * * *",
 53	} {
 54		if !strings.Contains(out, want) {
 55			t.Errorf("builds.html missing %q", want)
 56		}
 57	}
 58}
 59
 60// A writer sees the instruction to add CI; a reader without push access
 61// sees only the fact, since the instruction is not theirs to act on
 62// (#270).
 63func TestBuildsEmptyStateOmitsInstructionForReaders(t *testing.T) {
 64	var sb strings.Builder
 65	filter := buildFilter{}
 66	err := web.Render(&sb, "builds.html", struct {
 67		repoPage
 68		Builds   []control.BuildOut
 69		Jobs     []control.JobOut
 70		Runs     []buildRun
 71		Filter   buildFilter
 72		Facets   []facetGroup
 73		Refs     []string
 74		Older    string
 75		CanWrite bool
 76		Notice   string
 77	}{
 78		testRepoPage(), nil, nil, nil, filter, nil, nil, "", false, "",
 79	})
 80	if err != nil {
 81		t.Fatalf("render: %v", err)
 82	}
 83	out := sb.String()
 84	if !strings.Contains(out, "no builds") {
 85		t.Error(`missing "no builds"`)
 86	}
 87	if strings.Contains(out, "ci.yml") {
 88		t.Error("a reader without push access should not see the push instruction")
 89	}
 90}
 91
 92// A writer with zero builds gets the one instruction the page can give
 93// them (#270).
 94func TestBuildsEmptyStateShowsInstructionForWriters(t *testing.T) {
 95	var sb strings.Builder
 96	filter := buildFilter{}
 97	err := web.Render(&sb, "builds.html", struct {
 98		repoPage
 99		Builds   []control.BuildOut
100		Jobs     []control.JobOut
101		Runs     []buildRun
102		Filter   buildFilter
103		Facets   []facetGroup
104		Refs     []string
105		Older    string
106		CanWrite bool
107		Notice   string
108	}{
109		testRepoPage(), nil, nil, nil, filter, nil, nil, "", true, "",
110	})
111	if err != nil {
112		t.Fatalf("render: %v", err)
113	}
114	out := sb.String()
115	if !strings.Contains(out, "no builds") {
116		t.Error(`missing "no builds"`)
117	}
118	if !strings.Contains(out, "push a commit with a .gitbay/ci.yml") {
119		t.Error("a writer with no builds should see the push instruction")
120	}
121}
122
123// The badge section offers the snippet for a README.md and a README.org.
124func TestBuildsPageBadgeSnippetsMarkdownAndOrg(t *testing.T) {
125	var sb strings.Builder
126	rp := testRepoPage()
127	err := web.Render(&sb, "builds.html", struct {
128		repoPage
129		Builds   []control.BuildOut
130		Jobs     []control.JobOut
131		Runs     []buildRun
132		Filter   buildFilter
133		Facets   []facetGroup
134		Refs     []string
135		Older    string
136		CanWrite bool
137		Notice   string
138	}{
139		rp, nil, nil, nil, buildFilter{}, nil, nil, "", false, "",
140	})
141	if err != nil {
142		t.Fatalf("render: %v", err)
143	}
144	base := "https://" + rp.Host + "/" + rp.Repo.OwnerName + "/" + rp.Repo.Name
145	for _, want := range []string{
146		"[![build](" + base + "/badge/build.svg)](" + base + "/builds)",
147		"[[" + base + "/builds][" + base + "/badge/build.svg]]",
148	} {
149		if !strings.Contains(sb.String(), want) {
150			t.Errorf("badge section lacks %s", want)
151		}
152	}
153}
154
155func TestBuildPageRendersCommandOutput(t *testing.T) {
156	var sb strings.Builder
157	err := web.Render(&sb, "build.html", buildView{
158		repoPage: testRepoPage(),
159		Build: control.BuildOut{
160			Number: 60, Job: "build", Status: "success",
161			SHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b", Ref: "cli-coverage",
162			CreatedAt: "2026-08-28T04:42:54Z", FinishedAt: "2026-08-28T04:43:06Z",
163		},
164		Log:      "step 1 ok",
165		CanWrite: true,
166	})
167	if err != nil {
168		t.Fatalf("render: %v", err)
169	}
170	out := sb.String()
171	for _, want := range []string{
172		"Build 60", "success", "build on cli-coverage", "ff6271a9d4",
173		"2026-08-28 04:42", "2026-08-28 04:43", "step 1 ok",
174	} {
175		if !strings.Contains(out, want) {
176			t.Errorf("build.html missing %q", want)
177		}
178	}
179	// A finished build offers no cancel control, even to a writer.
180	if strings.Contains(out, "/cancel") {
181		t.Error("build.html offers cancel on a finished build")
182	}
183}
184
185// The count under the heading said "15 runs" while listing 23 builds
186// (#240). It names both numbers now, so neither is mistaken for the other.
187func TestBuildsPageCountsBuildsAndRuns(t *testing.T) {
188	builds := []control.BuildOut{
189		{Number: 4, Job: "instances", Status: "success", SHA: "aaa", Ref: "main", CreatedAt: "2026-09-20T06:00:00Z"},
190		{Number: 3, Job: "instances", Status: "success", SHA: "aaa", Ref: "main", CreatedAt: "2026-09-19T06:00:00Z"},
191		{Number: 2, Job: "lint", Status: "success", SHA: "aaa", Ref: "main", CreatedAt: "2026-09-12T11:20:03Z"},
192		{Number: 1, Job: "unit", Status: "success", SHA: "aaa", Ref: "main", CreatedAt: "2026-09-12T11:20:03Z"},
193	}
194	var sb strings.Builder
195	filter := buildFilter{}
196	err := web.Render(&sb, "builds.html", struct {
197		repoPage
198		Builds   []control.BuildOut
199		Jobs     []control.JobOut
200		Runs     []buildRun
201		Filter   buildFilter
202		Facets   []facetGroup
203		Refs     []string
204		Older    string
205		CanWrite bool
206		Notice   string
207	}{
208		testRepoPage(), builds, nil, groupRuns(builds), filter, nil,
209		distinctRefs(builds, filter.Ref), "", true, "",
210	})
211	if err != nil {
212		t.Fatalf("render: %v", err)
213	}
214	if !strings.Contains(sb.String(), "4 builds in 3 runs") {
215		t.Errorf("builds.html count line: %q", sb.String())
216	}
217}
218
219// A run row led with a ten-character sha and nothing said what the commit
220// was (#241). The subject leads now, the sha follows as metadata, and a
221// build whose commit is gone falls back to the sha alone.
222func TestBuildsPageLeadsWithTheSubject(t *testing.T) {
223	builds := []control.BuildOut{
224		{Number: 2, Job: "unit", Status: "success", SHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b",
225			Ref: "main", CreatedAt: "2026-09-20T06:00:00Z", Subject: "runner: cap a build's container"},
226		{Number: 1, Job: "unit", Status: "failure", SHA: "aa11bb22cc33dd44ee55ff6677889900aabbccdd",
227			Ref: "main", CreatedAt: "2026-09-19T06:00:00Z"},
228	}
229	out := renderBuilds(t, builds, buildFilter{}, "?cursor=abc")
230	if !strings.Contains(out, "runner: cap a build's container") {
231		t.Errorf("builds.html does not lead with the subject:\n%s", out)
232	}
233	if !strings.Contains(out, "<code>ff6271a9d4</code>") {
234		t.Errorf("builds.html drops the sha from the metadata line:\n%s", out)
235	}
236	// The commit is gone, so the sha is all there is to name the row by.
237	if !strings.Contains(out, ">aa11bb22cc</a>") {
238		t.Errorf("a subjectless run does not fall back to its sha:\n%s", out)
239	}
240}
241
242// Filtering appeared to raise the run count because the list was capped
243// at 50 with the window unstated (#244). The page is paged now: the count
244// says it counts this page, and the link to the next one carries every
245// filter.
246func TestBuildsPagePagesAndSaysSo(t *testing.T) {
247	builds := []control.BuildOut{{Number: 1, Job: "unit", Status: "failure", SHA: "aaa", Ref: "main", CreatedAt: "2026-09-19T06:00:00Z"}}
248	out := renderBuilds(t, builds, buildFilter{Status: "failure"}, "?cursor=abc&status=failure")
249	if !strings.Contains(out, "1 build in 1 run on this page") {
250		t.Errorf("count line does not name the page:\n%s", out)
251	}
252	if !strings.Contains(out, `href="?cursor=abc&amp;status=failure"`) {
253		t.Errorf("pager link missing or drops the filter:\n%s", out)
254	}
255	// With everything on one page the count is the whole count.
256	if out := renderBuilds(t, builds, buildFilter{}, ""); strings.Contains(out, "on this page") {
257		t.Errorf("an unpaged listing still hedges the count:\n%s", out)
258	}
259}
260
261func TestOlderBuildsCarriesFilters(t *testing.T) {
262	if got := olderBuilds(buildFilter{Ref: "main"}, ""); got != "" {
263		t.Errorf("no next cursor should mean no link, got %q", got)
264	}
265	got := olderBuilds(buildFilter{Ref: "feature/x", Status: "failure", Job: "unit"}, "c1")
266	for _, want := range []string{"cursor=c1", "ref=feature%2Fx", "status=failure", "job=unit"} {
267		if !strings.Contains(got, want) {
268			t.Errorf("olderBuilds = %q, missing %q", got, want)
269		}
270	}
271}
272
273func renderBuilds(t *testing.T, builds []control.BuildOut, filter buildFilter, older string) string {
274	t.Helper()
275	var sb strings.Builder
276	err := web.Render(&sb, "builds.html", struct {
277		repoPage
278		Builds   []control.BuildOut
279		Jobs     []control.JobOut
280		Runs     []buildRun
281		Filter   buildFilter
282		Facets   []facetGroup
283		Refs     []string
284		Older    string
285		CanWrite bool
286		Notice   string
287	}{
288		testRepoPage(), builds, nil, groupRuns(builds), filter, nil,
289		distinctRefs(builds, filter.Ref), older, true, "",
290	})
291	if err != nil {
292		t.Fatalf("render: %v", err)
293	}
294	return sb.String()
295}
296
297// A failed build's page folds its log by step, opens the step that
298// failed and links to it; no JavaScript (#266).
299func TestBuildPageFoldsStepsAndOpensFailure(t *testing.T) {
300	b := control.BuildOut{Number: 61, Job: "test", Status: "failure",
301		SHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b", Ref: "main",
302		CreatedAt: "2026-08-28T04:42:54Z", FinishedAt: "2026-08-28T04:53:50Z", DurationS: 656,
303		Steps: []string{"go build ./...", "go test ./..."}, FailedStep: 2, FailedReason: "exit 1"}
304	log := "$ git clone x (ff6271a9d4)\n$ go build ./...\n$ go test ./...\n--- FAIL: TestCLI\nstep 2/2 failed: exit 1\n"
305	v := buildView{repoPage: testRepoPage(), Build: b, Log: log, Duration: "10m56s"}
306	v.Steps, v.Failed = logSteps(log, b)
307	var sb strings.Builder
308	if err := web.Render(&sb, "build.html", v); err != nil {
309		t.Fatalf("render: %v", err)
310	}
311	out := sb.String()
312	for _, want := range []string{
313		`<details class="difffold buildstep" id="failed" open>`,
314		"step 2/2", "<code>go test ./...</code>", `href="#failed"`, "Jump to failure",
315		"ran 10m56s", "--- FAIL: TestCLI",
316	} {
317		if !strings.Contains(out, want) {
318			t.Errorf("build.html missing %q", want)
319		}
320	}
321	if n := strings.Count(out, `class="difffold buildstep"`); n != 3 {
322		t.Errorf("%d step folds, want 3 (setup and two steps)", n)
323	}
324	if n := strings.Count(out, `id="failed"`); n != 1 {
325		t.Errorf("%d failed anchors, want 1", n)
326	}
327}
328
329// A multi-line step's fold summary shows only its first line, as build
330// show does; the whole step is in the log (#266).
331func TestBuildPageStepSummaryIsFirstLine(t *testing.T) {
332	step := "set -e\ngo test ./..."
333	b := control.BuildOut{Number: 62, Job: "test", Status: "failure",
334		SHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b", Ref: "main",
335		CreatedAt: "2026-08-28T04:42:54Z", FinishedAt: "2026-08-28T04:43:54Z", DurationS: 60,
336		Steps: []string{step}, FailedStep: 1, FailedReason: "exit 1"}
337	log := "$ git clone x (ff6271a9d4)\n$ " + step + "\n--- FAIL: TestCLI\nstep 1/1 failed: exit 1\n"
338	v := buildView{repoPage: testRepoPage(), Build: b, Log: log, Duration: "1m0s"}
339	v.Steps, v.Failed = logSteps(log, b)
340	var sb strings.Builder
341	if err := web.Render(&sb, "build.html", v); err != nil {
342		t.Fatalf("render: %v", err)
343	}
344	out := sb.String()
345	if !strings.Contains(out, "<code>set -e</code>") {
346		t.Errorf("summary does not show the step's first line:\n%s", out)
347	}
348	if strings.Contains(out, "<code>set -e\ngo test") {
349		t.Errorf("summary shows the whole multi-line step")
350	}
351}
352
353// The Live note says the page updates itself, in plain words, rather
354// than the more technical "streams here" (#271).
355func TestBuildPageLiveNoteSaysItUpdatesItself(t *testing.T) {
356	var sb strings.Builder
357	if err := web.Render(&sb, "build.html", buildView{
358		repoPage: testRepoPage(), Live: true,
359	}); err != nil {
360		t.Fatalf("render: %v", err)
361	}
362	if !strings.Contains(sb.String(), "This page updates itself") {
363		t.Error(`Live note does not say the page updates itself`)
364	}
365}