Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions cmd/site/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
133 changes: 133 additions & 0 deletions e2e/greet_async_test.go
Original file line number Diff line number Diff line change
@@ -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] + "..."
}
88 changes: 88 additions & 0 deletions e2e/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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()
}
30 changes: 30 additions & 0 deletions examples/greet-async/greet.go
Original file line number Diff line number Diff line change
@@ -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
}
37 changes: 37 additions & 0 deletions examples/greet-async/greet.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Hello</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<link rel="stylesheet" href="{{lvtClientStyleURL}}">
<script defer src="{{lvtClientScriptURL}}"></script>
<style>
main.container{max-width:none;margin:0;padding:.25rem 0;text-align:center}
h1{font-size:26px;font-weight:800;letter-spacing:-.02em;margin:0 0 14px}
form{display:flex;flex-wrap:wrap;gap:8px;max-width:360px;margin:0 auto}
input{margin:0;flex:1 1 60%;min-width:0;order:1}
button.greet-btn{
--pico-background-color:#047857;
--pico-border-color:#047857;
background:#047857;
border-color:#047857;
color:#fff;
width:auto;
white-space:nowrap;
order:2;
}
</style>
</head>
<body>
<main class="container">
<h1>Hello, {{.Name}}</h1>
<form method="POST">
<input name="name" placeholder="Your name" required>
<button class="greet-btn" {{if .lvt.Pending}}type="button" aria-busy="true" disabled{{else}}name="greet"{{end}}>
{{if .lvt.Pending}}Loading...{{else}}Say hi{{end}}
</button>
</form>
</main>
</body>
</html>
47 changes: 47 additions & 0 deletions examples/greet-async/handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package greetasync

import (
"embed"
"log"
"net/http"
"os"
"path/filepath"
"sync"

"github.com/livetemplate/livetemplate"
)

//go:embed greet.tmpl
var templateFS embed.FS

var (
tmplPath string
tmplOnce sync.Once
)

func extractTemplate() string {
tmplOnce.Do(func() {
dir, err := os.MkdirTemp("", "greet-async-tmpl-*")
if err != nil {
log.Fatalf("greet-async: mkdtemp: %v", err)
}
data, err := templateFS.ReadFile("greet.tmpl")
if err != nil {
log.Fatalf("greet-async: read embedded tmpl: %v", err)
}
tmplPath = filepath.Join(dir, "greet.tmpl")
if err := os.WriteFile(tmplPath, data, 0o644); err != nil {
log.Fatalf("greet-async: write tmpl: %v", err)
}
})
return tmplPath
}

func Handler(opts ...livetemplate.Option) http.Handler {
baseOpts := []livetemplate.Option{
livetemplate.WithParseFiles(extractTemplate()),
livetemplate.WithAuthenticator(&livetemplate.AnonymousAuthenticator{}),
}
tmpl := livetemplate.Must(livetemplate.New("greet-async", append(baseOpts, opts...)...))
return tmpl.Handle(&Controller{}, livetemplate.AsState(&State{Name: "there"}))
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ require (
github.com/chromedp/chromedp v0.14.2
github.com/go-playground/validator/v10 v10.30.2
github.com/gorilla/websocket v1.5.3
github.com/livetemplate/livetemplate v0.19.0
github.com/livetemplate/livetemplate v0.22.0
github.com/livetemplate/lvt v0.2.0
github.com/livetemplate/lvt/components v0.1.8
gopkg.in/yaml.v3 v3.0.1
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,8 @@ github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kUL
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/livetemplate/livetemplate v0.19.0 h1:tLuF6vqDS5FE7ggwMjie6hmxLY2C94glPl2nAqFceNo=
github.com/livetemplate/livetemplate v0.19.0/go.mod h1:xkmgckEpdoYTqretGpaWfh8uqvjsBBLBJYd84n2O5QQ=
github.com/livetemplate/livetemplate v0.22.0 h1:7l2oDUDOm7Srl8oaySHtxElTsf6l490KLosq9eg13OU=
github.com/livetemplate/livetemplate v0.22.0/go.mod h1:xkmgckEpdoYTqretGpaWfh8uqvjsBBLBJYd84n2O5QQ=
github.com/livetemplate/lvt v0.2.0 h1:BQkZHKwFIACeq//L92cxjWdF7c4b5qjl9fxHnvbgQ6U=
github.com/livetemplate/lvt v0.2.0/go.mod h1:JPz+9CHGq4nTHVNH+rQAZxEapL9F+m9zyhzJJ8pYbzQ=
github.com/livetemplate/lvt/components v0.1.8 h1:9LNwmhrcoqxtnUQDYC1+sh2X9m9Yoqay0tDs8qoj9Xc=
Expand Down
Loading