package web
import (
"net/http/httptest"
"strings"
"testing"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/ci"
"craftdesign.group/quarry/internal/ops"
)
// fakeEngine answers the run page from fixtures.
type fakeEngine struct {
run ci.Run
chunks map[string][]ci.LogChunk
noLogs bool
}
func (f fakeEngine) RunDetail(string) (ci.Run, error) { return f.run, nil }
func (f fakeEngine) StepLogs(_, step string, _ int, _ bool) ([]ci.LogChunk, error) {
if f.noLogs {
return nil, ci.ErrNoLogs
}
return f.chunks[step], nil
}
func (f fakeEngine) Workers() ([]ci.Worker, error) { return nil, nil }
func runPageServer(t *testing.T, eng EngineViews, runs []api.CIRun) *httptest.Server {
t.Helper()
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: runs}}
if err := api.RegisterAll(r, deps); err != nil {
t.Fatal(err)
}
opts := []Option{WithSessions(fakeSessions{user: "dan"}), WithCaps(stubCaps{letters: "i"})}
if eng != nil {
opts = append(opts, WithEngine(eng))
}
srv := httptest.NewServer(HandlerWith(r, nil, nil, opts))
t.Cleanup(srv.Close)
return srv
}
// RFC 0009 §logs: the run page shows steps in order with durations,
// opens the failed step, tints stderr, records the failure marker, and
// prints the repro line.
func TestRunPageShowsStepsAndLogs(t *testing.T) {
eng := fakeEngine{
run: ci.Run{RunID: "run-1", Workflow: "quarry-e2e", Status: "failed",
CreatedAt: "2026-08-29T01:00:00Z", CompletedAt: "2026-08-29T01:03:30Z",
Input: map[string]any{"cloneUrl": "http://f/r/alpha", "checkin": "abc123"},
Steps: map[string]ci.Step{
"test": {Status: "completed", Attempts: 1, StartedAt: "2026-08-29T01:00:01Z", CompletedAt: "2026-08-29T01:01:00Z"},
"build": {Status: "failed", Attempts: 2, Error: "exit 1", StartedAt: "2026-08-29T01:01:01Z", CompletedAt: "2026-08-29T01:03:00Z"},
}},
chunks: map[string][]ci.LogChunk{
"build": {{Seq: 1, Stream: "out", Data: "compiling"},
{Seq: 2, Stream: "err", Data: "undefined: frob"},
{Seq: 3, Stream: "marker", Data: "failed"}},
"test": {{Seq: 1, Stream: "out", Data: "ok 12 tests"}},
}}
srv := runPageServer(t, eng, []api.CIRun{{RunID: "run-1", Checkin: "abc123", Workflow: "quarry-e2e", Pipeline: "ci/test.json", Status: "failed", Started: "2026-08-29T01:00:00Z"}})
body := asDan(t, srv, "/r/alpha/ci/run/run-1")
for _, want := range []string{
"took 3m30s", // run duration
"<strong>test</strong>", // steps present
"<strong>build</strong>", //
"59s", // per-step duration (#626)
"attempt 2", //
"exit 1", //
`runstep" open`, // failed step open
"undefined: frob", // stderr chunk
`class="ll ll-err"`, // tinted
`class="ll ll-mark"`, // marker recorded
"zit clone http://f/r/alpha work", // repro line
} {
if !strings.Contains(body, want) {
t.Fatalf("run page lacks %q", want)
}
}
// The passed step is folded.
if strings.Contains(body, `<details class="d-row runstep" open>
<summary><svg class="ic d-ic pass"`) {
t.Fatal("a green step rendered open")
}
}
// An engine that predates dagnats#624 says so instead of an empty pane.
func TestRunPageSaysWhenLogsAreNotServed(t *testing.T) {
eng := fakeEngine{noLogs: true,
run: ci.Run{RunID: "run-2", Status: "completed",
Steps: map[string]ci.Step{"test": {Status: "completed", Attempts: 1}}}}
srv := runPageServer(t, eng, []api.CIRun{{RunID: "run-2", Checkin: "abc", Workflow: "w", Pipeline: "p", Status: "completed", Started: "2026-08-29T01:00:00Z"}})
body := asDan(t, srv, "/r/alpha/ci/run/run-2")
if !strings.Contains(body, "does not serve logs yet (dagnats#624)") {
t.Fatal("the absence is not named")
}
}
// No engine at all: the forge's own record, said as that.
func TestRunPageWithoutAnEngine(t *testing.T) {
srv := runPageServer(t, nil, []api.CIRun{{RunID: "run-3", Checkin: "abc", Workflow: "w", Pipeline: "p", Status: "running", Started: "2026-08-29T01:00:00Z"}})
body := asDan(t, srv, "/r/alpha/ci/run/run-3")
if !strings.Contains(body, "No CI engine is configured") {
t.Fatal("the absence is not named")
}
}
// fakeLogStore is the forge's drained record in a map.
type fakeLogStore struct{ chunks []ci.LogChunk }
func (f fakeLogStore) DrainedLogs(string, string) ([]ci.LogChunk, bool) {
return f.chunks, len(f.chunks) > 0
}
// RFC 0009 §logs: the drained record answers first — a run whose hot
// lane has expired (or whose engine is gone) still shows its logs.
func TestRunPageReadsDrainedLogs(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-9", Checkin: "abc", Workflow: "w",
Pipeline: "ci/test.json", Status: "failed", Started: "2026-08-29T01:00:00Z"}}}}
if err := api.RegisterAll(r, deps); err != nil {
t.Fatal(err)
}
eng := fakeEngine{run: ci.Run{RunID: "run-9", Status: "failed",
Steps: map[string]ci.Step{"build": {Status: "failed", Attempts: 1}}}, noLogs: true}
store := fakeLogStore{chunks: []ci.LogChunk{
{Seq: 1, Stream: "out", Data: "compiling", Step: "build"},
{Seq: 2, Stream: "err", Data: "boom", Step: "build"}}}
srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{
WithSessions(fakeSessions{user: "dan"}), WithCaps(stubCaps{letters: "i"}),
WithEngine(eng), WithCILogs(store)}))
t.Cleanup(srv.Close)
body := asDan(t, srv, "/r/alpha/ci/run/run-9")
if !strings.Contains(body, "boom") || strings.Contains(body, "does not serve logs yet") {
t.Fatal("the drained record did not answer first")
}
}
// A cancelled run was stopped, not broken — superseded by a newer
// check-in, or called off by hand. It must not wear the failure badge,
// must not offer a "failing task" repro, and must not follow itself.
func TestCancelledRunIsNotDressedAsAFailure(t *testing.T) {
eng := fakeEngine{noLogs: true,
run: ci.Run{RunID: "run-c", Workflow: "quarry-e2e", Status: "cancelled",
Input: map[string]any{"cloneUrl": "http://f/r/alpha", "checkin": "abc123"},
Steps: map[string]ci.Step{"build": {Status: "cancelled", Attempts: 1}}}}
srv := runPageServer(t, eng, []api.CIRun{{RunID: "run-c", Checkin: "abc123", Workflow: "quarry-e2e", Pipeline: "ci/test.json", Status: "cancelled", Started: "2026-08-29T01:00:00Z"}})
body := asDan(t, srv, "/r/alpha/ci/run/run-c")
if !strings.Contains(body, ">cancelled</span>") {
t.Fatalf("no cancelled badge:\n%s", body)
}
// "ci-fail" alone lives in the stylesheet of every page; the chip
// usage is what must be absent.
for _, no := range []string{"tl-badge ci-fail", "Reproduce", "data-on-interval"} {
if strings.Contains(body, no) {
t.Errorf("cancelled run page contains %q", no)
}
}
}