Files
josh f79fe0f0db
CI / Lint + build + test (push) Successful in 1m26s
Release / release (push) Successful in 6m47s
ui: GitHub-Actions-style detail page, sub-steps, mini-tile run-view
Reshapes the detail page into a run-view: hybrid horizontal pipeline
+ expanded active-step pane with sub-steps, a per-step log pane with
line-numbered permalinks and client-side search, and a runs-history
sidebar that navigates via ?run=N. Default step is server-picked
(running → failed → Reporting) so the operator lands on the thing
that's moving.

Adds a sub_steps table + SSE topic (substep-{run}-{stage}-{ordinal})
so per-disk and per-pass work (SMART, CPUStress CPU/RAM, Storage,
GPU) is visible in the UI instead of buried in stage summary JSON.
Agent emits sub-step reports from existing per-iteration loops.

Dashboard tiles become a mini run-view with a 9-dot step strip so
the operator reads run health across the whole grid at a glance.
Register page gets the same card shell + button styling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 19:00:11 -04:00

127 lines
4.2 KiB
Go

package store_test
import (
"context"
"testing"
"time"
"vetting/internal/model"
"vetting/internal/store"
)
// TestSubStepsUpsertAndList covers the two-phase life cycle the agent
// is meant to exercise: an initial "started" upsert with state=running
// and started_at set, then a terminal upsert that flips state to passed
// and fills completed_at without clobbering started_at. Ordering falls
// out of the (stage_name, ordinal) index — ListForRun must return rows
// in that deterministic order even when they were inserted out of order.
func TestSubStepsUpsertAndList(t *testing.T) {
runs := newDB(t)
_, runID := seedRun(t, runs)
ss := &store.SubSteps{DB: runs.DB}
ctx := context.Background()
start := time.Date(2026, 4, 18, 12, 0, 0, 0, time.UTC)
end := start.Add(3 * time.Minute)
// Insert CPUStress ordinals out of order to prove ListForRun orders
// by (stage, ordinal) rather than insertion time.
if err := ss.Upsert(ctx, model.SubStep{
RunID: runID, StageName: "CPUStress", Ordinal: 1,
Name: "Memory pass", State: model.StageRunning, StartedAt: &start,
}); err != nil {
t.Fatalf("upsert CPUStress/1 running: %v", err)
}
if err := ss.Upsert(ctx, model.SubStep{
RunID: runID, StageName: "CPUStress", Ordinal: 0,
Name: "CPU pass", State: model.StagePassed, StartedAt: &start, CompletedAt: &end,
SummaryJSON: `{"elapsed_secs":180}`,
}); err != nil {
t.Fatalf("upsert CPUStress/0 passed: %v", err)
}
// Terminal update for ordinal 1 — only completed_at + state; started_at
// intentionally omitted so COALESCE preserves the original value.
if err := ss.Upsert(ctx, model.SubStep{
RunID: runID, StageName: "CPUStress", Ordinal: 1,
Name: "Memory pass", State: model.StagePassed, CompletedAt: &end,
}); err != nil {
t.Fatalf("upsert CPUStress/1 passed: %v", err)
}
list, err := ss.ListForRun(ctx, runID)
if err != nil {
t.Fatalf("ListForRun: %v", err)
}
if len(list) != 2 {
t.Fatalf("got %d rows, want 2", len(list))
}
if list[0].Name != "CPU pass" || list[1].Name != "Memory pass" {
t.Fatalf("order: %+v", list)
}
if list[1].State != model.StagePassed {
t.Fatalf("memory pass state = %q, want passed", list[1].State)
}
if list[1].StartedAt == nil {
t.Fatalf("memory pass started_at was wiped by terminal upsert")
}
if !list[1].StartedAt.Equal(start) {
t.Fatalf("started_at = %v, want %v", list[1].StartedAt, start)
}
if list[0].SummaryJSON != `{"elapsed_secs":180}` {
t.Fatalf("CPU pass summary = %q, want {\"elapsed_secs\":180}", list[0].SummaryJSON)
}
// ListForStage scopes to a single stage.
only, err := ss.ListForStage(ctx, runID, "CPUStress")
if err != nil {
t.Fatalf("ListForStage: %v", err)
}
if len(only) != 2 {
t.Fatalf("ListForStage CPUStress: got %d, want 2", len(only))
}
other, err := ss.ListForStage(ctx, runID, "Storage")
if err != nil {
t.Fatalf("ListForStage Storage: %v", err)
}
if len(other) != 0 {
t.Fatalf("ListForStage Storage should be empty, got %d", len(other))
}
}
// TestSubStepsSummaryPreserveOnDefault verifies the intentional special-
// case: when the second upsert supplies the default empty-object summary
// ('{}'), the prior non-default summary is kept. This lets a "complete"
// call that only carries timing update the row without wiping the rich
// summary the "start" call persisted.
func TestSubStepsSummaryPreserveOnDefault(t *testing.T) {
runs := newDB(t)
_, runID := seedRun(t, runs)
ss := &store.SubSteps{DB: runs.DB}
ctx := context.Background()
if err := ss.Upsert(ctx, model.SubStep{
RunID: runID, StageName: "SMART", Ordinal: 0,
Name: "smartctl /dev/sda", State: model.StageRunning,
SummaryJSON: `{"device":"/dev/sda"}`,
}); err != nil {
t.Fatalf("first upsert: %v", err)
}
// Terminal with empty summary — should not blow away the device field.
if err := ss.Upsert(ctx, model.SubStep{
RunID: runID, StageName: "SMART", Ordinal: 0,
Name: "smartctl /dev/sda", State: model.StagePassed,
}); err != nil {
t.Fatalf("terminal upsert: %v", err)
}
got, err := ss.Get(ctx, runID, "SMART", 0)
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.SummaryJSON != `{"device":"/dev/sda"}` {
t.Fatalf("summary wiped: %q", got.SummaryJSON)
}
if got.State != model.StagePassed {
t.Fatalf("state = %q, want passed", got.State)
}
}