/** * E2E Test for Chat Mode In-Place Title Edit * * The pencil button (#edit-title-btn) is only rendered once a session has * messages (showSessionButtons in chat.js). Clicking it pops a * `window.prompt()` for the new title; on confirm, chat.js PATCHes the * session and calls updateTitle() on success. * * Verified here: * - Pencil is hidden on a fresh welcome screen. * - After a session exists, the pencil becomes visible. * - Clicking it (with window.prompt stubbed) issues a PATCH and the *

text updates. * * No LLM required: we create the session + first message via API with * trigger_research=false, then drive the pencil flow. * * 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 }); } try { await page.screenshot({ path: path.join( SCREENSHOTS_DIR, `chat_edit_title_${name}_${Date.now()}.png` ), fullPage: true, }); } catch (_) {} } async function getCsrf(page) { return page.evaluate(() => { const m = document.querySelector('meta[name="csrf-token"]'); return m ? m.content : ''; }); } async function api(page, csrfOuter, urlOuter, methodOuter = 'GET', bodyOuter = null) { return page.evaluate( async ({ url, method, body, csrf }) => { const opts = { method, headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrf, }, }; if (body !== null) opts.body = JSON.stringify(body); const r = await fetch(url, opts); let data = null; try { data = await r.json(); } catch (_) {} return { ok: r.ok, status: r.status, data }; }, { url: urlOuter, method: methodOuter, body: bodyOuter, csrf: csrfOuter } ); } async function run() { console.log(`Running chat edit-title 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(); // Land on chat page to get CSRF. await page.goto(`${BASE_URL}/chat/`, { waitUntil: 'domcontentloaded', timeout: TIMEOUTS.navigation, }); await page.waitForSelector('.ldr-chat-container', { timeout: TIMEOUTS.selector }); const csrf = await getCsrf(page); // A bare /chat/ visit auto-resumes the most-recent session, which // shows the pencil (edit-title) button and would break the "hidden // on a fresh welcome" assertion below. Wait for chat.js init to // settle, then force a clean New Chat state so the welcome screen is // reliably shown regardless of what earlier tests in the shard (or a // CI retry) 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 } ); // Test 1: pencil hidden on a fresh welcome (no session) console.log('Test 1: edit-title button hidden on fresh chat'); try { const hidden = await page.$eval( '#edit-title-btn', (el) => getComputedStyle(el).display === 'none' ); if (!hidden) throw new Error('Pencil visible before any session'); console.log('PASSED'); passed++; } catch (e) { console.log(`FAILED: ${e.message}`); await snap(page, 'pencil_initial'); failed++; } // Create a session via API; populate at least one message so the // pencil becomes visible after we navigate into the session. const created = await api(page, csrf, '/api/chat/sessions', 'POST', { initial_query: 'edit-title-test seed', }); if (!created.ok || !created.data?.success) { throw new Error( `Session creation failed: ${created.status} ${JSON.stringify(created.data)}` ); } const sessionId = created.data.session_id; await api( page, csrf, `/api/chat/sessions/${sessionId}/messages`, 'POST', { content: 'hello', trigger_research: false } ); // Test 2: navigating to /chat/ shows the pencil console.log('Test 2: edit-title button visible inside a session'); try { await page.goto(`${BASE_URL}/chat/${sessionId}`, { waitUntil: 'domcontentloaded', timeout: TIMEOUTS.navigation, }); await page.waitForSelector('.ldr-chat-container', { timeout: TIMEOUTS.selector, }); // The pencil flip happens asynchronously inside loadSession — // wait for the inline-block style instead of querying once. await page.waitForFunction( () => { const el = document.getElementById('edit-title-btn'); return el && getComputedStyle(el).display !== 'none'; }, { timeout: TIMEOUTS.selector } ); console.log('PASSED'); passed++; } catch (e) { console.log(`FAILED: ${e.message}`); await snap(page, 'pencil_in_session'); failed++; } // Test 3: clicking the pencil with a stubbed prompt() updates the // title both in the DOM and on the server. console.log('Test 3: pencil click PATCHes title and updates DOM'); try { const newTitle = `Edited title ${Date.now()}`; // Stub window.prompt so the click can proceed unattended. await page.evaluate((t) => { window.prompt = () => t; }, newTitle); // Click via evaluate so headless interactability heuristics // can't refuse a small button at the page's top-right. await page.evaluate(() => { document.getElementById('edit-title-btn').click(); }); // Wait for the H1 text to flip — chat.js calls updateTitle on // a successful PATCH. await page.waitForFunction( (expected) => (document.getElementById('chat-title').textContent || '').trim() === expected, { timeout: TIMEOUTS.selector }, newTitle ); // Also verify the server actually persisted it by GETting back. const fetched = await api( page, csrf, `/api/chat/sessions/${sessionId}` ); const serverTitle = fetched.data?.session?.title; if (serverTitle !== newTitle) { throw new Error( `Server title mismatch: expected ${JSON.stringify(newTitle)}, got ${JSON.stringify(serverTitle)}` ); } console.log('PASSED'); passed++; } catch (e) { console.log(`FAILED: ${e.message}`); await snap(page, 'patch_title'); failed++; } // Test 4: cancelling the prompt (returning null) leaves the title // alone — no PATCH issued. console.log('Test 4: cancelling prompt leaves title unchanged'); try { const titleBefore = await page.$eval( '#chat-title', (h) => (h.textContent || '').trim() ); await page.evaluate(() => { window.prompt = () => null; }); await page.evaluate(() => { document.getElementById('edit-title-btn').click(); }); // Wait for the network to be quiet — if the cancel was // (incorrectly) treated as a rename, a PATCH would have // fired and we'd see network activity. waitForNetworkIdle // is deterministic vs. a wall-clock setTimeout. await page .waitForNetworkIdle({ idleTime: 500, timeout: TIMEOUTS.element }) .catch(() => {}); const titleAfter = await page.$eval( '#chat-title', (h) => (h.textContent || '').trim() ); if (titleAfter !== titleBefore) { throw new Error( `Title changed despite cancel: ${JSON.stringify(titleBefore)} -> ${JSON.stringify(titleAfter)}` ); } console.log('PASSED'); passed++; } catch (e) { console.log(`FAILED: ${e.message}`); await snap(page, 'cancel_prompt'); failed++; } } catch (e) { console.log(`Test suite error: ${e.message}`); failed++; } finally { await browser.close(); } console.log('-'.repeat(50)); console.log(`Chat Edit-Title 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); });