/** * E2E Tests for Chat Mode Welcome-Screen Suggestion Chips * * The four chips on the welcome screen (chat.html:44-56) each carry a * `data-query` attribute. Clicking a chip pre-fills the textarea and * calls handleSend() (chat.js), guarded against double-fire by * `isProcessing`. * * Contract verified here: * - All four chips render and have non-empty data-query values. * - Clicking a chip starts a chat: welcome screen hides and a user * message bubble appears with the chip's query text. * - The URL transitions from /chat/ to /chat/. * * No LLM required: we stop as soon as the user-side message has been * rendered. We do not wait for the assistant response. * * Prerequisites: Web server on http://127.0.0.1:5000 (override BASE_URL). */ const puppeteer = require('puppeteer'); const AuthHelper = require('../auth_helper'); const { getPuppeteerLaunchOptions } = require('../puppeteer_config'); const fs = require('fs'); const path = require('path'); const BASE_URL = process.env.BASE_URL || 'http://127.0.0.1:5000'; const isCI = !!process.env.CI; const TIMEOUTS = { navigation: isCI ? 60000 : 30000, selector: isCI ? 30000 : 10000, element: isCI ? 10000 : 5000, }; const SCREENSHOTS_DIR = path.join(__dirname, '..', 'screenshots'); async function snap(page, name) { if (!fs.existsSync(SCREENSHOTS_DIR)) { fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true }); } const file = path.join(SCREENSHOTS_DIR, `chat_chips_${name}_${Date.now()}.png`); try { await page.screenshot({ path: file, fullPage: true }); } catch (_) {} } async function gotoChat(page) { await page.goto(`${BASE_URL}/chat/`, { waitUntil: 'domcontentloaded', timeout: TIMEOUTS.navigation, }); await page.waitForSelector('.ldr-chat-container', { timeout: TIMEOUTS.selector }); // Wait for chat.js init() to fully settle, INCLUDING the async // restore of the most-recent session. A bare /chat/ visit // auto-resumes the last session, which hides the welcome screen and // detaches the suggestion chips — so racing the chips here yields a // "Node is not clickable" error. Once init-complete is set, force a // clean New Chat state so the welcome chips are reliably present and // clickable regardless of what earlier tests in the shard left // behind. await page.waitForSelector('#chat-input[data-init-complete="true"]', { timeout: TIMEOUTS.selector }); await page.evaluate(() => window.chatComponent.startNewChat()); await page.waitForFunction( () => { const el = document.getElementById('chat-welcome'); return el && getComputedStyle(el).display !== 'none'; }, { timeout: TIMEOUTS.selector } ); await page.waitForSelector('.ldr-chat-suggestion', { timeout: TIMEOUTS.selector }); } async function run() { console.log(`Running chat suggestion-chip tests (CI mode: ${isCI})`); const browser = await puppeteer.launch(getPuppeteerLaunchOptions()); const page = await browser.newPage(); await page.setViewport({ width: 1280, height: 800 }); if (isCI) { page.setDefaultTimeout(60000); page.setDefaultNavigationTimeout(60000); } page.on('pageerror', (e) => console.log('PAGE ERROR:', e.message)); page.on('console', (m) => { if (m.type() === 'error') console.log('BROWSER ERROR:', m.text()); }); const auth = new AuthHelper(page, BASE_URL); let passed = 0; let failed = 0; try { await auth.ensureAuthenticated(); // Test 1: Four chips render, each with a non-empty data-query console.log('Test 1: Four suggestion chips render with data-query'); try { await gotoChat(page); const chips = await page.$$eval('.ldr-chat-suggestion', (els) => els.map((e) => ({ text: (e.textContent || '').trim(), query: e.dataset.query || '', })) ); if (chips.length !== 4) { throw new Error(`Expected 4 chips, got ${chips.length}`); } for (const c of chips) { if (!c.text) throw new Error('Chip has empty label'); if (!c.query) throw new Error(`Chip "${c.text}" has empty data-query`); } console.log('PASSED — chips:', chips.map((c) => c.text).join(' | ')); passed++; } catch (e) { console.log(`FAILED: ${e.message}`); await snap(page, 'render'); failed++; } // Test 2: Clicking a chip starts a chat and surfaces the user message console.log('Test 2: Clicking a chip sends its query as a user message'); try { await gotoChat(page); const firstQuery = await page.$eval( '.ldr-chat-suggestion', (b) => b.dataset.query ); if (!firstQuery) throw new Error('First chip missing data-query'); // Real click for genuine interaction coverage. CDP input used // to be swallowed after login by Chrome's password-leak dialog // (#4430); that is disabled via chrome_profile.js now. await page.click('.ldr-chat-suggestion'); // Welcome screen hides (chat.js inside handleSend) await page.waitForFunction( () => { const el = document.getElementById('chat-welcome'); return !el || getComputedStyle(el).display === 'none'; }, { timeout: TIMEOUTS.selector } ); // User-bubble with the chip's query appears await page.waitForFunction( (needle) => { const nodes = document.querySelectorAll( '.ldr-chat-message-user .ldr-chat-message-text' ); return Array.from(nodes).some((n) => (n.textContent || '').includes(needle) ); }, { timeout: TIMEOUTS.selector }, firstQuery ); // URL moves from /chat/ -> /chat/ (createSession + pushState, chat.js) await page.waitForFunction( () => /\/chat\/[\w-]+/.test(window.location.pathname), { timeout: TIMEOUTS.selector } ); console.log('PASSED'); passed++; } catch (e) { console.log(`FAILED: ${e.message}`); await snap(page, 'click'); failed++; } // Test 3: Once isProcessing fires, additional chip clicks are no-ops // chat.js — `if (isProcessing) return;` console.log('Test 3: Subsequent chip clicks while processing are ignored'); try { await gotoChat(page); // First click — starts a chat. await page.click('.ldr-chat-suggestion'); // Wait until the first user message is in the DOM await page.waitForFunction( () => document.querySelectorAll( '.ldr-chat-message-user .ldr-chat-message-text' ).length >= 1, { timeout: TIMEOUTS.selector } ); // Click another chip — should be a no-op because isProcessing is // true. JS .click() here on purpose: the welcome screen (and the // chips) are already hidden at this point, so a real page.click // would fail on a non-visible node; the guard under test is the // handler's isProcessing check, not input delivery. await page.evaluate(() => { const chips = document.querySelectorAll('.ldr-chat-suggestion'); if (chips[1]) chips[1].click(); }); // Wait for the network to settle. The first chip click fired a // POST /api/chat/...; once that completes and no new requests // appear for 500ms, the synchronous second-chip handler has // had every opportunity to (incorrectly) add a bubble. This // is deterministic, unlike a wall-clock setTimeout. await page .waitForNetworkIdle({ idleTime: 500, timeout: TIMEOUTS.element }) .catch(() => {}); const userBubbleCount = await page.$$eval( '.ldr-chat-message-user .ldr-chat-message-text', (els) => els.length ); if (userBubbleCount !== 1) { throw new Error( `Expected exactly 1 user bubble, got ${userBubbleCount} ` + '(second chip click should have been blocked by isProcessing)' ); } console.log('PASSED'); passed++; } catch (e) { console.log(`FAILED: ${e.message}`); await snap(page, 'guard'); failed++; } } catch (e) { console.log(`Test suite error: ${e.message}`); failed++; } finally { await browser.close(); } console.log('-'.repeat(50)); console.log(`Chat Suggestion Chip Tests — passed: ${passed}, failed: ${failed}`); console.log('-'.repeat(50)); if (failed > 0) process.exit(1); } run().catch((e) => { console.error('Test runner error:', e); process.exit(1); });