#!/usr/bin/env node /** * Research Workflow UI Tests * * Tests for the research submission, progress tracking, and results viewing workflow. * * Run: node test_research_workflow_ci.js */ const { setupTest, teardownTest, TestResults, log, delay, navigateTo, withTimeout } = require('./test_lib'); // ============================================================================ // Research Form Tests // ============================================================================ const ResearchFormTests = { async researchFormStructure(page, baseUrl) { await navigateTo(page, `${baseUrl}/`); const result = await page.evaluate(() => { const queryInput = document.querySelector('#query, textarea[name="query"], input[name="query"]'); const submitBtn = document.querySelector('button[type="submit"], input[type="submit"], .ldr-research-submit'); const form = document.querySelector('form'); return { hasQueryInput: !!queryInput, queryInputType: queryInput?.tagName?.toLowerCase(), hasSubmitBtn: !!submitBtn, submitBtnText: submitBtn?.textContent?.trim(), hasForm: !!form }; }); const passed = result.hasQueryInput && result.hasSubmitBtn; return { passed, message: passed ? `Research form complete (query: ${result.queryInputType}, submit: "${result.submitBtnText}")` : `Missing: query=${result.hasQueryInput}, submit=${result.hasSubmitBtn}` }; }, async advancedOptionsToggle(page, baseUrl) { await navigateTo(page, `${baseUrl}/`); const result = await page.evaluate(() => { // Look for advanced options toggle button/link const toggleBtn = document.querySelector( '[data-bs-toggle="collapse"][data-bs-target*="advanced"], ' + '.advanced-options-toggle, ' + 'button[onclick*="advanced"], ' + 'a[href*="advanced"], ' + '.ldr-advanced-toggle' ); if (!toggleBtn) return { hasToggle: false }; // Find the collapsible section const advancedSection = document.querySelector( '#advancedOptions, .advanced-options, .collapse, [id*="advanced"]' ); return { hasToggle: true, toggleText: toggleBtn.textContent?.trim(), hasSection: !!advancedSection, sectionVisible: advancedSection ? window.getComputedStyle(advancedSection).display !== 'none' : null }; }); if (!result.hasToggle) { return { passed: null, skipped: true, message: 'No advanced options toggle found' }; } return { passed: result.hasSection, message: result.hasSection ? `Advanced options toggle found ("${result.toggleText}"), section exists` : 'Toggle found but section missing' }; }, async modelProviderDropdown(page, baseUrl) { await navigateTo(page, `${baseUrl}/`); const result = await page.evaluate(() => { const providerSelect = document.querySelector( 'select[name*="provider"], ' + '#llm_provider, ' + '.ldr-provider-select, ' + '[data-setting="llm.provider"]' ); if (!providerSelect) return { exists: false }; const options = Array.from(providerSelect.options || providerSelect.querySelectorAll('option')); return { exists: true, optionCount: options.length, options: options.slice(0, 5).map(o => o.textContent?.trim() || o.value), currentValue: providerSelect.value }; }); if (!result.exists) { return { passed: null, skipped: true, message: 'No provider dropdown on main page (may be in settings)' }; } return { passed: result.optionCount > 0, message: `Provider dropdown has ${result.optionCount} options: ${result.options.join(', ')}` }; }, async searchEngineDropdown(page, baseUrl) { await navigateTo(page, `${baseUrl}/`); const result = await page.evaluate(() => { const engineSelect = document.querySelector( 'select[name*="search"], ' + '#search_tool, ' + '.ldr-search-engine-select, ' + '[data-setting*="search"]' ); if (!engineSelect) return { exists: false }; const options = Array.from(engineSelect.options || engineSelect.querySelectorAll('option')); return { exists: true, optionCount: options.length, options: options.slice(0, 5).map(o => o.textContent?.trim() || o.value) }; }); if (!result.exists) { return { passed: null, skipped: true, message: 'No search engine dropdown on main page' }; } return { passed: result.optionCount > 0, message: `Search engine dropdown has ${result.optionCount} options: ${result.options.join(', ')}` }; }, async researchModeSelector(page, baseUrl) { await navigateTo(page, `${baseUrl}/`); const result = await page.evaluate(() => { // Look for mode selector (radio buttons, toggle, or dropdown) const modeRadios = document.querySelectorAll('input[name="mode"], input[name="research_mode"]'); const modeSelect = document.querySelector('select[name="mode"], select[name="research_mode"]'); const modeToggle = document.querySelector('.mode-toggle, .research-mode-toggle, [data-mode]'); if (modeRadios.length > 0) { return { exists: true, type: 'radio', options: Array.from(modeRadios).map(r => r.value) }; } if (modeSelect) { return { exists: true, type: 'select', options: Array.from(modeSelect.options).map(o => o.value) }; } if (modeToggle) { return { exists: true, type: 'toggle' }; } return { exists: false }; }); if (!result.exists) { return { passed: null, skipped: true, message: 'No research mode selector found' }; } return { passed: true, message: `Research mode selector found (${result.type})${result.options ? ': ' + result.options.join(', ') : ''}` }; } }; // ============================================================================ // Progress Page Tests // ============================================================================ const ProgressTests = { async progressPageStructure(page, baseUrl) { // The progress page (pages/progress.html) renders server-side for any // research id — the route does not look the id up in the DB, so this is // CI-safe with a fresh DB / no LLM. Assert the progress-page-specific // container plus its concrete progress bar and status indicator, so the // test fails if the wrong page (login/error/results) loaded instead. await navigateTo(page, `${baseUrl}/progress/1`); const result = await page.evaluate(() => { const progressPage = document.querySelector('#research-progress'); const progressFill = document.querySelector('#progress-bar.ldr-progress-fill'); const progressBar = document.querySelector('.ldr-progress-bar[role="progressbar"]'); const statusText = document.querySelector('#status-text.ldr-status-indicator'); return { onProgressPage: !!progressPage, hasProgressFill: !!progressFill, hasProgressBar: !!progressBar, hasStatusText: !!statusText, statusValue: statusText?.textContent?.trim() || null, pageTitle: document.title }; }); const passed = result.onProgressPage && result.hasProgressBar && result.hasProgressFill && result.hasStatusText; return { passed, message: passed ? `Progress page structure OK (#research-progress, progress bar, status="${result.statusValue}")` : `Progress page missing expected elements ` + `(page=${result.onProgressPage}, bar=${result.hasProgressBar}, ` + `fill=${result.hasProgressFill}, status=${result.hasStatusText}, title="${result.pageTitle}")` }; } }; // ============================================================================ // Results Page Tests // ============================================================================ const ResultsTests = { async resultsPageStructure(page, baseUrl) { // The results page (pages/results.html) renders server-side for any // research id — the route renders the template without a DB lookup, so // this is CI-safe with a fresh DB / no LLM. Assert the results-page // container, the exact title text, the report container and the // metadata block, so the test fails if a login/error page loaded. await navigateTo(page, `${baseUrl}/results/1`); const result = await page.evaluate(() => { const resultsPage = document.querySelector('#research-results'); const title = document.querySelector('#research-results h1.ldr-page-title'); // #results-content is the report container that results.js populates const reportContainer = document.querySelector('#results-content.ldr-results-content'); const metadata = document.querySelector('#research-metadata.ldr-results-metadata'); return { onResultsPage: !!resultsPage, titleText: title?.textContent?.trim() || null, hasReport: !!reportContainer, hasMetadata: !!metadata, pageTitle: document.title }; }); const passed = result.onResultsPage && result.titleText === 'Research Results' && result.hasReport && result.hasMetadata; return { passed, message: passed ? `Results page structure OK (#research-results, title="${result.titleText}", report container, metadata)` : `Results page missing expected elements ` + `(page=${result.onResultsPage}, title="${result.titleText}", ` + `report=${result.hasReport}, metadata=${result.hasMetadata}, docTitle="${result.pageTitle}")` }; }, async exportButtonsExist(page, baseUrl) { // Export controls are rendered server-side in pages/results.html with // stable ids, so navigate straight to the results page (no DB/LLM // dependency) and assert the concrete controls by id instead of doing a // substring match over arbitrary button text. await navigateTo(page, `${baseUrl}/results/1`); const result = await page.evaluate(() => { return { onResultsPage: !!document.querySelector('#research-results'), hasPdfBtn: !!document.querySelector('#download-pdf-btn'), hasExportDropdown: !!document.querySelector('#export-dropdown-btn[data-bs-toggle="dropdown"]'), hasMarkdownBtn: !!document.querySelector('#export-markdown-btn'), hasLatexBtn: !!document.querySelector('#export-latex-btn') }; }); const passed = result.onResultsPage && result.hasPdfBtn && result.hasExportDropdown && result.hasMarkdownBtn; return { passed, message: passed ? `Export controls present (#download-pdf-btn, #export-dropdown-btn, #export-markdown-btn, latex=${result.hasLatexBtn})` : `Export controls missing ` + `(page=${result.onResultsPage}, pdf=${result.hasPdfBtn}, ` + `dropdown=${result.hasExportDropdown}, markdown=${result.hasMarkdownBtn})` }; }, async starRatingExists(page, baseUrl) { await navigateTo(page, `${baseUrl}/history`); const researchId = await page.evaluate(() => { const link = document.querySelector('a[href*="/results/"]'); if (link) { const match = link.href.match(/\/results\/(\d+)/); return match ? match[1] : null; } return null; }); if (!researchId) { return { passed: null, skipped: true, message: 'No completed research to test star rating' }; } await navigateTo(page, `${baseUrl}/results/${researchId}`); const result = await page.evaluate(() => { const stars = document.querySelectorAll('.star-rating, .rating, [class*="star"], .fa-star, .bi-star'); const ratingContainer = document.querySelector('.rating-container, .star-container, [data-rating]'); return { hasStars: stars.length > 0, starCount: stars.length, hasRatingContainer: !!ratingContainer }; }); if (!result.hasStars && !result.hasRatingContainer) { return { passed: null, skipped: true, message: 'No star rating system on results page' }; } return { passed: true, message: `Star rating found (${result.starCount} star elements)` }; } }; // ============================================================================ // API Tests (checking API endpoints respond) // ============================================================================ const ApiTests = { async historyApiResponds(page, baseUrl) { await navigateTo(page, `${baseUrl}/`); const result = await page.evaluate(async (url) => { try { const response = await fetch(`${url}/api/history`); return { ok: response.ok, status: response.status, isJson: response.headers.get('content-type')?.includes('application/json') }; } catch (e) { return { ok: false, error: e.message }; } }, baseUrl); return { passed: result.ok, message: result.ok ? `History API responds (status ${result.status})` : `History API failed: ${result.error || 'status ' + result.status}` }; }, async settingsApiResponds(page, baseUrl) { await navigateTo(page, `${baseUrl}/`); const result = await page.evaluate(async (url) => { try { const response = await fetch(`${url}/settings/api`); return { ok: response.ok, status: response.status }; } catch (e) { return { ok: false, error: e.message }; } }, baseUrl); return { passed: result.ok, message: result.ok ? `Settings API responds (status ${result.status})` : `Settings API failed: ${result.error || 'status ' + result.status}` }; } }; // ============================================================================ // Main Test Runner // ============================================================================ async function main() { log.section('Research Workflow Tests'); const ctx = await setupTest({ authenticate: true }); const results = new TestResults('Research Workflow Tests'); const { page } = ctx; const { baseUrl } = ctx.config; const subTestTimeout = ctx.config.isCI ? 60000 : 30000; async function run(category, name, testFn) { try { const result = await withTimeout( testFn(page, baseUrl), subTestTimeout, `${category}/${name}` ); if (result.skipped) { results.skip(category, name, result.message); } else { results.add(category, name, result.passed, result.message); } } catch (error) { results.add(category, name, false, `Error: ${error.message}`); } } try { // Research Form Tests log.section('Research Form'); await run('Form', 'Research Form Structure', (p, u) => ResearchFormTests.researchFormStructure(p, u)); await run('Form', 'Advanced Options Toggle', (p, u) => ResearchFormTests.advancedOptionsToggle(p, u)); await run('Form', 'Model Provider Dropdown', (p, u) => ResearchFormTests.modelProviderDropdown(p, u)); await run('Form', 'Search Engine Dropdown', (p, u) => ResearchFormTests.searchEngineDropdown(p, u)); await run('Form', 'Research Mode Selector', (p, u) => ResearchFormTests.researchModeSelector(p, u)); // Progress/Results Tests log.section('Progress & Results'); await run('Results', 'Progress/Results Page Structure', (p, u) => ProgressTests.progressPageStructure(p, u)); await run('Results', 'Results Page Structure', (p, u) => ResultsTests.resultsPageStructure(p, u)); await run('Results', 'Export Buttons Exist', (p, u) => ResultsTests.exportButtonsExist(p, u)); await run('Results', 'Star Rating Exists', (p, u) => ResultsTests.starRatingExists(p, u)); // API Tests log.section('API Endpoints'); await run('API', 'History API Responds', (p, u) => ApiTests.historyApiResponds(p, u)); await run('API', 'Settings API Responds', (p, u) => ApiTests.settingsApiResponds(p, u)); } catch (error) { log.error(`Fatal error: ${error.message}`); console.error(error.stack); } finally { results.print(); results.save(); await teardownTest(ctx); process.exit(results.exitCode()); } } // Run if executed directly if (require.main === module) { main().catch(error => { console.error('Test runner failed:', error); process.exit(1); }); } module.exports = { ResearchFormTests, ProgressTests, ResultsTests, ApiTests };