diff --git a/cmd/site/main.go b/cmd/site/main.go index 86662a9..38fdd84 100644 --- a/cmd/site/main.go +++ b/cmd/site/main.go @@ -26,6 +26,7 @@ import ( draftform "github.com/livetemplate/docs/examples/draft-form" filetree "github.com/livetemplate/docs/examples/file-tree" "github.com/livetemplate/docs/examples/greet" + greetasync "github.com/livetemplate/docs/examples/greet-async" greetloading "github.com/livetemplate/docs/examples/greet-loading" greetloadingserver "github.com/livetemplate/docs/examples/greet-loading-server" greetnojs "github.com/livetemplate/docs/examples/greet-nojs" @@ -173,6 +174,13 @@ func main() { mux.Handle("/apps/greet-loading-server/", http.StripPrefix("/apps/greet-loading-server", greetloadingserver.Handler( livetemplate.WithAllowedOrigins(allowedOrigins), ))) + // greet-async is the Async+Pending variant: same server-owned loading + // behavior as greet-loading-server but collapsed to one method via + // livetemplate.Async and {{.lvt.Pending}}. WebSocket-enabled (Async + // dispatches the completion via DispatchChan on the event loop). + mux.Handle("/apps/greet-async/", http.StripPrefix("/apps/greet-async", greetasync.Handler( + livetemplate.WithAllowedOrigins(allowedOrigins), + ))) // greet-wall is the spine's climax (Steps 5-7) and the one shared, // WebSocket-enabled real-time app on the landing: per-user tab sync diff --git a/e2e/greet_async_test.go b/e2e/greet_async_test.go new file mode 100644 index 0000000..9368293 --- /dev/null +++ b/e2e/greet_async_test.go @@ -0,0 +1,133 @@ +package e2e + +import ( + "strings" + "testing" + "time" + + "github.com/chromedp/cdproto/network" + "github.com/chromedp/chromedp" +) + +// TestGreetAsync exercises the Async + {{.lvt.Pending}} example: clicking +// the button triggers a two-frame WebSocket sequence: +// +// 1. Pending render: button shows "Loading..." with disabled + aria-busy +// 2. Completion render: headline shows the name, button reverts to "Say hi" +// +// Unlike TestSpineLoadingServerEmbed (which only checks final DOM), this test +// captures WS frames and asserts the intermediate pending state actually +// reached the client. +func TestGreetAsync(t *testing.T) { + ctx, cancel := newCtx(t) + defer cancel() + + consoleErrs := captureConsoleErrors(ctx) + ws := recordWSFrames(ctx) + + if err := chromedp.Run(ctx, network.Enable()); err != nil { + t.Fatalf("enable network domain: %v", err) + } + + const name = "Ada" + + if err := chromedp.Run(ctx, + chromedp.Navigate(baseURL()+"/apps/greet-async/"), + chromedp.WaitVisible(`h1`, chromedp.ByQuery), + ); err != nil { + t.Fatalf("navigate: %v\nconsole: %v", err, consoleErrs()) + } + + // Wait for WS to connect and the initial mount render to arrive. + if err := ws.waitForReceivedWithTreeCount(1, 10*time.Second); err != nil { + t.Fatalf("WS mount frame never arrived: %v\nframes:\n%s", err, ws.dump()) + } + + // Baseline: count tree-bearing frames before the action so we can + // isolate the pending + completion pair (ignores heartbeats/acks). + baselineCount := len(ws.receivedWithTree()) + + // Type name and click. + if err := chromedp.Run(ctx, + chromedp.SendKeys(`input[name="name"]`, name, chromedp.ByQuery), + chromedp.Click(`button.greet-btn`, chromedp.ByQuery), + ); err != nil { + t.Fatalf("send keys / click: %v\nconsole: %v", err, consoleErrs()) + } + + // Wait for the completion render (tree frame #2 after the action). The + // async work takes ~700ms, so allow up to 5s. Filtering by tree key + // ensures heartbeats/acks don't shift the index. + if err := ws.waitForReceivedWithTreeCount(baselineCount+2, 5*time.Second); err != nil { + t.Fatalf("expected 2 tree frames after action (pending + completion): %v\nframes:\n%s", + err, ws.dump()) + } + + actionFrames := ws.receivedWithTreeSince(baselineCount) + + // --- Frame #1: pending render --- + // The pending frame's tree should contain "Loading..." (the button text + // when .lvt.Pending is true). + if len(actionFrames) < 1 { + t.Fatal("no frames after action") + } + pendingData := actionFrames[0].Data + if !strings.Contains(pendingData, "Loading...") { + t.Errorf("frame #1 (pending) should contain \"Loading...\", got:\n%s", truncateForLog(pendingData)) + } + + // --- Frame #2: completion render --- + // The completion frame should contain the applied name and NOT contain + // "Loading..." (pending cleared). + if len(actionFrames) < 2 { + t.Fatal("only 1 frame after action, expected 2 (pending + completion)") + } + completionData := actionFrames[1].Data + if !strings.Contains(completionData, name) { + t.Errorf("frame #2 (completion) should contain %q, got:\n%s", name, truncateForLog(completionData)) + } + if strings.Contains(completionData, "Loading...") { + t.Errorf("frame #2 (completion) should NOT contain \"Loading...\" (pending should be cleared)") + } + + // --- Final DOM state --- + var headline, button, disabled, busy string + if err := chromedp.Run(ctx, + chromedp.Text(`h1`, &headline, chromedp.ByQuery), + chromedp.Text(`button.greet-btn`, &button, chromedp.ByQuery), + chromedp.Evaluate(`document.querySelector('button.greet-btn')?.getAttribute('disabled') || ''`, &disabled), + chromedp.Evaluate(`document.querySelector('button.greet-btn')?.getAttribute('aria-busy') || ''`, &busy), + ); err != nil { + t.Fatalf("DOM query: %v\nconsole: %v", err, consoleErrs()) + } + + if !strings.Contains(headline, name) { + t.Errorf("headline = %q, want to contain %q", headline, name) + } + if strings.TrimSpace(button) != "Say hi" { + t.Errorf("button text = %q, want \"Say hi\" after completion", button) + } + if disabled != "" { + t.Errorf("button still disabled after completion") + } + if busy != "" { + t.Errorf("button still aria-busy after completion") + } + + // No console errors. + if errs := consoleErrs(); len(errs) > 0 { + t.Errorf("browser console errors: %v", errs) + } + + // Dump frames on failure for diagnostics. + if t.Failed() { + t.Logf("WS frames:\n%s", ws.dump()) + } +} + +func truncateForLog(s string) string { + if len(s) <= 500 { + return s + } + return s[:500] + "..." +} diff --git a/e2e/helpers_test.go b/e2e/helpers_test.go index 96808fa..59db4a2 100644 --- a/e2e/helpers_test.go +++ b/e2e/helpers_test.go @@ -2,8 +2,13 @@ package e2e import ( "context" + "encoding/json" + "fmt" "strings" + "sync" + "time" + "github.com/chromedp/cdproto/network" "github.com/chromedp/cdproto/runtime" "github.com/chromedp/chromedp" ) @@ -24,3 +29,86 @@ func captureConsoleErrors(ctx context.Context) func() []string { }) return func() []string { return errs } } + +type wsFrame struct { + Direction string + Data string + Parsed map[string]any +} + +type wsRecorder struct { + mu sync.Mutex + frames []wsFrame +} + +func recordWSFrames(ctx context.Context) *wsRecorder { + r := &wsRecorder{} + chromedp.ListenTarget(ctx, func(ev interface{}) { + switch ev := ev.(type) { + case *network.EventWebSocketFrameReceived: + r.mu.Lock() + defer r.mu.Unlock() + f := wsFrame{Direction: "received", Data: ev.Response.PayloadData} + if json.Valid([]byte(f.Data)) { + _ = json.Unmarshal([]byte(f.Data), &f.Parsed) + } + r.frames = append(r.frames, f) + case *network.EventWebSocketFrameSent: + r.mu.Lock() + defer r.mu.Unlock() + f := wsFrame{Direction: "sent", Data: ev.Response.PayloadData} + if json.Valid([]byte(f.Data)) { + _ = json.Unmarshal([]byte(f.Data), &f.Parsed) + } + r.frames = append(r.frames, f) + } + }) + return r +} + +func (r *wsRecorder) receivedWithTree() []wsFrame { + r.mu.Lock() + defer r.mu.Unlock() + var out []wsFrame + for _, f := range r.frames { + if f.Direction == "received" { + if _, ok := f.Parsed["tree"]; ok { + out = append(out, f) + } + } + } + return out +} + +func (r *wsRecorder) receivedWithTreeSince(n int) []wsFrame { + all := r.receivedWithTree() + if n >= len(all) { + return nil + } + return all[n:] +} + +func (r *wsRecorder) waitForReceivedWithTreeCount(want int, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if len(r.receivedWithTree()) >= want { + return nil + } + time.Sleep(50 * time.Millisecond) + } + return fmt.Errorf("timeout: got %d tree frames, want >= %d", len(r.receivedWithTree()), want) +} + +func (r *wsRecorder) dump() string { + r.mu.Lock() + defer r.mu.Unlock() + var b strings.Builder + for i, f := range r.frames { + data := f.Data + if len(data) > 200 { + data = data[:200] + "..." + } + fmt.Fprintf(&b, "[%d] %s: %s\n", i, f.Direction, data) + } + return b.String() +} diff --git a/examples/greet-async/greet.go b/examples/greet-async/greet.go new file mode 100644 index 0000000..cef91c9 --- /dev/null +++ b/examples/greet-async/greet.go @@ -0,0 +1,30 @@ +package greetasync + +import ( + "context" + "strings" + "time" + + "github.com/livetemplate/livetemplate" +) + +type State struct { + Name string +} + +type Controller struct{} + +func (c *Controller) Greet(s State, ctx *livetemplate.Context) (State, error) { + name := strings.TrimSpace(ctx.GetString("name")) + livetemplate.Async(ctx, + func(context.Context) (string, error) { + time.Sleep(700 * time.Millisecond) + return name, nil + }, + func(s State, name string, _ error) (State, error) { + s.Name = name + return s, nil + }, + ) + return s, nil +} diff --git a/examples/greet-async/greet.tmpl b/examples/greet-async/greet.tmpl new file mode 100644 index 0000000..76328b2 --- /dev/null +++ b/examples/greet-async/greet.tmpl @@ -0,0 +1,37 @@ + + +
+ +