From 5c00edd7b606cf18eeb284eb28f0055a4de51d49 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 18 Apr 2026 17:51:58 -0400 Subject: [PATCH] ui: fix htmx-ext-sse integrity hash (was silently blocked by browser) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detail-page pipeline + log panes weren't updating without a manual refresh. Root cause: the integrity attribute on htmx-ext-sse@2.2.2 in layout.templ was wrong, so the browser refused to execute the script (SRI enforcement is silent — no user-visible error unless you open devtools). htmx core loaded, boosted nav worked, forms worked — but sse-connect/sse-swap were inert because the extension never registered, so no EventSource was ever opened. Replaced the claimed hash (Y4gc0CK6...) with the real one (fw+eTlCc...) computed via curl -sL https://unpkg.com/htmx-ext-sse@2.2.2 | openssl dgst -sha384 -binary | openssl base64 -A Added sse_e2e_test.go as a regression canary that mounts the real chi router (RealIP + Recoverer + Logger middleware), opens GET /events, publishes a tile-update via Runner, and asserts the event lands on the wire. Server-side unit tests only verified rendered HTML — this one covers the full publish→wire path, which is what the next regression in this area will hit. Co-Authored-By: Claude Opus 4.7 --- internal/httpserver/sse_e2e_test.go | 145 +++++++++++++++++++++++++ internal/web/templates/layout.templ | 2 +- internal/web/templates/layout_templ.go | 2 +- 3 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 internal/httpserver/sse_e2e_test.go diff --git a/internal/httpserver/sse_e2e_test.go b/internal/httpserver/sse_e2e_test.go new file mode 100644 index 0000000..eb1d253 --- /dev/null +++ b/internal/httpserver/sse_e2e_test.go @@ -0,0 +1,145 @@ +package httpserver + +import ( + "bufio" + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "vetting/internal/api" + "vetting/internal/db" + "vetting/internal/events" + "vetting/internal/model" + "vetting/internal/orchestrator" + "vetting/internal/store" +) + +// TestSSE_EndToEnd is the canary test the unit-level render tests miss: +// it mounts the real chi router (with RealIP + Recoverer + Logger +// middleware, the same stack that runs in prod), opens a GET /events, +// publishes an event via Runner.PublishTileUpdate, and asserts the +// payload lands on the wire as a well-formed SSE message. +// +// Motivation: the user reported that after cdd6cae "the pipeline does +// not update without a manual refresh". Template-only tests can't catch +// that — they only check rendered HTML. This test fails if any layer +// between publish and browser (hub subscribe, writeSSE formatting, +// middleware ResponseWriter wrapping that strips Flusher) is broken. +func TestSSE_EndToEnd(t *testing.T) { + conn, err := db.Open(filepath.Join(t.TempDir(), "vetting.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + + hosts := &store.Hosts{DB: conn} + runs := &store.Runs{DB: conn} + stages := &store.Stages{DB: conn} + diffs := &store.SpecDiffs{DB: conn} + arts := &store.Artifacts{DB: conn} + hub := events.NewHub() + runner := &orchestrator.Runner{Runs: runs, Hosts: hosts, Stages: stages, EventHub: hub} + tiles := &api.TileEnricher{Runs: runs, Artifacts: arts, SpecDiffs: diffs} + + // The prod wiring: closures that render tile + pipeline fragments + // for publishTileUpdate. Without these, publishTileUpdate is a no-op + // except for the bare tile event. + orchestrator.TileRenderer = func(_ context.Context, _ model.Host, _ *model.Run) string { + return "
tile
" + } + orchestrator.PipelineRenderer = func(_ *model.Run, _ []model.Stage) string { + return "
pipeline
" + } + + ui := &api.UI{ + Hosts: hosts, Runs: runs, Stages: stages, SpecDiffs: diffs, Artifacts: arts, + EventHub: hub, Runner: runner, Tiles: tiles, + } + agent := &api.Agent{ + Hosts: hosts, Runs: runs, Stages: stages, Artifacts: arts, + SpecDiffs: diffs, Runner: runner, EventHub: hub, + } + + hostID, err := hosts.Create(context.Background(), model.Host{ + Name: "sse-e2e", MAC: "aa:bb:cc:dd:ee:77", + WoLBroadcastIP: "10.0.0.255", WoLPort: 9, + }) + if err != nil { + t.Fatalf("create host: %v", err) + } + + router := NewRouter(Deps{UI: ui, Agent: agent}) + srv := httptest.NewServer(router) + t.Cleanup(srv.Close) + + // Open the SSE stream. + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/events", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("GET /events: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") { + t.Fatalf("Content-Type = %q, want text/event-stream", ct) + } + + reader := bufio.NewReader(resp.Body) + + // Consume the hello preamble first. + if err := waitForSSEEvent(reader, "hello", 1*time.Second); err != nil { + t.Fatalf("hello preamble: %v", err) + } + + // Give the Subscribe goroutine a moment to land in the map so our + // publish doesn't beat it. This is the race the hub's select + // default-drops on — if the subscriber isn't registered yet, the + // publish is silently thrown away. + time.Sleep(50 * time.Millisecond) + + // Publish a tile-update via Runner. This is what the real orchestrator + // does on every state transition. + runner.PublishTileUpdate(context.Background(), hostID) + + // The browser should see one SSE message with `event: tile-`. + wantName := "tile-1" // first host → ID 1 + _ = hostID + if err := waitForSSEEvent(reader, wantName, 2*time.Second); err != nil { + t.Fatalf("tile publish: %v", err) + } +} + +// waitForSSEEvent reads lines from the SSE stream until it sees +// `event: ` or the deadline elapses. It tolerates interleaved +// heartbeats because the hub's 15s heartbeat can fire between the +// hello and the event we're looking for. +func waitForSSEEvent(r *bufio.Reader, name string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + want := "event: " + name + for time.Now().Before(deadline) { + line, err := r.ReadString('\n') + if err != nil { + return err + } + if strings.TrimRight(line, "\r\n") == want { + return nil + } + } + return &timeoutErr{} +} + +type timeoutErr struct{} + +func (e *timeoutErr) Error() string { return "timeout waiting for sse event" } diff --git a/internal/web/templates/layout.templ b/internal/web/templates/layout.templ index 1641f51..c9acdb2 100644 --- a/internal/web/templates/layout.templ +++ b/internal/web/templates/layout.templ @@ -9,7 +9,7 @@ templ Layout(title string) { { title } — Vetting - +
diff --git a/internal/web/templates/layout_templ.go b/internal/web/templates/layout_templ.go index 1dfc9aa..142fd91 100644 --- a/internal/web/templates/layout_templ.go +++ b/internal/web/templates/layout_templ.go @@ -42,7 +42,7 @@ func Layout(title string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " — Vetting
Vetting
·
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " — Vetting
Vetting
·
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err }