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
This commit is contained in:
juanatsap
2026-04-08 11:31:09 +01:00
parent b0e8e1ced7
commit 93e33f6496
3 changed files with 405 additions and 32 deletions
+8 -4
View File
@@ -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, `<div class="chat-message chat-agent">%s</div>`, formatResponse(agentText))
// Hidden input to preserve session ID for next request
_, _ = fmt.Fprintf(w, `<input type="hidden" name="session_id" value="%s" form="chat-form"/>`, sessionID)
// Update session ID via OOB swap (replaces existing input, avoids duplicates)
_, _ = fmt.Fprintf(w, `<input type="hidden" id="chat-session-id" name="session_id" value="%s" form="chat-form" hx-swap-oob="true"/>`, sessionID)
}
// formatResponse converts basic markdown to HTML for the chat bubble.
+22 -28
View File
@@ -27,11 +27,6 @@
_="on click toggle .visible on #chat-help-card">
<iconify-icon icon="mdi:help-circle-outline"></iconify-icon>
</button>
<button class="chat-close-btn"
_="on click remove .chat-open from #chat-panel
then remove .mascot-active from #chat-toggle-btn">
<iconify-icon icon="mdi:close"></iconify-icon>
</button>
</div>
<!-- Help / Onboarding Card -->
@@ -64,37 +59,37 @@
<!-- Suggested Questions -->
<div class="chat-suggestions">
{{if eq .Lang "es"}}
<button class="chat-chip"
<button type="button" class="chat-chip"
_="on click set #chat-input.value to '¿Qué proyectos en Go ha hecho?'
then send submit to #chat-form">¿Proyectos en Go?</button>
<button class="chat-chip"
then call htmx.trigger(#chat-form, 'submit')">¿Proyectos en Go?</button>
<button type="button" class="chat-chip"
_="on click set #chat-input.value to '¿Cuántos años de experiencia tiene?'
then send submit to #chat-form">¿Años de experiencia?</button>
<button class="chat-chip"
then call htmx.trigger(#chat-form, 'submit')">¿Años de experiencia?</button>
<button type="button" class="chat-chip"
_="on click set #chat-input.value to '¿En qué empresas ha trabajado?'
then send submit to #chat-form">¿Empresas?</button>
<button class="chat-chip"
then call htmx.trigger(#chat-form, 'submit')">¿Empresas?</button>
<button type="button" class="chat-chip"
_="on click set #chat-input.value to '¿Conoce React?'
then send submit to #chat-form">¿Conoce React?</button>
<button class="chat-chip"
then call htmx.trigger(#chat-form, 'submit')">¿Conoce React?</button>
<button type="button" class="chat-chip"
_="on click set #chat-input.value to '¿Qué certificaciones tiene?'
then send submit to #chat-form">¿Certificaciones?</button>
then call htmx.trigger(#chat-form, 'submit')">¿Certificaciones?</button>
{{else}}
<button class="chat-chip"
<button type="button" class="chat-chip"
_="on click set #chat-input.value to 'What Go projects has he built?'
then send submit to #chat-form">Go projects?</button>
<button class="chat-chip"
then call htmx.trigger(#chat-form, 'submit')">Go projects?</button>
<button type="button" class="chat-chip"
_="on click set #chat-input.value to 'How many years of experience?'
then send submit to #chat-form">Years of experience?</button>
<button class="chat-chip"
then call htmx.trigger(#chat-form, 'submit')">Years of experience?</button>
<button type="button" class="chat-chip"
_="on click set #chat-input.value to 'What companies has he worked at?'
then send submit to #chat-form">Companies?</button>
<button class="chat-chip"
then call htmx.trigger(#chat-form, 'submit')">Companies?</button>
<button type="button" class="chat-chip"
_="on click set #chat-input.value to 'Does he know React?'
then send submit to #chat-form">Knows React?</button>
<button class="chat-chip"
then call htmx.trigger(#chat-form, 'submit')">Knows React?</button>
<button type="button" class="chat-chip"
_="on click set #chat-input.value to 'What certifications?'
then send submit to #chat-form">Certifications?</button>
then call htmx.trigger(#chat-form, 'submit')">Certifications?</button>
{{end}}
</div>
@@ -104,7 +99,7 @@
hx-swap="beforeend scroll:#chat-messages:bottom"
hx-indicator="#chat-typing"
_="on htmx:afterRequest set #chat-input.value to ''">
<input type="hidden" name="session_id" value="">
<input type="hidden" id="chat-session-id" name="session_id" value="">
<input type="hidden" name="lang" value="{{.Lang}}">
<input
type="text"
@@ -112,8 +107,7 @@
name="message"
class="chat-input"
placeholder="{{if eq .Lang "es"}}Pregunta algo sobre el CV...{{else}}Ask something about the CV...{{end}}"
autocomplete="off"
required>
autocomplete="off">
<button type="submit" class="chat-send-btn" aria-label="Send">
<iconify-icon icon="mdi:send"></iconify-icon>
</button>
+375
View File
@@ -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);
});