package web
import (
"net/http/httptest"
"regexp"
"strings"
"testing"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/ci"
"craftdesign.group/quarry/internal/ops"
)
// hookAttrs reads the data-* attributes of every element carrying
// data-hook="name", in page order.
func hookAttrs(body, name string) []map[string]string {
tag := regexp.MustCompile(`<[a-z]+[^>]*data-hook="` + regexp.QuoteMeta(name) + `"[^>]*>`)
attr := regexp.MustCompile(`(data-[a-z-]+)="([^"]*)"`)
var out []map[string]string
for _, t := range tag.FindAllString(body, -1) {
m := map[string]string{}
for _, a := range attr.FindAllStringSubmatch(t, -1) {
m[a[1]] = a[2]
}
out = append(out, m)
}
return out
}
func stepHook(t *testing.T, body, step string) map[string]string {
t.Helper()
for _, h := range hookAttrs(body, "run-step") {
if h["data-step"] == step {
return h
}
}
t.Fatalf("no run-step hook for %q", step)
return nil
}
var failedThenRunning = map[int][]ci.LogChunk{
1: {{Seq: 1, Stream: "out", Data: "--- FAIL: TestFlake"}, {Seq: 2, Stream: "marker", Data: "failed"}},
2: {{Seq: 1, Stream: "out", Data: "=== RUN TestFlake"}},
}
// #283: a step on its second of three attempts says so, and says the
// one before it failed. The verdict is still open, so it still runs.
func TestRunPageNamesTheAttemptOfARetriedStep(t *testing.T) {
eng := fakeEngine{
run: ci.Run{RunID: "run-a", Workflow: "quarry", Status: "running",
Steps: map[string]ci.Step{"test": {Status: "running", Attempts: 2, StartedAt: "2026-08-29T01:00:01Z"}}},
attempts: map[string]map[int][]ci.LogChunk{"test": failedThenRunning},
limits: map[string]int{"test": 3}}
srv := runPageServer(t, eng, []api.CIRun{{RunID: "run-a", Checkin: "abc", Workflow: "quarry", Pipeline: "ci/quarry.json", Status: "running", Started: "2026-08-29T01:00:00Z"}})
h := stepHook(t, asDan(t, srv, "/r/alpha/ci/run/run-a"), "test")
if h["data-attempt"] != "2" || h["data-attempts"] != "3" || h["data-prev-outcome"] != "fail" || h["data-state"] != "run" {
t.Fatalf("step hook = %v, want attempt 2 of 3, previous fail, running", h)
}
}
// #283: the last allowed attempt failed while the engine still reports
// the step — and another — running. Nothing can change the verdict, so
// nothing spins: the exhausted step is failed, the other is stopped.
func TestRunPageStopsTheSpinnerOnceTheVerdictIsIn(t *testing.T) {
eng := fakeEngine{
run: ci.Run{RunID: "run-b", Workflow: "quarry", Status: "running",
Steps: map[string]ci.Step{
"test": {Status: "running", Attempts: 3, StartedAt: "2026-08-29T01:00:01Z"},
"fmt": {Status: "running", Attempts: 1, StartedAt: "2026-08-29T01:00:02Z"},
}},
attempts: map[string]map[int][]ci.LogChunk{
"test": {
1: {{Seq: 1, Stream: "marker", Data: "failed"}},
2: {{Seq: 1, Stream: "marker", Data: "failed"}},
3: {{Seq: 1, Stream: "out", Data: "--- FAIL: TestFlake"}, {Seq: 2, Stream: "marker", Data: "failed"}},
},
"fmt": {1: {{Seq: 1, Stream: "out", Data: "gofmt -l"}}},
},
limits: map[string]int{"test": 3, "fmt": 3}}
srv := runPageServer(t, eng, []api.CIRun{{RunID: "run-b", Checkin: "abc", Workflow: "quarry", Pipeline: "ci/quarry.json", Status: "running", Started: "2026-08-29T01:00:00Z"}})
body := asDan(t, srv, "/r/alpha/ci/run/run-b")
if h := stepHook(t, body, "test"); h["data-state"] != "fail" || h["data-attempt"] != "3" || h["data-attempts"] != "3" {
t.Errorf("exhausted step = %v, want fail on attempt 3 of 3", h)
}
if h := stepHook(t, body, "fmt"); h["data-state"] == "run" {
t.Errorf("a step that can no longer change the verdict still spins: %v", h)
}
}
// #283: the engine has failed the run, but a lane still reports a step
// running. The run is over; the step does not spin.
func TestRunPageDoesNotSpinInAFinishedRun(t *testing.T) {
eng := fakeEngine{
run: ci.Run{RunID: "run-c", Workflow: "quarry", Status: "failed",
Steps: map[string]ci.Step{
"fmt": {Status: "failed", Attempts: 3, StartedAt: "2026-08-29T01:00:01Z"},
"test": {Status: "running", Attempts: 2, StartedAt: "2026-08-29T01:00:02Z"},
}},
attempts: map[string]map[int][]ci.LogChunk{"test": failedThenRunning},
chunks: map[string][]ci.LogChunk{"fmt": {{Seq: 1, Stream: "marker", Data: "failed"}}},
limits: map[string]int{"test": 3, "fmt": 3}}
srv := runPageServer(t, eng, []api.CIRun{{RunID: "run-c", Checkin: "abc", Workflow: "quarry", Pipeline: "ci/quarry.json", Status: "failed", Started: "2026-08-29T01:00:00Z"}})
if h := stepHook(t, asDan(t, srv, "/r/alpha/ci/run/run-c"), "test"); h["data-state"] == "run" {
t.Fatalf("a finished run still spins a step: %v", h)
}
}
func landingWithEngine(t *testing.T, eng EngineViews, status string) string {
t.Helper()
ix := landingIndex(t, false)
r := ops.NewRegistry()
deps := api.Deps{Version: "v",
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}}),
Checks: fakeChecks{required: map[string][]string{"trunk": {"quarry"}}},
CI: fakeCI{runs: []api.CIRun{
{RunID: "runretry00000000", Workflow: "quarry", Pipeline: "ci/quarry.json", Status: status, Started: "2026-08-29"},
}}}
if err := api.RegisterAll(r, deps); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(HandlerWith(r, nil, nil,
[]Option{WithSessions(fakeSessions{user: "dan"}), WithCaps(stubCaps{letters: "i8"}), WithEngine(eng)}))
t.Cleanup(srv.Close)
return asDan(t, srv, "/r/alpha/landing/"+strings.Repeat("a1", 6))
}
var checksIcon = regexp.MustCompile(`<details class="d-row" id="l-checks"[^>]*>\s*<summary><svg class="ic d-ic (\w+)"`)
// #283, the landing card: a check whose step is mid-retry names the
// attempt and the failed one before it, and is still running.
func TestLandingCheckNamesTheAttempt(t *testing.T) {
eng := fakeEngine{
run: ci.Run{RunID: "runretry00000000", Workflow: "quarry", Status: "running",
Steps: map[string]ci.Step{"test": {Status: "running", Attempts: 2}}},
attempts: map[string]map[int][]ci.LogChunk{"test": failedThenRunning},
limits: map[string]int{"test": 3}}
body := landingWithEngine(t, eng, "running")
steps := hookAttrs(body, "check-step")
if len(steps) != 1 || steps[0]["data-attempt"] != "2" || steps[0]["data-attempts"] != "3" || steps[0]["data-prev-outcome"] != "fail" {
t.Fatalf("check-step hooks = %v, want attempt 2 of 3, previous fail", steps)
}
if rows := hookAttrs(body, "check-row"); len(rows) != 1 || rows[0]["data-state"] != "running" {
t.Fatalf("check-row hooks = %v, want one running", rows)
}
}
// #283, the landing card's gate line: the last allowed attempt failed,
// so the check is red and the gate stops waiting, whatever the engine
// still says about the lane.
func TestLandingGateStopsWaitingOnceTheVerdictIsIn(t *testing.T) {
eng := fakeEngine{
run: ci.Run{RunID: "runretry00000000", Workflow: "quarry", Status: "running",
Steps: map[string]ci.Step{"test": {Status: "running", Attempts: 3}}},
attempts: map[string]map[int][]ci.LogChunk{"test": {
2: {{Seq: 1, Stream: "marker", Data: "failed"}},
3: {{Seq: 1, Stream: "out", Data: "--- FAIL: TestFlake"}, {Seq: 2, Stream: "marker", Data: "failed"}},
}},
limits: map[string]int{"test": 3}}
body := landingWithEngine(t, eng, "running")
if rows := hookAttrs(body, "check-row"); len(rows) != 1 || rows[0]["data-state"] != "fail" {
t.Fatalf("check-row hooks = %v, want one fail", rows)
}
if m := checksIcon.FindStringSubmatch(body); m == nil || m[1] != "fail" {
t.Fatalf("gate line state = %v, want fail", m)
}
if strings.Contains(body, "/landing.sse/") {
t.Error("the card still follows a run whose verdict is in")
}
}