/** * Comprehensive End-to-End Browser Test Runner. * Connects directly to Chrome running on port 9444 via CDP, * tests the Web UI against all requirements in docs/implementation/WEBUI.md and CONTRACTS.md. */ import http from 'node:http'; import assert from 'node:assert/strict'; import { createMockServer } from '../dev/mock-server.js'; const MOCK_PORT = 5173; const CHROME_PORT = 9444; const APP_URL = `http://127.0.0.1:${MOCK_PORT}/`; class CDPClient { constructor(wsUrl) { this.wsUrl = wsUrl; this.ws = null; this.msgId = 1; this.pending = new Map(); this.consoleLogs = []; this.networkRequests = []; } async connect() { this.ws = new WebSocket(this.wsUrl); await new Promise((resolve, reject) => { this.ws.onopen = resolve; this.ws.onerror = reject; }); this.ws.onmessage = (event) => { const data = JSON.parse(event.data); if (data.id && this.pending.has(data.id)) { const { resolve, reject } = this.pending.get(data.id); this.pending.delete(data.id); if (data.error) reject(new Error(data.error.message || JSON.stringify(data.error))); else resolve(data.result); } else if (data.method === 'Runtime.consoleAPICalled') { this.consoleLogs.push(data.params); } else if (data.method === 'Network.requestWillBeSent') { this.networkRequests.push(data.params); } }; // Enable Runtime and Network domains await this.send('Runtime.enable'); await this.send('Network.enable'); } send(method, params = {}) { return new Promise((resolve, reject) => { const id = this.msgId++; this.pending.set(id, { resolve, reject }); this.ws.send(JSON.stringify({ id, method, params })); }); } async eval(expression) { const res = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); if (res.exceptionDetails) { throw new Error(`Eval error: ${res.exceptionDetails.text || ''} ${res.exceptionDetails.exception?.description || ''}`); } return res.result ? res.result.value : undefined; } async navigate(url) { await this.send('Page.navigate', { url }); // Wait for load event await new Promise((resolve) => setTimeout(resolve, 800)); } close() { if (this.ws) { this.ws.close(); } } } async function runTests() { console.log('=== Starting Web UI End-to-End Tests with Chrome (port 9444) ===\n'); // 1. Start mock server const server = createMockServer(); await new Promise((resolve) => server.listen(MOCK_PORT, '127.0.0.1', resolve)); console.log(`✓ Mock server started at http://127.0.0.1:${MOCK_PORT}/`); // 2. Discover Chrome tab on port 9444 const tabsRes = await fetch(`http://127.0.0.1:${CHROME_PORT}/json`); const tabs = await tabsRes.json(); const pageTab = tabs.find((t) => t.type === 'page') || tabs[0]; if (!pageTab || !pageTab.webSocketDebuggerUrl) { throw new Error('No active Chrome page tab found on port 9444'); } const cdp = new CDPClient(pageTab.webSocketDebuggerUrl); await cdp.connect(); console.log(`✓ Connected to Chrome tab: "${pageTab.title}" via CDP\n`); let passed = 0; let failed = 0; async function step(name, fn) { process.stdout.write(`• Test: ${name}... `); try { await fn(); console.log('PASS'); passed++; } catch (err) { console.log(`FAIL: ${err.message}`); failed++; } } try { // Reset scenario on mock server await fetch(`http://127.0.0.1:${MOCK_PORT}/dev/scenario`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scenario: 'normal' }) }); // Navigate to Web UI await cdp.navigate(APP_URL); await cdp.eval('document.cookie = "mock_scenario=normal; path=/";'); await cdp.navigate(APP_URL); // Test 1: Page Load & Initial View await step('Page Load & Initial View', async () => { const title = await cdp.eval('document.title'); assert.equal(title, 'Confluence Research'); const isPromptVisible = await cdp.eval('!document.getElementById("view-prompt").classList.contains("hidden")'); assert.equal(isPromptVisible, true, 'Prompt view must be visible initially'); const hasIndicator = await cdp.eval('document.getElementById("cred-indicator").classList.contains("active")'); assert.equal(hasIndicator, false, 'Credentials indicator must be unset (gray)'); const helperText = await cdp.eval('document.querySelector(".prompt-helper").textContent'); assert.ok(helperText.includes('Press Enter ↵ to send')); }); // Test 2: Credentials Modal Opening and Focus Trapping await step('Credentials Modal & Focus Trapping', async () => { // Click key button await cdp.eval('document.getElementById("key-btn").click()'); const isModalOpen = await cdp.eval('!document.getElementById("modal-backdrop").classList.contains("hidden")'); assert.equal(isModalOpen, true, 'Modal should be open'); const focusedId = await cdp.eval('document.activeElement.id'); assert.equal(focusedId, 'cred-url', 'URL input should be focused on open'); // Test Cancel closes modal and restores focus await cdp.eval('document.getElementById("btn-cancel-cred").click()'); const isModalClosed = await cdp.eval('document.getElementById("modal-backdrop").classList.contains("hidden")'); assert.equal(isModalClosed, true, 'Modal should close on cancel'); const restoredFocus = await cdp.eval('document.activeElement.id'); assert.equal(restoredFocus, 'key-btn', 'Focus should return to key-btn'); }); // Test 3: Password Visibility Toggle & Test Connection await step('Password Toggle & Test Connection', async () => { await cdp.eval('document.getElementById("key-btn").click()'); // Set input values await cdp.eval(` document.getElementById("cred-url").value = "https://approved.example.com"; document.getElementById("cred-pat").value = "dummy-pat-123"; `); // Toggle password const typeBefore = await cdp.eval('document.getElementById("cred-pat").type'); assert.equal(typeBefore, 'password'); await cdp.eval('document.getElementById("toggle-pat-btn").click()'); const typeAfter = await cdp.eval('document.getElementById("cred-pat").type'); assert.equal(typeAfter, 'text'); await cdp.eval('document.getElementById("toggle-pat-btn").click()'); // Click Test Connection await cdp.eval('document.getElementById("btn-test-cred").click()'); await new Promise((r) => setTimeout(r, 400)); const feedback = await cdp.eval('document.getElementById("modal-feedback").textContent'); assert.ok(feedback.includes('Connection successful'), `Feedback should indicate success: ${feedback}`); }); // Test 4: Save Credentials & Canary Leak Inspection await step('Save Credentials & Memory Canary Inspection', async () => { // Click Save & Close await cdp.eval('document.getElementById("btn-save-cred").click()'); await new Promise((r) => setTimeout(r, 200)); const isModalClosed = await cdp.eval('document.getElementById("modal-backdrop").classList.contains("hidden")'); assert.equal(isModalClosed, true, 'Modal should close after save'); const isActive = await cdp.eval('document.getElementById("cred-indicator").classList.contains("active")'); assert.equal(isActive, true, 'Credential indicator should turn green/active'); // Canary inspection: ensure credentials never entered localStorage, sessionStorage, or URL const canary = 'dummy-pat-123'; const localDump = await cdp.eval('JSON.stringify(localStorage)'); const sessionDump = await cdp.eval('JSON.stringify(sessionStorage)'); const href = await cdp.eval('window.location.href'); assert.ok(!localDump.includes(canary), 'localStorage must not contain credential canary'); assert.ok(!sessionDump.includes(canary), 'sessionStorage must not contain credential canary'); assert.ok(!href.includes(canary), 'URL must not contain tokens'); }); // Test 5: Research Query Submission & Loading View await step('Query Submission & Loading Thinking Orb', async () => { // Set prompt await cdp.eval('document.getElementById("prompt-input").value = "How do I deploy service X?"'); await cdp.eval('document.getElementById("submit-btn").click()'); // Check loading view const isLoadingVisible = await cdp.eval('!document.getElementById("view-loading").classList.contains("hidden")'); assert.equal(isLoadingVisible, true, 'Loading view must be visible while query executes'); const statusText = await cdp.eval('document.querySelector(".loading-status").textContent'); assert.equal(statusText, 'Agent researching Confluence...'); const orbPresent = await cdp.eval('!!document.querySelector("canvas.thinking-orb")'); assert.equal(orbPresent, true, 'Thinking orb canvas must be present'); // Wait for query completion and result view await new Promise((r) => setTimeout(r, 600)); const isResultVisible = await cdp.eval('!document.getElementById("view-result").classList.contains("hidden")'); assert.equal(isResultVisible, true, 'Result view must be displayed upon query completion'); }); // Test 6: Result View Rendering, Citation Links, and Security await step('Result View, Citation Links & Sanitization', async () => { const heading = await cdp.eval('document.querySelector("#output-content h1").textContent'); assert.ok(heading.includes('Deployment Guide for Service X')); // Verify citation link attributes const linkTarget = await cdp.eval('document.querySelector("#output-content a").target'); const linkRel = await cdp.eval('document.querySelector("#output-content a").rel'); const linkHref = await cdp.eval('document.querySelector("#output-content a").href'); assert.equal(linkTarget, '_blank', 'Citation link must open in new tab'); assert.equal(linkRel, 'noopener noreferrer', 'Citation link must have rel=noopener noreferrer'); assert.ok(linkHref.includes('847291')); // Intercept click: verify click targets canonical URL without automatic prefetch const citationTarget = await cdp.eval(` (() => { const link = document.querySelector("#output-content a"); return { target: link.getAttribute('target'), rel: link.getAttribute('rel'), href: link.href }; })() `); assert.equal(citationTarget.target, '_blank'); assert.equal(citationTarget.rel, 'noopener noreferrer'); assert.ok(citationTarget.href.startsWith('https://approved.example.com')); }); // Test 7: Export to MD Client-Side Blob and Exact Bytes await step('Export to MD Client-Side Blob and Exact Bytes', async () => { const exportResult = await cdp.eval(` new Promise((resolve) => { let capturedBlob = null; let revoked = false; const origCreate = URL.createObjectURL; const origRevoke = URL.revokeObjectURL; URL.createObjectURL = (blob) => { capturedBlob = blob; return origCreate(blob); }; URL.revokeObjectURL = (url) => { revoked = true; return origRevoke(url); }; document.getElementById("export-btn").click(); URL.createObjectURL = origCreate; URL.revokeObjectURL = origRevoke; if (!capturedBlob) { resolve({ error: 'No blob captured' }); return; } const reader = new FileReader(); reader.onload = () => { resolve({ text: reader.result, size: capturedBlob.size, type: capturedBlob.type, revoked }); }; reader.readAsText(capturedBlob); }) `); assert.ok(!exportResult.error, exportResult.error); assert.ok(exportResult.text.includes('Deployment Guide for Service X'), 'Exported text must match result markdown'); assert.ok(exportResult.type.includes('text/markdown'), 'Blob type must be text/markdown'); assert.equal(exportResult.revoked, true, 'Object URL must be revoked after dispatch'); }); // Test 8: Artifact Listing & Exact Download await step('Artifact Listing & Attachment Download', async () => { const artName = await cdp.eval('document.querySelector(".artifact-name").textContent'); assert.equal(artName, 'checklist.md'); const artMeta = await cdp.eval('document.querySelector(".artifact-meta").textContent'); assert.ok(artMeta.includes('32 B'), 'Artifact metadata should show 32 B'); // Verify download trigger executes without error await cdp.eval('document.querySelector(".download-btn").click()'); await new Promise((r) => setTimeout(r, 400)); const errorBoxHidden = await cdp.eval('document.getElementById("artifacts-error").classList.contains("hidden")'); assert.equal(errorBoxHidden, true, 'Artifacts error box must remain hidden on successful download'); }); // Test 9: Sources and Request History Expansion await step('Sources & Request History Drawer', async () => { const headerTitle = await cdp.eval('document.getElementById("history-toggle-title").textContent'); assert.ok(headerTitle.includes('1 page accessed')); // Expand history await cdp.eval('document.getElementById("history-toggle-btn").click()'); const isExpanded = await cdp.eval('document.getElementById("history-toggle-btn").getAttribute("aria-expanded")'); assert.equal(isExpanded, 'true'); // Page accessed check const pageTitle = await cdp.eval('document.querySelector(".page-title-link").textContent'); assert.equal(pageTitle, 'Deployment Guide'); const spaceKey = await cdp.eval('document.querySelector(".badge-space").textContent'); assert.equal(spaceKey, 'OPS'); // Tool call checks (2 calls) const toolCardsCount = await cdp.eval('document.querySelectorAll(".tool-card").length'); assert.equal(toolCardsCount, 2, 'Should display 2 distinct tool call cards'); // Expand details on first tool call await cdp.eval('document.querySelectorAll(".tool-expand-btn")[0].click()'); const detailsShown = await cdp.eval('!!document.querySelector(".tool-details")'); assert.equal(detailsShown, true, 'Tool details should be rendered lazily'); }); // Test 10: Back to Prompt Restores Text await step('Back to Prompt Restores Textarea', async () => { await cdp.eval('document.getElementById("back-btn").click()'); const isPromptVisible = await cdp.eval('!document.getElementById("view-prompt").classList.contains("hidden")'); assert.equal(isPromptVisible, true); const promptVal = await cdp.eval('document.getElementById("prompt-input").value'); assert.equal(promptVal, 'How do I deploy service X?', 'Prompt text must be preserved'); }); // Test 11: Query Cancellation in Loading View & Busy Recovery await step('Query Cancellation in Loading View & Busy Recovery', async () => { // Switch scenario to delayed_cancellation await cdp.eval(` fetch("/dev/scenario", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scenario: "delayed_cancellation" }) }) `); // Submit query await cdp.eval('document.getElementById("submit-btn").click()'); await new Promise((r) => setTimeout(r, 200)); const isLoading = await cdp.eval('!document.getElementById("view-loading").classList.contains("hidden")'); assert.equal(isLoading, true, 'Should enter loading view'); // Click Cancel await cdp.eval('document.getElementById("cancel-btn").click()'); const isPrompt = await cdp.eval('!document.getElementById("view-prompt").classList.contains("hidden")'); assert.equal(isPrompt, true, 'Should return to prompt view on cancel'); const promptVal = await cdp.eval('document.getElementById("prompt-input").value'); assert.equal(promptVal, 'How do I deploy service X?', 'Prompt preserved on cancellation'); // Wait a tick for the client abort to propagate to server await new Promise((r) => setTimeout(r, 80)); // Retry while cleanup is still pending (server holds busy for 400ms) await cdp.eval('document.getElementById("submit-btn").click()'); await new Promise((r) => setTimeout(r, 200)); const busyText = await cdp.eval('document.getElementById("prompt-error").textContent'); assert.ok(busyText.includes('busy'), `Should report busy while cleanup pending: ${busyText}`); // Wait for cleanup budget to expire (400ms) await new Promise((r) => setTimeout(r, 400)); // Reset scenario to normal and retry query await cdp.eval(` fetch("/dev/scenario", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scenario: "normal" }) }) `); await cdp.eval('document.getElementById("submit-btn").click()'); await new Promise((r) => setTimeout(r, 500)); const isRecovered = await cdp.eval('!document.getElementById("view-result").classList.contains("hidden")'); assert.equal(isRecovered, true, 'Subsequent query must recover and succeed after cleanup completes'); // Return to prompt for next tests await cdp.eval('document.getElementById("back-btn").click()'); }); // Test 12: Error Recovery (409 Busy & 504 Timeout) await step('Error Recovery (409 Busy & 504 Timeout)', async () => { // 409 Busy await cdp.eval(` fetch("/dev/scenario", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scenario: "409_busy" }) }) `); await cdp.eval('document.getElementById("submit-btn").click()'); await new Promise((r) => setTimeout(r, 300)); const errorText = await cdp.eval('document.getElementById("prompt-error").textContent'); assert.ok(errorText.includes('busy'), `Should show busy error: ${errorText}`); // 504 Timeout await cdp.eval(` fetch("/dev/scenario", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scenario: "504_timeout" }) }) `); await cdp.eval('document.getElementById("submit-btn").click()'); await new Promise((r) => setTimeout(r, 300)); const timeoutText = await cdp.eval('document.getElementById("prompt-error").textContent'); assert.ok(timeoutText.includes('timed out'), `Should show timeout error: ${timeoutText}`); }); // Test 13: Malicious Input, CSP Defense & History Field Sanitization await step('Malicious Markdown, CSP Defense & History Field Sanitization', async () => { await cdp.eval(` fetch("/dev/scenario", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scenario: "malicious_content" }) }) `); await cdp.eval('document.getElementById("submit-btn").click()'); await new Promise((r) => setTimeout(r, 500)); // Assert no