Files
Vetting/internal/web/templates/host_detail_test.go
T
josh 0db790ae3e
CI / Lint + build + test (push) Successful in 1m29s
Release / release (push) Has been cancelled
ui: stream host-detail fragments over SSE so the page updates live
The detail page was only partly live: Pipeline + LogTabs subscribed to
SSE, but the summary header, actions row, spec-diffs list and hold-key
block all froze at page-load and required a manual refresh to catch up
with state changes.

Extract each of those four regions into its own named templ component
with a stable id and sse-swap target, add Render*String helpers so the
orchestrator can publish pre-rendered fragments, and register a
HostDetailRenderer alongside the existing Tile/Pipeline renderers.
PublishHostDetail is folded into publishTileUpdate so every call site
that already refreshes a tile now also refreshes the detail page —
keeps the fan-out honest without scattering new publish calls.

The empty-state wrappers for spec-diffs and hold are load-bearing:
without the <section id=... sse-swap=...> present at initial GET, the
first live event after SpecValidate or Hold writes would have no DOM
node to swap into.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 16:36:13 -04:00

118 lines
3.2 KiB
Go

package templates
import (
"strings"
"testing"
"vetting/internal/model"
)
// TestDetailSummary_RootAttrs asserts the root <header> carries the
// stable id and sse-swap target. Successive SSE swaps replace the
// outer element, so without these attributes the second swap would
// have nothing to target.
func TestDetailSummary_RootAttrs(t *testing.T) {
d := HostDetailData{
Tile: TileData{
Host: model.Host{ID: 7, Name: "alpha", MAC: "aa:bb:cc:dd:ee:ff"},
},
}
html := RenderDetailSummaryString(d)
for _, want := range []string{
`id="detail-summary-7"`,
`sse-swap="detail-summary-7"`,
`hx-swap="outerHTML"`,
} {
if !strings.Contains(html, want) {
t.Errorf("DetailSummary missing %q in:\n%s", want, html)
}
}
}
func TestDetailActions_RootAttrs(t *testing.T) {
d := HostDetailData{
Tile: TileData{
Host: model.Host{ID: 7, Name: "alpha", MAC: "aa:bb:cc:dd:ee:ff"},
},
}
html := RenderDetailActionsString(d)
for _, want := range []string{
`id="detail-actions-7"`,
`sse-swap="detail-actions-7"`,
`hx-swap="outerHTML"`,
} {
if !strings.Contains(html, want) {
t.Errorf("DetailActions missing %q in:\n%s", want, html)
}
}
}
// TestDetailSpecDiffs_EmptyWrapper: when a run exists but has no diffs,
// the <section> wrapper still renders so a later SSE push has a target.
// Without this, the very first SpecValidate diff write would have no
// DOM element to swap into.
func TestDetailSpecDiffs_EmptyWrapper(t *testing.T) {
d := HostDetailData{
Tile: TileData{
Host: model.Host{ID: 7},
Latest: &model.Run{ID: 42},
},
}
html := RenderDetailSpecDiffsString(d)
for _, want := range []string{
`id="detail-specdiffs-42"`,
`sse-swap="detail-specdiffs-42"`,
} {
if !strings.Contains(html, want) {
t.Errorf("DetailSpecDiffs missing %q in empty state:\n%s", want, html)
}
}
if strings.Contains(html, "<details") {
t.Errorf("DetailSpecDiffs empty state must not render <details>:\n%s", html)
}
}
// TestDetailHold_EmptyWrapper: same rationale as specdiffs — the
// section wrapper is always present when a run exists so the first
// hold event has a target.
func TestDetailHold_EmptyWrapper(t *testing.T) {
d := HostDetailData{
Tile: TileData{
Host: model.Host{ID: 7},
Latest: &model.Run{ID: 42, State: model.StateInventoryCheck},
},
}
html := RenderDetailHoldString(d)
for _, want := range []string{
`id="detail-hold-42"`,
`sse-swap="detail-hold-42"`,
} {
if !strings.Contains(html, want) {
t.Errorf("DetailHold missing %q in empty state:\n%s", want, html)
}
}
if strings.Contains(html, "SSH available") {
t.Errorf("DetailHold non-holding state must not render SSH block:\n%s", html)
}
}
// TestDetailHold_HoldingRendersSSH: once the run enters FailedHolding
// with an IP, the wrapper renders the ssh invocation.
func TestDetailHold_HoldingRendersSSH(t *testing.T) {
d := HostDetailData{
Tile: TileData{
Host: model.Host{ID: 7},
HoldKeyPath: "/tmp/hold.key",
Latest: &model.Run{
ID: 42,
State: model.StateFailedHolding,
HoldIP: "10.0.0.7",
},
},
}
html := RenderDetailHoldString(d)
if !strings.Contains(html, "ssh -i /tmp/hold.key root@10.0.0.7") {
t.Errorf("DetailHold missing ssh invocation:\n%s", html)
}
}