From 5dc02314e8ea21d7b35e56adb278be2921e86dd7 Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Sun, 10 May 2026 05:01:11 +0000 Subject: [PATCH 1/2] chore: delete examples/patterns (folded into livetemplate/docs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The patterns app moved to livetemplate/docs at content/recipes/patterns/_app/ in livetemplate/docs#10 (B1, May 9). The B2 routing flip (livetemplate/docs#11) repointed the public catalog at /patterns/* to serve from the docs binary's in-process recipes server, and the e2e suite was relocated to docs/e2e/patterns/. After livetemplate/livetemplate#400 (just merged) added the test-docs cross-repo job that runs the relocated patterns suite against core livetemplate changes, the patterns app's "runs against core changes" coverage is preserved with no gap. This PR completes the migration: - Delete examples/patterns/ entirely (62 files, ~8.5k lines: 22 .go source files, 30 templates, Dockerfile, fly.toml, .dockerignore, README, plus the patterns/ subtree). - Remove "patterns" from test-all.sh's WORKING_EXAMPLES so the test-examples cross-repo job (in livetemplate/livetemplate) auto- skips it without erroring. - Update README.md to reflect that the /patterns docs catalog is now served from livetemplate/docs's recipes/patterns/_app/ package (not from this repo). Manual ops (not in this PR): - Decommission the lt-patterns.fly.dev Fly app β€” has been receiving zero production traffic since livetemplate/docs#11 deployed (May 10 ~04:30 UTC). Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- patterns/.dockerignore | 7 - patterns/Dockerfile | 37 - patterns/README.md | 118 - patterns/api_index.go | 130 - patterns/api_index_test.go | 177 - patterns/cross_handler_nav_test.go | 421 -- patterns/data.go | 347 -- patterns/fly.toml | 20 - patterns/handlers_feedback.go | 151 - patterns/handlers_forms.go | 255 -- patterns/handlers_lists.go | 445 -- patterns/handlers_loading.go | 352 -- patterns/handlers_navigation.go | 207 - patterns/handlers_realtime.go | 348 -- patterns/handlers_search.go | 81 - patterns/large_table_bench_test.go | 130 - patterns/main.go | 167 - patterns/patterns_test.go | 3913 ----------------- patterns/state_feedback.go | 32 - patterns/state_forms.go | 56 - patterns/state_lists.go | 60 - patterns/state_loading.go | 38 - patterns/state_navigation.go | 34 - patterns/state_realtime.go | 65 - patterns/state_search.go | 18 - patterns/templates/feedback/animations.tmpl | 27 - .../templates/feedback/flash-messages.tmpl | 26 - patterns/templates/feedback/highlight.tmpl | 17 - .../templates/feedback/loading-states.tmpl | 47 - patterns/templates/forms/bulk-update.tmpl | 22 - patterns/templates/forms/click-to-edit.tmpl | 33 - patterns/templates/forms/edit-row.tmpl | 34 - patterns/templates/forms/file-upload.tmpl | 26 - .../templates/forms/inline-validation.tmpl | 19 - patterns/templates/forms/preserve-inputs.tmpl | 21 - patterns/templates/forms/reset-input.tmpl | 15 - patterns/templates/index.tmpl | 17 - patterns/templates/layout.tmpl | 26 - patterns/templates/lists/click-to-load.tmpl | 25 - patterns/templates/lists/delete-row.tmpl | 30 - patterns/templates/lists/infinite-scroll.tmpl | 23 - patterns/templates/lists/large-table.tmpl | 55 - patterns/templates/lists/sortable.tmpl | 25 - patterns/templates/lists/value-select.tmpl | 27 - .../templates/loading/async-operations.tmpl | 21 - patterns/templates/loading/lazy-loading.tmpl | 17 - patterns/templates/loading/progress-bar.tmpl | 26 - .../templates/navigation/confirm-dialog.tmpl | 51 - .../navigation/keyboard-shortcuts.tmpl | 38 - .../templates/navigation/modal-dialog.tmpl | 44 - .../templates/navigation/spa-navigation.tmpl | 35 - patterns/templates/navigation/tabs.tmpl | 31 - patterns/templates/realtime/broadcasting.tmpl | 35 - patterns/templates/realtime/live-preview.tmpl | 18 - .../templates/realtime/multi-user-sync.tmpl | 14 - patterns/templates/realtime/presence.tmpl | 28 - patterns/templates/realtime/reconnection.tmpl | 21 - patterns/templates/realtime/server-push.tmpl | 19 - patterns/templates/search/active-search.tmpl | 25 - patterns/templates/search/url-filters.tmpl | 33 - test-all.sh | 1 - 62 files changed, 1 insertion(+), 8582 deletions(-) delete mode 100644 patterns/.dockerignore delete mode 100644 patterns/Dockerfile delete mode 100644 patterns/README.md delete mode 100644 patterns/api_index.go delete mode 100644 patterns/api_index_test.go delete mode 100644 patterns/cross_handler_nav_test.go delete mode 100644 patterns/data.go delete mode 100644 patterns/fly.toml delete mode 100644 patterns/handlers_feedback.go delete mode 100644 patterns/handlers_forms.go delete mode 100644 patterns/handlers_lists.go delete mode 100644 patterns/handlers_loading.go delete mode 100644 patterns/handlers_navigation.go delete mode 100644 patterns/handlers_realtime.go delete mode 100644 patterns/handlers_search.go delete mode 100644 patterns/large_table_bench_test.go delete mode 100644 patterns/main.go delete mode 100644 patterns/patterns_test.go delete mode 100644 patterns/state_feedback.go delete mode 100644 patterns/state_forms.go delete mode 100644 patterns/state_lists.go delete mode 100644 patterns/state_loading.go delete mode 100644 patterns/state_navigation.go delete mode 100644 patterns/state_realtime.go delete mode 100644 patterns/state_search.go delete mode 100644 patterns/templates/feedback/animations.tmpl delete mode 100644 patterns/templates/feedback/flash-messages.tmpl delete mode 100644 patterns/templates/feedback/highlight.tmpl delete mode 100644 patterns/templates/feedback/loading-states.tmpl delete mode 100644 patterns/templates/forms/bulk-update.tmpl delete mode 100644 patterns/templates/forms/click-to-edit.tmpl delete mode 100644 patterns/templates/forms/edit-row.tmpl delete mode 100644 patterns/templates/forms/file-upload.tmpl delete mode 100644 patterns/templates/forms/inline-validation.tmpl delete mode 100644 patterns/templates/forms/preserve-inputs.tmpl delete mode 100644 patterns/templates/forms/reset-input.tmpl delete mode 100644 patterns/templates/index.tmpl delete mode 100644 patterns/templates/layout.tmpl delete mode 100644 patterns/templates/lists/click-to-load.tmpl delete mode 100644 patterns/templates/lists/delete-row.tmpl delete mode 100644 patterns/templates/lists/infinite-scroll.tmpl delete mode 100644 patterns/templates/lists/large-table.tmpl delete mode 100644 patterns/templates/lists/sortable.tmpl delete mode 100644 patterns/templates/lists/value-select.tmpl delete mode 100644 patterns/templates/loading/async-operations.tmpl delete mode 100644 patterns/templates/loading/lazy-loading.tmpl delete mode 100644 patterns/templates/loading/progress-bar.tmpl delete mode 100644 patterns/templates/navigation/confirm-dialog.tmpl delete mode 100644 patterns/templates/navigation/keyboard-shortcuts.tmpl delete mode 100644 patterns/templates/navigation/modal-dialog.tmpl delete mode 100644 patterns/templates/navigation/spa-navigation.tmpl delete mode 100644 patterns/templates/navigation/tabs.tmpl delete mode 100644 patterns/templates/realtime/broadcasting.tmpl delete mode 100644 patterns/templates/realtime/live-preview.tmpl delete mode 100644 patterns/templates/realtime/multi-user-sync.tmpl delete mode 100644 patterns/templates/realtime/presence.tmpl delete mode 100644 patterns/templates/realtime/reconnection.tmpl delete mode 100644 patterns/templates/realtime/server-push.tmpl delete mode 100644 patterns/templates/search/active-search.tmpl delete mode 100644 patterns/templates/search/url-filters.tmpl diff --git a/README.md b/README.md index 9fa723b..a85aa45 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Example applications demonstrating LiveTemplate usage with various features and patterns. -πŸ“š **Framework documentation:** **** β€” guides, recipes, patterns catalog (with live demos), full reference. The `/examples` and `/patterns` sections of the docs site index every app in this repo. +πŸ“š **Framework documentation:** **** β€” guides, recipes, patterns catalog (with live demos), full reference. The `/examples` section of the docs site indexes every app in this repo. The `/patterns` catalog is served from the docs repo's own `content/recipes/patterns/_app/` package. ## Showcase: Todo App diff --git a/patterns/.dockerignore b/patterns/.dockerignore deleted file mode 100644 index a2bb226..0000000 --- a/patterns/.dockerignore +++ /dev/null @@ -1,7 +0,0 @@ -.git -.github -.worktrees -.vscode -.idea -*.swp -.DS_Store diff --git a/patterns/Dockerfile b/patterns/Dockerfile deleted file mode 100644 index 3ccb203..0000000 --- a/patterns/Dockerfile +++ /dev/null @@ -1,37 +0,0 @@ -# Phase 1 deployable image for the LiveTemplate patterns showcase. -# Targets fly.io as `lt-patterns` so the docs site's external-app router -# can proxy /patterns/ to it. Generic enough to run anywhere. -# -# Source is fetched at build time so the build context can be just the -# patterns/ directory (makes flyctl deploys self-contained per app). - -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 . -WORKDIR /src/patterns -RUN go mod download -C .. -RUN CGO_ENABLED=0 GOOS=linux go build \ - -ldflags="-s -w" \ - -o /out/patterns . - -# ---- Runtime stage ---- -FROM alpine:3.21 -RUN apk add --no-cache ca-certificates tzdata -RUN adduser -D -u 1000 patterns -WORKDIR /app -COPY --from=go-builder /out/patterns /usr/local/bin/patterns -# Templates are loaded by livetemplate.WithParseFiles at runtime as -# relative paths (e.g. "templates/layout.tmpl"), so cwd must contain -# the templates/ directory at process start. -COPY --from=go-builder /src/patterns/templates /app/templates -RUN chown -R patterns:patterns /app -USER patterns -EXPOSE 8080 -ENV PORT=8080 -CMD ["patterns"] diff --git a/patterns/README.md b/patterns/README.md deleted file mode 100644 index 227622b..0000000 --- a/patterns/README.md +++ /dev/null @@ -1,118 +0,0 @@ -# LiveTemplate Patterns - -A catalog of 31 reactive UI patterns implemented with [LiveTemplate](https://github.com/livetemplate/livetemplate). Each pattern is a self-contained handler demonstrating a single idiom β€” forms, lists, navigation, real-time, and more. - -The patterns trace the [htmx examples](https://htmx.org/examples/) and [Phoenix LiveView patterns](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html), translated to LiveTemplate's controller + state model. Styled with [Pico CSS](https://picocss.com/). - -## Quick Start - -```bash -cd examples -GOWORK=off go run ./patterns -``` - -Open for the live index β€” every pattern links to its own page. - -```bash -PORT=8081 GOWORK=off go run ./patterns -``` - -## What's Here - -### Forms & Editing - -- **Click To Edit** β€” toggle between view and edit mode -- **Edit Row** β€” inline editing of table rows -- **Inline Validation** β€” server-side field validation as you type -- **Bulk Update** β€” batch checkbox operations -- **Reset User Input** β€” auto-clear forms after submission -- **File Upload** β€” standard and chunked file uploads -- **Preserving File Inputs** β€” retain form values across re-renders - -### Lists & Data - -- **Delete Row** β€” animated row removal -- **Click To Load** β€” append-only pagination -- **Infinite Scroll** β€” auto-load on scroll with `lvt-scroll-sentinel` -- **Value Select** β€” cascading dependent selects - -### Search & Filtering - -- **Active Search** β€” debounced live search -- **URL-Preserved Filters** β€” bookmarkable filter state via query params - -### Loading & Progress - -- **Lazy Loading** β€” load content after page render via server push -- **Progress Bar** β€” WebSocket-pushed progress updates -- **Async Operations** β€” loading/success/error state machine - -### Dialogs, Tabs & Navigation - -- **Modal Dialog** β€” native dialog with `command`/`commandfor` -- **Confirm Dialog** β€” CSP-compliant confirmation flow -- **Tabs (HATEOAS)** β€” server-driven tabs via SPA navigation -- **SPA Navigation** β€” auto link interception with pushState -- **Keyboard Shortcuts** β€” global keyboard event binding - -### Visual Feedback - -- **Animations** β€” entry animations with `lvt-fx:animate` -- **Loading States** β€” auto `aria-busy` and custom loading text -- **Highlight on Change** β€” visual flash on DOM updates -- **Flash Messages** β€” toast notifications via `ctx.SetFlash` - -### Real-Time & Multi-User - -- **Multi-User Sync** β€” auto-sync across tabs via `Sync()` handler -- **Broadcasting** β€” cross-connection updates via `BroadcastAction` -- **Presence Tracking** β€” explicit join/leave with shared state -- **Reconnection Recovery** β€” state persistence via `lvt:"persist"` -- **Live Preview** β€” real-time input preview via `Change()` -- **Server Push** β€” background goroutine pushing updates with `session.TriggerAction` - -## Architecture - -One handler per pattern, grouped by category: - -``` -patterns/ -β”œβ”€β”€ main.go # Mux wiring + index handler -β”œβ”€β”€ data.go # Sample data + pattern catalog (drives the index page) -β”œβ”€β”€ state_{category}.go # State structs per category (Forms, Lists, …) -β”œβ”€β”€ handlers_{category}.go # Controllers + action methods per category -β”œβ”€β”€ templates/ -β”‚ β”œβ”€β”€ layout.tmpl # Shared shell (Pico CSS, breadcrumb) -β”‚ β”œβ”€β”€ index.tmpl # Catalog page rendered from data.go -β”‚ └── {category}/*.tmpl # One template per pattern -└── patterns_test.go # E2E tests (chromedp) -``` - -Each pattern follows the [controller + state convention](https://github.com/livetemplate/livetemplate/blob/main/docs/references/controller-pattern.md): controllers are singletons holding dependencies; state is a plain struct cloned per session. Action methods have signature `func (c *Controller) Action(state State, ctx *Context) (State, error)`. - -The index page is data-driven by `data.go :: allPatterns()` β€” adding or renaming a pattern is a single struct edit. - -## Testing - -```bash -# Full E2E suite (requires Docker for the chromedp container) -unset PORT && GOWORK=off go test -v -race -timeout=10m ./patterns - -# Visual checks (LLM-validated screenshots, opt-in) -unset PORT && LVT_VISUAL_CHECK=true GOWORK=off go test -v ./patterns -run "Visual_Check" - -# Single pattern -GOWORK=off go test -v ./patterns -run TestEditRow -``` - -Multi-tab tests (`TestMultiUserSync`, `TestBroadcasting`, `TestPresence`) use `chromedp.NewContext(parentCtx)` to open a second tab on the same allocator, sharing the session-group cookie. See `setupPeerTab` in `patterns_test.go`. - -E2E tests have access to browser console logs, server logs, WebSocket messages, and rendered HTML β€” see [`livetemplate/CLAUDE.md`](https://github.com/livetemplate/livetemplate/blob/main/CLAUDE.md) for the full E2E contract. - -## Reference - -- [`docs/proposals/patterns.md`](https://github.com/livetemplate/livetemplate/blob/main/docs/proposals/patterns.md) β€” original proposal driving the implementation -- [`examples/CLAUDE.md`](../CLAUDE.md) β€” examples-repo conventions (Tier 1 vs Tier 2, Pico/CSP boilerplate) -- [`livetemplate/CLAUDE.md`](https://github.com/livetemplate/livetemplate/blob/main/CLAUDE.md) β€” controller pattern, `data-key`, AI review loop -- [Progressive Complexity Reference](https://github.com/livetemplate/livetemplate/blob/main/docs/references/progressive-complexity-reference.md) β€” Tier 1 vs Tier 2 -- [Client Attributes Reference](https://github.com/livetemplate/livetemplate/blob/main/docs/references/client-attributes.md) β€” `lvt-*` attribute listing diff --git a/patterns/api_index.go b/patterns/api_index.go deleted file mode 100644 index 7f95010..0000000 --- a/patterns/api_index.go +++ /dev/null @@ -1,130 +0,0 @@ -package main - -import ( - "encoding/json" - "net/http" - "strings" -) - -// apiCategory and apiPattern are the JSON shapes the docs site catalog -// consumes from /api/index.json. Kept separate from PatternLink so the -// internal model can evolve without breaking the public schema. -type apiCategory struct { - Slug string `json:"slug"` - Name string `json:"name"` - Patterns []apiPattern `json:"patterns"` -} - -type apiPattern struct { - Slug string `json:"slug"` // last URL segment, e.g. "click-to-edit" - Name string `json:"name"` // human title - Path string `json:"path"` // upstream path, e.g. "/patterns/forms/click-to-edit" - Description string `json:"description"` // one-line problem statement - Status string `json:"status"` // "stable" | "soon" - Category string `json:"category"` // human category name (denormalized for client convenience) -} - -// apiIndexHandler exposes the pattern catalog as JSON for the -// LiveTemplate docs site (and any other consumer that wants to embed -// the catalog without scraping HTML). -// -// Response shape: -// -// { -// "version": 1, -// "categories": [{slug, name, patterns: [{slug, name, path, description, status, category}]}] -// } -// -// Versioned for forward-compat: bump `version` when the schema changes -// in a way that breaks existing consumers. -func apiIndexHandler() http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // CORS preflight first: a browser fetch from the docs site will - // OPTIONS-preflight on any cross-origin request that the spec - // considers non-simple. Reply with the same Allow-Origin we'd - // send for the actual GET so the real request goes through. - if r.Method == http.MethodOptions { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type") - w.Header().Set("Access-Control-Max-Age", "86400") - w.WriteHeader(http.StatusNoContent) - return - } - if r.Method != http.MethodGet && r.Method != http.MethodHead { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - categories := allPatterns() - out := struct { - Version int `json:"version"` - Categories []apiCategory `json:"categories"` - }{ - Version: 1, - Categories: make([]apiCategory, 0, len(categories)), - } - for _, c := range categories { - ac := apiCategory{ - Slug: categorySlug(c.Name), - Name: c.Name, - Patterns: make([]apiPattern, 0, len(c.Patterns)), - } - for _, p := range c.Patterns { - status := "stable" - if !p.Implemented { - status = "soon" - } - ac.Patterns = append(ac.Patterns, apiPattern{ - Slug: patternSlugFromPath(p.Path), - Name: p.Name, - Path: p.Path, - Description: p.Description, - Status: status, - Category: c.Name, - }) - } - out.Categories = append(out.Categories, ac) - } - - w.Header().Set("Content-Type", "application/json") - // Cached aggressively at fly's edge; this index is authoritatively - // produced from compiled-in code so it only changes on redeploy. - w.Header().Set("Cache-Control", "public, max-age=300") - // Allow the docs site (and any operator script) to fetch this - // from a different origin without a separate CORS proxy. - w.Header().Set("Access-Control-Allow-Origin", "*") - _ = json.NewEncoder(w).Encode(out) - }) -} - -// categorySlug derives a URL-safe slug from a category name. e.g. -// "Forms & Editing" -> "forms-editing". Used as a stable id the docs -// catalog can address without depending on the human display name. -func categorySlug(name string) string { - out := strings.ToLower(name) - out = strings.ReplaceAll(out, "&", "") - // collapse runs of non-alnum to a single dash - var b strings.Builder - prevDash := true - for _, r := range out { - switch { - case r >= 'a' && r <= 'z', r >= '0' && r <= '9': - b.WriteRune(r) - prevDash = false - case !prevDash: - b.WriteByte('-') - prevDash = true - } - } - return strings.Trim(b.String(), "-") -} - -// patternSlugFromPath extracts the pattern's last URL segment. -// "/patterns/forms/click-to-edit" -> "click-to-edit". -func patternSlugFromPath(path string) string { - if i := strings.LastIndex(path, "/"); i >= 0 { - return path[i+1:] - } - return path -} diff --git a/patterns/api_index_test.go b/patterns/api_index_test.go deleted file mode 100644 index 20cfa6f..0000000 --- a/patterns/api_index_test.go +++ /dev/null @@ -1,177 +0,0 @@ -package main - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestCategorySlug(t *testing.T) { - cases := []struct{ in, want string }{ - {"Forms & Editing", "forms-editing"}, - {"Lists & Data", "lists-data"}, - {"Search & Filtering", "search-filtering"}, - {"Loading & Progress", "loading-progress"}, - {"Dialogs, Tabs & Navigation", "dialogs-tabs-navigation"}, - {"Feedback & Animations", "feedback-animations"}, - {"Real-Time", "real-time"}, - {" Trim me ", "trim-me"}, - } - for _, c := range cases { - if got := categorySlug(c.in); got != c.want { - t.Errorf("categorySlug(%q) = %q, want %q", c.in, got, c.want) - } - } -} - -func TestPatternSlugFromPath(t *testing.T) { - cases := []struct{ in, want string }{ - {"/patterns/forms/click-to-edit", "click-to-edit"}, - {"/patterns/lists/delete-row", "delete-row"}, - {"no-slash", "no-slash"}, - } - for _, c := range cases { - if got := patternSlugFromPath(c.in); got != c.want { - t.Errorf("patternSlugFromPath(%q) = %q, want %q", c.in, got, c.want) - } - } -} - -func TestAPIIndex_StructureAndContents(t *testing.T) { - srv := httptest.NewServer(apiIndexHandler()) - defer srv.Close() - - resp, err := http.Get(srv.URL) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - t.Fatalf("status = %d", resp.StatusCode) - } - if ct := resp.Header.Get("Content-Type"); ct != "application/json" { - t.Errorf("content-type = %q", ct) - } - if cors := resp.Header.Get("Access-Control-Allow-Origin"); cors != "*" { - t.Errorf("missing CORS header for cross-origin docs-site fetch: %q", cors) - } - - var body struct { - Version int `json:"version"` - Categories []apiCategory `json:"categories"` - } - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { - t.Fatalf("decode: %v", err) - } - - if body.Version != 1 { - t.Errorf("version = %d, want 1 (bump only when schema breaks)", body.Version) - } - - if len(body.Categories) == 0 { - t.Fatal("no categories returned") - } - - // Spot-check the canonical first pattern is present and well-formed. - var firstPattern *apiPattern - for _, c := range body.Categories { - if len(c.Patterns) > 0 { - firstPattern = &c.Patterns[0] - break - } - } - if firstPattern == nil { - t.Fatal("no patterns in any category") - } - if firstPattern.Slug == "" || firstPattern.Name == "" || firstPattern.Path == "" { - t.Errorf("first pattern has empty fields: %+v", firstPattern) - } - if !strings.HasPrefix(firstPattern.Path, "/patterns/") { - t.Errorf("first pattern path doesn't look like a pattern URL: %q", firstPattern.Path) - } - if firstPattern.Status != "stable" && firstPattern.Status != "soon" { - t.Errorf("first pattern status %q must be stable|soon", firstPattern.Status) - } - if firstPattern.Category == "" { - t.Error("category denormalized field should be set") - } -} - -func TestAPIIndex_HEADRequest(t *testing.T) { - srv := httptest.NewServer(apiIndexHandler()) - defer srv.Close() - - req, _ := http.NewRequest(http.MethodHead, srv.URL, nil) - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - t.Errorf("HEAD status = %d", resp.StatusCode) - } -} - -func TestAPIIndex_RejectsNonGET(t *testing.T) { - srv := httptest.NewServer(apiIndexHandler()) - defer srv.Close() - - req, _ := http.NewRequest(http.MethodPost, srv.URL, nil) - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusMethodNotAllowed { - t.Errorf("POST status = %d, want 405", resp.StatusCode) - } -} - -// CORS preflight from cross-origin browsers (the docs site fetching -// from lt-patterns.fly.dev) MUST succeed or the actual GET never -// fires. Reviewer flagged this on the initial PR. -func TestAPIIndex_OPTIONSPreflightSucceeds(t *testing.T) { - srv := httptest.NewServer(apiIndexHandler()) - defer srv.Close() - - req, _ := http.NewRequest(http.MethodOptions, srv.URL, nil) - req.Header.Set("Origin", "https://livetemplate.fly.dev") - req.Header.Set("Access-Control-Request-Method", "GET") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusNoContent { - t.Errorf("OPTIONS status = %d, want 204", resp.StatusCode) - } - if origin := resp.Header.Get("Access-Control-Allow-Origin"); origin != "*" { - t.Errorf("Access-Control-Allow-Origin = %q, want *", origin) - } - allow := resp.Header.Get("Access-Control-Allow-Methods") - if !strings.Contains(allow, "GET") { - t.Errorf("Access-Control-Allow-Methods %q must include GET", allow) - } -} - -// Catch the common drift case: a handler is registered in main.go but -// never made it into allPatterns() (or vice versa). The API consumer -// expects the catalog to mirror what's actually served. -func TestAPIIndex_AllPatternsHaveImplementedHandlers(t *testing.T) { - categories := allPatterns() - if len(categories) == 0 { - t.Fatal("allPatterns() returned no categories") - } - count := 0 - for _, c := range categories { - count += len(c.Patterns) - } - if count < 30 { - t.Errorf("got %d patterns; expected at least 30 (drift between data.go and main.go?)", count) - } -} diff --git a/patterns/cross_handler_nav_test.go b/patterns/cross_handler_nav_test.go deleted file mode 100644 index 347b8a0..0000000 --- a/patterns/cross_handler_nav_test.go +++ /dev/null @@ -1,421 +0,0 @@ -package main - -import ( - "fmt" - "strings" - "testing" - "time" - - "github.com/chromedp/chromedp" - e2etest "github.com/livetemplate/lvt/testing" -) - -// TestCrossHandlerNavigation verifies that SPA navigation between different -// LiveTemplate handlers works correctly. Each pattern is a separate handler -// with its own data-lvt-id, so navigating between them requires the client -// to disconnect the old WebSocket and reconnect to the new handler. -func TestCrossHandlerNavigation(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - baseURL := e2etest.GetChromeTestURL(serverPort) - - t.Run("Index_to_Pattern", func(t *testing.T) { - var html string - err := chromedp.Run(ctx, - // Start at the index page - chromedp.Navigate(baseURL), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`h2`, chromedp.ByQuery), - - // Click on "Edit Row" link - chromedp.Click(`a[href="/patterns/forms/edit-row"]`, chromedp.ByQuery), - - // Wait for the Edit Row page content to appear - e2etest.WaitForText(`h3`, "Edit Row", 10*time.Second), - chromedp.OuterHTML(`body`, &html, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Navigation from index to Edit Row failed: %v", err) - } - if !strings.Contains(html, "Joe Smith") { - t.Error("Edit Row content 'Joe Smith' not found after navigation") - } - if !strings.Contains(html, "Edit Row") { - t.Error("Edit Row heading not found after navigation") - } - - // Verify URL was updated - var currentURL string - chromedp.Run(ctx, chromedp.Location(¤tURL)) - if !strings.HasSuffix(currentURL, "/patterns/forms/edit-row") { - t.Errorf("Expected URL ending in /patterns/forms/edit-row, got %s", currentURL) - } - }) - - t.Run("Pattern_Back_to_Index", func(t *testing.T) { - // We should already be on the Edit Row page from the previous test - var html string - err := chromedp.Run(ctx, - // Click "Patterns" nav link to go back to index - chromedp.Click(`nav a[href="/"]`, chromedp.ByQuery), - - // Wait for index page content - e2etest.WaitForText(`h2`, "LiveTemplate Patterns", 10*time.Second), - chromedp.OuterHTML(`body`, &html, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Navigation from pattern back to index failed: %v", err) - } - if !strings.Contains(html, "Forms & Editing") { - t.Error("Index page category 'Forms & Editing' not found") - } - - // Verify URL was updated back to root - var currentURL string - chromedp.Run(ctx, chromedp.Location(¤tURL)) - if !strings.HasSuffix(currentURL, fmt.Sprintf(":%d/", serverPort)) { - t.Errorf("Expected URL ending in :%d/, got %s", serverPort, currentURL) - } - }) - - t.Run("Navigate_Between_Two_Patterns", func(t *testing.T) { - // Start fresh at index - err := chromedp.Run(ctx, - chromedp.Navigate(baseURL), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`h2`, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to load index: %v", err) - } - - // Navigate to Click To Edit - var html1 string - err = chromedp.Run(ctx, - chromedp.Click(`a[href="/patterns/forms/click-to-edit"]`, chromedp.ByQuery), - e2etest.WaitForText(`h3`, "Click To Edit", 10*time.Second), - chromedp.OuterHTML(`article`, &html1, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Navigation to Click To Edit failed: %v", err) - } - if !strings.Contains(html1, "john@example.com") { - t.Error("Click To Edit content not found") - } - - // Navigate back to index via "Patterns" nav link - err = chromedp.Run(ctx, - chromedp.Click(`nav a[href="/"]`, chromedp.ByQuery), - e2etest.WaitForText(`h2`, "LiveTemplate Patterns", 10*time.Second), - ) - if err != nil { - t.Fatalf("Navigation back to index failed: %v", err) - } - - // Now navigate to Bulk Update (different pattern) - var html2 string - err = chromedp.Run(ctx, - chromedp.Click(`a[href="/patterns/forms/bulk-update"]`, chromedp.ByQuery), - e2etest.WaitForText(`h3`, "Bulk Update", 10*time.Second), - chromedp.OuterHTML(`article`, &html2, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Navigation to Bulk Update failed: %v", err) - } - if !strings.Contains(html2, "Joe Smith") { - t.Error("Bulk Update content not found") - } - - // Verify no stale content from Click To Edit - if strings.Contains(html2, "john@example.com") { - t.Error("Stale Click To Edit content still present on Bulk Update page") - } - }) - - t.Run("Browser_Back_Button", func(t *testing.T) { - // Start fresh at index - err := chromedp.Run(ctx, - chromedp.Navigate(baseURL), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`h2`, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to load index: %v", err) - } - - // Navigate forward to Click To Edit - err = chromedp.Run(ctx, - chromedp.Click(`a[href="/patterns/forms/click-to-edit"]`, chromedp.ByQuery), - e2etest.WaitForText(`h3`, "Click To Edit", 10*time.Second), - ) - if err != nil { - t.Fatalf("Forward navigation failed: %v", err) - } - - // Press browser Back button - err = chromedp.Run(ctx, - chromedp.NavigateBack(), - e2etest.WaitForText(`h2`, "LiveTemplate Patterns", 10*time.Second), - ) - if err != nil { - t.Fatalf("Back button navigation failed: %v", err) - } - - // Verify we're on the index page - var html string - err = chromedp.Run(ctx, - chromedp.OuterHTML(`body`, &html, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to read page after back: %v", err) - } - if !strings.Contains(html, "Forms & Editing") { - t.Error("Index page content not found after back button") - } - - // Press browser Forward button - err = chromedp.Run(ctx, - chromedp.NavigateForward(), - e2etest.WaitForText(`h3`, "Click To Edit", 10*time.Second), - ) - if err != nil { - t.Fatalf("Forward button navigation failed: %v", err) - } - }) - - t.Run("Title_Updates_On_Navigation", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Navigate(baseURL), - e2etest.WaitForWebSocketReady(5*time.Second), - ) - if err != nil { - t.Fatalf("Failed to load index: %v", err) - } - - // Verify index title - var title string - chromedp.Run(ctx, chromedp.Title(&title)) - if !strings.Contains(title, "LiveTemplate Patterns") { - t.Errorf("Expected index title to contain 'LiveTemplate Patterns', got %q", title) - } - - // Navigate to Click To Edit - err = chromedp.Run(ctx, - chromedp.Click(`a[href="/patterns/forms/click-to-edit"]`, chromedp.ByQuery), - e2etest.WaitForText(`h3`, "Click To Edit", 10*time.Second), - ) - if err != nil { - t.Fatalf("Navigation failed: %v", err) - } - - // Verify title updated - chromedp.Run(ctx, chromedp.Title(&title)) - if !strings.Contains(title, "Click To Edit") { - t.Errorf("Expected title to contain 'Click To Edit', got %q", title) - } - - // Navigate back and verify title restores - err = chromedp.Run(ctx, - chromedp.NavigateBack(), - e2etest.WaitForText(`h2`, "LiveTemplate Patterns", 10*time.Second), - ) - if err != nil { - t.Fatalf("Back navigation failed: %v", err) - } - - chromedp.Run(ctx, chromedp.Title(&title)) - if !strings.Contains(title, "LiveTemplate Patterns") { - t.Errorf("Expected title to restore to 'LiveTemplate Patterns', got %q", title) - } - }) - - t.Run("WebSocket_Works_After_Back_Button", func(t *testing.T) { - // Navigate to Reset Input, then back, then forward, then test WebSocket - err := chromedp.Run(ctx, - chromedp.Navigate(baseURL), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.Click(`a[href="/patterns/forms/reset-input"]`, chromedp.ByQuery), - e2etest.WaitForText(`h3`, "Reset User Input", 10*time.Second), - e2etest.WaitForWebSocketReady(10*time.Second), - // Go back - chromedp.NavigateBack(), - e2etest.WaitForText(`h2`, "LiveTemplate Patterns", 10*time.Second), - // Go forward to Reset Input again - chromedp.NavigateForward(), - e2etest.WaitForText(`h3`, "Reset User Input", 10*time.Second), - e2etest.WaitForWebSocketReady(10*time.Second), - ) - if err != nil { - t.Fatalf("Back/forward navigation failed: %v", err) - } - - // Verify WebSocket works by submitting a form - var html string - err = chromedp.Run(ctx, - chromedp.SendKeys(`input[name="message"]`, "After back-forward", chromedp.ByQuery), - chromedp.Click(`button[type="submit"]`, chromedp.ByQuery), - e2etest.WaitForText(`article`, "After back-forward", 5*time.Second), - chromedp.OuterHTML(`article`, &html, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Form submission after back/forward failed: %v", err) - } - if !strings.Contains(html, "After back-forward") { - t.Error("Message not found after back/forward navigation") - } - }) - - t.Run("Index_To_Delete_Row_No_Stale_Dom", func(t *testing.T) { - // Regression for cross-handler nav leaving stale index DOM: index's - // 7 category

s must NOT remain after clicking into Delete Row. - // The later WaitFor on row count is load-bearing β€” it ensures the - // WebSocket's initial tree update has been applied before we check, - // so any treeState merge bug actually shows up. - err := chromedp.Run(ctx, - chromedp.Navigate(baseURL), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`h2`, chromedp.ByQuery), - e2etest.WaitFor(`document.querySelectorAll('article').length >= 7`, 3*time.Second), - chromedp.Click(`a[href="/patterns/lists/delete-row"]`, chromedp.ByQuery), - e2etest.WaitForText(`h3`, "Delete Row", 10*time.Second), - e2etest.WaitFor(`document.querySelectorAll('tbody tr[data-key]').length === 5`, 5*time.Second), - e2etest.WaitForWebSocketReady(10*time.Second), - ) - if err != nil { - var articleCount, rowCount, leakedCategoryHeaders int - var wrapperHTML string - _ = chromedp.Run(ctx, - chromedp.Evaluate(`document.querySelectorAll('[data-lvt-id] article').length`, &articleCount), - chromedp.Evaluate(`document.querySelectorAll('tbody tr[data-key]').length`, &rowCount), - chromedp.Evaluate(`document.querySelectorAll('[data-lvt-id] h4').length`, &leakedCategoryHeaders), - chromedp.OuterHTML(`[data-lvt-id]`, &wrapperHTML, chromedp.ByQuery), - ) - t.Fatalf("Navigation bug: articles=%d rows=%d leakedH4=%d. Wrapper HTML:\n%s\nError: %v", - articleCount, rowCount, leakedCategoryHeaders, wrapperHTML, err) - } - // Assert: exactly 1 article, exactly 5 rows, 0 leaked

headers. - var articleCount, leakedCategoryHeaders int - _ = chromedp.Run(ctx, - chromedp.Evaluate(`document.querySelectorAll('[data-lvt-id] article').length`, &articleCount), - chromedp.Evaluate(`document.querySelectorAll('[data-lvt-id] h4').length`, &leakedCategoryHeaders), - ) - if leakedCategoryHeaders > 0 { - t.Errorf("Stale

category headers leaked from index: %d present", leakedCategoryHeaders) - } - if articleCount != 1 { - t.Errorf("Expected exactly 1
, got %d", articleCount) - } - }) - - t.Run("Index_To_Modal_Dialog_No_Stale_Dom", func(t *testing.T) { - // Regression: navigating from index to a navigation pattern must - // not leak the index page's category names ("Forms & Editing", - // "Lists & Data", etc.) into the new pattern's wrapper. We check - // for those specific strings rather than counting

s β€” Modal - // Dialog has its own legitimate h4 inside the dialog. - err := chromedp.Run(ctx, - chromedp.Navigate(baseURL), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`h2`, chromedp.ByQuery), - chromedp.Click(`a[href="/patterns/navigation/modal-dialog"]`, chromedp.ByQuery), - e2etest.WaitForText(`h3`, "Modal Dialog", 10*time.Second), - ) - if err != nil { - t.Fatalf("Index β†’ Modal Dialog navigation failed: %v", err) - } - var leakedText string - if err := chromedp.Run(ctx, chromedp.Evaluate(`(() => { - const wrapper = document.querySelector('[data-lvt-id]'); - if (!wrapper) return "no-wrapper"; - const text = wrapper.textContent || ""; - const leaks = ["Forms & Editing", "Lists & Data", "Search & Filtering", "Loading & Progress", "Visual Feedback", "Real-Time"]; - return leaks.find(s => text.includes(s)) || ""; - })()`, &leakedText)); err != nil { - t.Fatalf("Leak-detection script failed to evaluate: %v", err) - } - if leakedText == "no-wrapper" { - t.Fatalf("Wrapper element [data-lvt-id] not found after navigation") - } - if leakedText != "" { - t.Errorf("Stale index category text %q leaked into Modal Dialog wrapper", leakedText) - } - }) - - // Tabs same-pathname __navigate__ is covered by - // TestTabs/Tab_Switch_Uses_WebSocket_Not_HTTP in patterns_test.go. - - t.Run("SPA_Navigation_Cross_Pathname_Reconnects", func(t *testing.T) { - // From the SPA Navigation page, clicking a cross-pathname link to - // another pattern must change the wrapper's data-lvt-id (the new - // handler is a separate handler, so it gets a fresh wrapper id) and - // surface the new pattern's content. - err := chromedp.Run(ctx, - chromedp.Navigate(baseURL+"/patterns/navigation/spa-navigation"), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`a[href="/patterns/navigation/tabs"]`, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to load SPA Navigation page: %v", err) - } - var oldID, newID string - err = chromedp.Run(ctx, - chromedp.AttributeValue(`[data-lvt-id]`, "data-lvt-id", &oldID, nil, chromedp.ByQuery), - chromedp.Click(`a[href="/patterns/navigation/tabs"]`, chromedp.ByQuery), - e2etest.WaitForText(`h3`, "Tabs (HATEOAS)", 10*time.Second), - e2etest.WaitForWebSocketReady(10*time.Second), - chromedp.AttributeValue(`[data-lvt-id]`, "data-lvt-id", &newID, nil, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Cross-pathname click + reconnect failed: %v", err) - } - if oldID == "" || newID == "" { - t.Errorf("data-lvt-id values should be present (old=%q, new=%q)", oldID, newID) - } - if oldID == newID { - t.Errorf("Cross-pathname navigation must reconnect with a fresh wrapper id; got same id %q before and after", oldID) - } - }) - - t.Run("WebSocket_Works_After_Navigation", func(t *testing.T) { - // Start fresh at index - err := chromedp.Run(ctx, - chromedp.Navigate(baseURL), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`h2`, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to load index: %v", err) - } - - // Navigate to Reset Input - err = chromedp.Run(ctx, - chromedp.Click(`a[href="/patterns/forms/reset-input"]`, chromedp.ByQuery), - e2etest.WaitForText(`h3`, "Reset User Input", 10*time.Second), - // Wait for WebSocket to reconnect on the new handler - e2etest.WaitForWebSocketReady(10*time.Second), - ) - if err != nil { - t.Fatalf("Navigation to Reset Input failed: %v", err) - } - - // Submit a form β€” this requires a working WebSocket connection - var html string - err = chromedp.Run(ctx, - chromedp.SendKeys(`input[name="message"]`, "Cross-handler test", chromedp.ByQuery), - chromedp.Click(`button[type="submit"]`, chromedp.ByQuery), - e2etest.WaitForText(`article`, "Cross-handler test", 5*time.Second), - chromedp.OuterHTML(`article`, &html, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Form submission after cross-handler nav failed: %v", err) - } - if !strings.Contains(html, "Cross-handler test") { - t.Error("Submitted message not found β€” WebSocket not reconnected") - } - }) -} diff --git a/patterns/data.go b/patterns/data.go deleted file mode 100644 index e886445..0000000 --- a/patterns/data.go +++ /dev/null @@ -1,347 +0,0 @@ -package main - -import ( - "fmt" - "maps" - "slices" - "strings" -) - -// Contact represents a person in the demo data. -type Contact struct { - ID string - Name string - Email string -} - -// UserRow represents a user with an active status toggle. -type UserRow struct { - ID string - Name string - Email string - Active bool -} - -// Item is a generic named item used across multiple patterns. -type Item struct { - ID string - Name string - Email string -} - -// FilterItem is a todo-like item with status and date, used by URL-Preserved Filters. -type FilterItem struct { - ID string - Name string - Status string // "active" or "completed" - Date string // YYYY-MM-DD -} - -func sampleContacts() []Contact { - return []Contact{ - {ID: "1", Name: "Joe Smith", Email: "joe@smith.org"}, - {ID: "2", Name: "Angie MacDowell", Email: "angie@macdowell.org"}, - {ID: "3", Name: "Fuqua Tarkenton", Email: "fuqua@tarkenton.org"}, - {ID: "4", Name: "Kim Yee", Email: "kim@yee.org"}, - } -} - -func sampleUsers() []UserRow { - return []UserRow{ - {ID: "1", Name: "Joe Smith", Email: "joe@smith.org", Active: true}, - {ID: "2", Name: "Angie MacDowell", Email: "angie@macdowell.org", Active: true}, - {ID: "3", Name: "Fuqua Tarkenton", Email: "fuqua@tarkenton.org", Active: false}, - {ID: "4", Name: "Kim Yee", Email: "kim@yee.org", Active: false}, - } -} - -// listDataset (25 items) is used by Delete Row and Click To Load. -// 25 at page size 10 gives three pages (10, 10, 5). Infinite Scroll uses -// a larger dedicated dataset so the auto-scroll cascade is actually visible. -var listDataset = buildItemDataset(25, "Item") - -// infiniteScrollDataset (100 items) gives Infinite Scroll enough rows for -// the auto-pagination cascade to feel real under manual testing. -var infiniteScrollDataset = buildItemDataset(100, "Row") - -func buildItemDataset(n int, namePrefix string) []Item { - items := make([]Item, n) - for i := range items { - id := i + 1 - items[i] = Item{ - ID: fmt.Sprintf("%d", id), - Name: fmt.Sprintf("%s %d", namePrefix, id), - Email: fmt.Sprintf("%s%d@example.com", strings.ToLower(namePrefix), id), - } - } - return items -} - -// getItemPage returns a 1-indexed page of listDataset. Empty slice when -// out of range. Used by Click To Load and Delete Row's initial state. -func getItemPage(page, size int) []Item { - return pageSlice(listDataset, page, size) -} - -// getInfiniteScrollPage returns a 1-indexed page of infiniteScrollDataset. -func getInfiniteScrollPage(page, size int) []Item { - return pageSlice(infiniteScrollDataset, page, size) -} - -func pageSlice(dataset []Item, page, size int) []Item { - if page < 1 || size < 1 { - return nil - } - start := (page - 1) * size - if start >= len(dataset) { - return nil - } - end := start + size - if end > len(dataset) { - end = len(dataset) - } - return slices.Clone(dataset[start:end]) -} - -func initialSortableItems() []SortableItem { - return []SortableItem{ - {Key: "task-1", Name: "Design wireframes"}, - {Key: "task-2", Name: "Write API spec"}, - {Key: "task-3", Name: "Implement backend"}, - {Key: "task-4", Name: "Build frontend"}, - {Key: "task-5", Name: "Write tests"}, - {Key: "task-6", Name: "Deploy to staging"}, - } -} - -// LargeRow is a row in the Large Table demo. Five fields exercise the -// multi-field volatile-field update workload that closes Open Question 2 -// of the streaming-range proposal: a single-field change still emits a -// whole-item ["u"] op carrying all five. -type LargeRow struct { - ID string - Name string - Email string - Status string - Score int -} - -// largeTableDefaultSize is the demo's default row count. The e2e test -// overrides it via LARGE_TABLE_SIZE so CI doesn't pay for 10k DOM rows -// while still exercising every controller path. -const largeTableDefaultSize = 10000 - -var largeTableStatuses = []string{"active", "pending", "blocked", "archived"} - -// largeTableSeed builds the deterministic seed dataset. No rand: stable -// hashes across renders are required for the streaming-range diff to -// recognise unchanged items. -func largeTableSeed(n int) []LargeRow { - rows := make([]LargeRow, n) - for i := range rows { - id := i + 1 - rows[i] = LargeRow{ - ID: fmt.Sprintf("row-%05d", id), - Name: fmt.Sprintf("User %05d", id), - Email: fmt.Sprintf("user%05d@example.com", id), - Status: largeTableStatuses[id%len(largeTableStatuses)], - Score: (id * 37) % 1000, - } - } - return rows -} - -// carMakes maps car makes to their model lists. Used by Value Select to -// demonstrate cascading dependent selects. -var carMakes = map[string][]string{ - "Audi": {"A3", "A4", "Q5", "R8"}, - "BMW": {"3 Series", "5 Series", "X3", "M3"}, - "Toyota": {"Camry", "Corolla", "RAV4", "Highlander"}, -} - -// getCarMakes returns the sorted list of available car makes. -func getCarMakes() []string { - return slices.Sorted(maps.Keys(carMakes)) -} - -// getCarModels returns a copy of the models for a given make, or nil if -// the make is unknown. Returning a copy defends callers from aliasing the -// package-level carMakes map. -func getCarModels(carMake string) []string { - return slices.Clone(carMakes[carMake]) -} - -// contactDirectory is the 25-contact dataset used by Active Search. Kept -// distinct from sampleContacts() (4 entries) which is pinned by Edit Row tests. -var contactDirectory = []Contact{ - {ID: "1", Name: "Marcus Chen", Email: "marcus.chen@example.com"}, - {ID: "2", Name: "Priya Patel", Email: "priya.patel@example.com"}, - {ID: "3", Name: "Diana Okonkwo", Email: "diana.okonkwo@example.com"}, - {ID: "4", Name: "Rafael Hernandez", Email: "rafael.hernandez@example.com"}, - {ID: "5", Name: "Yuki Tanaka", Email: "yuki.tanaka@example.com"}, - {ID: "6", Name: "Fatima Al-Rashid", Email: "fatima.alrashid@example.com"}, - {ID: "7", Name: "Liam O'Brien", Email: "liam.obrien@example.com"}, - {ID: "8", Name: "Sofia Rossi", Email: "sofia.rossi@example.com"}, - {ID: "9", Name: "Kwame Asante", Email: "kwame.asante@example.com"}, - {ID: "10", Name: "Ingrid Nilsson", Email: "ingrid.nilsson@example.com"}, - {ID: "11", Name: "Arjun Sharma", Email: "arjun.sharma@example.com"}, - {ID: "12", Name: "Elena Volkov", Email: "elena.volkov@example.com"}, - {ID: "13", Name: "Mateo Silva", Email: "mateo.silva@example.com"}, - {ID: "14", Name: "Aisha Bello", Email: "aisha.bello@example.com"}, - {ID: "15", Name: "Thomas Weber", Email: "thomas.weber@example.com"}, - {ID: "16", Name: "Nadia Haddad", Email: "nadia.haddad@example.com"}, - {ID: "17", Name: "Jin-ho Park", Email: "jinho.park@example.com"}, - {ID: "18", Name: "Olivia Bennett", Email: "olivia.bennett@example.com"}, - {ID: "19", Name: "Samuel Adeyemi", Email: "samuel.adeyemi@example.com"}, - {ID: "20", Name: "Carmen Reyes", Email: "carmen.reyes@example.com"}, - {ID: "21", Name: "Henrik Larsen", Email: "henrik.larsen@example.com"}, - {ID: "22", Name: "Meera Krishnan", Email: "meera.krishnan@example.com"}, - {ID: "23", Name: "Luca Bianchi", Email: "luca.bianchi@example.com"}, - {ID: "24", Name: "Zara Ahmed", Email: "zara.ahmed@example.com"}, - {ID: "25", Name: "Gabriel Martinez", Email: "gabriel.martinez@example.com"}, -} - -// searchContacts returns contacts whose name or email contains query -// (case-insensitive). Empty query returns the full directory. -func searchContacts(query string) []Contact { - if query == "" { - return slices.Clone(contactDirectory) - } - q := strings.ToLower(query) - out := make([]Contact, 0, len(contactDirectory)) - for _, c := range contactDirectory { - if strings.Contains(strings.ToLower(c.Name), q) || strings.Contains(strings.ToLower(c.Email), q) { - out = append(out, c) - } - } - return out -} - -// filterDataset is the set of items used by URL-Preserved Filters. -// Mix of active/completed statuses and dates spanning ~1 year. -var filterDataset = []FilterItem{ - {ID: "1", Name: "Design homepage", Status: "completed", Date: "2024-02-14"}, - {ID: "2", Name: "Draft Q1 proposal", Status: "completed", Date: "2024-03-01"}, - {ID: "3", Name: "Review authentication spec", Status: "active", Date: "2024-03-15"}, - {ID: "4", Name: "Migrate legacy database", Status: "active", Date: "2024-04-02"}, - {ID: "5", Name: "Write onboarding docs", Status: "completed", Date: "2024-04-20"}, - {ID: "6", Name: "Upgrade CI pipeline", Status: "active", Date: "2024-05-08"}, - {ID: "7", Name: "Host team offsite", Status: "completed", Date: "2024-06-12"}, - {ID: "8", Name: "Ship payments beta", Status: "active", Date: "2024-07-04"}, - {ID: "9", Name: "Audit access controls", Status: "completed", Date: "2024-08-19"}, - {ID: "10", Name: "Refactor billing module", Status: "active", Date: "2024-09-28"}, - {ID: "11", Name: "Launch mobile app", Status: "active", Date: "2024-11-11"}, - {ID: "12", Name: "Year-end retrospective", Status: "active", Date: "2024-12-30"}, -} - -// filterItems returns filterDataset filtered by status and sorted by sort key. -// Unknown status values are treated as "all"; unknown sort values fall back to "name". -func filterItems(status, sort string) []FilterItem { - out := make([]FilterItem, 0, len(filterDataset)) - for _, item := range filterDataset { - if status == "active" || status == "completed" { - if item.Status != status { - continue - } - } - out = append(out, item) - } - switch sort { - case "date": - slices.SortFunc(out, func(a, b FilterItem) int { - return strings.Compare(b.Date, a.Date) // descending (newest first) - }) - default: // "name" and any unknown value - slices.SortFunc(out, func(a, b FilterItem) int { - return strings.Compare(a.Name, b.Name) - }) - } - return out -} - -// PatternLink describes a single pattern in the index page catalog. -type PatternLink struct { - Name string - Path string - Description string - Implemented bool -} - -// PatternCategory groups related patterns for the index page. -type PatternCategory struct { - Name string - Patterns []PatternLink -} - -func allPatterns() []PatternCategory { - return []PatternCategory{ - { - Name: "Forms & Editing", - Patterns: []PatternLink{ - {Name: "Click To Edit", Path: "/patterns/forms/click-to-edit", Description: "Toggle between view and edit mode", Implemented: true}, - {Name: "Edit Row", Path: "/patterns/forms/edit-row", Description: "Inline editing of table rows", Implemented: true}, - {Name: "Inline Validation", Path: "/patterns/forms/inline-validation", Description: "Server-side field validation as you type", Implemented: true}, - {Name: "Bulk Update", Path: "/patterns/forms/bulk-update", Description: "Batch checkbox operations", Implemented: true}, - {Name: "Reset User Input", Path: "/patterns/forms/reset-input", Description: "Auto-clear forms after submission", Implemented: true}, - {Name: "File Upload", Path: "/patterns/forms/file-upload", Description: "Standard and chunked file uploads", Implemented: true}, - {Name: "Preserving File Inputs", Path: "/patterns/forms/preserve-inputs", Description: "Retain form values across re-renders", Implemented: true}, - }, - }, - { - Name: "Lists & Data", - Patterns: []PatternLink{ - {Name: "Delete Row", Path: "/patterns/lists/delete-row", Description: "Animated row removal", Implemented: true}, - {Name: "Click To Load", Path: "/patterns/lists/click-to-load", Description: "Append-only pagination", Implemented: true}, - {Name: "Infinite Scroll", Path: "/patterns/lists/infinite-scroll", Description: "Auto-load on scroll with IntersectionObserver", Implemented: true}, - {Name: "Value Select", Path: "/patterns/lists/value-select", Description: "Cascading dependent selects", Implemented: true}, - {Name: "Sortable List", Path: "/patterns/lists/sortable", Description: "Drag-and-drop reordering with native HTML5 drag events", Implemented: true}, - {Name: "Large Table", Path: "/patterns/lists/large-table", Description: "10k-row table with filter, sort, append, update, delete, reset (streaming range)", Implemented: true}, - }, - }, - { - Name: "Search & Filtering", - Patterns: []PatternLink{ - {Name: "Active Search", Path: "/patterns/search/active-search", Description: "Debounced live search", Implemented: true}, - {Name: "URL-Preserved Filters", Path: "/patterns/search/url-filters", Description: "Bookmarkable filter state via query params", Implemented: true}, - }, - }, - { - Name: "Loading & Progress", - Patterns: []PatternLink{ - {Name: "Lazy Loading", Path: "/patterns/loading/lazy-loading", Description: "Load content after page render via server push", Implemented: true}, - {Name: "Progress Bar", Path: "/patterns/loading/progress-bar", Description: "WebSocket-pushed progress updates", Implemented: true}, - {Name: "Async Operations", Path: "/patterns/loading/async-operations", Description: "Loading/success/error state machine", Implemented: true}, - }, - }, - { - Name: "Dialogs, Tabs & Navigation", - Patterns: []PatternLink{ - {Name: "Modal Dialog", Path: "/patterns/navigation/modal-dialog", Description: "Native dialog with command/commandfor", Implemented: true}, - {Name: "Confirm Dialog", Path: "/patterns/navigation/confirm-dialog", Description: "CSP-compliant confirmation flow", Implemented: true}, - {Name: "Tabs (HATEOAS)", Path: "/patterns/navigation/tabs", Description: "Server-driven tabs via SPA navigation", Implemented: true}, - {Name: "SPA Navigation", Path: "/patterns/navigation/spa-navigation", Description: "Auto link interception with pushState", Implemented: true}, - {Name: "Keyboard Shortcuts", Path: "/patterns/navigation/keyboard-shortcuts", Description: "Global keyboard event binding", Implemented: true}, - }, - }, - { - Name: "Visual Feedback", - Patterns: []PatternLink{ - {Name: "Animations", Path: "/patterns/feedback/animations", Description: "Entry animations with lvt-fx:animate", Implemented: true}, - {Name: "Loading States", Path: "/patterns/feedback/loading-states", Description: "Auto aria-busy and custom loading text", Implemented: true}, - {Name: "Highlight on Change", Path: "/patterns/feedback/highlight", Description: "Visual flash on DOM updates", Implemented: true}, - {Name: "Flash Messages", Path: "/patterns/feedback/flash-messages", Description: "Toast notifications via ctx.SetFlash", Implemented: true}, - }, - }, - { - Name: "Real-Time & Multi-User", - Patterns: []PatternLink{ - {Name: "Multi-User Sync", Path: "/patterns/realtime/multi-user-sync", Description: "Auto-sync across tabs via Sync() handler", Implemented: true}, - {Name: "Broadcasting", Path: "/patterns/realtime/broadcasting", Description: "Cross-connection updates via BroadcastAction", Implemented: true}, - {Name: "Presence Tracking", Path: "/patterns/realtime/presence", Description: "Explicit join/leave with shared state", Implemented: true}, - {Name: "Reconnection Recovery", Path: "/patterns/realtime/reconnection", Description: "State persistence across disconnects", Implemented: true}, - {Name: "Live Preview", Path: "/patterns/realtime/live-preview", Description: "Real-time input preview via Change()", Implemented: true}, - {Name: "Server Push", Path: "/patterns/realtime/server-push", Description: "Background goroutine pushing updates", Implemented: true}, - }, - }, - } -} diff --git a/patterns/fly.toml b/patterns/fly.toml deleted file mode 100644 index 5bd4f9d..0000000 --- a/patterns/fly.toml +++ /dev/null @@ -1,20 +0,0 @@ -# Deployment of the LiveTemplate patterns showcase. -# Used by the docs site's external-app router to source live demos. - -app = "lt-patterns" -primary_region = "sjc" - -[build] - dockerfile = "Dockerfile" - -[http_service] - internal_port = 8080 - force_https = true - auto_stop_machines = "stop" - auto_start_machines = true - min_machines_running = 0 - -[[vm]] - cpu_kind = "shared" - cpus = 1 - memory_mb = 512 diff --git a/patterns/handlers_feedback.go b/patterns/handlers_feedback.go deleted file mode 100644 index 7697ec7..0000000 --- a/patterns/handlers_feedback.go +++ /dev/null @@ -1,151 +0,0 @@ -package main - -import ( - "fmt" - "net/http" - "slices" - "strings" - "time" - - "github.com/livetemplate/livetemplate" -) - -// --- Pattern #22: Animations --- - -type AnimationsController struct{} - -var validAnimateModes = map[string]bool{"fade": true, "slide": true, "scale": true} - -func (c *AnimationsController) Add(state AnimationsState, ctx *livetemplate.Context) (AnimationsState, error) { - if m := ctx.GetString("mode"); validAnimateModes[m] { - state.Mode = m - } - state.Items = append(state.Items, AnimationItem{ - ID: fmt.Sprintf("item-%d", len(state.Items)+1), - Name: fmt.Sprintf("Item %d (%s)", len(state.Items)+1, state.Mode), - Time: time.Now().Format("15:04:05"), - Mode: state.Mode, - }) - return state, nil -} - -func animationsHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/feedback/animations.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(&AnimationsController{}, livetemplate.AsState(&AnimationsState{ - Title: "Animations", - Category: "Visual Feedback", - Mode: "fade", - })) -} - -// --- Pattern #23: Loading States --- - -type LoadingStatesController struct{} - -const slowSaveDelay = 2 * time.Second - -func (c *LoadingStatesController) SlowSave(state LoadingStatesState, ctx *livetemplate.Context) (LoadingStatesState, error) { - // Real handlers should honor ctx.Context().Done(); plain Sleep is fine for a 2s demo. - time.Sleep(slowSaveDelay) - state.LastSave = time.Now().Format("15:04:05") - return state, nil -} - -func loadingStatesHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/feedback/loading-states.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(&LoadingStatesController{}, livetemplate.AsState(&LoadingStatesState{ - Title: "Loading States", - Category: "Visual Feedback", - })) -} - -// --- Pattern #24: Highlight on Change --- - -type HighlightController struct{} - -func (c *HighlightController) Increment(state HighlightState, ctx *livetemplate.Context) (HighlightState, error) { - state.Counter++ - return state, nil -} - -func highlightHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/feedback/highlight.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(&HighlightController{}, livetemplate.AsState(&HighlightState{ - Title: "Highlight on Change", - Category: "Visual Feedback", - })) -} - -// --- Pattern #25: Flash Messages --- - -type FlashMessagesController struct{} - -const flashSuccessExpiry = 5 * time.Second - -// nudgeFlashExpiry triggers a re-render at FlashExpiry's deadline. -// FlashExpiry is render-driven (no background timer), so without a nudge -// the expired flash sits in the DOM until the user's next interaction. -// The pruner runs on the next render and removes the expired entry. -// -// The "refresh" action it dispatches is registered as a no-op handler on -// each controller that calls this helper. -func nudgeFlashExpiry(ctx *livetemplate.Context, expiry time.Duration) { - session := ctx.Session() - if session == nil { - return - } - go func() { - time.Sleep(expiry + 100*time.Millisecond) - _ = session.TriggerAction("refresh", nil) - }() -} - -func (c *FlashMessagesController) Save(state FlashMessagesState, ctx *livetemplate.Context) (FlashMessagesState, error) { - name := strings.TrimSpace(ctx.GetString("name")) - if name == "" { - ctx.ClearFlash("success") - ctx.SetFlash("error", "Name is required") - return state, nil - } - ctx.ClearFlash("error") - ctx.SetFlash("success", "Saved: "+name, livetemplate.FlashExpiry(flashSuccessExpiry)) - nudgeFlashExpiry(ctx, flashSuccessExpiry) - return state, nil -} - -// Refresh is a no-op action whose only purpose is to trigger a re-render -// (and therefore a getMessages snapshot, which prunes expired flash). -// Invoked by the goroutine in Save after FlashExpiry elapses. -func (c *FlashMessagesController) Refresh(state FlashMessagesState, ctx *livetemplate.Context) (FlashMessagesState, error) { - return state, nil -} - -func (c *FlashMessagesController) Notify(state FlashMessagesState, ctx *livetemplate.Context) (FlashMessagesState, error) { - ctx.SetFlash("info", "Heads up β€” this stays until you dismiss it") - return state, nil -} - -func (c *FlashMessagesController) DismissNotify(state FlashMessagesState, ctx *livetemplate.Context) (FlashMessagesState, error) { - ctx.ClearFlash("info") - return state, nil -} - -func flashMessagesHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/feedback/flash-messages.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(&FlashMessagesController{}, livetemplate.AsState(&FlashMessagesState{ - Title: "Flash Messages", - Category: "Visual Feedback", - })) -} diff --git a/patterns/handlers_forms.go b/patterns/handlers_forms.go deleted file mode 100644 index b924b51..0000000 --- a/patterns/handlers_forms.go +++ /dev/null @@ -1,255 +0,0 @@ -package main - -import ( - "fmt" - "net/http" - "slices" - - "github.com/livetemplate/livetemplate" -) - -// --- Pattern #1: Click To Edit --- - -type ClickToEditController struct{} - -func (c *ClickToEditController) Edit(state ClickToEditState, ctx *livetemplate.Context) (ClickToEditState, error) { - state.Editing = true - return state, nil -} - -func (c *ClickToEditController) Save(state ClickToEditState, ctx *livetemplate.Context) (ClickToEditState, error) { - state.FirstName = ctx.GetString("firstName") - state.LastName = ctx.GetString("lastName") - state.Email = ctx.GetString("email") - state.Editing = false - return state, nil -} - -func (c *ClickToEditController) Cancel(state ClickToEditState, ctx *livetemplate.Context) (ClickToEditState, error) { - state.Editing = false - return state, nil -} - -func clickToEditHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/forms/click-to-edit.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(&ClickToEditController{}, livetemplate.AsState(&ClickToEditState{ - Title: "Click To Edit", - Category: "Forms & Editing", - FirstName: "John", - LastName: "Doe", - Email: "john@example.com", - })) -} - -// --- Pattern #2: Edit Row --- - -type EditRowController struct{} - -func (c *EditRowController) Edit(state EditRowState, ctx *livetemplate.Context) (EditRowState, error) { - // Edit/Save buttons send their ID via `value` attribute β€” see - // docs/references/progressive-complexity-reference.md. - state.EditingID = ctx.GetString("value") - return state, nil -} - -func (c *EditRowController) Save(state EditRowState, ctx *livetemplate.Context) (EditRowState, error) { - id := ctx.GetString("value") - for i, contact := range state.Contacts { - if contact.ID == id { - state.Contacts[i].Name = ctx.GetString("name") - state.Contacts[i].Email = ctx.GetString("email") - break - } - } - state.EditingID = "" - return state, nil -} - -func (c *EditRowController) Cancel(state EditRowState, ctx *livetemplate.Context) (EditRowState, error) { - state.EditingID = "" - return state, nil -} - -func editRowHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/forms/edit-row.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(&EditRowController{}, livetemplate.AsState(&EditRowState{ - Title: "Edit Row", - Category: "Forms & Editing", - Contacts: sampleContacts(), - })) -} - -// --- Pattern #3: Inline Validation --- - -type InlineValidationController struct{} - -func (c *InlineValidationController) Change(state InlineValidationState, ctx *livetemplate.Context) (InlineValidationState, error) { - if ctx.Has("email") { - state.Email = ctx.GetString("email") - } - if ctx.Has("username") { - state.Username = ctx.GetString("username") - } - _ = ctx.ValidateForm() - return state, nil -} - -func (c *InlineValidationController) Submit(state InlineValidationState, ctx *livetemplate.Context) (InlineValidationState, error) { - if err := ctx.ValidateForm(); err != nil { - return state, err - } - state.Saved = true - return state, nil -} - -func inlineValidationHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/forms/inline-validation.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(&InlineValidationController{}, livetemplate.AsState(&InlineValidationState{ - Title: "Inline Validation", - Category: "Forms & Editing", - })) -} - -// --- Pattern #4: Bulk Update --- - -type BulkUpdateController struct{} - -func (c *BulkUpdateController) BulkUpdate(state BulkUpdateState, ctx *livetemplate.Context) (BulkUpdateState, error) { - changed := 0 - for i, user := range state.Users { - newActive := ctx.GetBool("active-" + user.ID) - if newActive != user.Active { - changed++ - } - state.Users[i].Active = newActive - } - if changed == 0 { - ctx.ClearFlash("success") - ctx.SetFlash("info", "No changes") - } else { - ctx.ClearFlash("info") - ctx.SetFlash("success", fmt.Sprintf("Updated %d user(s)", changed)) - } - return state, nil -} - -func bulkUpdateHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/forms/bulk-update.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(&BulkUpdateController{}, livetemplate.AsState(&BulkUpdateState{ - Title: "Bulk Update", - Category: "Forms & Editing", - Users: sampleUsers(), - })) -} - -// --- Pattern #5: Reset User Input --- - -type ResetInputController struct{} - -func (c *ResetInputController) Submit(state ResetInputState, ctx *livetemplate.Context) (ResetInputState, error) { - msg := ctx.GetString("message") - if msg != "" { - state.Messages = append(state.Messages, msg) - } - return state, nil -} - -func resetInputHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/forms/reset-input.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(&ResetInputController{}, livetemplate.AsState(&ResetInputState{ - Title: "Reset User Input", - Category: "Forms & Editing", - })) -} - -// --- Pattern #6: File Upload --- - -type FileUploadController struct{} - -func (c *FileUploadController) Upload(state FileUploadState, ctx *livetemplate.Context) (FileUploadState, error) { - for _, name := range []string{"document", "chunked-doc"} { - if ctx.HasUploads(name) { - entries := ctx.GetCompletedUploads(name) - if len(entries) > 0 { - ctx.SetFlash("success", "Uploaded: "+entries[0].ClientName, livetemplate.FlashExpiry(flashSuccessExpiry)) - nudgeFlashExpiry(ctx, flashSuccessExpiry) - return state, nil - } - } - } - ctx.SetFlash("error", "No file selected") - return state, nil -} - -func (c *FileUploadController) Refresh(state FileUploadState, ctx *livetemplate.Context) (FileUploadState, error) { - return state, nil -} - -func fileUploadHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/forms/file-upload.tmpl"), - livetemplate.WithUpload("document", livetemplate.UploadConfig{ - MaxFileSize: 10 << 20, // 10 MB - MaxEntries: 1, - }), - livetemplate.WithUpload("chunked-doc", livetemplate.UploadConfig{ - MaxFileSize: 10 << 20, // 10 MB - MaxEntries: 1, - ChunkSize: 1024, // 1KB chunks β€” small so progress is visible for demo files - }), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(&FileUploadController{}, livetemplate.AsState(&FileUploadState{ - Title: "File Upload", - Category: "Forms & Editing", - })) -} - -// --- Pattern #7: Preserving File Inputs --- - -type PreserveInputsController struct{} - -func (c *PreserveInputsController) Submit(state PreserveInputsState, ctx *livetemplate.Context) (PreserveInputsState, error) { - state.Name = ctx.GetString("name") - state.Description = ctx.GetString("description") - if err := ctx.ValidateForm(); err != nil { - return state, err - } - ctx.SetFlash("success", "Saved: "+state.Name, livetemplate.FlashExpiry(flashSuccessExpiry)) - nudgeFlashExpiry(ctx, flashSuccessExpiry) - return state, nil -} - -func (c *PreserveInputsController) Refresh(state PreserveInputsState, ctx *livetemplate.Context) (PreserveInputsState, error) { - return state, nil -} - -func preserveInputsHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/forms/preserve-inputs.tmpl"), - livetemplate.WithUpload("attachment", livetemplate.UploadConfig{ - MaxFileSize: 10 << 20, // 10 MB - MaxEntries: 1, - }), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(&PreserveInputsController{}, livetemplate.AsState(&PreserveInputsState{ - Title: "Preserving File Inputs", - Category: "Forms & Editing", - })) -} diff --git a/patterns/handlers_lists.go b/patterns/handlers_lists.go deleted file mode 100644 index 9c064cd..0000000 --- a/patterns/handlers_lists.go +++ /dev/null @@ -1,445 +0,0 @@ -package main - -import ( - "cmp" - "fmt" - "math/rand" - "net/http" - "os" - "slices" - "strconv" - "strings" - "sync" - "time" - - "github.com/livetemplate/livetemplate" -) - -// listPageSize is the page size used by Click To Load (#9) and Infinite Scroll (#10). -const listPageSize = 10 - -// --- Pattern #8: Delete Row --- - -// DeleteRowController holds a shared in-memory "database" protected by a -// mutex. Mount copies the DB snapshot into per-session state on every -// connect, so deletions persist across reloads and cross-handler navigation -// without needing `lvt:"persist"` struct tags. The DB lives for the life -// of the process; restarting the server resets it. -type DeleteRowController struct { - mu sync.Mutex - items []Item -} - -const deleteRowInitialCount = 5 - -func newDeleteRowController() *DeleteRowController { - return &DeleteRowController{items: getItemPage(1, deleteRowInitialCount)} -} - -// snapshot returns an independent copy of the current DB. Caller must not -// hold c.mu when invoking (this method acquires it internally). -func (c *DeleteRowController) snapshot() []Item { - c.mu.Lock() - defer c.mu.Unlock() - return slices.Clone(c.items) -} - -func (c *DeleteRowController) Mount(state DeleteRowState, ctx *livetemplate.Context) (DeleteRowState, error) { - state.Items = c.snapshot() - return state, nil -} - -func (c *DeleteRowController) Delete(state DeleteRowState, ctx *livetemplate.Context) (DeleteRowState, error) { - // Button sends its `value` attribute as data.value β€” see - // docs/references/progressive-complexity-reference.md. - id := ctx.GetString("value") - c.mu.Lock() - c.items = slices.DeleteFunc(c.items, func(item Item) bool { - return item.ID == id - }) - c.mu.Unlock() - state.Items = c.snapshot() - return state, nil -} - -// Restore refills the DB to its initial state. Wired to a button that -// appears after the last item is deleted, so visitors can reset the demo -// without restarting the server. -func (c *DeleteRowController) Restore(state DeleteRowState, ctx *livetemplate.Context) (DeleteRowState, error) { - c.mu.Lock() - c.items = getItemPage(1, deleteRowInitialCount) - c.mu.Unlock() - state.Items = c.snapshot() - return state, nil -} - -func deleteRowHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/lists/delete-row.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(newDeleteRowController(), livetemplate.AsState(&DeleteRowState{ - Title: "Delete Row", - Category: "Lists & Data", - })) -} - -// --- Pattern #9: Click To Load --- - -type ClickToLoadController struct{} - -func (c *ClickToLoadController) LoadMore(state ClickToLoadState, ctx *livetemplate.Context) (ClickToLoadState, error) { - state.CurrentPage++ - newItems := getItemPage(state.CurrentPage, listPageSize) - state.Items = append(state.Items, newItems...) - state.HasMore = len(newItems) == listPageSize - return state, nil -} - -func clickToLoadHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/lists/click-to-load.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(&ClickToLoadController{}, livetemplate.AsState(&ClickToLoadState{ - Title: "Click To Load", - Category: "Lists & Data", - Items: getItemPage(1, listPageSize), - CurrentPage: 1, - HasMore: true, - })) -} - -// --- Pattern #11: Value Select (Cascading Selects) --- - -type ValueSelectController struct{} - -func (c *ValueSelectController) Mount(state ValueSelectState, ctx *livetemplate.Context) (ValueSelectState, error) { - state.Makes = getCarMakes() - if state.Make != "" { - state.Models = getCarModels(state.Make) - } - return state, nil -} - -func (c *ValueSelectController) Change(state ValueSelectState, ctx *livetemplate.Context) (ValueSelectState, error) { - if ctx.Has("make") { - state.Make = ctx.GetString("make") - state.Models = getCarModels(state.Make) - // Auto-select first model so the user sees the cascade propagate. - state.Model = "" - if len(state.Models) > 0 { - state.Model = state.Models[0] - } - } - if ctx.Has("model") { - state.Model = ctx.GetString("model") - } - return state, nil -} - -func valueSelectHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/lists/value-select.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(&ValueSelectController{}, livetemplate.AsState(&ValueSelectState{ - Title: "Value Select", - Category: "Lists & Data", - })) -} - -// --- Pattern #10: Infinite Scroll --- - -type InfiniteScrollController struct{} - -// LoadMore is dispatched by the client-side IntersectionObserver when -//
becomes visible. Uses the larger -// infiniteScrollDataset (100 items) so the auto-pagination cascade is -// actually visible during the demo; ClickToLoad uses the 25-item -// listDataset which only needs a couple of clicks. -func (c *InfiniteScrollController) LoadMore(state InfiniteScrollState, ctx *livetemplate.Context) (InfiniteScrollState, error) { - state.CurrentPage++ - newItems := getInfiniteScrollPage(state.CurrentPage, listPageSize) - state.Items = append(state.Items, newItems...) - state.HasMore = len(newItems) == listPageSize - return state, nil -} - -func infiniteScrollHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/lists/infinite-scroll.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(&InfiniteScrollController{}, livetemplate.AsState(&InfiniteScrollState{ - Title: "Infinite Scroll", - Category: "Lists & Data", - Items: getInfiniteScrollPage(1, listPageSize), - CurrentPage: 1, - HasMore: true, - })) -} - -// --- Sortable List --- - -// SortableController holds the list ordering process-wide so it persists across reloads (live multi-tab sync would need Sync()). -type SortableController struct { - mu sync.Mutex - items []SortableItem -} - -func newSortableController() *SortableController { - return &SortableController{items: initialSortableItems()} -} - -func (c *SortableController) snapshot() []SortableItem { - c.mu.Lock() - defer c.mu.Unlock() - return slices.Clone(c.items) -} - -func (c *SortableController) Mount(state SortableState, ctx *livetemplate.Context) (SortableState, error) { - state.Items = c.snapshot() - return state, nil -} - -// Reorder reads dragSourceKey / dragTargetKey (injected by livetemplate/client from the source/target data-key) and always repopulates state.Items from the locked snapshot β€” the framework-provided value is per-session and may lag the shared ordering. -func (c *SortableController) Reorder(state SortableState, ctx *livetemplate.Context) (SortableState, error) { - src := ctx.GetString("dragSourceKey") - tgt := ctx.GetString("dragTargetKey") - - c.mu.Lock() - defer c.mu.Unlock() - - if src == "" || tgt == "" || src == tgt { - state.Items = slices.Clone(c.items) - return state, nil - } - - srcIdx, tgtIdx := -1, -1 - for i, it := range c.items { - if it.Key == src { - srcIdx = i - } - if it.Key == tgt { - tgtIdx = i - } - if srcIdx >= 0 && tgtIdx >= 0 { - break - } - } - if srcIdx < 0 || tgtIdx < 0 { - state.Items = slices.Clone(c.items) - return state, nil - } - - moved := c.items[srcIdx] - c.items = slices.Delete(c.items, srcIdx, srcIdx+1) - if srcIdx < tgtIdx { - tgtIdx-- - } - c.items = slices.Insert(c.items, tgtIdx, moved) - - state.Items = slices.Clone(c.items) - return state, nil -} - -func (c *SortableController) Reset(state SortableState, ctx *livetemplate.Context) (SortableState, error) { - c.mu.Lock() - defer c.mu.Unlock() - c.items = initialSortableItems() - state.Items = slices.Clone(c.items) - return state, nil -} - -func sortableHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/lists/sortable.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(newSortableController(), livetemplate.AsState(&SortableState{ - Title: "Sortable List", - Category: "Lists & Data", - })) -} - -// --- Large Table (10k-row streaming-range demo) --- - -const ( - largeTableSortByName = "name" - largeTableSortByEmail = "email" - largeTableSortByStatus = "status" - largeTableSortByScore = "score" - largeTableSortAsc = "asc" - largeTableSortDesc = "desc" - largeTableAppendBatch = 50 -) - -// LargeTableController owns the row dataset process-wide. Mu protects -// rows + nextID + rng. Filter/sort live per-session in LargeTableState. -// SeedSize is captured at construction so Reset returns to the same N -// even when overridden via LARGE_TABLE_SIZE. -type LargeTableController struct { - mu sync.Mutex - rows []LargeRow - nextID int - seedSize int - rng *rand.Rand -} - -func newLargeTableController() *LargeTableController { - size := largeTableDefaultSize - if v := os.Getenv("LARGE_TABLE_SIZE"); v != "" { - if n, err := strconv.Atoi(v); err == nil && n > 0 { - size = n - } - } - return &LargeTableController{ - rows: largeTableSeed(size), - nextID: size + 1, - seedSize: size, - rng: rand.New(rand.NewSource(time.Now().UnixNano())), - } -} - -func (c *LargeTableController) snapshot() []LargeRow { - c.mu.Lock() - defer c.mu.Unlock() - return slices.Clone(c.rows) -} - -// applyView is pure: filters + sorts the snapshot per session settings -// and returns the displayed slice. No controller mutation. -func (c *LargeTableController) applyView(rows []LargeRow, filter, sortKey, sortDir string) []LargeRow { - if filter != "" { - f := strings.ToLower(filter) - filtered := rows[:0] - for _, r := range rows { - if strings.Contains(strings.ToLower(r.Name), f) || - strings.Contains(strings.ToLower(r.Email), f) { - filtered = append(filtered, r) - } - } - rows = filtered - } - if sortKey != "" { - slices.SortFunc(rows, func(a, b LargeRow) int { - switch sortKey { - case largeTableSortByName: - return strings.Compare(a.Name, b.Name) - case largeTableSortByEmail: - return strings.Compare(a.Email, b.Email) - case largeTableSortByStatus: - return strings.Compare(a.Status, b.Status) - case largeTableSortByScore: - return cmp.Compare(a.Score, b.Score) - } - return 0 - }) - if sortDir == largeTableSortDesc { - slices.Reverse(rows) - } - } - return rows -} - -func (c *LargeTableController) refreshView(state LargeTableState) LargeTableState { - snap := c.snapshot() - state.Total = len(snap) - state.Items = c.applyView(snap, state.Filter, state.SortKey, state.SortDir) - return state -} - -func (c *LargeTableController) Mount(state LargeTableState, ctx *livetemplate.Context) (LargeTableState, error) { - return c.refreshView(state), nil -} - -// Change handles the filter input. Auto-wired by the framework on inputs -// with name="filter" (300ms debounce). -func (c *LargeTableController) Change(state LargeTableState, ctx *livetemplate.Context) (LargeTableState, error) { - if ctx.Has("filter") { - state.Filter = ctx.GetString("filter") - } - return c.refreshView(state), nil -} - -// Sort toggles direction on the same column or sorts ascending on a new one. -// Wired to a button with name="sort" carrying the column key in `value`. -func (c *LargeTableController) Sort(state LargeTableState, ctx *livetemplate.Context) (LargeTableState, error) { - key := ctx.GetString("value") - if key == "" { - return state, nil - } - if state.SortKey == key && state.SortDir == largeTableSortAsc { - state.SortDir = largeTableSortDesc - } else { - state.SortKey = key - state.SortDir = largeTableSortAsc - } - return c.refreshView(state), nil -} - -// AppendN adds largeTableAppendBatch rows to the end of the table. -func (c *LargeTableController) AppendN(state LargeTableState, ctx *livetemplate.Context) (LargeTableState, error) { - c.mu.Lock() - for i := 0; i < largeTableAppendBatch; i++ { - id := c.nextID - c.nextID++ - c.rows = append(c.rows, LargeRow{ - ID: fmt.Sprintf("row-%05d", id), - Name: fmt.Sprintf("User %05d", id), - Email: fmt.Sprintf("user%05d@example.com", id), - Status: largeTableStatuses[id%len(largeTableStatuses)], - Score: (id * 37) % 1000, - }) - } - c.mu.Unlock() - return c.refreshView(state), nil -} - -// UpdateRandomRow increments Score on a random row. The streaming-range -// proposal's worst-case workload for whole-item updates: a single field -// change still emits one ["u"] op carrying every dynamic position. Closes -// Open Question 2 β€” the wire cost measured here decides whether a future -// targeted-field op is needed. -func (c *LargeTableController) UpdateRandomRow(state LargeTableState, ctx *livetemplate.Context) (LargeTableState, error) { - c.mu.Lock() - if len(c.rows) > 0 { - idx := c.rng.Intn(len(c.rows)) - c.rows[idx].Score = (c.rows[idx].Score + 1) % 1000 - } - c.mu.Unlock() - return c.refreshView(state), nil -} - -// Delete removes the row whose ID matches the clicked button's value. -func (c *LargeTableController) Delete(state LargeTableState, ctx *livetemplate.Context) (LargeTableState, error) { - id := ctx.GetString("value") - c.mu.Lock() - c.rows = slices.DeleteFunc(c.rows, func(r LargeRow) bool { return r.ID == id }) - c.mu.Unlock() - return c.refreshView(state), nil -} - -// Reset restores the seed dataset and clears filter/sort. -func (c *LargeTableController) Reset(state LargeTableState, ctx *livetemplate.Context) (LargeTableState, error) { - c.mu.Lock() - c.rows = largeTableSeed(c.seedSize) - c.nextID = c.seedSize + 1 - c.mu.Unlock() - state.Filter = "" - state.SortKey = "" - state.SortDir = "" - return c.refreshView(state), nil -} - -func largeTableHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/lists/large-table.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(newLargeTableController(), livetemplate.AsState(&LargeTableState{ - Title: "Large Table", - Category: "Lists & Data", - })) -} diff --git a/patterns/handlers_loading.go b/patterns/handlers_loading.go deleted file mode 100644 index cf55ddb..0000000 --- a/patterns/handlers_loading.go +++ /dev/null @@ -1,352 +0,0 @@ -package main - -import ( - "math/rand" - "net/http" - "slices" - "time" - - "github.com/livetemplate/livetemplate" -) - -// --- Pattern #14: Lazy Loading --- - -// LazyLoadController spawns a goroutine on OnConnect that pushes the lazily- -// loaded payload via session.TriggerAction after a simulated delay. If the -// client reconnects after the payload has already arrived, OnConnect is a -// no-op so the goroutine does not fire a second time. -type LazyLoadController struct{} - -// lazyLoadDelay is how long the simulated "slow API" takes before data arrives. -const lazyLoadDelay = 2 * time.Second - -func (c *LazyLoadController) Mount(state LazyLoadState, ctx *livetemplate.Context) (LazyLoadState, error) { - // Guard: Mount also fires on POST actions (e.g., Reload). Without this, - // the POST would reset Data/Loading and stomp on the action's own return. - if ctx.Action() == "" { - state.Loading = true - state.Data = "" - } - return state, nil -} - -func (c *LazyLoadController) OnConnect(state LazyLoadState, ctx *livetemplate.Context) (LazyLoadState, error) { - // Skip if the data has already arrived (e.g., reconnect after a network - // hiccup) β€” re-spawning the goroutine would emit a duplicate update. - if !state.Loading { - return state, nil - } - // Session is guaranteed non-nil by livetemplate v0.8.18+ (every connect - // path wires WithSession). The defensive check stays so a future - // framework regression surfaces as "no push happens" rather than a - // panic β€” but it should NOT be confused with the JS-disabled fallback. - // JS-disabled clients never reach OnConnect at all (no WebSocket = no - // OnConnect call); the JS-disabled spinner-forever case is created by - // Mount() returning Loading=true on the initial HTTP GET. The nil - // branch here is purely a defensive guard against framework bugs. - session := ctx.Session() - if session == nil { - return state, nil - } - // Reconnect-during-loading note: if the client disconnects and - // reconnects within the 2s window, OnConnect fires again and spawns - // a second goroutine while the first is still asleep. Both goroutines - // dispatch via groupID lookup (registry.GetByGroup), and groupID is - // stable across reconnects (cookie-bound), so when each goroutine - // wakes one of two things happens: - // (a) The reconnect hasn't completed yet β†’ GetByGroup returns no - // connections β†’ TriggerAction returns "no connected sessions" - // β†’ goroutine exits via the cancellation pattern below. - // (b) The reconnect has completed β†’ both goroutines successfully - // dispatch to the new connection. DataLoaded runs twice with - // slightly different timestamps; the second call overwrites - // Data. This is harmless β€” the user just sees the timestamp - // update once. Loading=false is idempotent. - // No explicit dedup guard is needed for this demo. Production code - // that absolutely requires single-flight semantics should track the - // in-flight request ID in state and check it inside DataLoaded. - go func() { - time.Sleep(lazyLoadDelay) - if err := session.TriggerAction("dataLoaded", map[string]any{ - "data": "Content loaded lazily at " + time.Now().Format("15:04:05"), - }); err != nil { - return // Session disconnected β€” stop cleanly. - } - }() - return state, nil -} - -func (c *LazyLoadController) DataLoaded(state LazyLoadState, ctx *livetemplate.Context) (LazyLoadState, error) { - state.Data = ctx.GetString("data") - state.Loading = false - return state, nil -} - -func (c *LazyLoadController) Reload(state LazyLoadState, ctx *livetemplate.Context) (LazyLoadState, error) { - // Re-entrancy guard, symmetric with ProgressBarController.Start and - // AsyncOpsController.Fetch. The template hides the Reload button while - // Loading=true so a click cannot re-trigger this during the 2s window, - // but a direct WebSocket message bypassing the rendered UI could β€” - // without this guard, two goroutines would both write state.Data and - // the second timestamp would overwrite the first. Harmless for a demo, - // but the asymmetry would be a trap for readers pattern-matching from - // this file. - if state.Loading { - return state, nil - } - // Check session BEFORE mutating state. With livetemplate v0.8.18+ this - // is always non-nil, but the early return ensures the UI does not - // transition into Loading=true with no goroutine to ever clear it - // β€” which would happen if the framework's session wiring regressed. - session := ctx.Session() - if session == nil { - return state, nil - } - state.Loading = true - state.Data = "" - go func() { - time.Sleep(lazyLoadDelay) - if err := session.TriggerAction("dataLoaded", map[string]any{ - "data": "Content reloaded at " + time.Now().Format("15:04:05"), - }); err != nil { - return // Session disconnected β€” stop cleanly. - } - }() - return state, nil -} - -func lazyLoadingHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/loading/lazy-loading.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(&LazyLoadController{}, livetemplate.AsState(&LazyLoadState{ - Title: "Lazy Loading", - Category: "Loading & Progress", - })) -} - -// --- Pattern #15: Progress Bar --- - -// ProgressBarController drives a bounded goroutine that ticks progress from -// 10% to 100% in 10% increments every 500ms. session.TriggerAction is -// retried for ~5 seconds per tick when the session group has zero -// connections, so brief mobile backgrounding (iOS app-switch under the -// client's 3s visibility-reconnect threshold) doesn't lose ticks. The -// retry budget is per-tick β€” a tick that never succeeds blocks for ~5s, -// so the goroutine's worst-case lifetime under a permanent disconnect is -// (progressTickRate + progressRetryWindow) Γ— ceil((100-Progress)/progressStep), -// bounded at ~55s for the full 10-tick run. The next Mount returns non-Running -// state (Running is intentionally not persisted) and the user sees a -// clean Start Job button. -// -// No Mount-driven revival: a second goroutine spawned by Mount while the -// retrying goroutine was still alive caused racing UpdateProgress writes -// (one goroutine sets Done=true, the trailing one overwrites Progress -// with a mid-flight value, producing impossible "Run Again at 70%" UI). -// Likewise no OnConnect: the framework's restorePersistedState already -// loads Progress/Done from the session-group store on every reconnect, -// so manual hydration would be redundant and would re-introduce the -// same race window. -// -// UpdateProgress also guards on state.Done as defense in depth. -type ProgressBarController struct{} - -// Retry attempt count derives from window/delay so the ~5s total stays -// consistent if either is tuned later. Both Duration operands are -// constants and Go allows int(typedConst), so the whole trio remains -// const β€” keeps the values immutable from test code. -const ( - progressStep = 10 - progressTickRate = 500 * time.Millisecond - progressRetryDelay = 100 * time.Millisecond - progressRetryWindow = 5 * time.Second - - progressRetryAttempts = int(progressRetryWindow / progressRetryDelay) -) - -func (c *ProgressBarController) Start(state ProgressBarState, ctx *livetemplate.Context) (ProgressBarState, error) { - if state.Running { - return state, nil - } - session := ctx.Session() - if session == nil { - return state, nil - } - state.Running = true - state.Done = false - state.Progress = 0 - c.spawnTicker(session) - return state, nil -} - -func (c *ProgressBarController) UpdateProgress(state ProgressBarState, ctx *livetemplate.Context) (ProgressBarState, error) { - // Guard against stale ticks from a goroutine that was overtaken by a - // faster one (e.g. multi-tab race). Without this, a trailing goroutine - // could overwrite Progress to a mid-flight value AFTER another goroutine - // already set Done=true, producing an impossible "Run Again at 70%" UI. - if state.Done { - return state, nil - } - state.Progress = ctx.GetInt("progress") - if state.Progress >= 100 { - state.Running = false - state.Done = true - ctx.SetFlash("success", "Job complete", livetemplate.FlashExpiry(flashSuccessExpiry)) - nudgeFlashExpiry(ctx, flashSuccessExpiry) - } - return state, nil -} - -func (c *ProgressBarController) Refresh(state ProgressBarState, ctx *livetemplate.Context) (ProgressBarState, error) { - return state, nil -} - -// spawnTicker drives state.Progress 10..100. tickWithRetry survives a -// ~5s window of dead WebSocket so brief mobile backgrounds don't end -// the run; if the connection comes back within that window the timer -// resumes seamlessly. -func (c *ProgressBarController) spawnTicker(session livetemplate.Session) { - go func() { - for i := progressStep; i <= 100; i += progressStep { - time.Sleep(progressTickRate) - if err := tickWithRetry(session, i); err != nil { - return - } - } - }() -} - -func tickWithRetry(session livetemplate.Session, progress int) error { - var lastErr error - for attempt := 0; attempt < progressRetryAttempts; attempt++ { - if attempt > 0 { - time.Sleep(progressRetryDelay) - } - err := session.TriggerAction("updateProgress", map[string]any{ - "progress": progress, - }) - if err == nil { - return nil - } - lastErr = err - } - return lastErr -} - -func progressBarHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/loading/progress-bar.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(&ProgressBarController{}, livetemplate.AsState(&ProgressBarState{ - Title: "Progress Bar", - Category: "Loading & Progress", - })) -} - -// --- Pattern #16: Async Operations --- - -// AsyncOpsController implements a loading/success/error state machine. The -// Fetch action transitions to "loading" synchronously, then a goroutine waits -// and pushes a "fetchResult" action with either a success payload or an error -// payload. Demonstrates the minimal state-machine shape you'd use for any -// async RPC (database query, HTTP API, job queue, etc.). -// -// Reconnect semantics β€” why no OnConnect (same reasoning as ProgressBarController): -// AsyncOpsState has no `lvt:"persist"` tags, so a reconnect mid-fetch produces -// fresh zero-value state (Status="") via cloneStateTyped, not a stuck -// Status="loading". The user always sees the Fetch Data button after a -// reconnect. The in-flight goroutine's eventual TriggerAction either lands on -// the new connection (showing a result the user didn't initiate β€” harmless, -// since this is a demo) or errors out cleanly when the goroutine's session -// is gone. Adding OnConnect to "recover" loading state would actively make -// this worse by trying to restore Status="loading" against a goroutine that -// the framework has already torn down. -type AsyncOpsController struct{} - -const asyncFetchDelay = 2 * time.Second - -func (c *AsyncOpsController) Fetch(state AsyncOpsState, ctx *livetemplate.Context) (AsyncOpsState, error) { - // Re-entrancy guard: block concurrent Fetch while one is already in - // flight. The button is template-disabled during loading, but a direct - // WebSocket message bypassing the rendered UI could otherwise spawn - // two parallel goroutines that both call TriggerAction("fetchResult"), - // producing two state transitions and two SetFlash calls on the same - // session. Mirrors the Running guard in ProgressBarController.Start. - if state.Status == "loading" { - return state, nil - } - // Check session BEFORE setting Status="loading". With livetemplate - // v0.8.18+ this is always non-nil, but if it ever became nil the - // previous ordering (mutate first, check second) would leave the - // button stuck showing "Fetching..." with no goroutine to clear it. - session := ctx.Session() - if session == nil { - return state, nil - } - state.Status = "loading" - state.Result = "" - state.Error = "" - go func() { - time.Sleep(asyncFetchDelay) - // Simulated ~33% failure rate. Non-deterministic between runs because - // Go 1.20+ auto-seeds top-level math/rand from a system source at - // program startup β€” no rand.Seed call is needed. Tests must assert - // {success OR error}, not a specific branch, since either may fire - // on any given run. - // - // Both branches use the same `if err := …; err != nil { return }` - // pattern as the other controllers for consistency, even though - // this is a single-shot goroutine where there's nothing else to - // cancel β€” readers learning the pattern from this example should - // see the idiomatic form everywhere. - if rand.Intn(3) == 0 { - if err := session.TriggerAction("fetchResult", map[string]any{ - "success": false, - "error": "Connection timed out", - }); err != nil { - return // Session disconnected β€” stop cleanly. - } - } else { - if err := session.TriggerAction("fetchResult", map[string]any{ - "success": true, - "result": "Data fetched successfully at " + time.Now().Format("15:04:05"), - }); err != nil { - return // Session disconnected β€” stop cleanly. - } - } - }() - return state, nil -} - -func (c *AsyncOpsController) FetchResult(state AsyncOpsState, ctx *livetemplate.Context) (AsyncOpsState, error) { - if ctx.GetBool("success") { - state.Status = "success" - state.Result = ctx.GetString("result") - state.Error = "" - ctx.SetFlash("success", "Fetch complete", livetemplate.FlashExpiry(flashSuccessExpiry)) - nudgeFlashExpiry(ctx, flashSuccessExpiry) - } else { - state.Status = "error" - state.Error = ctx.GetString("error") - state.Result = "" - ctx.SetFlash("error", "Fetch failed") - } - return state, nil -} - -func (c *AsyncOpsController) Refresh(state AsyncOpsState, ctx *livetemplate.Context) (AsyncOpsState, error) { - return state, nil -} - -func asyncOperationsHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/loading/async-operations.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(&AsyncOpsController{}, livetemplate.AsState(&AsyncOpsState{ - Title: "Async Operations", - Category: "Loading & Progress", - })) -} diff --git a/patterns/handlers_navigation.go b/patterns/handlers_navigation.go deleted file mode 100644 index 9296592..0000000 --- a/patterns/handlers_navigation.go +++ /dev/null @@ -1,207 +0,0 @@ -package main - -import ( - "fmt" - "net/http" - "slices" - "strconv" - "time" - - "github.com/go-playground/validator/v10" - "github.com/livetemplate/livetemplate" -) - -// ctx.ValidateForm doesn't surface a schema for forms inside ; -// use BindAndValidate (the dialog-patterns shape) instead. -var validateNav = validator.New() - -// --- Pattern #17: Modal Dialog --- - -// On invalid submit, field errors must render inside the still-open dialog. -type ModalDialogController struct{} - -type modalDialogInput struct { - Name string `json:"name" validate:"required,min=3"` - Email string `json:"email" validate:"required,email"` -} - -func (c *ModalDialogController) Save(state ModalDialogState, ctx *livetemplate.Context) (ModalDialogState, error) { - var in modalDialogInput - if err := ctx.BindAndValidate(&in, validateNav); err != nil { - return state, err - } - state.Name = in.Name - state.Email = in.Email - state.SavedAt = time.Now().Format("15:04:05") - ctx.SetFlash("success", "Profile saved", livetemplate.FlashExpiry(5*time.Second)) - return state, nil -} - -func modalDialogHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/navigation/modal-dialog.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(&ModalDialogController{}, livetemplate.AsState(&ModalDialogState{ - Title: "Modal Dialog", - Category: "Dialogs, Tabs & Navigation", - Name: "Ada Lovelace", - Email: "ada@analytical.engine", - })) -} - -// --- Pattern #18: Confirm Dialog --- - -// The Delete action reads the item id from the submit button's value attribute -// (the canonical Tier-1 row-action shape), not a hidden input. -type ConfirmDialogController struct{} - -const confirmDialogItemCount = 5 - -func (c *ConfirmDialogController) Mount(state ConfirmDialogState, ctx *livetemplate.Context) (ConfirmDialogState, error) { - if len(state.Items) == 0 && ctx.Action() == "" { - state.Items = getItemPage(1, confirmDialogItemCount) - } - return state, nil -} - -func (c *ConfirmDialogController) Delete(state ConfirmDialogState, ctx *livetemplate.Context) (ConfirmDialogState, error) { - // "value" is the framework key for the clicked submit button's value - // attribute (independent of the button's name). Same idiom as dialog-patterns. - // id is a server-rendered Item.ID echoed back via the form, NOT free-form - // user input β€” no escaping/allowlist check is needed. - id := ctx.GetString("value") - // Unknown ids are tolerated as a no-op β€” the next render reconciles any - // drift between client and server item lists without surfacing a flash. - state.Items = slices.DeleteFunc(state.Items, func(it Item) bool { return it.ID == id }) - return state, nil -} - -func confirmDialogHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/navigation/confirm-dialog.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(&ConfirmDialogController{}, livetemplate.AsState(&ConfirmDialogState{ - Title: "Confirm Dialog", - Category: "Dialogs, Tabs & Navigation", - })) -} - -// --- Pattern #19: Tabs (HATEOAS) --- - -// Mount-only: tab links use the in-band __navigate__ action, which re-runs -// Mount with ctx.Action()=="" so the same guard covers initial GET. -type TabsController struct{} - -var validTabs = map[string]bool{"overview": true, "settings": true, "activity": true} - -func (c *TabsController) Mount(state TabsState, ctx *livetemplate.Context) (TabsState, error) { - if ctx.Action() == "" { - t := ctx.GetString("tab") - switch { - case t != "" && validTabs[t]: - state.ActiveTab = t - case t != "": - // Explicit unknown tab β†’ reset to overview (matches template promise). - state.ActiveTab = "overview" - case !validTabs[state.ActiveTab]: - // First load (no param, empty state) β†’ default to overview. - state.ActiveTab = "overview" - } - } - // Invariant: by here ActiveTab is always in validTabs. Belt-and-suspenders - // in case a malformed message routes through Mount with ctx.Action() != "". - if !validTabs[state.ActiveTab] { - state.ActiveTab = "overview" - } - return state, nil -} - -func tabsHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/navigation/tabs.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(&TabsController{}, livetemplate.AsState(&TabsState{ - Title: "Tabs (HATEOAS)", - Category: "Dialogs, Tabs & Navigation", - })) -} - -// --- Pattern #20: SPA Navigation --- - -type SPANavController struct{} - -const spaNavMaxStep = 3 - -func (c *SPANavController) Mount(state SPANavState, ctx *livetemplate.Context) (SPANavState, error) { - if ctx.Action() == "" { - // Out-of-range or non-integer step β†’ fall through to the default below. - if s := ctx.GetString("step"); s != "" { - if n, err := strconv.Atoi(s); err == nil && n >= 1 && n <= spaNavMaxStep { - state.Step = n - } - } - if state.Step == 0 { - state.Step = 1 - } - } - return state, nil -} - -func spaNavigationHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/navigation/spa-navigation.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(&SPANavController{}, livetemplate.AsState(&SPANavState{ - Title: "SPA Navigation", - Category: "Dialogs, Tabs & Navigation", - })) -} - -// --- Pattern #21: Keyboard Shortcuts --- - -// Tier-2: lvt-on:window:keydown drives the panel; "/" opens, "Escape" closes -// (bound only while the panel is rendered). -type ShortcutsController struct{} - -const shortcutsLogMax = 5 - -func (c *ShortcutsController) Open(state ShortcutsState, ctx *livetemplate.Context) (ShortcutsState, error) { - if state.PanelOpen { - return state, nil - } - state.PanelOpen = true - state.Log = appendLog(state.Log, fmt.Sprintf("[%s] Opened panel", time.Now().Format("15:04:05"))) - return state, nil -} - -func (c *ShortcutsController) Close(state ShortcutsState, ctx *livetemplate.Context) (ShortcutsState, error) { - if !state.PanelOpen { - return state, nil - } - state.PanelOpen = false - state.Log = appendLog(state.Log, fmt.Sprintf("[%s] Closed panel", time.Now().Format("15:04:05"))) - return state, nil -} - -func appendLog(log []string, entry string) []string { - log = append(log, entry) - if len(log) > shortcutsLogMax { - log = log[len(log)-shortcutsLogMax:] - } - return log -} - -func keyboardShortcutsHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/navigation/keyboard-shortcuts.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(&ShortcutsController{}, livetemplate.AsState(&ShortcutsState{ - Title: "Keyboard Shortcuts", - Category: "Dialogs, Tabs & Navigation", - })) -} diff --git a/patterns/handlers_realtime.go b/patterns/handlers_realtime.go deleted file mode 100644 index 2f2d833..0000000 --- a/patterns/handlers_realtime.go +++ /dev/null @@ -1,348 +0,0 @@ -package main - -import ( - "net/http" - "slices" - "strings" - "sync" - "time" - - "github.com/livetemplate/livetemplate" -) - -// --- Pattern #26: Multi-User Sync --- - -type MultiUserSyncController struct { - mu sync.RWMutex - counter int -} - -// Mount runs on every initial render. Without it, a tab that opens -// AFTER other tabs have incremented would render Counter:0 and only -// converge on the next peer action's Sync. Same fix as PresenceController. -func (c *MultiUserSyncController) Mount(state MultiUserSyncState, ctx *livetemplate.Context) (MultiUserSyncState, error) { - c.mu.RLock() - state.Counter = c.counter - c.mu.RUnlock() - return state, nil -} - -// Sync is a reserved method name (livetemplate/mount.go:114). The framework -// auto-dispatches it to peer connections in the same session group after any -// action completes β€” Increment doesn't need to call BroadcastAction. The state -// arg is the peer's local state; we replace its Counter from the shared -// controller value so all tabs converge. -func (c *MultiUserSyncController) Sync(state MultiUserSyncState, ctx *livetemplate.Context) (MultiUserSyncState, error) { - c.mu.RLock() - state.Counter = c.counter - c.mu.RUnlock() - return state, nil -} - -func (c *MultiUserSyncController) Increment(state MultiUserSyncState, ctx *livetemplate.Context) (MultiUserSyncState, error) { - c.mu.Lock() - c.counter++ - state.Counter = c.counter - c.mu.Unlock() - return state, nil -} - -func multiUserSyncHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/realtime/multi-user-sync.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(&MultiUserSyncController{}, livetemplate.AsState(&MultiUserSyncState{ - Title: "Multi-User Sync", - Category: "Real-Time & Multi-User", - })) -} - -// --- Pattern #27: Broadcasting --- - -type BroadcastingController struct { - mu sync.RWMutex - nextID int - messages []BroadcastMessage -} - -// snapshotLocked returns a copy of c.messages. The Locked suffix signals -// that the caller MUST hold c.mu (read or write) β€” without that, slices.Clone -// reads c.messages concurrently with Send's append and races. -func (c *BroadcastingController) snapshotLocked() []BroadcastMessage { - return slices.Clone(c.messages) -} - -func (c *BroadcastingController) Mount(state BroadcastingState, ctx *livetemplate.Context) (BroadcastingState, error) { - c.mu.RLock() - state.Messages = c.snapshotLocked() - c.mu.RUnlock() - return state, nil -} - -func (c *BroadcastingController) Join(state BroadcastingState, ctx *livetemplate.Context) (BroadcastingState, error) { - name := strings.TrimSpace(ctx.GetString("username")) - if name == "" { - return state, nil - } - state.Username = name - return state, nil -} - -func (c *BroadcastingController) Send(state BroadcastingState, ctx *livetemplate.Context) (BroadcastingState, error) { - if state.Username == "" { - return state, nil - } - text := strings.TrimSpace(ctx.GetString("text")) - if text == "" { - return state, nil - } - c.mu.Lock() - c.nextID++ - // No cap on c.messages: deliberately omitted to keep the demo focused - // on the BroadcastAction mechanism. Production apps would ring-buffer, - // paginate, or persist to a store with TTL. - c.messages = append(c.messages, BroadcastMessage{ID: c.nextID, User: state.Username, Text: text}) - state.Messages = c.snapshotLocked() - c.mu.Unlock() - // BroadcastAction must come after the lock release β€” holding the - // connection registry mutex while queuing broadcasts can deadlock with - // peer dispatches that take the same mutex from the other side. Peers - // receive "NewMessage" and refresh their local copy. - ctx.BroadcastAction("NewMessage", nil) - return state, nil -} - -func (c *BroadcastingController) NewMessage(state BroadcastingState, ctx *livetemplate.Context) (BroadcastingState, error) { - c.mu.RLock() - state.Messages = c.snapshotLocked() - c.mu.RUnlock() - return state, nil -} - -func broadcastingHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/realtime/broadcasting.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(&BroadcastingController{}, livetemplate.AsState(&BroadcastingState{ - Title: "Broadcasting", - Category: "Real-Time & Multi-User", - })) -} - -// --- Pattern #28: Presence Tracking --- - -type PresenceController struct { - mu sync.RWMutex - onlineUsers map[string]bool -} - -func newPresenceController() *PresenceController { - return &PresenceController{onlineUsers: make(map[string]bool)} -} - -// Mount runs on every initial render. Without it a new visitor's -// state.OnlineCount would default to 0 even when other users are -// already in the shared map β€” they'd see "0 user(s) online" until -// the next Join/Leave broadcast updates them. -func (c *PresenceController) Mount(state PresenceState, ctx *livetemplate.Context) (PresenceState, error) { - c.mu.RLock() - state.OnlineCount = len(c.onlineUsers) - c.mu.RUnlock() - return state, nil -} - -func (c *PresenceController) Join(state PresenceState, ctx *livetemplate.Context) (PresenceState, error) { - name := strings.TrimSpace(ctx.GetString("username")) - if name == "" { - return state, nil - } - c.mu.Lock() - c.onlineUsers[name] = true - state.Username = name - state.Joined = true - state.OnlineCount = len(c.onlineUsers) - c.mu.Unlock() - ctx.BroadcastAction("PresenceChanged", nil) - return state, nil -} - -func (c *PresenceController) Leave(state PresenceState, ctx *livetemplate.Context) (PresenceState, error) { - if state.Username == "" { - return state, nil - } - c.mu.Lock() - delete(c.onlineUsers, state.Username) - state.Username = "" - state.Joined = false - state.OnlineCount = len(c.onlineUsers) - c.mu.Unlock() - ctx.BroadcastAction("PresenceChanged", nil) - return state, nil -} - -// PresenceChanged refreshes only the shared OnlineCount. Username and -// Joined are per-connection identity and must NOT be overwritten from a -// peer broadcast β€” every connection's own Join/Leave is the only thing -// that mutates those fields locally. -func (c *PresenceController) PresenceChanged(state PresenceState, ctx *livetemplate.Context) (PresenceState, error) { - c.mu.RLock() - state.OnlineCount = len(c.onlineUsers) - c.mu.RUnlock() - return state, nil -} - -func presenceHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/realtime/presence.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(newPresenceController(), livetemplate.AsState(&PresenceState{ - Title: "Presence Tracking", - Category: "Real-Time & Multi-User", - })) -} - -// --- Pattern #29: Reconnection Recovery --- - -type ReconnectionController struct{} - -func (c *ReconnectionController) Increment(state ReconnectionState, ctx *livetemplate.Context) (ReconnectionState, error) { - state.Counter++ - return state, nil -} - -func (c *ReconnectionController) SaveNotes(state ReconnectionState, ctx *livetemplate.Context) (ReconnectionState, error) { - // Notes is a free-form textarea β€” leading/trailing whitespace AND - // internal newlines are deliberate user content. Unlike Send/Join - // inputs (which use TrimSpace to reject all-whitespace submissions), - // SaveNotes preserves whatever the user typed verbatim. - state.Notes = ctx.GetString("notes") - return state, nil -} - -func reconnectionHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/realtime/reconnection.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(&ReconnectionController{}, livetemplate.AsState(&ReconnectionState{ - Title: "Reconnection Recovery", - Category: "Real-Time & Multi-User", - })) -} - -// --- Pattern #30: Live Preview --- - -type LivePreviewController struct{} - -// Change is auto-bound by the framework when the controller exposes it. -// Reads the input's current value via ctx.GetString and updates state.Preview. -// Does NOT write back to state.Input β€” patching the input element's value -// attribute mid-typing would reset the cursor position. (See -// examples/live-preview/main.go:26-29 for the same constraint.) An explicit -// Submit action commits state.Input on form submission. -func (c *LivePreviewController) Change(state LivePreviewState, ctx *livetemplate.Context) (LivePreviewState, error) { - if ctx.Has("input") { - state.Preview = "Hello, " + ctx.GetString("input") + "!" - } - return state, nil -} - -func (c *LivePreviewController) Submit(state LivePreviewState, ctx *livetemplate.Context) (LivePreviewState, error) { - state.Input = ctx.GetString("input") - state.Preview = "Saved: " + state.Input - return state, nil -} - -func livePreviewHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/realtime/live-preview.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(&LivePreviewController{}, livetemplate.AsState(&LivePreviewState{ - Title: "Live Preview", - Category: "Real-Time & Multi-User", - // Preview is intentionally empty initially β€” Change builds the - // "Hello, …!" value as the user types. Mirrors live-preview/main.go's - // initial state (preview("") β†’ empty until the first Change fires). - })) -} - -// --- Pattern #31: Server Push --- - -type ServerPushController struct{} - -const serverPushTickInterval = 1 * time.Second -const serverPushTickCount = 10 - -// StartTimer flips state.Running and spawns a 10Γ—1s ticker goroutine. -// -// Running is intentionally NOT lvt:"persist". If the connection drops -// mid-timer (browser refresh, network blip), the reconnected client -// will see Running=false in its initial render β€” the goroutine on the -// server keeps ticking and eventually fires TimerDone, so the client -// will pop directly to the "Last completed: Xs" view rather than the -// running view. Trade-off: a persisted Running flag could survive the -// reconnect, but a stale "Running=true" with no in-flight goroutine is -// a worse failure mode (the UI would be stuck forever waiting for ticks -// that aren't coming). -func (c *ServerPushController) StartTimer(state ServerPushState, ctx *livetemplate.Context) (ServerPushState, error) { - if state.Running { - return state, nil - } - // Check session BEFORE flipping Running. Framework guarantees a - // session for WebSocket connections, but if it ever is nil we'd - // render "Timer running" with no goroutine to ever clear it. - session := ctx.Session() - if session == nil { - return state, nil - } - state.Running = true - state.Elapsed = 0 - state.Total = serverPushTickCount - go func() { - ticker := time.NewTicker(serverPushTickInterval) - defer ticker.Stop() - for i := 0; i < serverPushTickCount; i++ { - <-ticker.C - // session.TriggerAction returns an error when the session group has - // no live connections (livetemplate/session_impl.go:91-159). Bail - // out cleanly so the goroutine exits when the user closes the tab. - if err := session.TriggerAction("tick", map[string]any{ - "elapsed": i + 1, - }); err != nil { - return - } - } - // timerDone fires after the loop completes. We discard the error - // here (unlike the per-tick error check above): if the connection - // is gone by now, the goroutine exits anyway β€” no recovery action - // is meaningful, and propagating would force the caller of the - // goroutine to handle a context that's already finished. - _ = session.TriggerAction("timerDone", nil) - }() - return state, nil -} - -func (c *ServerPushController) Tick(state ServerPushState, ctx *livetemplate.Context) (ServerPushState, error) { - state.Elapsed = ctx.GetInt("elapsed") - return state, nil -} - -func (c *ServerPushController) TimerDone(state ServerPushState, ctx *livetemplate.Context) (ServerPushState, error) { - state.Running = false - return state, nil -} - -func serverPushHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/realtime/server-push.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(&ServerPushController{}, livetemplate.AsState(&ServerPushState{ - Title: "Server Push", - Category: "Real-Time & Multi-User", - })) -} diff --git a/patterns/handlers_search.go b/patterns/handlers_search.go deleted file mode 100644 index 0eb295f..0000000 --- a/patterns/handlers_search.go +++ /dev/null @@ -1,81 +0,0 @@ -package main - -import ( - "net/http" - "slices" - - "github.com/livetemplate/livetemplate" -) - -// --- Pattern #12: Active Search --- - -type ActiveSearchController struct{} - -func (c *ActiveSearchController) Mount(state ActiveSearchState, ctx *livetemplate.Context) (ActiveSearchState, error) { - // Full directory visible on initial render so the "filter down" story is obvious. - state.Results = searchContacts(state.Query) - return state, nil -} - -func (c *ActiveSearchController) Change(state ActiveSearchState, ctx *livetemplate.Context) (ActiveSearchState, error) { - if ctx.Has("query") { - state.Query = ctx.GetString("query") - state.Results = searchContacts(state.Query) - } - return state, nil -} - -func activeSearchHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/search/active-search.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(&ActiveSearchController{}, livetemplate.AsState(&ActiveSearchState{ - Title: "Active Search", - Category: "Search & Filtering", - })) -} - -// --- Pattern #13: URL-Preserved Filters --- - -type URLFiltersController struct{} - -// validStatuses / validSorts allow Mount to reject unknown query param values -// without crashing: unknown values fall back to the previous/default state -// rather than producing a 404 or an error. Bookmarks with stale params still -// render usefully. -var ( - validStatuses = map[string]bool{"all": true, "active": true, "completed": true} - validSorts = map[string]bool{"name": true, "date": true} -) - -func (c *URLFiltersController) Mount(state URLFiltersState, ctx *livetemplate.Context) (URLFiltersState, error) { - // ctx.Action() == "" means this is a GET navigation (initial load or SPA - // link click), not a POST action. Only read URL query params on GET to - // avoid clobbering state from an in-flight action. - if ctx.Action() == "" { - if s := ctx.GetString("status"); s != "" && validStatuses[s] { - state.Status = s - } - if s := ctx.GetString("sort"); s != "" && validSorts[s] { - state.Sort = s - } - } - // Always recompute the item list so the initial render (with defaults) - // and any subsequent action both see fresh data. - state.Items = filterItems(state.Status, state.Sort) - return state, nil -} - -func urlFiltersHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/search/url-filters.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(&URLFiltersController{}, livetemplate.AsState(&URLFiltersState{ - Title: "URL-Preserved Filters", - Category: "Search & Filtering", - Status: "all", - Sort: "name", - })) -} diff --git a/patterns/large_table_bench_test.go b/patterns/large_table_bench_test.go deleted file mode 100644 index 9349421..0000000 --- a/patterns/large_table_bench_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package main - -import ( - "bytes" - "io" - "testing" - - "github.com/livetemplate/livetemplate" -) - -// BenchmarkLargeTable_UpdateRandomRow_WireBytes closes Open Question 2 in -// the streaming-range proposal: at N=10k, a single-field whole-item update -// must stay below the wire-cost ceiling that would justify a future -// targeted-field op (`["uf", key, fieldIdx, value]`). Project policy -// (per the user-approved Phase 6 plan): ceiling is 30% of full-tree size. -// -// The benchmark renders once (Execute β†’ first render with statics), then -// calls ExecuteUpdates twice β€” first to transition to stream mode, then -// repeatedly to measure the per-update wire cost reported via b.ReportMetric. -// -// Each iteration mutates ONE field on ONE row; the wire output should be -// dominated by a single ["u", key, dynamics] op carrying all five fields -// of that row. With no sort active, no reorder op is emitted. -func BenchmarkLargeTable_UpdateRandomRow_WireBytes(b *testing.B) { - cases := []struct { - name string - n int - }{ - {"N=200", 200}, - {"N=1000", 1000}, - {"N=10000", 10000}, - } - for _, tc := range cases { - b.Run(tc.name, func(b *testing.B) { - tmpl := livetemplate.Must(livetemplate.New("layout", - livetemplate.WithParseFiles( - "templates/layout.tmpl", - "templates/lists/large-table.tmpl", - ), - )) - - c := newLargeTableController() - c.rows = largeTableSeed(tc.n) - c.nextID = tc.n + 1 - c.seedSize = tc.n - - state := c.refreshView(LargeTableState{ - Title: "Large Table", - Category: "Lists & Data", - }) - - if err := tmpl.Execute(io.Discard, state); err != nil { - b.Fatalf("initial Execute: %v", err) - } - if err := tmpl.ExecuteUpdates(io.Discard, state); err != nil { - b.Fatalf("transition ExecuteUpdates: %v", err) - } - - var buf bytes.Buffer - b.ResetTimer() - b.ReportAllocs() - - var totalBytes int64 - for i := 0; i < b.N; i++ { - idx := i % tc.n - c.mu.Lock() - c.rows[idx].Score = (c.rows[idx].Score + 1) % 1000 - c.mu.Unlock() - state = c.refreshView(state) - - buf.Reset() - if err := tmpl.ExecuteUpdates(&buf, state); err != nil { - b.Fatalf("ExecuteUpdates iter %d: %v", i, err) - } - totalBytes += int64(buf.Len()) - } - b.ReportMetric(float64(totalBytes)/float64(b.N), "wire-B/op") - }) - } -} - -// BenchmarkLargeTable_FullTreeBaseline_WireBytes is the legacy comparison -// point β€” render the FIRST tree (which always carries statics + every -// dynamic) and report its byte size. This is the size every subsequent -// render would carry if streaming-range diff did NOT exist (or fell back -// to full-tree replacement). Used for OQ2's "% of full-tree" ratio. -func BenchmarkLargeTable_FullTreeBaseline_WireBytes(b *testing.B) { - cases := []struct { - name string - n int - }{ - {"N=200", 200}, - {"N=1000", 1000}, - {"N=10000", 10000}, - } - for _, tc := range cases { - b.Run(tc.name, func(b *testing.B) { - tmpl := livetemplate.Must(livetemplate.New("layout", - livetemplate.WithParseFiles( - "templates/layout.tmpl", - "templates/lists/large-table.tmpl", - ), - )) - - c := newLargeTableController() - c.rows = largeTableSeed(tc.n) - c.nextID = tc.n + 1 - c.seedSize = tc.n - - state := c.refreshView(LargeTableState{ - Title: "Large Table", - Category: "Lists & Data", - }) - - var buf bytes.Buffer - b.ResetTimer() - b.ReportAllocs() - - var totalBytes int64 - for i := 0; i < b.N; i++ { - buf.Reset() - if err := tmpl.Execute(&buf, state); err != nil { - b.Fatalf("Execute iter %d: %v", i, err) - } - totalBytes += int64(buf.Len()) - } - b.ReportMetric(float64(totalBytes)/float64(b.N), "wire-B/op") - }) - } -} diff --git a/patterns/main.go b/patterns/main.go deleted file mode 100644 index fbefa03..0000000 --- a/patterns/main.go +++ /dev/null @@ -1,167 +0,0 @@ -package main - -import ( - "context" - "log/slog" - "net/http" - "os" - "os/signal" - "slices" - "syscall" - "time" - - "github.com/livetemplate/livetemplate" - e2etest "github.com/livetemplate/lvt/testing" -) - -// IndexController serves the pattern catalog index page. -type IndexController struct{} - -// IndexState holds the categorized pattern list for the index page. -type IndexState struct { - Title string - Category string - Categories []PatternCategory -} - -func indexHandler(baseOpts []livetemplate.Option) http.Handler { - opts := append(slices.Clone(baseOpts), - livetemplate.WithParseFiles("templates/layout.tmpl", "templates/index.tmpl"), - ) - tmpl := livetemplate.Must(livetemplate.New("layout", opts...)) - return tmpl.Handle(&IndexController{}, livetemplate.AsState(&IndexState{ - Categories: allPatterns(), - })) -} - -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 - } - slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))) - - baseOpts := envConfig.ToOptions() - - mux := http.NewServeMux() - - // Index page - mux.Handle("/", indexHandler(baseOpts)) - - // Category: Forms & Editing (#1–#7) - mux.Handle("/patterns/forms/click-to-edit", clickToEditHandler(baseOpts)) - mux.Handle("/patterns/forms/edit-row", editRowHandler(baseOpts)) - mux.Handle("/patterns/forms/inline-validation", inlineValidationHandler(baseOpts)) - mux.Handle("/patterns/forms/bulk-update", bulkUpdateHandler(baseOpts)) - mux.Handle("/patterns/forms/reset-input", resetInputHandler(baseOpts)) - mux.Handle("/patterns/forms/file-upload", fileUploadHandler(baseOpts)) - mux.Handle("/patterns/forms/preserve-inputs", preserveInputsHandler(baseOpts)) - - // Category: Lists & Data - mux.Handle("/patterns/lists/delete-row", deleteRowHandler(baseOpts)) - mux.Handle("/patterns/lists/click-to-load", clickToLoadHandler(baseOpts)) - mux.Handle("/patterns/lists/infinite-scroll", infiniteScrollHandler(baseOpts)) - mux.Handle("/patterns/lists/value-select", valueSelectHandler(baseOpts)) - mux.Handle("/patterns/lists/sortable", sortableHandler(baseOpts)) - mux.Handle("/patterns/lists/large-table", largeTableHandler(baseOpts)) - - // Category: Search & Filtering (#12–#13) - mux.Handle("/patterns/search/active-search", activeSearchHandler(baseOpts)) - mux.Handle("/patterns/search/url-filters", urlFiltersHandler(baseOpts)) - - // Category: Loading & Progress (#14–#16) - mux.Handle("/patterns/loading/lazy-loading", lazyLoadingHandler(baseOpts)) - mux.Handle("/patterns/loading/progress-bar", progressBarHandler(baseOpts)) - mux.Handle("/patterns/loading/async-operations", asyncOperationsHandler(baseOpts)) - - // Category: Dialogs, Tabs & Navigation (#17–#21) - mux.Handle("/patterns/navigation/modal-dialog", modalDialogHandler(baseOpts)) - mux.Handle("/patterns/navigation/confirm-dialog", confirmDialogHandler(baseOpts)) - mux.Handle("/patterns/navigation/tabs", tabsHandler(baseOpts)) - mux.Handle("/patterns/navigation/spa-navigation", spaNavigationHandler(baseOpts)) - mux.Handle("/patterns/navigation/keyboard-shortcuts", keyboardShortcutsHandler(baseOpts)) - - // Category: Visual Feedback (#22–#25) - mux.Handle("/patterns/feedback/animations", animationsHandler(baseOpts)) - mux.Handle("/patterns/feedback/loading-states", loadingStatesHandler(baseOpts)) - mux.Handle("/patterns/feedback/highlight", highlightHandler(baseOpts)) - mux.Handle("/patterns/feedback/flash-messages", flashMessagesHandler(baseOpts)) - - // Category: Real-Time & Multi-User (#26–#31) - mux.Handle("/patterns/realtime/multi-user-sync", multiUserSyncHandler(baseOpts)) - mux.Handle("/patterns/realtime/broadcasting", broadcastingHandler(baseOpts)) - mux.Handle("/patterns/realtime/presence", presenceHandler(baseOpts)) - mux.Handle("/patterns/realtime/reconnection", reconnectionHandler(baseOpts)) - mux.Handle("/patterns/realtime/live-preview", livePreviewHandler(baseOpts)) - mux.Handle("/patterns/realtime/server-push", serverPushHandler(baseOpts)) - - // JSON catalog index β€” consumed by the LiveTemplate docs site to - // render its patterns landing page without scraping HTML. - mux.Handle("/api/index.json", apiIndexHandler()) - - // Client library and CSS (dev mode) - if localClient := os.Getenv("LVT_LOCAL_CLIENT"); localClient != "" { - mux.HandleFunc("/livetemplate-client.js", func(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, localClient) - }) - } else { - 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("Patterns 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 server...") - if err := server.Shutdown(ctx); err != nil { - slog.Error("Shutdown error", "error", err) - } - slog.Info("Shutdown complete") -} diff --git a/patterns/patterns_test.go b/patterns/patterns_test.go deleted file mode 100644 index e6d1e0a..0000000 --- a/patterns/patterns_test.go +++ /dev/null @@ -1,3913 +0,0 @@ -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) -} - -// setupTest starts the server and Docker Chrome, returning the chromedp context -// and the server port. Cleanup is handled via t.Cleanup. -func setupTest(t *testing.T) (context.Context, context.CancelFunc, int) { - t.Helper() - - 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, ".", serverPort) - t.Cleanup(func() { - if serverCmd != nil && serverCmd.Process != nil { - serverCmd.Process.Kill() - } - }) - - chromeCmd := e2etest.StartDockerChrome(t, debugPort) - _ = chromeCmd - t.Cleanup(func() { - e2etest.StopDockerChrome(t, debugPort) - }) - - chromeURL := fmt.Sprintf("http://localhost:%d", debugPort) - allocCtx, allocCancel := chromedp.NewRemoteAllocator(context.Background(), chromeURL) - t.Cleanup(allocCancel) - - ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(t.Logf)) - t.Cleanup(cancel) - - ctx, cancel = context.WithTimeout(ctx, 120*time.Second) - - return ctx, cancel, serverPort -} - -// uiStandardsJS is the JavaScript snippet for UI standards validation. -const uiStandardsJS = `(() => { - 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('; '); -})()` - -// runUIStandards validates CSP compliance and meta tags. -func runUIStandards(t *testing.T, ctx context.Context) { - t.Helper() - var violations string - err := chromedp.Run(ctx, - chromedp.Evaluate(uiStandardsJS, &violations), - ) - if err != nil { - t.Fatalf("UI standards check failed: %v", err) - } - if violations != "" { - t.Errorf("UI standard violations: %s", violations) - } -} - -// runUIStandardsWithPico validates CSP compliance, meta tags, AND Pico CSS -// conventions (input+button must be inside fieldset[role="group"]). -// Use this for pages with inline forms. For pages with vertical labeled forms, -// use runUIStandards (the fieldset[role="group"] rule doesn't apply to vertical forms). -func runUIStandardsWithPico(t *testing.T, ctx context.Context) { - t.Helper() - runUIStandards(t, ctx) - if err := chromedp.Run(ctx, e2etest.ValidatePicoCSS()); err != nil { - t.Errorf("Pico CSS check failed: %v", err) - } -} - -// runStandardSubtests runs the boilerplate `UI_Standards` + `Visual_Check` -// subtest pair. `pico=true` invokes the Pico-variant UI check. Patterns -// that need additional setup before the UI check (e.g. waiting for -// entry animations to finish) should inline the subtests instead. -func runStandardSubtests(t *testing.T, ctx context.Context, pico bool, screenshotDesc string) { - t.Helper() - t.Run("UI_Standards", func(t *testing.T) { - if pico { - runUIStandardsWithPico(t, ctx) - } else { - runUIStandards(t, ctx) - } - }) - t.Run("Visual_Check", func(t *testing.T) { - e2etest.ValidateScreenshotWithLLM(t, ctx, screenshotDesc) - }) -} - -// attachFileViaDataTransfer sets a File on the given file input using the -// DataTransfer API. chromedp.SetUploadFiles cannot be used with Docker -// Chrome because the container has no access to host filesystem paths. -func attachFileViaDataTransfer(inputSelector, filename, content, mimeType string) chromedp.Action { - script := fmt.Sprintf(` - (() => { - const file = new File([%q], %q, {type: %q}); - const input = document.querySelector(%q); - const dt = new DataTransfer(); - dt.items.add(file); - input.files = dt.files; - })() - `, content, filename, mimeType, inputSelector) - return chromedp.Evaluate(script, nil) -} - -// --- Index Page --- - -func TestIndexPage(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - 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(`h2`, 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, "LiveTemplate Patterns") { - t.Error("Page title not found") - } - if !strings.Contains(html, "Forms & Editing") { - t.Error("Forms & Editing category not found") - } - }) - - runStandardSubtests(t, ctx, true, "Pattern index page β€” heading, 7 category cards with pattern links and descriptions") - - t.Run("Pattern_Links", func(t *testing.T) { - var count int - err := chromedp.Run(ctx, - chromedp.Evaluate(`document.querySelectorAll('a[href^="/patterns/forms/"]').length`, &count), - ) - if err != nil { - t.Fatalf("Failed to count pattern links: %v", err) - } - if count != 7 { - t.Errorf("Expected 7 Forms pattern links, got %d", count) - } - }) -} - -// --- Pattern #1: Click To Edit --- - -func TestClickToEdit(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/forms/click-to-edit" - - t.Run("Initial_Load", func(t *testing.T) { - var html string - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`h3`, chromedp.ByQuery), - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - chromedp.OuterHTML(`article`, &html, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to load page: %v", err) - } - if !strings.Contains(html, "John") { - t.Error("Default first name 'John' not found") - } - if !strings.Contains(html, "john@example.com") { - t.Error("Default email not found") - } - // Should be in view mode β€” table should be present, form should not - if !strings.Contains(html, "") { - t.Error("View mode table not found") - } - }) - - runStandardSubtests(t, ctx, true, "Click To Edit β€” view mode with name/email displayed and Edit button") - - t.Run("Edit_Mode", func(t *testing.T) { - var html string - err := chromedp.Run(ctx, - chromedp.Click(`button[name="edit"]`, chromedp.ByQuery), - e2etest.WaitFor(`document.querySelector('input[name="firstName"]') !== null`, 5*time.Second), - chromedp.OuterHTML(`article`, &html, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to enter edit mode: %v", err) - } - if !strings.Contains(html, `name="firstName"`) { - t.Error("Edit form firstName input not found") - } - if !strings.Contains(html, `name="save"`) { - t.Error("Save button not found") - } - if !strings.Contains(html, `name="cancel"`) { - t.Error("Cancel button not found") - } - }) - - t.Run("Save", func(t *testing.T) { - var html string - err := chromedp.Run(ctx, - // Clear and fill fields - chromedp.Clear(`input[name="firstName"]`, chromedp.ByQuery), - chromedp.SendKeys(`input[name="firstName"]`, "Jane", chromedp.ByQuery), - chromedp.Clear(`input[name="lastName"]`, chromedp.ByQuery), - chromedp.SendKeys(`input[name="lastName"]`, "Smith", chromedp.ByQuery), - chromedp.Clear(`input[name="email"]`, chromedp.ByQuery), - chromedp.SendKeys(`input[name="email"]`, "jane@smith.org", chromedp.ByQuery), - chromedp.Click(`button[name="save"]`, chromedp.ByQuery), - // Wait for view mode to return - e2etest.WaitFor(`document.querySelector('article table') !== null`, 5*time.Second), - chromedp.OuterHTML(`article`, &html, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to save: %v", err) - } - if !strings.Contains(html, "Jane") { - t.Error("Updated first name 'Jane' not found") - } - if !strings.Contains(html, "Smith") { - t.Error("Updated last name 'Smith' not found") - } - if !strings.Contains(html, "jane@smith.org") { - t.Error("Updated email not found") - } - }) - - t.Run("Cancel", func(t *testing.T) { - var html string - err := chromedp.Run(ctx, - // Enter edit mode - chromedp.Click(`button[name="edit"]`, chromedp.ByQuery), - e2etest.WaitFor(`document.querySelector('input[name="firstName"]') !== null`, 5*time.Second), - // Cancel without saving - chromedp.Click(`button[name="cancel"]`, chromedp.ByQuery), - e2etest.WaitFor(`document.querySelector('article table') !== null`, 5*time.Second), - chromedp.OuterHTML(`article`, &html, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to cancel: %v", err) - } - // Should still have the saved values from previous test - if !strings.Contains(html, "Jane") { - t.Error("First name should still be 'Jane' after cancel") - } - }) -} - -// --- Pattern #2: Edit Row --- - -func TestEditRow(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/forms/edit-row" - - t.Run("Initial_Load", func(t *testing.T) { - var html string - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`table`, chromedp.ByQuery), - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - chromedp.OuterHTML(`tbody`, &html, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to load page: %v", err) - } - if !strings.Contains(html, "Joe Smith") { - t.Error("Contact 'Joe Smith' not found") - } - if !strings.Contains(html, "Kim Yee") { - t.Error("Contact 'Kim Yee' not found") - } - }) - - runStandardSubtests(t, ctx, true, "Edit Row β€” table with 4 contacts, each with name/email and Edit button") - - t.Run("Edit_Row", func(t *testing.T) { - // Click Edit on the first row - err := chromedp.Run(ctx, - chromedp.Click(`tr[data-key="1"] button[name="edit"]`, chromedp.ByQuery), - e2etest.WaitFor(`document.querySelector('tr[data-key="1"] input[name="name"]') !== null`, 5*time.Second), - ) - if err != nil { - t.Fatalf("Failed to enter edit mode for row 1: %v", err) - } - - // Verify the edit form has the correct values - var nameVal, emailVal string - err = chromedp.Run(ctx, - chromedp.Value(`tr[data-key="1"] input[name="name"]`, &nameVal, chromedp.ByQuery), - chromedp.Value(`tr[data-key="1"] input[name="email"]`, &emailVal, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to read input values: %v", err) - } - if nameVal != "Joe Smith" { - t.Errorf("Expected name 'Joe Smith', got %q", nameVal) - } - if emailVal != "joe@smith.org" { - t.Errorf("Expected email 'joe@smith.org', got %q", emailVal) - } - }) - - t.Run("Save_Row", func(t *testing.T) { - var html string - err := chromedp.Run(ctx, - chromedp.Clear(`tr[data-key="1"] input[name="name"]`, chromedp.ByQuery), - chromedp.SendKeys(`tr[data-key="1"] input[name="name"]`, "Joseph Smith", chromedp.ByQuery), - chromedp.Click(`tr[data-key="1"] button[name="save"]`, chromedp.ByQuery), - e2etest.WaitForText(`tr[data-key="1"]`, "Joseph Smith", 5*time.Second), - chromedp.OuterHTML(`tbody`, &html, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to save row: %v", err) - } - if !strings.Contains(html, "Joseph Smith") { - t.Error("Updated name 'Joseph Smith' not found") - } - // Verify other rows are unaffected - if !strings.Contains(html, "Angie MacDowell") { - t.Error("Other contact 'Angie MacDowell' should still be present") - } - if !strings.Contains(html, "Kim Yee") { - t.Error("Other contact 'Kim Yee' should still be present") - } - }) - - t.Run("Cancel_Edit", func(t *testing.T) { - var html string - err := chromedp.Run(ctx, - // Edit row 2 - chromedp.Click(`tr[data-key="2"] button[name="edit"]`, chromedp.ByQuery), - e2etest.WaitFor(`document.querySelector('tr[data-key="2"] input[name="name"]') !== null`, 5*time.Second), - // Cancel - chromedp.Click(`tr[data-key="2"] button[name="cancel"]`, chromedp.ByQuery), - e2etest.WaitFor(`document.querySelector('tr[data-key="2"] input[name="name"]') === null`, 5*time.Second), - chromedp.OuterHTML(`tbody`, &html, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to cancel edit: %v", err) - } - if !strings.Contains(html, "Angie MacDowell") { - t.Error("Contact 'Angie MacDowell' should remain unchanged after cancel") - } - }) -} - -// --- Pattern #3: Inline Validation --- - -func TestInlineValidation(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/forms/inline-validation" - - t.Run("Initial_Load", func(t *testing.T) { - var html string - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`h3`, chromedp.ByQuery), - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - chromedp.OuterHTML(`article`, &html, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to load page: %v", err) - } - if !strings.Contains(html, "Inline Validation") { - t.Error("Page heading not found") - } - }) - - runStandardSubtests(t, ctx, false, "Inline Validation β€” email and username inputs with submit button, no errors shown yet") - - t.Run("Valid_Submit", func(t *testing.T) { - var html string - err := chromedp.Run(ctx, - chromedp.SendKeys(`input[name="email"]`, "test@example.com", chromedp.ByQuery), - chromedp.SendKeys(`input[name="username"]`, "testuser", chromedp.ByQuery), - chromedp.Click(`button[type="submit"]`, chromedp.ByQuery), - e2etest.WaitForText(`article`, "Saved successfully", 5*time.Second), - chromedp.OuterHTML(`article`, &html, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to submit valid form: %v", err) - } - if !strings.Contains(html, "Saved successfully") { - t.Error("Success message not found") - } - }) -} - -// --- Pattern #4: Bulk Update --- - -func TestBulkUpdate(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/forms/bulk-update" - - t.Run("Initial_Load", func(t *testing.T) { - var html string - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`table`, chromedp.ByQuery), - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - chromedp.OuterHTML(`tbody`, &html, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to load page: %v", err) - } - if !strings.Contains(html, "Joe Smith") { - t.Error("User 'Joe Smith' not found") - } - // Verify initial checkbox states (users 1,2 active; 3,4 inactive) - var checked1, checked3 bool - err = chromedp.Run(ctx, - chromedp.Evaluate(`document.querySelector('input[name="active-1"]').checked`, &checked1), - chromedp.Evaluate(`document.querySelector('input[name="active-3"]').checked`, &checked3), - ) - if err != nil { - t.Fatalf("Failed to check checkbox states: %v", err) - } - if !checked1 { - t.Error("User 1 should be active initially") - } - if checked3 { - t.Error("User 3 should be inactive initially") - } - }) - - runStandardSubtests(t, ctx, true, "Bulk Update β€” table with 4 users, checkboxes for active status, Update button") - - t.Run("Toggle_And_Update", func(t *testing.T) { - err := chromedp.Run(ctx, - // Uncheck user 1, check user 3 - chromedp.Click(`input[name="active-1"]`, chromedp.ByQuery), - chromedp.Click(`input[name="active-3"]`, chromedp.ByQuery), - // Click Update - chromedp.Click(`button[name="bulkUpdate"]`, chromedp.ByQuery), - // Wait for flash message (FlashTag renders as ) - e2etest.WaitForText(`output[data-flash]`, "Updated", 5*time.Second), - ) - if err != nil { - t.Fatalf("Failed to toggle and update: %v", err) - } - - // Verify new checkbox states - var checked1, checked2, checked3, checked4 bool - err = chromedp.Run(ctx, - chromedp.Evaluate(`document.querySelector('input[name="active-1"]').checked`, &checked1), - chromedp.Evaluate(`document.querySelector('input[name="active-2"]').checked`, &checked2), - chromedp.Evaluate(`document.querySelector('input[name="active-3"]').checked`, &checked3), - chromedp.Evaluate(`document.querySelector('input[name="active-4"]').checked`, &checked4), - ) - if err != nil { - t.Fatalf("Failed to verify checkbox states: %v", err) - } - if checked1 { - t.Error("User 1 should now be inactive") - } - if !checked2 { - t.Error("User 2 should still be active") - } - if !checked3 { - t.Error("User 3 should now be active") - } - if checked4 { - t.Error("User 4 should still be inactive") - } - }) - - t.Run("Submit_With_No_Changes", func(t *testing.T) { - // Clicking Update without toggling anything should report - // "No changes" instead of a spurious "Updated N user(s)" count. - err := chromedp.Run(ctx, - chromedp.Click(`button[name="bulkUpdate"]`, chromedp.ByQuery), - e2etest.WaitForText(`output[data-flash]`, "No changes", 5*time.Second), - ) - if err != nil { - t.Fatalf("Expected 'No changes' flash, got: %v", err) - } - }) -} - -// --- Pattern #5: Reset User Input --- - -func TestResetInput(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/forms/reset-input" - - t.Run("Initial_Load", func(t *testing.T) { - var html string - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`h3`, chromedp.ByQuery), - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - chromedp.OuterHTML(`article`, &html, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to load page: %v", err) - } - if !strings.Contains(html, "Reset User Input") { - t.Error("Page heading not found") - } - }) - - runStandardSubtests(t, ctx, true, "Reset User Input β€” message input with Send button, info text about auto-clear") - - t.Run("Submit_Message", func(t *testing.T) { - var html string - err := chromedp.Run(ctx, - chromedp.SendKeys(`input[name="message"]`, "Hello World", chromedp.ByQuery), - chromedp.Click(`button[type="submit"]`, chromedp.ByQuery), - e2etest.WaitForText(`article`, "Hello World", 5*time.Second), - chromedp.OuterHTML(`article`, &html, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to submit message: %v", err) - } - if !strings.Contains(html, "Hello World") { - t.Error("Submitted message not found") - } - }) - - t.Run("Form_Auto_Reset", func(t *testing.T) { - // After submission, the input should be cleared - var inputVal string - err := chromedp.Run(ctx, - chromedp.Value(`input[name="message"]`, &inputVal, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to read input value: %v", err) - } - if inputVal != "" { - t.Errorf("Input should be empty after submit, got %q", inputVal) - } - }) - - t.Run("Multiple_Messages", func(t *testing.T) { - var html string - err := chromedp.Run(ctx, - chromedp.SendKeys(`input[name="message"]`, "Second message", chromedp.ByQuery), - chromedp.Click(`button[type="submit"]`, chromedp.ByQuery), - e2etest.WaitForText(`article`, "Second message", 5*time.Second), - chromedp.OuterHTML(`article`, &html, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to submit second message: %v", err) - } - // Both messages should be present - if !strings.Contains(html, "Hello World") { - t.Error("First message 'Hello World' should still be present") - } - if !strings.Contains(html, "Second message") { - t.Error("Second message not found") - } - }) -} - -// --- Pattern #6: File Upload --- - -func TestFileUpload(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/forms/file-upload" - - t.Run("Initial_Load", func(t *testing.T) { - var html string - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`h3`, chromedp.ByQuery), - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - chromedp.OuterHTML(`article`, &html, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to load page: %v", err) - } - if !strings.Contains(html, "File Upload") { - t.Error("Page heading not found") - } - if !strings.Contains(html, "Tier 1") { - t.Error("Tier 1 section not found") - } - if !strings.Contains(html, "Tier 2") { - t.Error("Tier 2 section not found") - } - }) - - runStandardSubtests(t, ctx, true, "File Upload β€” two sections: Tier 1 standard HTML upload and Tier 2 chunked upload, each with file input and Upload button") - - t.Run("Submit_Without_File", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.Click(`button[name="upload"]`, chromedp.ByQuery), - e2etest.WaitForText(`article`, "No file selected", 5*time.Second), - ) - if err != nil { - t.Fatalf("No-file error flash not shown: %v", err) - } - }) - - t.Run("Tier1_Upload_With_File", func(t *testing.T) { - // Upload a file via Tier 1 (standard multipart form). - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`input[name="document"]`, chromedp.ByQuery), - attachFileViaDataTransfer(`input[name="document"]`, "hello.txt", "hello world", "text/plain"), - chromedp.Click(`button[name="upload"]`, chromedp.ByQuery), - e2etest.WaitForText(`output[data-flash]`, "Uploaded: hello.txt", 10*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("Tier 1 upload failed: %v", err) - } - }) - - t.Run("Form_Structure", func(t *testing.T) { - // Verify both Tier 1 and Tier 2 upload forms are present - var enctype string - var hasFileInput, hasLvtUpload bool - err := chromedp.Run(ctx, - chromedp.AttributeValue(`form[enctype]`, "enctype", &enctype, nil, chromedp.ByQuery), - chromedp.Evaluate(`document.querySelector('input[name="document"][type="file"]') !== null`, &hasFileInput), - chromedp.Evaluate(`document.querySelector('input[lvt-upload="chunked-doc"]') !== null`, &hasLvtUpload), - ) - if err != nil { - t.Fatalf("Failed to verify form structure: %v", err) - } - if enctype != "multipart/form-data" { - t.Errorf("Expected enctype='multipart/form-data', got %q", enctype) - } - if !hasFileInput { - t.Error("Tier 1 file input not found") - } - if !hasLvtUpload { - t.Error("Tier 2 lvt-upload input not found") - } - }) -} - -// --- Pattern #7: Preserving File Inputs --- - -func TestPreserveInputs(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/forms/preserve-inputs" - - t.Run("Initial_Load", func(t *testing.T) { - var html string - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`h3`, chromedp.ByQuery), - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - chromedp.OuterHTML(`article`, &html, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to load page: %v", err) - } - if !strings.Contains(html, "Preserving Form Inputs") { - t.Error("Page heading not found") - } - if !strings.Contains(html, `lvt-form:preserve`) { - t.Error("lvt-form:preserve attribute not found") - } - }) - - runStandardSubtests(t, ctx, false, "Preserving Form Inputs β€” name input, description textarea, file attachment input, submit button") - - t.Run("Submit_Shows_Flash", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.SendKeys(`input[name="name"]`, "Test Name", chromedp.ByQuery), - chromedp.SendKeys(`textarea[name="description"]`, "Test Description", chromedp.ByQuery), - chromedp.Click(`button[type="submit"]`, chromedp.ByQuery), - e2etest.WaitForText(`article`, "Saved: Test Name", 5*time.Second), - ) - if err != nil { - t.Fatalf("Failed to submit or flash not shown: %v", err) - } - }) - - t.Run("Form_Values_Preserved_After_Submit", func(t *testing.T) { - // After successful submit with lvt-form:preserve, form values - // should NOT be cleared (unlike normal forms which auto-reset). - var nameVal, descVal string - err := chromedp.Run(ctx, - chromedp.Value(`input[name="name"]`, &nameVal, chromedp.ByQuery), - chromedp.Value(`textarea[name="description"]`, &descVal, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to read form values: %v", err) - } - if nameVal != "Test Name" { - t.Errorf("Name should be preserved after submit, got %q", nameVal) - } - if descVal != "Test Description" { - t.Errorf("Description should be preserved after submit, got %q", descVal) - } - }) - - t.Run("Values_Survive_Rerender", func(t *testing.T) { - // Submit again β€” triggers a re-render. Form values should survive - // because lvt-form:preserve prevents the client from overwriting - // input values during DOM patching. - err := chromedp.Run(ctx, - chromedp.Click(`button[type="submit"]`, chromedp.ByQuery), - e2etest.WaitForText(`article`, "Saved: Test Name", 5*time.Second), - ) - if err != nil { - t.Fatalf("Second submit failed: %v", err) - } - - var nameVal string - err = chromedp.Run(ctx, - chromedp.Value(`input[name="name"]`, &nameVal, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to read name after re-render: %v", err) - } - if nameVal != "Test Name" { - t.Errorf("Name should survive re-render with lvt-form:preserve, got %q", nameVal) - } - }) - - t.Run("Submit_With_File_Attached", func(t *testing.T) { - // Regression: text fields must reach the server even when the - // HTTP multipart path is taken (file attached). - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`input[name="name"]`, chromedp.ByQuery), - chromedp.SendKeys(`input[name="name"]`, "WithFile Name", chromedp.ByQuery), - chromedp.SendKeys(`textarea[name="description"]`, "With File Description", chromedp.ByQuery), - attachFileViaDataTransfer(`input[name="attachment"]`, "test.txt", "test content", "text/plain"), - chromedp.Click(`button[type="submit"]`, chromedp.ByQuery), - e2etest.WaitForText(`output[data-flash]`, "Saved: WithFile Name", 10*time.Second), - ) - if err != nil { - t.Fatalf("Submit with file attached failed: %v", err) - } - }) -} - -// --- Pattern #8: Delete Row --- - -func TestDeleteRow(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/lists/delete-row" - - t.Run("Initial_Load", func(t *testing.T) { - var html string - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`table`, chromedp.ByQuery), - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - e2etest.WaitForCount(`tbody tr[data-key]`, 5, 5*time.Second), - chromedp.OuterHTML(`tbody`, &html, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to load page: %v", err) - } - for i := 1; i <= 5; i++ { - if !strings.Contains(html, fmt.Sprintf(`data-key="%d"`, i)) { - t.Errorf("Row with data-key=%q not found", fmt.Sprintf("%d", i)) - } - } - }) - - t.Run("UI_Standards", func(t *testing.T) { - // Wait for lvt-fx:animate entry animations to finish before the - // inline-style check β€” animationend clears the style attribute. - err := chromedp.Run(ctx, - e2etest.WaitFor(`Array.from(document.querySelectorAll('[data-key]')).every(el => !el.hasAttribute('style'))`, 3*time.Second), - ) - if err != nil { - t.Fatalf("Animations did not complete: %v", err) - } - runUIStandards(t, ctx) - }) - - t.Run("Visual_Check", func(t *testing.T) { - e2etest.ValidateScreenshotWithLLM(t, ctx, "Delete Row β€” table with 5 items showing ID, Name, Email columns and a Delete button on each row") - }) - - t.Run("Delete_First_Row", func(t *testing.T) { - var html string - err := chromedp.Run(ctx, - chromedp.Click(`tr[data-key="1"] button[name="delete"]`, chromedp.ByQuery), - e2etest.WaitForCount(`tbody tr[data-key]`, 4, 5*time.Second), - chromedp.OuterHTML(`tbody`, &html, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to delete first row: %v", err) - } - if strings.Contains(html, `data-key="1"`) { - t.Error("Row 1 still present after delete") - } - if !strings.Contains(html, `data-key="2"`) { - t.Error("Row 2 should still be present") - } - }) - - t.Run("Delete_All_Remaining_Rows", func(t *testing.T) { - // Delete rows 2, 3, 4, 5 one at a time, asserting the count after each. - for _, row := range []struct { - id string - expectedAfter int - }{ - {"2", 3}, - {"3", 2}, - {"4", 1}, - {"5", 0}, - } { - err := chromedp.Run(ctx, - chromedp.Click(fmt.Sprintf(`tr[data-key="%s"] button[name="delete"]`, row.id), chromedp.ByQuery), - e2etest.WaitForCount(`tbody tr[data-key]`, row.expectedAfter, 5*time.Second), - ) - if err != nil { - t.Fatalf("Failed to delete row %s: %v", row.id, err) - } - } - // Assert empty state message appears and Restore button is present - err := chromedp.Run(ctx, - e2etest.WaitForText(`article`, "All items deleted", 5*time.Second), - chromedp.WaitVisible(`button[name="restore"]`, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Empty state or restore button not shown: %v", err) - } - }) - - t.Run("State_Persists_Across_Reload", func(t *testing.T) { - // Reload the page β€” the shared in-memory DB should still be empty - // from the previous Delete_All_Remaining_Rows subtest, proving that - // state persists across reloads without needing lvt:"persist" tags. - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - e2etest.WaitForText(`article`, "All items deleted", 5*time.Second), - chromedp.WaitVisible(`button[name="restore"]`, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Empty state did not persist across reload: %v", err) - } - }) - - t.Run("Restore_Refills_Items", func(t *testing.T) { - // Click Restore to refill the DB. All 5 items should reappear. - err := chromedp.Run(ctx, - chromedp.Click(`button[name="restore"]`, chromedp.ByQuery), - e2etest.WaitForCount(`tbody tr[data-key]`, 5, 5*time.Second), - ) - if err != nil { - t.Fatalf("Restore did not refill items: %v", err) - } - }) -} - -// --- Pattern #9: Click To Load --- - -func TestClickToLoad(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/lists/click-to-load" - - t.Run("Initial_Load", func(t *testing.T) { - var html string - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`table`, chromedp.ByQuery), - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - e2etest.WaitForCount(`tbody tr[data-key]`, 10, 5*time.Second), - chromedp.OuterHTML(`article`, &html, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Failed to load page: %v", err) - } - if !strings.Contains(html, `name="loadMore"`) { - t.Error("Load More button not found") - } - if !strings.Contains(html, "Item 10") { - t.Error("First page's last item (Item 10) not found") - } - if strings.Contains(html, "Item 11") { - t.Error("Second page item (Item 11) should not be present yet") - } - }) - - runStandardSubtests(t, ctx, false, "Click To Load β€” table with 10 rows (ID, Name, Email) and a Load More button below") - - t.Run("Load_Second_Page", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Click(`button[name="loadMore"]`, chromedp.ByQuery), - e2etest.WaitForCount(`tbody tr[data-key]`, 20, 5*time.Second), - ) - if err != nil { - t.Fatalf("Failed to load second page: %v", err) - } - var html string - err = chromedp.Run(ctx, chromedp.OuterHTML(`tbody`, &html, chromedp.ByQuery)) - if err != nil { - t.Fatalf("Failed to read tbody: %v", err) - } - if !strings.Contains(html, "Item 11") { - t.Error("Second page item (Item 11) not found after load") - } - if !strings.Contains(html, "Item 20") { - t.Error("Second page's last item (Item 20) not found after load") - } - }) - - t.Run("Load_Third_Page_And_Hide_Button", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Click(`button[name="loadMore"]`, chromedp.ByQuery), - e2etest.WaitForCount(`tbody tr[data-key]`, 25, 5*time.Second), - // Wait for the button to disappear (HasMore flips false when the - // final page returns fewer than listPageSize items). - e2etest.WaitFor(`document.querySelector('button[name="loadMore"]') === null`, 5*time.Second), - e2etest.WaitForText(`article`, "End of list", 3*time.Second), - ) - if err != nil { - t.Fatalf("Failed to load third page: %v", err) - } - }) -} - -// --- Pattern #11: Value Select --- - -// selectValueAndDispatchChange sets a dropdowns in headless Chrome. -func selectValueAndDispatchChange(selector, value string) chromedp.Action { - script := fmt.Sprintf(`(() => { - const el = document.querySelector(%q); - if (!el) return 'missing:' + %q; - el.value = %q; - el.dispatchEvent(new Event('change', { bubbles: true })); - return 'ok'; - })()`, selector, selector, value) - return chromedp.Evaluate(script, nil) -} - -func TestValueSelect(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/lists/value-select" - - t.Run("Initial_Load", func(t *testing.T) { - var makeOptionCount int - var modelDisabled bool - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`select[name="make"]`, chromedp.ByQuery), - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - chromedp.Evaluate(`document.querySelectorAll('select[name="make"] option').length`, &makeOptionCount), - chromedp.Evaluate(`document.querySelector('select[name="model"]').disabled`, &modelDisabled), - ) - if err != nil { - t.Fatalf("Failed to load page: %v", err) - } - // 1 placeholder + 3 makes (Audi, BMW, Toyota) - if makeOptionCount != 4 { - t.Errorf("Expected 4 make options, got %d", makeOptionCount) - } - if !modelDisabled { - t.Error("Model select should be disabled when no make is selected") - } - }) - - runStandardSubtests(t, ctx, false, "Value Select β€” Make dropdown with 3 car makes and Model dropdown disabled until a make is selected") - - t.Run("Select_Make_Auto_Selects_First_Model", func(t *testing.T) { - // Selecting a Make auto-selects the first Model for immediate visual - // feedback β€” the Model dropdown's value updates and the "Selected:" - // line appears without needing a second user click. - err := chromedp.Run(ctx, - selectValueAndDispatchChange(`select[name="make"]`, "Audi"), - // Wait for Model options to be populated (4 models + placeholder = 5). - e2etest.WaitFor(`document.querySelectorAll('select[name="model"] option').length === 5`, 5*time.Second), - // Wait for the auto-selected "Audi A3" line to appear. - e2etest.WaitForText(`article`, "Audi A3", 5*time.Second), - ) - if err != nil { - t.Fatalf("Failed to select make or auto-select model: %v", err) - } - var html string - err = chromedp.Run(ctx, chromedp.OuterHTML(`select[name="model"]`, &html, chromedp.ByQuery)) - if err != nil { - t.Fatalf("Failed to read model select: %v", err) - } - for _, model := range []string{"A3", "A4", "Q5", "R8"} { - if !strings.Contains(html, model) { - t.Errorf("Expected Audi model %q in select, got:\n%s", model, html) - } - } - }) - - t.Run("Select_Model_Updates_Selection", func(t *testing.T) { - err := chromedp.Run(ctx, - selectValueAndDispatchChange(`select[name="model"]`, "A4"), - e2etest.WaitForText(`article`, "Audi A4", 5*time.Second), - ) - if err != nil { - t.Fatalf("Failed to select model: %v", err) - } - }) - - t.Run("Change_Make_Auto_Selects_New_First_Model", func(t *testing.T) { - // Switching Make auto-selects the new Make's first Model β€” so the - // previous "Audi A4" line becomes "BMW 3 Series" without the user - // needing to touch the Model dropdown. - err := chromedp.Run(ctx, - selectValueAndDispatchChange(`select[name="make"]`, "BMW"), - e2etest.WaitFor(`(() => { - const opts = document.querySelectorAll('select[name="model"] option'); - if (opts.length !== 5) return false; - const texts = Array.from(opts).map(o => o.textContent); - return texts.includes('3 Series') && !texts.includes('A4'); - })()`, 5*time.Second), - e2etest.WaitForText(`article`, "BMW 3 Series", 5*time.Second), - ) - if err != nil { - t.Fatalf("Failed to switch make or auto-select new model: %v", err) - } - // The previous "Audi A4" line should be gone. - var html string - err = chromedp.Run(ctx, chromedp.OuterHTML(`article`, &html, chromedp.ByQuery)) - if err != nil { - t.Fatalf("Failed to read article: %v", err) - } - if strings.Contains(html, "Audi A4") { - t.Error("Previous selection 'Audi A4' should be cleared after make change") - } - }) -} - -// --- Sortable List --- - -func TestSortable(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/lists/sortable" - - // CDP Input.dispatchMouseEvent is unreliable for HTML5 DnD in headless - // Docker Chrome, so we dispatch real DragEvent objects with a shared - // DataTransfer instead. This still exercises the full client - // delegation pipeline β€” not a liveTemplateClient.send() shortcut. - simulateDrag := func(srcKey, tgtKey string) chromedp.Action { - js := fmt.Sprintf(` - (() => { - const src = document.querySelector('#sortable-list li[data-key=%q]'); - const tgt = document.querySelector('#sortable-list li[data-key=%q]'); - if (!src || !tgt) throw new Error('source or target not found'); - const dt = new DataTransfer(); - src.dispatchEvent(new DragEvent('dragstart', {bubbles:true, cancelable:true, dataTransfer:dt})); - tgt.dispatchEvent(new DragEvent('dragover', {bubbles:true, cancelable:true, dataTransfer:dt})); - tgt.dispatchEvent(new DragEvent('drop', {bubbles:true, cancelable:true, dataTransfer:dt})); - })() - `, srcKey, tgtKey) - return chromedp.Evaluate(js, nil) - } - - // Reset the demo's shared in-memory order at the start. The controller's - // state is process-wide so other tests (or a previous run of this test - // in dev) could leave the list reordered. - t.Run("Initial_Reset", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`#sortable-list`, chromedp.ByQuery), - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - e2etest.WaitForCount(`#sortable-list li[data-key]`, 6, 5*time.Second), - chromedp.Click(`button[name="reset"]`, chromedp.ByQuery), - e2etest.WaitFor( - `document.querySelectorAll('#sortable-list li')[0].dataset.key === 'task-1'`, - 5*time.Second, - ), - ) - if err != nil { - t.Fatalf("Failed to load + reset: %v", err) - } - }) - - runStandardSubtests(t, ctx, false, "Sortable List β€” six task items each with a hamburger drag handle, in default order, plus a Reset Order button") - - // resetToInitial restores the canonical task-1..task-6 order so each - // reorder subtest starts from a known state and doesn't depend on the - // previous one's outcome. - resetToInitial := chromedp.Tasks{ - chromedp.Click(`button[name="reset"]`, chromedp.ByQuery), - e2etest.WaitFor( - `document.querySelectorAll('#sortable-list li')[0].dataset.key === 'task-1' && document.querySelectorAll('#sortable-list li')[5].dataset.key === 'task-6'`, - 5*time.Second, - ), - } - - t.Run("Reorder_DragForward", func(t *testing.T) { - // Initial: [task-1, task-2, task-3, task-4, task-5, task-6] - // Drag task-1 onto task-3 with insert-before-target semantics: - // task-1 is removed from index 0, the post-removal target index of - // task-3 is 1, and task-1 is inserted at index 1. - // Expected: [task-2, task-1, task-3, task-4, task-5, task-6] - var order string - err := chromedp.Run(ctx, - resetToInitial, - simulateDrag("task-1", "task-3"), - e2etest.WaitFor( - `document.querySelectorAll('#sortable-list li')[1].dataset.key === 'task-1'`, - 5*time.Second, - ), - chromedp.Evaluate( - `Array.from(document.querySelectorAll('#sortable-list li')).map(el => el.dataset.key).join(',')`, - &order, - ), - ) - if err != nil { - t.Fatalf("Forward drag failed: %v", err) - } - want := "task-2,task-1,task-3,task-4,task-5,task-6" - if order != want { - t.Errorf("Order after forward drag: got %q, want %q", order, want) - } - }) - - t.Run("Reorder_DragBackward", func(t *testing.T) { - // Initial: [task-1, task-2, task-3, task-4, task-5, task-6] - // Drag task-6 onto task-2: task-6 is removed from index 5, no - // post-removal index adjustment (srcIdx > tgtIdx), task-6 inserted - // at task-2's index 1. - // Expected: [task-1, task-6, task-2, task-3, task-4, task-5] - var order string - err := chromedp.Run(ctx, - resetToInitial, - simulateDrag("task-6", "task-2"), - e2etest.WaitFor( - `document.querySelectorAll('#sortable-list li')[1].dataset.key === 'task-6'`, - 5*time.Second, - ), - chromedp.Evaluate( - `Array.from(document.querySelectorAll('#sortable-list li')).map(el => el.dataset.key).join(',')`, - &order, - ), - ) - if err != nil { - t.Fatalf("Backward drag failed: %v", err) - } - want := "task-1,task-6,task-2,task-3,task-4,task-5" - if order != want { - t.Errorf("Order after backward drag: got %q, want %q", order, want) - } - }) - - t.Run("SelfDrop_NoOp", func(t *testing.T) { - // The controller short-circuits when source == target, so no diff - // is emitted. We can't condition-wait on a state that should NOT - // change, so we wait long enough for any spurious server-side - // reorder to round-trip (~500ms) and assert order is unchanged. - var orderBefore string - if err := chromedp.Run(ctx, - resetToInitial, - chromedp.Evaluate( - `Array.from(document.querySelectorAll('#sortable-list li')).map(el => el.dataset.key).join(',')`, - &orderBefore, - ), - ); err != nil { - t.Fatalf("Failed to read order before self-drop: %v", err) - } - - firstKey := strings.Split(orderBefore, ",")[0] - if err := chromedp.Run(ctx, simulateDrag(firstKey, firstKey)); err != nil { - t.Fatalf("Self-drop dispatch failed: %v", err) - } - - // time.Sleep (Go-side) is fine for negative assertions β€” the - // CLAUDE.md "no chromedp.Sleep" rule is about browser-side waits - // that hide timing bugs in positive assertions. 1s gives loaded - // CI runners headroom for any spurious server-side reorder to - // round-trip and surface in the assertion below. - time.Sleep(1 * time.Second) - - var orderAfter string - if err := chromedp.Run(ctx, chromedp.Evaluate( - `Array.from(document.querySelectorAll('#sortable-list li')).map(el => el.dataset.key).join(',')`, - &orderAfter, - )); err != nil { - t.Fatalf("Failed to read order after self-drop: %v", err) - } - if orderAfter != orderBefore { - t.Errorf("Self-drop changed order: was %q, now %q", orderBefore, orderAfter) - } - }) - - t.Run("Reset_RestoresInitialOrder", func(t *testing.T) { - // Scramble first so Reset has something to undo. Without this - // step the assertion would pass trivially when the list happened - // to already be in initial order. - var order string - err := chromedp.Run(ctx, - resetToInitial, - simulateDrag("task-3", "task-1"), - e2etest.WaitFor( - `document.querySelectorAll('#sortable-list li')[0].dataset.key === 'task-3'`, - 5*time.Second, - ), - chromedp.Click(`button[name="reset"]`, chromedp.ByQuery), - e2etest.WaitFor( - `document.querySelectorAll('#sortable-list li')[0].dataset.key === 'task-1' && document.querySelectorAll('#sortable-list li')[5].dataset.key === 'task-6'`, - 5*time.Second, - ), - chromedp.Evaluate( - `Array.from(document.querySelectorAll('#sortable-list li')).map(el => el.dataset.key).join(',')`, - &order, - ), - ) - if err != nil { - t.Fatalf("Reset failed: %v", err) - } - want := "task-1,task-2,task-3,task-4,task-5,task-6" - if order != want { - t.Errorf("Order after reset: got %q, want %q", order, want) - } - }) -} - -// --- Large Table (10k-row streaming-range demo) --- - -func TestLargeTable(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - // CI uses a 200-row dataset; the demo defaults to 10k. The smaller - // dataset still exercises every controller path and every range op - // the streaming-range diff emits, while keeping subtest latency low. - t.Setenv("LARGE_TABLE_SIZE", "200") - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/lists/large-table" - - frames := e2etest.RecordWSFrames(ctx) - - t.Run("Initial_Load_Renders_All_Rows", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`#large-table-pattern`, chromedp.ByQuery), - e2etest.WaitForCount(`tbody tr[data-key]`, 200, 30*time.Second), - e2etest.WaitForText(`#large-table-count`, "Showing 200 of 200 rows.", 5*time.Second), - ) - if err != nil { - t.Fatalf("Initial load failed: %v", err) - } - }) - - t.Run("UI_Standards", func(t *testing.T) { - runUIStandards(t, ctx) - }) - - t.Run("Filter_Reduces_Visible_Rows", func(t *testing.T) { - // "00099" matches User 00099 only (single row out of 200). - err := chromedp.Run(ctx, - chromedp.Focus(`input[name="filter"]`, chromedp.ByQuery), - chromedp.SendKeys(`input[name="filter"]`, "00099", chromedp.ByQuery), - e2etest.WaitForCount(`tbody tr[data-key]`, 1, 5*time.Second), - e2etest.WaitForText(`#large-table-count`, "Showing 1 of 200 rows.", 5*time.Second), - ) - if err != nil { - t.Fatalf("Filter did not narrow rows: %v", err) - } - }) - - t.Run("Filter_Clear_Restores_All_Rows", func(t *testing.T) { - // chromedp.Clear doesn't fire the input event the auto-wirer needs; - // set value and dispatch input/change manually (mirrors the pattern - // in TestActiveSearch). - var filterValue string - err := chromedp.Run(ctx, - chromedp.Focus(`input[name="filter"]`, chromedp.ByQuery), - chromedp.Evaluate(`(() => { - const el = document.querySelector('input[name="filter"]'); - el.value = ''; - el.dispatchEvent(new Event('input', { bubbles: true })); - el.dispatchEvent(new Event('change', { bubbles: true })); - return el.value; - })()`, nil), - e2etest.WaitForCount(`tbody tr[data-key]`, 200, 10*time.Second), - chromedp.Value(`input[name="filter"]`, &filterValue, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Filter clear did not restore: %v", err) - } - if filterValue != "" { - t.Errorf("Expected filter input to be cleared, got %q", filterValue) - } - }) - - t.Run("Update_Random_Row_Bounded_WS_Frame", func(t *testing.T) { - // Bounded-WS-size assertion (proposal Β§379, Β§386 OQ2): with no sort - // applied, a single-field change on a 5-field row must emit a small - // whole-item ["u"] op (~hundreds of bytes), NOT a full-tree - // replacement (KBs at this scale). 1.5KB is the test-tier ceiling β€” - // well above whole-item op size, well below full-tree size at N=200. - // Sort-active scenarios add a reorder op and are bounded separately - // in Update_With_Sort_Active_Bounded_WS_Frame below. - const wsFrameCeilingBytes = 1536 - - frames.Clear() - err := chromedp.Run(ctx, - chromedp.Click(`button[name="updateRandomRow"]`, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Click failed: %v", err) - } - // Wait for any received frame from the server. - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - if frames.CountByDirection("received") > 0 { - break - } - time.Sleep(50 * time.Millisecond) - } - if frames.CountByDirection("received") == 0 { - t.Fatalf("No received frame after UpdateRandomRow click") - } - - var maxBytes int - var maxMsg string - for _, msg := range frames.GetReceived() { - if len(msg.Data) > maxBytes { - maxBytes = len(msg.Data) - maxMsg = msg.Data - } - } - if maxBytes > wsFrameCeilingBytes { - head := maxMsg - if len(head) > 600 { - head = head[:600] + "...(truncated)" - } - t.Errorf("UpdateRandomRow WS frame exceeded streaming-range ceiling: got %d B, ceiling %d B\nFrame head: %s", maxBytes, wsFrameCeilingBytes, head) - } - t.Logf("UpdateRandomRow (no sort) max received frame: %d bytes (ceiling %d B)", maxBytes, wsFrameCeilingBytes) - }) - - t.Run("Sort_By_Score_Toggles_Direction", func(t *testing.T) { - var firstAsc, firstDesc string - err := chromedp.Run(ctx, - chromedp.Click(`button[name="sort"][value="score"]`, chromedp.ByQuery), - e2etest.WaitFor(`document.querySelector('button[name="sort"][value="score"]').textContent.includes('↑')`, 5*time.Second), - chromedp.Text(`tbody tr:first-child td:nth-child(4)`, &firstAsc, chromedp.ByQuery), - chromedp.Click(`button[name="sort"][value="score"]`, chromedp.ByQuery), - e2etest.WaitFor(`document.querySelector('button[name="sort"][value="score"]').textContent.includes('↓')`, 5*time.Second), - chromedp.Text(`tbody tr:first-child td:nth-child(4)`, &firstDesc, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Sort toggle failed: %v", err) - } - if firstAsc == firstDesc { - t.Errorf("Expected different first-row score after toggle, got %q both directions", firstAsc) - } - }) - - t.Run("Append_50_Grows_Total", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Click(`button[name="appendN"]`, chromedp.ByQuery), - e2etest.WaitForText(`#large-table-count`, "Showing 250 of 250 rows.", 5*time.Second), - ) - if err != nil { - t.Fatalf("Append failed: %v", err) - } - }) - - t.Run("Update_With_Sort_Active_Bounded_WS_Frame", func(t *testing.T) { - // With sort-by-score active and Append_50 having grown the table to - // 250 rows, an UpdateRandomRow shifts the changed row's rank in the - // sorted view, triggering an additional ["o", new-keys] reorder op. - // Reorder ops carry one key per row, so the ceiling scales linearly - // with N. At N=250 with ~12-char keys, expect ~3-4KB total. - const wsFrameCeilingBytes = 5120 - - frames.Clear() - err := chromedp.Run(ctx, - chromedp.Click(`button[name="updateRandomRow"]`, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Click failed: %v", err) - } - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - if frames.CountByDirection("received") > 0 { - break - } - time.Sleep(50 * time.Millisecond) - } - if frames.CountByDirection("received") == 0 { - t.Fatalf("No received frame after UpdateRandomRow click") - } - - var maxBytes int - var maxMsg string - for _, msg := range frames.GetReceived() { - if len(msg.Data) > maxBytes { - maxBytes = len(msg.Data) - maxMsg = msg.Data - } - } - if maxBytes > wsFrameCeilingBytes { - head := maxMsg - if len(head) > 600 { - head = head[:600] + "...(truncated)" - } - t.Errorf("UpdateRandomRow with sort exceeded streaming-range+reorder ceiling: got %d B, ceiling %d B\nFrame head: %s", maxBytes, wsFrameCeilingBytes, head) - } - t.Logf("UpdateRandomRow (sort active) max received frame: %d bytes (ceiling %d B)", maxBytes, wsFrameCeilingBytes) - }) - - t.Run("Delete_Single_Row", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Click(`tbody tr[data-key="row-00050"] button[name="delete"]`, chromedp.ByQuery), - e2etest.WaitForText(`#large-table-count`, "Showing 249 of 249 rows.", 5*time.Second), - ) - if err != nil { - t.Fatalf("Delete failed: %v", err) - } - }) - - t.Run("Reset_Restores_Initial_Count", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Click(`button[name="reset"]`, chromedp.ByQuery), - e2etest.WaitForCount(`tbody tr[data-key]`, 200, 10*time.Second), - e2etest.WaitForText(`#large-table-count`, "Showing 200 of 200 rows.", 5*time.Second), - ) - if err != nil { - t.Fatalf("Reset failed: %v", err) - } - }) - - t.Run("Delete_Targeted_Apply_Path_Taken", func(t *testing.T) { - // Verifies the client#107 targeted-apply path actually fires for - // the LargeTable template structure. A passing 10k stress test - // elsewhere proves wall-clock improved, but doesn't distinguish - // "targeted-apply works" from "targeted-apply rejects but the - // fallback also happens to be ~OK". This guards the predicate. - var hits int - err := chromedp.Run(ctx, - chromedp.Evaluate(`window.__lvtTargetedHits = 0`, nil), - chromedp.Click(`tbody tr[data-key="row-00100"] button[name="delete"]`, chromedp.ByQuery), - e2etest.WaitForText(`#large-table-count`, "Showing 199 of 199 rows.", 5*time.Second), - chromedp.Evaluate(`window.__lvtTargetedHits || 0`, &hits), - ) - if err != nil { - t.Fatalf("Delete failed: %v", err) - } - if hits == 0 { - t.Errorf("Targeted-apply path did NOT fire β€” canApplyTargeted rejected the LargeTable structure and we hit the fallback (deepClone + reconstructFromTree + morphdom-over-whole-range) path.") - } - t.Logf("Delete (N=199): targeted-apply hits=%d", hits) - }) -} - -// TestLargeTable_DeleteLatency_10k stress-tests the client-side targeted DOM -// mutation path at the demo's default scale (10,000 rows). This is the -// scenario from livetemplate/client#107: pre-fix, single-row delete took 6–8s -// in Chrome desktop because the client deep-cloned 10k items, rebuilt 5MB -// of HTML, parsed it, and ran morphdom over the entire range. Post-fix, the -// targeted-apply path mutates the live DOM directly and a sentinel attribute -// tells morphdom to short-circuit the 10k-row subtree. -// -// The 3500 ms ceiling is intentionally generous: it catches catastrophic -// regression (back to the 6-8s full-rebuild path) but accepts the residual -// cost of post-morphdom side-effect rescans (handleScrollDirectives, -// changeAutoWirer.wireElements, etc.) which still walk the wrapper at O(N). -// Tightening that further is a follow-up. Skipped under -short. -func TestLargeTable_DeleteLatency_10k(t *testing.T) { - if testing.Short() { - t.Skip("Skipping 10k-row latency test in short mode") - } - - t.Setenv("LARGE_TABLE_SIZE", "10000") - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/lists/large-table" - - const ceilingMs = 2500 - var elapsedMs float64 - var targetedHits int - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(15*time.Second), - chromedp.WaitVisible(`#large-table-pattern`, chromedp.ByQuery), - // 10k-row initial render needs a generous wait β€” the WS frame is - // multiple MB and morphdom on initial load walks every row once. - e2etest.WaitForCount(`tbody tr[data-key]`, 10000, 60*time.Second), - e2etest.WaitForText(`#large-table-count`, "Showing 10000 of 10000 rows.", 10*time.Second), - chromedp.Evaluate(`window.__lvtTargetedHits = 0`, nil), - // Bracket the click-to-DOM-removal interval. Wait specifically for - // the row's data-key to be gone from the DOM, not the count text β€” - // the count update is a sibling scalar that flows through morphdom - // and would conflate with the targeted-apply measurement. - chromedp.Evaluate(`window.__lvtT0 = performance.now()`, nil), - chromedp.Click(`tbody tr[data-key="row-05000"] button[name="delete"]`, chromedp.ByQuery), - e2etest.WaitFor(`document.querySelector('tbody tr[data-key="row-05000"]') === null`, 30*time.Second), - chromedp.Evaluate(`performance.now() - window.__lvtT0`, &elapsedMs), - chromedp.Evaluate(`window.__lvtTargetedHits || 0`, &targetedHits), - ) - if err != nil { - t.Fatalf("10k delete flow failed: %v", err) - } - if targetedHits == 0 { - t.Errorf("Targeted-apply path did NOT fire β€” canApplyTargeted rejected the LargeTable structure and we hit the fallback (full rebuild) path. The fix is a no-op for this template.") - } - if elapsedMs > ceilingMs { - t.Errorf("Delete wall-clock %.1f ms exceeded ceiling %d ms at N=10000 β€” targeted DOM apply may have regressed", elapsedMs, ceilingMs) - } - t.Logf("Delete (N=10000) wall-clock: %.1f ms (ceiling %d ms), targeted-apply hits: %d", elapsedMs, ceilingMs, targetedHits) -} - -// --- Pattern #12: Active Search --- - -func TestActiveSearch(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/search/active-search" - - t.Run("Initial_Load", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`input[name="query"]`, chromedp.ByQuery), - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - // Full directory is 25 contacts - e2etest.WaitForCount(`tbody tr[data-key]`, 25, 5*time.Second), - ) - if err != nil { - t.Fatalf("Failed to load page: %v", err) - } - }) - - runStandardSubtests(t, ctx, false, "Active Search β€” search input labeled 'Search contacts' with a table of 25 contacts showing Name and Email columns below") - - t.Run("Filter_To_Single_Result", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Focus(`input[name="query"]`, chromedp.ByQuery), - chromedp.SendKeys(`input[name="query"]`, "Chen", chromedp.ByQuery), - // WaitForCount naturally waits out the 300ms debounce - e2etest.WaitForCount(`tbody tr[data-key]`, 1, 5*time.Second), - ) - if err != nil { - t.Fatalf("Failed to filter results: %v", err) - } - var html string - err = chromedp.Run(ctx, chromedp.OuterHTML(`tbody`, &html, chromedp.ByQuery)) - if err != nil { - t.Fatalf("Failed to read tbody: %v", err) - } - if !strings.Contains(html, "Marcus Chen") { - t.Errorf("Expected Marcus Chen in results, got:\n%s", html) - } - }) - - t.Run("Clear_Query_Restores_All", func(t *testing.T) { - // chromedp.Clear doesn't fire DOM events β€” set value and dispatch both - // `input` (what the Change auto-wirer listens for on text inputs) and - // `change` (defensive for event-filter implementations) in a single - // script so the auto-wirer picks it up regardless. - // - // Timeout bumped to 10s: this test was flaky under CI load where - // orphan processes from earlier tests compete for CPU. Locally - // completes in ~0.4s; CI failure pattern was a hard 5s timeout - // while still showing the previous query's 1-result state. - err := chromedp.Run(ctx, - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.Focus(`input[name="query"]`, chromedp.ByQuery), - chromedp.Evaluate(`(() => { - const el = document.querySelector('input[name="query"]'); - el.value = ''; - el.dispatchEvent(new Event('input', { bubbles: true })); - el.dispatchEvent(new Event('change', { bubbles: true })); - return el.value; - })()`, nil), - e2etest.WaitForCount(`tbody tr[data-key]`, 25, 10*time.Second), - ) - if err != nil { - t.Fatalf("Failed to clear query: %v", err) - } - }) - - t.Run("Empty_Results_Shows_No_Results", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Focus(`input[name="query"]`, chromedp.ByQuery), - chromedp.SendKeys(`input[name="query"]`, "xzyzzzz-no-match", chromedp.ByQuery), - e2etest.WaitForCount(`tbody tr[data-key]`, 0, 5*time.Second), - e2etest.WaitForText(`article`, "No contacts match", 3*time.Second), - ) - if err != nil { - t.Fatalf("Failed to show empty results: %v", err) - } - }) -} - -// --- Pattern #13: URL-Preserved Filters --- - -func TestURLFilters(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - baseURL := e2etest.GetChromeTestURL(serverPort) + "/patterns/search/url-filters" - - t.Run("Initial_Load", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Navigate(baseURL), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`table`, chromedp.ByQuery), - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - // Full dataset: 12 items - e2etest.WaitForCount(`tbody tr[data-key]`, 12, 5*time.Second), - ) - if err != nil { - t.Fatalf("Failed to load page: %v", err) - } - // "All" and "By Name" should have aria-current="page" - var html string - err = chromedp.Run(ctx, chromedp.OuterHTML(`article nav`, &html, chromedp.ByQuery)) - if err != nil { - t.Fatalf("Failed to read nav: %v", err) - } - if !strings.Contains(html, `aria-current="page">All`) { - t.Errorf("Expected 'All' link marked aria-current, got:\n%s", html) - } - if !strings.Contains(html, `aria-current="page">By Name`) { - t.Errorf("Expected 'By Name' link marked aria-current, got:\n%s", html) - } - }) - - runStandardSubtests(t, ctx, false, "URL-Preserved Filters β€” two groups of filter links (status: All/Active/Completed and sort: By Name/By Date) above a table of items with Name, Status, Date columns") - - t.Run("Filter_By_Active", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Click(`a[href="?status=active&sort=name"]`, chromedp.ByQuery), - // 7 active items in filterDataset (IDs 3, 4, 6, 8, 10, 11, 12). - e2etest.WaitForCount(`tbody tr[data-key]`, 7, 5*time.Second), - ) - if err != nil { - t.Fatalf("Failed to filter by active: %v", err) - } - var currentURL string - err = chromedp.Run(ctx, chromedp.Location(¤tURL)) - if err != nil { - t.Fatalf("Failed to read URL: %v", err) - } - if !strings.Contains(currentURL, "status=active") { - t.Errorf("URL should contain status=active, got: %s", currentURL) - } - }) - - t.Run("Bookmarkable_Reload", func(t *testing.T) { - // Direct navigate to a filtered URL (simulates bookmark reload). - err := chromedp.Run(ctx, - chromedp.Navigate(baseURL+"?status=completed&sort=date"), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`table`, chromedp.ByQuery), - // Completed items: 1, 2, 5, 7, 9 = 5 - e2etest.WaitForCount(`tbody tr[data-key]`, 5, 5*time.Second), - ) - if err != nil { - t.Fatalf("Bookmarked URL did not restore state: %v", err) - } - // Verify sort order is date-desc: first row should be the newest completed item - // (ID 9, 2024-08-19) per filterDataset in data.go. - var firstRowHTML string - err = chromedp.Run(ctx, chromedp.OuterHTML(`tbody tr:first-child`, &firstRowHTML, chromedp.ByQuery)) - if err != nil { - t.Fatalf("Failed to read first row: %v", err) - } - if !strings.Contains(firstRowHTML, "2024-08-19") { - t.Errorf("Expected newest completed item (2024-08-19) first, got:\n%s", firstRowHTML) - } - var navHTML string - err = chromedp.Run(ctx, chromedp.OuterHTML(`article nav`, &navHTML, chromedp.ByQuery)) - if err != nil { - t.Fatalf("Failed to read nav: %v", err) - } - if !strings.Contains(navHTML, `aria-current="page">Completed`) { - t.Errorf("Completed link should be marked aria-current after bookmarked reload, got:\n%s", navHTML) - } - if !strings.Contains(navHTML, `aria-current="page">By Date`) { - t.Errorf("By Date link should be marked aria-current after bookmarked reload, got:\n%s", navHTML) - } - }) - - t.Run("Invalid_Status_Falls_Back", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Navigate(baseURL+"?status=nonsense&sort=date"), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`table`, chromedp.ByQuery), - // Unknown status falls back to default "all" β†’ 12 items - e2etest.WaitForCount(`tbody tr[data-key]`, 12, 5*time.Second), - ) - if err != nil { - t.Fatalf("Invalid status did not fall back gracefully: %v", err) - } - }) - - t.Run("Reset_To_All", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Click(`a[href="?status=all&sort=date"]`, chromedp.ByQuery), - e2etest.WaitForCount(`tbody tr[data-key]`, 12, 5*time.Second), - ) - if err != nil { - t.Fatalf("Failed to reset to all: %v", err) - } - }) -} - -// --- Pattern #10: Infinite Scroll --- - -// TestInfiniteScroll verifies the [lvt-scroll-sentinel] IntersectionObserver -// wiring and the loadMorePending throttle. In headless Chrome the short -// first page keeps the sentinel intersecting, so page 2 auto-advances; -// subsequent pages require an explicit scroll because the sentinel has -// drifted past the 200px rootMargin. -func TestInfiniteScroll(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/lists/infinite-scroll" - - t.Run("Initial_Load_And_Auto_Advance", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`table`, chromedp.ByQuery), - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - // First page renders, observer auto-advances while sentinel is - // in view (safely throttled by the client's loadMorePending flag). - e2etest.WaitFor(`document.querySelectorAll('tbody tr[data-key]').length >= 10`, 5*time.Second), - ) - if err != nil { - t.Fatalf("Initial load failed: %v", err) - } - // Wait for the auto-advance to settle: two consecutive polls with - // the same row count (rows have stopped arriving). - var dataKeys string - err = chromedp.Run(ctx, - e2etest.WaitFor(`(() => { - const prev = window.__lastRowCount || 0; - const cur = document.querySelectorAll('tbody tr[data-key]').length; - window.__lastRowCount = cur; - return cur === prev && cur > 0; - })()`, 3*time.Second), - chromedp.Evaluate(`Array.from(document.querySelectorAll('tbody tr[data-key]')).map(r => r.getAttribute('data-key')).join(',')`, &dataKeys), - ) - if err != nil { - t.Fatalf("Auto-advance did not settle: %v", err) - } - // Verify no duplicate data-keys β€” the client's loadMorePending flag - // plus the WS-aware connect() ensure that each load_more lands - // exactly once on the server-side persistent state path. - seen := make(map[string]bool) - for _, k := range strings.Split(dataKeys, ",") { - if seen[k] { - t.Fatalf("Duplicate data-key %q after auto-advance: %s", k, dataKeys) - } - seen[k] = true - } - }) - - runStandardSubtests(t, ctx, false, "Infinite Scroll β€” table with 20 rows (ID, Name, Email) followed by a 'Loading more…' sentinel at the bottom") - - t.Run("Scroll_Triggers_More_Pages", func(t *testing.T) { - // Scroll the sentinel into view repeatedly. Each scroll fires one - // observer callback (throttled by the client's loadMorePending flag), - // appending one more page. With the 100-item dataset at page size 10, - // we'd need ~8-10 scrolls to fully exhaust, so we verify the pipeline - // works by scrolling twice and confirming two extra pages loaded. - var baseline int - _ = chromedp.Run(ctx, chromedp.Evaluate(`document.querySelectorAll('tbody tr[data-key]').length`, &baseline)) - if baseline < 10 { - t.Fatalf("Baseline row count too low: %d", baseline) - } - // Scroll once - err := chromedp.Run(ctx, - chromedp.Evaluate(`(() => { - const s = document.querySelector('[lvt-scroll-sentinel]'); - if (s) s.scrollIntoView({ block: 'center' }); - })()`, nil), - e2etest.WaitFor(`document.querySelectorAll('tbody tr[data-key]').length > `+fmt.Sprintf("%d", baseline), 5*time.Second), - ) - if err != nil { - t.Fatalf("First scroll did not trigger a new page: %v", err) - } - // Scroll again - var afterFirstScroll int - _ = chromedp.Run(ctx, chromedp.Evaluate(`document.querySelectorAll('tbody tr[data-key]').length`, &afterFirstScroll)) - err = chromedp.Run(ctx, - chromedp.Evaluate(`(() => { - const s = document.querySelector('[lvt-scroll-sentinel]'); - if (s) s.scrollIntoView({ block: 'center' }); - })()`, nil), - e2etest.WaitFor(`document.querySelectorAll('tbody tr[data-key]').length > `+fmt.Sprintf("%d", afterFirstScroll), 5*time.Second), - ) - if err != nil { - t.Fatalf("Second scroll did not trigger a new page: %v", err) - } - // Duplicate check: all data-keys are unique. - var dataKeys string - _ = chromedp.Run(ctx, - chromedp.Evaluate(`Array.from(document.querySelectorAll('tbody tr[data-key]')).map(r => r.getAttribute('data-key')).join(',')`, &dataKeys), - ) - seen := make(map[string]bool) - for _, k := range strings.Split(dataKeys, ",") { - if seen[k] { - t.Errorf("Duplicate data-key %q after scroll-driven pagination: %s", k, dataKeys) - } - seen[k] = true - } - // Sanity: at least 3 items from past the first page should be present. - var html string - _ = chromedp.Run(ctx, chromedp.OuterHTML(`tbody`, &html, chromedp.ByQuery)) - if !strings.Contains(html, "Row 1") { - t.Error("Row 1 (first item) missing after scroll") - } - }) -} - -// --- Session 3: Loading & Progress --- - -func TestLazyLoading(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/loading/lazy-loading" - - t.Run("Initial_Load_Shows_Spinner", func(t *testing.T) { - // The page should render immediately with the spinner; the content - // blockquote must be absent until the goroutine fires (~2s later). - var hasBlockquote bool - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`p[aria-busy="true"]`, chromedp.ByQuery), - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - chromedp.Evaluate(`!!document.querySelector('blockquote')`, &hasBlockquote), - ) - if err != nil { - t.Fatalf("Initial load failed: %v", err) - } - if hasBlockquote { - t.Error("Blockquote should not be present while still loading") - } - }) - - t.Run("Data_Arrives_Via_Server_Push", func(t *testing.T) { - // The goroutine sleeps 2s then pushes via TriggerAction. 5s timeout - // is generous. After arrival, the spinner must be gone. - var hasSpinner bool - err := chromedp.Run(ctx, - e2etest.WaitForText(`blockquote`, "Content loaded lazily", 5*time.Second), - chromedp.Evaluate(`!!document.querySelector('p[aria-busy="true"]')`, &hasSpinner), - ) - if err != nil { - t.Fatalf("Data did not arrive: %v", err) - } - if hasSpinner { - t.Error("Spinner should be gone after data arrives") - } - }) - - t.Run("Reload_Refetches_Fresh_Content", func(t *testing.T) { - // Click Reload; spinner reappears; new content arrives via a fresh - // goroutine push. The two strings have different prefixes ("Content - // loaded lazily at …" vs "Content reloaded at …"), so an inequality - // check between them is trivially true and would not actually prove - // that a second goroutine ran. Instead, assert directly on the - // expected prefix transitions: firstContent must be the - // initial-load message, secondContent must be the reload message. - // Both prefixes are produced by separate goroutine paths, so this - // assertion proves real second-goroutine execution. - var firstContent, secondContent string - err := chromedp.Run(ctx, - chromedp.Text(`blockquote`, &firstContent, chromedp.ByQuery), - chromedp.Click(`button[name="reload"]`, chromedp.ByQuery), - chromedp.WaitVisible(`p[aria-busy="true"]`, chromedp.ByQuery), - e2etest.WaitForText(`blockquote`, "Content reloaded", 5*time.Second), - chromedp.Text(`blockquote`, &secondContent, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Reload failed: %v", err) - } - if !strings.Contains(firstContent, "Content loaded lazily") { - t.Errorf("First content was not the initial load message: %q", firstContent) - } - if strings.Contains(firstContent, "Content reloaded") { - t.Errorf("First content already had the reload prefix β€” test ordering broken: %q", firstContent) - } - if !strings.Contains(secondContent, "Content reloaded") { - t.Errorf("Second content did not have the reload prefix: %q", secondContent) - } - if strings.Contains(secondContent, "Content loaded lazily") { - t.Errorf("Second content still had the initial-load prefix: %q", secondContent) - } - }) - - runStandardSubtests(t, ctx, false, "Lazy Loading β€” page showing a blockquote with lazily-loaded content and a secondary 'Reload' button below") -} - -func TestProgressBar(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/loading/progress-bar" - - t.Run("Initial_Load", func(t *testing.T) { - var hasProgress bool - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`button[name="start"]`, chromedp.ByQuery), - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - chromedp.Evaluate(`!!document.querySelector('progress')`, &hasProgress), - ) - if err != nil { - t.Fatalf("Initial load failed: %v", err) - } - if hasProgress { - t.Error(" should not be present before Start is clicked") - } - }) - - t.Run("Start_Runs_To_Completion", func(t *testing.T) { - // Click Start; progress element appears and ticks up. Goroutine runs - // 10 Γ— 500ms = 5s. The intermediate-tick assertion (value > 0 AND - // value < 100) catches a regression where the goroutine skips - // intermediate ticks and jumps straight to 100 β€” a value > 0 check - // alone would also be satisfied by an instant 100, missing the bug. - // 5s timeout matches the goroutine's full duration so on loaded CI - // runners we still catch a real value < 100 before completion. - err := chromedp.Run(ctx, - chromedp.Click(`button[name="start"]`, chromedp.ByQuery), - e2etest.WaitFor(`!!document.querySelector('progress')`, 3*time.Second), - e2etest.WaitFor(`document.querySelector('progress') && document.querySelector('progress').value > 0 && document.querySelector('progress').value < 100`, 5*time.Second), - e2etest.WaitForText(`button`, "Run Again", 10*time.Second), - e2etest.WaitForText(`output[data-flash="success"]`, "Job complete", 3*time.Second), - ) - if err != nil { - t.Fatalf("Progress bar did not complete: %v", err) - } - }) - - t.Run("Run_Again_Restarts_Timer", func(t *testing.T) { - // The Run Again button starts the timer again. Progress must begin - // from below 100, climb back to completion, AND re-emit the success - // flash. The flash assertion catches a regression where the second - // run completes silently (e.g., if the controller forgot to call - // SetFlash on the re-completion path). - err := chromedp.Run(ctx, - chromedp.Click(`button[name="start"]`, chromedp.ByQuery), - e2etest.WaitFor(`document.querySelector('progress') && document.querySelector('progress').value > 0 && document.querySelector('progress').value < 100`, 5*time.Second), - e2etest.WaitForText(`button`, "Run Again", 10*time.Second), - e2etest.WaitForText(`output[data-flash="success"]`, "Job complete", 3*time.Second), - ) - if err != nil { - t.Fatalf("Run Again failed: %v", err) - } - }) - - t.Run("Brief_Disconnect_Within_Retry_Window_Completes", func(t *testing.T) { - // Reload to clean state. Click Start. Once the timer is mid-flight, - // force-disconnect the WebSocket via the client's public API. The - // server-side ticker will retry session.TriggerAction for ~5s; if we - // reconnect within that window, the timer must continue and - // complete to 100%. - err := chromedp.Run(ctx, - chromedp.Reload(), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`button[name="start"]`, chromedp.ByQuery), - chromedp.Click(`button[name="start"]`, chromedp.ByQuery), - e2etest.WaitFor(`document.querySelector('progress') && document.querySelector('progress').value >= 20`, 3*time.Second), - // Force-disconnect; this is the same disconnect() the - // visibility-reconnect path uses, so the server treats it - // identically to an iOS-killed connection. - chromedp.Evaluate(`window.liveTemplateClient.disconnect()`, nil), - // Wall-clock sleep: we're deliberately leaving the connection - // down inside the goroutine's 5s retry window to verify it - // survives the gap. There's no client-observable signal - // during the retry loop (the goroutine's TriggerAction errors - // silently), so a condition-based wait wouldn't fit here. - chromedp.Sleep(1*time.Second), - chromedp.Evaluate(`window.liveTemplateClient.connect()`, nil), - e2etest.WaitForWebSocketReady(5*time.Second), - // Run continues. Wait for completion. - e2etest.WaitForText(`button`, "Run Again", 10*time.Second), - ) - if err != nil { - t.Fatalf("Brief-disconnect run did not complete: %v", err) - } - }) - - t.Run("Long_Disconnect_Beyond_Retry_Window_Settles_Without_Impossible_State", func(t *testing.T) { - // Reload to clean state. Click Start. Mid-flight, disconnect for - // 7s β€” past the goroutine's 5s retry budget. The goroutine must - // give up; on reconnect the page must NOT display a corrupted - // state (Done=true with Progress<100, or Running=true with no - // goroutine to advance it). Acceptable settled states: clean - // "Start Job" button (Running=false, Done=false) OR completed - // "Run Again" with Progress=100. - err := chromedp.Run(ctx, - chromedp.Reload(), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`button[name="start"]`, chromedp.ByQuery), - chromedp.Click(`button[name="start"]`, chromedp.ByQuery), - e2etest.WaitFor(`document.querySelector('progress') && document.querySelector('progress').value >= 20`, 3*time.Second), - chromedp.Evaluate(`window.liveTemplateClient.disconnect()`, nil), - // chromedp.Sleep is intentional β€” we're waiting wall-clock - // for the goroutine's 5s retry budget to expire. - chromedp.Sleep(7*time.Second), - chromedp.Evaluate(`window.liveTemplateClient.connect()`, nil), - e2etest.WaitForWebSocketReady(5*time.Second), - // Wait for a stable settled state. - e2etest.WaitFor(`(() => { - const btn = document.querySelector('button[name="start"]'); - if (!btn) return false; - return btn.textContent.includes('Start Job') || btn.textContent.includes('Run Again'); - })()`, 5*time.Second), - ) - if err != nil { - t.Fatalf("Long-disconnect run did not settle: %v", err) - } - - // Invariant: if "Run Again" is shown (Done=true), progress must be 100. - // If "Start Job" is shown (Running=false, Done=false), no progress - // element should be present. - var settled struct { - HasRunAgain bool `json:"hasRunAgain"` - HasStartJob bool `json:"hasStartJob"` - HasProgress bool `json:"hasProgress"` - ProgressVal int `json:"progressVal"` - } - err = chromedp.Run(ctx, chromedp.Evaluate(`(() => { - const btn = document.querySelector('button[name="start"]'); - const p = document.querySelector('progress'); - return { - hasRunAgain: btn && btn.textContent.includes('Run Again'), - hasStartJob: btn && btn.textContent.includes('Start Job'), - hasProgress: !!p, - progressVal: p ? Number(p.value) : 0, - }; - })()`, &settled)) - if err != nil { - t.Fatalf("Could not read settled state: %v", err) - } - if settled.HasRunAgain && settled.ProgressVal != 100 { - t.Errorf("Impossible state: Run Again button shown but progress=%d (must be 100)", settled.ProgressVal) - } - if settled.HasStartJob && settled.HasProgress { - t.Errorf("Impossible state: Start Job button shown but progress element still present") - } - if !settled.HasRunAgain && !settled.HasStartJob { - t.Error("Impossible state: neither Run Again nor Start Job button shown after long disconnect") - } - }) - - t.Run("Multiple_Disconnect_Cycles_Never_Produce_Impossible_State", func(t *testing.T) { - // Run Again, then disconnect/reconnect rapidly several times during - // the run. After settled, verify the same invariant: Done=true β†’ - // Progress=100. This is the regression test for the bug where Mount - // revival spawned a competing goroutine that overwrote Progress with - // a stale value AFTER another goroutine had set Done=true. - err := chromedp.Run(ctx, - chromedp.Reload(), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`button[name="start"]`, chromedp.ByQuery), - chromedp.Click(`button[name="start"]`, chromedp.ByQuery), - e2etest.WaitFor(`document.querySelector('progress') && document.querySelector('progress').value >= 10`, 3*time.Second), - ) - if err != nil { - t.Fatalf("Could not start the timer: %v", err) - } - - // Three disconnect/reconnect cycles. Each cycle: capture progress - // before, fire disconnect+connect back-to-back, wait for the - // reconnect to land, then wait for the goroutine to make further - // progress (or for the run to complete) before the next cycle β€” - // avoids fixed-duration sleeps that flake on slow CI. - for i := 0; i < 3; i++ { - var beforeProgress int - err := chromedp.Run(ctx, chromedp.Evaluate( - `(()=>{const p=document.querySelector('progress');return p?Number(p.value):0;})()`, - &beforeProgress, - )) - if err != nil { - t.Fatalf("Cycle %d: could not read pre-cycle progress: %v", i, err) - } - err = chromedp.Run(ctx, - chromedp.Evaluate(`window.liveTemplateClient.disconnect(); window.liveTemplateClient.connect()`, nil), - e2etest.WaitForWebSocketReady(3*time.Second), - e2etest.WaitFor(fmt.Sprintf(`(() => { - const btn = document.querySelector('button[name="start"]'); - if (btn && btn.textContent.includes('Run Again')) return true; - const p = document.querySelector('progress'); - return p && Number(p.value) > %d; - })()`, beforeProgress), 5*time.Second), - ) - if err != nil { - t.Fatalf("Disconnect cycle %d failed: %v", i, err) - } - } - - // Wait for a stable final state. - err = chromedp.Run(ctx, - e2etest.WaitFor(`(() => { - const btn = document.querySelector('button[name="start"]'); - if (!btn) return false; - return btn.textContent.includes('Start Job') || btn.textContent.includes('Run Again'); - })()`, 15*time.Second), - ) - if err != nil { - t.Fatalf("Did not settle after disconnect cycles: %v", err) - } - - var settled struct { - HasRunAgain bool `json:"hasRunAgain"` - HasStartJob bool `json:"hasStartJob"` - ProgressVal int `json:"progressVal"` - } - err = chromedp.Run(ctx, chromedp.Evaluate(`(() => { - const btn = document.querySelector('button[name="start"]'); - const p = document.querySelector('progress'); - return { - hasRunAgain: btn && btn.textContent.includes('Run Again'), - hasStartJob: btn && btn.textContent.includes('Start Job'), - progressVal: p ? Number(p.value) : 0, - }; - })()`, &settled)) - if err != nil { - t.Fatalf("Could not read settled state: %v", err) - } - // The core invariant: if the UI advertises completion (Run Again - // button), progress must be a true 100. The screenshot reproducer - // for this bug showed Run Again next to a 70% bar. - if settled.HasRunAgain && settled.ProgressVal != 100 { - t.Errorf("Impossible state after disconnect cycles: Run Again button shown but progress=%d", settled.ProgressVal) - } - }) - - t.Run("Done_State_Survives_Reconnect_Via_Persist", func(t *testing.T) { - // Progress and Done are lvt:"persist" β€” a completed run must stay - // completed across a disconnect/reconnect cycle. The user keeps the - // "Run Again" button rather than snapping back to Start Job. - err := chromedp.Run(ctx, - chromedp.Reload(), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`button[name="start"]`, chromedp.ByQuery), - chromedp.Click(`button[name="start"]`, chromedp.ByQuery), - e2etest.WaitForText(`button`, "Run Again", 10*time.Second), - // Disconnect+reconnect back-to-back; WaitForWebSocketReady - // synchronises on the new connection rather than a fixed sleep. - chromedp.Evaluate(`window.liveTemplateClient.disconnect(); window.liveTemplateClient.connect()`, nil), - e2etest.WaitForWebSocketReady(5*time.Second), - // Run Again must still be there. - e2etest.WaitForText(`button`, "Run Again", 5*time.Second), - ) - if err != nil { - t.Fatalf("Done state did not persist across reconnect: %v", err) - } - }) - - runStandardSubtests(t, ctx, false, "Progress Bar β€” completed state showing a full progress bar, a 'Job complete' success flash below it, and a 'Run Again' button") -} - -func TestAsyncOperations(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/loading/async-operations" - - t.Run("Initial_Load", func(t *testing.T) { - var hasResult bool - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - e2etest.WaitForText(`button[name="fetch"]`, "Fetch Data", 3*time.Second), - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - chromedp.Evaluate(`!!document.querySelector('blockquote') || !!document.querySelector('mark')`, &hasResult), - ) - if err != nil { - t.Fatalf("Initial load failed: %v", err) - } - if hasResult { - t.Error("Result/error display should not be present before Fetch is clicked") - } - }) - - t.Run("Fetch_Transitions_Through_Loading_To_Result", func(t *testing.T) { - // Click Fetch β†’ transient loading state β†’ final success OR error. - // The branch is random (~33% error rate). Tests must tolerate either. - err := chromedp.Run(ctx, - chromedp.Click(`button[name="fetch"]`, chromedp.ByQuery), - // Loading state: button shows "Fetching..." and aria-busy. - e2etest.WaitForText(`button[name="fetch"]`, "Fetching...", 3*time.Second), - // Final state: either
(success) or (error). - e2etest.WaitFor(`!!document.querySelector('blockquote') || !!document.querySelector('mark')`, 5*time.Second), - // Button must re-enable (exits "loading" status). - e2etest.WaitForText(`button[name="fetch"]`, "Fetch Data", 3*time.Second), - ) - if err != nil { - t.Fatalf("Async flow did not complete: %v", err) - } - // Exactly one of success or error must be present, plus the matching - // flash. The flash text is asserted against the controller's exact - // SetFlash message, not just the element presence β€” an empty - // placeholder would satisfy a presence-only - // check and silently mask a regression where SetFlash wasn't called. - // - // `outcome` is read first, then both the wait-for-flash and the - // flash-text read are batched into a single chromedp.Run so the - // outcome value can't drift between the read and the wait. - var outcome string - err = chromedp.Run(ctx, chromedp.Evaluate(`(() => { - if (document.querySelector('blockquote')) return 'success'; - if (document.querySelector('mark')) return 'error'; - return 'none'; - })()`, &outcome)) - if err != nil { - t.Fatalf("Failed to read outcome: %v", err) - } - if outcome == "none" { - t.Fatal("No outcome (neither success nor error) rendered") - } - // Map outcome β†’ expected flash text from the controller. - // Mirrors AsyncOpsController.FetchResult ctx.SetFlash calls. - expectedFlashText := map[string]string{ - "success": "Fetch complete", - "error": "Fetch failed", - }[outcome] - flashSelector := fmt.Sprintf(`output[data-flash="%s"]`, outcome) - var flashText string - err = chromedp.Run(ctx, - e2etest.WaitFor(fmt.Sprintf(`!!document.querySelector('%s')`, flashSelector), 3*time.Second), - chromedp.Evaluate( - fmt.Sprintf(`(() => { const el = document.querySelector('%s'); return el ? el.textContent.trim() : ""; })()`, flashSelector), - &flashText, - ), - ) - if err != nil { - t.Fatalf("Outcome %q: failed to read %s: %v", outcome, flashSelector, err) - } - if !strings.Contains(flashText, expectedFlashText) { - t.Errorf("Outcome %q: flash text = %q, want it to contain %q", outcome, flashText, expectedFlashText) - } - }) - - // Regression test for the AsyncOpsController.Fetch Running guard. - // Without the guard, two rapid `fetch` actions sent via direct - // WebSocket message (bypassing the template-disabled button) would - // each spawn a goroutine that calls TriggerAction("fetchResult"), - // resulting in two state transitions, two SetFlash calls, and - // potentially malformed rendered state. With the guard, the second - // Fetch is a no-op (state.Status == "loading" β†’ return early). - // - // This test asserts the user-visible invariant: concurrent Fetch - // calls leave the UI in a single consistent state with exactly one - // result element (blockquote OR mark, never both, never stacked). - // It does not directly verify the guard rejected the second call β€” - // detecting that from the rendered HTML is hard because the state - // machine is idempotent in its final state β€” but it does prove the - // guard's user-visible promise (concurrent Fetches don't break the - // page) holds. - t.Run("Concurrent_Fetch_Reaches_Single_Result", func(t *testing.T) { - var resultCount int - err := chromedp.Run(ctx, - // Wait for idle state from the previous subtest. - e2etest.WaitForText(`button[name="fetch"]`, "Fetch Data", 3*time.Second), - // Send two Fetch actions in immediate sequence via direct WS, - // bypassing the rendered button (which would be disabled - // after the first click). - chromedp.Evaluate(`(() => { - window.liveTemplateClient.send({action: 'fetch'}); - window.liveTemplateClient.send({action: 'fetch'}); - })()`, nil), - // Two-phase wait. We MUST observe the loading state first β€” - // otherwise polling can match the pre-Fetch "Fetch Data" state - // before the Fetch render arrives at the browser, the test - // "succeeds" instantly, and a microsecond later the Fetch - // render lands and clears the result element. Then Evaluate - // counts 0 even though the cycle is fine. By gating on - // "Fetching..." first, we prove the Fetch render landed. - // The completion gate then waits for both the button AND the - // result element atomically, so there's no window where one - // is true but the other isn't. - e2etest.WaitForText(`button[name="fetch"]`, "Fetching...", 3*time.Second), - e2etest.WaitFor(`(() => { - const btn = document.querySelector('button[name="fetch"]'); - if (!btn || btn.textContent.trim() !== 'Fetch Data') return false; - return document.querySelectorAll('blockquote, mark').length >= 1; - })()`, 5*time.Second), - // Count result elements. Exactly one of (blockquote, mark) must - // be present. If two goroutines somehow corrupted the state - // machine, we might see zero, two of either, or both. - chromedp.Evaluate(`document.querySelectorAll('blockquote, mark').length`, &resultCount), - ) - if err != nil { - t.Fatalf("Concurrent Fetch test failed: %v", err) - } - if resultCount != 1 { - t.Errorf("Expected exactly 1 result element after concurrent Fetch, got %d", resultCount) - } - }) - - runStandardSubtests(t, ctx, false, "Async Operations β€” 'Fetch Data' button followed by either a success flash and blockquote with fetch result, or an error flash and mark element with an error message") -} - -// --- Pattern #17: Modal Dialog --- - -func TestModalDialog(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/navigation/modal-dialog" - - t.Run("Initial_Load", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`button[commandfor="edit-dialog"][command="show-modal"]`, chromedp.ByQuery), - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - ) - if err != nil { - t.Fatalf("Initial load failed: %v", err) - } - var dialogOpen bool - if err := chromedp.Run(ctx, chromedp.Evaluate(`document.getElementById('edit-dialog').open`, &dialogOpen)); err != nil { - t.Fatalf("Read dialog state failed: %v", err) - } - if dialogOpen { - t.Error("Dialog should be closed on initial load") - } - }) - - t.Run("Open_Via_Button", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Click(`button[commandfor="edit-dialog"][command="show-modal"]`, chromedp.ByQuery), - e2etest.WaitFor(`document.getElementById('edit-dialog').open === true`, 5*time.Second), - ) - if err != nil { - t.Fatalf("Open via button failed: %v", err) - } - }) - - t.Run("Submit_Invalid_Form_Stays_Open_With_Field_Errors", func(t *testing.T) { - // noValidate=true bypasses HTML5 form validation so the empty input - // reaches the server's validator, which is what we want to exercise. - err := chromedp.Run(ctx, - e2etest.WaitFor(`document.getElementById('edit-dialog').open === true`, 3*time.Second), - chromedp.Evaluate(`document.querySelector('dialog#edit-dialog form').noValidate = true`, nil), - chromedp.Clear(`dialog#edit-dialog input[name="name"]`, chromedp.ByQuery), - chromedp.Click(`dialog#edit-dialog button[type="submit"]`, chromedp.ByQuery), - e2etest.WaitFor(`(() => { const d = document.getElementById('edit-dialog'); return d.open && d.querySelector('input[name="name"][aria-invalid="true"]') !== null; })()`, 5*time.Second), - ) - if err != nil { - t.Fatalf("Invalid form submit did not produce field error inside open dialog: %v", err) - } - var dialogOpen bool - if err := chromedp.Run(ctx, chromedp.Evaluate(`document.getElementById('edit-dialog').open`, &dialogOpen)); err != nil { - t.Fatalf("Read dialog state failed: %v", err) - } - if !dialogOpen { - t.Error("Dialog should remain open after invalid submit") - } - var errorText string - if err := chromedp.Run(ctx, chromedp.Evaluate(`(() => { const s = document.querySelector('dialog#edit-dialog small'); return s ? s.textContent.trim() : ""; })()`, &errorText)); err != nil { - t.Fatalf("Read error text failed: %v", err) - } - if errorText == "" { - t.Error("Expected a field error message inside the dialog, found none") - } - }) - - t.Run("Submit_Valid_Form_Closes_Dialog_And_Updates_State", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Clear(`dialog#edit-dialog input[name="name"]`, chromedp.ByQuery), - chromedp.SendKeys(`dialog#edit-dialog input[name="name"]`, "Grace Hopper", chromedp.ByQuery), - chromedp.Click(`dialog#edit-dialog button[type="submit"]`, chromedp.ByQuery), - e2etest.WaitForText(`output[data-flash="success"]`, "Profile saved", 5*time.Second), - e2etest.WaitFor(`document.getElementById('edit-dialog').open === false`, 5*time.Second), - ) - if err != nil { - t.Fatalf("Valid form submit did not produce success flash + dialog close: %v", err) - } - var bodyText string - if err := chromedp.Run(ctx, chromedp.Evaluate(`document.body.textContent`, &bodyText)); err != nil { - t.Fatalf("Read body text failed: %v", err) - } - if !strings.Contains(bodyText, "Grace Hopper") { - t.Error("Saved Name 'Grace Hopper' not visible in page text") - } - // Re-open the dialog and verify the form input now reflects the saved - // state (the value="{{.Name}}" template expression should have rerendered). - var nameValue string - err = chromedp.Run(ctx, - chromedp.Click(`button[commandfor="edit-dialog"][command="show-modal"]`, chromedp.ByQuery), - e2etest.WaitFor(`document.getElementById('edit-dialog').open === true`, 5*time.Second), - chromedp.Value(`dialog#edit-dialog input[name="name"]`, &nameValue, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Re-open dialog to verify form state failed: %v", err) - } - if nameValue != "Grace Hopper" { - t.Errorf("Form input not repopulated from saved state; got %q, want %q", nameValue, "Grace Hopper") - } - }) - - t.Run("Open_Via_Hash_Link", func(t *testing.T) { - // Reset to a clean URL first (no #hash), then click the hash anchor. - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`a[href="#edit-dialog"]`, chromedp.ByQuery), - chromedp.Click(`a[href="#edit-dialog"]`, chromedp.ByQuery), - e2etest.WaitFor(`document.getElementById('edit-dialog').open === true`, 5*time.Second), - ) - if err != nil { - t.Fatalf("Open via hash link failed: %v", err) - } - var hash string - if err := chromedp.Run(ctx, chromedp.Evaluate(`location.hash`, &hash)); err != nil { - t.Fatalf("Read hash failed: %v", err) - } - if hash != "#edit-dialog" { - t.Errorf("Expected #edit-dialog after hash-link click, got %q", hash) - } - }) - - t.Run("Browser_Back_Closes_Dialog", func(t *testing.T) { - err := chromedp.Run(ctx, - e2etest.WaitFor(`document.getElementById('edit-dialog').open === true`, 3*time.Second), - chromedp.Evaluate(`history.back()`, nil), - e2etest.WaitFor(`document.getElementById('edit-dialog').open === false`, 5*time.Second), - ) - if err != nil { - t.Fatalf("Browser Back did not close the dialog: %v", err) - } - }) - - runStandardSubtests(t, ctx, false, "Modal Dialog β€” page heading, profile summary, an 'Edit profile' button, and an 'Open via URL hash' secondary link. The dialog itself is closed at this point.") -} - -// --- Pattern #18: Confirm Dialog --- - -func TestConfirmDialog(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/navigation/confirm-dialog" - - t.Run("Initial_Load", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`button[commandfor="confirm-1"]`, chromedp.ByQuery), - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - ) - if err != nil { - t.Fatalf("Initial load failed: %v", err) - } - var rowCount int - if err := chromedp.Run(ctx, chromedp.Evaluate(`document.querySelectorAll('tbody tr[data-key]').length`, &rowCount)); err != nil { - t.Fatalf("Row count read failed: %v", err) - } - if rowCount != confirmDialogItemCount { - t.Errorf("Expected %d rows, got %d", confirmDialogItemCount, rowCount) - } - }) - - t.Run("Open_Specific_Item_Confirm", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Click(`button[commandfor="confirm-2"][command="show-modal"]`, chromedp.ByQuery), - e2etest.WaitFor(`document.getElementById('confirm-2').open === true`, 5*time.Second), - ) - if err != nil { - t.Fatalf("Open confirm-2 failed: %v", err) - } - var otherOpen bool - if err := chromedp.Run(ctx, chromedp.Evaluate(`document.getElementById('confirm-1').open || document.getElementById('confirm-3').open`, &otherOpen)); err != nil { - t.Fatalf("Sibling dialog state read failed: %v", err) - } - if otherOpen { - t.Error("Sibling confirm dialogs should remain closed") - } - }) - - t.Run("Cancel_Closes_Without_Deleting", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Click(`dialog#confirm-2 button[command="close"]`, chromedp.ByQuery), - e2etest.WaitFor(`document.getElementById('confirm-2').open === false`, 5*time.Second), - ) - if err != nil { - t.Fatalf("Cancel close failed: %v", err) - } - var rowCount int - if err := chromedp.Run(ctx, chromedp.Evaluate(`document.querySelectorAll('tbody tr[data-key]').length`, &rowCount)); err != nil { - t.Fatalf("Row count read failed: %v", err) - } - if rowCount != confirmDialogItemCount { - t.Errorf("Expected %d rows after cancel, got %d", confirmDialogItemCount, rowCount) - } - }) - - t.Run("Confirm_Deletes_Item", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Click(`button[commandfor="confirm-3"][command="show-modal"]`, chromedp.ByQuery), - e2etest.WaitFor(`document.getElementById('confirm-3').open === true`, 5*time.Second), - chromedp.Click(`dialog#confirm-3 button[name="delete"]`, chromedp.ByQuery), - e2etest.WaitForCount(`tbody tr[data-key]`, confirmDialogItemCount-1, 5*time.Second), - ) - if err != nil { - t.Fatalf("Delete via confirm failed: %v", err) - } - var rowExists bool - if err := chromedp.Run(ctx, chromedp.Evaluate(`!!document.querySelector('tr[data-key="3"]')`, &rowExists)); err != nil { - t.Fatalf("Row existence check failed: %v", err) - } - if rowExists { - t.Error("Row with data-key=3 should be removed after delete") - } - }) - - t.Run("Per_Item_Hash_Link_Opens_Specific_Dialog", func(t *testing.T) { - // confirm-3 was just deleted, so use confirm-1. - err := chromedp.Run(ctx, - chromedp.Navigate(url+"#confirm-1"), - e2etest.WaitForWebSocketReady(5*time.Second), - e2etest.WaitFor(`document.getElementById('confirm-1') && document.getElementById('confirm-1').open === true`, 5*time.Second), - ) - if err != nil { - t.Fatalf("Direct hash-link did not open confirm-1: %v", err) - } - }) - - runStandardSubtests(t, ctx, false, "Confirm Dialog β€” page heading, table of items each with a Delete button, and one open dialog showing 'Delete \"\"?' confirmation prompt with Cancel and Delete buttons.") -} - -// --- Pattern #19: Tabs (HATEOAS) --- - -func TestTabs(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/navigation/tabs" - - t.Run("Default_Tab_Is_Overview", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`a[href="?tab=overview"][aria-current="page"]`, chromedp.ByQuery), - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - ) - if err != nil { - t.Fatalf("Default tab not Overview: %v", err) - } - }) - - t.Run("Click_Settings_Tab_Activates_It", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Click(`a[href="?tab=settings"]`, chromedp.ByQuery), - e2etest.WaitFor(`!!document.querySelector('a[href="?tab=settings"][aria-current="page"]')`, 5*time.Second), - e2etest.WaitForText(`section h4`, "Settings", 3*time.Second), - ) - if err != nil { - t.Fatalf("Settings tab click failed: %v", err) - } - var overviewActive bool - if err := chromedp.Run(ctx, chromedp.Evaluate(`!!document.querySelector('a[href="?tab=overview"][aria-current="page"]')`, &overviewActive)); err != nil { - t.Fatalf("Overview state read failed: %v", err) - } - if overviewActive { - t.Error("Overview tab should no longer be active after Settings click") - } - }) - - t.Run("Tab_Switch_Uses_WebSocket_Not_HTTP", func(t *testing.T) { - // Override window.fetch to count HTTP requests to the tabs URL. - // The __navigate__ in-band path must not trigger any. t.Cleanup - // guarantees the restore even if a chromedp step fails mid-flow, - // so a failure here cannot pollute later subtests. - t.Cleanup(func() { - _ = chromedp.Run(ctx, chromedp.Evaluate(`(() => { if (window.__origFetch) { window.fetch = window.__origFetch; delete window.__origFetch; } })()`, nil)) - }) - err := chromedp.Run(ctx, - chromedp.Evaluate(`(() => { - window.__navHttpHits = 0; - window.__origFetch = window.fetch; - window.fetch = function(input, init) { - try { - const u = typeof input === 'string' ? input : input.url; - if (u && u.includes('/patterns/navigation/tabs')) window.__navHttpHits++; - } catch (e) {} - return window.__origFetch.apply(window, arguments); - }; - })()`, nil), - chromedp.Click(`a[href="?tab=activity"]`, chromedp.ByQuery), - e2etest.WaitFor(`!!document.querySelector('a[href="?tab=activity"][aria-current="page"]')`, 5*time.Second), - e2etest.WaitForText(`section h4`, "Activity", 3*time.Second), - ) - if err != nil { - t.Fatalf("Activity tab click failed: %v", err) - } - var hits int - if err := chromedp.Run(ctx, chromedp.Evaluate(`window.__navHttpHits`, &hits)); err != nil { - t.Fatalf("HTTP hit count read failed: %v", err) - } - if hits != 0 { - t.Errorf("Same-pathname tab switch should use WebSocket __navigate__, not HTTP fetch (got %d HTTP hits)", hits) - } - }) - - t.Run("Direct_URL_Load_Activates_Tab", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Navigate(url+"?tab=settings"), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`a[href="?tab=settings"][aria-current="page"]`, chromedp.ByQuery), - e2etest.WaitForText(`section h4`, "Settings", 3*time.Second), - ) - if err != nil { - t.Fatalf("Direct URL load with ?tab=settings failed: %v", err) - } - }) - - t.Run("Invalid_Tab_Falls_Back_To_Default", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Navigate(url+"?tab=garbage"), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`a[href="?tab=overview"][aria-current="page"]`, chromedp.ByQuery), - ) - if err != nil { - t.Fatalf("Invalid tab fallback failed: %v", err) - } - }) - - runStandardSubtests(t, ctx, false, "Tabs (HATEOAS) β€” page heading, three-tab nav with the Overview tab marked active, and an Overview content section beneath.") -} - -// --- Pattern #20: SPA Navigation --- - -func TestSPANavigation(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/navigation/spa-navigation" - - t.Run("Initial_Load", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`a[href="?step=1"]`, chromedp.ByQuery), - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - e2etest.WaitForText(`section p strong`, "Step 1 of 3", 3*time.Second), - ) - if err != nil { - t.Fatalf("Initial load failed: %v", err) - } - }) - - t.Run("Same_Pathname_Step_Update_No_HTTP", func(t *testing.T) { - t.Cleanup(func() { - _ = chromedp.Run(ctx, chromedp.Evaluate(`(() => { if (window.__origFetchSPA) { window.fetch = window.__origFetchSPA; delete window.__origFetchSPA; } })()`, nil)) - }) - err := chromedp.Run(ctx, - chromedp.Evaluate(`(() => { - window.__spaHttpHits = 0; - window.__origFetchSPA = window.fetch; - window.fetch = function(input, init) { - try { - const u = typeof input === 'string' ? input : input.url; - if (u && u.includes('/patterns/navigation/spa-navigation')) window.__spaHttpHits++; - } catch (e) {} - return window.__origFetchSPA.apply(window, arguments); - }; - })()`, nil), - chromedp.Click(`a[href="?step=2"]`, chromedp.ByQuery), - e2etest.WaitForText(`section p strong`, "Step 2 of 3", 5*time.Second), - ) - if err != nil { - t.Fatalf("Step-2 click failed: %v", err) - } - var hits int - if err := chromedp.Run(ctx, chromedp.Evaluate(`window.__spaHttpHits`, &hits)); err != nil { - t.Fatalf("HTTP hit count read failed: %v", err) - } - if hits != 0 { - t.Errorf("Same-pathname step update should use WebSocket __navigate__, got %d HTTP hits", hits) - } - }) - - t.Run("External_Link_Has_No_Intercept_Attribute", func(t *testing.T) { - var hasAttr bool - err := chromedp.Run(ctx, - chromedp.Evaluate(`!!document.querySelector('a[href="https://example.com"][lvt-nav\\:no-intercept]')`, &hasAttr), - ) - if err != nil { - t.Fatalf("External link attribute check failed: %v", err) - } - if !hasAttr { - t.Error("External example.com link must carry lvt-nav:no-intercept opt-out attribute") - } - }) - - t.Run("Step_3_Direct_URL_Activates", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Navigate(url+"?step=3"), - e2etest.WaitForWebSocketReady(5*time.Second), - e2etest.WaitForText(`section p strong`, "Step 3 of 3", 5*time.Second), - ) - if err != nil { - t.Fatalf("Direct ?step=3 load failed: %v", err) - } - }) - - t.Run("Out_Of_Range_Step_Falls_Back_To_Default", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Navigate(url+"?step=99"), - e2etest.WaitForWebSocketReady(5*time.Second), - e2etest.WaitForText(`section p strong`, "Step 1 of 3", 5*time.Second), - ) - if err != nil { - t.Fatalf("Out-of-range ?step= did not fall back to Step 1: %v", err) - } - }) - - runStandardSubtests(t, ctx, false, "SPA Navigation β€” page heading and three sections: same-pathname step nav with Step indicator, cross-pathname links to other patterns, and an external link section.") -} - -// --- Pattern #21: Keyboard Shortcuts --- - -func TestKeyboardShortcuts(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/navigation/keyboard-shortcuts" - - t.Run("Initial_Load", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`button[name="open"]`, chromedp.ByQuery), - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - ) - if err != nil { - t.Fatalf("Initial load failed: %v", err) - } - }) - - t.Run("Open_Button_Click_Opens_Panel", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Click(`button[name="open"]`, chromedp.ByQuery), - e2etest.WaitForText(`h4`, "Command Panel", 5*time.Second), - chromedp.Click(`button[name="close"]`, chromedp.ByQuery), - e2etest.WaitForText(`button`, "Open panel", 5*time.Second), - ) - if err != nil { - t.Fatalf("Open-button Tier-1 fallback failed: %v", err) - } - }) - - t.Run("Slash_Key_Opens_Panel", func(t *testing.T) { - // chromedp.KeyEvent delivers to the focused element; lvt-on:window:keydown - // listens at the window, so we dispatch a synthetic event there. - err := chromedp.Run(ctx, - chromedp.Evaluate(`window.dispatchEvent(new KeyboardEvent('keydown', {key: '/', bubbles: true}))`, nil), - e2etest.WaitForText(`h4`, "Command Panel", 5*time.Second), - e2etest.WaitFor(`(() => { - const items = document.querySelectorAll('ul li small'); - return Array.from(items).some(el => (el.textContent || "").includes("Opened panel")); - })()`, 3*time.Second), - ) - if err != nil { - var html string - _ = chromedp.Run(ctx, chromedp.OuterHTML(`body`, &html, chromedp.ByQuery)) - t.Fatalf("Slash key did not open panel: %v\nrendered body:\n%s", err, html) - } - }) - - t.Run("Escape_Closes_Panel", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Evaluate(`window.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape', bubbles: true}))`, nil), - e2etest.WaitForText(`button`, "Open panel", 5*time.Second), - ) - if err != nil { - t.Fatalf("Escape did not close panel: %v", err) - } - var logHasClose bool - // `ul li small` matches the layout's category breadcrumb too, so - // scan all matches for the "Closed panel" entry rather than relying - // on the first match. - if err := chromedp.Run(ctx, chromedp.Evaluate(`Array.from(document.querySelectorAll('ul li small')).some(el => (el.textContent || "").includes('Closed panel'))`, &logHasClose)); err != nil { - t.Fatalf("Log read failed: %v", err) - } - if !logHasClose { - t.Error("Activity log should contain a 'Closed panel' entry") - } - }) - - t.Run("Tier1_Form_Fallback_Works", func(t *testing.T) { - // Re-open via /, then close via the in-panel form button (which - // works without keyboard or JS as a Tier-1 fallback). - err := chromedp.Run(ctx, - chromedp.Evaluate(`window.dispatchEvent(new KeyboardEvent('keydown', {key: '/', bubbles: true}))`, nil), - e2etest.WaitForText(`h4`, "Command Panel", 5*time.Second), - chromedp.Click(`button[name="close"]`, chromedp.ByQuery), - e2etest.WaitForText(`button`, "Open panel", 5*time.Second), - ) - if err != nil { - t.Fatalf("Tier-1 form fallback close failed: %v", err) - } - }) - - runStandardSubtests(t, ctx, false, "Keyboard Shortcuts β€” page heading with shortcut hints (kbd elements for / and Escape), an 'Open panel' button when closed, and an Activity log with recent open/close entries.") -} - -func TestAnimations(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/feedback/animations" - - t.Run("Initial_Load", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`select[name="mode"]`, chromedp.ByQuery), - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - ) - if err != nil { - t.Fatalf("Initial load failed: %v", err) - } - }) - - t.Run("Add_Plays_Fade_Animation", func(t *testing.T) { - // Click Add with default mode "fade". The directive sets - // element.style.animation = "lvt-fade-in 500ms ease-out" on the new - // row. The keystone assertion is "directive applied the keyframe" - // β€” cleanup is verified by the Existing_Rows subtest below, which - // asserts items 1 and 2 have no inline style after a third add. - var anim string - err := chromedp.Run(ctx, - chromedp.Click(`button[name="add"]`, chromedp.ByQuery), - e2etest.WaitFor(`!!document.querySelector('li[data-key="item-1"]')`, 3*time.Second), - chromedp.Evaluate(`document.querySelector('li[data-key="item-1"]').style.animation`, &anim), - ) - if err != nil { - t.Fatalf("Add did not produce item-1: %v", err) - } - if !strings.Contains(anim, "lvt-fade-in") { - t.Errorf("expected style.animation to contain 'lvt-fade-in', got %q", anim) - } - }) - - t.Run("Mode_Switch_Affects_New_Rows", func(t *testing.T) { - // Switch select to "slide" via DOM (the value is form-submitted with - // Add). The next row's `lvt-fx:animate="{{$.Mode}}"` resolves to slide, - // so style.animation should contain lvt-slide-in. After the render, - // the select must still show "slide" (server state echoed via the - // `selected` attribute). - var anim, selectVal string - err := chromedp.Run(ctx, - chromedp.SetValue(`select[name="mode"]`, "slide", chromedp.ByQuery), - chromedp.Click(`button[name="add"]`, chromedp.ByQuery), - e2etest.WaitFor(`!!document.querySelector('li[data-key="item-2"]')`, 3*time.Second), - chromedp.Evaluate(`document.querySelector('li[data-key="item-2"]').style.animation`, &anim), - chromedp.Evaluate(`document.querySelector('select[name="mode"]').value`, &selectVal), - ) - if err != nil { - t.Fatalf("Slide-mode add failed: %v", err) - } - if !strings.Contains(anim, "lvt-slide-in") { - t.Errorf("expected style.animation to contain 'lvt-slide-in', got %q", anim) - } - if selectVal != "slide" { - t.Errorf("mode select did not retain 'slide' across re-render, got %q", selectVal) - } - }) - - t.Run("Existing_Rows_Do_Not_Re_animate", func(t *testing.T) { - // Wait for item-2 animation to complete, then add item-3. The WeakSet - // guard in directives.ts must prevent items 1 and 2 from re-animating. - // Note: the 3s timeout assumes the default 500ms `--lvt-animate-duration`. - // If a future page overrides that variable to >3s, this test will flake. - var item1Style, item2Style string - err := chromedp.Run(ctx, - e2etest.WaitFor(`document.querySelector('li[data-key="item-2"]').style.animation === ""`, 5*time.Second), - chromedp.Click(`button[name="add"]`, chromedp.ByQuery), - e2etest.WaitFor(`!!document.querySelector('li[data-key="item-3"]')`, 3*time.Second), - chromedp.Evaluate(`document.querySelector('li[data-key="item-1"]').style.animation || ""`, &item1Style), - chromedp.Evaluate(`document.querySelector('li[data-key="item-2"]').style.animation || ""`, &item2Style), - ) - if err != nil { - t.Fatalf("Re-animate guard test setup failed: %v", err) - } - if item1Style != "" { - t.Errorf("item-1 style.animation should be empty after second add (WeakSet guard), got %q", item1Style) - } - if item2Style != "" { - t.Errorf("item-2 style.animation should be empty after third add (WeakSet guard), got %q", item2Style) - } - // Wait for item-3 to finish before standard subtests so they don't - // see a transient inline-style on a [data-key] element. - _ = chromedp.Run(ctx, e2etest.WaitFor(`document.querySelector('li[data-key="item-3"]').style.animation === ""`, 5*time.Second)) - }) - - runStandardSubtests(t, ctx, true, "Animations pattern β€” heading, a row with a mode select (fade/slide/scale) and Add Item button, and a list of three added items each labeled with its mode.") -} - -func TestLoadingStates(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/feedback/loading-states" - - t.Run("Initial_Load", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`section:nth-of-type(1) button[name="slowSave"]`, chromedp.ByQuery), - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - ) - if err != nil { - t.Fatalf("Initial load failed: %v", err) - } - }) - - t.Run("Tier1_Fieldset_Disabled_During_Pending", func(t *testing.T) { - // Submit Tier 1 form with typed input. While the action is in flight - // (2s sleep), the fieldset should carry `disabled` and the form - // should be aria-busy. After completion, both reset AND the input - // auto-clears (default form-reset behavior; would need `lvt-form:preserve` - // to retain). - var inputValue string - err := chromedp.Run(ctx, - chromedp.SendKeys(`section:nth-of-type(1) input[name="data"]`, "hello", chromedp.ByQuery), - chromedp.Click(`section:nth-of-type(1) button[name="slowSave"]`, chromedp.ByQuery), - e2etest.WaitFor(`document.querySelector('section:nth-of-type(1) fieldset').disabled === true`, 1*time.Second), - e2etest.WaitFor(`document.querySelector('section:nth-of-type(1) form').getAttribute('aria-busy') === 'true'`, 1*time.Second), - e2etest.WaitFor(`document.querySelector('section:nth-of-type(1) fieldset').disabled === false`, 8*time.Second), - chromedp.Evaluate(`document.querySelector('section:nth-of-type(1) input[name="data"]').value`, &inputValue), - ) - if err != nil { - t.Fatalf("Tier 1 fieldset auto-disable failed: %v", err) - } - if inputValue != "" { - t.Errorf("Tier 1 input did not auto-reset after submit, got %q", inputValue) - } - }) - - t.Run("DisableWith_Replaces_Button_Text", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Click(`section:nth-of-type(2) button[name="slowSave"]`, chromedp.ByQuery), - e2etest.WaitForText(`section:nth-of-type(2) button[name="slowSave"]`, "Saving", 1*time.Second), - e2etest.WaitForText(`section:nth-of-type(2) button[name="slowSave"]`, "Save", 5*time.Second), - ) - if err != nil { - t.Fatalf("disable-with text replacement failed: %v", err) - } - }) - - t.Run("SetAttr_Pending_Sets_AriaBusy", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Click(`section:nth-of-type(3) button[name="slowSave"]`, chromedp.ByQuery), - e2etest.WaitFor(`document.querySelector('section:nth-of-type(3) button[name="slowSave"]').getAttribute('aria-busy') === 'true'`, 1*time.Second), - e2etest.WaitFor(`document.querySelector('section:nth-of-type(3) button[name="slowSave"]').getAttribute('aria-busy') === 'false'`, 5*time.Second), - ) - if err != nil { - t.Fatalf("setAttr:on:pending toggle failed: %v", err) - } - }) - - t.Run("Submit_Updates_LastSave", func(t *testing.T) { - // Multiple elements on the page (breadcrumb + section descriptions), - // so check the document body's text. - err := chromedp.Run(ctx, - e2etest.WaitForText(`body`, "Last save:", 8*time.Second), - ) - if err != nil { - t.Fatalf("Last save indicator never appeared: %v", err) - } - }) - - runStandardSubtests(t, ctx, true, "Loading States pattern β€” three sections (Tier 1 automatic, Tier 2 disable-with, Tier 2 setAttr) each with an input and Save button, plus a 'Last save:' timestamp below.") -} - -func TestHighlightOnChange(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/feedback/highlight" - - t.Run("Initial_Load", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`button[name="increment"]`, chromedp.ByQuery), - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - ) - if err != nil { - t.Fatalf("Initial load failed: %v", err) - } - }) - - // UI_Standards is intentionally NOT run for this pattern. The highlight - // directive's whole job is to add an inline background-color and - // transition for the visual flash, which fires on every render walk that - // touches the subtree β€” including the first paint. Even with the - // post-cycle attribute cleanup landed in client v0.8.37 - // (livetemplate/client#100), the in-flight state during the ~550ms cycle - // still has a non-empty `style` attribute that the [style] CSP rule would - // flag if UI_Standards happened to sample mid-cycle. The "no inline - // styles" rule isn't a meaningful guarantee for a pattern whose entire - // premise is inline styling β€” Visual_Check plus the interaction subtests - // below cover the right behavior. - - t.Run("Increment_Flashes_Both_Highlight_Targets", func(t *testing.T) { - // directives.ts sets style.transition for ~550ms per render-touch. - // The transition assertion has a wider polling window than the - // bg-color one (which clears at 50ms) and is just as load-bearing - // β€” the transition IS the visual flash. Use Array.from to count - // only the inner highlight cards (page wrappers don't carry the - // directive). The 5s WaitFor budget is generous; with the lvt - // chrome-throttling fix (livetemplate/lvt#314, v0.1.4) the polled - // approach reliably lands inside the directive's window. - // - // Wait for any pending highlight cycle from the initial page render - // to clear before clicking. The directive runs FIRE-ON-CHANGE on - // every render including the initial one, and the rate-limit guard - // (`__lvtHighlighting`) coalesces overlapping triggers β€” so a click - // landing inside the initial cycle gets silently skipped, leaving - // the test polling for a transition that never gets set. This is - // the documented coalesce behavior in directives.ts:162-164, not a - // bug; the test just has to wait it out. - err := chromedp.Run(ctx, - e2etest.WaitFor(`(() => { - const els = Array.from(document.querySelectorAll('[lvt-fx\\:highlight]')); - return els.every(el => (el.style.transition || "") === ''); - })()`, 2*time.Second), - chromedp.Click(`button[name="increment"]`, chromedp.ByQuery), - e2etest.WaitFor(`(() => { - const els = Array.from(document.querySelectorAll('[lvt-fx\\:highlight]')); - if (els.length < 2) return false; - return els.every(el => (el.style.transition || "").includes('background-color')); - })()`, 5*time.Second), - ) - if err != nil { - t.Fatalf("Highlight transition not applied to both targets: %v", err) - } - }) - - t.Run("Highlight_Cleans_Up_After_Duration", func(t *testing.T) { - // directives.ts highlight cycle: 50ms delay + 500ms transition. The - // WaitFor polls until both bg + transition are clear, with a 5s - // budget that comfortably covers the 550ms cycle plus any - // re-render flapping (e.g. flash-expiry nudges from prior subtests). - err := chromedp.Run(ctx, - e2etest.WaitFor(`(() => { - const els = Array.from(document.querySelectorAll('[lvt-fx\\:highlight]')); - return els.every(el => el.style.backgroundColor === '' && el.style.transition === ''); - })()`, 5*time.Second), - ) - if err != nil { - t.Fatalf("Highlight did not clean up: %v", err) - } - }) - - t.Run("Counter_Increments_On_Both_Mirrors", func(t *testing.T) { - // Counter is at 1 from the prior `Increment_Flashes_Both_Highlight_Targets` - // subtest; this click brings it to 2. Both mirrors must reflect the - // shared `.Counter` value. - err := chromedp.Run(ctx, - chromedp.Click(`button[name="increment"]`, chromedp.ByQuery), - e2etest.WaitForText(`body`, "Counter A: 2", 3*time.Second), - e2etest.WaitForText(`body`, "Counter B (mirror): 2", 3*time.Second), - ) - if err != nil { - t.Fatalf("Counter increment not reflected on mirrors: %v", err) - } - }) - - t.Run("Visual_Check", func(t *testing.T) { - e2etest.ValidateScreenshotWithLLM(t, ctx, "Highlight on Change pattern β€” heading, an Increment button, and two cards 'Counter A' and 'Counter B (mirror)' both showing the same number 2.") - }) -} - -func TestFlashMessages(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/feedback/flash-messages" - - t.Run("Initial_Load", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`button[name="save"]`, chromedp.ByQuery), - e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), - ) - if err != nil { - t.Fatalf("Initial load failed: %v", err) - } - }) - - t.Run("Empty_Save_Shows_Error_Flash", func(t *testing.T) { - err := chromedp.Run(ctx, - chromedp.Click(`button[name="save"]`, chromedp.ByQuery), - e2etest.WaitForText(`output[data-flash="error"]`, "Name is required", 3*time.Second), - ) - if err != nil { - t.Fatalf("Empty save did not surface error flash: %v", err) - } - }) - - t.Run("Valid_Save_Shows_Success_Clears_Error", func(t *testing.T) { - var nameValue string - err := chromedp.Run(ctx, - chromedp.SendKeys(`input[name="name"]`, "Ada", chromedp.ByQuery), - chromedp.Click(`button[name="save"]`, chromedp.ByQuery), - e2etest.WaitForText(`output[data-flash="success"]`, "Saved: Ada", 3*time.Second), - // Error flash should be gone (ClearFlash("error") in the controller). - e2etest.WaitFor(`!document.querySelector('output[data-flash="error"]')`, 3*time.Second), - // Form auto-resets on success β€” the name input must be cleared. - chromedp.Evaluate(`document.querySelector('input[name="name"]').value`, &nameValue), - ) - if err != nil { - t.Fatalf("Valid save did not clear error / set success: %v", err) - } - if nameValue != "" { - t.Errorf("name input did not auto-reset after successful save, got %q", nameValue) - } - }) - - t.Run("Notify_Persists_Until_Dismiss", func(t *testing.T) { - // 2s idle is enough to prove persistence β€” info has no FlashExpiry, - // so any prune timer would have fired by now if one existed. Wall- - // clock waits like this can't be condition-based (proving absence- - // of-change), but 2s is comfortably below the success-flash 5s - // expiry from the previous subtest. - err := chromedp.Run(ctx, - chromedp.Click(`button[name="notify"]`, chromedp.ByQuery), - e2etest.WaitForText(`output[data-flash="info"]`, "Heads up", 3*time.Second), - chromedp.Sleep(2*time.Second), - e2etest.WaitFor(`!!document.querySelector('output[data-flash="info"]')`, 1*time.Second), - chromedp.Click(`button[name="dismissNotify"]`, chromedp.ByQuery), - e2etest.WaitFor(`!document.querySelector('output[data-flash="info"]')`, 3*time.Second), - ) - if err != nil { - t.Fatalf("Notifyβ†’Dismiss lifecycle failed: %v", err) - } - }) - - t.Run("Success_AutoExpires_After_FlashExpiry", func(t *testing.T) { - // FlashExpiry is render-driven: pruneExpiredFlash runs inside - // getMessages before the snapshot, so the next render after the - // deadline ships clean HTML. Wait past the expiry, click Notify - // to trigger a render, and assert success is gone in the same - // response that introduces info. - err := chromedp.Run(ctx, - chromedp.Evaluate(`document.querySelector('input[name="name"]').value = ""`, nil), - chromedp.SendKeys(`input[name="name"]`, "Bob", chromedp.ByQuery), - chromedp.Click(`button[name="save"]`, chromedp.ByQuery), - e2etest.WaitForText(`output[data-flash="success"]`, "Saved: Bob", 3*time.Second), - ) - if err != nil { - t.Fatalf("Could not seed a success flash: %v", err) - } - // chromedp.Sleep is unavoidable here β€” we're literally waiting on - // wall-clock for the FlashExpiry deadline to elapse. Sleep past 5s - // (the expiry duration), then click Notify to trigger a render. - err = chromedp.Run(ctx, - chromedp.Sleep(5500*time.Millisecond), - chromedp.Click(`button[name="notify"]`, chromedp.ByQuery), - e2etest.WaitForText(`output[data-flash="info"]`, "Heads up", 3*time.Second), - e2etest.WaitFor(`!document.querySelector('output[data-flash="success"]')`, 3*time.Second), - ) - if err != nil { - t.Fatalf("Success flash did not auto-expire after FlashExpiry: %v", err) - } - }) - - // pico=false: page uses {{.lvt.FlashTag}}, which renders ; - // the Pico validator (chrome.go:967) prefers /, but this pattern - // IS the FlashTag demo, so the standard subtests use the non-Pico variant. - runStandardSubtests(t, ctx, false, "Flash Messages pattern β€” heading, two forms (one with a Name input + Save, one with Notify and Dismiss buttons), and an info flash 'Heads up β€” this stays until you dismiss it' visible.") -} - -// --- Pattern #26: Multi-User Sync --- - -func TestMultiUserSync(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/realtime/multi-user-sync" - - if err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`button[name="increment"]`, chromedp.ByQuery), - ); err != nil { - t.Fatalf("Tab 1 initial load failed: %v", err) - } - - // chromedp.NewContext(parent) where parent is a chromedp context creates - // a NEW TAB in the same browser. Cookies and storage are shared, so both - // tabs land in the same session group β€” the prerequisite for Sync() - // auto-dispatch (mount.go:1466-1468) to fire across them. - peerCtx, peerCancel := chromedp.NewContext(ctx) - defer peerCancel() - if err := chromedp.Run(peerCtx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`button[name="increment"]`, chromedp.ByQuery), - ); err != nil { - t.Fatalf("Peer tab initial load failed: %v", err) - } - - t.Run("Increment_Tab1_Updates_Both", func(t *testing.T) { - if err := chromedp.Run(ctx, - chromedp.Click(`button[name="increment"]`, chromedp.ByQuery), - e2etest.WaitForText(`article`, "Counter: 1", 3*time.Second), - ); err != nil { - t.Fatalf("Tab 1 did not reflect Counter: 1: %v", err) - } - // Peer must see the same value via Sync auto-dispatch β€” Increment - // did NOT call BroadcastAction; Sync fires unconditionally because - // HasSync && !syncExplicitlyBroadcast at mount.go:1466. - if err := chromedp.Run(peerCtx, - e2etest.WaitForText(`article`, "Counter: 1", 3*time.Second), - ); err != nil { - t.Fatalf("Peer did not pick up Counter: 1 from Sync auto-dispatch: %v", err) - } - }) - - t.Run("Increment_Tab2_Updates_Both", func(t *testing.T) { - if err := chromedp.Run(peerCtx, - chromedp.Click(`button[name="increment"]`, chromedp.ByQuery), - e2etest.WaitForText(`article`, "Counter: 2", 3*time.Second), - ); err != nil { - t.Fatalf("Peer did not reflect Counter: 2 after its own click: %v", err) - } - if err := chromedp.Run(ctx, - e2etest.WaitForText(`article`, "Counter: 2", 3*time.Second), - ); err != nil { - t.Fatalf("Tab 1 did not pick up Counter: 2 from peer Sync: %v", err) - } - }) - - t.Run("Late_Joiner_Sees_Current_Counter_On_Mount", func(t *testing.T) { - // Counter is at 2 from the prior subtests. A new tab opening - // AFTER the increments must see 2 immediately on its initial - // render β€” not 0 with a wait for the next peer action's Sync. - // This guards the MultiUserSyncController.Mount() call (without - // it, the late joiner would render Counter:0 from zero-value - // state until a peer action fired Sync). - lateCtx, lateCancel := chromedp.NewContext(ctx) - defer lateCancel() - if err := chromedp.Run(lateCtx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`button[name="increment"]`, chromedp.ByQuery), - e2etest.WaitForText(`article`, "Counter: 2", 3*time.Second), - ); err != nil { - t.Fatalf("Late-joining tab did not see Counter: 2 on mount: %v", err) - } - }) - - runStandardSubtests(t, ctx, true, "Multi-User Sync pattern β€” heading, a paragraph 'Counter: 2', and an Increment button. Layout is centered with Pico styling.") -} - -// --- Pattern #27: Broadcasting --- - -func TestBroadcasting(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/realtime/broadcasting" - - // Tab 1 Joins as Alice. - if err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`input[name="username"]`, chromedp.ByQuery), - chromedp.SendKeys(`input[name="username"]`, "Alice", chromedp.ByQuery), - chromedp.Click(`button[name="join"]`, chromedp.ByQuery), - chromedp.WaitVisible(`button[name="send"]`, chromedp.ByQuery), - ); err != nil { - t.Fatalf("Tab 1 join failed: %v", err) - } - - // Peer tab Joins as Bob. Username is intentionally NOT lvt:"persist" - // (state_realtime.go) so the second tab gets its own join form even - // though it shares the session-group cookie with tab 1. - peerCtx, peerCancel := chromedp.NewContext(ctx) - defer peerCancel() - if err := chromedp.Run(peerCtx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`input[name="username"]`, chromedp.ByQuery), - chromedp.SendKeys(`input[name="username"]`, "Bob", chromedp.ByQuery), - chromedp.Click(`button[name="join"]`, chromedp.ByQuery), - chromedp.WaitVisible(`button[name="send"]`, chromedp.ByQuery), - ); err != nil { - t.Fatalf("Peer tab join failed: %v", err) - } - - t.Run("Send_From_Tab1_Appears_In_Peer", func(t *testing.T) { - var textVal string - if err := chromedp.Run(ctx, - chromedp.SendKeys(`input[name="text"]`, "hi from Alice", chromedp.ByQuery), - chromedp.Click(`button[name="send"]`, chromedp.ByQuery), - e2etest.WaitForText(`div.messages`, "hi from Alice", 3*time.Second), - // CLAUDE.md E2E #4: assert form fields cleared after submit. - // The compose form has no lvt-form:preserve, so the text input - // resets to empty after a successful Send re-renders the form. - chromedp.Evaluate(`document.querySelector('input[name="text"]').value`, &textVal), - ); err != nil { - t.Fatalf("Tab 1 did not see its own message: %v", err) - } - if textVal != "" { - t.Errorf("text input did not reset after Send, got %q", textVal) - } - if err := chromedp.Run(peerCtx, - e2etest.WaitForText(`div.messages`, "hi from Alice", 3*time.Second), - ); err != nil { - t.Fatalf("Peer did not receive broadcast from tab 1: %v", err) - } - }) - - t.Run("Send_From_Peer_Appears_In_Tab1", func(t *testing.T) { - var textVal string - if err := chromedp.Run(peerCtx, - chromedp.SendKeys(`input[name="text"]`, "hi from Bob", chromedp.ByQuery), - chromedp.Click(`button[name="send"]`, chromedp.ByQuery), - e2etest.WaitForText(`div.messages`, "hi from Bob", 3*time.Second), - chromedp.Evaluate(`document.querySelector('input[name="text"]').value`, &textVal), - ); err != nil { - t.Fatalf("Peer did not see its own message: %v", err) - } - if textVal != "" { - t.Errorf("peer text input did not reset after Send, got %q", textVal) - } - if err := chromedp.Run(ctx, - e2etest.WaitForText(`div.messages`, "hi from Bob", 3*time.Second), - ); err != nil { - t.Fatalf("Tab 1 did not receive broadcast from peer: %v", err) - } - }) - - t.Run("Empty_Send_Appends_Nothing", func(t *testing.T) { - // Testing "no change after time T" without a wall-clock Sleep: - // fire the empty Send first, then a known-good "guard" Send, and - // wait for the guard message to appear. By the time the guard's - // render lands, the empty Send's no-op response (queued before - // the guard) has been processed too, so any spurious append from - // the empty would already be in the DOM. The final count must be - // baseline + 1 (the guard) β€” anything else means the empty Send - // appended. - var countBefore int - if err := chromedp.Run(ctx, - chromedp.Evaluate(`document.querySelectorAll('div.messages p[data-key]').length`, &countBefore), - ); err != nil { - t.Fatalf("Could not count messages: %v", err) - } - const guardText = "guard message" - var countAfter int - // Both sends go via liveTemplateClient.send rather than the form UI - // because the empty submit's transient pending state races with the - // next click's SendKeys ("Element is not focusable"). This is the - // same idiom TestAsyncOperations.Concurrent_Fetch_Reaches_Single_Result - // uses (patterns_test.go around line 1750) for the analogous - // "two-sends-in-a-row, observe one render" pattern. The behavior - // being tested is the empty-input branch of Send returning no-op - // state β€” that path is exercised identically whether the client - // fires it via form submit or via send(), and the protocol-level - // helper is the only way to avoid the focus race here without a - // wall-clock Sleep. - if err := chromedp.Run(ctx, - chromedp.Evaluate(fmt.Sprintf(`(() => { - window.liveTemplateClient.send({action: 'send', data: {text: ''}}); - window.liveTemplateClient.send({action: 'send', data: {text: %q}}); - })()`, guardText), nil), - e2etest.WaitForText(`div.messages`, guardText, 3*time.Second), - chromedp.Evaluate(`document.querySelectorAll('div.messages p[data-key]').length`, &countAfter), - ); err != nil { - t.Fatalf("Empty/guard send sequence failed: %v", err) - } - if countAfter != countBefore+1 { - t.Errorf("Empty send appended a message: before=%d after=%d (expected before+1=%d for guard only)", countBefore, countAfter, countBefore+1) - } - }) - - t.Run("Empty_Username_Join_Is_NoOp", func(t *testing.T) { - // Targeted protocol test for the Join handler's empty-username - // guard. The HTML `required` attribute on the username input - // stops empty submission via the UI; the server-side guard is - // defense-in-depth for protocol-level clients. Same guard-message - // idiom as Empty_Send_Appends_Nothing: fire empty + guard, wait - // for the guard's effect, conclude empty had no effect. - if err := chromedp.Run(peerCtx, - // Re-navigate to get a fresh join form. Peer was previously - // joined as Bob and sent messages; navigating resets its - // per-connection state so we can test the empty-username path - // against an unjoined client. - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`input[name="username"]`, chromedp.ByQuery), - chromedp.Evaluate(`(() => { - window.liveTemplateClient.send({action: 'join', data: {username: ''}}); - window.liveTemplateClient.send({action: 'join', data: {username: 'GuardBob'}}); - })()`, nil), - // Guard's Join sets state.Username, swapping the join form - // out for the compose form. If the empty-username send had - // gone through first and set state.Username = "", the swap - // would still happen on the guard β€” but if the empty had - // somehow set Username to "" AFTER the guard, we'd never - // see "Posting as GuardBob". - e2etest.WaitForText(`article`, "Posting as GuardBob", 3*time.Second), - chromedp.WaitVisible(`button[name="send"]`, chromedp.ByQuery), - ); err != nil { - t.Fatalf("Empty/guard join sequence failed: %v", err) - } - }) - - runStandardSubtests(t, ctx, true, "Broadcasting pattern β€” heading, 'Posting as Alice' label, message list with three entries (one from Alice, one from Bob, and a 'guard message' from Alice), and a compose form with a text input + Send button.") -} - -// --- Pattern #28: Presence Tracking --- - -func TestPresence(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/realtime/presence" - - if err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`input[name="username"]`, chromedp.ByQuery), - chromedp.SendKeys(`input[name="username"]`, "Alice", chromedp.ByQuery), - chromedp.Click(`button[name="join"]`, chromedp.ByQuery), - e2etest.WaitForText(`mark`, "1 user(s) online", 3*time.Second), - ); err != nil { - t.Fatalf("Tab 1 Alice join failed: %v", err) - } - - peerCtx, peerCancel := chromedp.NewContext(ctx) - defer peerCancel() - if err := chromedp.Run(peerCtx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`input[name="username"]`, chromedp.ByQuery), - chromedp.SendKeys(`input[name="username"]`, "Bob", chromedp.ByQuery), - chromedp.Click(`button[name="join"]`, chromedp.ByQuery), - e2etest.WaitForText(`mark`, "2 user(s) online", 3*time.Second), - ); err != nil { - t.Fatalf("Peer Bob join failed: %v", err) - } - - t.Run("Tab1_Sees_Two_After_Peer_Joins", func(t *testing.T) { - if err := chromedp.Run(ctx, - e2etest.WaitForText(`mark`, "2 user(s) online", 3*time.Second), - ); err != nil { - t.Fatalf("Tab 1 did not see updated count after peer joined: %v", err) - } - }) - - t.Run("Tab1_Leave_Decrements_Both", func(t *testing.T) { - if err := chromedp.Run(ctx, - chromedp.Click(`button[name="leave"]`, chromedp.ByQuery), - e2etest.WaitForText(`mark`, "1 user(s) online", 3*time.Second), - ); err != nil { - t.Fatalf("Tab 1 leave did not decrement local count: %v", err) - } - if err := chromedp.Run(peerCtx, - e2etest.WaitForText(`mark`, "1 user(s) online", 3*time.Second), - ); err != nil { - t.Fatalf("Peer did not see decrement after tab 1 leave: %v", err) - } - }) - - t.Run("Peer_Leave_Goes_To_Zero", func(t *testing.T) { - if err := chromedp.Run(peerCtx, - chromedp.Click(`button[name="leave"]`, chromedp.ByQuery), - e2etest.WaitForText(`mark`, "0 user(s) online", 3*time.Second), - ); err != nil { - t.Fatalf("Peer leave did not decrement local count: %v", err) - } - if err := chromedp.Run(ctx, - e2etest.WaitForText(`mark`, "0 user(s) online", 3*time.Second), - ); err != nil { - t.Fatalf("Tab 1 did not see final decrement after peer leave: %v", err) - } - }) - - t.Run("Empty_Username_Join_Is_NoOp", func(t *testing.T) { - // Targeted protocol test for Join's empty-username guard. The - // HTML `required` attribute is the UI guard; this test covers - // the server-side defense-in-depth path. Same guard-message - // idiom as Broadcasting: fire empty + guard, wait for the - // guard's effect β€” if empty had been honored, OnlineCount - // would have ticked to 1 before the guard's Join fired and we'd - // never see exactly 1 user. - // - // Run on peerCtx (which is at the join form post-Peer_Leave) and - // have the guard user Leave at the end so ctx ends in the - // canonical "0 user(s) online + join form" state for - // runStandardSubtests' Visual_Check. - if err := chromedp.Run(peerCtx, - chromedp.Evaluate(`(() => { - window.liveTemplateClient.send({action: 'join', data: {username: ''}}); - window.liveTemplateClient.send({action: 'join', data: {username: 'GuardX'}}); - })()`, nil), - e2etest.WaitForText(`mark`, "1 user(s) online", 3*time.Second), - // Cleanup: peer leaves so ctx ends at "0 user(s) online". - chromedp.Click(`button[name="leave"]`, chromedp.ByQuery), - e2etest.WaitForText(`mark`, "0 user(s) online", 3*time.Second), - ); err != nil { - t.Fatalf("Empty/guard join sequence failed: %v", err) - } - if err := chromedp.Run(ctx, - e2etest.WaitForText(`mark`, "0 user(s) online", 3*time.Second), - ); err != nil { - t.Fatalf("ctx did not see GuardX leave broadcast: %v", err) - } - }) - - runStandardSubtests(t, ctx, true, "Presence Tracking pattern β€” heading, a highlighted '0 user(s) online' indicator, and a join form with a username input + Join button.") -} - -// --- Pattern #29: Reconnection Recovery --- - -func TestReconnection(t *testing.T) { - if testing.Short() { - t.Skip("Skipping E2E test in short mode") - } - - ctx, cancel, serverPort := setupTest(t) - defer cancel() - - url := e2etest.GetChromeTestURL(serverPort) + "/patterns/realtime/reconnection" - - if err := chromedp.Run(ctx, - chromedp.Navigate(url), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`button[name="increment"]`, chromedp.ByQuery), - ); err != nil { - t.Fatalf("Initial load failed: %v", err) - } - - t.Run("Counter_And_Notes_Survive_Reload", func(t *testing.T) { - if err := chromedp.Run(ctx, - chromedp.Click(`button[name="increment"]`, chromedp.ByQuery), - e2etest.WaitForText(`article`, "Counter: 1", 3*time.Second), - chromedp.Click(`button[name="increment"]`, chromedp.ByQuery), - e2etest.WaitForText(`article`, "Counter: 2", 3*time.Second), - chromedp.Click(`button[name="increment"]`, chromedp.ByQuery), - e2etest.WaitForText(`article`, "Counter: 3", 3*time.Second), - chromedp.SendKeys(`textarea[name="notes"]`, "persisted hello", chromedp.ByQuery), - chromedp.Click(`button[name="saveNotes"]`, chromedp.ByQuery), - e2etest.WaitFor(`document.querySelector('textarea[name="notes"]').value === "persisted hello"`, 3*time.Second), - ); err != nil { - t.Fatalf("Pre-reload setup failed: %v", err) - } - - // Reload β€” fresh HTTP GET re-mounts via the session-group cookie; - // the framework restores Counter and Notes from the session store - // before the first render. - var notesValue string - if err := chromedp.Run(ctx, - chromedp.Reload(), - e2etest.WaitForWebSocketReady(5*time.Second), - chromedp.WaitVisible(`button[name="increment"]`, chromedp.ByQuery), - e2etest.WaitForText(`article`, "Counter: 3", 3*time.Second), - chromedp.Evaluate(`document.querySelector('textarea[name="notes"]').value`, ¬esValue), - ); err != nil { - t.Fatalf("Reload + restore failed: %v", err) - } - if notesValue != "persisted hello" { - t.Errorf("Notes not restored after reload, got %q", notesValue) - } - }) - - // pico=false: the page uses a vertical labeled - - - - - - {{.lvt.FlashTag "success"}} - {{.lvt.ErrorTag "name"}} - -{{end}} diff --git a/patterns/templates/forms/reset-input.tmpl b/patterns/templates/forms/reset-input.tmpl deleted file mode 100644 index 79b3b1d..0000000 --- a/patterns/templates/forms/reset-input.tmpl +++ /dev/null @@ -1,15 +0,0 @@ -{{define "content"}} -
-

Reset User Input

-

Form clears automatically after successful submission β€” no extra attributes needed.

-
-
- - -
- - {{range .Messages}} -

{{.}}

- {{end}} -
-{{end}} diff --git a/patterns/templates/index.tmpl b/patterns/templates/index.tmpl deleted file mode 100644 index be6e492..0000000 --- a/patterns/templates/index.tmpl +++ /dev/null @@ -1,17 +0,0 @@ -{{define "content"}} -
-

LiveTemplate Patterns

-

31 UI patterns demonstrating progressive complexity

-
-{{range .Categories}} -
-

{{.Name}}

- {{range .Patterns}} -

- {{if .Implemented}}{{.Name}}{{else}}{{.Name}}{{end}} - β€” {{.Description}} -

- {{end}} -
-{{end}} -{{end}} diff --git a/patterns/templates/layout.tmpl b/patterns/templates/layout.tmpl deleted file mode 100644 index bf73d93..0000000 --- a/patterns/templates/layout.tmpl +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - {{if .Title}}{{.Title}} β€” {{end}}LiveTemplate Patterns - - {{if .lvt.DevMode}} - - - {{else}} - - - {{end}} - - -
- - {{template "content" .}} -
- - diff --git a/patterns/templates/lists/click-to-load.tmpl b/patterns/templates/lists/click-to-load.tmpl deleted file mode 100644 index 23e0f6b..0000000 --- a/patterns/templates/lists/click-to-load.tmpl +++ /dev/null @@ -1,25 +0,0 @@ -{{define "content"}} -
-

Click To Load

-

Append-only pagination. Clicking Load More asks the server for the next page; the diff engine appends new rows without redrawing existing ones.

-
- - - {{range .Items}} - - - - - - {{end}} - -
IDNameEmail
{{.ID}}{{.Name}}{{.Email}}
- {{if .HasMore}} -
- -
- {{else}} -

End of list.

- {{end}} -

-{{end}} diff --git a/patterns/templates/lists/delete-row.tmpl b/patterns/templates/lists/delete-row.tmpl deleted file mode 100644 index ee5b5d4..0000000 --- a/patterns/templates/lists/delete-row.tmpl +++ /dev/null @@ -1,30 +0,0 @@ -{{define "content"}} -
-

Delete Row

-

Click Delete on any row. New rows slide in via lvt-fx:animate="slide" (entry-only). Items live in a process-wide in-memory table, so deletions persist across reloads and navigation; use Restore to reset.

- {{if gt (len .Items) 0}} - - - - {{range .Items}} - - - - - - - {{end}} - -
IDNameEmail
{{.ID}}{{.Name}}{{.Email}} -
- -
-
- {{else}} -

All items deleted.

-
- -
- {{end}} -
-{{end}} diff --git a/patterns/templates/lists/infinite-scroll.tmpl b/patterns/templates/lists/infinite-scroll.tmpl deleted file mode 100644 index 1fc630c..0000000 --- a/patterns/templates/lists/infinite-scroll.tmpl +++ /dev/null @@ -1,23 +0,0 @@ -{{define "content"}} -
-

Infinite Scroll

-

A single <div lvt-scroll-sentinel> at the end of the list is watched by the client's IntersectionObserver. When it enters the viewport, the client dispatches load_more automatically β€” no client JS to wire up. Dataset has 100 items; scroll to watch pages append.

- - - - {{range .Items}} - - - - - - {{end}} - -
IDNameEmail
{{.ID}}{{.Name}}{{.Email}}
- {{if .HasMore}} -
Loading more…
- {{else}} -

End of list.

- {{end}} -
-{{end}} diff --git a/patterns/templates/lists/large-table.tmpl b/patterns/templates/lists/large-table.tmpl deleted file mode 100644 index 48ebff7..0000000 --- a/patterns/templates/lists/large-table.tmpl +++ /dev/null @@ -1,55 +0,0 @@ -{{define "content"}} -
-
-

Large Table

-

10,000 rows with stable keys. Filter, sort, append, update, and delete β€” every range op the streaming-range diff emits is reachable here. The server retains per-item hashes (~30 B/row) instead of full TreeNodes (~150 B/row), so 10k rows stay interactive at modest memory cost.

-
- -
-
- -
-
- -

Showing {{len .Items}} of {{.Total}} rows.

- -
-
- - - -
-
- - - - - - - - - - - - - {{range .Items}} - - - - - - - - {{end}} - -
{{.Name}}{{.Email}}{{.Status}}{{.Score}} -
- -
-
- - {{if eq (len .Items) 0}} -

No rows match the filter.

- {{end}} -
-{{end}} diff --git a/patterns/templates/lists/sortable.tmpl b/patterns/templates/lists/sortable.tmpl deleted file mode 100644 index 3399165..0000000 --- a/patterns/templates/lists/sortable.tmpl +++ /dev/null @@ -1,25 +0,0 @@ -{{define "content"}} -
-
-

Sortable List

-

Drag any row to reorder (mouse only; no keyboard path or visual drop-zone highlighting in this demo). The new order persists across reloads. Use Reset Order to restore the initial sequence.

-
-

Use a mouse to drag items. Keyboard reordering is not supported in this demo.

-
    - {{range .Items}} -
  • - {{.Name}} -
  • - {{end}} -
-
- -
-
-{{end}} diff --git a/patterns/templates/lists/value-select.tmpl b/patterns/templates/lists/value-select.tmpl deleted file mode 100644 index b345acc..0000000 --- a/patterns/templates/lists/value-select.tmpl +++ /dev/null @@ -1,27 +0,0 @@ -{{define "content"}} -
-

Value Select

-

Cascading dependent selects. The Change() handler auto-fires when the Make select changes and updates the Model options server-side β€” no client JS.

-
- - -
- {{if .Model}} -

Selected: {{.Make}} {{.Model}}

- {{end}} -
-{{end}} diff --git a/patterns/templates/loading/async-operations.tmpl b/patterns/templates/loading/async-operations.tmpl deleted file mode 100644 index 2c6c8e3..0000000 --- a/patterns/templates/loading/async-operations.tmpl +++ /dev/null @@ -1,21 +0,0 @@ -{{define "content"}} -
-

Async Operations

-

A loading β†’ success / error state machine. ~33% simulated failure rate on each fetch.

-
- -
- {{.lvt.FlashTag "success"}} - {{.lvt.FlashTag "error"}} - {{with .Result}}
{{.}}
{{end}} - {{/* is the sanctioned choice for secondary inline error details - (see CLAUDE.md "Use for highlighted/badge text and for - secondary inline error details"). The FlashTag above is the - primary error alert with role="alert"; highlights the - specific error string. */}} - {{with .Error}}

{{.}}

{{end}} -
-{{end}} diff --git a/patterns/templates/loading/lazy-loading.tmpl b/patterns/templates/loading/lazy-loading.tmpl deleted file mode 100644 index 8889e5c..0000000 --- a/patterns/templates/loading/lazy-loading.tmpl +++ /dev/null @@ -1,17 +0,0 @@ -{{define "content"}} -
-

Lazy Loading

-

The page renders immediately; content arrives from the server ~2s later via a goroutine pushing session.TriggerAction.

- - {{if .Loading}} -

Loading content...

- {{else}} -
{{.Data}}
-
- -
- {{end}} -
-{{end}} diff --git a/patterns/templates/loading/progress-bar.tmpl b/patterns/templates/loading/progress-bar.tmpl deleted file mode 100644 index 8232566..0000000 --- a/patterns/templates/loading/progress-bar.tmpl +++ /dev/null @@ -1,26 +0,0 @@ -{{define "content"}} -
-

Progress Bar

-

A background goroutine ticks progress from 10% to 100% via WebSocket pushes β€” no polling.

- {{if or .Running .Done}} - -

{{.Progress}}% complete

- {{end}} - {{if .Done}} - {{/* FlashTag must live inside the .Done branch: UpdateProgress emits - the flash only when transitioning to Done, so they always render - together. Flashes are ephemeral (consumed on first render) β€” moving - this outside the branch would cause the flash to be consumed during - a Running or idle render before Done is reached, and the user would - never see it. */}} - {{.lvt.FlashTag "success"}} -
- -
- {{else if not .Running}} -
- -
- {{end}} -
-{{end}} diff --git a/patterns/templates/navigation/confirm-dialog.tmpl b/patterns/templates/navigation/confirm-dialog.tmpl deleted file mode 100644 index 142b388..0000000 --- a/patterns/templates/navigation/confirm-dialog.tmpl +++ /dev/null @@ -1,51 +0,0 @@ -{{define "content"}} -
-

Confirm Dialog

-

Per-item confirmation gated by a native <dialog> with no inline JS. Each row owns its own dialog id (confirm-{{`{{.ID}}`}}) so URL hashes can deep-link to a specific row's confirmation.

- - {{if .Items}} - - - - - - - - - - {{range .Items}} - - - - - - {{end}} - -
NameEmail
{{.Name}}{{.Email}} - -
- - {{/* Dialogs render outside the table β€” inside is invalid HTML. */}} - {{range .Items}} - -
-
-

Delete “{{.Name}}”?

-
-

This cannot be undone.

-
-
-
- - -
-
-
-
-
- {{end}} - {{else}} -

All items deleted. Reload the page or navigate away and back to reseed the list.

- {{end}} -
-{{end}} diff --git a/patterns/templates/navigation/keyboard-shortcuts.tmpl b/patterns/templates/navigation/keyboard-shortcuts.tmpl deleted file mode 100644 index 62e96f3..0000000 --- a/patterns/templates/navigation/keyboard-shortcuts.tmpl +++ /dev/null @@ -1,38 +0,0 @@ -{{define "content"}} -{{/* Bind the "/" listener only when the panel is closed and the "Escape" listener only when it is open β€” keeps stray keypresses from triggering no-op WebSocket round-trips. */}} -{{if .PanelOpen}} -
-{{else}} -
-{{end}} -

Keyboard Shortcuts

-

Press / to open the command panel and Escape to close it. Bindings use lvt-on:window:keydown with the lvt-key filter; both global and Tier-1 form fallbacks work.

- - {{if .PanelOpen}} -
-
-

Command Panel

-
- {{/* Decorative β€” wiring to a Change()/Submit() handler is out of scope for the keyboard-shortcut demo. disabled removes the false affordance of a working search box. */}} - -
- -
-
- {{else}} -

Panel is closed. Press / or click the button to open.

-
- -
- {{end}} - -

Activity

- {{if .Log}} -
    - {{range .Log}}
  • {{.}}
  • {{end}} -
- {{else}} -

No activity yet.

- {{end}} -
-{{end}} diff --git a/patterns/templates/navigation/modal-dialog.tmpl b/patterns/templates/navigation/modal-dialog.tmpl deleted file mode 100644 index 040de2e..0000000 --- a/patterns/templates/navigation/modal-dialog.tmpl +++ /dev/null @@ -1,44 +0,0 @@ -{{define "content"}} -
-

Modal Dialog

-

Native <dialog> opened via the Invoker Commands API (command="show-modal" + commandfor) or directly via a hash link (<a href="#edit-dialog">, client v0.8.30+). Submitting the form with valid input closes the dialog and shows a success flash; invalid input keeps it open with field errors rendered inside (client v0.8.33+).

- -
-
Profile
-
{{.Name}} · {{.Email}}{{if .SavedAt}} · Saved at {{.SavedAt}}{{end}}
-
- -
- - Open via URL hash -
- - {{.lvt.FlashTag "success"}} - - -
-
-

Edit profile

-
-
- - -
-
- - -
-
-
-
-
-
-{{end}} diff --git a/patterns/templates/navigation/spa-navigation.tmpl b/patterns/templates/navigation/spa-navigation.tmpl deleted file mode 100644 index 49e1905..0000000 --- a/patterns/templates/navigation/spa-navigation.tmpl +++ /dev/null @@ -1,35 +0,0 @@ -{{define "content"}} -
-

SPA Navigation

-

Every <a> link inside the LiveTemplate wrapper is auto-intercepted. Same-pathname links use the in-band WebSocket __navigate__ action; cross-pathname links fetch and reconnect transparently. External targets opt out with lvt-nav:no-intercept.

- -
-

Same-pathname (in-band __navigate__)

-

Click these to update ?step= without an HTTP round-trip. The page does not reload — only this section re-renders.

- -

Step {{.Step}} of 3.

-
- -
-

Cross-pathname (WebSocket reconnect)

-

Each pattern is its own handler with its own data-lvt-id. Clicking these links triggers a fetch + DOM swap + WebSocket reconnect — without a hard page reload.

- -
- -
-

Opt-out (lvt-nav:no-intercept)

-

External links should opt out so the browser handles them natively.

-

example.com — opt-out via lvt-nav:no-intercept

-
-
-{{end}} diff --git a/patterns/templates/navigation/tabs.tmpl b/patterns/templates/navigation/tabs.tmpl deleted file mode 100644 index 157b6c5..0000000 --- a/patterns/templates/navigation/tabs.tmpl +++ /dev/null @@ -1,31 +0,0 @@ -{{define "content"}} -
-

Tabs (HATEOAS)

-

Server-driven tabs. Each tab is a query-param link (?tab=…); the framework intercepts the click and routes it through the WebSocket via the in-band __navigate__ action (server v0.8.19+, client v0.8.26+). No HTTP round-trip; Mount() re-runs with the new param.

- - - - {{if eq .ActiveTab "overview"}} -
-

Overview

-

This pattern uses a single Mount handler reading the tab query parameter. The active link is marked with aria-current="page" for screen readers and visually styled by Pico CSS.

-
- {{else if eq .ActiveTab "settings"}} -
-

Settings

-

Tab content is rendered server-side from the same template β€” switching tabs is a re-render, not a partial fragment. Bookmark ?tab=settings and the deep link works on next load too.

-
- {{else if eq .ActiveTab "activity"}} -
-

Activity

-

Unknown tab values fall back silently to Overview. The valid set lives in a validTabs allowlist; bookmarks with stale values stay safe.

-
- {{end}} -
-{{end}} diff --git a/patterns/templates/realtime/broadcasting.tmpl b/patterns/templates/realtime/broadcasting.tmpl deleted file mode 100644 index 20f043b..0000000 --- a/patterns/templates/realtime/broadcasting.tmpl +++ /dev/null @@ -1,35 +0,0 @@ -{{define "content"}} -
-

Broadcasting

-

ctx.BroadcastAction("NewMessage", nil) fans the named action out to every other connection in the session group. Peers receive it as a regular action invocation; their handler reads the shared message log under a mutex and refreshes local state. The broadcast is queued during the action and executes after it returns successfully.

- - {{if eq .Username ""}} -
-
- - -
-
- {{else}} -

Posting as {{.Username}}

- -
- {{range .Messages}} -

{{.User}}: {{.Text}}

- {{else}} -

No messages yet β€” send one to broadcast it to all connected peers.

- {{end}} -
- -
-
- - -
-
- {{end}} - -

Try: Open this page in a second tab and Join with a different name. Sending in either tab broadcasts to both. The shared log lives on the controller; each tab's Username is per-connection (not persisted) so two tabs in the same browser stay independent β€” see Reconnection Recovery for the persist case.

-

Limitation: The shared message log is in-memory and uncapped β€” production apps would ring-buffer, paginate, or persist to a TTL store. Kept simple here to focus on the broadcast mechanism itself.

-
-{{end}} diff --git a/patterns/templates/realtime/live-preview.tmpl b/patterns/templates/realtime/live-preview.tmpl deleted file mode 100644 index f317f03..0000000 --- a/patterns/templates/realtime/live-preview.tmpl +++ /dev/null @@ -1,18 +0,0 @@ -{{define "content"}} -
-

Live Preview

-

When the controller has a Change() method, the framework auto-binds it to input/change events on form fields with a 300ms debounce β€” no lvt-on:input attribute needed. The handler reads ctx.GetString("input") and updates state.Preview only; writing back to state.Input would patch the input's value attribute mid-typing and reset the cursor.

- -
- -
- - -
-
- - {{.Preview}} - -

Try: Type in the input β€” the preview updates ~300ms after you stop typing. Click Save to commit the value to state.Input (the field is lvt:"persist", so it survives reconnect). The cursor never jumps because Change() only updates the preview.

-
-{{end}} diff --git a/patterns/templates/realtime/multi-user-sync.tmpl b/patterns/templates/realtime/multi-user-sync.tmpl deleted file mode 100644 index 9f0cb31..0000000 --- a/patterns/templates/realtime/multi-user-sync.tmpl +++ /dev/null @@ -1,14 +0,0 @@ -{{define "content"}} -
-

Multi-User Sync

-

The reserved Sync() method on a controller is auto-dispatched to peer connections in the same session group after any action completes β€” no explicit BroadcastAction call is needed. Both tabs read the same shared counter from the controller's mutex-protected state.

- -

Counter: {{.Counter}}

- -
- -
- -

Try: Open this page in a second tab. Click Increment in either tab β€” both stay in sync because the framework dispatches Sync() to peers on every action's render.

-
-{{end}} diff --git a/patterns/templates/realtime/presence.tmpl b/patterns/templates/realtime/presence.tmpl deleted file mode 100644 index de61409..0000000 --- a/patterns/templates/realtime/presence.tmpl +++ /dev/null @@ -1,28 +0,0 @@ -{{define "content"}} -
-

Presence Tracking

-

Explicit Join/Leave actions update a mutex-guarded user map on the controller. Each mutation broadcasts PresenceChanged to peers, who recompute their local OnlineCount from the shared map.

- -

{{.OnlineCount}} user(s) online

- - {{if .Joined}} -

Logged in as {{.Username}}

-
- -
- {{else}} -
-
- - -
-
- {{end}} - -

Limitations (this is a pattern demo; production apps need more care):

-
    -
  • Close-tab leak: Closing a tab without clicking Leave leaves the user in the online set β€” the framework's OnDisconnect() hook receives no state or context, so it can't identify which user disconnected. Production apps either track connections explicitly or run a heartbeat sweep.
  • -
  • Same-username collision: The map keys on username, so two tabs joining as the same name register as one entry. When EITHER tab clicks Leave, the count drops to 0 even though the other tab is still "online" β€” connection-keyed tracking is the proper fix.
  • -
-
-{{end}} diff --git a/patterns/templates/realtime/reconnection.tmpl b/patterns/templates/realtime/reconnection.tmpl deleted file mode 100644 index 1b9d175..0000000 --- a/patterns/templates/realtime/reconnection.tmpl +++ /dev/null @@ -1,21 +0,0 @@ -{{define "content"}} -
-

Reconnection Recovery

-

State fields tagged lvt:"persist" survive WebSocket disconnects, server restarts, and full page reloads β€” the framework restores them from the session store via the group cookie before the first render after reconnect.

- -

Counter: {{.Counter}} (lvt:"persist")

-
- -
- -
- - -
- -

Try: Increment a few times and save some notes, then reload the page. State is restored before the first render. Why both tags on Notes? lvt:"persist" (server-side) survives reconnect. lvt-form:preserve (client-side) keeps your in-progress text in the DOM across re-renders that other actions trigger β€” without it, typing while the Counter is incrementing would lose the textarea draft.

-
-{{end}} diff --git a/patterns/templates/realtime/server-push.tmpl b/patterns/templates/realtime/server-push.tmpl deleted file mode 100644 index 550001b..0000000 --- a/patterns/templates/realtime/server-push.tmpl +++ /dev/null @@ -1,19 +0,0 @@ -{{define "content"}} -
-

Server Push

-

session.TriggerAction(name, data) fires an action on the originating connection from a background goroutine. The handler updates state and the rendered tree is pushed to the browser. The call returns a non-nil error when the session group has no live connections β€” checking it on every iteration is the documented goroutine-cancellation pattern (no done channel needed).

- - {{if .Running}} -

Timer running: {{.Elapsed}} / {{.Total}}s

- {{else}} -
- -
- {{if gt .Elapsed 0}} -

Last completed: {{.Elapsed}}s

- {{end}} - {{end}} - -

Try: Click Start and watch the counter tick up once per second. The goroutine on the server pushes each tick via session.TriggerAction("tick", {elapsed: …}). If you close the tab mid-run, the next TriggerAction returns an error and the goroutine exits β€” no leak.

-
-{{end}} diff --git a/patterns/templates/search/active-search.tmpl b/patterns/templates/search/active-search.tmpl deleted file mode 100644 index f42ae1f..0000000 --- a/patterns/templates/search/active-search.tmpl +++ /dev/null @@ -1,25 +0,0 @@ -{{define "content"}} -
-

Active Search

-

Type in the search box. The Change() handler fires after a 300ms debounce and re-renders the result list server-side β€” no client-side filtering logic.

-
- -
- - - - {{range .Results}} - - - - - {{end}} - -
NameEmail
{{.Name}}{{.Email}}
- {{if and .Query (eq (len .Results) 0)}} -

No contacts match "{{.Query}}".

- {{end}} -
-{{end}} diff --git a/patterns/templates/search/url-filters.tmpl b/patterns/templates/search/url-filters.tmpl deleted file mode 100644 index 2ffa2c2..0000000 --- a/patterns/templates/search/url-filters.tmpl +++ /dev/null @@ -1,33 +0,0 @@ -{{define "content"}} -
-

URL-Preserved Filters

-

Filter state lives in the URL. SPA link clicks update history; direct-loading a bookmark restores the view. Mount() reads query params.

-

Current: status={{.Status}}, sort={{.Sort}}

- - - - - {{range .Items}} - - - - - - {{end}} - -
NameStatusDate
{{.Name}}{{.Status}}{{.Date}}
- {{if eq (len .Items) 0}} -

No items match this filter.

- {{end}} -
-{{end}} diff --git a/test-all.sh b/test-all.sh index 816efb7..edcdd78 100755 --- a/test-all.sh +++ b/test-all.sh @@ -41,7 +41,6 @@ WORKING_EXAMPLES=( "avatar-upload" "progressive-enhancement" "dialog-patterns" - "patterns" "landing-demo" ) From 932e17b7ab8b7de0849fe78c5f9ad93c1031754e Mon Sep 17 00:00:00 2001 From: Adnaan Badr Date: Sun, 10 May 2026 05:03:51 +0000 Subject: [PATCH 2/2] fix: add direct link to docs/recipes/patterns source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Claude review on #99 β€” the README sentence pointing to the docs catalog source named the path but didn't link it. Direct URL keeps the trail navigable for contributors arriving here post-deletion. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a85aa45..794cc36 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Example applications demonstrating LiveTemplate usage with various features and patterns. -πŸ“š **Framework documentation:** **** β€” guides, recipes, patterns catalog (with live demos), full reference. The `/examples` section of the docs site indexes every app in this repo. The `/patterns` catalog is served from the docs repo's own `content/recipes/patterns/_app/` package. +πŸ“š **Framework documentation:** **** β€” guides, recipes, patterns catalog (with live demos), full reference. The `/examples` section of the docs site indexes every app in this repo. The `/patterns` catalog is served from the docs repo's [`content/recipes/patterns/_app/`](https://github.com/livetemplate/docs/tree/main/content/recipes/patterns/_app/) package. ## Showcase: Todo App