f79fe0f0db
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>
127 lines
4.2 KiB
Go
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)
|
|
}
|
|
}
|