项目文件夹

文件
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 行
167 KiB
JavaScript

/**
* 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 (~80–100 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 518–540 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}`);
});
});
});