Files
learningcircuit--local-deep…/tests/puppeteer/test_deep_functionality.js
T
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

3860 lines
167 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Deep Functionality Tests
*
* These tests go beyond basic page loads to verify:
* 1. Settings actually change and persist across page reloads
* 2. Research can be started and progress is tracked
* 3. News subscriptions can be created
* 4. Library collections can be created and managed
* 5. Ollama/LM Studio URL configuration works
* 6. API endpoints respond correctly
*/
const puppeteer = require('puppeteer');
const { expect } = require('chai');
// Import shared helpers
const {
BASE_URL,
HEADLESS,
SLOW_MO,
delay,
takeScreenshot,
ensureLoggedIn,
getLaunchOptions,
generateTestRunId,
getCSRFToken,
gotoWithRetry
} = require('./helpers');
// Match the Chrome CDP timeouts that appear after a long-running suite has
// aged the browser session — NOT a catch-all. Anything else (e.g. an
// app-level navigation bug, an unexpected 500) still surfaces as a real
// test failure.
//
// Observed shapes in release run #2341:
// * `Navigation timeout of 60000 ms exceeded` (page.goBack)
// * `Emulation.setDeviceMetricsOverride timed out` (setViewport)
// * Generic Puppeteer `ProtocolError` with the recommendation to
// bump `protocolTimeout`
function isCdpSessionFlake(err) {
const msg = (err && err.message) || String(err);
return (
msg.includes('Navigation timeout') ||
msg.includes('ProtocolError') ||
msg.includes('protocolTimeout') ||
msg.includes('Emulation.setDeviceMetricsOverride timed out') ||
err?.name === 'ProtocolError' ||
err?.name === 'TimeoutError'
);
}
// Generate unique username for this test run - ensures fresh state each time
const TEST_RUN_ID = generateTestRunId();
const TEST_USERNAME = `test_user_${TEST_RUN_ID}`;
const TEST_PASSWORD = 'Test_password_123';
console.log(`Test run ID: ${TEST_RUN_ID}`);
console.log(`Test username: ${TEST_USERNAME}`);
describe('Deep Functionality Tests', function() {
this.timeout(300000);
let browser;
let page;
before(async () => {
console.log(`\nStarting browser (headless: ${HEADLESS}, slowMo: ${SLOW_MO})`);
browser = await puppeteer.launch(getLaunchOptions());
page = await browser.newPage();
await page.setViewport({ width: 1400, height: 900 });
page.setDefaultNavigationTimeout(60000);
page.on('console', msg => {
if (msg.type() === 'error') {
console.log('Browser ERROR:', msg.text());
}
});
// Login once at start
await ensureLoggedIn(page, TEST_USERNAME, TEST_PASSWORD);
});
after(async () => {
if (browser) await browser.close();
});
// Re-authenticate before each test if session was lost
beforeEach(async function() {
const url = page.url();
if (url.includes('/login') || url.includes('/auth/login')) {
console.log(' beforeEach: Session lost, re-authenticating...');
await ensureLoggedIn(page, TEST_USERNAME, TEST_PASSWORD);
}
});
describe('Settings Persistence', () => {
it('should change search iterations and verify it persists', async () => {
await page.goto(`${BASE_URL}/settings`, { waitUntil: 'domcontentloaded' });
await takeScreenshot(page, 'settings-before-change');
// Wait for settings to load
await delay(2000);
// Find the search iterations setting
// Click on Search Engines tab first
const searchTab = await page.$('[data-tab="search"]');
if (searchTab) {
await searchTab.click();
await delay(1000);
}
await takeScreenshot(page, 'settings-search-tab');
// Look for iterations input
const iterationsInput = await page.$('input[name="search.iterations"], input[data-key="search.iterations"], #search-iterations');
if (iterationsInput) {
// Get current value
const currentValue = await iterationsInput.evaluate(el => el.value);
console.log(` Current iterations value: ${currentValue}`);
// Change it
const newValue = currentValue === '3' ? '5' : '3';
await iterationsInput.click({ clickCount: 3 });
await page.keyboard.press('Backspace');
await iterationsInput.type(newValue);
console.log(` Changed to: ${newValue}`);
// Wait for auto-save
await delay(2000);
await takeScreenshot(page, 'settings-after-change');
// Reload page
await page.goto(`${BASE_URL}/settings`, { waitUntil: 'domcontentloaded' });
await delay(2000);
if (searchTab) {
const newSearchTab = await page.$('[data-tab="search"]');
if (newSearchTab) await newSearchTab.click();
await delay(1000);
}
// Check if value persisted
const persistedInput = await page.$('input[name="search.iterations"], input[data-key="search.iterations"], #search-iterations');
if (persistedInput) {
const persistedValue = await persistedInput.evaluate(el => el.value);
console.log(` After reload value: ${persistedValue}`);
await takeScreenshot(page, 'settings-after-reload');
}
} else {
console.log(' Could not find iterations input, checking via API');
}
});
it('should change LLM provider via dropdown and verify selection', async () => {
await page.goto(`${BASE_URL}/settings`, { waitUntil: 'domcontentloaded' });
await delay(2000);
// Click on LLM tab
const llmTab = await page.$('[data-tab="llm"]');
if (llmTab) {
await llmTab.click();
await delay(1000);
}
await takeScreenshot(page, 'settings-llm-tab');
// Look for provider select or custom dropdown
const providerSelect = await page.$('select[name="llm.provider"], select[data-key="llm.provider"]');
if (providerSelect) {
const options = await page.$$eval('select[name="llm.provider"] option, select[data-key="llm.provider"] option',
opts => opts.map(o => ({ value: o.value, text: o.textContent })));
console.log(` Available providers: ${JSON.stringify(options.slice(0, 5))}`);
const currentProvider = await providerSelect.evaluate(el => el.value);
console.log(` Current provider: ${currentProvider}`);
}
// Check for custom dropdown
const customDropdown = await page.$('.ldr-custom-dropdown[data-key*="provider"]');
if (customDropdown) {
console.log(' Found custom dropdown for provider');
}
await takeScreenshot(page, 'settings-provider-options');
});
});
describe('Research Workflow - End to End', () => {
let researchId;
it('should start a quick research and track progress', async () => {
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' });
await delay(2000); // Wait for page to fully load
await takeScreenshot(page, 'research-start-page');
// Check if we're on the research page or redirected to login
const currentUrl = page.url();
if (currentUrl.includes('/login')) {
console.log(' Not logged in, skipping research test');
return;
}
// Wait for query input to be available
try {
await page.waitForSelector('#query, textarea[name="query"]', { timeout: 10000, visible: true });
} catch {
console.log(' Query input not found - may not be on research page');
await takeScreenshot(page, 'research-no-query-input');
return;
}
// Enter query
const queryInput = await page.$('#query, textarea[name="query"]');
await queryInput.click({ clickCount: 3 });
await page.keyboard.press('Backspace');
await queryInput.type('What is 2+2?');
await takeScreenshot(page, 'research-query-entered');
// Ensure quick mode is selected
const quickMode = await page.$('#mode-quick');
if (quickMode) {
await quickMode.click();
}
// Import default settings to ensure search engines are configured
// This is needed because new users in CI don't have settings seeded
console.log(' Importing default settings...');
const importResult = await page.evaluate(async (baseUrl) => {
try {
// Get CSRF token from meta tag or cookie
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
const csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : '';
const res = await fetch(`${baseUrl}/settings/api/import`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
const body = await res.text();
return { status: res.status, ok: res.ok, body: body.substring(0, 200) };
} catch (e) {
return { error: e.message };
}
}, BASE_URL);
console.log(` Import settings result: ${JSON.stringify(importResult)}`);
// Set search engine to serper explicitly (env vars may not propagate to frontend)
const searchEngineSet = await page.evaluate(() => {
const hiddenInput = document.getElementById('search_engine_hidden');
if (hiddenInput) {
hiddenInput.value = 'serper';
return { success: true, value: hiddenInput.value };
}
return { success: false, error: 'Hidden input not found' };
});
console.log(` Search engine hidden input: ${JSON.stringify(searchEngineSet)}`);
// Intercept the API request to see what's being sent
let capturedPayload = null;
await page.setRequestInterception(true);
page.on('request', request => {
if (request.url().includes('/research/api/start_research')) {
try {
capturedPayload = JSON.parse(request.postData());
console.log(` API Request payload search_engine: ${capturedPayload?.search_engine}`);
} catch (e) {
console.log(` Could not parse request: ${e.message}`);
}
}
request.continue();
});
// Submit research
console.log(' Submitting research...');
const submitBtn = await page.$('#start-research-btn, button[type="submit"]');
if (submitBtn) {
await submitBtn.click();
}
// Wait for navigation to progress page
await delay(3000);
const progressUrl = page.url();
console.log(` Progress URL: ${progressUrl}`);
await takeScreenshot(page, 'research-progress-page');
// Extract research ID from URL
const match = progressUrl.match(/progress\/([a-f0-9-]+)/);
if (match) {
researchId = match[1];
console.log(` Research ID: ${researchId}`);
}
// Accept either progress page or staying on home (if research couldn't start)
const isValid = progressUrl.includes('/progress') || progressUrl === `${BASE_URL}/`;
expect(isValid).to.be.true;
});
it('should show progress updates on the progress page', async () => {
if (!researchId) {
console.log(' Skipping - no research ID from previous test');
return;
}
// Check for progress elements
const progressElements = await page.$$('.progress-bar, .ldr-progress, [class*="progress"]');
console.log(` Found ${progressElements.length} progress elements`);
// Wait a bit and check for status updates
await delay(5000);
await takeScreenshot(page, 'research-progress-update');
// Check for status text
const bodyText = await page.$eval('body', el => el.textContent);
const hasStatusInfo = bodyText.includes('research') ||
bodyText.includes('progress') ||
bodyText.includes('searching') ||
bodyText.includes('complete');
console.log(` Has status info: ${hasStatusInfo}`);
});
it('should be viewable in history after starting', async () => {
await page.goto(`${BASE_URL}/history`, { waitUntil: 'domcontentloaded' });
await delay(2000);
await takeScreenshot(page, 'history-after-research');
// Look for any research entries
const historyItems = await page.$$('.history-item, .research-item, [class*="history"], tr');
console.log(` Found ${historyItems.length} history items`);
});
it('should wait for research to complete and show results', async () => {
if (!researchId) {
console.log(' Skipping - no research ID from previous test');
return;
}
// Navigate to progress page - use domcontentloaded since page does continuous polling
try {
await page.goto(`${BASE_URL}/progress/${researchId}`, { waitUntil: 'domcontentloaded', timeout: 15000 });
} catch {
console.log(' Navigation timeout (expected for polling page)');
}
console.log(` Monitoring research: ${researchId}`);
// Wait for completion with timeout (max 90 seconds for quick research)
const maxWait = 90000;
const checkInterval = 5000;
const startTime = Date.now();
let completed = false;
let lastStatus = '';
while (Date.now() - startTime < maxWait && !completed) {
await delay(checkInterval);
const currentUrl = page.url();
const bodyText = await page.$eval('body', el => el.textContent.toLowerCase());
// Check for completion indicators
completed = bodyText.includes('complete') ||
bodyText.includes('finished') ||
bodyText.includes('report') ||
currentUrl.includes('/results/') ||
currentUrl.includes('/report/');
// Check for actual error state indicators set by progress.js
const hasError = await page.evaluate(() => {
const progressBar = document.querySelector('#progress-bar.bg-danger');
const statusEl = document.querySelector('#status-text');
const statusText = statusEl ? statusEl.textContent.toLowerCase().trim() : '';
return progressBar !== null || statusText === 'error' || statusText === 'failed';
});
// Extract status info
const statusMatch = bodyText.match(/(searching|processing|generating|analyzing|complete)/i);
const currentStatus = statusMatch ? statusMatch[1] : 'unknown';
if (currentStatus !== lastStatus) {
console.log(` Status: ${currentStatus} (${Math.round((Date.now() - startTime) / 1000)}s)`);
lastStatus = currentStatus;
}
if (hasError) {
console.log(' ⚠ Error detected in research');
await takeScreenshot(page, 'research-error');
break;
}
// Take periodic screenshots
if ((Date.now() - startTime) % 30000 < checkInterval) {
await takeScreenshot(page, `research-progress-${Math.round((Date.now() - startTime) / 1000)}s`);
}
}
// Poll the API to confirm database status is truly "completed"
// This bridges the gap between UI rendering and database commit timing
if (completed) {
const apiMaxWait = 30000;
const apiStart = Date.now();
let apiCompleted = false;
while (Date.now() - apiStart < apiMaxWait && !apiCompleted) {
const statusRes = await page.evaluate(async (baseUrl, id) => {
try {
const res = await fetch(`${baseUrl}/api/research/${id}`);
const data = await res.json();
return data.status;
} catch {
return null;
}
}, BASE_URL, researchId);
if (statusRes === 'completed') {
apiCompleted = true;
console.log(' API confirmed research completed');
} else {
console.log(` Waiting for API status (currently: ${statusRes})`);
await delay(2000);
}
}
if (!apiCompleted) {
throw new Error('API did not confirm completion within 30s timeout');
}
}
await takeScreenshot(page, 'research-final-state');
const totalTime = Math.round((Date.now() - startTime) / 1000);
console.log(` Research completed: ${completed} (took ${totalTime}s)`);
// If completed, verify we can see results
if (completed) {
const currentUrl = page.url();
console.log(` Final URL: ${currentUrl}`);
// Check for result content
const bodyText = await page.$eval('body', el => el.textContent);
const hasResultContent = bodyText.length > 500;
console.log(` Has substantial content: ${hasResultContent}`);
}
});
it('should export and display research output', async () => {
if (!researchId) {
console.log(' Skipping - no research ID from previous test');
return;
}
// Navigate to the results page (not progress page)
await page.goto(`${BASE_URL}/results/${researchId}`, { waitUntil: 'domcontentloaded' });
await delay(3000); // Wait for content to load
await takeScreenshot(page, 'research-results-page');
// Read the research output from the results container
const resultContent = await page.$eval('#results-content', el => el.textContent).catch(() => null);
if (resultContent && resultContent.length > 100) {
console.log(' === RESEARCH OUTPUT ===');
// Log first 3000 chars to see actual content
console.log(resultContent.substring(0, 3000));
if (resultContent.length > 3000) {
console.log(` ... [truncated, total ${resultContent.length} chars]`);
}
console.log(' === END OUTPUT ===');
// Save research output to file for CI to include in PR comment
const fs = require('fs');
const outputDir = './research-output';
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
fs.writeFileSync(`${outputDir}/research-result.txt`, resultContent);
console.log(' Saved research output to research-output/research-result.txt');
// Check for research errors
const hasResearchError = resultContent.includes('Research Failed') ||
resultContent.includes('Unable to conduct research') ||
resultContent.includes('Error Type:');
// Check specifically for search engine configuration error (known CI limitation)
const isSearchEngineError = resultContent.includes('Unable to conduct research without a search engine');
// Transient upstream LLM provider failures (rate limits, 429/5xx) are not
// code regressions — the research workflow itself reached the results page.
const isTransientLlmError = resultContent.includes('API Rate Limit Exceeded') ||
resultContent.includes('Error code: 429') ||
/Error code: 5\d\d/.test(resultContent);
if (hasResearchError) {
console.log(' ❌ Research failed with error');
// Log the error for debugging
const errorMatch = resultContent.match(/Error.*?(?=\n\n|\n#|$)/s);
if (errorMatch) console.log(' Error:', errorMatch[0]);
if (isSearchEngineError || isTransientLlmError) {
// Known CI limitation — either search engines aren't fully configured
// for new users, or the upstream LLM provider is transiently
// unavailable (rate-limited / 5xx). We still verified that:
// 1. Research can be initiated
// 2. Research ID is assigned
// 3. Progress page is displayed
// 4. Results page is reached
if (isSearchEngineError) {
console.log(' ⚠️ Search engine configuration issue (known CI limitation)');
} else {
console.log(' ⚠️ Transient upstream LLM provider error (known CI limitation)');
}
console.log(' ✓ Verified research workflow mechanics work correctly');
} else {
// Other errors should fail the test
expect(hasResearchError, 'Research should not contain error messages').to.be.false;
}
} else {
// Research succeeded - verify we got actual content.
//
// The CI release pipeline uses a small, free-tier LLM
// (Gemini 2.5 Flash Lite via OpenRouter). It sometimes
// returns brief, non-markdown output even when the
// research workflow completed end-to-end — this is an
// upstream content-quality flake, not a code regression.
// The test should still validate the *workflow* (research
// initiated → progress → results page → output rendered),
// which we already did before reaching this branch.
const hasActualContent = resultContent.includes('##') || // Has markdown headers
resultContent.length > 500; // Substantial content
console.log(` Has actual content: ${hasActualContent}`);
if (!hasActualContent) {
console.log(
' ⚠️ Research returned brief output ' +
`(${resultContent.length} chars, no markdown). ` +
'Treating as transient LLM-quality flake — ' +
'workflow mechanics validated upstream.'
);
}
}
} else {
console.log(' No substantial content found in #results-content');
}
// Also get the query that was researched
const query = await page.$eval('#result-query', el => el.textContent).catch(() => 'unknown');
console.log(` Research query: ${query}`);
// Save query and metadata for CI PR comment
const fs = require('fs');
const outputDir = './research-output';
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const metadata = {
query,
researchId,
timestamp: new Date().toISOString(),
hasContent: resultContent && resultContent.length > 100,
contentLength: resultContent ? resultContent.length : 0
};
fs.writeFileSync(`${outputDir}/research-metadata.json`, JSON.stringify(metadata, null, 2));
console.log(' Saved research metadata to research-output/research-metadata.json');
// Test the Export Markdown button
const exportBtn = await page.$('#export-markdown-btn');
if (exportBtn) {
console.log(' Found Export Markdown button');
// Set up download handling
const downloadPath = '/tmp/puppeteer-downloads';
if (!fs.existsSync(downloadPath)) {
fs.mkdirSync(downloadPath, { recursive: true });
}
// Clear any existing files
const existingFiles = fs.readdirSync(downloadPath);
existingFiles.forEach(f => fs.unlinkSync(`${downloadPath}/${f}`));
// Configure download behavior
const client = await page.target().createCDPSession();
await client.send('Page.setDownloadBehavior', {
behavior: 'allow',
downloadPath
});
// Click export button. The handle was queried earlier in the
// test; if the page re-rendered between then and now (e.g.,
// results panel updated after a late stream chunk), the
// handle can go stale and Puppeteer's clickability check
// fails with `Node is either not clickable or not an Element`.
// Retry with a DOM-level click which doesn't go through the
// clickability check, then fall through to the no-file branch
// if the second attempt also fails.
try {
await exportBtn.click();
} catch (clickError) {
console.log(` ⚠️ exportBtn.click() failed: ${clickError.message}`);
console.log(' Retrying via DOM-level click...');
try {
await page.evaluate(() => {
const btn = document.querySelector('#export-markdown-btn');
if (btn) btn.click();
});
} catch (retryError) {
console.log(` ⚠️ DOM-level retry also failed: ${retryError.message}`);
console.log(' ✓ Workflow mechanics validated; export click is a transient flake.');
}
}
await delay(3000); // Wait for download
// Check for downloaded file
const files = fs.readdirSync(downloadPath);
if (files.length > 0) {
const downloadedFile = `${downloadPath}/${files[0]}`;
const markdownContent = fs.readFileSync(downloadedFile, 'utf8');
console.log(' === EXPORTED MARKDOWN ===');
console.log(markdownContent.substring(0, 3000));
if (markdownContent.length > 3000) {
console.log(` ... [truncated, total ${markdownContent.length} chars]`);
}
console.log(' === END MARKDOWN ===');
// Check for expected markdown structure
const hasTitle = markdownContent.includes('# ');
const hasQuery = markdownContent.includes('Research Results:') ||
markdownContent.includes('Query:') ||
markdownContent.includes('What is');
const hasTimestamp = markdownContent.includes('Generated:');
console.log(` Markdown structure: title=${hasTitle}, query=${hasQuery}, timestamp=${hasTimestamp}`);
// Verify it's not just an error report (unless it's a known
// CI limitation — search engine config or transient upstream
// LLM failure). Mirrors the tolerance applied on the
// results page check above.
const isErrorReport = markdownContent.includes('Research Failed') ||
markdownContent.includes('Error Type:');
const isSearchEngineError = markdownContent.includes('Unable to conduct research without a search engine');
const isTransientLlmError = markdownContent.includes('API Rate Limit Exceeded') ||
markdownContent.includes('Error code: 429') ||
/Error code: 5\d\d/.test(markdownContent);
if (isErrorReport && isSearchEngineError) {
// Known CI limitation - export will contain the error report
console.log(' ⚠️ Export contains error (known CI limitation - search engine config)');
} else if (isErrorReport && isTransientLlmError) {
console.log(' ⚠️ Export contains error (transient upstream LLM provider error)');
} else if (isErrorReport) {
expect(isErrorReport, 'Markdown export should not be an error report').to.be.false;
}
// Verify minimum content (even error reports have content)
expect(markdownContent.length, 'Markdown should have substantial content').to.be.greaterThan(100);
} else {
console.log(' No file was downloaded (export may use clipboard instead)');
}
}
await takeScreenshot(page, 'research-export-complete');
// Verify the results page rendered something. We deliberately do
// *not* hard-assert a length threshold here: the CI release pipeline
// uses a small free-tier LLM that occasionally returns very brief
// output (~80100 chars) even though the research workflow itself
// completed end-to-end. That brevity is an upstream content-quality
// flake, not a code regression — the workflow validation above
// (initiation → progress → results page → content rendered) is what
// this test exists to cover. The branches at lines 518540 already
// log a warning in that case.
expect(resultContent).to.not.be.null;
});
it('should verify research contains sources', async () => {
if (!researchId) {
console.log(' Skipping - no research ID');
return;
}
// Check the research API for sources
const sourcesResponse = await page.evaluate(async (baseUrl, id) => {
try {
const res = await fetch(`${baseUrl}/api/research/${id}`);
return await res.json();
} catch (e) {
return { error: e.message };
}
}, BASE_URL, researchId);
console.log(' Research details:', JSON.stringify(sourcesResponse, null, 2).substring(0, 1000));
// Verify research completed successfully
if (sourcesResponse.status) {
expect(sourcesResponse.status, 'Research status should be completed').to.equal('completed');
}
// Check for sources in the response or page
const pageContent = await page.$eval('body', el => el.textContent);
const hasSources = pageContent.includes('Source') ||
pageContent.includes('Reference') ||
pageContent.includes('http') ||
pageContent.includes('www.');
console.log(` Has sources/references: ${hasSources}`);
});
it('should verify search was actually performed', async () => {
if (!researchId) {
console.log(' Skipping - no research ID');
return;
}
// Get research logs to verify search happened
const logsResponse = await page.evaluate(async (baseUrl, id) => {
try {
const res = await fetch(`${baseUrl}/api/research/${id}/logs`);
return await res.json();
} catch (e) {
return { error: e.message };
}
}, BASE_URL, researchId);
if (logsResponse.logs || logsResponse.entries) {
const logsText = JSON.stringify(logsResponse);
console.log(' Logs preview:', logsText.substring(0, 500));
// Check for search-related log entries
const searchPerformed = logsText.toLowerCase().includes('search') ||
logsText.toLowerCase().includes('wikipedia') ||
logsText.includes('results') ||
logsText.includes('query');
console.log(` Search performed: ${searchPerformed}`);
// Check for LLM activity
const llmUsed = logsText.toLowerCase().includes('generating') ||
logsText.toLowerCase().includes('openrouter') ||
logsText.toLowerCase().includes('gemini') ||
logsText.toLowerCase().includes('model');
console.log(` LLM used: ${llmUsed}`);
} else {
console.log(' No logs available:', JSON.stringify(logsResponse).substring(0, 200));
}
});
});
describe('Research API Workflow', () => {
it('should start a research task and verify it in history', async () => {
console.log('\n--- Test: Start research via API and verify in history ---');
// Navigate to home page to have a valid page context
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' });
// Extract CSRF token for the POST request
const csrfToken = await getCSRFToken(page);
// Start research via API (requires CSRF token, session cookie auto-sent)
const startResult = await page.evaluate(async (baseUrl, csrf) => {
try {
const res = await fetch(`${baseUrl}/research/api/start`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrf
},
body: JSON.stringify({
query: 'What is 2+2?',
mode: 'quick',
model_provider: 'openrouter',
model: 'google/gemini-2.5-flash-lite',
search_engine: 'serper'
})
});
const text = await res.text();
return { status: res.status, body: text.substring(0, 500) };
} catch (e) {
return { error: e.message };
}
}, BASE_URL, csrfToken);
console.log('Start research result:', JSON.stringify(startResult, null, 2));
expect(startResult.error).to.be.undefined;
expect(startResult.status).to.equal(200);
// Extract research ID
const startData = JSON.parse(startResult.body);
const researchId = startData.research_id;
expect(researchId).to.be.a('string');
console.log(` Research ID: ${researchId}`);
// Verify it appears in history
await delay(2000);
const historyResult = await page.evaluate(async (baseUrl) => {
try {
const res = await fetch(`${baseUrl}/api/history`);
return { status: res.status, body: await res.text() };
} catch (e) {
return { error: e.message };
}
}, BASE_URL);
expect(historyResult.status).to.equal(200);
const historyData = JSON.parse(historyResult.body);
const found = historyData.items && historyData.items.some(item => item.id === researchId);
console.log(` Research found in history: ${found}`);
expect(found).to.be.true;
// Brief check for status progression
await delay(5000);
const statusResult = await page.evaluate(async (baseUrl, id) => {
try {
const res = await fetch(`${baseUrl}/api/research/${id}/status`);
const data = await res.json();
return { status: res.status, research_status: data.status };
} catch (e) {
return { error: e.message };
}
}, BASE_URL, researchId);
console.log(` Research status: ${statusResult.research_status}`);
// Accept any valid ResearchStatus — mirrors the 8-member enum in constants.py
const validStatuses = ['queued', 'in_progress', 'completed', 'error', 'pending', 'suspended', 'failed', 'cancelled'];
expect(validStatuses).to.include(statusResult.research_status);
// Terminate research to avoid side effects on subsequent tests
// /api/terminate/{id} is NOT CSRF-exempt, so we need the CSRF token from meta tag
const csrfForTerminate = await page.$eval('meta[name="csrf-token"]', el => el.content).catch(() => null);
if (csrfForTerminate) {
await page.evaluate(async (baseUrl, id, csrf) => {
try {
await fetch(`${baseUrl}/api/terminate/${id}`, {
method: 'POST',
headers: { 'X-CSRFToken': csrf }
});
} catch {
// Ignore cleanup errors
}
}, BASE_URL, researchId, csrfForTerminate);
console.log(' Research terminated for cleanup');
} else {
console.log(' Could not get CSRF token for cleanup - research will be cleaned up on browser close');
}
});
});
describe('News Subscription Creation', () => {
it('should navigate to new subscription form', async () => {
await page.goto(`${BASE_URL}/news/subscriptions/new`, { waitUntil: 'domcontentloaded' });
await takeScreenshot(page, 'subscription-form');
const url = page.url();
expect(url).to.include('/subscriptions/new');
// Check for form elements
const form = await page.$('form');
expect(form).to.not.be.null;
console.log(' ✓ Subscription form found');
});
it('should fill out subscription form fields', async () => {
// Look for name/title input
const nameInput = await page.$('input[name="name"], input[name="title"], input#name, input#title');
if (nameInput) {
await nameInput.click({ clickCount: 3 });
await nameInput.type('Test Subscription ' + Date.now());
console.log(' ✓ Filled name field');
}
// Look for topic/query input
const topicInput = await page.$('input[name="topic"], input[name="query"], textarea[name="query"], input#topic');
if (topicInput) {
await topicInput.click({ clickCount: 3 });
await topicInput.type('artificial intelligence');
console.log(' ✓ Filled topic field');
}
await takeScreenshot(page, 'subscription-form-filled');
// Look for schedule options
const scheduleOptions = await page.$$('select[name="schedule"], input[name="schedule"], [name*="frequency"]');
console.log(` Found ${scheduleOptions.length} schedule options`);
});
it('should show subscription list page', async () => {
await page.goto(`${BASE_URL}/news/subscriptions`, { waitUntil: 'domcontentloaded' });
await takeScreenshot(page, 'subscriptions-list');
const url = page.url();
expect(url).to.include('/subscriptions');
// Check for any subscription items or empty state
const bodyText = await page.$eval('body', el => el.textContent);
const hasContent = bodyText.includes('subscription') || bodyText.includes('Subscription') || bodyText.includes('No');
console.log(` Has subscription content: ${hasContent}`);
});
it('should create a new subscription and verify it appears in list', async () => {
// Navigate to subscription create page
await page.goto(`${BASE_URL}/news/subscriptions/new`, { waitUntil: 'domcontentloaded' });
await delay(2000); // Wait for form JS to initialize
await takeScreenshot(page, 'subscription-create-form');
const subName = `Test Subscription ${TEST_RUN_ID}`;
console.log(` Creating subscription: ${subName}`);
// Wait for form to be ready
try {
await page.waitForSelector('#subscription-query', { timeout: 5000, visible: true });
} catch {
console.log(' Subscription query input not found');
await takeScreenshot(page, 'subscription-form-not-found');
return;
}
// Fill required query field
await page.type('#subscription-query', 'artificial intelligence breakthroughs machine learning');
console.log(' ✓ Filled query field');
// Fill name field
const nameInput = await page.$('#subscription-name');
if (nameInput) {
await page.type('#subscription-name', subName);
console.log(' ✓ Filled name field');
}
// Set interval to daily (1440 minutes)
const intervalInput = await page.$('#subscription-interval');
if (intervalInput) {
await intervalInput.click({ clickCount: 3 });
await page.keyboard.press('Backspace');
await intervalInput.type('1440');
console.log(' ✓ Set interval to 1440 (daily)');
}
await takeScreenshot(page, 'subscription-form-filled');
// Submit the form - look for submit button
const submitBtn = await page.$('button[type="submit"], .btn-primary[type="submit"]');
if (submitBtn) {
console.log(' Clicking submit button...');
await submitBtn.click();
// Wait for response
await delay(3000);
await takeScreenshot(page, 'subscription-after-submit');
const currentUrl = page.url();
console.log(` After submit URL: ${currentUrl}`);
// Check for success indicators
const bodyText = await page.$eval('body', el => el.textContent.toLowerCase());
const hasSuccess = bodyText.includes('success') ||
bodyText.includes('created') ||
currentUrl.includes('/subscriptions') && !currentUrl.includes('/new');
console.log(` Creation success indicators: ${hasSuccess}`);
} else {
console.log(' Submit button not found');
}
// Verify subscription appears in list
await page.goto(`${BASE_URL}/news/subscriptions`, { waitUntil: 'domcontentloaded' });
await delay(2000);
await takeScreenshot(page, 'subscriptions-list-after-create');
const bodyText = await page.$eval('body', el => el.textContent);
const foundName = bodyText.includes(subName) || bodyText.includes('Test Subscription');
const foundQuery = bodyText.includes('artificial intelligence') || bodyText.includes('machine learning');
console.log(` Subscription name found: ${foundName}`);
console.log(` Subscription query found: ${foundQuery}`);
});
});
describe('Library Collection Management', () => {
it('should navigate to collections page', async () => {
await page.goto(`${BASE_URL}/library/collections`, { waitUntil: 'domcontentloaded' });
await takeScreenshot(page, 'collections-page');
const url = page.url();
expect(url).to.include('/collections');
});
it('should show create collection button or form', async () => {
// Look for create button
const createBtn = await page.$('button[onclick*="create"], a[href*="create"], .create-collection, #create-collection');
if (createBtn) {
console.log(' ✓ Found create collection button');
await takeScreenshot(page, 'collections-create-btn');
}
// Or look for inline form
const nameInput = await page.$('input[name="collection_name"], input#collection-name, input[placeholder*="collection"]');
if (nameInput) {
console.log(' ✓ Found collection name input');
}
});
it('should list existing collections with document counts', async () => {
await page.goto(`${BASE_URL}/library/`, { waitUntil: 'domcontentloaded' });
await takeScreenshot(page, 'library-with-collections');
// Check collection dropdown
const collectionSelect = await page.$('#filter-collection');
if (collectionSelect) {
const options = await page.$$eval('#filter-collection option',
opts => opts.map(o => ({ value: o.value, text: o.textContent.trim() })));
console.log(` Collections: ${JSON.stringify(options)}`);
expect(options.length).to.be.greaterThan(0);
}
});
it('should create a new collection and verify it appears', async () => {
// Navigate to create page
await page.goto(`${BASE_URL}/library/collections/create`, { waitUntil: 'domcontentloaded' });
await takeScreenshot(page, 'collection-create-form');
const collectionName = `Test Collection ${TEST_RUN_ID}`;
console.log(` Creating collection: ${collectionName}`);
// Wait for form to be ready
try {
await page.waitForSelector('#collection-name', { timeout: 5000, visible: true });
} catch {
console.log(' Collection name input not found');
await takeScreenshot(page, 'collection-form-not-found');
return;
}
// Fill the form
await page.type('#collection-name', collectionName);
console.log(' ✓ Filled collection name');
const descInput = await page.$('#collection-description');
if (descInput) {
await page.type('#collection-description', 'Automated test collection created by Puppeteer tests');
console.log(' ✓ Filled description');
}
await takeScreenshot(page, 'collection-form-filled');
// Submit the form
const createBtn = await page.$('#create-collection-btn');
if (createBtn) {
console.log(' Clicking create button...');
await createBtn.click();
// Wait for response
await delay(3000);
await takeScreenshot(page, 'collection-after-submit');
const currentUrl = page.url();
console.log(` After create URL: ${currentUrl}`);
// Check for success - might redirect to collection page or show success message
const bodyText = await page.$eval('body', el => el.textContent.toLowerCase());
const hasSuccess = bodyText.includes('success') ||
bodyText.includes('created') ||
currentUrl.includes('/collections/') ||
!currentUrl.includes('/create');
console.log(` Creation success indicators: ${hasSuccess}`);
}
// Verify collection appears in library dropdown
await page.goto(`${BASE_URL}/library/`, { waitUntil: 'domcontentloaded' });
await delay(1000);
await takeScreenshot(page, 'library-after-collection-create');
const collectionSelect = await page.$('#filter-collection');
if (collectionSelect) {
const options = await page.$$eval('#filter-collection option',
opts => opts.map(o => o.textContent.trim()));
const found = options.some(o => o.includes('Test Collection'));
console.log(` Collection found in dropdown: ${found}`);
console.log(` Available collections: ${JSON.stringify(options)}`);
}
});
});
describe('Ollama Configuration', () => {
it('should show Ollama URL in settings', async () => {
await page.goto(`${BASE_URL}/settings`, { waitUntil: 'domcontentloaded' });
await delay(2000);
// Click LLM tab
const llmTab = await page.$('[data-tab="llm"]');
if (llmTab) {
await llmTab.click();
await delay(1000);
}
await takeScreenshot(page, 'settings-ollama-section');
// Search for ollama in settings
const searchInput = await page.$('#settings-search');
if (searchInput) {
await searchInput.type('ollama');
await delay(1000);
await takeScreenshot(page, 'settings-ollama-search');
}
// Look for Ollama URL input
const ollamaInput = await page.$('input[name*="ollama"], input[data-key*="ollama"], input[placeholder*="ollama"], input[placeholder*="11434"]');
if (ollamaInput) {
const value = await ollamaInput.evaluate(el => el.value || el.placeholder);
console.log(` Ollama URL setting: ${value}`);
}
});
it('should test Ollama connection status endpoint', async () => {
// Make sure we're on settings page first
await page.goto(`${BASE_URL}/settings`, { waitUntil: 'domcontentloaded' });
await delay(1000);
// This tests the SSRF-protected endpoint
const response = await page.evaluate(async () => {
try {
const res = await fetch('/settings/api/ollama/status', {
method: 'GET',
credentials: 'include'
});
return {
status: res.status,
ok: res.ok,
body: await res.text()
};
} catch (e) {
return { error: e.message };
}
});
console.log(` Ollama status endpoint: ${JSON.stringify(response)}`);
// Endpoint should respond (even if Ollama isn't running) - accept 401 if not logged in
expect(response.status).to.be.oneOf([200, 401, 404, 500]);
});
});
describe('API Endpoints Verification', () => {
beforeEach(async () => {
// Make sure we're on a page (for cookies to work)
await page.goto(`${BASE_URL}/settings`, { waitUntil: 'domcontentloaded' });
await delay(1000);
});
it('should get settings via API', async () => {
const response = await page.evaluate(async () => {
try {
const res = await fetch('/settings/api/settings', {
credentials: 'include'
});
const data = await res.json();
return {
status: res.status,
hasData: Object.keys(data).length > 0,
sampleKeys: Object.keys(data).slice(0, 5)
};
} catch (e) {
return { error: e.message };
}
});
console.log(` Settings API response: ${JSON.stringify(response)}`);
// Accept 200, 401, or 404 (endpoint may have different structure)
expect(response.status).to.be.oneOf([200, 401, 404]);
if (response.status === 200) {
expect(response.hasData).to.be.true;
}
});
it('should get available search engines via API', async () => {
const response = await page.evaluate(async () => {
try {
const res = await fetch('/settings/api/available-search-engines', {
credentials: 'include'
});
const data = await res.json();
return {
status: res.status,
engines: Array.isArray(data) ? data.length : 'not array',
sample: Array.isArray(data) ? data.slice(0, 3) : data
};
} catch (e) {
return { error: e.message };
}
});
console.log(` Search engines API: ${JSON.stringify(response)}`);
expect(response.status).to.be.oneOf([200, 401]);
});
it('should get available model providers via API', async () => {
const response = await page.evaluate(async () => {
try {
const res = await fetch('/settings/api/available-model-providers', {
credentials: 'include'
});
const data = await res.json();
return {
status: res.status,
providers: Array.isArray(data) ? data.map(p => p.name || p) : 'not array'
};
} catch (e) {
return { error: e.message };
}
});
console.log(` Model providers API: ${JSON.stringify(response)}`);
expect(response.status).to.be.oneOf([200, 401, 404]);
});
it('should handle research history API', async () => {
const response = await page.evaluate(async () => {
try {
const res = await fetch('/api/history', {
credentials: 'include'
});
const data = await res.json();
return {
status: res.status,
isArray: Array.isArray(data),
count: Array.isArray(data) ? data.length : 0,
hasData: typeof data === 'object'
};
} catch (e) {
return { error: e.message };
}
});
console.log(` History API: ${JSON.stringify(response)}`);
expect(response.status).to.be.oneOf([200, 401, 404]);
// Data structure may vary - just check we got some response
expect(response.hasData).to.be.true;
});
});
describe('Download Manager', () => {
it('should load download manager page', async () => {
await gotoWithRetry(page, `${BASE_URL}/library/downloads`);
await takeScreenshot(page, 'download-manager');
const url = page.url();
// May redirect to login if not authenticated, or to downloads
const isValid = url.includes('/downloads') || url.includes('/login') || url.includes('/library');
expect(isValid).to.be.true;
console.log(` Download manager URL: ${url}`);
});
it('should show download queue or status', async () => {
// Navigate to library first (more reliable)
await gotoWithRetry(page, `${BASE_URL}/library/`);
// Look for download-related elements
const queueElements = await page.$$('.download-queue, .queue-item, [class*="download"], table, .ldr-library-container');
console.log(` Found ${queueElements.length} download/library elements`);
await takeScreenshot(page, 'download-queue');
});
});
describe('Error Handling', () => {
it('should gracefully handle invalid research ID', async () => {
try {
await page.goto(`${BASE_URL}/progress/invalid-id-12345`, { waitUntil: 'domcontentloaded', timeout: 15000 });
} catch {
// Timeout is acceptable - page may be polling
console.log(' Navigation timeout (expected for polling page)');
}
await takeScreenshot(page, 'invalid-research-id');
// Should not crash, should show error or redirect
const bodyText = await page.$eval('body', el => el.textContent);
const hasContent = bodyText.length > 20;
expect(hasContent).to.be.true;
console.log(' ✓ Invalid research ID handled gracefully');
});
it('should handle invalid API requests', async () => {
// Make sure we're on a valid page first
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded', timeout: 15000 });
const response = await page.evaluate(async () => {
try {
const res = await fetch('/api/research/invalid-id-xyz/status', {
credentials: 'include'
});
return {
status: res.status,
body: await res.text()
};
} catch (e) {
return { error: e.message };
}
});
console.log(` Invalid API request response: ${JSON.stringify(response)}`);
// Should return error code, not crash - accept 401 if not logged in
expect(response.status).to.be.oneOf([401, 404, 400, 500]);
});
});
describe('Metrics and Analytics', () => {
it('should load metrics page', async () => {
try {
await page.goto(`${BASE_URL}/metrics`, { waitUntil: 'domcontentloaded', timeout: 15000 });
} catch {
console.log(' Metrics page navigation timeout');
}
await takeScreenshot(page, 'metrics-page');
const url = page.url();
// May redirect or show metrics
console.log(` Metrics page URL: ${url}`);
});
it('should load benchmark page', async () => {
try {
await page.goto(`${BASE_URL}/benchmark`, { waitUntil: 'domcontentloaded', timeout: 15000 });
} catch {
console.log(' Benchmark page navigation timeout');
}
await takeScreenshot(page, 'benchmark-page');
const url = page.url();
console.log(` Benchmark page URL: ${url}`);
// Just check page loaded, content may vary
const bodyText = await page.$eval('body', el => el.textContent.toLowerCase());
const hasContent = bodyText.length > 20;
expect(hasContent).to.be.true;
});
});
describe('History Page Tests', () => {
it('should load history page', async () => {
await page.goto(`${BASE_URL}/history`, { waitUntil: 'domcontentloaded' });
await takeScreenshot(page, 'history-page');
const url = page.url();
expect(url).to.include('/history');
console.log(` History page loaded: ${url}`);
});
it('should display research history items or empty state', async () => {
await page.goto(`${BASE_URL}/history`, { waitUntil: 'domcontentloaded' });
await delay(2000);
const bodyText = await page.$eval('body', el => el.textContent);
// Should show history items or empty state message
const hasContent = bodyText.includes('research') ||
bodyText.includes('Research') ||
bodyText.includes('history') ||
bodyText.includes('No') ||
bodyText.includes('empty');
console.log(` Has history content: ${hasContent}`);
expect(hasContent).to.be.true;
});
it('should have search/filter functionality', async () => {
await page.goto(`${BASE_URL}/history`, { waitUntil: 'domcontentloaded' });
await delay(1000);
// Look for search input
const searchInput = await page.$('input[type="search"], input[placeholder*="search"], input[placeholder*="Search"], .search-input, #search');
if (searchInput) {
await searchInput.type('test query');
console.log(' ✓ Search input found and usable');
await delay(500);
} else {
console.log(' No search input found on history page');
}
// Look for filter options
const filters = await page.$$('select, [class*="filter"], button[class*="filter"]');
console.log(` Found ${filters.length} filter elements`);
await takeScreenshot(page, 'history-with-search');
});
it('should handle pagination if present', async () => {
await page.goto(`${BASE_URL}/history`, { waitUntil: 'domcontentloaded' });
await delay(1000);
// Look for pagination elements
const pagination = await page.$$('.pagination, [class*="page"], nav[aria-label*="pagination"], .pager');
console.log(` Found ${pagination.length} pagination elements`);
// Look for next/prev buttons
const pageButtons = await page.$$('button[class*="page"], a[class*="page"], [aria-label*="page"]');
console.log(` Found ${pageButtons.length} page buttons`);
await takeScreenshot(page, 'history-pagination');
});
});
describe('Settings Deep Interaction Tests', () => {
it('should navigate between all settings tabs', async () => {
await page.goto(`${BASE_URL}/settings`, { waitUntil: 'domcontentloaded' });
await delay(2000);
// Get all tabs
const tabs = await page.$$('[data-tab], .tab, [role="tab"]');
console.log(` Found ${tabs.length} settings tabs`);
// Click through each tab
for (let i = 0; i < Math.min(tabs.length, 5); i++) {
try {
await tabs[i].click();
await delay(500);
const tabName = await tabs[i].evaluate(el => el.textContent || el.getAttribute('data-tab'));
console.log(` ✓ Clicked tab: ${tabName}`);
} catch {
console.log(` Tab ${i} not clickable`);
}
}
await takeScreenshot(page, 'settings-all-tabs');
});
it('should use settings search functionality', async () => {
await page.goto(`${BASE_URL}/settings`, { waitUntil: 'domcontentloaded' });
await delay(2000);
const searchInput = await page.$('#settings-search, input[placeholder*="search"], .search-input');
if (searchInput) {
// Search for a common setting
await searchInput.type('model');
await delay(1000);
await takeScreenshot(page, 'settings-search-model');
// Clear and search again
await searchInput.click({ clickCount: 3 });
await page.keyboard.press('Backspace');
await searchInput.type('api');
await delay(1000);
await takeScreenshot(page, 'settings-search-api');
console.log(' ✓ Settings search working');
} else {
console.log(' No settings search input found');
}
});
it('should show tooltips or help text for settings', async () => {
await page.goto(`${BASE_URL}/settings`, { waitUntil: 'domcontentloaded' });
await delay(2000);
// Look for help icons or tooltips
const helpElements = await page.$$('[title], [data-tooltip], .tooltip, [aria-describedby], .help-icon, .info-icon, [class*="help"]');
console.log(` Found ${helpElements.length} help/tooltip elements`);
// Try to hover over a help icon to trigger tooltip
if (helpElements.length > 0) {
await helpElements[0].hover();
await delay(500);
await takeScreenshot(page, 'settings-tooltip');
}
});
it('should handle form validation on settings', async () => {
await page.goto(`${BASE_URL}/settings`, { waitUntil: 'domcontentloaded' });
await delay(2000);
// Find a numeric input
const numericInput = await page.$('input[type="number"]');
if (numericInput) {
const originalValue = await numericInput.evaluate(el => el.value);
console.log(` Original value: ${originalValue}`);
// Try to enter invalid value
await numericInput.click({ clickCount: 3 });
await numericInput.type('-999999');
await delay(1000);
// Check for validation message
const validation = await page.$('.error, .invalid, [class*="error"], .validation-message');
if (validation) {
console.log(' ✓ Validation message shown');
}
// Restore original
await numericInput.click({ clickCount: 3 });
await numericInput.type(originalValue || '5');
await delay(500);
}
await takeScreenshot(page, 'settings-validation');
});
});
describe('Research Advanced Features', () => {
it('should show research mode options', async () => {
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' });
await delay(2000);
// Look for mode selection
const modes = await page.$$('#mode-quick, #mode-detailed, [name="mode"], .mode-selector input');
console.log(` Found ${modes.length} research mode options`);
expect(modes.length).to.be.greaterThan(0);
// Get mode labels
const modeLabels = await page.$$eval('label[for*="mode"], .mode-label',
els => els.map(el => el.textContent.trim()));
console.log(` Mode labels: ${JSON.stringify(modeLabels)}`);
await takeScreenshot(page, 'research-modes');
});
it('should show strategy selection options', async () => {
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' });
await delay(2000);
// Look for strategy options
const strategySelect = await page.$('#strategy, select[name="strategy"], [class*="strategy"]');
if (strategySelect) {
const options = await page.$$eval('#strategy option, select[name="strategy"] option',
opts => opts.map(o => ({ value: o.value, text: o.textContent.trim() })));
console.log(` Strategy options: ${JSON.stringify(options)}`);
expect(options.length).to.be.greaterThan(0);
} else {
console.log(' No strategy selector found');
}
await takeScreenshot(page, 'research-strategy');
});
it('should show model provider selection', async () => {
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' });
await delay(2000);
const providerSelect = await page.$('#model-provider, select[name="model_provider"], .provider-select');
if (providerSelect) {
const options = await page.$$eval('#model-provider option, select[name="model_provider"] option',
opts => opts.map(o => ({ value: o.value, text: o.textContent.trim() })));
console.log(` Provider options: ${JSON.stringify(options.slice(0, 5))}`);
}
await takeScreenshot(page, 'research-providers');
});
it('should show iterations and questions per iteration controls', async () => {
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' });
await delay(2000);
// Look for iterations input
const iterationsInput = await page.$('#iterations, input[name="iterations"]');
if (iterationsInput) {
const value = await iterationsInput.evaluate(el => el.value);
console.log(` Iterations value: ${value}`);
}
// Look for questions per iteration
const questionsInput = await page.$('#questions-per-iteration, input[name="questions_per_iteration"]');
if (questionsInput) {
const value = await questionsInput.evaluate(el => el.value);
console.log(` Questions per iteration: ${value}`);
}
await takeScreenshot(page, 'research-iterations');
});
});
describe('Additional API Tests', () => {
beforeEach(async () => {
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' });
await delay(500);
});
it('should get categories via API', async () => {
const response = await page.evaluate(async () => {
try {
const res = await fetch('/settings/api/categories', { credentials: 'include' });
const data = await res.json();
return { status: res.status, categories: data };
} catch (e) {
return { error: e.message };
}
});
console.log(` Categories API: ${JSON.stringify(response).substring(0, 200)}`);
expect(response.status).to.be.oneOf([200, 401, 404]);
});
it('should get rate limiting info via API', async () => {
const response = await page.evaluate(async () => {
try {
const res = await fetch('/settings/api/rate-limiting/stats', { credentials: 'include' });
return { status: res.status, body: await res.text() };
} catch (e) {
return { error: e.message };
}
});
console.log(` Rate limiting API: status=${response.status}`);
expect(response.status).to.be.oneOf([200, 401, 404, 500]);
});
it('should get library collections via API', async () => {
const response = await page.evaluate(async () => {
try {
const res = await fetch('/library/api/collections', { credentials: 'include' });
const data = await res.json();
return { status: res.status, count: Array.isArray(data) ? data.length : 'not array', sample: data };
} catch (e) {
return { error: e.message };
}
});
console.log(` Collections API: ${JSON.stringify(response).substring(0, 300)}`);
expect(response.status).to.be.oneOf([200, 401, 404]);
});
it('should get news subscriptions via API', async () => {
const response = await page.evaluate(async () => {
try {
const res = await fetch('/news/api/subscriptions/current', { credentials: 'include' });
const data = await res.json();
return { status: res.status, data };
} catch (e) {
return { error: e.message };
}
});
console.log(` Subscriptions API: ${JSON.stringify(response).substring(0, 200)}`);
expect(response.status).to.be.oneOf([200, 401, 404]);
});
it('should handle metrics API', async () => {
const response = await page.evaluate(async () => {
try {
const res = await fetch('/api/metrics', { credentials: 'include' });
return { status: res.status, body: await res.text() };
} catch (e) {
return { error: e.message };
}
});
console.log(` Metrics API: status=${response.status}`);
expect(response.status).to.be.oneOf([200, 401, 404, 500]);
});
});
describe('Edge Cases and Input Validation', () => {
it('should handle very long query input', async () => {
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' });
await delay(2000);
const queryInput = await page.$('#query, textarea[name="query"]');
if (queryInput) {
// Generate a very long query
const longQuery = 'This is a test query. '.repeat(100);
await queryInput.type(longQuery);
await delay(500);
const value = await queryInput.evaluate(el => el.value);
console.log(` Long query length: ${value.length}`);
// Check if there's a character limit warning
const warning = await page.$('.warning, .error, [class*="limit"], [class*="warning"]');
if (warning) {
console.log(' Character limit warning displayed');
}
await takeScreenshot(page, 'long-query-input');
}
});
it('should handle special characters in query', async () => {
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' });
await delay(2000);
const queryInput = await page.$('#query, textarea[name="query"]');
if (queryInput) {
await queryInput.click({ clickCount: 3 });
await page.keyboard.press('Backspace');
// Test special characters
const specialQuery = '<script>alert("test")</script> & "quotes" \'apostrophe\' 日本語 emoji: 🔬';
await queryInput.type(specialQuery);
const value = await queryInput.evaluate(el => el.value);
console.log(` Special chars preserved: ${value.includes('🔬')}`);
await takeScreenshot(page, 'special-chars-query');
}
});
it('should handle empty form submission', async () => {
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' });
await delay(2000);
// Try to submit without query
const submitBtn = await page.$('#submit-btn, button[type="submit"], .submit-button');
if (submitBtn) {
// Clear any existing query
const queryInput = await page.$('#query, textarea[name="query"]');
if (queryInput) {
await queryInput.click({ clickCount: 3 });
await page.keyboard.press('Backspace');
}
await submitBtn.click();
await delay(1000);
// Check for validation error
const error = await page.$('.error, .validation-error, [class*="error"], .invalid-feedback');
if (error) {
const errorText = await error.evaluate(el => el.textContent);
console.log(` Validation error shown: ${errorText}`);
}
await takeScreenshot(page, 'empty-submission');
}
});
it('should handle rapid navigation', async () => {
// Test rapid navigation between pages
const urls = [
`${BASE_URL}/`,
`${BASE_URL}/settings`,
`${BASE_URL}/history`,
`${BASE_URL}/library/`,
`${BASE_URL}/`
];
for (const url of urls) {
await page.goto(url, { waitUntil: 'domcontentloaded' });
}
// Should end up on home page without errors
const finalUrl = page.url();
console.log(` Final URL after rapid nav: ${finalUrl}`);
expect(finalUrl).to.include(BASE_URL);
await takeScreenshot(page, 'rapid-navigation');
});
// Mocha's `this.skip()` only works on `function() {}` test bodies,
// not arrow functions — that's why the three late-stage tests below
// use `function()`. Using skip lets CI dashboards surface the
// flake frequency accurately (skipped vs spuriously-passed).
// `isCdpSessionFlake` is defined at module scope above so both
// describes here can reuse it.
it('should handle browser back/forward navigation', async function () {
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' });
await page.goto(`${BASE_URL}/settings`, { waitUntil: 'domcontentloaded' });
await page.goto(`${BASE_URL}/history`, { waitUntil: 'domcontentloaded' });
try {
// Go back twice
await page.goBack();
await delay(1000);
const afterBack1 = page.url();
console.log(` After first back: ${afterBack1}`);
await page.goBack();
await delay(1000);
const afterBack2 = page.url();
console.log(` After second back: ${afterBack2}`);
// Go forward
await page.goForward();
await delay(1000);
const afterForward = page.url();
console.log(` After forward: ${afterForward}`);
await takeScreenshot(page, 'back-forward-nav');
} catch (navError) {
if (isCdpSessionFlake(navError)) {
console.log(
` ⚠️ Skipping: CDP-session flake on back/forward: ${navError.message}`
);
this.skip();
}
throw navError;
}
});
});
describe('Responsive Design Tests', () => {
// Late-stage tests on a long-lived Chrome session. CDP commands
// (setViewport / goto) can hit their `protocolTimeout` even though
// the launch options set it to 120s — the browser just becomes
// unresponsive after the cumulative test load. We skip on the
// documented CDP flake, re-throw anything else. The dedicated
// `responsive-ui-tests-enhanced.yml` workflow covers the same
// surface from a fresh browser.
it('should render correctly on mobile viewport', async function () {
try {
await page.setViewport({ width: 375, height: 667 }); // iPhone SE
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' });
await delay(1000);
await takeScreenshot(page, 'mobile-home');
// Check for mobile menu or hamburger
const mobileMenu = await page.$('.mobile-menu, .hamburger, [class*="mobile"], .navbar-toggler, .menu-toggle');
console.log(` Mobile menu found: ${mobileMenu !== null}`);
} catch (viewportError) {
if (isCdpSessionFlake(viewportError)) {
console.log(
` ⚠️ Skipping: CDP-session flake on mobile viewport: ${viewportError.message}`
);
this.skip();
}
throw viewportError;
} finally {
// Reset viewport even if the test errored, so subsequent
// tests don't inherit a 375-wide window. Wrap defensively
// because the same CDP flake can hit the reset itself.
try {
await page.setViewport({ width: 1400, height: 900 });
} catch (resetError) {
console.log(
` ⚠️ Viewport reset also failed: ${resetError.message}`
);
}
}
});
it('should render settings on tablet viewport', async function () {
try {
await page.setViewport({ width: 768, height: 1024 }); // iPad
await page.goto(`${BASE_URL}/settings`, { waitUntil: 'domcontentloaded' });
await delay(1000);
await takeScreenshot(page, 'tablet-settings');
} catch (viewportError) {
if (isCdpSessionFlake(viewportError)) {
console.log(
` ⚠️ Skipping: CDP-session flake on tablet viewport: ${viewportError.message}`
);
this.skip();
}
throw viewportError;
} finally {
try {
await page.setViewport({ width: 1400, height: 900 });
} catch (resetError) {
console.log(
` ⚠️ Viewport reset also failed: ${resetError.message}`
);
}
}
});
});
describe('Accessibility Basic Tests', () => {
it('should have page title', async () => {
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' });
const title = await page.title();
console.log(` Page title: ${title}`);
expect(title.length).to.be.greaterThan(0);
});
it('should have form labels', async () => {
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' });
await delay(1000);
const labels = await page.$$('label');
console.log(` Found ${labels.length} form labels`);
// Check if inputs have associated labels
const inputsWithLabels = await page.$$eval('input[id], textarea[id]', inputs => {
return inputs.map(input => {
const label = document.querySelector(`label[for="${input.id}"]`);
return { id: input.id, hasLabel: label !== null };
});
});
console.log(` Inputs with labels: ${JSON.stringify(inputsWithLabels.slice(0, 5))}`);
});
it('should have alt text on images', async () => {
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' });
const images = await page.$$eval('img', imgs => {
return imgs.map(img => ({
src: img.src.substring(img.src.lastIndexOf('/') + 1),
hasAlt: img.hasAttribute('alt'),
alt: img.alt
}));
});
console.log(` Found ${images.length} images`);
if (images.length > 0) {
const withAlt = images.filter(i => i.hasAlt).length;
console.log(` Images with alt: ${withAlt}/${images.length}`);
}
});
it('should be keyboard navigable', async () => {
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' });
await delay(1000);
// Tab through elements
for (let i = 0; i < 10; i++) {
await page.keyboard.press('Tab');
}
// Get currently focused element
const focused = await page.evaluate(() => {
const el = document.activeElement;
return {
tag: el.tagName,
id: el.id,
class: el.className
};
});
console.log(` After 10 tabs, focused on: ${JSON.stringify(focused)}`);
await takeScreenshot(page, 'keyboard-navigation');
});
});
describe('Session and State Tests', () => {
it('should maintain state after page refresh', async () => {
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' });
await delay(1000);
// Enter something in query
const queryInput = await page.$('#query, textarea[name="query"]');
if (queryInput) {
await queryInput.type('Test query for state');
}
// Check if logged in
const beforeRefresh = await page.evaluate(() => {
return {
hasSession: document.cookie.includes('session'),
url: window.location.href
};
});
console.log(` Before refresh: ${JSON.stringify(beforeRefresh)}`);
// Refresh
await page.reload({ waitUntil: 'domcontentloaded' });
await delay(1000);
const afterRefresh = await page.evaluate(() => {
return {
hasSession: document.cookie.includes('session'),
url: window.location.href
};
});
console.log(` After refresh: ${JSON.stringify(afterRefresh)}`);
// Should still be on same page
expect(afterRefresh.url).to.include(BASE_URL);
});
it('should handle concurrent API calls', async () => {
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' });
// Make multiple concurrent API calls
const results = await page.evaluate(async () => {
const calls = [
fetch('/settings/api/settings', { credentials: 'include' }),
fetch('/api/history', { credentials: 'include' }),
fetch('/settings/api/categories', { credentials: 'include' })
];
try {
const responses = await Promise.all(calls);
return responses.map(r => ({ status: r.status, ok: r.ok }));
} catch (e) {
return { error: e.message };
}
});
console.log(` Concurrent API results: ${JSON.stringify(results)}`);
expect(Array.isArray(results)).to.be.true;
});
});
describe('Authentication Tests', () => {
it('should reject login with invalid credentials', async () => {
// First logout if logged in
await page.goto(`${BASE_URL}/logout`, { waitUntil: 'domcontentloaded' });
await delay(1000);
await page.goto(`${BASE_URL}/login`, { waitUntil: 'domcontentloaded' });
await takeScreenshot(page, 'login-page-for-invalid');
const usernameInput = await page.$('input[name="username"], #username');
const passwordInput = await page.$('input[name="password"], #password');
if (usernameInput && passwordInput) {
await usernameInput.type('invalid_user_xyz');
await passwordInput.type('wrong_password_123');
const submitBtn = await page.$('button[type="submit"], input[type="submit"]');
if (submitBtn) {
await submitBtn.click();
await delay(2000);
}
const currentUrl = page.url();
const bodyText = await page.$eval('body', el => el.textContent.toLowerCase());
// Should still be on login page or show error
const hasError = bodyText.includes('invalid') ||
bodyText.includes('incorrect') ||
bodyText.includes('error') ||
bodyText.includes('failed') ||
currentUrl.includes('/login');
console.log(` Login rejected: ${hasError}`);
expect(hasError).to.be.true;
}
await takeScreenshot(page, 'login-invalid-creds');
// Re-login with valid credentials for remaining tests
await ensureLoggedIn(page, TEST_USERNAME, TEST_PASSWORD);
});
it('should show error message for wrong password', async () => {
await page.goto(`${BASE_URL}/logout`, { waitUntil: 'domcontentloaded' });
await delay(1000);
await page.goto(`${BASE_URL}/login`, { waitUntil: 'domcontentloaded' });
const usernameInput = await page.$('input[name="username"], #username');
const passwordInput = await page.$('input[name="password"], #password');
if (usernameInput && passwordInput) {
// Use existing username but wrong password
await usernameInput.type(TEST_USERNAME);
await passwordInput.type('definitely_wrong_password');
const submitBtn = await page.$('button[type="submit"], input[type="submit"]');
if (submitBtn) {
await submitBtn.click();
await delay(2000);
}
// Look for error message
const errorMsg = await page.$('.error, .alert-danger, [class*="error"], .flash-message');
if (errorMsg) {
const text = await errorMsg.evaluate(el => el.textContent);
console.log(` Error message: ${text}`);
}
}
await takeScreenshot(page, 'login-wrong-password');
// Re-login
await ensureLoggedIn(page, TEST_USERNAME, TEST_PASSWORD);
});
it('should handle logout correctly', async () => {
// Make sure we're logged in first
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' });
// Try to logout
await page.goto(`${BASE_URL}/logout`, { waitUntil: 'domcontentloaded' });
await delay(2000);
const afterLogoutUrl = page.url();
console.log(` After logout URL: ${afterLogoutUrl}`);
// Should redirect to login or home
const isLoggedOut = afterLogoutUrl.includes('/login') ||
afterLogoutUrl === `${BASE_URL}/` ||
afterLogoutUrl.includes('logout');
expect(isLoggedOut).to.be.true;
await takeScreenshot(page, 'after-logout');
// Re-login for remaining tests
await ensureLoggedIn(page, TEST_USERNAME, TEST_PASSWORD);
});
it('should redirect to login when session expires', async () => {
// Clear cookies to simulate session expiry
const client = await page.target().createCDPSession();
await client.send('Network.clearBrowserCookies');
// Try to access protected page
await page.goto(`${BASE_URL}/settings`, { waitUntil: 'domcontentloaded' });
await delay(2000);
const currentUrl = page.url();
console.log(` After clearing cookies URL: ${currentUrl}`);
// Should redirect to login
const redirectedToLogin = currentUrl.includes('/login');
console.log(` Redirected to login: ${redirectedToLogin}`);
await takeScreenshot(page, 'session-expired-redirect');
// Re-login
await ensureLoggedIn(page, TEST_USERNAME, TEST_PASSWORD);
});
it('should prevent access to protected pages when logged out', async () => {
// Logout first
await page.goto(`${BASE_URL}/logout`, { waitUntil: 'domcontentloaded' });
await delay(1000);
// Try to access protected pages
const protectedUrls = [
`${BASE_URL}/settings`,
`${BASE_URL}/history`,
`${BASE_URL}/library/`
];
for (const url of protectedUrls) {
await page.goto(url, { waitUntil: 'domcontentloaded' });
await delay(500);
const currentUrl = page.url();
const isProtected = currentUrl.includes('/login') || currentUrl !== url;
console.log(` ${url} protected: ${isProtected}`);
}
await takeScreenshot(page, 'protected-pages-check');
// Re-login
await ensureLoggedIn(page, TEST_USERNAME, TEST_PASSWORD);
});
it('should handle multiple failed login attempts', async () => {
await page.goto(`${BASE_URL}/logout`, { waitUntil: 'domcontentloaded' });
await delay(1000);
// Try multiple failed logins
for (let i = 0; i < 3; i++) {
await page.goto(`${BASE_URL}/login`, { waitUntil: 'domcontentloaded' });
const usernameInput = await page.$('input[name="username"], #username');
const passwordInput = await page.$('input[name="password"], #password');
if (usernameInput && passwordInput) {
await usernameInput.type(`bad_user_${i}`);
await passwordInput.type(`bad_pass_${i}`);
const submitBtn = await page.$('button[type="submit"], input[type="submit"]');
if (submitBtn) {
await submitBtn.click();
await delay(1000);
}
}
}
// Check for rate limiting or lockout message
const bodyText = await page.$eval('body', el => el.textContent.toLowerCase());
const hasRateLimit = bodyText.includes('rate') ||
bodyText.includes('limit') ||
bodyText.includes('too many') ||
bodyText.includes('locked');
console.log(` Rate limiting detected: ${hasRateLimit}`);
await takeScreenshot(page, 'multiple-failed-logins');
// Re-login
await ensureLoggedIn(page, TEST_USERNAME, TEST_PASSWORD);
});
});
describe('Research Deletion Tests', () => {
it('should show delete button on history items', async () => {
await page.goto(`${BASE_URL}/history`, { waitUntil: 'domcontentloaded' });
await delay(2000);
await takeScreenshot(page, 'history-delete-buttons');
// Look for delete buttons
const deleteButtons = await page.$$('button[class*="delete"], .delete-btn, [title*="delete"], [title*="Delete"], [aria-label*="delete"], .btn-danger');
console.log(` Found ${deleteButtons.length} delete buttons`);
// Also check for delete icons
const deleteIcons = await page.$$('[class*="trash"], [class*="delete"], .fa-trash');
console.log(` Found ${deleteIcons.length} delete icons`);
});
it('should show confirmation before deleting', async () => {
await page.goto(`${BASE_URL}/history`, { waitUntil: 'domcontentloaded' });
await delay(2000);
// Look for a delete button and click it
const deleteBtn = await page.$('button[class*="delete"], .delete-btn, [title*="delete"], .btn-danger');
if (deleteBtn) {
// Set up dialog handler before clicking
let dialogShown = false;
page.once('dialog', async dialog => {
dialogShown = true;
console.log(` Confirmation dialog: ${dialog.message()}`);
await dialog.dismiss(); // Cancel the deletion
});
await deleteBtn.click();
await delay(1000);
console.log(` Confirmation dialog shown: ${dialogShown}`);
await takeScreenshot(page, 'delete-confirmation');
} else {
console.log(' No delete button found (may be empty history)');
}
});
it('should remove item from list after deletion', async () => {
await page.goto(`${BASE_URL}/history`, { waitUntil: 'domcontentloaded' });
await delay(2000);
// Count initial items
const initialItems = await page.$$('.history-item, .research-item, tr[data-id], [class*="history-row"]');
console.log(` Initial items: ${initialItems.length}`);
if (initialItems.length > 0) {
// Get first item identifier for comparison
const firstItemId = await initialItems[0].evaluate(el => el.getAttribute('data-id') || el.id || 'unknown');
console.log(` First item ID: ${firstItemId}`);
// Find and click delete button
const deleteBtn = await page.$('button[class*="delete"], .delete-btn, [title*="delete"]');
if (deleteBtn) {
// Accept confirmation
page.once('dialog', async dialog => {
await dialog.accept();
});
await deleteBtn.click();
await delay(2000);
// Count items after deletion
const afterItems = await page.$$('.history-item, .research-item, tr[data-id], [class*="history-row"]');
console.log(` After deletion: ${afterItems.length}`);
}
}
await takeScreenshot(page, 'history-after-delete');
});
it('should handle deletion via API', async () => {
// First get list of research items
const historyResponse = await page.evaluate(async () => {
try {
const res = await fetch('/api/history', { credentials: 'include' });
return await res.json();
} catch (e) {
return { error: e.message };
}
});
if (Array.isArray(historyResponse) && historyResponse.length > 0) {
const researchId = historyResponse[0].id || historyResponse[0].research_id;
console.log(` Attempting to delete research: ${researchId}`);
if (researchId) {
const deleteResponse = await page.evaluate(async (id) => {
try {
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
const csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : '';
const res = await fetch(`/api/research/${id}`, {
method: 'DELETE',
credentials: 'include',
headers: {
'X-CSRFToken': csrfToken
}
});
return { status: res.status, ok: res.ok };
} catch (e) {
return { error: e.message };
}
}, researchId);
console.log(` Delete API response: ${JSON.stringify(deleteResponse)}`);
expect(deleteResponse.status).to.be.oneOf([200, 204, 401, 404]);
}
} else {
console.log(' No research items to delete');
}
});
it('should handle clear all history', async () => {
await page.goto(`${BASE_URL}/history`, { waitUntil: 'domcontentloaded' });
await delay(2000);
// Look for "clear all" button
const clearAllBtn = await page.$('button[class*="clear-all"], .clear-history, [title*="clear all"], #clear-all');
if (clearAllBtn) {
console.log(' Found clear all button');
// Set up dialog handler
page.once('dialog', async dialog => {
console.log(` Clear all dialog: ${dialog.message()}`);
await dialog.dismiss(); // Don't actually clear
});
await clearAllBtn.click();
await delay(1000);
} else {
console.log(' No clear all button found');
}
await takeScreenshot(page, 'history-clear-all');
});
it('should prevent deletion of in-progress research', async () => {
// Try to delete an in-progress research via API
const response = await page.evaluate(async () => {
try {
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
const csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : '';
// Try with a fake in-progress ID
const res = await fetch('/api/research/in-progress-fake-id', {
method: 'DELETE',
credentials: 'include',
headers: {
'X-CSRFToken': csrfToken
}
});
return { status: res.status, body: await res.text() };
} catch (e) {
return { error: e.message };
}
});
console.log(` Delete in-progress response: ${JSON.stringify(response)}`);
// Should return error (404, 400, 405 Method Not Allowed, etc.)
expect(response.status).to.be.oneOf([400, 401, 404, 405, 409]);
});
});
describe('Research Termination Tests', () => {
it('should show terminate button during research', async () => {
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' });
await delay(2000);
// Check progress pages in history for terminate button
await page.goto(`${BASE_URL}/history`, { waitUntil: 'domcontentloaded' });
await delay(2000);
// Look for any in-progress items
const inProgressItems = await page.$$('[class*="in-progress"], [class*="running"], [data-status="running"]');
console.log(` Found ${inProgressItems.length} in-progress items`);
// Look for terminate/stop buttons
const terminateButtons = await page.$$('button[class*="terminate"], button[class*="stop"], .cancel-btn, [title*="stop"], [title*="terminate"]');
console.log(` Found ${terminateButtons.length} terminate buttons`);
await takeScreenshot(page, 'terminate-button-check');
});
it('should terminate research via API', async () => {
// Get a research ID that might be in progress
const historyResponse = await page.evaluate(async () => {
try {
const res = await fetch('/api/history', { credentials: 'include' });
return await res.json();
} catch (e) {
return { error: e.message };
}
});
if (Array.isArray(historyResponse) && historyResponse.length > 0) {
const researchId = historyResponse[0].id || historyResponse[0].research_id;
console.log(` Attempting to terminate research: ${researchId}`);
if (researchId) {
const terminateResponse = await page.evaluate(async (id) => {
try {
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
const csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : '';
const res = await fetch(`/api/research/${id}/terminate`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
return { status: res.status, body: await res.text() };
} catch (e) {
return { error: e.message };
}
}, researchId);
console.log(` Terminate API response: ${JSON.stringify(terminateResponse)}`);
// Accept various responses - may already be completed
expect(terminateResponse.status).to.be.oneOf([200, 400, 401, 404, 409]);
}
} else {
console.log(' No research items to terminate');
}
});
it('should update UI when research is terminated', async () => {
// Navigate to a progress page if we have a research
const historyResponse = await page.evaluate(async () => {
try {
const res = await fetch('/api/history', { credentials: 'include' });
return await res.json();
} catch (e) {
return { error: e.message };
}
});
if (Array.isArray(historyResponse) && historyResponse.length > 0) {
const researchId = historyResponse[0].id || historyResponse[0].research_id;
if (researchId) {
try {
await page.goto(`${BASE_URL}/progress/${researchId}`, {
waitUntil: 'domcontentloaded',
timeout: 10000
});
} catch {
console.log(' Progress page timeout (expected)');
}
// Check for status indicators
const bodyText = await page.$eval('body', el => el.textContent.toLowerCase());
const hasStatus = bodyText.includes('complete') ||
bodyText.includes('terminated') ||
bodyText.includes('cancelled') ||
bodyText.includes('progress');
console.log(` Has status indicator: ${hasStatus}`);
await takeScreenshot(page, 'research-status-ui');
}
} else {
console.log(' No research items available');
}
});
it('should show partial results after termination', async () => {
const historyResponse = await page.evaluate(async () => {
try {
const res = await fetch('/api/history', { credentials: 'include' });
return await res.json();
} catch (e) {
return { error: e.message };
}
});
if (Array.isArray(historyResponse) && historyResponse.length > 0) {
// Find a terminated or completed research
const research = historyResponse.find(r =>
r.status === 'terminated' || r.status === 'cancelled' || r.status === 'completed');
if (research) {
const researchId = research.id || research.research_id;
await page.goto(`${BASE_URL}/results/${researchId}`, { waitUntil: 'domcontentloaded' });
await delay(2000);
// Check for result content
const hasResults = await page.$('#results-content, .results, [class*="result"]');
console.log(` Has results container: ${hasResults !== null}`);
await takeScreenshot(page, 'partial-results');
}
} else {
console.log(' No research items to check');
}
});
it('should handle termination of already completed research', async () => {
const historyResponse = await page.evaluate(async () => {
try {
const res = await fetch('/api/history', { credentials: 'include' });
return await res.json();
} catch (e) {
return { error: e.message };
}
});
if (Array.isArray(historyResponse) && historyResponse.length > 0) {
// Find a completed research
const completed = historyResponse.find(r => r.status === 'completed');
if (completed) {
const researchId = completed.id || completed.research_id;
const response = await page.evaluate(async (id) => {
try {
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
const csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : '';
const res = await fetch(`/api/research/${id}/terminate`, {
method: 'POST',
credentials: 'include',
headers: {
'X-CSRFToken': csrfToken
}
});
return { status: res.status, body: await res.text() };
} catch (e) {
return { error: e.message };
}
}, researchId);
console.log(` Terminate completed research: ${JSON.stringify(response)}`);
// Should gracefully handle (400 or 409 for already completed)
expect(response.status).to.be.oneOf([200, 400, 401, 404, 409]);
} else {
console.log(' No completed research found');
}
} else {
console.log(' No research items available');
}
});
});
describe('Research Details Page Tests', () => {
let testResearchId;
before(async () => {
// Get a research ID for testing
const historyResponse = await page.evaluate(async () => {
try {
const res = await fetch('/api/history', { credentials: 'include' });
return await res.json();
} catch (e) {
return { error: e.message };
}
});
if (Array.isArray(historyResponse) && historyResponse.length > 0) {
testResearchId = historyResponse[0].id || historyResponse[0].research_id;
console.log(` Using research ID: ${testResearchId}`);
}
});
it('should load details page for completed research', async () => {
if (!testResearchId) {
console.log(' Skipping - no research ID available');
return;
}
await page.goto(`${BASE_URL}/results/${testResearchId}`, { waitUntil: 'domcontentloaded' });
await delay(2000);
const url = page.url();
console.log(` Details page URL: ${url}`);
await takeScreenshot(page, 'research-details-page');
expect(url).to.include('/results/');
});
it('should display query and parameters', async () => {
if (!testResearchId) {
console.log(' Skipping - no research ID available');
return;
}
await page.goto(`${BASE_URL}/results/${testResearchId}`, { waitUntil: 'domcontentloaded' });
await delay(2000);
// Look for query display
const queryElement = await page.$('#result-query, .research-query, [class*="query"]');
if (queryElement) {
const queryText = await queryElement.evaluate(el => el.textContent);
console.log(` Query: ${queryText}`);
}
// Look for parameters
const params = await page.$$('[class*="param"], [class*="setting"], .research-params');
console.log(` Found ${params.length} parameter elements`);
await takeScreenshot(page, 'research-query-params');
});
it('should show research timing information', async () => {
if (!testResearchId) {
console.log(' Skipping - no research ID available');
return;
}
await page.goto(`${BASE_URL}/results/${testResearchId}`, { waitUntil: 'domcontentloaded' });
await delay(2000);
const bodyText = await page.$eval('body', el => el.textContent);
// Check for timing info
const hasStartTime = bodyText.includes('started') ||
bodyText.includes('Started') ||
bodyText.includes('created') ||
bodyText.includes('timestamp');
const hasDuration = bodyText.includes('duration') ||
bodyText.includes('Duration') ||
bodyText.includes('took') ||
bodyText.includes('minutes') ||
bodyText.includes('seconds');
console.log(` Has start time: ${hasStartTime}`);
console.log(` Has duration: ${hasDuration}`);
await takeScreenshot(page, 'research-timing');
});
it('should display model and provider used', async () => {
if (!testResearchId) {
console.log(' Skipping - no research ID available');
return;
}
// Get research details via API
const details = await page.evaluate(async (id) => {
try {
const res = await fetch(`/api/research/${id}`, { credentials: 'include' });
return await res.json();
} catch (e) {
return { error: e.message };
}
}, testResearchId);
console.log(` Research details: ${JSON.stringify(details).substring(0, 500)}`);
if (details.model || details.provider || details.llm_provider) {
console.log(` Model: ${details.model || 'N/A'}`);
console.log(` Provider: ${details.provider || details.llm_provider || 'N/A'}`);
}
});
it('should show iteration breakdown', async () => {
if (!testResearchId) {
console.log(' Skipping - no research ID available');
return;
}
await page.goto(`${BASE_URL}/results/${testResearchId}`, { waitUntil: 'domcontentloaded' });
await delay(2000);
// Look for iteration info
const iterations = await page.$$('[class*="iteration"], .iteration-item');
console.log(` Found ${iterations.length} iteration elements`);
const bodyText = await page.$eval('body', el => el.textContent);
const hasIterationInfo = bodyText.includes('iteration') ||
bodyText.includes('Iteration') ||
bodyText.includes('step') ||
bodyText.includes('phase');
console.log(` Has iteration info: ${hasIterationInfo}`);
await takeScreenshot(page, 'research-iterations');
});
it('should have link back to results', async () => {
if (!testResearchId) {
console.log(' Skipping - no research ID available');
return;
}
await page.goto(`${BASE_URL}/results/${testResearchId}`, { waitUntil: 'domcontentloaded' });
await delay(2000);
// Look for navigation links
const navLinks = await page.$$('a[href*="history"], a[href*="results"], .back-link, [class*="nav"]');
console.log(` Found ${navLinks.length} navigation links`);
// Check for home/back button
const homeLink = await page.$('a[href="/"], .home-link, [title*="home"]');
console.log(` Home link found: ${homeLink !== null}`);
await takeScreenshot(page, 'research-navigation');
});
});
describe('Report Format Tests', () => {
let testResearchId;
before(async () => {
const historyResponse = await page.evaluate(async () => {
try {
const res = await fetch('/api/history', { credentials: 'include' });
return await res.json();
} catch (e) {
return { error: e.message };
}
});
if (Array.isArray(historyResponse) && historyResponse.length > 0) {
testResearchId = historyResponse[0].id || historyResponse[0].research_id;
}
});
it('should get report via API', async () => {
if (!testResearchId) {
console.log(' Skipping - no research ID available');
return;
}
const report = await page.evaluate(async (id) => {
try {
const res = await fetch(`/api/research/${id}/report`, { credentials: 'include' });
const contentType = res.headers.get('content-type');
const text = await res.text();
return {
status: res.status,
contentType,
length: text.length,
preview: text.substring(0, 500)
};
} catch (e) {
return { error: e.message };
}
}, testResearchId);
console.log(` Report API response: status=${report.status}, length=${report.length}`);
console.log(` Content type: ${report.contentType}`);
if (report.preview) {
console.log(` Preview: ${report.preview.substring(0, 200)}`);
}
});
it('should support markdown format', async () => {
if (!testResearchId) {
console.log(' Skipping - no research ID available');
return;
}
// Try to get markdown format
const mdReport = await page.evaluate(async (id) => {
try {
const res = await fetch(`/api/research/${id}/report?format=markdown`, { credentials: 'include' });
const text = await res.text();
return {
status: res.status,
hasMarkdown: text.includes('#') || text.includes('*') || text.includes('-'),
preview: text.substring(0, 300)
};
} catch (e) {
return { error: e.message };
}
}, testResearchId);
console.log(` Markdown report: ${JSON.stringify(mdReport).substring(0, 300)}`);
});
it('should include research metadata in report', async () => {
if (!testResearchId) {
console.log(' Skipping - no research ID available');
return;
}
await page.goto(`${BASE_URL}/results/${testResearchId}`, { waitUntil: 'domcontentloaded' });
await delay(2000);
const bodyText = await page.$eval('body', el => el.textContent);
// Check for metadata
const hasQuery = bodyText.includes('query') || bodyText.includes('Query') || bodyText.includes('Research');
const hasDate = bodyText.includes('date') || bodyText.includes('Date') || /\d{4}[-/]\d{2}[-/]\d{2}/.test(bodyText);
console.log(` Has query in report: ${hasQuery}`);
console.log(` Has date in report: ${hasDate}`);
await takeScreenshot(page, 'report-metadata');
});
it('should handle report for failed research', async () => {
// Try to get report for a non-existent research
const response = await page.evaluate(async () => {
try {
const res = await fetch('/api/research/non-existent-id/report', { credentials: 'include' });
return { status: res.status, body: await res.text() };
} catch (e) {
return { error: e.message };
}
});
console.log(` Failed research report: ${JSON.stringify(response)}`);
expect(response.status).to.be.oneOf([401, 404]);
});
it('should include sources in report', async () => {
if (!testResearchId) {
console.log(' Skipping - no research ID available');
return;
}
await page.goto(`${BASE_URL}/results/${testResearchId}`, { waitUntil: 'domcontentloaded' });
await delay(2000);
const bodyText = await page.$eval('body', el => el.textContent);
// Check for sources/references
const hasSources = bodyText.includes('source') ||
bodyText.includes('Source') ||
bodyText.includes('reference') ||
bodyText.includes('Reference') ||
bodyText.includes('http') ||
bodyText.includes('wikipedia');
console.log(` Has sources: ${hasSources}`);
// Look for links
const links = await page.$$eval('a[href^="http"]', els => els.length);
console.log(` External links found: ${links}`);
await takeScreenshot(page, 'report-sources');
});
});
describe('News Subscription Lifecycle Tests', () => {
let testSubscriptionId;
before(async () => {
// Get existing subscriptions
const subs = await page.evaluate(async () => {
try {
const res = await fetch('/news/api/subscriptions/current', { credentials: 'include' });
return await res.json();
} catch (e) {
return { error: e.message };
}
});
if (Array.isArray(subs) && subs.length > 0) {
testSubscriptionId = subs[0].id;
console.log(` Using subscription ID: ${testSubscriptionId}`);
}
});
it('should edit existing subscription', async () => {
if (!testSubscriptionId) {
console.log(' Skipping - no subscription available');
return;
}
await page.goto(`${BASE_URL}/news/subscriptions/${testSubscriptionId}/edit`, { waitUntil: 'domcontentloaded' });
await delay(2000);
await takeScreenshot(page, 'subscription-edit-form');
const url = page.url();
const hasEditForm = url.includes('/edit') || await page.$('form') !== null;
console.log(` Edit form found: ${hasEditForm}`);
});
it('should delete subscription', async () => {
// Try delete via API
const deleteSub = await page.evaluate(async () => {
try {
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
const csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : '';
// Don't actually delete - just check API exists
const res = await fetch('/news/api/subscriptions/test-id', {
method: 'DELETE',
credentials: 'include',
headers: { 'X-CSRFToken': csrfToken }
});
return { status: res.status };
} catch (e) {
return { error: e.message };
}
});
console.log(` Delete subscription API: ${JSON.stringify(deleteSub)}`);
expect(deleteSub.status).to.be.oneOf([200, 204, 401, 404, 405, 500]);
});
it('should show confirmation before delete', async () => {
await page.goto(`${BASE_URL}/news/subscriptions`, { waitUntil: 'domcontentloaded' });
await delay(2000);
// Look for delete buttons
const deleteBtn = await page.$('.delete-subscription, [title*="delete"], button[class*="delete"]');
if (deleteBtn) {
let dialogShown = false;
page.once('dialog', async dialog => {
dialogShown = true;
console.log(` Delete confirmation: ${dialog.message()}`);
await dialog.dismiss();
});
await deleteBtn.click();
await delay(1000);
console.log(` Confirmation shown: ${dialogShown}`);
} else {
console.log(' No delete button found');
}
await takeScreenshot(page, 'subscription-delete-confirm');
});
it('should update subscription list after changes', async () => {
await page.goto(`${BASE_URL}/news/subscriptions`, { waitUntil: 'domcontentloaded' });
await delay(2000);
// Count subscriptions
const items = await page.$$('.subscription-item, tr[data-id], [class*="subscription-row"]');
console.log(` Subscriptions count: ${items.length}`);
// Refresh page
await page.reload({ waitUntil: 'domcontentloaded' });
await delay(2000);
const itemsAfter = await page.$$('.subscription-item, tr[data-id], [class*="subscription-row"]');
console.log(` After refresh: ${itemsAfter.length}`);
await takeScreenshot(page, 'subscription-list-refresh');
});
it('should handle subscription API errors', async () => {
const response = await page.evaluate(async () => {
try {
const res = await fetch('/news/api/subscriptions/invalid-id-xyz', { credentials: 'include' });
return { status: res.status, body: await res.text() };
} catch (e) {
return { error: e.message };
}
});
console.log(` Invalid subscription API: ${JSON.stringify(response)}`);
expect(response.status).to.be.oneOf([401, 404, 500]);
});
});
describe('Cost Analytics Tests', () => {
it('should load costs page', async () => {
await page.goto(`${BASE_URL}/costs`, { waitUntil: 'domcontentloaded' });
await delay(2000);
await takeScreenshot(page, 'costs-page');
const url = page.url();
console.log(` Costs page URL: ${url}`);
// Page may redirect if not available
expect(url).to.include(BASE_URL);
});
it('should display cost data via API', async () => {
const costData = await page.evaluate(async () => {
try {
const res = await fetch('/api/costs', { credentials: 'include' });
const data = await res.json();
return { status: res.status, data };
} catch (e) {
return { error: e.message };
}
});
console.log(` Costs API: ${JSON.stringify(costData).substring(0, 500)}`);
expect(costData.status).to.be.oneOf([200, 401, 404]);
});
it('should show cost breakdown', async () => {
await page.goto(`${BASE_URL}/costs`, { waitUntil: 'domcontentloaded' });
await delay(2000);
const bodyText = await page.$eval('body', el => el.textContent.toLowerCase());
// Look for cost-related terms
const hasCostInfo = bodyText.includes('cost') ||
bodyText.includes('token') ||
bodyText.includes('usage') ||
bodyText.includes('$') ||
bodyText.includes('price');
console.log(` Has cost information: ${hasCostInfo}`);
// Look for breakdown elements
const breakdownElements = await page.$$('[class*="cost"], [class*="breakdown"], table, .stats');
console.log(` Breakdown elements: ${breakdownElements.length}`);
await takeScreenshot(page, 'cost-breakdown');
});
it('should handle empty cost data', async () => {
await page.goto(`${BASE_URL}/costs`, { waitUntil: 'domcontentloaded' });
await delay(2000);
const bodyText = await page.$eval('body', el => el.textContent);
// Check for empty state message
const hasEmptyState = bodyText.includes('no cost') ||
bodyText.includes('No cost') ||
bodyText.includes('no data') ||
bodyText.includes('empty') ||
bodyText.includes('0');
console.log(` Has empty state handling: ${hasEmptyState}`);
});
it('should display pricing information', async () => {
await page.goto(`${BASE_URL}/costs`, { waitUntil: 'domcontentloaded' });
await delay(2000);
const bodyText = await page.$eval('body', el => el.textContent);
// Look for pricing info
const hasPricing = bodyText.includes('price') ||
bodyText.includes('Price') ||
bodyText.includes('rate') ||
bodyText.includes('per') ||
/\$\d/.test(bodyText);
console.log(` Has pricing info: ${hasPricing}`);
await takeScreenshot(page, 'cost-pricing');
});
});
describe('Search Configuration UI Tests', () => {
it('should display available search engines', async () => {
await page.goto(`${BASE_URL}/settings`, { waitUntil: 'domcontentloaded' });
await delay(2000);
// Navigate to search tab
const searchTab = await page.$('[data-tab="search"]');
if (searchTab) {
await searchTab.click();
await delay(1000);
}
await takeScreenshot(page, 'search-engines-settings');
// Look for search engine options
const engines = await page.$$('[class*="engine"], select[name*="search"] option, .search-engine-item');
console.log(` Search engine elements: ${engines.length}`);
// Check API
const enginesApi = await page.evaluate(async () => {
try {
const res = await fetch('/settings/api/available-search-engines', { credentials: 'include' });
const data = await res.json();
return Array.isArray(data) ? data.map(e => e.name || e) : data;
} catch (e) {
return { error: e.message };
}
});
console.log(` Available engines: ${JSON.stringify(enginesApi).substring(0, 300)}`);
});
it('should allow selecting different engines', async () => {
await page.goto(`${BASE_URL}/settings`, { waitUntil: 'domcontentloaded' });
await delay(2000);
const searchTab = await page.$('[data-tab="search"]');
if (searchTab) {
await searchTab.click();
await delay(1000);
}
// Look for engine selector
const engineSelect = await page.$('select[name*="search_engine"], select[data-key*="search_engine"], #search-engine');
if (engineSelect) {
const options = await page.$$eval('select[name*="search_engine"] option, select[data-key*="search_engine"] option',
opts => opts.map(o => o.value));
console.log(` Engine options: ${JSON.stringify(options)}`);
// Try selecting a different engine
if (options.length > 1) {
await page.select('select[name*="search_engine"], select[data-key*="search_engine"]', options[1] || 'wikipedia');
await delay(1000);
console.log(' ✓ Selected different engine');
}
} else {
console.log(' Engine select not found (may use custom UI)');
}
await takeScreenshot(page, 'search-engine-select');
});
it('should show engine descriptions', async () => {
await page.goto(`${BASE_URL}/settings`, { waitUntil: 'domcontentloaded' });
await delay(2000);
const searchTab = await page.$('[data-tab="search"]');
if (searchTab) {
await searchTab.click();
await delay(1000);
}
// Look for descriptions
const descriptions = await page.$$('[class*="description"], .help-text, .setting-description');
console.log(` Description elements: ${descriptions.length}`);
const bodyText = await page.$eval('body', el => el.textContent);
const hasDescriptions = bodyText.includes('Wikipedia') ||
bodyText.includes('search') ||
bodyText.includes('engine');
console.log(` Has engine descriptions: ${hasDescriptions}`);
await takeScreenshot(page, 'engine-descriptions');
});
it('should persist engine selection', async () => {
await page.goto(`${BASE_URL}/settings`, { waitUntil: 'domcontentloaded' });
await delay(2000);
const searchTab = await page.$('[data-tab="search"]');
if (searchTab) {
await searchTab.click();
await delay(1000);
}
// Get current selection
const currentEngine = await page.evaluate(() => {
const select = document.querySelector('select[name*="search_engine"], select[data-key*="search_engine"]');
return select ? select.value : null;
});
console.log(` Current engine: ${currentEngine}`);
// Reload and check
await page.reload({ waitUntil: 'domcontentloaded' });
await delay(2000);
if (searchTab) {
const newSearchTab = await page.$('[data-tab="search"]');
if (newSearchTab) await newSearchTab.click();
await delay(1000);
}
const afterReload = await page.evaluate(() => {
const select = document.querySelector('select[name*="search_engine"], select[data-key*="search_engine"]');
return select ? select.value : null;
});
console.log(` After reload: ${afterReload}`);
await takeScreenshot(page, 'engine-persistence');
});
});
describe('Follow-up Research Tests', () => {
let testResearchId;
before(async () => {
const historyResponse = await page.evaluate(async () => {
try {
const res = await fetch('/api/history', { credentials: 'include' });
return await res.json();
} catch (e) {
return { error: e.message };
}
});
if (Array.isArray(historyResponse) && historyResponse.length > 0) {
testResearchId = historyResponse[0].id || historyResponse[0].research_id;
}
});
it('should load follow-up page', async () => {
if (!testResearchId) {
console.log(' Skipping - no research ID available');
return;
}
await page.goto(`${BASE_URL}/follow-up/${testResearchId}`, { waitUntil: 'domcontentloaded' });
await delay(2000);
await takeScreenshot(page, 'follow-up-page');
const url = page.url();
console.log(` Follow-up page URL: ${url}`);
// May redirect if feature not available
expect(url).to.include(BASE_URL);
});
it('should show original research context', async () => {
if (!testResearchId) {
console.log(' Skipping - no research ID available');
return;
}
await page.goto(`${BASE_URL}/follow-up/${testResearchId}`, { waitUntil: 'domcontentloaded' });
await delay(2000);
const bodyText = await page.$eval('body', el => el.textContent);
// Look for original query or context
const hasContext = bodyText.includes('original') ||
bodyText.includes('Original') ||
bodyText.includes('previous') ||
bodyText.includes('based on') ||
bodyText.includes('follow');
console.log(` Has original context: ${hasContext}`);
await takeScreenshot(page, 'follow-up-context');
});
it('should handle follow-up submission', async () => {
if (!testResearchId) {
console.log(' Skipping - no research ID available');
return;
}
await page.goto(`${BASE_URL}/follow-up/${testResearchId}`, { waitUntil: 'domcontentloaded' });
await delay(2000);
// Look for follow-up query input
const queryInput = await page.$('#follow-up-query, #query, textarea[name="query"]');
if (queryInput) {
await queryInput.type('Tell me more about this topic');
console.log(' ✓ Entered follow-up query');
// Look for submit button
const submitBtn = await page.$('button[type="submit"], .submit-btn');
if (submitBtn) {
console.log(' Found submit button');
}
} else {
console.log(' Follow-up input not found (feature may not be available)');
}
await takeScreenshot(page, 'follow-up-submission');
});
});
describe('Password Change Tests', () => {
it('should load change password page', async () => {
await page.goto(`${BASE_URL}/auth/change-password`, { waitUntil: 'domcontentloaded' });
await delay(2000);
await takeScreenshot(page, 'change-password-page');
const url = page.url();
console.log(` Change password page URL: ${url}`);
// Check for password form elements
const currentPassword = await page.$('input[name="current_password"], input[type="password"]');
const newPassword = await page.$('input[name="new_password"], input[name="password"]');
console.log(` Current password field: ${currentPassword !== null}`);
console.log(` New password field: ${newPassword !== null}`);
// Page should load (may redirect if not supported)
expect(url).to.include(BASE_URL);
});
it('should validate current password', async () => {
await page.goto(`${BASE_URL}/auth/change-password`, { waitUntil: 'domcontentloaded' });
await delay(2000);
const currentPasswordInput = await page.$('input[name="current_password"], #current-password');
if (currentPasswordInput) {
await currentPasswordInput.type('wrong_password_xyz');
const newPasswordInput = await page.$('input[name="new_password"], #new-password');
if (newPasswordInput) {
await newPasswordInput.type('new_password_123');
}
const confirmInput = await page.$('input[name="confirm_password"], #confirm-password');
if (confirmInput) {
await confirmInput.type('new_password_123');
}
const submitBtn = await page.$('button[type="submit"]');
if (submitBtn) {
await submitBtn.click();
await delay(2000);
// Should show error for wrong current password
const bodyText = await page.$eval('body', el => el.textContent.toLowerCase());
const hasError = bodyText.includes('incorrect') ||
bodyText.includes('wrong') ||
bodyText.includes('invalid') ||
bodyText.includes('error');
console.log(` Validation error shown: ${hasError}`);
}
} else {
console.log(' Change password form not found (feature may not be available)');
}
await takeScreenshot(page, 'password-validation-error');
});
it('should enforce password requirements', async () => {
await page.goto(`${BASE_URL}/auth/change-password`, { waitUntil: 'domcontentloaded' });
await delay(2000);
const newPasswordInput = await page.$('input[name="new_password"], #new-password');
if (newPasswordInput) {
// Try a weak password
await newPasswordInput.type('123');
await delay(500);
// Check for validation feedback
const validation = await page.$('.error, .invalid, [class*="error"], .validation-message, .password-strength');
if (validation) {
const text = await validation.evaluate(el => el.textContent);
console.log(` Password requirement feedback: ${text}`);
}
} else {
console.log(' New password field not found');
}
await takeScreenshot(page, 'password-requirements');
});
it('should have password confirmation field', async () => {
await page.goto(`${BASE_URL}/auth/change-password`, { waitUntil: 'domcontentloaded' });
await delay(2000);
const confirmInput = await page.$('input[name="confirm_password"], input[name="password_confirm"], #confirm-password');
console.log(` Confirm password field exists: ${confirmInput !== null}`);
// Look for password match validation
const newPasswordInput = await page.$('input[name="new_password"], #new-password');
if (newPasswordInput && confirmInput) {
await newPasswordInput.type('password123');
await confirmInput.type('different456');
await delay(500);
const bodyText = await page.$eval('body', el => el.textContent.toLowerCase());
const hasMismatchWarning = bodyText.includes('match') ||
bodyText.includes('same') ||
bodyText.includes('confirm');
console.log(` Password mismatch handled: ${hasMismatchWarning}`);
}
await takeScreenshot(page, 'password-confirm-field');
});
});
describe('CSRF Token Tests', () => {
it('should retrieve CSRF token from endpoint', async () => {
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' });
await delay(1000);
const csrfResponse = await page.evaluate(async () => {
try {
const res = await fetch('/auth/csrf-token', { credentials: 'include' });
const data = await res.json();
return { status: res.status, hasToken: !!data.csrf_token || !!data.token, data };
} catch (e) {
return { error: e.message };
}
});
console.log(` CSRF token endpoint: ${JSON.stringify(csrfResponse)}`);
expect(csrfResponse.status).to.be.oneOf([200, 401, 404]);
});
it('should have CSRF token in page meta tag', async () => {
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' });
await delay(1000);
const csrfMeta = await page.$('meta[name="csrf-token"]');
if (csrfMeta) {
const token = await csrfMeta.evaluate(el => el.getAttribute('content'));
console.log(` CSRF token in meta tag: ${token ? 'present' : 'missing'}`);
console.log(` Token length: ${token ? token.length : 0}`);
expect(token).to.not.be.null;
expect(token.length).to.be.greaterThan(10);
} else {
console.log(' CSRF meta tag not found (may use different method)');
}
});
});
describe('Research Ratings Tests', () => {
let testResearchId;
before(async () => {
const historyResponse = await page.evaluate(async () => {
try {
const res = await fetch('/api/history', { credentials: 'include' });
return await res.json();
} catch (e) {
return { error: e.message };
}
});
if (Array.isArray(historyResponse) && historyResponse.length > 0) {
testResearchId = historyResponse[0].id || historyResponse[0].research_id;
}
});
it('should load star reviews page', async () => {
await page.goto(`${BASE_URL}/star-reviews`, { waitUntil: 'domcontentloaded' });
await delay(2000);
await takeScreenshot(page, 'star-reviews-page');
const url = page.url();
console.log(` Star reviews page URL: ${url}`);
// Check for ratings elements
const ratingElements = await page.$$('[class*="star"], [class*="rating"], .review-item');
console.log(` Rating elements found: ${ratingElements.length}`);
});
it('should submit rating for research', async () => {
if (!testResearchId) {
console.log(' Skipping - no research ID available');
return;
}
const ratingResponse = await page.evaluate(async (id) => {
try {
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
const csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : '';
const res = await fetch(`/api/ratings/${id}`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ rating: 4, comment: 'Test rating from E2E' })
});
return { status: res.status, body: await res.text() };
} catch (e) {
return { error: e.message };
}
}, testResearchId);
console.log(` Rating submission: ${JSON.stringify(ratingResponse)}`);
expect(ratingResponse.status).to.be.oneOf([200, 201, 400, 401, 404, 500]);
});
it('should retrieve ratings via API', async () => {
if (!testResearchId) {
console.log(' Skipping - no research ID available');
return;
}
const ratingsResponse = await page.evaluate(async (id) => {
try {
const res = await fetch(`/api/ratings/${id}`, { credentials: 'include' });
return { status: res.status, body: await res.text() };
} catch (e) {
return { error: e.message };
}
}, testResearchId);
console.log(` Ratings retrieval: ${JSON.stringify(ratingsResponse).substring(0, 200)}`);
expect(ratingsResponse.status).to.be.oneOf([200, 401, 404, 500]);
});
it('should handle rating analytics', async () => {
const analyticsResponse = await page.evaluate(async () => {
try {
// Rating analytics is served by the enhanced-metrics
// endpoint (get_rating_analytics feeds api_enhanced_metrics);
// there is no standalone /api/rating-analytics route.
const res = await fetch('/metrics/api/metrics/enhanced', { credentials: 'include' });
return { status: res.status, body: await res.text() };
} catch (e) {
return { error: e.message };
}
});
console.log(` Rating analytics: ${JSON.stringify(analyticsResponse).substring(0, 200)}`);
expect(analyticsResponse.status).to.be.oneOf([200, 401, 404, 500]);
});
});
describe('Settings Backup Tests', () => {
it('should export settings via API', async () => {
await page.goto(`${BASE_URL}/settings`, { waitUntil: 'domcontentloaded' });
await delay(1000);
const exportResponse = await page.evaluate(async () => {
try {
const res = await fetch('/settings/api/settings', { credentials: 'include' });
const data = await res.json();
return {
status: res.status,
hasSettings: Object.keys(data).length > 0,
settingsCount: Object.keys(data).length
};
} catch (e) {
return { error: e.message };
}
});
console.log(` Settings export: ${JSON.stringify(exportResponse)}`);
expect(exportResponse.status).to.be.oneOf([200, 401, 404]);
if (exportResponse.status === 200) {
expect(exportResponse.hasSettings).to.be.true;
}
});
it('should import settings from defaults', async () => {
await page.goto(`${BASE_URL}/settings`, { waitUntil: 'domcontentloaded' });
await delay(1000);
const importResponse = await page.evaluate(async () => {
try {
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
const csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : '';
const res = await fetch('/settings/api/import', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
return { status: res.status, body: await res.text() };
} catch (e) {
return { error: e.message };
}
});
console.log(` Settings import: ${JSON.stringify(importResponse)}`);
expect(importResponse.status).to.be.oneOf([200, 400, 401, 404, 500]);
});
it('should reset settings to defaults', async () => {
await page.goto(`${BASE_URL}/settings`, { waitUntil: 'domcontentloaded' });
await delay(1000);
const resetResponse = await page.evaluate(async () => {
try {
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
const csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : '';
const res = await fetch('/settings/reset_to_defaults', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
}
});
return { status: res.status, body: await res.text() };
} catch (e) {
return { error: e.message };
}
});
console.log(` Settings reset: ${JSON.stringify(resetResponse)}`);
expect(resetResponse.status).to.be.oneOf([200, 302, 400, 401, 404, 500]);
});
it('should get bulk settings', async () => {
await page.goto(`${BASE_URL}/settings`, { waitUntil: 'domcontentloaded' });
await delay(1000);
const bulkResponse = await page.evaluate(async () => {
try {
const res = await fetch('/settings/api/bulk', { credentials: 'include' });
const data = await res.json();
return {
status: res.status,
hasData: typeof data === 'object',
keys: Object.keys(data).slice(0, 5)
};
} catch (e) {
return { error: e.message };
}
});
console.log(` Bulk settings: ${JSON.stringify(bulkResponse)}`);
expect(bulkResponse.status).to.be.oneOf([200, 401, 404, 500]);
});
});
describe('Queue Management Tests', () => {
it('should get queue status via API', async () => {
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' });
await delay(1000);
const queueResponse = await page.evaluate(async () => {
try {
const res = await fetch('/api/queue/status', { credentials: 'include' });
const data = await res.json();
return { status: res.status, data };
} catch (e) {
return { error: e.message };
}
});
console.log(` Queue status: ${JSON.stringify(queueResponse)}`);
expect(queueResponse.status).to.be.oneOf([200, 401, 404, 500]);
});
it('should show queue position for research', async () => {
// Get a research ID first
const historyResponse = await page.evaluate(async () => {
try {
const res = await fetch('/api/history', { credentials: 'include' });
return await res.json();
} catch (e) {
return { error: e.message };
}
});
if (Array.isArray(historyResponse) && historyResponse.length > 0) {
const researchId = historyResponse[0].id || historyResponse[0].research_id;
const positionResponse = await page.evaluate(async (id) => {
try {
const res = await fetch(`/api/queue/${id}/position`, { credentials: 'include' });
return { status: res.status, body: await res.text() };
} catch (e) {
return { error: e.message };
}
}, researchId);
console.log(` Queue position: ${JSON.stringify(positionResponse)}`);
expect(positionResponse.status).to.be.oneOf([200, 400, 401, 404, 500]);
} else {
console.log(' No research items for queue position test');
}
});
it('should handle empty queue gracefully', async () => {
const emptyQueueResponse = await page.evaluate(async () => {
try {
const res = await fetch('/api/queue/non-existent-id/position', { credentials: 'include' });
return { status: res.status, body: await res.text() };
} catch (e) {
return { error: e.message };
}
});
console.log(` Empty queue handling: ${JSON.stringify(emptyQueueResponse)}`);
// Should return 404 or similar for non-existent research
expect(emptyQueueResponse.status).to.be.oneOf([200, 400, 401, 404, 500]);
});
});
describe('Research Resources Tests', () => {
let testResearchId;
before(async () => {
const historyResponse = await page.evaluate(async () => {
try {
const res = await fetch('/api/history', { credentials: 'include' });
return await res.json();
} catch (e) {
return { error: e.message };
}
});
if (Array.isArray(historyResponse) && historyResponse.length > 0) {
testResearchId = historyResponse[0].id || historyResponse[0].research_id;
}
});
it('should list resources for research', async () => {
if (!testResearchId) {
console.log(' Skipping - no research ID available');
return;
}
const resourcesResponse = await page.evaluate(async (id) => {
try {
const res = await fetch(`/api/resources/${id}`, { credentials: 'include' });
const data = await res.json();
return {
status: res.status,
isArray: Array.isArray(data),
count: Array.isArray(data) ? data.length : 0
};
} catch (e) {
return { error: e.message };
}
}, testResearchId);
console.log(` Resources list: ${JSON.stringify(resourcesResponse)}`);
expect(resourcesResponse.status).to.be.oneOf([200, 401, 404, 500]);
});
it('should handle research with no resources', async () => {
const noResourcesResponse = await page.evaluate(async () => {
try {
const res = await fetch('/api/resources/non-existent-research-id', { credentials: 'include' });
return { status: res.status, body: await res.text() };
} catch (e) {
return { error: e.message };
}
});
console.log(` No resources handling: ${JSON.stringify(noResourcesResponse)}`);
expect(noResourcesResponse.status).to.be.oneOf([200, 400, 401, 404, 500]);
});
it('should get resource details via API', async () => {
if (!testResearchId) {
console.log(' Skipping - no research ID available');
return;
}
// First get the list of resources
const resourcesList = await page.evaluate(async (id) => {
try {
const res = await fetch(`/api/resources/${id}`, { credentials: 'include' });
return await res.json();
} catch (e) {
return { error: e.message };
}
}, testResearchId);
if (Array.isArray(resourcesList) && resourcesList.length > 0) {
const resourceId = resourcesList[0].id;
console.log(` Checking resource: ${resourceId}`);
const detailResponse = await page.evaluate(async (resId, researchId) => {
try {
const res = await fetch(`/api/resources/${researchId}/${resId}`, { credentials: 'include' });
return { status: res.status, body: await res.text() };
} catch (e) {
return { error: e.message };
}
}, resourceId, testResearchId);
console.log(` Resource detail: ${JSON.stringify(detailResponse).substring(0, 200)}`);
} else {
console.log(' No resources available for detail test');
}
});
});
describe('Search Engine Connectivity Tests', () => {
it('should verify search engine API availability', async () => {
// Test via the settings API that checks search engine availability
const searchEnginesResponse = await page.evaluate(async () => {
try {
const res = await fetch('/settings/api/search-engines', { credentials: 'include' });
return await res.json();
} catch (e) {
return { error: e.message };
}
});
console.log(' Available search engines:', JSON.stringify(searchEnginesResponse).substring(0, 500));
if (searchEnginesResponse.engines) {
const hasSerper = searchEnginesResponse.engines.some(e => e.id === 'serper' || e.name?.toLowerCase().includes('serper'));
console.log(` Serper available: ${hasSerper}`);
}
});
it('should check search engine configuration in settings', async () => {
await page.goto(`${BASE_URL}/settings`, { waitUntil: 'domcontentloaded' });
await delay(2000);
// Click on Search tab
const searchTab = await page.$('[data-tab="search"]');
if (searchTab) {
await searchTab.click();
await delay(1000);
}
// Check for search engine dropdown or selection
const pageContent = await page.$eval('body', el => el.textContent);
const hasSearchConfig = pageContent.includes('serper') ||
pageContent.includes('Serper') ||
pageContent.includes('search engine');
console.log(` Search config visible: ${hasSearchConfig}`);
await takeScreenshot(page, 'search-engine-settings');
});
});
describe('LLM Provider Connectivity Tests', () => {
it('should verify model availability via API', async () => {
const modelsResponse = await page.evaluate(async () => {
try {
const res = await fetch('/settings/api/models?provider=openrouter', { credentials: 'include' });
return await res.json();
} catch (e) {
return { error: e.message };
}
});
if (modelsResponse.models) {
console.log(` Models available: ${modelsResponse.models.length}`);
// Check for Gemini model
const hasGemini = modelsResponse.models.some(m =>
m.id?.includes('gemini') || m.name?.toLowerCase().includes('gemini'));
console.log(` Gemini model available: ${hasGemini}`);
} else {
console.log(' Models response:', JSON.stringify(modelsResponse).substring(0, 300));
}
});
it('should check LLM configuration in settings', async () => {
await page.goto(`${BASE_URL}/settings`, { waitUntil: 'domcontentloaded' });
await delay(2000);
// Click on LLM tab
const llmTab = await page.$('[data-tab="llm"]');
if (llmTab) {
await llmTab.click();
await delay(1000);
}
// Check for provider selection
const pageContent = await page.$eval('body', el => el.textContent);
const hasLLMConfig = pageContent.includes('OpenRouter') ||
pageContent.includes('provider') ||
pageContent.includes('model');
console.log(` LLM config visible: ${hasLLMConfig}`);
await takeScreenshot(page, 'llm-settings');
});
});
describe('System Health Tests', () => {
it('should verify server responds to API requests', async () => {
const healthResponse = await page.evaluate(async () => {
try {
const res = await fetch('/api/health', { credentials: 'include' });
if (res.ok) {
return { status: res.status, body: await res.json() };
}
return { status: res.status, body: await res.text() };
} catch (e) {
return { error: e.message };
}
});
console.log(' Health check:', JSON.stringify(healthResponse));
// Server is healthy if we can reach it
expect(healthResponse.status).to.be.oneOf([200, 404]); // 404 is ok if no health endpoint
});
it('should verify database connectivity via history API', async () => {
// Check if we can access research history (requires DB)
const historyResponse = await page.evaluate(async () => {
try {
const res = await fetch('/api/history?limit=1', { credentials: 'include' });
return { status: res.status, body: await res.text() };
} catch (e) {
return { error: e.message };
}
});
console.log(` Database check (history API): status=${historyResponse.status}`);
expect(historyResponse.status).to.be.oneOf([200, 401, 404]);
});
it('should check active research count', async () => {
const activeResponse = await page.evaluate(async () => {
try {
const res = await fetch('/api/research/active', { credentials: 'include' });
if (res.ok) {
return await res.json();
}
return { status: res.status };
} catch (e) {
return { error: e.message };
}
});
console.log(' Active research:', JSON.stringify(activeResponse));
});
});
describe('UI Component Tests', () => {
it('should render main navigation correctly', async () => {
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' });
await delay(1000);
// Check for main navigation elements
const navItems = await page.$$eval('nav a, .nav-link, [data-nav]',
els => els.map(el => ({ text: el.textContent.trim(), href: el.href })));
console.log(` Navigation items: ${navItems.length}`);
navItems.slice(0, 10).forEach(item => {
console.log(` - ${item.text}`);
});
expect(navItems.length).to.be.greaterThan(0);
});
it('should show user info in header', async () => {
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' });
await delay(1000);
const headerContent = await page.$eval('header, .header, nav', el => el.textContent).catch(() => '');
const hasUserInfo = headerContent.includes(TEST_USERNAME) ||
headerContent.includes('logout') ||
headerContent.includes('Logout');
console.log(` User info in header: ${hasUserInfo}`);
});
it('should have proper page structure', async () => {
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' });
// Check for essential page elements
const hasMain = await page.$('main, #main, .main-content');
const hasFooter = await page.$('footer, .footer');
console.log(` Has main content area: ${!!hasMain}`);
console.log(` Has footer: ${!!hasFooter}`);
});
});
});