Files
Vetting/internal/api/run_page_test.go
T
josh 19608bef1b
CI / Lint + build + test (push) Successful in 1m35s
Release / release (push) Successful in 23m47s
ui: split /hosts/{id} into host page + /runs/{runID} run page
Host page owns host metadata, full runs table with per-row stage strip,
in-flight banner, and empty-state CTA. Run page owns pipeline, active
step, logs, sub-steps, spec diffs, and hold banner with a breadcrumb
back to the host. Dashboard tile reverts to host-only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 20:37:57 -04:00

244 lines
8.0 KiB
Go

package api_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"vetting/internal/model"
"vetting/internal/store"
)
func runReq(runID int64) *http.Request {
req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/runs/%d", runID), nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("runID", fmt.Sprintf("%d", runID))
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
}
// defaultOpenStage returns the data-stage attr of the single
// `<details ... open data-stage="...">` rendered by ActiveStep.
// Rendered attribute order is fixed (class, then open?, then
// data-stage), so a tight substring match is safe.
func defaultOpenStage(body string) string {
re := regexp.MustCompile(`open data-stage="([^"]+)"`)
m := re.FindStringSubmatch(body)
if len(m) < 2 {
return ""
}
return m[1]
}
// TestRunPage_Pipeline: GET /runs/{id} emits the breadcrumb, run
// header, pipeline section, and active-step panels for every canonical
// stage. Sanity check for the run-page shell.
func TestRunPage_Pipeline(t *testing.T) {
ui, hosts, runs := setupPage(t)
ctx := context.Background()
id, err := hosts.Create(ctx, model.Host{
Name: "run-pipeline",
MAC: "aa:bb:cc:dd:ee:70",
WoLBroadcastIP: "10.0.0.255",
WoLPort: 9,
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
})
if err != nil {
t.Fatalf("create host: %v", err)
}
runID, err := runs.Create(ctx, id, "rp", false)
if err != nil {
t.Fatalf("create run: %v", err)
}
if err := ui.Stages.Seed(ctx, runID); err != nil {
t.Fatalf("seed stages: %v", err)
}
rr := httptest.NewRecorder()
ui.RunPage(rr, runReq(runID))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String())
}
body := rr.Body.String()
wantPipelineID := fmt.Sprintf(`id="pipeline-%d"`, runID)
if !strings.Contains(body, wantPipelineID) {
t.Fatalf("body missing %s", wantPipelineID)
}
wantHeader := fmt.Sprintf(`id="run-header-%d"`, runID)
if !strings.Contains(body, wantHeader) {
t.Fatalf("body missing %s", wantHeader)
}
// Breadcrumb: Dashboard / host / run #N — three segments.
wantCrumb := fmt.Sprintf(`run #%d`, runID)
if !strings.Contains(body, wantCrumb) {
t.Fatalf("body missing breadcrumb %q", wantCrumb)
}
if !strings.Contains(body, `href="/hosts/`) {
t.Fatalf("body missing breadcrumb host link: %s", body)
}
// Every stage gets its own active-step panel with a log pane.
for _, s := range store.DefaultStageOrder {
wantPanel := fmt.Sprintf(`data-stage="%s"`, s)
if !strings.Contains(body, wantPanel) {
t.Fatalf("body missing active-step panel %s", wantPanel)
}
wantPane := fmt.Sprintf(`id="log-%d-%s"`, runID, s)
if !strings.Contains(body, wantPane) {
t.Fatalf("body missing log pane %s", wantPane)
}
}
}
// TestRunPage_DefaultStep_Running: while a stage is running, its
// <details open> opens by default so the operator's eye lands on the
// live work first.
func TestRunPage_DefaultStep_Running(t *testing.T) {
ui, hosts, runs := setupPage(t)
ctx := context.Background()
id, _ := hosts.Create(ctx, model.Host{
Name: "run-running",
MAC: "aa:bb:cc:dd:ee:71",
WoLBroadcastIP: "10.0.0.255",
WoLPort: 9,
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
})
runID, _ := runs.Create(ctx, id, "rr", false)
_ = ui.Stages.Seed(ctx, runID)
for _, name := range []string{"Inventory", "SpecValidate"} {
_ = ui.Stages.StartByName(ctx, runID, name)
_ = ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, "")
}
_ = ui.Stages.StartByName(ctx, runID, "SMART")
rr := httptest.NewRecorder()
ui.RunPage(rr, runReq(runID))
if got := defaultOpenStage(rr.Body.String()); got != "SMART" {
t.Fatalf("default step = %q, want SMART", got)
}
}
// TestRunPage_DefaultStep_Failed: no stage running but one failed →
// default opens the failed stage so the operator reads the blocker.
func TestRunPage_DefaultStep_Failed(t *testing.T) {
ui, hosts, runs := setupPage(t)
ctx := context.Background()
id, _ := hosts.Create(ctx, model.Host{
Name: "run-failed",
MAC: "aa:bb:cc:dd:ee:72",
WoLBroadcastIP: "10.0.0.255",
WoLPort: 9,
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
})
runID, _ := runs.Create(ctx, id, "rf", false)
_ = ui.Stages.Seed(ctx, runID)
for _, name := range []string{"Inventory", "SpecValidate", "SMART"} {
_ = ui.Stages.StartByName(ctx, runID, name)
_ = ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, "")
}
_ = ui.Stages.StartByName(ctx, runID, "CPUStress")
_ = ui.Stages.CompleteByName(ctx, runID, "CPUStress", model.StageFailed, `{"reason":"thermal"}`)
rr := httptest.NewRecorder()
ui.RunPage(rr, runReq(runID))
if got := defaultOpenStage(rr.Body.String()); got != "CPUStress" {
t.Fatalf("default step = %q, want CPUStress", got)
}
}
// TestRunPage_DefaultStep_Completed: all stages passed → default lands
// on Reporting (where the report link lives).
func TestRunPage_DefaultStep_Completed(t *testing.T) {
ui, hosts, runs := setupPage(t)
ctx := context.Background()
id, _ := hosts.Create(ctx, model.Host{
Name: "run-done",
MAC: "aa:bb:cc:dd:ee:73",
WoLBroadcastIP: "10.0.0.255",
WoLPort: 9,
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
})
runID, _ := runs.Create(ctx, id, "rd", false)
_ = ui.Stages.Seed(ctx, runID)
for _, name := range store.DefaultStageOrder {
_ = ui.Stages.StartByName(ctx, runID, name)
_ = ui.Stages.CompleteByName(ctx, runID, name, model.StagePassed, "")
}
rr := httptest.NewRecorder()
ui.RunPage(rr, runReq(runID))
if got := defaultOpenStage(rr.Body.String()); got != "Reporting" {
t.Fatalf("default step = %q, want Reporting", got)
}
}
// TestRunPage_CancelForm: non-terminal run shows a Cancel button that
// posts to /hosts/{hostID}/cancel. Terminal runs omit Cancel and
// instead offer Start-new-run (posts to /hosts/{hostID}/start).
func TestRunPage_CancelForm(t *testing.T) {
ui, hosts, runs := setupPage(t)
ctx := context.Background()
id, _ := hosts.Create(ctx, model.Host{
Name: "run-cancel",
MAC: "aa:bb:cc:dd:ee:74",
WoLBroadcastIP: "10.0.0.255",
WoLPort: 9,
ExpectedSpecYAML: "memory:\n total_gib: 16\n",
})
runID, _ := runs.Create(ctx, id, "rc", false)
// Non-terminal (Queued): Cancel form present.
rr := httptest.NewRecorder()
ui.RunPage(rr, runReq(runID))
body := rr.Body.String()
wantCancel := fmt.Sprintf(`/hosts/%d/cancel`, id)
if !strings.Contains(body, wantCancel) {
t.Fatalf("non-terminal run missing Cancel form %s", wantCancel)
}
if strings.Contains(body, "Start new run") {
t.Fatalf("non-terminal run should not offer Start new run: %s", body)
}
// Now flip to Cancelled (terminal) and re-render.
if err := ui.Runs.SetState(ctx, runID, model.StateCancelled); err != nil {
t.Fatalf("cancel run: %v", err)
}
rr = httptest.NewRecorder()
ui.RunPage(rr, runReq(runID))
body = rr.Body.String()
if strings.Contains(body, wantCancel) {
t.Fatalf("terminal run should not show Cancel form: %s", body)
}
wantStart := fmt.Sprintf(`/hosts/%d/start`, id)
if !strings.Contains(body, wantStart) {
t.Fatalf("terminal run missing Start-new-run form %s", wantStart)
}
}
// TestRunPage_UnknownRun: /runs/999999 returns 404 rather than rendering
// an empty page.
func TestRunPage_UnknownRun(t *testing.T) {
ui, _, _ := setupPage(t)
rr := httptest.NewRecorder()
ui.RunPage(rr, runReq(999999))
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rr.Code)
}
}
// TestRunPage_BadID: a non-numeric runID returns 400.
func TestRunPage_BadID(t *testing.T) {
ui, _, _ := setupPage(t)
req := httptest.NewRequest(http.MethodGet, "/runs/bogus", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("runID", "bogus")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rr := httptest.NewRecorder()
ui.RunPage(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rr.Code)
}
}