From 93e33f64968e09f009de83d7257c708921a088d9 Mon Sep 17 00:00:00 2001 From: juanatsap Date: Wed, 8 Apr 2026 11:31:09 +0100 Subject: [PATCH] fix: chat submission and session handling + 37 Playwright tests - Fix chip auto-submit: use htmx.trigger() instead of native submit to bypass browser validation on required field - Remove required from input (server validates already) - Fix session_id duplication: use hx-swap-oob to replace single input - Fix agent context: use background context with 30s timeout instead of HTTP request context (prevents premature cancellation) - Remove redundant close button from header (toggle button handles it) - Add 83-chat-mascot.test.mjs: 37 tests covering button, panel, help card, chips, typed questions, session, Spanish, positioning --- internal/chat/handler.go | 12 +- templates/partials/widgets/chat-widget.html | 50 ++- tests/mjs/83-chat-mascot.test.mjs | 375 ++++++++++++++++++++ 3 files changed, 405 insertions(+), 32 deletions(-) create mode 100644 tests/mjs/83-chat-mascot.test.mjs diff --git a/internal/chat/handler.go b/internal/chat/handler.go index 77fa9f2..1dd296b 100644 --- a/internal/chat/handler.go +++ b/internal/chat/handler.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "strings" + "time" "github.com/juanatsap/cv-site/internal/cache" @@ -132,11 +133,14 @@ func (h *Handler) HandleChat(w http.ResponseWriter, r *http.Request) { sessionID = created.Session.ID() } - // Run the agent + // Run the agent with a dedicated context (not tied to HTTP request lifecycle) + agentCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + userMsg := genai.NewContentFromText(message, genai.RoleUser) var response strings.Builder - for event, err := range h.runner.Run(ctx, "visitor", sessionID, userMsg, agent.RunConfig{}) { + for event, err := range h.runner.Run(agentCtx, "visitor", sessionID, userMsg, agent.RunConfig{}) { if err != nil { log.Printf("Chat agent error: %v", err) w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -168,8 +172,8 @@ func (h *Handler) HandleChat(w http.ResponseWriter, r *http.Request) { } _, _ = fmt.Fprintf(w, `
%s
`, formatResponse(agentText)) - // Hidden input to preserve session ID for next request - _, _ = fmt.Fprintf(w, ``, sessionID) + // Update session ID via OOB swap (replaces existing input, avoids duplicates) + _, _ = fmt.Fprintf(w, ``, sessionID) } // formatResponse converts basic markdown to HTML for the chat bubble. diff --git a/templates/partials/widgets/chat-widget.html b/templates/partials/widgets/chat-widget.html index 01d69c2..aba8032 100644 --- a/templates/partials/widgets/chat-widget.html +++ b/templates/partials/widgets/chat-widget.html @@ -27,11 +27,6 @@ _="on click toggle .visible on #chat-help-card"> - @@ -64,37 +59,37 @@
{{if eq .Lang "es"}} - - + - + - + - + + then call htmx.trigger(#chat-form, 'submit')">¿Certificaciones? {{else}} - - + - + - + - + + then call htmx.trigger(#chat-form, 'submit')">Certifications? {{end}}
@@ -104,7 +99,7 @@ hx-swap="beforeend scroll:#chat-messages:bottom" hx-indicator="#chat-typing" _="on htmx:afterRequest set #chat-input.value to ''"> - + + autocomplete="off"> diff --git a/tests/mjs/83-chat-mascot.test.mjs b/tests/mjs/83-chat-mascot.test.mjs new file mode 100644 index 0000000..7060c69 --- /dev/null +++ b/tests/mjs/83-chat-mascot.test.mjs @@ -0,0 +1,375 @@ +#!/usr/bin/env bun +/** + * CV ASSISTANT MASCOT TEST + * ========================= + * Tests the AI chat widget (mascot) functionality + * - Widget visibility and toggle + * - Help popup display and dismiss + * - Suggested question chips + * - Text input and form submission + * - Chat messages rendering + * - Session persistence + * - Language awareness + * - Responsive behavior + */ + +import { chromium } from 'playwright'; + +const URL = "http://localhost:1999"; +const CHAT_TIMEOUT = 15000; // Agent responses can take a few seconds + +async function testChatMascot() { + console.log('🤖 CV ASSISTANT MASCOT TEST\n'); + console.log('='.repeat(70)); + + const browser = await chromium.launch({ headless: false }); + const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } }); + + const errors = []; + const testResults = []; + let passed = 0; + let failed = 0; + + function record(name, success, detail = '') { + testResults.push({ name, success, detail }); + if (success) { + passed++; + console.log(` ✅ ${name}${detail ? ' — ' + detail : ''}`); + } else { + failed++; + console.log(` ❌ ${name}${detail ? ' — ' + detail : ''}`); + } + } + + page.on('console', msg => { + if (msg.type() === 'error') { + errors.push(msg.text()); + } + }); + + // ======================================================================== + // SETUP: Load page + // ======================================================================== + console.log("\n📋 Loading page..."); + await page.goto(`${URL}/?lang=en`); + await page.waitForTimeout(2000); + + // ======================================================================== + // TEST 1: Mascot button exists and is visible + // ======================================================================== + console.log("\n1️⃣ Mascot Button Presence"); + + const btnExists = await page.locator('#chat-toggle-btn').isVisible(); + record('Mascot toggle button is visible', btnExists); + + const btnIcon = await page.locator('#chat-toggle-btn .chat-icon-open').isVisible(); + record('Mascot icon (robot) is visible', btnIcon); + + const closeIconHidden = await page.locator('#chat-toggle-btn .chat-icon-close').isHidden(); + record('Close icon is hidden initially', closeIconHidden); + + // ======================================================================== + // TEST 2: Chat panel starts closed + // ======================================================================== + console.log("\n2️⃣ Initial State"); + + const panelHidden = await page.locator('#chat-panel').isHidden(); + record('Chat panel is hidden initially', panelHidden); + + // ======================================================================== + // TEST 3: Click mascot opens chat panel + // ======================================================================== + console.log("\n3️⃣ Open Chat Panel"); + + await page.click('#chat-toggle-btn'); + await page.waitForTimeout(500); + + const panelOpen = await page.locator('#chat-panel').isVisible(); + record('Chat panel opens on click', panelOpen); + + const hasMascotActive = await page.locator('#chat-toggle-btn.mascot-active').count(); + record('Button has mascot-active class', hasMascotActive > 0); + + // ======================================================================== + // TEST 4: Help card is visible on first open + // ======================================================================== + console.log("\n4️⃣ Help Card (Onboarding)"); + + const helpVisible = await page.locator('#chat-help-card.visible').isVisible(); + record('Help card is visible on first open', helpVisible); + + const helpText = await page.locator('.chat-help-text').textContent(); + record('Help text contains assistant description', + helpText.includes('CV Assistant') || helpText.includes('Asistente')); + + const dismissBtn = await page.locator('.chat-help-dismiss').isVisible(); + record('Dismiss button is visible', dismissBtn); + + // ======================================================================== + // TEST 5: Dismiss help card + // ======================================================================== + console.log("\n5️⃣ Dismiss Help Card"); + + await page.click('.chat-help-dismiss'); + await page.waitForTimeout(300); + + const helpDismissed = await page.locator('#chat-help-card.visible').count(); + record('Help card dismissed after click', helpDismissed === 0); + + // ======================================================================== + // TEST 6: Help button re-toggles help card + // ======================================================================== + console.log("\n6️⃣ Re-toggle Help Card"); + + await page.click('.chat-help-btn'); + await page.waitForTimeout(300); + + const helpReOpened = await page.locator('#chat-help-card.visible').isVisible(); + record('Help card re-opens via ? button', helpReOpened); + + // Dismiss again for clean state + await page.click('.chat-help-dismiss'); + await page.waitForTimeout(200); + + // ======================================================================== + // TEST 7: Initial welcome message exists + // ======================================================================== + console.log("\n7️⃣ Welcome Message"); + + const welcomeMsg = await page.locator('#chat-messages .chat-agent').first().textContent(); + record('Welcome message is present', + welcomeMsg.includes('Ask me anything') || welcomeMsg.includes('Pregúntame')); + + // ======================================================================== + // TEST 8: Suggested question chips exist + // ======================================================================== + console.log("\n8️⃣ Suggested Question Chips"); + + const chipCount = await page.locator('.chat-chip').count(); + record('5 suggested question chips exist', chipCount === 5, `found ${chipCount}`); + + const firstChipText = await page.locator('.chat-chip').first().textContent(); + record('First chip has text', firstChipText.length > 0, firstChipText); + + // ======================================================================== + // TEST 9: Text input exists and is functional + // ======================================================================== + console.log("\n9️⃣ Text Input"); + + const inputExists = await page.locator('#chat-input').isVisible(); + record('Chat input is visible', inputExists); + + const placeholder = await page.locator('#chat-input').getAttribute('placeholder'); + record('Input has placeholder', + placeholder.includes('Ask something') || placeholder.includes('Pregunta')); + + // ======================================================================== + // TEST 10: Send button exists + // ======================================================================== + console.log("\n🔟 Send Button"); + + const sendBtn = await page.locator('.chat-send-btn').isVisible(); + record('Send button is visible', sendBtn); + + // ======================================================================== + // TEST 11: Chip click fills input and submits + // ======================================================================== + console.log("\n1️⃣1️⃣ Chip Click → Submit"); + + const msgCountBefore = await page.locator('#chat-messages .chat-message').count(); + + await page.click('.chat-chip >> text=Go projects'); + // Wait for the response (agent call takes time) + await page.waitForSelector('#chat-messages .chat-user', { timeout: CHAT_TIMEOUT }); + + const userMsg = await page.locator('#chat-messages .chat-user').last().textContent(); + record('User message appears after chip click', + userMsg.includes('Go projects'), userMsg.substring(0, 50)); + + // Wait for agent response + await page.waitForSelector('#chat-messages .chat-agent:nth-child(3)', { timeout: CHAT_TIMEOUT }); + + const agentMsg = await page.locator('#chat-messages .chat-agent').last().textContent(); + record('Agent response appears', + agentMsg.length > 20, `${agentMsg.substring(0, 60)}...`); + + record('Agent mentions Go projects', + agentMsg.toLowerCase().includes('go') || agentMsg.toLowerCase().includes('immich') || agentMsg.toLowerCase().includes('cmux')); + + // ======================================================================== + // TEST 12: Type and submit custom question + // ======================================================================== + console.log("\n1️⃣2️⃣ Type Custom Question"); + + const userMsgCountBefore = await page.locator('#chat-messages .chat-user').count(); + await page.fill('#chat-input', 'How many years of experience?'); + await page.click('.chat-send-btn'); + + // Wait for a new user message to appear + await page.waitForFunction( + (count) => document.querySelectorAll('#chat-messages .chat-user').length > count, + userMsgCountBefore, + { timeout: CHAT_TIMEOUT } + ); + const customUserMsg = await page.locator('#chat-messages .chat-user').last().textContent(); + record('Custom typed message appears', + customUserMsg.includes('years of experience')); + + // Wait for agent response + await page.waitForFunction(() => { + const msgs = document.querySelectorAll('#chat-messages .chat-agent'); + return msgs.length >= 3; + }, { timeout: CHAT_TIMEOUT }); + + const customAgentMsg = await page.locator('#chat-messages .chat-agent').last().textContent(); + record('Agent responds to custom question', + customAgentMsg.length > 10, `${customAgentMsg.substring(0, 60)}...`); + + // ======================================================================== + // TEST 13: Input clears after submit + // ======================================================================== + console.log("\n1️⃣3️⃣ Input Clear After Submit"); + + const inputValueAfter = await page.locator('#chat-input').inputValue(); + record('Input is cleared after submission', inputValueAfter === ''); + + // ======================================================================== + // TEST 14: Session ID persisted + // ======================================================================== + console.log("\n1️⃣4️⃣ Session Persistence"); + + const sessionId = await page.locator('#chat-session-id').inputValue(); + record('Session ID is set after response', + sessionId.length > 0, sessionId.substring(0, 20) + '...'); + + // ======================================================================== + // TEST 15: Close and reopen panel + // ======================================================================== + console.log("\n1️⃣5️⃣ Close and Reopen"); + + await page.click('#chat-toggle-btn'); + await page.waitForTimeout(300); + + const panelClosedAgain = await page.locator('#chat-panel').isHidden(); + record('Panel closes on second toggle click', panelClosedAgain); + + await page.click('#chat-toggle-btn'); + await page.waitForTimeout(300); + + const panelReopened = await page.locator('#chat-panel').isVisible(); + record('Panel reopens on third click', panelReopened); + + // Messages should still be there + const msgCountAfterReopen = await page.locator('#chat-messages .chat-message').count(); + record('Messages preserved after reopen', msgCountAfterReopen >= 3, + `${msgCountAfterReopen} messages`); + + // ======================================================================== + // TEST 16: Chat header content + // ======================================================================== + console.log("\n1️⃣6️⃣ Header Content"); + + const headerText = await page.locator('.chat-header span').textContent(); + record('Header shows "CV Assistant"', + headerText.includes('CV Assistant') || headerText.includes('Asistente')); + + const helpBtnExists = await page.locator('.chat-help-btn').isVisible(); + record('Help button (?) in header', helpBtnExists); + + // ======================================================================== + // TEST 17: Spanish language version + // ======================================================================== + console.log("\n1️⃣7️⃣ Spanish Language"); + + // Close chat first + await page.click('#chat-toggle-btn'); + await page.waitForTimeout(200); + + await page.goto(`${URL}/?lang=es`); + await page.waitForTimeout(2000); + + await page.click('#chat-toggle-btn'); + await page.waitForTimeout(500); + + const esHeaderText = await page.locator('.chat-header span').textContent(); + record('Spanish header shows "Asistente del CV"', + esHeaderText.includes('Asistente del CV')); + + const esChipText = await page.locator('.chat-chip').first().textContent(); + record('Spanish chips are in Spanish', + esChipText.includes('Go') || esChipText.includes('Proyectos')); + + const esWelcome = await page.locator('#chat-messages .chat-agent').first().textContent(); + record('Spanish welcome message', + esWelcome.includes('Pregúntame') || esWelcome.includes('Hola')); + + const esPlaceholder = await page.locator('#chat-input').getAttribute('placeholder'); + record('Spanish placeholder', + esPlaceholder.includes('Pregunta')); + + // ======================================================================== + // TEST 18: Empty message handling + // ======================================================================== + console.log("\n1️⃣8️⃣ Empty Message Handling"); + + const msgCountBeforeEmpty = await page.locator('#chat-messages .chat-message').count(); + await page.fill('#chat-input', ''); + await page.click('.chat-send-btn'); + await page.waitForTimeout(1000); + + const msgCountAfterEmpty = await page.locator('#chat-messages .chat-message').count(); + // Should show error or stay the same + record('Empty message handled gracefully', msgCountAfterEmpty >= msgCountBeforeEmpty); + + // ======================================================================== + // TEST 19: No console errors + // ======================================================================== + console.log("\n1️⃣9️⃣ Console Errors"); + + const chatErrors = errors.filter(e => + e.includes('chat') || e.includes('htmx') || e.includes('hyperscript')); + record('No chat-related console errors', chatErrors.length === 0, + chatErrors.length > 0 ? chatErrors.join(', ') : 'clean'); + + // ======================================================================== + // TEST 20: Chat panel CSS positioning + // ======================================================================== + console.log("\n2️⃣0️⃣ CSS Positioning"); + + const btnPos = await page.locator('#chat-toggle-btn').boundingBox(); + record('Button is on the left half of screen', + btnPos && btnPos.x < 200, `x=${btnPos?.x}`); + + const panelPos = await page.locator('#chat-panel').boundingBox(); + record('Panel is on the left side', + panelPos && panelPos.x < 200, `x=${panelPos?.x}`); + + // ======================================================================== + // SUMMARY + // ======================================================================== + console.log('\n' + '='.repeat(70)); + console.log(`\n📊 RESULTS: ${passed} passed, ${failed} failed (${testResults.length} total)\n`); + + if (failed > 0) { + console.log('❌ FAILED TESTS:'); + testResults.filter(t => !t.success).forEach(t => { + console.log(` • ${t.name}${t.detail ? ' — ' + t.detail : ''}`); + }); + console.log(''); + } + + if (errors.length > 0) { + console.log(`⚠️ Console errors: ${errors.length}`); + errors.forEach(e => console.log(` • ${e}`)); + } + + await browser.close(); + + console.log(failed === 0 ? '✅ ALL TESTS PASSED!' : '❌ SOME TESTS FAILED'); + process.exit(failed > 0 ? 1 : 0); +} + +testChatMascot().catch(err => { + console.error('💥 Test crashed:', err.message); + process.exit(1); +});