Testing live handlers
A handler’s contract is “given this action, these patches come out.”
livetest drops the HTTP transport (no server, no SSE parsing) and keeps
that contract.
import ( "github.com/bmartel/alacris-go/live" "github.com/bmartel/alacris-go/live/livetest")
func TestAddCard(t *testing.T) { srv := live.New() defer srv.Close() registerHandlers(srv) // the code under test, exactly as production wires it
sess, rec := livetest.NewSession(t, srv)
err := livetest.Invoke(t, sess, "add-card", "board", components.BoardAddDetail{Text: "Cut the trailer", Column: "todo"}) if err != nil { t.Fatal(err) }
p, ok := rec.Last("board", "items") if !ok { t.Fatal("add-card did not patch the items prop") } items := p.Value.([]todo.Item) if len(items) != 1 || items[0].Text != "Cut the trailer" { t.Errorf("items = %v", items) }}Three moving parts:
livetest.NewSessionmints a session the way a page render would and attaches aRecorderin place of the browser.livetest.Invokeruns the registered handler (session-scoped or server-wide, the same lookup an incoming POST uses) with the detail marshalled for you.recholds every patch the handler sent.Last(id, key)answers the question a test usually asks: what would the page be showing?
Everything is synchronous. A Send inside a handler has reached the recorder
by the time Invoke returns. No polling, no sleeps, no eventual consistency.
Testing OnOpen
Section titled “Testing OnOpen”OnOpen runs when a browser attaches, so the test keeps the real order:
render first, register, then attach.
sess := livetest.RenderSession(t, srv)sess.OnOpen(func(s *live.Session) { pushAll(s) }) // the code under test
rec := livetest.Attach(t, sess) // "the browser connects"
if _, ok := rec.Last("board", "items"); !ok { t.Error("OnOpen did not restate the board")}Patches sent between RenderSession and Attach are buffered and recorded
too, exactly as the pre-connect buffer behaves in production.
Frames are the Batch contract
Section titled “Frames are the Batch contract”Recorder.Patches flattens everything; Recorder.Frames keeps frame
boundaries, and a frame is what lands on the page in one paint. To assert that
a handler’s writes arrive together, assert on frames:
frames := rec.Frames()if len(frames) != 1 { t.Errorf("the list and the count arrived in %d paints, want 1", len(frames))}Errors and unknown actions
Section titled “Errors and unknown actions”Invoke returns whatever the handler returned, so error paths are plain Go:
err := livetest.Invoke(t, sess, "move-card", "", components.BoardMoveDetail{Column: ""})if err == nil { t.Fatal("an empty column must be rejected")}An action nobody registered is live.ErrNoAction, which catches the rename
that only happened on one side:
if !errors.Is(livetest.Invoke(t, sess, "add-cards", "", nil), live.ErrNoAction) { ... }What still belongs in a browser
Section titled “What still belongs in a browser”livetest proves the right patches leave the server. It cannot prove that
applying them leaves focus, scroll position and typed text alone. Those claims
live in the Playwright suite under e2e/, which drives the real example app
in a real DOM. Test handler logic here; test DOM identity there.