package web
import (
"errors"
"net/http/httptest"
"regexp"
"strings"
"testing"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/ci"
"craftdesign.group/quarry/internal/ops"
)
type attemptHook struct {
attempt, outcome string
open bool
body string
}
var attemptOpenTag = regexp.MustCompile(`<details[^>]*data-hook="step-attempt"[^>]*>`)
var attemptAttrRe = regexp.MustCompile(`(data-attempt|data-outcome)="([^"]*)"`)
// attemptHooks reads every step-attempt block on the page, in order,
// with the markup up to the next block as its body.
func attemptHooks(body string) []attemptHook {
locs := attemptOpenTag.FindAllStringIndex(body, -1)
var out []attemptHook
for i, loc := range locs {
tag := body[loc[0]:loc[1]]
h := attemptHook{open: regexp.MustCompile(`\sopen[\s>]`).MatchString(tag)}
for _, m := range attemptAttrRe.FindAllStringSubmatch(tag, -1) {
if m[1] == "data-attempt" {
h.attempt = m[2]
} else {
h.outcome = m[2]
}
}
end := len(body)
if i+1 < len(locs) {
end = locs[i+1][0]
}
h.body = body[loc[1]:end]
out = append(out, h)
}
return out
}
func assertRetriedStep(t *testing.T, body string) {
t.Helper()
hooks := attemptHooks(body)
if len(hooks) != 2 {
t.Fatalf("step-attempt blocks = %d, want 2", len(hooks))
}
one, two := hooks[0], hooks[1]
if one.attempt != "1" || one.outcome != "fail" || !one.open {
t.Errorf("attempt 1 = %+v, want attempt=1 outcome=fail open", one)
}
if !strings.Contains(one.body, "--- FAIL: TestFlake") {
t.Errorf("attempt 1 does not carry its failing output")
}
if two.attempt != "2" || two.outcome != "pass" || two.open {
t.Errorf("attempt 2 = %+v, want attempt=2 outcome=pass closed", two)
}
if !strings.Contains(two.body, "--- PASS: TestFlake") {
t.Errorf("attempt 2 does not carry its output")
}
}
// #269: a step that failed its first attempt and passed its second
// shows both, from the drained record, the failure expanded.
func TestRunPageShowsEveryAttemptFromTheRecord(t *testing.T) {
ix := landingIndex(t, false)
r := ops.NewRegistry()
deps := api.Deps{Version: "v",
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}}),
CI: fakeCI{runs: []api.CIRun{{RunID: "run-7", Checkin: "abc", Workflow: "w",
Pipeline: "ci/test.json", Status: "completed", Started: "2026-08-29T01:00:00Z"}}}}
if err := api.RegisterAll(r, deps); err != nil {
t.Fatal(err)
}
eng := fakeEngine{run: ci.Run{RunID: "run-7", Status: "completed",
Steps: map[string]ci.Step{"quarry.test": {Status: "completed", Attempts: 2, StartedAt: "2026-08-29T01:00:01Z"}}}}
store := fakeLogStore{chunks: []ci.LogChunk{
{Seq: 1, Stream: "out", Data: "--- FAIL: TestFlake", Step: "quarry.test", Attempt: 1},
{Seq: 2, Stream: "marker", Data: "failed", Step: "quarry.test", Attempt: 1},
{Seq: 1, Stream: "out", Data: "--- PASS: TestFlake", Step: "quarry.test", Attempt: 2},
{Seq: 2, Stream: "marker", Data: "completed", Step: "quarry.test", Attempt: 2}}}
srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{
WithSessions(fakeSessions{user: "dan"}), WithCaps(stubCaps{letters: "i8"}),
WithEngine(eng), WithCILogs(store)}))
t.Cleanup(srv.Close)
assertRetriedStep(t, asDan(t, srv, "/r/alpha/ci/run/run-7"))
}
// The same from a run the engine still holds: each attempt is read
// from its own lane, not only the latest.
func TestRunPageShowsEveryAttemptFromTheEngine(t *testing.T) {
eng := fakeEngine{
run: ci.Run{RunID: "run-8", Status: "completed",
Steps: map[string]ci.Step{"quarry.test": {Status: "completed", Attempts: 2, StartedAt: "2026-08-29T01:00:01Z"}}},
attempts: map[string]map[int][]ci.LogChunk{"quarry.test": {
1: {{Seq: 1, Stream: "out", Data: "--- FAIL: TestFlake"}, {Seq: 2, Stream: "marker", Data: "failed"}},
2: {{Seq: 1, Stream: "out", Data: "--- PASS: TestFlake"}, {Seq: 2, Stream: "marker", Data: "completed"}},
}}}
srv := runPageServer(t, eng, []api.CIRun{{RunID: "run-8", Checkin: "abc", Workflow: "w", Pipeline: "ci/test.json", Status: "completed", Started: "2026-08-29T01:00:00Z"}})
assertRetriedStep(t, asDan(t, srv, "/r/alpha/ci/run/run-8"))
}
// A retried-then-green step is not the step that broke a red run: the
// checks card and the run list name the step whose LAST attempt failed.
func TestFailTailJudgesAStepByItsLastAttempt(t *testing.T) {
store := fakeLogStore{chunks: []ci.LogChunk{
{Seq: 1, Stream: "out", Data: "--- FAIL: TestLint", Step: "quarry.fmt", Attempt: 1},
{Seq: 2, Stream: "marker", Data: "failed", Step: "quarry.fmt", Attempt: 1},
{Seq: 1, Stream: "out", Data: "--- FAIL: TestFlake", Step: "quarry.test", Attempt: 1},
{Seq: 2, Stream: "marker", Data: "failed", Step: "quarry.test", Attempt: 1},
{Seq: 1, Stream: "out", Data: "ok", Step: "quarry.test", Attempt: 2},
{Seq: 2, Stream: "marker", Data: "completed", Step: "quarry.test", Attempt: 2}}}
if step, _, _ := failTail(nil, store, "alpha", "run-1"); step != "quarry.fmt" {
t.Errorf("failTail step = %q, want quarry.fmt", step)
}
if step, _ := failSummary(store, "alpha", "run-1"); step != "quarry.fmt" {
t.Errorf("failSummary step = %q, want quarry.fmt", step)
}
}
// forgetfulEngine has lost the run: the page answers from the record.
type forgetfulEngine struct{ fakeEngine }
func (forgetfulEngine) RunDetail(string) (ci.Run, error) {
return ci.Run{}, errors.New("no such run")
}
var runStepIcon = regexp.MustCompile(`<details [^>]*class="d-row runstep"[^>]*>\s*<summary><svg class="ic d-ic (\w+)"`)
// Record-only, the engine having forgotten the run: a step that failed
// once and passed on retry is judged by its latest attempt — green —
// while the failed attempt still opens.
func TestRecordOnlyRunJudgesAStepByItsLastAttempt(t *testing.T) {
ix := landingIndex(t, false)
r := ops.NewRegistry()
deps := api.Deps{Version: "v",
Repos: api.NewRepos(map[string]*api.Repo{"alpha": {Index: ix}}),
CI: fakeCI{runs: []api.CIRun{{RunID: "run-6", Checkin: "abc", Workflow: "w",
Pipeline: "ci/test.json", Status: "completed", Started: "2026-08-29T01:00:00Z"}}}}
if err := api.RegisterAll(r, deps); err != nil {
t.Fatal(err)
}
store := fakeLogStore{chunks: []ci.LogChunk{
{Seq: 1, Stream: "out", Data: "--- FAIL: TestFlake", Step: "quarry.test", Attempt: 1},
{Seq: 2, Stream: "marker", Data: "failed", Step: "quarry.test", Attempt: 1},
{Seq: 1, Stream: "out", Data: "--- PASS: TestFlake", Step: "quarry.test", Attempt: 2},
{Seq: 2, Stream: "marker", Data: "completed", Step: "quarry.test", Attempt: 2}}}
srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{
WithSessions(fakeSessions{user: "dan"}), WithCaps(stubCaps{letters: "i8"}),
WithEngine(forgetfulEngine{}), WithCILogs(store)}))
t.Cleanup(srv.Close)
body := asDan(t, srv, "/r/alpha/ci/run/run-6")
icons := runStepIcon.FindAllStringSubmatch(body, -1)
if len(icons) != 1 || icons[0][1] != "pass" {
t.Fatalf("record-only step icons = %v, want one pass", icons)
}
assertRetriedStep(t, body)
}