From 1af94d0e8599238c4c9bab4772cbeebed2a8134b Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Sat, 23 May 2026 05:34:48 +0000 Subject: [PATCH 01/12] refactor(examples/counter): move out of content/recipes/_app/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Counter is the first recipe to be folded into the new examples// layout. The package, template, and runtime move out of content/recipes/counter/_app/ into examples/counter/, with a new cmd/main.go subpackage that lets `go run ./examples/counter/cmd` launch the recipe standalone. Handler now takes variadic livetemplate.Option so callers control origin policy: cmd/site passes the docs-deploy allowlist; cmd/main.go passes either the same allowlist or permissive-origin + dev-mode under --dev. The recipe itself stays environment-agnostic. content/recipes/counter/index.md and content/index.md switch from `include="./_app/..."` to site-rooted `include="/examples/counter/..."`, using the tinkerdown syntax added in livetemplate/tinkerdown PR for feat/site-rooted-includes. The Dockerfile pin will need updating to that tinkerdown tag before this branch can merge — tracked in PR description. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/site/main.go | 8 ++- content/index.md | 4 +- content/recipes/counter/index.md | 8 +-- examples/counter/cmd/main.go | 57 +++++++++++++++++++ .../_app => examples/counter}/counter.go | 0 .../_app => examples/counter}/counter.tmpl | 0 .../_app => examples/counter}/handler.go | 28 +++++---- 7 files changed, 81 insertions(+), 24 deletions(-) create mode 100644 examples/counter/cmd/main.go rename {content/recipes/counter/_app => examples/counter}/counter.go (100%) rename {content/recipes/counter/_app => examples/counter}/counter.tmpl (100%) rename {content/recipes/counter/_app => examples/counter}/handler.go (66%) diff --git a/cmd/site/main.go b/cmd/site/main.go index cc7f740..6d8fa7d 100644 --- a/cmd/site/main.go +++ b/cmd/site/main.go @@ -10,7 +10,7 @@ // // Recipes are imported as Go packages — each exposes `Handler() http.Handler`. // Adding a recipe is two lines here plus a Go package under -// content/recipes//_app/. +// examples//. package main import ( @@ -20,7 +20,7 @@ import ( "github.com/livetemplate/livetemplate" - counter "github.com/livetemplate/docs/content/recipes/counter/_app" + "github.com/livetemplate/docs/examples/counter" loginrecipe "github.com/livetemplate/docs/content/recipes/login/_app" patterns "github.com/livetemplate/docs/content/recipes/patterns/_app" pe "github.com/livetemplate/docs/content/recipes/progressive-enhancement/_app" @@ -49,7 +49,9 @@ func main() { // page embed-lvt path="/apps/counter/" upstream="http://localhost:9091" // → tinkerdown fetches http://localhost:9091/apps/counter/ // → mux routes to counter.Handler() - mux.Handle("/apps/counter/", http.StripPrefix("/apps/counter", counter.Handler())) + mux.Handle("/apps/counter/", http.StripPrefix("/apps/counter", counter.Handler( + livetemplate.WithAllowedOrigins(allowedOrigins), + ))) // UI patterns are mounted at their recipe URL space because the // catalog and detail pages are first-class recipes. Tinkerdown's proxy diff --git a/content/index.md b/content/index.md index 5ae6a81..bf3a70a 100644 --- a/content/index.md +++ b/content/index.md @@ -24,12 +24,12 @@ The widget above is a real, deployed LiveTemplate app — the same code as the [ The state and handlers — `counter.go`: -```go include="./recipes/counter/_app/counter.go" lines="9-33" +```go include="/examples/counter/counter.go" lines="9-33" ``` The template — `counter.tmpl`: -```html include="./recipes/counter/_app/counter.tmpl" +```html include="/examples/counter/counter.tmpl" ``` A button's `name` attribute IS the routing key — ` + + + + + + + + diff --git a/examples/avatar-upload/avatar-upload_test.go b/examples/avatar-upload/avatar-upload_test.go new file mode 100644 index 0000000..55c6006 --- /dev/null +++ b/examples/avatar-upload/avatar-upload_test.go @@ -0,0 +1,482 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/chromedp/chromedp" + "github.com/gorilla/websocket" + "github.com/livetemplate/livetemplate" + e2etest "github.com/livetemplate/lvt/testing" +) + +// ========== E2E Tests ========== + +// TestAvatarUploadE2E tests the avatar upload app end-to-end with a real browser +func TestAvatarUploadE2E(t *testing.T) { + if testing.Short() { + t.Skip("Skipping E2E test in short mode") + } + + // Get free ports for server and Chrome debugging + serverPort, err := e2etest.GetFreePort() + if err != nil { + t.Fatalf("Failed to get free port for server: %v", err) + } + + debugPort, err := e2etest.GetFreePort() + if err != nil { + t.Fatalf("Failed to get free port for Chrome: %v", err) + } + + // Start avatar-upload server + serverCmd := e2etest.StartTestServer(t, "main.go", serverPort) + defer func() { + if serverCmd != nil && serverCmd.Process != nil { + serverCmd.Process.Kill() + } + }() + + // Start Docker Chrome container + chromeCmd := e2etest.StartDockerChrome(t, debugPort) + defer e2etest.StopDockerChrome(t, debugPort) + _ = chromeCmd // Command returned for reference; cleanup handled by StopDockerChrome + + // Connect to Docker Chrome via remote debugging + chromeURL := fmt.Sprintf("http://localhost:%d", debugPort) + allocCtx, allocCancel := chromedp.NewRemoteAllocator(context.Background(), chromeURL) + defer allocCancel() + + ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(t.Logf)) + defer cancel() + + // Set timeout for the entire test + ctx, cancel = context.WithTimeout(ctx, 120*time.Second) + defer cancel() + + t.Run("Initial Load", func(t *testing.T) { + var initialHTML string + + err := chromedp.Run(ctx, + chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)), + e2etest.WaitForWebSocketReady(5*time.Second), // Wait for WebSocket init + chromedp.WaitVisible(`h1`, chromedp.ByQuery), + e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), // Validate no raw template expressions + chromedp.OuterHTML(`body`, &initialHTML, chromedp.ByQuery), + ) + + if err != nil { + t.Fatalf("Failed to load page: %v", err) + } + + // Verify initial state + if !strings.Contains(initialHTML, "Profile Settings") { + t.Error("Page title not found") + } + if !strings.Contains(initialHTML, "John Doe") { + t.Error("Initial name not found") + } + if !strings.Contains(initialHTML, "john@example.com") { + t.Error("Initial email not found") + } + + t.Log("✅ Initial page load verified") + }) + + t.Run("UI_Standards", func(t *testing.T) { + var violations string + err := chromedp.Run(ctx, + chromedp.Evaluate(`(() => { + const v = []; + ['onclick','onchange','oninput','onsubmit','onkeydown','onkeyup'].forEach(h => { + document.querySelectorAll('[' + h + ']').forEach(el => v.push('inline ' + h + ' on <' + el.tagName.toLowerCase() + '>')); + }); + document.querySelectorAll('[style]').forEach(el => { + if (el.tagName !== 'INS' && el.tagName !== 'DEL' && !el.closest('[data-modal]') && !el.closest('[data-lvt-toast-stack]')) + v.push('inline style on <' + el.tagName.toLowerCase() + '>'); + }); + if (!document.querySelector('meta[name="color-scheme"]')) v.push('missing color-scheme meta'); + if (document.documentElement.lang !== 'en') v.push('missing lang=en'); + const c = document.querySelector('.container'); + if (c && c.offsetWidth > 700) v.push('container too wide: ' + c.offsetWidth + 'px'); + return v.join('; '); + })()`, &violations), + ) + if err != nil { + t.Fatalf("UI standards check failed: %v", err) + } + if violations != "" { + t.Errorf("UI standard violations: %s", violations) + } + var cssStatus int + chromedp.Run(ctx, chromedp.Evaluate(`(() => { const x = new XMLHttpRequest(); x.open('GET', '/livetemplate.css', false); x.send(); return x.status; })()`, &cssStatus)) + if cssStatus != 200 { + t.Logf("Warning: Shared CSS not loading: status=%d (may not be available in CI)", cssStatus) + } + }) + + t.Run("Upload Avatar and Verify", func(t *testing.T) { + // Tier 1 file upload: create a File object in JavaScript, set it on the + // input, and submit the form. The client detects the file input and sends + // via HTTP fetch with FormData. The server parses the multipart body. + + err := chromedp.Run(ctx, + // Fresh page load + chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)), + e2etest.WaitForWebSocketReady(5*time.Second), + + // Create a minimal 1x1 PNG file in JavaScript and set it on the input. + // We can't use chromedp.SetUploadFiles because Chrome runs in Docker + // and can't access host filesystem paths. + chromedp.Evaluate(` + (() => { + const b64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAMBBBQAB1x2RAAAASElEQVQI12P4z8BQDwCNAQz/cWMmRQAAAABJRU5ErkJggg=='; + const binary = atob(b64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + const file = new File([bytes], 'test-avatar.png', {type: 'image/png'}); + + const input = document.querySelector('#avatar'); + const dt = new DataTransfer(); + dt.items.add(file); + input.files = dt.files; + return 'file set (' + input.files.length + ' files)'; + })() + `, nil), + + // Click submit button + chromedp.Click(`button[type="submit"]`, chromedp.ByQuery), + + // Wait for the avatar image to appear + e2etest.WaitFor(`document.querySelector('img[alt^="Avatar"]') !== null`, 15*time.Second), + ) + if err != nil { + var debugHTML string + _ = chromedp.Run(ctx, chromedp.OuterHTML(`body`, &debugHTML, chromedp.ByQuery)) + t.Logf("Page HTML at failure:\n%s", debugHTML) + t.Fatalf("Upload flow failed: %v", err) + } + + // Verify Name and Email fields retained their values after upload + var nameVal, emailVal string + err = chromedp.Run(ctx, + chromedp.Evaluate(`document.getElementById('name').value`, &nameVal), + chromedp.Evaluate(`document.getElementById('email').value`, &emailVal), + ) + if err != nil { + t.Fatalf("Failed to read form fields after upload: %v", err) + } + if nameVal != "John Doe" { + t.Errorf("Name field should retain value after upload, got %q", nameVal) + } + if emailVal != "john@example.com" { + t.Errorf("Email field should retain value after upload, got %q", emailVal) + } + + t.Log("✅ Tier 1 file upload: avatar image rendered, form fields retained") + }) + + t.Run("WebSocket Connection", func(t *testing.T) { + // Verify WebSocket client is initialized + err := chromedp.Run(ctx, + chromedp.Evaluate(`console.log('WebSocket test'); 'logged'`, nil), + e2etest.WaitFor(`typeof LiveTemplateClient !== 'undefined'`, 3*time.Second), + ) + + if err != nil { + t.Fatalf("Failed to check WebSocket: %v", err) + } + + t.Log("✅ WebSocket connection working") + }) +} + +// ========== WebSocket Tests ========== + +// TestUploadViaWebSocket tests the complete upload flow via WebSocket +// This test reproduces the actual browser upload behavior +func TestUploadViaWebSocket(t *testing.T) { + // Create test server + state := &ProfileState{ + Name: "John Doe", + Email: "john@example.com", + } + + handler := createTestHandler(t, state) + server := httptest.NewServer(handler) + defer server.Close() + + // Connect WebSocket + wsURL := "ws" + server.URL[4:] + "/ws" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("Failed to connect WebSocket: %v", err) + } + defer conn.Close() + + // Read messages in background + messages := make(chan []byte, 10) + errors := make(chan error, 1) + go func() { + for { + _, msg, err := conn.ReadMessage() + if err != nil { + errors <- err + return + } + t.Logf("📩 Received message: %s", string(msg)) + messages <- msg + } + }() + + // Wait for initial tree + select { + case msg := <-messages: + var tree map[string]interface{} + if err := json.Unmarshal(msg, &tree); err != nil { + t.Fatalf("Failed to parse initial tree: %v", err) + } + t.Log("✅ Received initial tree") + case <-time.After(2 * time.Second): + t.Fatal("Timeout waiting for initial tree") + } + + // Create a small test image (1x1 red PNG) + pngData := []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, + 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, + 0x54, 0x08, 0x99, 0x63, 0xF8, 0x0F, 0x00, 0x00, + 0x01, 0x01, 0x00, 0x05, 0x18, 0x0D, 0xA8, 0xDB, + 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, + 0xAE, 0x42, 0x60, 0x82, + } + + // Step 1: Send upload_start + uploadStartMsg := map[string]interface{}{ + "action": "upload_start", + "upload_name": "avatar", + "files": []map[string]interface{}{ + { + "name": "test-avatar.png", + "type": "image/png", + "size": len(pngData), + }, + }, + } + + if err := conn.WriteJSON(uploadStartMsg); err != nil { + t.Fatalf("Failed to send upload_start: %v", err) + } + t.Log("📤 Sent upload_start") + + // Wait for upload_start response + var entryID string + select { + case msg := <-messages: + var response map[string]interface{} + if err := json.Unmarshal(msg, &response); err != nil { + t.Fatalf("Failed to parse upload_start response: %v", err) + } + + entries, ok := response["entries"].([]interface{}) + if !ok || len(entries) == 0 { + t.Fatalf("No entries in upload_start response: %+v", response) + } + + entry := entries[0].(map[string]interface{}) + entryID = entry["entry_id"].(string) + t.Logf("✅ Received upload_start response, entry_id: %s", entryID) + case <-time.After(2 * time.Second): + t.Fatal("Timeout waiting for upload_start response") + } + + // Step 2: Send upload chunks + chunkSize := 256 * 1024 + offset := 0 + for offset < len(pngData) { + end := offset + chunkSize + if end > len(pngData) { + end = len(pngData) + } + + chunk := pngData[offset:end] + chunkBase64 := base64.StdEncoding.EncodeToString(chunk) + + chunkMsg := map[string]interface{}{ + "action": "upload_chunk", + "entry_id": entryID, + "chunk_base64": chunkBase64, + "offset": offset, + "total": len(pngData), + } + + if err := conn.WriteJSON(chunkMsg); err != nil { + t.Fatalf("Failed to send upload_chunk: %v", err) + } + t.Logf("📤 Sent chunk %d-%d", offset, end) + + offset = end + } + + // Small delay to ensure chunks are processed + time.Sleep(100 * time.Millisecond) + + // Step 3: Send upload_complete + uploadCompleteMsg := map[string]interface{}{ + "action": "upload_complete", + "upload_name": "avatar", + "entry_ids": []string{entryID}, + } + + if err := conn.WriteJSON(uploadCompleteMsg); err != nil { + t.Fatalf("Failed to send upload_complete: %v", err) + } + t.Log("📤 Sent upload_complete") + + // Wait for tree update showing upload completion + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + receivedUpdate := false + for !receivedUpdate { + select { + case msg := <-messages: + var update map[string]interface{} + if err := json.Unmarshal(msg, &update); err != nil { + t.Logf("Skipping non-JSON message: %v", err) + continue + } + + // Check if this is a tree update (has "tree" field) + if tree, ok := update["tree"]; ok { + t.Logf("✅ Received tree update after upload_complete") + receivedUpdate = true + + // Verify the update is valid (not null/undefined) + if tree == nil { + t.Error("❌ Tree update has null tree - this causes client error!") + } + + // Verify the tree is not empty + treeMap, ok := tree.(map[string]interface{}) + if !ok || len(treeMap) == 0 { + t.Error("❌ Tree update is empty - no data changed!") + } else { + t.Logf("✅ Tree has %d root keys", len(treeMap)) + + // Check if upload entries are in the tree and have Done=true + // Position 4 is the upload-preview div content (after flash tag, avatar img, name, email) + if uploadPreview, ok := treeMap["4"]; ok { + t.Logf("📦 Upload preview section found in tree: %T", uploadPreview) + // uploadPreview is an array containing the range operation + if outerArray, ok := uploadPreview.([]interface{}); ok && len(outerArray) > 0 { + // The first element is the actual range operation like ["a", [data], [statics], {idKey}] + if rangeOp, ok := outerArray[0].([]interface{}); ok { + t.Logf("Range operation has %d elements", len(rangeOp)) + if len(rangeOp) > 1 { + t.Logf("Range operation [0] (op type): %v", rangeOp[0]) + t.Logf("Range operation [1] (items data) type: %T", rangeOp[1]) + if itemsData, ok := rangeOp[1].([]interface{}); ok { + t.Logf("Items data has %d items", len(itemsData)) + if len(itemsData) > 0 { + t.Logf("First item type: %T", itemsData[0]) + // First item should be the upload entry data + if entryData, ok := itemsData[0].(map[string]interface{}); ok { + t.Logf("📊 Upload entry data keys: %v", getKeys(entryData)) + t.Logf("📊 Upload entry data in tree: %+v", entryData) + + // Check if position 3 (the status message div wrapper) exists + if msgDiv, exists := entryData["3"]; exists { + t.Logf("✅ Position 3 (status div wrapper) exists: %T", msgDiv) + // msgDiv should be a map with position "0" containing the actual message + if msgMap, ok := msgDiv.(map[string]interface{}); ok { + if actualMsg, exists := msgMap["0"]; exists { + t.Logf("📝 Status message content: %v", actualMsg) + // Check if it contains success message + if msgStr, ok := actualMsg.(string); ok { + if msgStr == "✅ Upload complete!" || msgStr == "Upload complete!" { + t.Logf("✅ SUCCESS MESSAGE FOUND: Upload complete!") + } else { + t.Errorf("❌ Expected success message but got: %s", msgStr) + } + } else if msgTree, ok := actualMsg.(map[string]interface{}); ok { + // Message might be a nested tree + t.Logf("Message is a tree: %+v", msgTree) + } + } else { + t.Error("❌ Position 0 (actual message) missing in status div!") + } + } + } else { + t.Error("❌ Position 3 (status message) missing - success message won't show!") + t.Logf("Entry only has these positions: %v", getKeys(entryData)) + } + } else { + t.Logf("First item is not a map: %+v", itemsData[0]) + } + } + } else { + t.Logf("rangeOp[1] is not []interface{}: %+v", rangeOp[1]) + } + } + } else { + t.Logf("outerArray[0] is not a range operation array: %+v", outerArray[0]) + } + } else { + t.Logf("Upload preview is not a []interface{} or is empty: %+v", uploadPreview) + } + } else { + t.Error("❌ Upload preview section (position 4) not in tree update!") + } + } + } + case err := <-errors: + t.Fatalf("WebSocket error: %v", err) + case <-ctx.Done(): + t.Fatal("❌ Timeout waiting for tree update after upload_complete") + } + } + + t.Log("✅ Upload test completed successfully") +} + +func getKeys(m map[string]interface{}) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} + +func createTestHandler(t *testing.T, state *ProfileState) http.Handler { + // Same setup as main.go + lt, err := livetemplate.New("avatar-upload", + livetemplate.WithParseFiles("avatar-upload.tmpl"), + livetemplate.WithDevMode(true), + livetemplate.WithUpload("avatar", livetemplate.UploadConfig{ + Accept: []string{"image/jpeg", "image/png", "image/gif"}, + MaxFileSize: 5 * 1024 * 1024, + MaxEntries: 1, + AutoUpload: false, + ChunkSize: 256 * 1024, + }), + ) + if err != nil { + t.Fatalf("Failed to create LiveTemplate: %v", err) + } + + controller := &ProfileController{} + return lt.Handle(controller, livetemplate.AsState(state)) +} diff --git a/examples/avatar-upload/main.go b/examples/avatar-upload/main.go new file mode 100644 index 0000000..18284d5 --- /dev/null +++ b/examples/avatar-upload/main.go @@ -0,0 +1,161 @@ +package main + +import ( + "embed" + "fmt" + "log" + "net/http" + "os" + "path/filepath" + + "github.com/livetemplate/livetemplate" + lvttest "github.com/livetemplate/lvt/testing" +) + +//go:embed *.tmpl +var templates embed.FS + +// ProfileController is a singleton that holds dependencies. +type ProfileController struct{} + +// ProfileState is pure data, cloned per session. +type ProfileState struct { + Name string `lvt:"persist"` + Email string `lvt:"persist"` + AvatarPath string `lvt:"persist"` + AvatarURL string `lvt:"persist"` +} + +// UpdateProfile handles the "UpdateProfile" action for profile update form submission +func (c *ProfileController) UpdateProfile(state ProfileState, ctx *livetemplate.Context) (ProfileState, error) { + state.Name = ctx.GetString("name") + state.Email = ctx.GetString("email") + + // Also process avatar if it was uploaded with the form + if ctx.HasUploads("avatar") { + var err error + state, err = c.processAvatarUpload(state, ctx) + if err != nil { + return state, err + } + } + + ctx.SetFlash("success", "Profile updated") + log.Printf("Profile updated: name=%s, email=%s", state.Name, state.Email) + return state, nil +} + +// processAvatarUpload handles avatar upload processing +func (c *ProfileController) processAvatarUpload(state ProfileState, ctx *livetemplate.Context) (ProfileState, error) { + // Get completed uploads from Context + uploads := ctx.GetCompletedUploads("avatar") + log.Printf("DEBUG: ProcessAvatarUpload called, found %d completed uploads", len(uploads)) + if len(uploads) == 0 { + log.Printf("DEBUG: No completed uploads found") + return state, nil // No uploads to process + } + + // Create uploads directory if it doesn't exist + uploadsDir := "uploads" + if err := os.MkdirAll(uploadsDir, 0755); err != nil { + return state, fmt.Errorf("failed to create uploads directory: %w", err) + } + + for _, entry := range uploads { + log.Printf("DEBUG: Processing entry %s, TempPath: %s, exists: %v", entry.ID, entry.TempPath, fileExists(entry.TempPath)) + + // Check if temp file exists (may have been processed already by auto-trigger) + if !fileExists(entry.TempPath) { + log.Printf("DEBUG: Temp file already processed for entry %s, skipping", entry.ID) + continue + } + + // Generate permanent filename + ext := filepath.Ext(entry.ClientName) + permanentPath := filepath.Join(uploadsDir, fmt.Sprintf("avatar-%s%s", entry.ID, ext)) + + // Move from temp to permanent location + if err := os.Rename(entry.TempPath, permanentPath); err != nil { + log.Printf("DEBUG: Rename failed: %v, trying copy", err) + // If rename fails (different filesystem), try copy + if err := copyFile(entry.TempPath, permanentPath); err != nil { + return state, fmt.Errorf("failed to save avatar: %w", err) + } + os.Remove(entry.TempPath) // Clean up temp file + } + + // Update state with new avatar + state.AvatarPath = permanentPath + state.AvatarURL = "/" + permanentPath + + log.Printf("Avatar saved: %s (original: %s, size: %d bytes)", permanentPath, entry.ClientName, entry.ClientSize) + } + + return state, nil +} + +// copyFile copies a file from src to dst +func copyFile(src, dst string) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + return os.WriteFile(dst, data, 0644) +} + +// fileExists checks if a file exists +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func main() { + // Parse port from environment or use default + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + // Create LiveTemplate instance with upload configuration + lt := livetemplate.Must(livetemplate.New("avatar-upload", + livetemplate.WithParseFiles("avatar-upload.tmpl"), + livetemplate.WithDevMode(true), + // Configure upload using WithUpload option + livetemplate.WithUpload("avatar", livetemplate.UploadConfig{ + Accept: []string{"image/jpeg", "image/png", "image/gif"}, + MaxFileSize: 5 * 1024 * 1024, // 5MB + MaxEntries: 1, // Single file + }), + )) + + // Create controller (singleton) + controller := &ProfileController{} + + // Create initial state (pure data, cloned per session) + initialState := &ProfileState{ + Name: "John Doe", + Email: "john@example.com", + } + + // Create handler with Controller+State pattern + handler := lt.Handle(controller, livetemplate.AsState(initialState)) + + // Serve static files (for uploaded avatars) + http.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir("uploads")))) + + // Serve client library + http.HandleFunc("/livetemplate-client.js", lvttest.ServeClientLibrary) + http.HandleFunc("/livetemplate.css", lvttest.ServeCSS) + + // Mount the LiveTemplate handler + http.Handle("/", handler) + + // Start server + addr := ":" + port + log.Printf("Avatar upload example running at http://localhost%s", addr) + log.Printf("Uploaded files will be saved to ./uploads/") + + if err := http.ListenAndServe(addr, nil); err != nil { + log.Fatal(err) + } +} diff --git a/examples/avatar-upload/run.sh b/examples/avatar-upload/run.sh new file mode 100755 index 0000000..99f21a5 --- /dev/null +++ b/examples/avatar-upload/run.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Simple script to run the avatar upload example + +cd "$(dirname "$0")" + +echo "🚀 Starting Avatar Upload Example..." +echo "📍 Server will run at http://localhost:8082" +echo "" + +# Set PORT and run +PORT=8082 go run main.go diff --git a/examples/avatar-upload/uploads/.gitkeep b/examples/avatar-upload/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/examples/chat/README.md b/examples/chat/README.md new file mode 100644 index 0000000..22e2872 --- /dev/null +++ b/examples/chat/README.md @@ -0,0 +1,521 @@ +# Building a Real-Time Chat App with LiveTemplate + +A complete tutorial for building a real-time chat application using LiveTemplate's simple kit. This demonstrates **automatic multi-tab syncing**, session management, and reactive UI updates with just **2 files**. + +## What You'll Build + +- Real-time messaging with automatic tab syncing +- User login and presence tracking +- Instant UI updates across all tabs in the same browser +- Browser session isolation (each browser has its own chat room) +- Message history and timestamps + +**All in just 2 files: `main.go` and `chat.tmpl`** + +## Quick Start + +```bash +cd examples/chat +GOWORK=off go run main.go +``` + +Then open in **multiple browser tabs** to see automatic syncing in action: +- Messages sent in one tab appear instantly in all other tabs +- Each browser gets its own isolated chat session + +## Tutorial: Building from Scratch + +### Step 1: Create a New App + +Start by creating a new LiveTemplate application with the `simple` kit: + +```bash +lvt new chat --kit simple +cd chat +``` + +The `simple` kit generates a minimal structure: + +- `main.go` - Application logic (single file) +- `chat.tmpl` - HTML template (single file) +- `go.mod` - Go module configuration +- `README.md` - Documentation + +No cmd/, internal/, or database directories. Perfect for focused applications! + +### Step 2: Define the Chat State + +Open `main.go` and replace the counter example with chat state: + +```go +package main + +import ( + "log" + "net/http" + "os" + "sync" + "time" + + "github.com/livetemplate/livetemplate" +) + +type ChatState struct { + Messages []Message + Users map[string]*User + CurrentUser string + OnlineCount int + TotalMessages int + mu sync.RWMutex // Thread-safe access +} + +type Message struct { + ID int + Username string + Text string + Timestamp string +} + +type User struct { + Username string + JoinedAt time.Time + IsOnline bool +} +``` + +**Key concepts:** + +- Single `ChatState` struct holds all app state +- `sync.RWMutex` for thread-safe concurrent access +- Simple Go structs - no database, no ORM, no complexity + +### Step 3: Implement Actions + +Add the `Change` method to handle user actions: + +```go +func (s *ChatState) Change(ctx *livetemplate.ActionContext) error { + s.mu.Lock() + defer s.mu.Unlock() + + switch ctx.Action { + case "send": + var data struct { + Message string `json:"message"` + } + + if err := ctx.Bind(&data); err != nil { + return nil + } + + if data.Message == "" { + return nil + } + + s.TotalMessages++ + msg := Message{ + ID: s.TotalMessages, + Username: s.CurrentUser, + Text: data.Message, + Timestamp: time.Now().Format("15:04:05"), + } + + s.Messages = append(s.Messages, msg) + return nil // Auto-syncs to all tabs in same browser! + + case "join": + var data struct { + Username string `json:"username"` + } + + if err := ctx.Bind(&data); err != nil { + return nil + } + + s.CurrentUser = data.Username + + if _, exists := s.Users[data.Username]; !exists { + s.Users[data.Username] = &User{ + Username: data.Username, + JoinedAt: time.Now(), + IsOnline: true, + } + s.updateOnlineCount() + } + + return nil + } + + return nil +} + +func (s *ChatState) updateOnlineCount() { + count := 0 + for _, user := range s.Users { + if user.IsOnline { + count++ + } + } + s.OnlineCount = count +} +``` + +**Key concepts:** + +- Actions route via `
` and `` (button/form `name` routing) +- `ctx.GetString("field")` extracts form data +- Just modify state - broadcasting happens automatically! +- No manual WebSocket code needed + +### Step 4: Initialize and Run + +Add initialization and main function: + +```go +func (s *ChatState) Init() error { + if s.Users == nil { + s.Users = make(map[string]*User) + } + if s.Messages == nil { + s.Messages = []Message{} + } + return nil +} + +func main() { + log.Println("chat starting...") + + state := &ChatState{ + Users: make(map[string]*User), + Messages: []Message{}, + } + + tmpl := livetemplate.New("chat", livetemplate.WithDevMode(true)) + http.Handle("/", tmpl.Handle(state)) + + // Serve client library for development + http.HandleFunc("/livetemplate-client.js", serveClientLibrary) + + port := os.Getenv("PORT") + if port == "" { + port = "8090" + } + + log.Printf("🚀 Chat server starting on http://localhost:%s", port) + log.Println("📝 Open multiple browser tabs to test multi-user chat") + log.Println("💬 Messages are broadcast to all connected users") + + http.ListenAndServe(":"+port, nil) +} +``` + +### Step 5: Create the UI + +Replace `chat.tmpl` with the chat interface. Key template concepts: + +**Conditional Rendering:** + +```html +{{if not .CurrentUser}} + +{{else}} + +{{end}} +``` + +**Message Loop:** + +```html +{{range .Messages}} +
+
+ {{.Username}} + {{.Timestamp}} +
+
{{.Text}}
+
+{{end}} +``` + +**Form Actions:** + +```html + + + +
+ +
+ + +
+``` + +**Auto-scroll Script:** + +```html + +``` + +### Step 6: Run and Test + +```bash +go run main.go +``` + +Open in multiple browser tabs: + +**Test 1 - Same browser, multiple tabs:** + +- Open 2+ tabs in Chrome +- Login with any username in tab 1 +- Send a message in tab 1 +- **It appears instantly in tab 2!** ✨ +- Try sending from tab 2 - appears in tab 1 + +**Test 2 - Different browsers (isolated sessions):** + +- Open Chrome and Firefox +- Each browser gets its own chat room +- Messages in Chrome don't appear in Firefox +- Each browser maintains separate state + +## How It Works + +### Automatic Session Syncing + +```text +Chrome Tab 1 Server (Go) Chrome Tab 2 + | | | + |---- join -------->| | + | [groupID: session-abc] | + | |<------ join --------| + | [Same groupID: session-abc] | + | | | + |--- send msg ----->| | + | [Auto-broadcast to group] | + |<---- update ------|------- update ----->| + | | | +``` + +**The magic:** + +1. Each browser gets a unique session ID (stored in cookie) +2. All tabs in the same browser share the session ID +3. State changes automatically sync to all tabs in the same session +4. Only changed HTML is sent (tree-diffing) +5. Zero manual broadcasting code required! + +### Why So Simple? + +**Traditional approach (what you DON'T need):** + +- ❌ Manual WebSocket management +- ❌ Database setup +- ❌ ORM configuration +- ❌ Complex directory structure +- ❌ Separate frontend/backend +- ❌ API endpoints +- ❌ State sync logic + +**LiveTemplate simple kit:** + +- ✅ Just modify Go structs +- ✅ 2 files total +- ✅ Auto-broadcasting +- ✅ Auto-updates +- ✅ Standard `html/template` +- ✅ Standard `net/http` + +## Customization Ideas + +### Add Persistence + +Store messages in a slice that survives restarts: + +```go +var persistedMessages []Message + +func (s *ChatState) Init() error { + s.Messages = persistedMessages // Load from memory + // Or load from file: loadFromJSON("messages.json") + return nil +} + +func (s *ChatState) Change(ctx *livetemplate.ActionContext) error { + // ... after adding message + persistedMessages = s.Messages // Save to memory + // Or save to file: saveToJSON("messages.json", s.Messages) +} +``` + +### Add Typing Indicators + +```go +type ChatState struct { + // ... existing fields + TypingUsers map[string]bool +} + +// In Change() +case "typing": + var data struct { + Username string `json:"username"` + } + ctx.Bind(&data) + s.TypingUsers[data.Username] = true + // Auto-broadcast! +``` + +### Add Message Reactions + +```go +type Message struct { + // ... existing fields + Reactions map[string]int // emoji -> count +} + +case "react": + var data struct { + MessageID int `json:"messageId"` + Emoji string `json:"emoji"` + } + ctx.Bind(&data) + s.Messages[data.MessageID].Reactions[data.Emoji]++ +``` + +### Add Chat Rooms + +```go +type ChatState struct { + Rooms map[string]*Room + CurrentRoom string +} + +type Room struct { + Name string + Messages []Message +} +``` + +## Production Considerations + +### 1. Use CDN for Client Library + +In `chat.tmpl`: + +```html + +``` + +### 2. Add Rate Limiting + +```go +case "send": + if time.Since(s.LastMessageTime) < time.Second { + return nil // Too fast, ignore + } + // ... process message +``` + +### 3. Add Message Limits + +```go +if len(s.Messages) > 100 { + s.Messages = s.Messages[len(s.Messages)-100:] // Keep last 100 +} +``` + +### 4. Add Authentication + +For production, use real auth instead of just username: + +```go +auth := livetemplate.NewBasicAuthenticator(func(username, password string) (bool, error) { + return validateUser(username, password) +}) + +tmpl := livetemplate.New("chat", + livetemplate.WithDevMode(false), + livetemplate.WithAuthenticator(auth), +) +``` + +### 5. Create a Global Chat Room (Cross-Browser) + +By default, each browser has its own isolated chat. To make all users share the same chat room: + +```go +// Custom authenticator that puts everyone in same session group +type GlobalChatAuthenticator struct{} + +func (a *GlobalChatAuthenticator) Identify(r *http.Request) (string, error) { + return "", nil // Anonymous +} + +func (a *GlobalChatAuthenticator) GetSessionGroup(r *http.Request, userID string) (string, error) { + return "global-chat-room", nil // Everyone shares same group! +} + +// Use it: +tmpl := livetemplate.New("chat", + livetemplate.WithDevMode(true), + livetemplate.WithAuthenticator(&GlobalChatAuthenticator{}), +) +``` + +Now Chrome, Firefox, Safari all see the same messages! + +## Key Takeaways + +1. **Two files** - That's it! `main.go` + `chat.tmpl` +2. **Zero boilerplate** - No cmd/, internal/, database/ +3. **Auto-syncing** - Tabs stay in sync automatically +4. **Standard Go** - Uses `net/http` and `html/template` +5. **Type-safe** - Go structs, no JSON marshaling needed +6. **Efficient** - Tree-diffing sends only changes + +## Comparison with Counter Example + +The simple kit starts with a counter. Here's how we evolved it: + +| Counter Example | Chat Example | +|-----------------|--------------| +| `AppState{Counter int}` | `ChatState{Messages []Message}` | +| `increment/decrement` actions | `join/send` actions | +| Single user | Multi-user with broadcasting | +| Simple int update | List of messages | + +Same pattern, different data! + +## Next Steps + +- Try the `counter` example for a simpler starting point +- Try the `todos` example for CRUD operations +- Use `lvt new myapp --kit multi` for apps needing databases + +## Related Documentation + +- [LiveTemplate Core Docs](../../README.md) +- [Broadcasting Guide](../../docs/design/IMPLEMENTATION_STATUS.md) +- [Template Syntax](https://pkg.go.dev/html/template) +- [LiveTemplate API](https://pkg.go.dev/github.com/livetemplate/livetemplate) diff --git a/examples/chat/chat.tmpl b/examples/chat/chat.tmpl new file mode 100644 index 0000000..f397087 --- /dev/null +++ b/examples/chat/chat.tmpl @@ -0,0 +1,64 @@ + + + + + + + Chat — LiveTemplate + + {{if .lvt.DevMode}} + + + {{else}} + + + {{end}} + + +
+
+
+
+

Chat

+ {{if .CurrentUser}} +

Logged in as {{.CurrentUser}} · {{.OnlineCount}} user{{if ne .OnlineCount 1}}s{{end}} online · {{.TotalMessages}} message{{if ne .TotalMessages 1}}s{{end}}

+ {{else}} +

Welcome! Please login to join the chat

+ {{end}} +
+
+ + {{if not .CurrentUser}} +
+ + +
+ {{else}} +
+ {{if eq (len .Messages) 0}} +

No messages yet. Be the first to send one!

+ {{else}} + {{range .Messages}} +
+ {{.Username}} · {{.Timestamp}} +

{{.Text}}

+
+ {{end}} + {{end}} +
+ +
+
+ + +
+
+ {{end}} +
+
+ + + diff --git a/examples/chat/chat_e2e_test.go b/examples/chat/chat_e2e_test.go new file mode 100644 index 0000000..e0610df --- /dev/null +++ b/examples/chat/chat_e2e_test.go @@ -0,0 +1,396 @@ +package main + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/chromedp/chromedp" + e2etest "github.com/livetemplate/lvt/testing" +) + +// waitFor polls a JavaScript condition until it returns true or timeout is reached +func waitFor(condition string, timeout time.Duration) chromedp.Action { + return chromedp.ActionFunc(func(ctx context.Context) error { + startTime := time.Now() + for { + var result bool + err := chromedp.Evaluate(condition, &result).Do(ctx) + if err != nil { + return fmt.Errorf("failed to evaluate condition '%s': %w", condition, err) + } + if result { + return nil + } + if time.Since(startTime) > timeout { + return fmt.Errorf("timeout waiting for condition '%s' after %v", condition, timeout) + } + time.Sleep(10 * time.Millisecond) + } + }) +} + +// TestChatE2E tests the chat app end-to-end with a real browser +func TestChatE2E(t *testing.T) { + if testing.Short() { + t.Skip("Skipping E2E test in short mode") + } + + // Get free ports for server and Chrome debugging + serverPort, err := e2etest.GetFreePort() + if err != nil { + t.Fatalf("Failed to get free port for server: %v", err) + } + + debugPort, err := e2etest.GetFreePort() + if err != nil { + t.Fatalf("Failed to get free port for Chrome: %v", err) + } + + // Start chat server using e2etest helper + serverCmd := e2etest.StartTestServer(t, "main.go", serverPort) + defer func() { + if serverCmd != nil && serverCmd.Process != nil { + serverCmd.Process.Kill() + } + }() + + serverURL := fmt.Sprintf("http://localhost:%d", serverPort) + t.Logf("✅ Test server ready at %s", serverURL) + + // Start Docker Chrome container + chromeCmd := e2etest.StartDockerChrome(t, debugPort) + defer e2etest.StopDockerChrome(t, debugPort) + _ = chromeCmd // Command returned for reference; cleanup handled by StopDockerChrome + + // Connect to Docker Chrome via remote debugging + chromeURL := fmt.Sprintf("http://localhost:%d", debugPort) + allocCtx, allocCancel := chromedp.NewRemoteAllocator(context.Background(), chromeURL) + defer allocCancel() + + browserCtx, cancelBrowser := chromedp.NewContext(allocCtx, chromedp.WithLogf(t.Logf)) + defer cancelBrowser() + + // Set timeout for the entire test + browserCtx, cancelTimeout := context.WithTimeout(browserCtx, 120*time.Second) + defer cancelTimeout() + + // URL for Docker Chrome to access the server + chromeTestURL := e2etest.GetChromeTestURL(serverPort) + + t.Run("Initial_Load", func(t *testing.T) { + var initialHTML string + + err := chromedp.Run(browserCtx, + chromedp.Navigate(chromeTestURL), + chromedp.WaitVisible(`[data-lvt-id]`, chromedp.ByQuery), + waitFor(`typeof window.liveTemplateClient !== 'undefined'`, 5*time.Second), + chromedp.WaitVisible(`input[name="username"]`, chromedp.ByQuery), + chromedp.OuterHTML(`body`, &initialHTML, chromedp.ByQuery), + ) + + if err != nil { + t.Fatalf("Failed to load page: %v", err) + } + + // Verify welcome message visible + if !strings.Contains(initialHTML, "Welcome") { + t.Errorf("Initial page should show welcome message") + } + + // Verify join form visible + if !strings.Contains(initialHTML, `name="username"`) { + t.Errorf("Initial page should show username input") + } + + // Verify no template expressions leaked + if strings.Contains(initialHTML, "{{") { + t.Errorf("Initial HTML contains unprocessed template expressions") + } + + t.Logf("✅ Initial page loaded correctly") + }) + + t.Run("UI_Standards", func(t *testing.T) { + var violations string + err := chromedp.Run(browserCtx, + chromedp.Evaluate(`(() => { + const v = []; + ['onclick','onchange','oninput','onsubmit','onkeydown','onkeyup'].forEach(h => { + document.querySelectorAll('[' + h + ']').forEach(el => v.push('inline ' + h + ' on <' + el.tagName.toLowerCase() + '>')); + }); + document.querySelectorAll('[style]').forEach(el => { + if (el.tagName !== 'INS' && el.tagName !== 'DEL' && !el.closest('[data-modal]') && !el.closest('[data-lvt-toast-stack]')) + v.push('inline style on <' + el.tagName.toLowerCase() + '>'); + }); + if (!document.querySelector('meta[name="color-scheme"]')) v.push('missing color-scheme meta'); + if (document.documentElement.lang !== 'en') v.push('missing lang=en'); + const c = document.querySelector('.container'); + if (c && c.offsetWidth > 700) v.push('container too wide: ' + c.offsetWidth + 'px'); + return v.join('; '); + })()`, &violations), + ) + if err != nil { + t.Fatalf("UI standards check failed: %v", err) + } + if violations != "" { + t.Errorf("UI standard violations: %s", violations) + } + var cssStatus int + chromedp.Run(browserCtx, chromedp.Evaluate(`(() => { const x = new XMLHttpRequest(); x.open('GET', '/livetemplate.css', false); x.send(); return x.status; })()`, &cssStatus)) + if cssStatus != 200 { + t.Logf("Warning: Shared CSS not loading: status=%d (may not be available in CI)", cssStatus) + } + }) + + t.Run("Join_Flow", func(t *testing.T) { + var initialStatsText string + var initialFormVisible bool + var afterStatsText string + var afterChatVisible bool + var afterFormVisible bool + + err := chromedp.Run(browserCtx, + // Capture initial state + chromedp.Text("hgroup p", &initialStatsText, chromedp.ByQuery), + chromedp.Evaluate(`document.querySelector('form[name="join"]') !== null`, &initialFormVisible), + + // Fill and submit join form + chromedp.SetValue(`input[name="username"]`, "testuser", chromedp.ByQuery), + chromedp.Click(`form[name="join"] button[type="submit"]`, chromedp.ByQuery), + waitFor(`document.querySelector('.messages') !== null`, 5*time.Second), + + // Capture after-join state + chromedp.Text("hgroup p", &afterStatsText, chromedp.ByQuery), + chromedp.Evaluate(`document.querySelector('.messages') !== null`, &afterChatVisible), + chromedp.Evaluate(`document.querySelector('form[name="join"]') !== null`, &afterFormVisible), + ) + + if err != nil { + t.Fatalf("Join flow failed: %v", err) + } + + // Verify initial state + if !strings.Contains(initialStatsText, "Welcome") { + t.Errorf("Initial stats should show welcome message, got: %q", initialStatsText) + } + if !initialFormVisible { + t.Error("Join form should be visible initially") + } + + // Verify after-join state + if !strings.Contains(afterStatsText, "Logged in as testuser") { + t.Errorf("After join, stats should show logged in state, got: %q", afterStatsText) + } + if !strings.Contains(afterStatsText, "user") && !strings.Contains(afterStatsText, "online") { + t.Errorf("After join, stats should show online users, got: %q", afterStatsText) + } + if !strings.Contains(afterStatsText, "message") { + t.Errorf("After join, stats should show message count, got: %q", afterStatsText) + } + if !afterChatVisible { + t.Error("Chat interface should be visible after join") + } + if afterFormVisible { + t.Error("Join form should NOT be visible after join") + } + + t.Logf("✅ Chat join flow test passed") + t.Logf(" Initial: %q", initialStatsText) + t.Logf(" After: %q", afterStatsText) + }) + + t.Run("Send_Message", func(t *testing.T) { + var beforeHTML string + var after1HTML string + var after2HTML string + var after3HTML string + var msg1Count, msg2Count, msg3Count int + var msg1Text, msg2Text, msg3Text string + + // Note: This test depends on Join_Flow having run first in the same browser context + // When run standalone, we need to ensure we're in the joined state + var isJoined bool + chromedp.Run(browserCtx, + chromedp.Evaluate(`document.querySelector('.messages') !== null`, &isJoined), + ) + + if !isJoined { + t.Log("Not yet joined, performing join...") + chromedp.Run(browserCtx, + chromedp.WaitVisible(`input[name="username"]`, chromedp.ByQuery), + chromedp.SetValue(`input[name="username"]`, "testuser", chromedp.ByQuery), + chromedp.Click(`form[name="join"] button[type="submit"]`, chromedp.ByQuery), + waitFor(`document.querySelector('.messages') !== null`, 5*time.Second), + chromedp.WaitVisible(`.messages`, chromedp.ByQuery), + ) + t.Log("Join completed, .messages container is visible") + } + + err := chromedp.Run(browserCtx, + + chromedp.ActionFunc(func(ctx context.Context) error { + t.Log("Step 1: Capturing initial state") + return nil + }), + chromedp.OuterHTML(`.messages`, &beforeHTML, chromedp.ByQuery), + + chromedp.ActionFunc(func(ctx context.Context) error { + t.Log("Step 2: Sending FIRST message") + return nil + }), + chromedp.SetValue(`input[name="message"]`, "First message", chromedp.ByQuery), + chromedp.Click(`form[name="send"] button[type="submit"]`, chromedp.ByQuery), + waitFor(`document.querySelectorAll('.messages .message').length >= 1`, 5*time.Second), + + chromedp.ActionFunc(func(ctx context.Context) error { + t.Log("Step 3: Checking first message") + return nil + }), + chromedp.Evaluate(`document.querySelectorAll('.messages .message').length`, &msg1Count), + chromedp.OuterHTML(`.messages`, &after1HTML, chromedp.ByQuery), + chromedp.Evaluate(`Array.from(document.querySelectorAll('.message p')).map(el => el.textContent).join('|')`, &msg1Text), + + chromedp.ActionFunc(func(ctx context.Context) error { + t.Logf("After 1st: count=%d, text=%q", msg1Count, msg1Text) + return nil + }), + + chromedp.ActionFunc(func(ctx context.Context) error { + t.Log("Step 4: Sending SECOND message") + return nil + }), + chromedp.SetValue(`input[name="message"]`, "Second message", chromedp.ByQuery), + chromedp.Click(`form[name="send"] button[type="submit"]`, chromedp.ByQuery), + waitFor(`document.querySelectorAll('.messages .message').length >= 2`, 5*time.Second), + + chromedp.ActionFunc(func(ctx context.Context) error { + t.Log("Step 5: Checking second message") + return nil + }), + chromedp.Evaluate(`document.querySelectorAll('.messages .message').length`, &msg2Count), + chromedp.OuterHTML(`.messages`, &after2HTML, chromedp.ByQuery), + chromedp.Evaluate(`Array.from(document.querySelectorAll('.message p')).map(el => el.textContent).join('|')`, &msg2Text), + + chromedp.ActionFunc(func(ctx context.Context) error { + t.Logf("After 2nd: count=%d, text=%q", msg2Count, msg2Text) + return nil + }), + + chromedp.ActionFunc(func(ctx context.Context) error { + t.Log("Step 6: Sending THIRD message") + return nil + }), + chromedp.SetValue(`input[name="message"]`, "Third message", chromedp.ByQuery), + chromedp.Click(`form[name="send"] button[type="submit"]`, chromedp.ByQuery), + waitFor(`document.querySelectorAll('.messages .message').length >= 3`, 5*time.Second), + + chromedp.ActionFunc(func(ctx context.Context) error { + t.Log("Step 7: Checking third message") + return nil + }), + chromedp.Evaluate(`document.querySelectorAll('.messages .message').length`, &msg3Count), + chromedp.OuterHTML(`.messages`, &after3HTML, chromedp.ByQuery), + chromedp.Evaluate(`Array.from(document.querySelectorAll('.message p')).map(el => el.textContent).join('|')`, &msg3Text), + ) + + if err != nil { + t.Fatalf("Send message failed: %v", err) + } + + // Log state at each step + t.Logf("Before: empty=%v", strings.Contains(beforeHTML, "No messages yet")) + t.Logf("After 1st: count=%d, texts=%q", msg1Count, msg1Text) + t.Logf("After 2nd: count=%d, texts=%q", msg2Count, msg2Text) + t.Logf("After 3rd: count=%d, texts=%q", msg3Count, msg3Text) + + // Verify first message + if msg1Count != 1 { + t.Errorf("After 1st message: expected 1 message, got %d", msg1Count) + t.Logf("HTML after 1st:\n%s", after1HTML) + } + if !strings.Contains(msg1Text, "First message") { + t.Errorf("After 1st message: expected 'First message', got %q", msg1Text) + } + + // Verify second message + if msg2Count != 2 { + t.Errorf("After 2nd message: expected 2 messages, got %d", msg2Count) + t.Logf("HTML after 2nd:\n%s", after2HTML) + } + if !strings.Contains(msg2Text, "First message") { + t.Errorf("After 2nd message: 'First message' missing from %q", msg2Text) + } + if !strings.Contains(msg2Text, "Second message") { + t.Errorf("After 2nd message: 'Second message' missing from %q", msg2Text) + } + + // Verify third message + if msg3Count != 3 { + t.Errorf("After 3rd message: expected 3 messages, got %d", msg3Count) + t.Logf("HTML after 3rd:\n%s", after3HTML) + } + if !strings.Contains(msg3Text, "First message") { + t.Errorf("After 3rd message: 'First message' missing from %q", msg3Text) + } + if !strings.Contains(msg3Text, "Second message") { + t.Errorf("After 3rd message: 'Second message' missing from %q", msg3Text) + } + if !strings.Contains(msg3Text, "Third message") { + t.Errorf("After 3rd message: 'Third message' missing from %q", msg3Text) + } + + // Verify message input was cleared after submit (form auto-reset) + var inputVal string + chromedp.Run(browserCtx, + chromedp.Evaluate(`document.querySelector('input[name="message"]').value`, &inputVal), + ) + if inputVal != "" { + t.Errorf("Message input should be empty after send, got %q", inputVal) + } + + // Verify stats contain message count + var statsText string + chromedp.Run(browserCtx, + chromedp.TextContent(`hgroup p`, &statsText, chromedp.ByQuery), + ) + if !strings.Contains(statsText, "3") { + t.Errorf("Stats should contain message count '3', got %q", statsText) + } + + t.Logf("✅ Multiple message send test passed") + }) + + t.Run("WebSocket_Updates", func(t *testing.T) { + var finalHTML string + + err := chromedp.Run(browserCtx, + chromedp.OuterHTML(`[data-lvt-id]`, &finalHTML, chromedp.ByQuery), + ) + + if err != nil { + t.Fatalf("Failed to get final HTML: %v", err) + } + + // Verify no template expressions leaked through + if strings.Contains(finalHTML, "{{") { + t.Errorf("Final HTML contains template expressions") + } + + // Verify messages are present (from Send_Message test) + if !strings.Contains(finalHTML, "First message") { + t.Errorf("Final HTML should contain 'First message'") + } + if !strings.Contains(finalHTML, "Third message") { + t.Errorf("Final HTML should contain 'Third message'") + } + + t.Logf("✅ WebSocket updates working correctly") + }) + + t.Logf("\n============================================================") + t.Logf("🎉 All Chat E2E tests passed!") + t.Logf("============================================================") +} diff --git a/examples/chat/main.go b/examples/chat/main.go new file mode 100644 index 0000000..e3b18d2 --- /dev/null +++ b/examples/chat/main.go @@ -0,0 +1,214 @@ +package main + +import ( + "log" + "net/http" + "os" + "sync" + "time" + + "github.com/livetemplate/livetemplate" + e2etest "github.com/livetemplate/lvt/testing" +) + +// ChatController is a singleton holding shared data (messages, users). +// With per-connection state, the controller is the single source of truth +// for data that all tabs need to see. Each tab has its own ChatState clone. +type ChatController struct { + mu sync.RWMutex + messages []Message + users map[string]bool // username → online + totalMessages int +} + +// ChatState is per-connection — each tab gets its own independent copy. +// CurrentUser is never shared across tabs. +type ChatState struct { + Messages []Message `json:"messages"` + CurrentUser string `json:"current_user" lvt:"persist"` + OnlineCount int `json:"online_count"` + TotalMessages int `json:"total_messages"` +} + +type Message struct { + ID int `json:"id"` + Username string `json:"username"` + Text string `json:"text"` + Timestamp string `json:"timestamp"` +} + +// Mount runs once per session group. Subscribes the self-topic so peer tabs +// receive the UserJoined / NewMessage / UserLeft dispatches Publish'd below. +func (c *ChatController) Mount(state ChatState, ctx *livetemplate.Context) (ChatState, error) { + if err := ctx.Subscribe(ctx.SelfTopic()); err != nil { + return state, err + } + c.mu.RLock() + defer c.mu.RUnlock() + state.Messages = c.copyMessages() + state.TotalMessages = c.totalMessages + state.OnlineCount = c.countOnline() + return state, nil +} + +// OnConnect is called every WebSocket connection (every tab). +// Each tab starts with no user — must join independently. +func (c *ChatController) OnConnect(state ChatState, ctx *livetemplate.Context) (ChatState, error) { + c.mu.RLock() + defer c.mu.RUnlock() + state.CurrentUser = "" + state.Messages = c.copyMessages() + state.TotalMessages = c.totalMessages + state.OnlineCount = c.countOnline() + return state, nil +} + +// Join handles the "join" action when a user joins in this tab. +// Sets CurrentUser on this connection only, then broadcasts to other tabs. +func (c *ChatController) Join(state ChatState, ctx *livetemplate.Context) (ChatState, error) { + username := ctx.GetString("username") + if username == "" { + return state, nil + } + + c.mu.Lock() + state.CurrentUser = username + c.users[username] = true + state.OnlineCount = c.countOnline() + c.mu.Unlock() + + // Tell other tabs someone joined. We propagate Publish's error rather than + // log-and-swallow because the only errors it can return are programmer + // errors (empty SelfTopic from a misconfigured Authenticator, or the + // per-action publish cap exceeded). Surfacing them loudly is a feature. + // Same pattern applies to every Publish call site in this file. + if err := ctx.Publish(ctx.SelfTopic(), "UserJoined", nil); err != nil { + return state, err + } + return state, nil +} + +// UserJoined is dispatched on other connections when someone joins. +// Each tab refreshes its online count from the controller. +func (c *ChatController) UserJoined(state ChatState, ctx *livetemplate.Context) (ChatState, error) { + c.mu.RLock() + defer c.mu.RUnlock() + state.OnlineCount = c.countOnline() + return state, nil +} + +// Send handles the "send" action to send a chat message. +// Adds message to shared store, updates this tab, broadcasts to others. +func (c *ChatController) Send(state ChatState, ctx *livetemplate.Context) (ChatState, error) { + text := ctx.GetString("message") + if text == "" || state.CurrentUser == "" { + return state, nil + } + + c.mu.Lock() + c.totalMessages++ + msg := Message{ + ID: c.totalMessages, + Username: state.CurrentUser, + Text: text, + Timestamp: time.Now().Format("15:04:05"), + } + c.messages = append(c.messages, msg) + state.Messages = c.copyMessages() + state.TotalMessages = c.totalMessages + c.mu.Unlock() + + // Tell other tabs about the new message + if err := ctx.Publish(ctx.SelfTopic(), "NewMessage", nil); err != nil { + return state, err + } + return state, nil +} + +// NewMessage is dispatched on other connections when a message is sent. +// Each tab reloads messages from the controller. +func (c *ChatController) NewMessage(state ChatState, ctx *livetemplate.Context) (ChatState, error) { + c.mu.RLock() + defer c.mu.RUnlock() + state.Messages = c.copyMessages() + state.TotalMessages = c.totalMessages + return state, nil +} + +// Leave handles the "leave" action. +func (c *ChatController) Leave(state ChatState, ctx *livetemplate.Context) (ChatState, error) { + if state.CurrentUser == "" { + return state, nil + } + + c.mu.Lock() + delete(c.users, state.CurrentUser) + state.CurrentUser = "" + state.OnlineCount = c.countOnline() + c.mu.Unlock() + + if err := ctx.Publish(ctx.SelfTopic(), "UserLeft", nil); err != nil { + return state, err + } + return state, nil +} + +// UserLeft is dispatched on other connections when someone leaves. +func (c *ChatController) UserLeft(state ChatState, ctx *livetemplate.Context) (ChatState, error) { + c.mu.RLock() + defer c.mu.RUnlock() + state.OnlineCount = c.countOnline() + return state, nil +} + +func (c *ChatController) countOnline() int { + count := 0 + for _, online := range c.users { + if online { + count++ + } + } + return count +} + +func (c *ChatController) copyMessages() []Message { + msgs := make([]Message, len(c.messages)) + copy(msgs, c.messages) + return msgs +} + +func main() { + log.Println("chat starting...") + + envConfig, err := livetemplate.LoadEnvConfig() + if err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + if err := envConfig.Validate(); err != nil { + log.Fatalf("Invalid configuration: %v", err) + } + + controller := &ChatController{ + users: make(map[string]bool), + } + + initialState := &ChatState{} + + tmpl := livetemplate.Must(livetemplate.New("chat", envConfig.ToOptions()...)) + + http.Handle("/", tmpl.Handle(controller, livetemplate.AsState(initialState))) + http.HandleFunc("/livetemplate-client.js", e2etest.ServeClientLibrary) + http.HandleFunc("/livetemplate.css", e2etest.ServeCSS) + + port := os.Getenv("PORT") + if port == "" { + port = "8090" + } + + log.Printf("Chat server starting on http://localhost:%s", port) + log.Println("Open multiple tabs — each tab joins independently") + + if err := http.ListenAndServe(":"+port, nil); err != nil { + log.Fatalf("Server failed to start: %v", err) + } +} diff --git a/examples/dialog-patterns/dialog-patterns.tmpl b/examples/dialog-patterns/dialog-patterns.tmpl new file mode 100644 index 0000000..3fcfac0 --- /dev/null +++ b/examples/dialog-patterns/dialog-patterns.tmpl @@ -0,0 +1,80 @@ + + + + + + + Dialog Patterns — LiveTemplate + + {{if .lvt.DevMode}} + + + {{else}} + + + {{end}} + + +
+
+
+
+

Dialog Patterns

+

Native <dialog> with command/commandfor (Tier 1)

+
+
+ + + + {{ if .Items }} + + + + + + + + + {{ range .Items }} + + + + + {{ end }} + +
Title
{{.Title}} + +
+ {{ else }} +

No items yet. Add one above!

+ {{ end }} + +
+ {{len .Items}} items +
+
+
+ + +
+
+

Add Item

+
+
+ +
+
+ + +
+
+
+
+
+ + + diff --git a/examples/dialog-patterns/dialog_patterns_test.go b/examples/dialog-patterns/dialog_patterns_test.go new file mode 100644 index 0000000..99ad386 --- /dev/null +++ b/examples/dialog-patterns/dialog_patterns_test.go @@ -0,0 +1,388 @@ +package main + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/chromedp/chromedp" + e2etest "github.com/livetemplate/lvt/testing" +) + +func TestMain(m *testing.M) { + e2etest.CleanupChromeContainers() + + code := m.Run() + + e2etest.CleanupChromeContainers() + os.Exit(code) +} + +func TestDialogPatternsE2E(t *testing.T) { + if testing.Short() { + t.Skip("Skipping E2E test in short mode") + } + + serverPort, err := e2etest.GetFreePort() + if err != nil { + t.Fatalf("Failed to get free port for server: %v", err) + } + + debugPort, err := e2etest.GetFreePort() + if err != nil { + t.Fatalf("Failed to get free port for Chrome: %v", err) + } + + serverCmd := e2etest.StartTestServer(t, "main.go", serverPort) + defer func() { + if serverCmd != nil && serverCmd.Process != nil { + serverCmd.Process.Kill() + } + }() + + chromeCmd := e2etest.StartDockerChrome(t, debugPort) + defer e2etest.StopDockerChrome(t, debugPort) + _ = chromeCmd + + chromeURL := fmt.Sprintf("http://localhost:%d", debugPort) + allocCtx, allocCancel := chromedp.NewRemoteAllocator(context.Background(), chromeURL) + defer allocCancel() + + ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(t.Logf)) + defer cancel() + + ctx, cancel = context.WithTimeout(ctx, 60*time.Second) + defer cancel() + + t.Run("Initial_Load", func(t *testing.T) { + var html string + err := chromedp.Run(ctx, + chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)), + e2etest.WaitForWebSocketReady(5*time.Second), + chromedp.WaitVisible(`h1`, chromedp.ByQuery), + e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), + chromedp.OuterHTML(`body`, &html, chromedp.ByQuery), + ) + if err != nil { + t.Fatalf("Failed to load page: %v", err) + } + + if !strings.Contains(html, "Dialog Patterns") { + t.Error("Page title not found") + } + if !strings.Contains(html, "Learn LiveTemplate") { + t.Error("Seed item 'Learn LiveTemplate' not found") + } + if !strings.Contains(html, "Build a dialog example") { + t.Error("Seed item 'Build a dialog example' not found") + } + if !strings.Contains(html, "Write E2E tests") { + t.Error("Seed item 'Write E2E tests' not found") + } + if !strings.Contains(html, "3 items") { + t.Error("Item count '3 items' not found") + } + + // Dialog should be closed initially + var dialogOpen bool + err = chromedp.Run(ctx, + chromedp.Evaluate(`document.getElementById('add-dialog').open`, &dialogOpen), + ) + if err != nil { + t.Fatalf("Failed to check dialog state: %v", err) + } + if dialogOpen { + t.Error("Dialog should be closed initially") + } + + t.Log("✅ Initial page load verified with 3 seed items and closed dialog") + }) + + t.Run("UI_Standards", func(t *testing.T) { + var violations string + err := chromedp.Run(ctx, + chromedp.Evaluate(`(() => { + const v = []; + ['onclick','onchange','oninput','onsubmit','onkeydown','onkeyup'].forEach(h => { + document.querySelectorAll('[' + h + ']').forEach(el => v.push('inline ' + h + ' on <' + el.tagName.toLowerCase() + '>')); + }); + document.querySelectorAll('[style]').forEach(el => { + if (el.tagName !== 'INS' && el.tagName !== 'DEL' && !el.closest('[data-modal]') && !el.closest('[data-lvt-toast-stack]')) + v.push('inline style on <' + el.tagName.toLowerCase() + '>'); + }); + if (!document.querySelector('meta[name="color-scheme"]')) v.push('missing color-scheme meta'); + if (document.documentElement.lang !== 'en') v.push('missing lang=en'); + const c = document.querySelector('.container'); + if (c && c.offsetWidth > 700) v.push('container too wide: ' + c.offsetWidth + 'px'); + return v.join('; '); + })()`, &violations), + ) + if err != nil { + t.Fatalf("UI standards check failed: %v", err) + } + if violations != "" { + t.Errorf("UI standard violations: %s", violations) + } + t.Log("✅ UI standards passed") + }) + + t.Run("Open_Dialog", func(t *testing.T) { + // Click the "Add Item" button which uses command="show-modal" commandfor="add-dialog" + err := chromedp.Run(ctx, + chromedp.Click(`button[commandfor="add-dialog"][command="show-modal"]`, chromedp.ByQuery), + e2etest.WaitFor(`document.getElementById('add-dialog').open === true`, 5*time.Second), + ) + if err != nil { + t.Fatalf("Failed to open dialog: %v", err) + } + + // Verify dialog is visible + var dialogOpen bool + err = chromedp.Run(ctx, + chromedp.Evaluate(`document.getElementById('add-dialog').open`, &dialogOpen), + ) + if err != nil { + t.Fatalf("Failed to check dialog state: %v", err) + } + if !dialogOpen { + t.Error("Dialog should be open after clicking show-modal button") + } + + t.Log("✅ Dialog opened via command='show-modal' polyfill") + }) + + t.Run("Close_Dialog_Cancel", func(t *testing.T) { + // Click the cancel button which uses command="close" commandfor="add-dialog" + err := chromedp.Run(ctx, + chromedp.Click(`button[commandfor="add-dialog"][command="close"]`, chromedp.ByQuery), + e2etest.WaitFor(`document.getElementById('add-dialog').open === false`, 5*time.Second), + ) + if err != nil { + t.Fatalf("Failed to close dialog: %v", err) + } + + var dialogOpen bool + err = chromedp.Run(ctx, + chromedp.Evaluate(`document.getElementById('add-dialog').open`, &dialogOpen), + ) + if err != nil { + t.Fatalf("Failed to check dialog state: %v", err) + } + if dialogOpen { + t.Error("Dialog should be closed after clicking close button") + } + + // Verify items unchanged + var html string + err = chromedp.Run(ctx, chromedp.OuterHTML(`body`, &html, chromedp.ByQuery)) + if err != nil { + t.Fatalf("Failed to get HTML: %v", err) + } + if !strings.Contains(html, "3 items") { + t.Error("Items should be unchanged after cancel") + } + + t.Log("✅ Dialog closed via command='close' polyfill, items unchanged") + }) + + t.Run("Add_Item_Via_Dialog", func(t *testing.T) { + // Open the dialog + err := chromedp.Run(ctx, + chromedp.Click(`button[commandfor="add-dialog"][command="show-modal"]`, chromedp.ByQuery), + e2etest.WaitFor(`document.getElementById('add-dialog').open === true`, 5*time.Second), + ) + if err != nil { + t.Fatalf("Failed to open dialog: %v", err) + } + + // Fill in the title and submit + err = chromedp.Run(ctx, + chromedp.Clear(`dialog#add-dialog input[name="title"]`, chromedp.ByQuery), + chromedp.SendKeys(`dialog#add-dialog input[name="title"]`, "New Test Item", chromedp.ByQuery), + chromedp.Click(`dialog#add-dialog button[type="submit"]`, chromedp.ByQuery), + e2etest.WaitFor(`document.body.innerText.includes('New Test Item')`, 5*time.Second), + ) + if err != nil { + t.Fatalf("Failed to add item: %v", err) + } + + // Verify all items exist + var html string + err = chromedp.Run(ctx, chromedp.OuterHTML(`body`, &html, chromedp.ByQuery)) + if err != nil { + t.Fatalf("Failed to get HTML: %v", err) + } + if !strings.Contains(html, "Learn LiveTemplate") { + t.Error("Seed item 'Learn LiveTemplate' missing after add") + } + if !strings.Contains(html, "Build a dialog example") { + t.Error("Seed item 'Build a dialog example' missing after add") + } + if !strings.Contains(html, "Write E2E tests") { + t.Error("Seed item 'Write E2E tests' missing after add") + } + if !strings.Contains(html, "New Test Item") { + t.Error("New item 'New Test Item' not found") + } + if !strings.Contains(html, "4 items") { + t.Error("Item count should be '4 items'") + } + + // Dialog should be closed after form submission + var dialogOpen bool + err = chromedp.Run(ctx, + chromedp.Evaluate(`document.getElementById('add-dialog').open`, &dialogOpen), + ) + if err != nil { + t.Fatalf("Failed to check dialog state: %v", err) + } + if dialogOpen { + t.Error("Dialog should be closed after form submission") + } + + // Form should be reset + var inputValue string + err = chromedp.Run(ctx, + chromedp.Evaluate(`document.querySelector('dialog#add-dialog input[name="title"]').value`, &inputValue), + ) + if err != nil { + t.Fatalf("Failed to get input value: %v", err) + } + if inputValue != "" { + t.Errorf("Input should be reset after submission, got: %q", inputValue) + } + + t.Log("✅ Item added via dialog, dialog closed, form reset") + }) + + t.Run("Add_Empty_Title_Error", func(t *testing.T) { + // Navigate fresh to get clean polyfill state (command/commandfor listeners + // are lost after WebSocket DOM updates from the previous test) + err := chromedp.Run(ctx, + chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)), + e2etest.WaitForWebSocketReady(5*time.Second), + chromedp.WaitVisible(`h1`, chromedp.ByQuery), + ) + if err != nil { + t.Fatalf("Failed to reload page: %v", err) + } + + // Open the dialog + err = chromedp.Run(ctx, + chromedp.Click(`button[commandfor="add-dialog"][command="show-modal"]`, chromedp.ByQuery), + e2etest.WaitFor(`document.getElementById('add-dialog').open === true`, 5*time.Second), + ) + if err != nil { + t.Fatalf("Failed to open dialog: %v", err) + } + + // Remove `required` via JS so the empty form reaches the server for validation. + err = chromedp.Run(ctx, + chromedp.Evaluate(`document.querySelector('dialog#add-dialog input[name="title"]').removeAttribute('required')`, nil), + chromedp.Clear(`dialog#add-dialog input[name="title"]`, chromedp.ByQuery), + chromedp.Click(`dialog#add-dialog button[type="submit"]`, chromedp.ByQuery), + e2etest.WaitFor(`document.querySelector('dialog#add-dialog small') !== null`, 10*time.Second), + ) + if err != nil { + t.Fatalf("Failed to submit empty form or validation error not shown: %v", err) + } + + // Dialog should stay open while showing validation errors + var dialogOpen bool + err = chromedp.Run(ctx, + chromedp.Evaluate(`document.getElementById('add-dialog').open`, &dialogOpen), + ) + if err != nil { + t.Fatalf("Failed to check dialog state: %v", err) + } + if !dialogOpen { + t.Error("Dialog should remain open when showing validation errors") + } + + // Validation error tag should be visible inside the dialog + var errorText string + err = chromedp.Run(ctx, + chromedp.Evaluate(`document.querySelector('dialog#add-dialog small').textContent`, &errorText), + ) + if err != nil { + t.Fatalf("Failed to read error text: %v", err) + } + if errorText == "" { + t.Error("Validation error message should not be empty") + } + t.Logf("Validation error shown: %q", errorText) + + // Input should have aria-invalid="true" + var ariaInvalid string + err = chromedp.Run(ctx, + chromedp.Evaluate(`document.querySelector('dialog#add-dialog input[name="title"]').getAttribute('aria-invalid')`, &ariaInvalid), + ) + if err != nil { + t.Fatalf("Failed to check aria-invalid: %v", err) + } + if ariaInvalid != "true" { + t.Errorf("Input should have aria-invalid='true', got %q", ariaInvalid) + } + + // Item count should remain 3 (unchanged — fresh session has seed items) + var html string + err = chromedp.Run(ctx, chromedp.OuterHTML(`body`, &html, chromedp.ByQuery)) + if err != nil { + t.Fatalf("Failed to get HTML: %v", err) + } + if !strings.Contains(html, "3 items") { + t.Error("Item count should still be '3 items' after failed empty submission") + } + + t.Log("✅ Validation errors shown inside open dialog, item count unchanged") + }) + + t.Run("Delete_Item", func(t *testing.T) { + // Navigate fresh to get clean state + err := chromedp.Run(ctx, + chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)), + e2etest.WaitForWebSocketReady(5*time.Second), + chromedp.WaitVisible(`table`, chromedp.ByQuery), + ) + if err != nil { + t.Fatalf("Failed to reload page: %v", err) + } + + // Delete the first seed item (Learn LiveTemplate) + err = chromedp.Run(ctx, + chromedp.Click(`button[name="delete"][value="1"]`, chromedp.ByQuery), + e2etest.WaitFor(`!document.body.innerText.includes('Learn LiveTemplate')`, 5*time.Second), + ) + if err != nil { + t.Fatalf("Failed to delete item: %v", err) + } + + var html string + err = chromedp.Run(ctx, chromedp.OuterHTML(`body`, &html, chromedp.ByQuery)) + if err != nil { + t.Fatalf("Failed to get HTML: %v", err) + } + + if strings.Contains(html, "Learn LiveTemplate") { + t.Error("Deleted item 'Learn LiveTemplate' should not be present") + } + if !strings.Contains(html, "Build a dialog example") { + t.Error("Remaining item 'Build a dialog example' should be present") + } + if !strings.Contains(html, "Write E2E tests") { + t.Error("Remaining item 'Write E2E tests' should be present") + } + if !strings.Contains(html, "2 items") { + t.Error("Item count should be '2 items' after deletion") + } + + t.Log("✅ Item deleted, remaining items preserved") + }) + + fmt.Println("\n" + strings.Repeat("=", 60)) + fmt.Println("🎉 All dialog-patterns E2E tests passed!") + fmt.Println(strings.Repeat("=", 60)) +} diff --git a/examples/dialog-patterns/main.go b/examples/dialog-patterns/main.go new file mode 100644 index 0000000..354b556 --- /dev/null +++ b/examples/dialog-patterns/main.go @@ -0,0 +1,156 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/go-playground/validator/v10" + "github.com/livetemplate/livetemplate" + e2etest "github.com/livetemplate/lvt/testing" +) + +var validate = validator.New() + +type DialogController struct{} + +type DialogState struct { + Items []Item +} + +type Item struct { + ID string + Title string +} + +type AddInput struct { + Title string `json:"title" validate:"required,min=3"` +} + +func (c *DialogController) Mount(state DialogState, ctx *livetemplate.Context) (DialogState, error) { + if len(state.Items) == 0 { + state.Items = []Item{ + {ID: "1", Title: "Learn LiveTemplate"}, + {ID: "2", Title: "Build a dialog example"}, + {ID: "3", Title: "Write E2E tests"}, + } + } + return state, nil +} + +func (c *DialogController) Add(state DialogState, ctx *livetemplate.Context) (DialogState, error) { + var input AddInput + if err := ctx.BindAndValidate(&input, validate); err != nil { + return state, err + } + id := fmt.Sprintf("%d", time.Now().UnixNano()) + state.Items = append(state.Items, Item{ID: id, Title: input.Title}) + return state, nil +} + +func (c *DialogController) Delete(state DialogState, ctx *livetemplate.Context) (DialogState, error) { + id := ctx.GetString("value") + for i, item := range state.Items { + if item.ID == id { + state.Items = append(state.Items[:i], state.Items[i+1:]...) + break + } + } + return state, nil +} + +func main() { + envConfig, err := livetemplate.LoadEnvConfig() + if err != nil { + slog.Error("Failed to load configuration", "error", err) + os.Exit(1) + } + if err := envConfig.Validate(); err != nil { + slog.Error("Invalid configuration", "error", err) + os.Exit(1) + } + + var level slog.Level + switch envConfig.LogLevel { + case "debug": + level = slog.LevelDebug + case "warn": + level = slog.LevelWarn + case "error": + level = slog.LevelError + default: + level = slog.LevelInfo + } + + var handler slog.Handler + if os.Getenv("ENV") == "production" { + handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level}) + } else { + handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}) + } + slog.SetDefault(slog.New(handler)) + + controller := &DialogController{} + initialState := &DialogState{} + + opts := envConfig.ToOptions() + tmpl := livetemplate.Must(livetemplate.New("dialog-patterns", opts...)) + liveHandler := tmpl.Handle(controller, livetemplate.AsState(initialState)) + + mux := http.NewServeMux() + mux.Handle("/", liveHandler) + mux.HandleFunc("/livetemplate-client.js", e2etest.ServeClientLibrary) + mux.HandleFunc("/livetemplate.css", e2etest.ServeCSS) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + server := &http.Server{ + Addr: ":" + port, + Handler: mux, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + go func() { + slog.Info("Server starting", "url", "http://localhost:"+port) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("Server failed", "error", err) + os.Exit(1) + } + }() + + <-quit + + shutdownTimeout := envConfig.ShutdownTimeout + if shutdownTimeout == 0 { + shutdownTimeout = 30 * time.Second + } + ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + + slog.Info("Shutting down HTTP server...") + if err := server.Shutdown(ctx); err != nil { + slog.Error("HTTP shutdown error", "error", err) + } + + if s, ok := liveHandler.(interface{ Shutdown(context.Context) error }); ok { + slog.Info("Shutting down WebSocket connections...") + if err := s.Shutdown(ctx); err != nil { + slog.Error("LiveHandler shutdown error", "error", err) + } + } + + slog.Info("Shutdown complete") +} diff --git a/examples/flash-messages/README.md b/examples/flash-messages/README.md new file mode 100644 index 0000000..14c0966 --- /dev/null +++ b/examples/flash-messages/README.md @@ -0,0 +1,87 @@ +# Flash Messages Example + +This example demonstrates flash messages in LiveTemplate - page-level notifications that show once and clear after each action. + +## Running + +```bash +cd examples/flash-messages +go run . +``` + +Then open http://localhost:8080 + +## Flash Message Types + +| Type | Use Case | Style | +|------|----------|-------| +| `success` | Operation completed | Green | +| `error` | Something went wrong | Red | +| `warning` | Caution/duplicate | Yellow | +| `info` | Informational | Blue | + +## Setting Flash Messages (Controller) + +```go +func (c *Controller) MyAction(state State, ctx *livetemplate.Context) (State, error) { + // Success notification + ctx.SetFlash("success", "Item added successfully!") + + // Error notification + ctx.SetFlash("error", "Failed to save changes") + + // Warning notification + ctx.SetFlash("warning", "Item already exists") + + // Info notification + ctx.SetFlash("info", "Processing complete") + + return state, nil +} +``` + +## Reading Flash Messages (Template) + +```html + +{{if .lvt.HasAnyFlash}} +
+ + + {{if .lvt.HasFlash "success"}} +
{{.lvt.Flash "success"}}
+ {{end}} + + {{if .lvt.HasFlash "error"}} +
{{.lvt.Flash "error"}}
+ {{end}} + +
+{{end}} +``` + +## Flash vs Field Errors + +| Aspect | Flash Messages | Field Errors | +|--------|----------------|--------------| +| **Purpose** | Page-level notifications | Form field validation | +| **Affects Success** | No | Yes | +| **Template Access** | `.lvt.Flash "key"` | `.lvt.Error "field"` | +| **Lifecycle** | Cleared after render | Cleared on next action | +| **Example** | "Changes saved!" | "Email is required" | + +## Key Behaviors + +1. **Show Once**: Flash messages are cleared after each action response +2. **Per-Connection**: Not shared across browser tabs +3. **No Persistence**: Don't survive page refresh or WebSocket reconnects +4. **Don't Block Success**: Unlike field errors, flash messages don't set `Success: false` + +## Available Template Helpers + +| Helper | Description | +|--------|-------------| +| `.lvt.Flash "key"` | Get flash message for key | +| `.lvt.HasFlash "key"` | Check if flash exists for key | +| `.lvt.HasAnyFlash` | Check if any flash messages exist | +| `.lvt.AllFlash` | Get all flash messages as map | diff --git a/examples/flash-messages/flash.tmpl b/examples/flash-messages/flash.tmpl new file mode 100644 index 0000000..c0b530b --- /dev/null +++ b/examples/flash-messages/flash.tmpl @@ -0,0 +1,96 @@ + + + + + + + Flash Messages — LiveTemplate + + {{if .lvt.DevMode}} + + + {{else}} + + + {{end}} + + +
+
+
+
+

{{.Title}}

+

Flash notification patterns for LiveTemplate

+
+
+ + {{if .lvt.HasFlash "success"}} + {{.lvt.Flash "success"}} + {{end}} + {{if .lvt.HasFlash "error"}} + {{.lvt.Flash "error"}} + {{end}} + {{if .lvt.HasFlash "warning"}} + {{.lvt.Flash "warning"}} + {{end}} + {{if .lvt.HasFlash "info"}} + {{.lvt.Flash "info"}} + {{end}} + +
+
+ + +
+ {{.lvt.ErrorTag "item"}} +
+ +
+ + +
+
+ +
+
+ Items ({{.ItemCount}}) +
+ {{if .Items}} + + + {{range .Items}} + + + + + {{end}} + +
{{.}} +
+ + +
+
+ {{else}} +

No items. Add some above!

+ {{end}} +
+ +
+ About Flash Messages +
    +
  • Flash messages show once and clear after each action
  • +
  • They don't affect ResponseMetadata.Success
  • +
  • Types: success, error, warning, info
  • +
  • Set via: ctx.SetFlash("success", "message")
  • +
  • Render via: .lvt.FlashTag "success" (recommended)
  • +
  • Or manually: .lvt.HasFlash "key", .lvt.Flash "key"
  • +
+
+
+ + + diff --git a/examples/flash-messages/flash_e2e_test.go b/examples/flash-messages/flash_e2e_test.go new file mode 100644 index 0000000..db89310 --- /dev/null +++ b/examples/flash-messages/flash_e2e_test.go @@ -0,0 +1,207 @@ +package main + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/chromedp/chromedp" + e2etest "github.com/livetemplate/lvt/testing" +) + +func TestMain(m *testing.M) { + e2etest.CleanupChromeContainers() + code := m.Run() + e2etest.CleanupChromeContainers() + os.Exit(code) +} + +func TestFlashMessagesE2E(t *testing.T) { + if testing.Short() { + t.Skip("Skipping E2E test in short mode") + } + + serverPort, err := e2etest.GetFreePort() + if err != nil { + t.Fatalf("Failed to get free port for server: %v", err) + } + + debugPort, err := e2etest.GetFreePort() + if err != nil { + t.Fatalf("Failed to get free port for Chrome: %v", err) + } + + serverCmd := e2etest.StartTestServer(t, "main.go", serverPort) + defer func() { + if serverCmd != nil && serverCmd.Process != nil { + serverCmd.Process.Kill() + } + }() + + if err := e2etest.StartDockerChrome(t, debugPort); err != nil { + t.Fatalf("Failed to start Docker Chrome: %v", err) + } + defer e2etest.StopDockerChrome(t, debugPort) + + chromeURL := fmt.Sprintf("http://localhost:%d", debugPort) + allocCtx, allocCancel := chromedp.NewRemoteAllocator(context.Background(), chromeURL) + defer allocCancel() + + ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(t.Logf)) + defer cancel() + + ctx, cancel = context.WithTimeout(ctx, 60*time.Second) + defer cancel() + + t.Run("InitialLoad", func(t *testing.T) { + var html string + err := chromedp.Run(ctx, + chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)), + e2etest.WaitForWebSocketReady(5*time.Second), + chromedp.WaitVisible(`h1`, chromedp.ByQuery), + chromedp.OuterHTML(`body`, &html, chromedp.ByQuery), + ) + if err != nil { + t.Fatalf("Failed to load page: %v", err) + } + if !strings.Contains(html, "Flash Messages Demo") { + t.Error("Page title not found") + } + }) + + t.Run("UI_Standards", func(t *testing.T) { + var violations string + err := chromedp.Run(ctx, + chromedp.Evaluate(`(() => { + const v = []; + ['onclick','onchange','oninput','onsubmit','onkeydown','onkeyup'].forEach(h => { + document.querySelectorAll('[' + h + ']').forEach(el => v.push('inline ' + h + ' on <' + el.tagName.toLowerCase() + '>')); + }); + document.querySelectorAll('[style]').forEach(el => { + if (el.tagName !== 'INS' && el.tagName !== 'DEL' && !el.closest('[data-modal]') && !el.closest('[data-lvt-toast-stack]')) + v.push('inline style on <' + el.tagName.toLowerCase() + '>'); + }); + if (!document.querySelector('meta[name="color-scheme"]')) v.push('missing color-scheme meta'); + if (document.documentElement.lang !== 'en') v.push('missing lang=en'); + const c = document.querySelector('.container'); + if (c && c.offsetWidth > 700) v.push('container too wide: ' + c.offsetWidth + 'px'); + return v.join('; '); + })()`, &violations), + ) + if err != nil { + t.Fatalf("UI standards check failed: %v", err) + } + if violations != "" { + t.Errorf("UI standard violations: %s", violations) + } + var cssStatus int + chromedp.Run(ctx, chromedp.Evaluate(`(() => { const x = new XMLHttpRequest(); x.open('GET', '/livetemplate.css', false); x.send(); return x.status; })()`, &cssStatus)) + if cssStatus != 200 { + t.Logf("Warning: Shared CSS not loading: status=%d (may not be available in CI)", cssStatus) + } + if err := chromedp.Run(ctx, e2etest.ValidatePicoCSS()); err != nil { + t.Errorf("Pico CSS check failed: %v", err) + } + }) + + t.Run("Visual_Check", func(t *testing.T) { + // Navigate in case this subtest runs in isolation (e.g., -run Visual_Check) + if err := chromedp.Run(ctx, + chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)), + e2etest.WaitForWebSocketReady(5*time.Second), + chromedp.WaitVisible(`h1`, chromedp.ByQuery), + ); err != nil { + t.Fatalf("Failed to load page: %v", err) + } + e2etest.ValidateScreenshotWithLLM(t, ctx, "Flash Messages Demo — form with input+button group, action buttons below") + }) + + t.Run("AddItemShowsSuccessFlash", func(t *testing.T) { + // Use real form submission instead of WebSocket API bypass + err := chromedp.Run(ctx, + chromedp.WaitVisible(`input[name="item"]`, chromedp.ByQuery), + chromedp.SendKeys(`input[name="item"]`, "Test Item", chromedp.ByQuery), + chromedp.Click(`button[name="addItem"]`, chromedp.ByQuery), + e2etest.WaitFor(`document.body.innerText.includes('Test Item')`, 5*time.Second), + ) + if err != nil { + t.Fatalf("Failed to add item: %v", err) + } + + var html string + chromedp.Run(ctx, chromedp.OuterHTML(`body`, &html, chromedp.ByQuery)) + + if !strings.Contains(html, "Test Item") { + t.Error("Item not added") + } + if !strings.Contains(html, "Added item") { + t.Error("Success flash not shown after adding item") + } + + // Verify form input was cleared (auto-reset) + var inputVal string + chromedp.Run(ctx, chromedp.Evaluate(`document.querySelector('input[name="item"]').value`, &inputVal)) + if inputVal != "" { + t.Errorf("Item input should be empty after submit, got %q", inputVal) + } + }) + + t.Run("SimulateErrorShowsErrorFlash", func(t *testing.T) { + err := chromedp.Run(ctx, + chromedp.Click(`button[name="simulateError"]`, chromedp.ByQuery), + e2etest.WaitFor(`document.body.innerText.includes('Something went wrong')`, 5*time.Second), + ) + if err != nil { + t.Fatalf("Failed to click or error flash not shown: %v", err) + } + }) + + t.Run("RemoveItemWorks", func(t *testing.T) { + // Add a fresh item to remove + err := chromedp.Run(ctx, + chromedp.WaitVisible(`input[name="item"]`, chromedp.ByQuery), + chromedp.SendKeys(`input[name="item"]`, "Item To Remove", chromedp.ByQuery), + chromedp.Click(`button[name="addItem"]`, chromedp.ByQuery), + e2etest.WaitFor(`document.body.innerText.includes('Item To Remove')`, 5*time.Second), + ) + if err != nil { + t.Fatalf("Failed to add item for removal: %v", err) + } + + // Count items before remove + var beforeCount int + chromedp.Run(ctx, chromedp.Evaluate(`document.querySelectorAll('button[name="removeItem"]').length`, &beforeCount)) + + // Click the last remove button — check table cells (not body, since flash message contains item name) + err = chromedp.Run(ctx, + chromedp.Click(`table tbody tr:last-child button[name="removeItem"]`, chromedp.ByQuery), + e2etest.WaitFor(`(() => { + const cells = document.querySelectorAll('table tbody tr td:first-child'); + return !Array.from(cells).some(td => td.textContent.includes('Item To Remove')); + })()`, 10*time.Second), + ) + if err != nil { + t.Fatalf("Remove item failed: %v", err) + } + + // Verify flash message appeared for the removal + var html string + chromedp.Run(ctx, chromedp.OuterHTML(`body`, &html, chromedp.ByQuery)) + if !strings.Contains(html, "Removed item") { + t.Error("Expected 'Removed item' flash message after removal") + } + }) + + t.Run("ClearItemsWorks", func(t *testing.T) { + err := chromedp.Run(ctx, + chromedp.Click(`button[name="clearItems"]`, chromedp.ByQuery), + e2etest.WaitFor(`document.body.innerText.includes('No items')`, 5*time.Second), + ) + if err != nil { + t.Fatalf("Clear items failed: %v", err) + } + }) +} diff --git a/examples/flash-messages/flash_test.go b/examples/flash-messages/flash_test.go new file mode 100644 index 0000000..f2152f6 --- /dev/null +++ b/examples/flash-messages/flash_test.go @@ -0,0 +1,278 @@ +//go:build http + +package main + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/livetemplate/livetemplate" +) + +// TestFlash_ShowsInTemplate tests that flash messages appear in rendered HTML. +func TestFlash_ShowsInTemplate(t *testing.T) { + // Create controller and state + controller := &FlashController{} + initialState := &FlashState{} + + // Create template + tmpl := livetemplate.Must(livetemplate.New("flash", + livetemplate.WithDevMode(true), + )) + + // Create test server + handler := tmpl.Handle(controller, livetemplate.AsState(initialState)) + server := httptest.NewServer(handler) + defer server.Close() + + // Create client with cookie jar for session persistence + jar, _ := newCookieJar() + client := &http.Client{Jar: jar} + + // 1. First GET to establish session and mount state + resp, err := client.Get(server.URL + "/") + if err != nil { + t.Fatalf("Initial GET failed: %v", err) + } + resp.Body.Close() + + // 2. POST to add item (should set success flash) + form := url.Values{} + form.Set("addItem", "") + form.Set("item", "Test Item") + + resp, err = client.PostForm(server.URL+"/", form) + if err != nil { + t.Fatalf("POST add_item failed: %v", err) + } + defer resp.Body.Close() + + // Read response body + body := readBody(t, resp) + + // Flash message should appear in response + if !strings.Contains(body, "Added item: Test Item") { + t.Errorf("Expected success flash 'Added item: Test Item' in response, got:\n%s", body) + } + + // Should have flash-success class + if !strings.Contains(body, "flash-success") { + t.Errorf("Expected flash-success class in response") + } +} + +// TestFlash_ClearsAfterAction tests that flash is cleared on subsequent action. +func TestFlash_ClearsAfterAction(t *testing.T) { + controller := &FlashController{} + initialState := &FlashState{} + + tmpl := livetemplate.Must(livetemplate.New("flash", + livetemplate.WithDevMode(true), + )) + + handler := tmpl.Handle(controller, livetemplate.AsState(initialState)) + server := httptest.NewServer(handler) + defer server.Close() + + jar, _ := newCookieJar() + client := &http.Client{Jar: jar} + + // 1. GET to establish session + resp, err := client.Get(server.URL + "/") + if err != nil { + t.Fatalf("Initial GET failed: %v", err) + } + resp.Body.Close() + + // 2. POST to add first item (sets flash) + form := url.Values{} + form.Set("addItem", "") + form.Set("item", "First Item") + resp, err = client.PostForm(server.URL+"/", form) + if err != nil { + t.Fatalf("First POST failed: %v", err) + } + body1 := readBody(t, resp) + resp.Body.Close() + + if !strings.Contains(body1, "Added item: First Item") { + t.Error("First action should show flash") + } + + // 3. POST to add second item (new flash replaces old) + form = url.Values{} + form.Set("addItem", "") + form.Set("item", "Second Item") + resp, err = client.PostForm(server.URL+"/", form) + if err != nil { + t.Fatalf("Second POST failed: %v", err) + } + body2 := readBody(t, resp) + resp.Body.Close() + + // Old flash should be gone + if strings.Contains(body2, "Added item: First Item") { + t.Error("Old flash should be cleared after new action") + } + + // New flash should be present + if !strings.Contains(body2, "Added item: Second Item") { + t.Error("New flash should appear") + } +} + +// TestFlash_DifferentTypes tests success, error, warning, and info flash types. +func TestFlash_DifferentTypes(t *testing.T) { + controller := &FlashController{} + initialState := &FlashState{} + + tmpl := livetemplate.Must(livetemplate.New("flash", + livetemplate.WithDevMode(true), + )) + + handler := tmpl.Handle(controller, livetemplate.AsState(initialState)) + server := httptest.NewServer(handler) + defer server.Close() + + jar, _ := newCookieJar() + client := &http.Client{Jar: jar} + + // Initial GET + resp, _ := client.Get(server.URL + "/") + resp.Body.Close() + + tests := []struct { + name string + action string + item string + wantClass string + wantText string + }{ + { + name: "success flash", + action: "addItem", + item: "New Item", + wantClass: "flash-success", + wantText: "Added item: New Item", + }, + { + name: "warning flash (duplicate)", + action: "addItem", + item: "New Item", // Same item = duplicate + wantClass: "flash-warning", + wantText: "Item already exists", + }, + { + name: "info flash", + action: "removeItem", + item: "New Item", + wantClass: "flash-info", + wantText: "Removed item: New Item", + }, + { + name: "error flash", + action: "simulateError", + item: "", + wantClass: "flash-error", + wantText: "Something went wrong", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + form := url.Values{} + form.Set(tt.action, "") + if tt.item != "" { + form.Set("item", tt.item) + } + + resp, err := client.PostForm(server.URL+"/", form) + if err != nil { + t.Fatalf("POST failed: %v", err) + } + body := readBody(t, resp) + resp.Body.Close() + + if !strings.Contains(body, tt.wantClass) { + t.Errorf("Expected %s in response", tt.wantClass) + } + if !strings.Contains(body, tt.wantText) { + t.Errorf("Expected '%s' in response, got:\n%s", tt.wantText, body) + } + }) + } +} + +// TestFlash_FieldErrorsStillWork tests that field errors work alongside flash. +func TestFlash_FieldErrorsStillWork(t *testing.T) { + controller := &FlashController{} + initialState := &FlashState{} + + tmpl := livetemplate.Must(livetemplate.New("flash", + livetemplate.WithDevMode(true), + )) + + handler := tmpl.Handle(controller, livetemplate.AsState(initialState)) + server := httptest.NewServer(handler) + defer server.Close() + + jar, _ := newCookieJar() + client := &http.Client{Jar: jar} + + // Initial GET + resp, _ := client.Get(server.URL + "/") + resp.Body.Close() + + // POST with empty item (triggers field error, not flash) + form := url.Values{} + form.Set("addItem", "") + form.Set("item", "") // Empty = validation error + + resp, err := client.PostForm(server.URL+"/", form) + if err != nil { + t.Fatalf("POST failed: %v", err) + } + body := readBody(t, resp) + resp.Body.Close() + + // Should show field error, not flash + if !strings.Contains(body, "Item name is required") { + t.Error("Expected field error 'Item name is required'") + } + if !strings.Contains(body, "field-error") { + t.Error("Expected field-error class") + } +} + +// Helper functions + +func newCookieJar() (http.CookieJar, error) { + // Simple cookie jar implementation + return &simpleCookieJar{cookies: make(map[string][]*http.Cookie)}, nil +} + +type simpleCookieJar struct { + cookies map[string][]*http.Cookie +} + +func (j *simpleCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) { + j.cookies[u.Host] = cookies +} + +func (j *simpleCookieJar) Cookies(u *url.URL) []*http.Cookie { + return j.cookies[u.Host] +} + +func readBody(t *testing.T, resp *http.Response) string { + t.Helper() + buf := new(strings.Builder) + _, err := io.Copy(buf, resp.Body) + if err != nil { + t.Fatalf("Failed to read response body: %v", err) + } + return buf.String() +} diff --git a/examples/flash-messages/main.go b/examples/flash-messages/main.go new file mode 100644 index 0000000..5021dae --- /dev/null +++ b/examples/flash-messages/main.go @@ -0,0 +1,168 @@ +package main + +import ( + "log" + "net/http" + "os" + "time" + + "github.com/livetemplate/livetemplate" + e2etest "github.com/livetemplate/lvt/testing" +) + +// FlashController demonstrates flash messages for page-level notifications. +// +// Flash messages are per-connection and show once (cleared after render). +// They don't affect ResponseMetadata.Success (unlike field validation errors). +// +// Common flash keys: "success", "error", "info", "warning" +type FlashController struct{} + +// FlashState holds the demo data. +type FlashState struct { + Title string `json:"title"` + Items []string `json:"items" lvt:"persist"` + ItemCount int `json:"item_count" lvt:"persist"` +} + +// AddItem handles the "add_item" action - demonstrates success flash. +func (c *FlashController) AddItem(state FlashState, ctx *livetemplate.Context) (FlashState, error) { + item := ctx.GetString("item") + + if item == "" { + // Field validation error (affects Success: false) + return state, livetemplate.FieldError{Field: "item", Message: "Item name is required"} + } + + // Check for duplicates + for _, existing := range state.Items { + if existing == item { + // Use flash for page-level warning (doesn't affect Success) + ctx.SetFlash("warning", "Item already exists: "+item) + return state, nil + } + } + + state.Items = append(state.Items, item) + state.ItemCount = len(state.Items) + + // Success flash message + ctx.SetFlash("success", "Added item: "+item) + + return state, nil +} + +// RemoveItem handles the "remove_item" action - demonstrates flash on removal. +func (c *FlashController) RemoveItem(state FlashState, ctx *livetemplate.Context) (FlashState, error) { + item := ctx.GetString("item") + + // Find and remove + found := false + newItems := make([]string, 0, len(state.Items)) + for _, existing := range state.Items { + if existing == item { + found = true + } else { + newItems = append(newItems, existing) + } + } + + if !found { + ctx.SetFlash("error", "Item not found: "+item) + return state, nil + } + + state.Items = newItems + state.ItemCount = len(state.Items) + + ctx.SetFlash("info", "Removed item: "+item) + return state, nil +} + +// ClearItems handles the "clear_items" action - demonstrates warning flash. +func (c *FlashController) ClearItems(state FlashState, ctx *livetemplate.Context) (FlashState, error) { + if len(state.Items) == 0 { + ctx.SetFlash("warning", "No items to clear") + return state, nil + } + + count := len(state.Items) + state.Items = []string{} + state.ItemCount = 0 + + ctx.SetFlash("success", "Cleared all items ("+string(rune('0'+count))+" removed)") + return state, nil +} + +// SimulateError handles the "simulate_error" action - demonstrates error flash. +func (c *FlashController) SimulateError(state FlashState, ctx *livetemplate.Context) (FlashState, error) { + // Simulate a server error that should be shown as flash + ctx.SetFlash("error", "Something went wrong! Please try again.") + return state, nil +} + +// Mount initializes state with sample data. +func (c *FlashController) Mount(state FlashState, ctx *livetemplate.Context) (FlashState, error) { + state.Title = "Flash Messages Demo" + state.Items = []string{"Apple", "Banana", "Cherry"} + state.ItemCount = len(state.Items) + return state, nil +} + +func main() { + log.Println("LiveTemplate Flash Messages Example starting...") + + // Load configuration from environment variables + envConfig, err := livetemplate.LoadEnvConfig() + if err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + + // Validate configuration + if err := envConfig.Validate(); err != nil { + log.Fatalf("Invalid configuration: %v", err) + } + + // Create controller (singleton) + controller := &FlashController{} + + // Create initial state (pure data, cloned per session) + initialState := &FlashState{} + + // Create template with environment-based configuration + opts := envConfig.ToOptions() + tmpl := livetemplate.Must(livetemplate.New("flash", opts...)) + + // Mount handler + http.Handle("/", tmpl.Handle(controller, livetemplate.AsState(initialState))) + + // Health check endpoint for testing + http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"ok","timestamp":"` + time.Now().Format(time.RFC3339) + `"}`)) + }) + + // Serve client library (development only - use CDN in production) + http.HandleFunc("/livetemplate-client.js", e2etest.ServeClientLibrary) + http.HandleFunc("/livetemplate.css", e2etest.ServeCSS) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + log.Printf("Server starting on http://localhost:%s", port) + log.Println("") + log.Println("Flash Message Types:") + log.Println(" - success: Green notification (e.g., 'Item added')") + log.Println(" - error: Red notification (e.g., 'Something went wrong')") + log.Println(" - warning: Yellow notification (e.g., 'Item exists')") + log.Println(" - info: Blue notification (e.g., 'Item removed')") + log.Println("") + log.Println("Note: Flash messages show once and are cleared after each action.") + log.Println("") + + if err := http.ListenAndServe(":"+port, nil); err != nil { + log.Fatalf("Server failed to start: %v", err) + } +} diff --git a/examples/landing-demo/Dockerfile b/examples/landing-demo/Dockerfile new file mode 100644 index 0000000..fbb9ddd --- /dev/null +++ b/examples/landing-demo/Dockerfile @@ -0,0 +1,37 @@ +# Deployable image for the LiveTemplate landing-demo. The docs site +# proxies same-origin to this app so the home page can iframe a real +# LiveTemplate counter without cross-origin or chrome friction. + +ARG EXAMPLES_REF=main + +# ---- Build stage ---- +FROM golang:1.26-alpine AS go-builder +ARG EXAMPLES_REF +RUN apk add --no-cache git ca-certificates +ENV GOTOOLCHAIN=auto +WORKDIR /src +RUN git clone --depth=1 --branch=${EXAMPLES_REF} https://github.com/livetemplate/examples.git . +# Fail fast (vs. silently shipping a stale build) if the requested ref +# does not contain landing-demo. Common cause: deploying before this app +# is merged to main without overriding --build-arg EXAMPLES_REF=. +RUN test -d /src/landing-demo || (echo "ERROR: landing-demo/ not found at ref '${EXAMPLES_REF}'. If deploying from a branch, pass --build-arg EXAMPLES_REF=." && exit 1) +WORKDIR /src/landing-demo +RUN go mod download -C .. +RUN CGO_ENABLED=0 GOOS=linux go build \ + -ldflags="-s -w" \ + -o /out/landing-demo . + +# ---- Runtime stage ---- +FROM alpine:3.21 +RUN apk add --no-cache ca-certificates tzdata +RUN adduser -D -u 1000 demo +WORKDIR /app +COPY --from=go-builder /out/landing-demo /usr/local/bin/landing-demo +# counter.tmpl is loaded via livetemplate.WithParseFiles at runtime as +# a relative path, so cwd must contain it at process start. +COPY --from=go-builder /src/landing-demo/counter.tmpl /app/counter.tmpl +RUN chown -R demo:demo /app +USER demo +EXPOSE 8080 +ENV PORT=8080 +CMD ["landing-demo"] diff --git a/examples/landing-demo/README.md b/examples/landing-demo/README.md new file mode 100644 index 0000000..1b51b94 --- /dev/null +++ b/examples/landing-demo/README.md @@ -0,0 +1,36 @@ +# landing-demo + +The minimal LiveTemplate counter that powers the live demo on +[livetemplate.fly.dev](https://livetemplate.fly.dev). Deployed standalone +as `lt-landing-demo.fly.dev` and proxied same-origin by the docs site so +the landing page can iframe it without cross-origin friction. + +The whole app is `main.go` (~50 lines) plus `counter.tmpl` (~25 lines). +Same code, three transports: + +- **Without JS**: form POST, page reloads with new state. +- **With the JS client (fetch)**: same form POSTs via `fetch()`; the DOM is patched in place. +- **With WebSocket**: actions ride the WS; other tabs in the same browser session sync automatically. + +## How cross-tab sync works + +Two pieces enable it: + +- `Count int \`lvt:"persist"\`` makes the field session-store backed, so it survives reconnects and is visible to every connection in the same session group. +- The controller explicitly calls `ctx.BroadcastAction(...)` after counter mutations so peer tabs receive the same counter action and re-render. + +Without `BroadcastAction`, peer tabs would only see the latest value on their own next action or a full reload. Without `persist`, a full reload would not preserve the session count. + +## Run locally + +```bash +go run . +``` + +Then open http://localhost:8080. + +## Deploy + +```bash +flyctl deploy --remote-only +``` diff --git a/examples/landing-demo/counter.tmpl b/examples/landing-demo/counter.tmpl new file mode 100644 index 0000000..ff3eb35 --- /dev/null +++ b/examples/landing-demo/counter.tmpl @@ -0,0 +1,32 @@ + + + + + + + Counter — LiveTemplate + + {{if .lvt.DevMode}} + + + {{else}} + + + {{end}} + + +
+
+

Live Counter

+

Count: {{.Count}}

+
+
+ + + +
+
+
+
+ + diff --git a/examples/landing-demo/fly.toml b/examples/landing-demo/fly.toml new file mode 100644 index 0000000..f6a00ca --- /dev/null +++ b/examples/landing-demo/fly.toml @@ -0,0 +1,24 @@ +# Deployment of the LiveTemplate landing-demo. +# Iframed by the docs site (livetemplate.fly.dev) on the landing page, +# routed via tinkerdown's same-origin proxy at /demo/counter/. + +app = "lt-landing-demo" +primary_region = "sjc" + +[build] + dockerfile = "Dockerfile" + +[http_service] + internal_port = 8080 + force_https = true + auto_stop_machines = "stop" + auto_start_machines = true + # This app is iframed on the docs landing page. min_machines_running=1 + # keeps a warm machine so the first visitor after idle doesn't see an + # empty iframe during a 10-25s machine wake-up. + min_machines_running = 1 + +[[vm]] + cpu_kind = "shared" + cpus = 1 + memory_mb = 512 diff --git a/examples/landing-demo/landing_demo_test.go b/examples/landing-demo/landing_demo_test.go new file mode 100644 index 0000000..00ffb6b --- /dev/null +++ b/examples/landing-demo/landing_demo_test.go @@ -0,0 +1,277 @@ +// Browser e2e for the landing-demo counter. Mirrors examples/counter's +// shape: spin up the server on a free port, drive a real Chrome via +// chromedp, exercise every controller method (Increment, Decrement, +// Reset, Sync). Each sub-test resets the counter first so it doesn't +// depend on execution order or the state left by other tests. +package main + +import ( + "context" + "fmt" + "net/http" + "net/url" + "os" + "strings" + "testing" + "time" + + "github.com/chromedp/chromedp" + e2etest "github.com/livetemplate/lvt/testing" +) + +func TestMain(m *testing.M) { + e2etest.CleanupChromeContainers() + code := m.Run() + e2etest.CleanupChromeContainers() + os.Exit(code) +} + +func TestLandingDemoE2E(t *testing.T) { + if testing.Short() { + t.Skip("Skipping E2E test in short mode") + } + + serverPort, err := e2etest.GetFreePort() + if err != nil { + t.Fatalf("get free server port: %v", err) + } + debugPort, err := e2etest.GetFreePort() + if err != nil { + t.Fatalf("get free debug port: %v", err) + } + + serverCmd := e2etest.StartTestServer(t, "main.go", serverPort) + defer func() { + if serverCmd != nil && serverCmd.Process != nil { + serverCmd.Process.Kill() + } + }() + + chromeCmd := e2etest.StartDockerChrome(t, debugPort) + defer e2etest.StopDockerChrome(t, debugPort) + _ = chromeCmd + + chromeURL := fmt.Sprintf("http://localhost:%d", debugPort) + allocCtx, allocCancel := chromedp.NewRemoteAllocator(context.Background(), chromeURL) + defer allocCancel() + + ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(t.Logf)) + defer cancel() + ctx, cancel = context.WithTimeout(ctx, 90*time.Second) + defer cancel() + + // resetCounter brings the demo back to Count: 0 so each sub-test + // starts from a known baseline. Skips the click when the count is + // already 0 — clicking Reset on an already-zero state produces an + // empty diff that the LiveTemplate client appears to never receive a + // reply for, which then blocks the next click for at least the + // WaitFor timeout. Reading the count via Evaluate is passive, so it + // can't trigger that condition. + resetCounter := func(t *testing.T) { + t.Helper() + var current string + if err := chromedp.Run(ctx, + chromedp.Evaluate(`document.querySelector('output strong').textContent`, ¤t), + ); err != nil { + t.Fatalf("read current count: %v", err) + } + if strings.TrimSpace(current) == "0" { + return + } + if err := chromedp.Run(ctx, + chromedp.Click(`button[name="reset"]`, chromedp.ByQuery), + e2etest.WaitFor(`document.body.innerText.includes('Count: 0')`, 5*time.Second), + ); err != nil { + t.Fatalf("reset baseline: %v", err) + } + } + + t.Run("Initial_Load_Renders_Counter_At_Zero", func(t *testing.T) { + var bodyHTML string + if err := chromedp.Run(ctx, + chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)), + e2etest.WaitForWebSocketReady(5*time.Second), + chromedp.WaitVisible(`output[aria-live="polite"]`, chromedp.ByQuery), + e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), + chromedp.OuterHTML(`body`, &bodyHTML, chromedp.ByQuery), + ); err != nil { + t.Fatalf("initial load: %v", err) + } + if !strings.Contains(bodyHTML, "0") { + t.Errorf("initial Count != 0; body = %s", bodyHTML) + } + if !strings.Contains(bodyHTML, `aria-live="polite"`) { + t.Errorf("counter is not in a live region; screen readers won't announce updates") + } + }) + + t.Run("UI_Standards_Pico_And_CSP_Clean", func(t *testing.T) { + var violations string + err := chromedp.Run(ctx, + chromedp.Evaluate(`(() => { + const v = []; + ['onclick','onchange','oninput','onsubmit','onkeydown','onkeyup'].forEach(h => { + document.querySelectorAll('[' + h + ']').forEach(el => v.push('inline ' + h + ' on <' + el.tagName.toLowerCase() + '>')); + }); + document.querySelectorAll('[style]').forEach(el => { + if (el.tagName !== 'INS' && el.tagName !== 'DEL') + v.push('inline style on <' + el.tagName.toLowerCase() + '>'); + }); + // NOTE: no check for