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
3860 lines
167 KiB
JavaScript
3860 lines
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}`);
|
||
});
|
||
});
|
||
});
|