confluence_web/frontend/tests/e2e_runner.js
Artur Mukhamadiev a9908a533f frontend: web UI track handoff (contract revision 1)
Static frontend with vendored marked/DOMPurify, bounded Markdown
pipeline, same-origin mock server with scenario selection, unit,
contract and CDP end-to-end tests under frontend/**.
2026-09-14 21:57:54 +03:00

593 lines
25 KiB
JavaScript

/**
* 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 Gear Spinner', 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 gearPresent = await cdp.eval('!!document.querySelector(".gear-spinner")');
assert.equal(gearPresent, true, 'Gear spinner glyph 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 <script> executed in document
const scriptTagsInOutput = await cdp.eval('document.querySelectorAll("#output-content script").length');
assert.equal(scriptTagsInOutput, 0, 'No executable script tags should be present in output');
// Assert no <img> or <iframe> or <form> tags survived sanitization
const imgCount = await cdp.eval('document.querySelectorAll("#output-content img").length');
const iframeCount = await cdp.eval('document.querySelectorAll("#output-content iframe").length');
const formCount = await cdp.eval('document.querySelectorAll("#output-content form").length');
assert.equal(imgCount, 0, 'No img tags allowed');
assert.equal(iframeCount, 0, 'No iframe tags allowed');
assert.equal(formCount, 0, 'No form tags allowed');
// Assert malicious javascript: link had href removed
const jsLinkHasHref = await cdp.eval(`
Array.from(document.querySelectorAll("#output-content a")).some(a => a.href && a.href.startsWith("javascript:"))
`);
assert.equal(jsLinkHasHref, false, 'No javascript: href links permitted');
// History field sanitization check
await cdp.eval('document.getElementById("history-toggle-btn").click()');
await cdp.eval('document.querySelectorAll(".tool-expand-btn")[0].click()');
await new Promise((r) => setTimeout(r, 100));
const historyScripts = await cdp.eval('document.querySelectorAll("#history-content script").length');
const historyImgs = await cdp.eval('document.querySelectorAll("#history-content img").length');
assert.equal(historyScripts, 0, 'No script tags in history content');
assert.equal(historyImgs, 0, 'No img tags in history content');
const toolParamText = await cdp.eval('document.querySelectorAll(".tool-result-box")[0].textContent');
assert.ok(toolParamText.includes('xss-param'), 'Parameters rendered safely as plain text');
});
// Test 14: Large Output & Bounded Section Navigation
await step('Large Output & Bounded Section Navigation', async () => {
await cdp.eval(`
fetch("/dev/scenario", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scenario: "large_output" })
})
`);
await cdp.eval('document.getElementById("back-btn").click()');
await cdp.eval('document.getElementById("submit-btn").click()');
await new Promise((r) => setTimeout(r, 800));
// Check section navigation banner
const isNavVisible = await cdp.eval('!document.getElementById("section-nav").classList.contains("hidden")');
assert.equal(isNavVisible, true, 'Section nav should be visible for large document');
const navText = await cdp.eval('document.querySelector(".section-nav-banner").textContent');
assert.ok(navText.includes('Showing section 1'), `Should show section 1: ${navText}`);
// Click Next
await cdp.eval('document.querySelectorAll(".section-btn")[1].click()');
const nextNavText = await cdp.eval('document.querySelector(".section-nav-banner").textContent');
assert.ok(nextNavText.includes('Showing section 2'), `Should show section 2: ${nextNavText}`);
// Click Previous
await cdp.eval('document.querySelectorAll(".section-btn")[0].click()');
const prevNavText = await cdp.eval('document.querySelector(".section-nav-banner").textContent');
assert.ok(prevNavText.includes('Showing section 1'), `Should return to section 1: ${prevNavText}`);
});
// Test 15: Clear Credentials
await step('Clear Credentials Memory & Guard', async () => {
await cdp.eval('document.getElementById("key-btn").click()');
await cdp.eval('document.getElementById("btn-clear-cred").click()');
const isActive = await cdp.eval('document.getElementById("cred-indicator").classList.contains("active")');
assert.equal(isActive, false, 'Indicator should be deactivated after clear');
await cdp.eval('document.getElementById("modal-close-btn").click()');
await cdp.eval('document.getElementById("back-btn").click()');
// Attempt submit without credentials
await cdp.eval('document.getElementById("submit-btn").click()');
const promptErr = await cdp.eval('document.getElementById("prompt-error").textContent');
assert.ok(promptErr.includes('credentials required'), 'Submitting without credentials must show error');
const modalReopened = await cdp.eval('!document.getElementById("modal-backdrop").classList.contains("hidden")');
assert.equal(modalReopened, true, 'Modal should open when attempting query without credentials');
});
// Test 16: Zero Automatic External Requests Network Assertion
await step('Zero Automatic External Requests Network Assertion', async () => {
// Filter any requests initiated to destinations other than loopback mock server or internal data/blob URLs
const externalRequests = cdp.networkRequests.filter((r) => {
const url = r.request.url;
if (url.startsWith(`http://127.0.0.1:${MOCK_PORT}/`) || url.startsWith('data:') || url.startsWith('blob:')) {
return false;
}
return true;
});
assert.equal(
externalRequests.length,
0,
`Zero automatic external requests permitted! Found: ${externalRequests.map((r) => r.request.url).join(', ')}`
);
});
} finally {
cdp.close();
server.close();
}
console.log(`\n=== E2E Test Results: ${passed} passed, ${failed} failed ===`);
if (failed > 0) {
process.exit(1);
}
}
runTests().catch((err) => {
console.error('Fatal E2E error:', err);
process.exit(1);
});