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);
+});