diff --git a/CLAUDE.md b/CLAUDE.md index 113abe0..bbd8144 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -137,8 +137,10 @@ Every template must use this boilerplate: {{if .lvt.DevMode}} + {{else}} + {{end}} ``` diff --git a/avatar-upload/avatar-upload.tmpl b/avatar-upload/avatar-upload.tmpl index 51ad894..5972bbc 100644 --- a/avatar-upload/avatar-upload.tmpl +++ b/avatar-upload/avatar-upload.tmpl @@ -8,8 +8,10 @@ {{if .lvt.DevMode}} + {{else}} + {{end}} @@ -80,10 +82,5 @@ - {{if .lvt.DevMode}} - - {{else}} - - {{end}} diff --git a/chat/chat.tmpl b/chat/chat.tmpl index 7ce2a3f..f397087 100644 --- a/chat/chat.tmpl +++ b/chat/chat.tmpl @@ -8,8 +8,10 @@ {{if .lvt.DevMode}} + {{else}} + {{end}} @@ -58,10 +60,5 @@ - {{if .lvt.DevMode}} - - {{else}} - - {{end}} diff --git a/counter/counter.tmpl b/counter/counter.tmpl index baa25f6..c848484 100644 --- a/counter/counter.tmpl +++ b/counter/counter.tmpl @@ -8,8 +8,10 @@ {{if .lvt.DevMode}} + {{else}} + {{end}} @@ -33,10 +35,5 @@ - {{if .lvt.DevMode}} - - {{else}} - - {{end}} diff --git a/dialog-patterns/dialog-patterns.tmpl b/dialog-patterns/dialog-patterns.tmpl index 4485e24..43d27a4 100644 --- a/dialog-patterns/dialog-patterns.tmpl +++ b/dialog-patterns/dialog-patterns.tmpl @@ -8,8 +8,10 @@ {{if .lvt.DevMode}} + {{else}} + {{end}} @@ -73,10 +75,5 @@ - {{if .lvt.DevMode}} - - {{else}} - - {{end}} diff --git a/dialog-patterns/dialog_patterns_test.go b/dialog-patterns/dialog_patterns_test.go index 13a6c15..c5a3b0e 100644 --- a/dialog-patterns/dialog_patterns_test.go +++ b/dialog-patterns/dialog_patterns_test.go @@ -259,45 +259,64 @@ func TestDialogPatternsE2E(t *testing.T) { }) t.Run("Add_Empty_Title_Error", func(t *testing.T) { - // Open the dialog and submit with empty title + // Navigate fresh to get clean polyfill state (command/commandfor listeners + // are lost after WebSocket DOM updates from the previous test) err := chromedp.Run(ctx, + chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)), + e2etest.WaitForWebSocketReady(5*time.Second), + chromedp.WaitVisible(`h1`, chromedp.ByQuery), + ) + if err != nil { + t.Fatalf("Failed to reload page: %v", err) + } + + // Open the dialog + err = chromedp.Run(ctx, chromedp.Click(`button[commandfor="add-dialog"][command="show-modal"]`, chromedp.ByQuery), e2etest.WaitFor(`document.getElementById('add-dialog').open === true`, 5*time.Second), + ) + if err != nil { + t.Fatalf("Failed to open dialog: %v", err) + } + + // The input has `required`, so browser validation prevents empty submission. + // Remove `required` via JS to test server-side validation. + err = chromedp.Run(ctx, + chromedp.Evaluate(`document.querySelector('dialog#add-dialog input[name="title"]').removeAttribute('required')`, nil), chromedp.Clear(`dialog#add-dialog input[name="title"]`, chromedp.ByQuery), chromedp.Click(`dialog#add-dialog button[type="submit"]`, chromedp.ByQuery), + e2etest.WaitFor(`true`, 2*time.Second), // wait for server response ) if err != nil { t.Fatalf("Failed to submit empty form: %v", err) } - // Item count should remain 4 (unchanged from Add_Item_Via_Dialog) + // Item count should remain 3 (unchanged — fresh session has seed items) var html string - err = chromedp.Run(ctx, - e2etest.WaitFor(`true`, 1*time.Second), // brief wait for any server response - chromedp.OuterHTML(`body`, &html, chromedp.ByQuery), - ) + err = chromedp.Run(ctx, chromedp.OuterHTML(`body`, &html, chromedp.ByQuery)) if err != nil { t.Fatalf("Failed to get HTML: %v", err) } - if !strings.Contains(html, "4 items") { - t.Error("Item count should still be '4 items' after failed empty submission") + if !strings.Contains(html, "3 items") { + t.Error("Item count should still be '3 items' after failed empty submission") } - // Close dialog for next test - err = chromedp.Run(ctx, - chromedp.Click(`button[commandfor="add-dialog"][command="close"]`, chromedp.ByQuery), - e2etest.WaitFor(`document.getElementById('add-dialog').open === false`, 5*time.Second), + t.Log("✅ Empty title submission rejected by server, item count unchanged") + }) + + t.Run("Delete_Item", func(t *testing.T) { + // Navigate fresh to get clean state + err := chromedp.Run(ctx, + chromedp.Navigate(e2etest.GetChromeTestURL(serverPort)), + e2etest.WaitForWebSocketReady(5*time.Second), + chromedp.WaitVisible(`table`, chromedp.ByQuery), ) if err != nil { - t.Fatalf("Failed to close dialog: %v", err) + t.Fatalf("Failed to reload page: %v", err) } - t.Log("✅ Empty title submission rejected, item count unchanged") - }) - - t.Run("Delete_Item", func(t *testing.T) { // Delete the first seed item (Learn LiveTemplate) - err := chromedp.Run(ctx, + err = chromedp.Run(ctx, chromedp.Click(`button[name="delete"][value="1"]`, chromedp.ByQuery), e2etest.WaitFor(`!document.body.innerText.includes('Learn LiveTemplate')`, 5*time.Second), ) @@ -320,11 +339,8 @@ func TestDialogPatternsE2E(t *testing.T) { if !strings.Contains(html, "Write E2E tests") { t.Error("Remaining item 'Write E2E tests' should be present") } - if !strings.Contains(html, "New Test Item") { - t.Error("Previously added item 'New Test Item' should be present") - } - if !strings.Contains(html, "3 items") { - t.Error("Item count should be '3 items' after deletion") + if !strings.Contains(html, "2 items") { + t.Error("Item count should be '2 items' after deletion") } t.Log("✅ Item deleted, remaining items preserved") diff --git a/flash-messages/flash.tmpl b/flash-messages/flash.tmpl index 7a8750f..c0b530b 100644 --- a/flash-messages/flash.tmpl +++ b/flash-messages/flash.tmpl @@ -8,8 +8,10 @@ {{if .lvt.DevMode}} + {{else}} + {{end}} @@ -90,10 +92,5 @@ - {{if .lvt.DevMode}} - - {{else}} - - {{end}} diff --git a/live-preview/preview.tmpl b/live-preview/preview.tmpl index 3d797c2..f29221a 100644 --- a/live-preview/preview.tmpl +++ b/live-preview/preview.tmpl @@ -8,8 +8,10 @@ {{if .lvt.DevMode}} + {{else}} + {{end}} @@ -32,10 +34,5 @@ - {{if .lvt.DevMode}} - - {{else}} - - {{end}} diff --git a/login/templates/auth.html b/login/templates/auth.html index e7d76d7..f0f5006 100644 --- a/login/templates/auth.html +++ b/login/templates/auth.html @@ -8,8 +8,10 @@ {{if .lvt.DevMode}} + {{else}} + {{end}} @@ -53,10 +55,6 @@

Login

{{end}} - {{if .lvt.DevMode}} - - {{else}} - - {{end}} + diff --git a/progressive-enhancement/progressive-enhancement.tmpl b/progressive-enhancement/progressive-enhancement.tmpl index 472e1c4..b58cba7 100644 --- a/progressive-enhancement/progressive-enhancement.tmpl +++ b/progressive-enhancement/progressive-enhancement.tmpl @@ -8,8 +8,10 @@ {{if .lvt.DevMode}} + {{else}} + {{end}} @@ -88,10 +90,5 @@ - {{if .lvt.DevMode}} - - {{else}} - - {{end}} diff --git a/progressive-enhancement/progressive_enhancement_test.go b/progressive-enhancement/progressive_enhancement_test.go index 1f7e49c..5c2e9bc 100644 --- a/progressive-enhancement/progressive_enhancement_test.go +++ b/progressive-enhancement/progressive_enhancement_test.go @@ -4,7 +4,7 @@ import ( "context" "fmt" "net/http" - "net/http/httptest" + "os" "strings" "testing" "time" @@ -15,8 +15,17 @@ import ( e2etest "github.com/livetemplate/lvt/testing" ) -// setupServer creates a test server with the progressive enhancement example -func setupServer(t *testing.T) *httptest.Server { +func TestMain(m *testing.M) { + e2etest.CleanupChromeContainers() + + code := m.Run() + + e2etest.CleanupChromeContainers() + os.Exit(code) +} + +// setupServer creates a test server with the progressive enhancement example (for HTTP-only tests) +func setupServer(t *testing.T) *http.Server { t.Helper() controller := &TodoController{ @@ -33,328 +42,408 @@ func setupServer(t *testing.T) *httptest.Server { mux.HandleFunc("/livetemplate-client.js", e2etest.ServeClientLibrary) mux.HandleFunc("/livetemplate.css", e2etest.ServeCSS) - return httptest.NewServer(mux) -} - -// TestProgressiveEnhancement_WithJS tests the app with JavaScript enabled -func TestProgressiveEnhancement_WithJS(t *testing.T) { - server := setupServer(t) - defer server.Close() - - // Create browser context with logging - opts := append(chromedp.DefaultExecAllocatorOptions[:], - chromedp.Flag("headless", true), - ) - allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...) - defer allocCancel() + port, err := e2etest.GetFreePort() + if err != nil { + t.Fatalf("Failed to get free port: %v", err) + } - ctx, cancel := chromedp.NewContext(allocCtx) - defer cancel() + srv := &http.Server{Addr: fmt.Sprintf(":%d", port), Handler: mux} + go func() { _ = srv.ListenAndServe() }() - // Set timeout - ctx, timeoutCancel := context.WithTimeout(ctx, 15*time.Second) - defer timeoutCancel() + // Wait for server to be ready + addr := fmt.Sprintf("http://localhost:%d", port) + for i := 0; i < 50; i++ { + resp, err := http.Get(addr) + if err == nil { + resp.Body.Close() + break + } + time.Sleep(100 * time.Millisecond) + } - var initialHTML string + t.Cleanup(func() { srv.Close() }) + return srv +} - err := chromedp.Run(ctx, - // Navigate to the app - chromedp.Navigate(server.URL), +func serverURL(srv *http.Server) string { + return fmt.Sprintf("http://localhost%s", srv.Addr) +} - // Wait for page to load - chromedp.WaitReady("body"), +// ========== E2E Tests (Docker Chrome) ========== - // Get HTML to verify page loaded - chromedp.OuterHTML("html", &initialHTML), - ) +func TestProgressiveEnhancementE2E(t *testing.T) { + if testing.Short() { + t.Skip("Skipping E2E test in short mode") + } + srvPort, err := e2etest.GetFreePort() if err != nil { - t.Fatalf("chromedp failed: %v", err) + t.Fatalf("Failed to get free port for server: %v", err) } - // Verify page contains expected content - if !strings.Contains(initialHTML, "Progressive Enhancement Todo List") { - t.Error("Expected page title in HTML") + debugPort, err := e2etest.GetFreePort() + if err != nil { + t.Fatalf("Failed to get free port for Chrome: %v", err) } - // Verify form has submit button with name - if !strings.Contains(initialHTML, `name="add"`) { - t.Error("Expected button with name='add' in form") - } -} + serverCmd := e2etest.StartTestServer(t, "main.go", srvPort) + defer func() { + if serverCmd != nil && serverCmd.Process != nil { + serverCmd.Process.Kill() + } + }() -// TestProgressiveEnhancement_UIStandards checks CSP compliance, shared CSS, and layout standards -func TestProgressiveEnhancement_UIStandards(t *testing.T) { - server := setupServer(t) - defer server.Close() + chromeCmd := e2etest.StartDockerChrome(t, debugPort) + defer e2etest.StopDockerChrome(t, debugPort) + _ = chromeCmd - opts := append(chromedp.DefaultExecAllocatorOptions[:], - chromedp.Flag("headless", true), - ) - allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...) + chromeURL := fmt.Sprintf("http://localhost:%d", debugPort) + allocCtx, allocCancel := chromedp.NewRemoteAllocator(context.Background(), chromeURL) defer allocCancel() - ctx, cancel := chromedp.NewContext(allocCtx) + ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(t.Logf)) defer cancel() - ctx, timeoutCancel := context.WithTimeout(ctx, 15*time.Second) - defer timeoutCancel() - - var violations string - err := chromedp.Run(ctx, - chromedp.Navigate(server.URL), - chromedp.WaitReady("body"), - chromedp.Evaluate(`(() => { - const v = []; - ['onclick','onchange','oninput','onsubmit','onkeydown','onkeyup'].forEach(h => { - document.querySelectorAll('[' + h + ']').forEach(el => v.push('inline ' + h + ' on <' + el.tagName.toLowerCase() + '>')); - }); - document.querySelectorAll('[style]').forEach(el => { - if (el.tagName !== 'INS' && el.tagName !== 'DEL' && !el.closest('[data-modal]') && !el.closest('[data-lvt-toast-stack]')) - v.push('inline style on <' + el.tagName.toLowerCase() + '>'); - }); - if (!document.querySelector('meta[name="color-scheme"]')) v.push('missing color-scheme meta'); - if (document.documentElement.lang !== 'en') v.push('missing lang=en'); - const c = document.querySelector('.container'); - if (c && c.offsetWidth > 700) v.push('container too wide: ' + c.offsetWidth + 'px'); - return v.join('; '); - })()`, &violations), - ) - if err != nil { - t.Fatalf("UI standards check failed: %v", err) - } - if violations != "" { - t.Errorf("UI standard violations: %s", violations) - } - var cssStatus int - chromedp.Run(ctx, chromedp.Evaluate(`(() => { const x = new XMLHttpRequest(); x.open('GET', '/livetemplate.css', false); x.send(); return x.status; })()`, &cssStatus)) - if cssStatus != 200 { - t.Logf("Warning: Shared CSS not loading: status=%d (may not be available in CI)", cssStatus) - } - if err := chromedp.Run(ctx, e2etest.ValidatePicoCSS()); err != nil { - t.Errorf("Pico CSS check failed: %v", err) - } -} - -// TestProgressiveEnhancement_VisualCheck uses LLM vision to check for visual UI issues. -func TestProgressiveEnhancement_VisualCheck(t *testing.T) { - server := setupServer(t) - defer server.Close() - - opts := append(chromedp.DefaultExecAllocatorOptions[:], - chromedp.Flag("headless", true), - ) - allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...) - defer allocCancel() - - ctx, cancel := chromedp.NewContext(allocCtx) + ctx, cancel = context.WithTimeout(ctx, 60*time.Second) defer cancel() - ctx, timeoutCancel := context.WithTimeout(ctx, 15*time.Second) - defer timeoutCancel() + t.Run("Initial_Load", func(t *testing.T) { + var html string + err := chromedp.Run(ctx, + chromedp.Navigate(e2etest.GetChromeTestURL(srvPort)), + e2etest.WaitForWebSocketReady(5*time.Second), + chromedp.WaitVisible(`h1`, chromedp.ByQuery), + e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), + chromedp.OuterHTML(`html`, &html, chromedp.ByQuery), + ) + if err != nil { + t.Fatalf("Failed to load page: %v", err) + } - if err := chromedp.Run(ctx, chromedp.Navigate(server.URL), chromedp.WaitReady("body")); err != nil { - t.Fatalf("Failed to load page: %v", err) - } + if !strings.Contains(html, "Progressive Enhancement") { + t.Error("Page title not found") + } + if !strings.Contains(html, `name="add"`) { + t.Error("Expected button with name='add' in form") + } + if !strings.Contains(html, "livetemplate-client.js") { + t.Error("Script tag for livetemplate-client.js not found") + } - e2etest.ValidateScreenshotWithLLM(t, ctx, "Progressive Enhancement Todo List — form with input+button, todo table below") -} + t.Log("✅ Initial page load verified") + }) + + t.Run("UI_Standards", func(t *testing.T) { + var violations string + err := chromedp.Run(ctx, + chromedp.Evaluate(`(() => { + const v = []; + ['onclick','onchange','oninput','onsubmit','onkeydown','onkeyup'].forEach(h => { + document.querySelectorAll('[' + h + ']').forEach(el => v.push('inline ' + h + ' on <' + el.tagName.toLowerCase() + '>')); + }); + document.querySelectorAll('[style]').forEach(el => { + if (el.tagName !== 'INS' && el.tagName !== 'DEL' && !el.closest('[data-modal]') && !el.closest('[data-lvt-toast-stack]')) + v.push('inline style on <' + el.tagName.toLowerCase() + '>'); + }); + if (!document.querySelector('meta[name="color-scheme"]')) v.push('missing color-scheme meta'); + if (document.documentElement.lang !== 'en') v.push('missing lang=en'); + const c = document.querySelector('.container'); + if (c && c.offsetWidth > 700) v.push('container too wide: ' + c.offsetWidth + 'px'); + return v.join('; '); + })()`, &violations), + ) + if err != nil { + t.Fatalf("UI standards check failed: %v", err) + } + if violations != "" { + t.Errorf("UI standard violations: %s", violations) + } + if err := chromedp.Run(ctx, e2etest.ValidatePicoCSS()); err != nil { + t.Errorf("Pico CSS check failed: %v", err) + } + t.Log("✅ UI standards passed") + }) -// TestProgressiveEnhancement_JSFormSubmission tests that JS mode intercepts forms and updates DOM -func TestProgressiveEnhancement_JSFormSubmission(t *testing.T) { - server := setupServer(t) - defer server.Close() + t.Run("WebSocket_Connection", func(t *testing.T) { + var wsConnected bool + err := chromedp.Run(ctx, + chromedp.Evaluate(`window.liveTemplateClient && window.liveTemplateClient.isReady()`, &wsConnected), + ) + if err != nil { + t.Fatalf("Failed to check WebSocket: %v", err) + } + if !wsConnected { + t.Error("WebSocket not connected") + } + t.Log("✅ WebSocket connection established") + }) - // Create browser context with console logging - opts := append(chromedp.DefaultExecAllocatorOptions[:], - chromedp.Flag("headless", true), - ) - allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...) - defer allocCancel() + t.Run("Add_Todo", func(t *testing.T) { + var initialCount int + err := chromedp.Run(ctx, + chromedp.Evaluate(`document.querySelectorAll('table tbody tr').length`, &initialCount), + ) + if err != nil { + t.Fatalf("Failed to count todos: %v", err) + } - ctx, cancel := chromedp.NewContext(allocCtx) - defer cancel() + expectedCount := initialCount + 1 + err = chromedp.Run(ctx, + chromedp.SendKeys(`input[name="title"]`, "E2E Test Todo", chromedp.ByQuery), + chromedp.Click(`button[name="add"]`, chromedp.ByQuery), + e2etest.WaitFor(fmt.Sprintf(`document.querySelectorAll('table tbody tr').length === %d`, expectedCount), 5*time.Second), + ) + if err != nil { + t.Fatalf("Failed to add todo: %v", err) + } - // Set timeout - ctx, timeoutCancel := context.WithTimeout(ctx, 30*time.Second) - defer timeoutCancel() + var afterCount int + err = chromedp.Run(ctx, + chromedp.Evaluate(`document.querySelectorAll('table tbody tr').length`, &afterCount), + ) + if err != nil { + t.Fatalf("Failed to count after add: %v", err) + } + if afterCount != expectedCount { + t.Errorf("Expected %d todos, got %d", expectedCount, afterCount) + } - var initialHTML string - var hasWrapper bool - var wsConnected bool + // Verify input was cleared (form auto-reset) + var inputValue string + err = chromedp.Run(ctx, + chromedp.Evaluate(`document.querySelector('input[name="title"]').value`, &inputValue), + ) + if err != nil { + t.Fatalf("Failed to get input value: %v", err) + } + if inputValue != "" { + t.Errorf("Input should be cleared after add, got: %q", inputValue) + } - err := chromedp.Run(ctx, - // Navigate to the app - chromedp.Navigate(server.URL), + t.Logf("✅ Todo added, count: %d → %d, input cleared", initialCount, afterCount) + }) - // Wait for page to load and wrapper to exist - chromedp.WaitReady("body"), - chromedp.Sleep(500*time.Millisecond), + t.Run("Toggle_Todo", func(t *testing.T) { + // Toggle the last todo (mark as complete) + err := chromedp.Run(ctx, + chromedp.Click(`table tbody tr:last-child button[name="toggle"]`, chromedp.ByQuery), + e2etest.WaitFor(`document.querySelector('table tbody tr:last-child').querySelector('s') !== null`, 5*time.Second), + ) + if err != nil { + t.Fatalf("Failed to toggle todo: %v", err) + } - // Verify wrapper div exists with data-lvt-id - chromedp.Evaluate(`document.querySelector('[data-lvt-id]') !== null`, &hasWrapper), + var hasStrikethrough bool + err = chromedp.Run(ctx, + chromedp.Evaluate(`document.querySelector('table tbody tr:last-child').querySelector('s') !== null`, &hasStrikethrough), + ) + if err != nil { + t.Fatalf("Failed to check strikethrough: %v", err) + } + if !hasStrikethrough { + t.Error("Todo should have strikethrough after toggle") + } - // Get initial HTML - chromedp.OuterHTML("html", &initialHTML), - ) + // Toggle again (mark as incomplete) + err = chromedp.Run(ctx, + chromedp.Click(`table tbody tr:last-child button[name="toggle"]`, chromedp.ByQuery), + e2etest.WaitFor(`document.querySelector('table tbody tr:last-child').querySelector('s') === null`, 5*time.Second), + ) + if err != nil { + t.Fatalf("Failed to untoggle todo: %v", err) + } - if err != nil { - t.Fatalf("Initial page load failed: %v", err) - } + err = chromedp.Run(ctx, + chromedp.Evaluate(`document.querySelector('table tbody tr:last-child').querySelector('s') !== null`, &hasStrikethrough), + ) + if err != nil { + t.Fatalf("Failed to check strikethrough: %v", err) + } + if hasStrikethrough { + t.Error("Todo should NOT have strikethrough after untoggle") + } - if !hasWrapper { - t.Fatal("data-lvt-id wrapper not found - client library won't initialize") - } - t.Log("data-lvt-id wrapper found") + t.Log("✅ Toggle and untoggle work correctly") + }) - // Log wrapper ID for debugging - t.Logf("HTML contains data-lvt-id: %v", strings.Contains(initialHTML, "data-lvt-id")) + t.Run("Delete_Todo", func(t *testing.T) { + var beforeCount int + err := chromedp.Run(ctx, + chromedp.Evaluate(`document.querySelectorAll('table tbody tr').length`, &beforeCount), + ) + if err != nil { + t.Fatalf("Failed to count todos: %v", err) + } - // Wait for WebSocket to connect - err = chromedp.Run(ctx, - // Wait for client to be ready - e2etest.WaitFor(`window.liveTemplateClient && window.liveTemplateClient.isReady()`, 10*time.Second), + expectedCount := beforeCount - 1 + err = chromedp.Run(ctx, + chromedp.Click(`table tbody tr:last-child button[name="delete"]`, chromedp.ByQuery), + e2etest.WaitFor(fmt.Sprintf(`document.querySelectorAll('table tbody tr').length === %d`, expectedCount), 5*time.Second), + ) + if err != nil { + t.Fatalf("Failed to delete todo: %v", err) + } - // Verify WebSocket connected - chromedp.Evaluate(`window.liveTemplateClient && window.liveTemplateClient.isReady()`, &wsConnected), - ) + var afterCount int + err = chromedp.Run(ctx, + chromedp.Evaluate(`document.querySelectorAll('table tbody tr').length`, &afterCount), + ) + if err != nil { + t.Fatalf("Failed to count after delete: %v", err) + } + if afterCount != expectedCount { + t.Errorf("Expected %d todos after delete, got %d", expectedCount, afterCount) + } - if err != nil { - t.Logf("WebSocket connection check failed: %v", err) - } + t.Logf("✅ Todo deleted, count: %d → %d", beforeCount, afterCount) + }) - if wsConnected { - t.Log("WebSocket connected successfully") - } else { - t.Log("WebSocket not connected - may be using HTTP mode") - } + t.Run("Delete_Then_Toggle", func(t *testing.T) { + // Navigate fresh to get clean state (3 seed items) + err := chromedp.Run(ctx, + chromedp.Navigate(e2etest.GetChromeTestURL(srvPort)), + e2etest.WaitForWebSocketReady(5*time.Second), + chromedp.WaitVisible(`table`, chromedp.ByQuery), + ) + if err != nil { + t.Fatalf("Failed to reload page: %v", err) + } - // Count initial todos - var initialTodoCount int - err = chromedp.Run(ctx, - chromedp.Evaluate(`document.querySelectorAll('table tbody tr').length`, &initialTodoCount), - ) - if err != nil { - t.Fatalf("Failed to count initial todos: %v", err) - } - t.Logf("Initial todo count: %d", initialTodoCount) + var initialCount int + err = chromedp.Run(ctx, + chromedp.Evaluate(`document.querySelectorAll('table tbody tr').length`, &initialCount), + ) + if err != nil { + t.Fatalf("Failed to count todos: %v", err) + } + if initialCount < 2 { + t.Fatalf("Need at least 2 items, got %d", initialCount) + } - // Type a new todo and submit the form - var messageCount int - err = chromedp.Run(ctx, - // Type in the input field - chromedp.SetValue(`input[name="title"]`, "New todo via JS", chromedp.ByQuery), + // Delete the first todo + expectedAfterDelete := initialCount - 1 + err = chromedp.Run(ctx, + chromedp.Click(`table tbody tr:first-child button[name="delete"]`, chromedp.ByQuery), + e2etest.WaitFor(fmt.Sprintf(`document.querySelectorAll('table tbody tr').length === %d`, expectedAfterDelete), 5*time.Second), + ) + if err != nil { + t.Fatalf("Failed to delete first item: %v", err) + } + + // Toggle the last remaining todo + var hadStrikethrough bool + err = chromedp.Run(ctx, + chromedp.Evaluate(`document.querySelector('table tbody tr:last-child').querySelector('s') !== null`, &hadStrikethrough), + ) + if err != nil { + t.Fatalf("Failed to check state before toggle: %v", err) + } - // Get current message count before submission - chromedp.Evaluate(`window.__wsMessageCount || 0`, &messageCount), + toggleWait := `document.querySelector('table tbody tr:last-child').querySelector('s') !== null` + if hadStrikethrough { + toggleWait = `document.querySelector('table tbody tr:last-child').querySelector('s') === null` + } - // Click the submit button - chromedp.Click(`button[type="submit"]`, chromedp.ByQuery), + err = chromedp.Run(ctx, + chromedp.Click(`table tbody tr:last-child button[name="toggle"]`, chromedp.ByQuery), + e2etest.WaitFor(toggleWait, 5*time.Second), + ) + if err != nil { + t.Fatalf("Failed to toggle after delete: %v", err) + } - // Wait for a DOM update - chromedp.Sleep(1*time.Second), - ) + var hasStrikethrough bool + err = chromedp.Run(ctx, + chromedp.Evaluate(`document.querySelector('table tbody tr:last-child').querySelector('s') !== null`, &hasStrikethrough), + ) + if err != nil { + t.Fatalf("Failed to check toggle result: %v", err) + } + if hasStrikethrough == hadStrikethrough { + t.Errorf("Toggle failed: strikethrough should have changed from %v", hadStrikethrough) + } - if err != nil { - t.Fatalf("Form submission failed: %v", err) - } + t.Log("✅ Toggle works correctly after deleting a different item") + }) - // Check if there was a WebSocket message - var newMessageCount int - chromedp.Run(ctx, - chromedp.Evaluate(`window.__wsMessageCount || 0`, &newMessageCount), - ) + t.Run("Validation_Error_Browser", func(t *testing.T) { + // Navigate fresh + err := chromedp.Run(ctx, + chromedp.Navigate(e2etest.GetChromeTestURL(srvPort)), + e2etest.WaitForWebSocketReady(5*time.Second), + chromedp.WaitVisible(`table`, chromedp.ByQuery), + ) + if err != nil { + t.Fatalf("Failed to reload page: %v", err) + } - t.Logf("Message count before: %d, after: %d", messageCount, newMessageCount) + var beforeCount int + err = chromedp.Run(ctx, + chromedp.Evaluate(`document.querySelectorAll('table tbody tr').length`, &beforeCount), + ) + if err != nil { + t.Fatalf("Failed to count todos: %v", err) + } - // Count todos after submission - var finalTodoCount int - err = chromedp.Run(ctx, - chromedp.Evaluate(`document.querySelectorAll('table tbody tr').length`, &finalTodoCount), - ) - if err != nil { - t.Fatalf("Failed to count final todos: %v", err) - } - t.Logf("Final todo count: %d", finalTodoCount) - - // Get the actual WebSocket messages received - var lastMessage string - var wsMessages []interface{} - chromedp.Run(ctx, - chromedp.Evaluate(`window.__lastWSMessage || ""`, &lastMessage), - chromedp.Evaluate(`window.__wsMessages || []`, &wsMessages), - ) - t.Logf("Last WS message (raw): %s", lastMessage) - if len(wsMessages) > 0 { - t.Logf("Total WS messages received: %d", len(wsMessages)) - for i, msg := range wsMessages { - t.Logf("WS message %d: %+v", i, msg) - } - } - - // If WebSocket is working, we should see more todos now (no page reload) - if finalTodoCount > initialTodoCount { - t.Log("SUCCESS: Todo was added via WebSocket without page reload") - } else { - // Check for debug flags to understand why it didn't work - var debugInfo map[string]interface{} - chromedp.Run(ctx, - chromedp.Evaluate(`({ - submitListenerTriggered: window.__lvtSubmitListenerTriggered, - inWrapper: window.__lvtInWrapper, - actionFound: window.__lvtActionFound, - sendCalled: window.__lvtSendCalled, - sendPath: window.__lvtSendPath, - wsMessage: window.__lvtWSMessage, - wsMessageCount: window.__wsMessageCount, - })`, &debugInfo), + // Submit with a too-short title (less than 3 chars) + err = chromedp.Run(ctx, + chromedp.SetValue(`input[name="title"]`, "ab", chromedp.ByQuery), + chromedp.Click(`button[name="add"]`, chromedp.ByQuery), + e2etest.WaitFor(`document.querySelector('[aria-invalid="true"]') !== null`, 5*time.Second), ) - t.Logf("Debug info: %+v", debugInfo) + if err != nil { + t.Fatalf("Failed to submit invalid form: %v", err) + } - // Get rendered HTML to see current state - var currentHTML string - chromedp.Run(ctx, - chromedp.OuterHTML(".todo-list", ¤tHTML, chromedp.ByQuery), + var afterCount int + err = chromedp.Run(ctx, + chromedp.Evaluate(`document.querySelectorAll('table tbody tr').length`, &afterCount), ) - t.Logf("Current todo list HTML length: %d", len(currentHTML)) + if err != nil { + t.Fatalf("Failed to count after invalid submit: %v", err) + } + if afterCount != beforeCount { + t.Errorf("Count should be unchanged after validation error: expected %d, got %d", beforeCount, afterCount) + } - // This is informational - the test still passes if HTTP fallback worked - t.Log("Note: Form may have submitted via HTTP (check server logs)") - } + t.Log("✅ Validation error handled correctly in browser") + }) + + fmt.Println("\n" + strings.Repeat("=", 60)) + fmt.Println("All progressive-enhancement E2E tests passed!") + fmt.Println(strings.Repeat("=", 60)) } -// TestProgressiveEnhancement_NoJS tests the app without JavaScript +// ========== HTTP-only Tests (no browser needed) ========== + func TestProgressiveEnhancement_NoJS(t *testing.T) { - server := setupServer(t) - defer server.Close() + srv := setupServer(t) + url := serverURL(srv) - // Test using HTTP client (simulating no-JS browser) client := &http.Client{ CheckRedirect: func(req *http.Request, via []*http.Request) error { - // Don't follow redirects automatically so we can verify PRG pattern return http.ErrUseLastResponse }, } // GET initial page - resp, err := client.Get(server.URL) + resp, err := client.Get(url) if err != nil { t.Fatalf("GET failed: %v", err) } resp.Body.Close() - if resp.StatusCode != http.StatusOK { t.Errorf("Expected status 200, got %d", resp.StatusCode) } // POST a new todo (simulating form submission without JS) form := strings.NewReader("add=&title=HTTP+test+todo") - req, err := http.NewRequest("POST", server.URL, form) + req, err := http.NewRequest("POST", url, form) if err != nil { t.Fatalf("Failed to create request: %v", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Accept", "text/html") // Indicate we want HTML, not JSON + req.Header.Set("Accept", "text/html") resp, err = client.Do(req) if err != nil { @@ -367,36 +456,29 @@ func TestProgressiveEnhancement_NoJS(t *testing.T) { t.Errorf("Expected status 303 (See Other), got %d", resp.StatusCode) } - // Verify redirect URL exists location := resp.Header.Get("Location") if location == "" { t.Error("Expected Location header in redirect response") } - - // The location might be just a path like "/?success=..." - // which is valid for redirects t.Logf("Redirect location: %s", location) } -// TestProgressiveEnhancement_ValidationError tests validation error handling func TestProgressiveEnhancement_ValidationError(t *testing.T) { - server := setupServer(t) - defer server.Close() - + srv := setupServer(t) + url := serverURL(srv) client := &http.Client{} // GET to establish session - resp, err := client.Get(server.URL) + resp, err := client.Get(url) if err != nil { t.Fatalf("GET failed: %v", err) } cookies := resp.Cookies() resp.Body.Close() - // POST with empty title via JSON (like the client library does) - // Field errors are returned inline in JSON responses + // POST with empty title via JSON form := strings.NewReader("add=&title=") - req, err := http.NewRequest("POST", server.URL, form) + req, err := http.NewRequest("POST", url, form) if err != nil { t.Fatalf("Failed to create request: %v", err) } @@ -412,7 +494,6 @@ func TestProgressiveEnhancement_ValidationError(t *testing.T) { } defer resp.Body.Close() - // Should return 200 with JSON containing field errors if resp.StatusCode != http.StatusOK { t.Errorf("Expected status 200, got %d", resp.StatusCode) } @@ -423,16 +504,13 @@ func TestProgressiveEnhancement_ValidationError(t *testing.T) { } } -// TestProgressiveEnhancement_JSClientGetsJSON verifies JS clients get JSON func TestProgressiveEnhancement_JSClientGetsJSON(t *testing.T) { - server := setupServer(t) - defer server.Close() - + srv := setupServer(t) + url := serverURL(srv) client := &http.Client{} - // POST with Accept: application/json (like JS client would) form := strings.NewReader("add=&title=JSON+test") - req, err := http.NewRequest("POST", server.URL, form) + req, err := http.NewRequest("POST", url, form) if err != nil { t.Fatalf("Failed to create request: %v", err) } @@ -445,22 +523,19 @@ func TestProgressiveEnhancement_JSClientGetsJSON(t *testing.T) { } defer resp.Body.Close() - // Should return 200 with JSON if resp.StatusCode != http.StatusOK { t.Errorf("Expected status 200, got %d", resp.StatusCode) } - // Content-Type should be JSON ct := resp.Header.Get("Content-Type") if !strings.Contains(ct, "application/json") { t.Errorf("Expected Content-Type application/json, got %q", ct) } } -// TestProgressiveEnhancement_Toggle tests toggling a todo -func TestProgressiveEnhancement_Toggle(t *testing.T) { - server := setupServer(t) - defer server.Close() +func TestProgressiveEnhancement_HTTPToggle(t *testing.T) { + srv := setupServer(t) + url := serverURL(srv) client := &http.Client{ CheckRedirect: func(req *http.Request, via []*http.Request) error { @@ -468,9 +543,8 @@ func TestProgressiveEnhancement_Toggle(t *testing.T) { }, } - // Toggle an existing todo (ID "1" is pre-populated) form := strings.NewReader("toggle=&id=1") - req, err := http.NewRequest("POST", server.URL, form) + req, err := http.NewRequest("POST", url, form) if err != nil { t.Fatalf("Failed to create request: %v", err) } @@ -483,16 +557,14 @@ func TestProgressiveEnhancement_Toggle(t *testing.T) { } defer resp.Body.Close() - // Should redirect (toggle is a successful action) if resp.StatusCode != http.StatusSeeOther { t.Errorf("Expected status 303 (See Other), got %d", resp.StatusCode) } } -// TestProgressiveEnhancement_Delete tests deleting a todo -func TestProgressiveEnhancement_Delete(t *testing.T) { - server := setupServer(t) - defer server.Close() +func TestProgressiveEnhancement_HTTPDelete(t *testing.T) { + srv := setupServer(t) + url := serverURL(srv) client := &http.Client{ CheckRedirect: func(req *http.Request, via []*http.Request) error { @@ -500,9 +572,8 @@ func TestProgressiveEnhancement_Delete(t *testing.T) { }, } - // Delete an existing todo (ID "2" is pre-populated) form := strings.NewReader("delete=&id=2") - req, err := http.NewRequest("POST", server.URL, form) + req, err := http.NewRequest("POST", url, form) if err != nil { t.Fatalf("Failed to create request: %v", err) } @@ -515,26 +586,17 @@ func TestProgressiveEnhancement_Delete(t *testing.T) { } defer resp.Body.Close() - // Should redirect (delete is a successful action) if resp.StatusCode != http.StatusSeeOther { t.Errorf("Expected status 303 (See Other), got %d", resp.StatusCode) } - - // Verify flash message in redirect URL - location := resp.Header.Get("Location") - if !strings.Contains(location, "success=") { - t.Log("Note: Delete action should set success flash") - } } -// TestProgressiveEnhancement_DisabledReturnsJSON tests that disabling progressive enhancement returns JSON func TestProgressiveEnhancement_DisabledReturnsJSON(t *testing.T) { controller := &TodoController{ validate: validator.New(), } initialState := &TodoState{} - // Create template with progressive enhancement DISABLED tmpl := livetemplate.Must(livetemplate.New("test", livetemplate.WithDevMode(true), livetemplate.WithProgressiveEnhancement(false), @@ -543,14 +605,29 @@ func TestProgressiveEnhancement_DisabledReturnsJSON(t *testing.T) { mux := http.NewServeMux() mux.Handle("/", tmpl.Handle(controller, livetemplate.AsState(initialState))) - server := httptest.NewServer(mux) - defer server.Close() + port, err := e2etest.GetFreePort() + if err != nil { + t.Fatalf("Failed to get free port: %v", err) + } - client := &http.Client{} + srv := &http.Server{Addr: fmt.Sprintf(":%d", port), Handler: mux} + go func() { _ = srv.ListenAndServe() }() + defer srv.Close() + + url := fmt.Sprintf("http://localhost:%d", port) + // Wait for server + for i := 0; i < 50; i++ { + resp, err := http.Get(url) + if err == nil { + resp.Body.Close() + break + } + time.Sleep(100 * time.Millisecond) + } - // POST with Accept: text/html (like a no-JS browser) + client := &http.Client{} form := strings.NewReader("add=&title=test") - req, err := http.NewRequest("POST", server.URL, form) + req, err := http.NewRequest("POST", url, form) if err != nil { t.Fatalf("Failed to create request: %v", err) } @@ -563,251 +640,8 @@ func TestProgressiveEnhancement_DisabledReturnsJSON(t *testing.T) { } defer resp.Body.Close() - // Should still return JSON because progressive enhancement is disabled ct := resp.Header.Get("Content-Type") if !strings.Contains(ct, "application/json") { t.Errorf("Expected Content-Type application/json when progressive enhancement is disabled, got %q", ct) } } - -// TestProgressiveEnhancement_WebSocketCRUD tests add, toggle, and delete operations via WebSocket -// with DOM verification to ensure the UI updates correctly. -func TestProgressiveEnhancement_WebSocketCRUD(t *testing.T) { - server := setupServer(t) - defer server.Close() - - // Create browser context - opts := append(chromedp.DefaultExecAllocatorOptions[:], - chromedp.Flag("headless", true), - ) - allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...) - defer allocCancel() - - ctx, cancel := chromedp.NewContext(allocCtx) - defer cancel() - - ctx, timeoutCancel := context.WithTimeout(ctx, 30*time.Second) - defer timeoutCancel() - - var initialCount int - var afterAddCount int - var afterToggleCount int - var hasCompletedClass bool - var afterUntoggleCount int - var hasCompletedClassAfterUntoggle bool - var afterDeleteCount int - - // Step 1: Navigate and wait for WebSocket to connect - err := chromedp.Run(ctx, - chromedp.Navigate(server.URL), - chromedp.WaitReady(`body`, chromedp.ByQuery), - e2etest.WaitFor(`window.liveTemplateClient && window.liveTemplateClient.isReady()`, 5*time.Second), - chromedp.Evaluate(`document.querySelectorAll('table tbody tr').length`, &initialCount), - ) - if err != nil { - t.Fatalf("Step 1 (navigate) error: %v", err) - } - t.Logf("Initial todo count: %d", initialCount) - - // Step 2: Add a new todo via form submission (client intercepts and routes via WebSocket) - expectedAfterAdd := initialCount + 1 - err = chromedp.Run(ctx, - chromedp.SendKeys(`input[name="title"]`, "E2E Test Todo", chromedp.ByQuery), - chromedp.Click(`button[name="add"]`, chromedp.ByQuery), - // Wait for DOM to update with new item - e2etest.WaitFor(fmt.Sprintf(`document.querySelectorAll('table tbody tr').length === %d`, expectedAfterAdd), 5*time.Second), - chromedp.Evaluate(`document.querySelectorAll('table tbody tr').length`, &afterAddCount), - ) - if err != nil { - t.Fatalf("Step 2 (add) error: %v", err) - } - t.Logf("After add count: %d", afterAddCount) - - // Verify add worked - if afterAddCount != expectedAfterAdd { - t.Errorf("Add failed: expected %d todos, got %d", expectedAfterAdd, afterAddCount) - } - - // Verify flash message appeared after add - var flashText string - chromedp.Run(ctx, - chromedp.Evaluate(`(() => { - const el = document.querySelector('[data-flash="success"]') || document.querySelector('output[role="status"]'); - return el ? el.textContent.trim() : ''; - })()`, &flashText), - ) - if flashText == "" { - t.Log("Note: No flash message found after add (may be cleared)") - } else { - t.Logf("Flash after add: %q", flashText) - if !strings.Contains(strings.ToLower(flashText), "added") { - t.Errorf("Flash message should contain 'added', got %q", flashText) - } - } - - // Step 3: Toggle the last todo (mark as complete) - err = chromedp.Run(ctx, - chromedp.Click(`table tbody tr:last-child button[name="toggle"]`, chromedp.ByQuery), - // Wait for the completed class to appear - e2etest.WaitFor(`document.querySelector('table tbody tr:last-child').querySelector('s') !== null`, 5*time.Second), - chromedp.Evaluate(`document.querySelectorAll('table tbody tr').length`, &afterToggleCount), - chromedp.Evaluate(`document.querySelector('table tbody tr:last-child').querySelector('s') !== null`, &hasCompletedClass), - ) - if err != nil { - t.Fatalf("Step 3 (toggle) error: %v", err) - } - t.Logf("After toggle count: %d, has strikethrough: %v", afterToggleCount, hasCompletedClass) - - // Verify toggle worked - count should stay the same, but item should now have strikethrough - if afterToggleCount != afterAddCount { - t.Errorf("Toggle changed item count: expected %d, got %d", afterAddCount, afterToggleCount) - } - if !hasCompletedClass { - t.Errorf("Toggle failed: item should have strikethrough after toggle") - } - - // Step 4: Toggle again (mark as incomplete) - err = chromedp.Run(ctx, - chromedp.Click(`table tbody tr:last-child button[name="toggle"]`, chromedp.ByQuery), - // Wait for the completed class to be removed - e2etest.WaitFor(`document.querySelector('table tbody tr:last-child').querySelector('s') === null`, 5*time.Second), - chromedp.Evaluate(`document.querySelectorAll('table tbody tr').length`, &afterUntoggleCount), - chromedp.Evaluate(`document.querySelector('table tbody tr:last-child').querySelector('s') !== null`, &hasCompletedClassAfterUntoggle), - ) - if err != nil { - t.Fatalf("Step 4 (untoggle) error: %v", err) - } - t.Logf("After untoggle count: %d, has strikethrough: %v", afterUntoggleCount, hasCompletedClassAfterUntoggle) - - // Verify untoggle worked - count should stay the same, item should NOT have strikethrough - if afterUntoggleCount != afterAddCount { - t.Errorf("Untoggle changed item count: expected %d, got %d", afterAddCount, afterUntoggleCount) - } - if hasCompletedClassAfterUntoggle { - t.Errorf("Untoggle failed: item should NOT have strikethrough after second toggle") - } - - // Step 5: Delete the last todo (the one we just added) - err = chromedp.Run(ctx, - chromedp.Click(`table tbody tr:last-child button[name="delete"]`, chromedp.ByQuery), - // Wait for DOM to update with deleted item - e2etest.WaitFor(fmt.Sprintf(`document.querySelectorAll('table tbody tr').length === %d`, initialCount), 5*time.Second), - chromedp.Evaluate(`document.querySelectorAll('table tbody tr').length`, &afterDeleteCount), - ) - if err != nil { - t.Fatalf("Step 5 (delete) error: %v", err) - } - t.Logf("After delete count: %d", afterDeleteCount) - - // Verify delete worked - if afterDeleteCount != initialCount { - t.Errorf("Delete failed: expected %d todos, got %d", initialCount, afterDeleteCount) - } - - t.Log("SUCCESS: All CRUD operations (add, toggle, untoggle, delete) work correctly") -} - -// TestProgressiveEnhancement_DeleteThenToggle tests toggling a todo after deleting another one. -// This specifically tests that the auto-generated keys work correctly after item removal. -func TestProgressiveEnhancement_DeleteThenToggle(t *testing.T) { - server := setupServer(t) - defer server.Close() - - // Create browser context - opts := append(chromedp.DefaultExecAllocatorOptions[:], - chromedp.Flag("headless", true), - ) - allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...) - defer allocCancel() - - ctx, cancel := chromedp.NewContext(allocCtx) - defer cancel() - - ctx, timeoutCancel := context.WithTimeout(ctx, 30*time.Second) - defer timeoutCancel() - - var initialCount int - var afterDeleteCount int - var afterToggleCount int - var hasCompletedClass bool - - // Step 1: Navigate and wait for WebSocket to connect - err := chromedp.Run(ctx, - chromedp.Navigate(server.URL), - chromedp.WaitReady(`body`, chromedp.ByQuery), - e2etest.WaitFor(`window.liveTemplateClient && window.liveTemplateClient.isReady()`, 5*time.Second), - chromedp.Evaluate(`document.querySelectorAll('table tbody tr').length`, &initialCount), - ) - if err != nil { - t.Fatalf("Step 1 (navigate) error: %v", err) - } - t.Logf("Initial todo count: %d", initialCount) - - if initialCount < 2 { - t.Fatalf("Need at least 2 initial items for this test, got %d", initialCount) - } - - // Step 2: Delete the FIRST todo - expectedAfterDelete := initialCount - 1 - err = chromedp.Run(ctx, - chromedp.Click(`table tbody tr:first-child button[name="delete"]`, chromedp.ByQuery), - // Wait for DOM to update with deleted item - e2etest.WaitFor(fmt.Sprintf(`document.querySelectorAll('table tbody tr').length === %d`, expectedAfterDelete), 5*time.Second), - chromedp.Evaluate(`document.querySelectorAll('table tbody tr').length`, &afterDeleteCount), - ) - if err != nil { - t.Fatalf("Step 2 (delete) error: %v", err) - } - t.Logf("After delete count: %d", afterDeleteCount) - - // Verify delete worked - if afterDeleteCount != expectedAfterDelete { - t.Errorf("Delete failed: expected %d todos, got %d", expectedAfterDelete, afterDeleteCount) - } - - // Step 3: Toggle the LAST remaining todo (different item than what we deleted) - // Check if it has strikethrough before toggle - var hasCompletedClassBefore bool - err = chromedp.Run(ctx, - chromedp.Evaluate(`document.querySelector('table tbody tr:last-child').querySelector('s') !== null`, &hasCompletedClassBefore), - ) - if err != nil { - t.Fatalf("Step 3 (check before toggle) error: %v", err) - } - t.Logf("Last item has strikethrough before toggle: %v", hasCompletedClassBefore) - - // Build the wait condition based on whether we expect completed class or not - toggleWaitCondition := `document.querySelector('table tbody tr:last-child').querySelector('s') !== null` - if hasCompletedClassBefore { - toggleWaitCondition = `document.querySelector('table tbody tr:last-child').querySelector('s') === null` - } - - err = chromedp.Run(ctx, - chromedp.Click(`table tbody tr:last-child button[name="toggle"]`, chromedp.ByQuery), - // Wait for the completed class to change - e2etest.WaitFor(toggleWaitCondition, 5*time.Second), - chromedp.Evaluate(`document.querySelectorAll('table tbody tr').length`, &afterToggleCount), - chromedp.Evaluate(`document.querySelector('table tbody tr:last-child').querySelector('s') !== null`, &hasCompletedClass), - ) - if err != nil { - t.Fatalf("Step 3 (toggle) error: %v", err) - } - t.Logf("After toggle count: %d, has strikethrough: %v", afterToggleCount, hasCompletedClass) - - // Verify toggle worked - count should stay the same - if afterToggleCount != afterDeleteCount { - t.Errorf("Toggle changed item count: expected %d, got %d", afterDeleteCount, afterToggleCount) - } - - // Verify the completed class changed - if hasCompletedClass == hasCompletedClassBefore { - t.Errorf("Toggle failed: strikethrough should have changed from %v to %v", hasCompletedClassBefore, !hasCompletedClassBefore) - } - - t.Log("SUCCESS: Toggle works correctly after deleting a different item") -} - -func init() { - // Suppress log output during tests - fmt.Println("Progressive Enhancement E2E Tests") -} diff --git a/shared-notepad/notepad.tmpl b/shared-notepad/notepad.tmpl index 96f5d51..175a46b 100644 --- a/shared-notepad/notepad.tmpl +++ b/shared-notepad/notepad.tmpl @@ -8,8 +8,10 @@ {{if .lvt.DevMode}} + {{else}} + {{end}} @@ -46,10 +48,5 @@ - {{if .lvt.DevMode}} - - {{else}} - - {{end}} diff --git a/test-all.sh b/test-all.sh index 94ddcaa..b7be6ea 100755 --- a/test-all.sh +++ b/test-all.sh @@ -39,11 +39,10 @@ WORKING_EXAMPLES=( "shared-notepad" "flash-messages" "avatar-upload" + "progressive-enhancement" + "dialog-patterns" ) -# Examples that use local Chrome (not Docker Chrome) — skip in CI -# "progressive-enhancement" uses chromedp.NewExecAllocator (requires local Chrome) - # Disabled examples DISABLED_EXAMPLES=( ) diff --git a/todos/todos.tmpl b/todos/todos.tmpl index 0f942f6..aea7092 100644 --- a/todos/todos.tmpl +++ b/todos/todos.tmpl @@ -155,8 +155,10 @@ /> {{ if .lvt.DevMode }} + {{ else }} + {{ end }} @@ -228,10 +230,5 @@ {{/* Toast Notifications */}} {{ template "lvt:toast:container:v1" .Toasts }} - {{ if .lvt.DevMode }} - - {{ else }} - - {{ end }} diff --git a/todos/todos_test.go b/todos/todos_test.go index 3422638..91841df 100644 --- a/todos/todos_test.go +++ b/todos/todos_test.go @@ -127,7 +127,7 @@ func TestTodosE2E(t *testing.T) { e2etest.WaitForWebSocketReady(5*time.Second), // Wait for WebSocket init and first update chromedp.WaitVisible(`h1`, chromedp.ByQuery), e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"), // Validate no raw template expressions - chromedp.OuterHTML(`body`, &initialHTML, chromedp.ByQuery), + chromedp.OuterHTML(`html`, &initialHTML, chromedp.ByQuery), ) if err != nil { diff --git a/ws-disabled/ws-disabled.tmpl b/ws-disabled/ws-disabled.tmpl index ea55653..be8c3ac 100644 --- a/ws-disabled/ws-disabled.tmpl +++ b/ws-disabled/ws-disabled.tmpl @@ -8,8 +8,10 @@ {{if .lvt.DevMode}} + {{else}} + {{end}} @@ -73,10 +75,5 @@ - {{if .lvt.DevMode}} - - {{else}} - - {{end}}