#!/usr/bin/env node /** * Journal Quality Dashboard UI Tests * * Tests for /metrics/journals — the journal quality dashboard. * * Asserts on the page's real contracts (element IDs from * journal_quality.html, API response shapes from metrics_routes.py) * rather than heuristic selectors: * - init completes: #ldr-loading hides, #ldr-content shows * - tab bar switches panels and the active class * - threshold slider live-preview + debounced save round-trips * through /settings/api/search.journal_reputation.threshold * - data-sources banner renders one card per source from * /metrics/api/journal-data/status * - "Your Research" tab resolves to exactly one of empty/content * * First-install state (reference DB not downloaded — the CI default, * since datasets are fetched fresh at runtime and never bundled): * /metrics/api/journals does NOT fast-fail in that state. Its first * access lazily builds the reference DB via * ensure_journal_data(auto_download=True) (journal_quality/db.py * _build_or_raise) — a synchronous multi-minute download from * OpenAlex/DOAJ inside the request. The test therefore gates every * code path that could touch that endpoint on * /metrics/api/journal-data/status (pure filesystem check, always * fast): when data is not installed the global-journals API test is * skipped, and the tab-switch test stubs the journals fetch so * clicking the tab cannot kick off the download in CI. * * Run: node test_journal_quality_ci.js */ const { setupTest, teardownTest, TestResults, log, navigateTo, withTimeout } = require('./test_lib'); const PAGE_PATH = '/metrics/journals'; const THRESHOLD_KEY = 'search.journal_reputation.threshold'; // ============================================================================ // Page structure & init // ============================================================================ const PageTests = { async initCompletes(page) { // Init runs on window `load` (after Vite module scripts): it hides // #ldr-loading and shows #ldr-content. Wait for that contract. await page.waitForFunction(() => { const loading = document.getElementById('ldr-loading'); const content = document.getElementById('ldr-content'); return loading && content && getComputedStyle(loading).display === 'none' && getComputedStyle(content).display !== 'none'; }, { timeout: 30000 }); const result = await page.evaluate(() => ({ headerText: document.querySelector('.ldr-metrics-header h1')?.textContent?.trim(), errorVisible: getComputedStyle(document.getElementById('ldr-error')).display !== 'none', errorText: document.getElementById('ldr-error')?.textContent?.trim(), })); if (!result.headerText?.includes('Journal Quality')) { return { passed: false, message: `Unexpected header: "${result.headerText}"` }; } if (result.errorVisible) { return { passed: false, message: `Error box visible after init: "${result.errorText}"` }; } return { passed: true, message: 'Init completed: loading hidden, content shown, no error box' }; }, async tabBarStructure(page) { const result = await page.evaluate(() => { const tabs = Array.from(document.querySelectorAll('.ldr-tab-bar .ldr-tab-btn')); return { tabIds: tabs.map(t => t.dataset.tab), activeTab: tabs.find(t => t.classList.contains('ldr-tab-active'))?.dataset.tab, yourResearchVisible: getComputedStyle(document.getElementById('ldr-tab-your-research')).display !== 'none', globalDbVisible: getComputedStyle(document.getElementById('ldr-tab-global-db')).display !== 'none', howItWorksVisible: getComputedStyle(document.getElementById('ldr-tab-how-it-works')).display !== 'none', }; }); const expected = ['your-research', 'global-db', 'how-it-works']; const missing = expected.filter(t => !result.tabIds.includes(t)); if (missing.length > 0) { return { passed: false, message: `Missing tabs: ${missing.join(', ')} (found: ${result.tabIds.join(', ')})` }; } // Default tab is "Your Research" — only its panel may be visible. const passed = result.activeTab === 'your-research' && result.yourResearchVisible && !result.globalDbVisible && !result.howItWorksVisible; return { passed, message: passed ? 'Tab bar has all 3 tabs; default is "Your Research" with only its panel visible' : `Bad default tab state: active=${result.activeTab}, visible panels: yr=${result.yourResearchVisible}, db=${result.globalDbVisible}, how=${result.howItWorksVisible}` }; }, async yourResearchResolves(page) { // loadUserResearchJournals() must resolve the tab to exactly one of // the two terminal states: empty state (fresh user) or content. await page.waitForFunction(() => { const loading = document.getElementById('ldr-yr-loading'); return loading && getComputedStyle(loading).display === 'none'; }, { timeout: 30000 }); const result = await page.evaluate(() => ({ emptyVisible: getComputedStyle(document.getElementById('ldr-yr-empty')).display !== 'none', contentVisible: getComputedStyle(document.getElementById('ldr-yr-content')).display !== 'none', })); const states = [result.emptyVisible, result.contentVisible].filter(Boolean).length; if (states !== 1) { return { passed: false, message: `Expected exactly one of empty/content visible, got empty=${result.emptyVisible}, content=${result.contentVisible}` }; } return { passed: true, message: `"Your Research" tab resolved to ${result.emptyVisible ? 'empty state (no research yet)' : 'content with stats'}` }; }, async tabSwitchingWorks(page, dataInstalled) { // The first click on "Global Database" lazily fires // loadJournalPage() → GET /metrics/api/journals. In the // first-install state that GET triggers the server's synchronous // fetch-on-first-use download (multi-minute, by design — see the // header comment), so stub just that URL with the 503 the page // already handles as its documented first-install response. The // contract under test — active-class movement and panel swap — // is pure DOM and unaffected by the stub. if (!dataInstalled) { await page.evaluate(() => { window.__ldrTestRealFetch = window.fetch; window.fetch = (input, init) => { const url = typeof input === 'string' ? input : input.url; if (url.includes('/metrics/api/journals?')) { return Promise.resolve(new Response('', { status: 503 })); } return window.__ldrTestRealFetch(input, init); }; }); } let afterSwitch; try { // Click "Global Database" → active class moves, panels swap. await page.evaluate(() => { document.querySelector('.ldr-tab-btn[data-tab="global-db"]').click(); }); afterSwitch = await page.evaluate(() => ({ activeTab: document.querySelector('.ldr-tab-btn.ldr-tab-active')?.dataset.tab, globalDbVisible: getComputedStyle(document.getElementById('ldr-tab-global-db')).display !== 'none', yourResearchVisible: getComputedStyle(document.getElementById('ldr-tab-your-research')).display !== 'none', })); // Switch back to leave the page in its default state for later tests. await page.evaluate(() => { document.querySelector('.ldr-tab-btn[data-tab="your-research"]').click(); }); } finally { if (!dataInstalled) { await page.evaluate(() => { if (window.__ldrTestRealFetch) { window.fetch = window.__ldrTestRealFetch; delete window.__ldrTestRealFetch; } }); } } const passed = afterSwitch.activeTab === 'global-db' && afterSwitch.globalDbVisible && !afterSwitch.yourResearchVisible; return { passed, message: passed ? 'Tab switch to Global Database moved active class and swapped panels' : `Tab switch failed: active=${afterSwitch.activeTab}, db=${afterSwitch.globalDbVisible}, yr=${afterSwitch.yourResearchVisible}` }; }, async globalDbControlsPresent(page) { const result = await page.evaluate(() => { const tierFilter = document.getElementById('ldr-filter-tier'); const sourceFilter = document.getElementById('ldr-filter-source'); return { hasSearch: !!document.getElementById('ldr-journal-search'), tierOptions: tierFilter ? tierFilter.options.length : 0, sourceOptions: sourceFilter ? sourceFilter.options.length : 0, sortableHeaders: document.querySelectorAll('.ldr-journal-table th[data-sort]').length, defaultSortDesc: !!document.querySelector('.ldr-journal-table th[data-sort="quality"].ldr-sorted-desc'), hasTbody: !!document.getElementById('ldr-journal-tbody'), hasPagination: !!document.getElementById('ldr-pagination'), }; }); const problems = []; if (!result.hasSearch) problems.push('search input missing'); if (result.tierOptions < 2) problems.push(`tier filter has ${result.tierOptions} options`); if (result.sourceOptions < 2) problems.push(`source filter has ${result.sourceOptions} options`); if (result.sortableHeaders < 1) problems.push('no sortable headers'); if (!result.defaultSortDesc) problems.push('default quality desc sort indicator missing'); if (!result.hasTbody || !result.hasPagination) problems.push('table body or pagination container missing'); return { passed: problems.length === 0, message: problems.length === 0 ? `Global DB controls present (${result.sortableHeaders} sortable columns, tier/source filters, search)` : `Problems: ${problems.join('; ')}` }; }, async sourcesBannerRendered(page) { // checkDataStatus() injects one card per data source into // #ldr-sources-grid, each with a Downloaded/Not-downloaded pill. await page.waitForFunction( () => document.getElementById('ldr-sources-grid')?.children.length > 0, { timeout: 15000 } ); const result = await page.evaluate(() => { const cards = Array.from(document.getElementById('ldr-sources-grid').children); return { cardCount: cards.length, cardsWithPill: cards.filter(c => /Downloaded|Not downloaded/.test(c.textContent)).length, names: cards.map(c => c.querySelector('a')?.textContent?.trim()).filter(Boolean), }; }); const passed = result.cardCount >= 3 && result.cardsWithPill === result.cardCount; return { passed, message: passed ? `Sources banner rendered ${result.cardCount} cards with status pills: ${result.names.join(', ')}` : `Banner incomplete: ${result.cardCount} cards, ${result.cardsWithPill} with status pill` }; }, }; // ============================================================================ // Threshold slider // ============================================================================ const ThresholdTests = { async livePreviewUpdates(page) { // Dragging the top slider must update the value label, the // explanation text, and sync the second slider — without saving. const result = await page.evaluate(() => { const slider = document.getElementById('ldr-threshold-slider-top'); slider.value = '7'; slider.dispatchEvent(new Event('input')); return { valueLabel: document.getElementById('ldr-threshold-value-top')?.textContent?.trim(), explanation: document.getElementById('ldr-threshold-explanation-top')?.textContent || '', otherSliderValue: document.getElementById('ldr-threshold-slider')?.value, }; }); // "Good quality" is THRESHOLD_DESCRIPTIONS[7].label in // journal_quality.html — the label text is part of the asserted // contract; update both together if the copy changes. const passed = result.valueLabel === '7' && result.explanation.includes('Good quality') && result.otherSliderValue === '7'; return { passed, message: passed ? 'Slider input updates value label, explanation, and syncs the second slider' : `Live preview wrong: label=${result.valueLabel} (expected "7"), synced=${result.otherSliderValue}, explanation expected "Good quality" but got "${result.explanation.slice(0, 60)}"` }; }, async changePersistsToSettings(page) { // The slider's change handler debounces 300 ms, PUTs the setting, // and shows "✓ Saved". Verify the full round-trip via the GET API. const original = await page.evaluate(async (key) => { const resp = await fetch(`/settings/api/${key}`); const data = await resp.json(); return (data.setting && data.setting.value) ?? data.value; }, THRESHOLD_KEY); if (typeof original !== 'number') { return { passed: false, message: `Could not read current threshold (got ${JSON.stringify(original)})` }; } // Pick a target that differs from the current value (both are // inside the slider's valid 1–10 range). const target = original === 5 ? 6 : 5; // Mutate-then-verify inside try/catch and restore unconditionally // afterwards: a transient failure (e.g. the save-status wait // timing out) must not leave the user's setting at `target`. let mutated = false; let statusText = null; let saved = null; let roundTripError = null; try { await page.evaluate((value) => { const slider = document.getElementById('ldr-threshold-slider-top'); slider.value = String(value); slider.dispatchEvent(new Event('input')); slider.dispatchEvent(new Event('change')); }, target); mutated = true; // Wait for the save status to resolve (debounce 300 ms + PUT). await page.waitForFunction(() => { const status = document.getElementById('ldr-threshold-save-status-top')?.textContent || ''; return status.includes('Saved') || status.includes('failed'); }, { timeout: 15000 }); statusText = await page.evaluate(() => document.getElementById('ldr-threshold-save-status-top')?.textContent?.trim() ); saved = await page.evaluate(async (key) => { const resp = await fetch(`/settings/api/${key}`); const data = await resp.json(); return (data.setting && data.setting.value) ?? data.value; }, THRESHOLD_KEY); } catch (e) { roundTripError = e.message; } // Restore the original value so the test doesn't leak state — // even when the round-trip above failed part-way. let restored = true; if (mutated) { restored = await page.evaluate(async ([key, value]) => { const csrf = document.querySelector('meta[name="csrf-token"]'); const headers = { 'Content-Type': 'application/json' }; if (csrf) headers['X-CSRFToken'] = csrf.content; const resp = await fetch(`/settings/api/${key}`, { method: 'PUT', headers, body: JSON.stringify({ value }), }); return resp.ok; }, [THRESHOLD_KEY, original]); } if (!restored) { return { passed: false, message: `FAILED to restore original value ${original} after mutating to ${target} — user setting is dirty` }; } if (roundTripError) { return { passed: false, message: `Round-trip error (setting restored to ${original}): ${roundTripError}` }; } const passed = statusText?.includes('Saved') && saved === target; return { passed, message: passed ? `Slider change persisted: ${original} → ${target} (status "${statusText}"), then restored to ${original}` : `Persist failed: status="${statusText}", GET returned ${JSON.stringify(saved)} (expected ${target}); setting restored to ${original}` }; }, }; // ============================================================================ // API contracts // ============================================================================ const ApiTests = { async journalDataStatusContract(page) { const result = await page.evaluate(async () => { const resp = await fetch('/metrics/api/journal-data/status'); if (!resp.ok) return { ok: false, status: resp.status }; const data = await resp.json(); return { ok: true, availableIsBool: typeof data.available === 'boolean', sourcesIsArray: Array.isArray(data.sources), sourceCount: Array.isArray(data.sources) ? data.sources.length : 0, sourcesShapeOk: Array.isArray(data.sources) && data.sources.every( s => typeof s.name === 'string' && typeof s.present === 'boolean' ), hasLatestVersion: typeof data.latest_version === 'string', }; }); if (!result.ok) { return { passed: false, message: `Status endpoint returned HTTP ${result.status}` }; } const passed = result.availableIsBool && result.sourcesIsArray && result.sourceCount >= 3 && result.sourcesShapeOk && result.hasLatestVersion; return { passed, message: passed ? `journal-data/status contract OK (${result.sourceCount} sources, available + latest_version present)` : `Contract violation: ${JSON.stringify(result)}` }; }, async userResearchContract(page) { const result = await page.evaluate(async () => { const resp = await fetch('/metrics/api/journals/user-research'); if (!resp.ok) return { ok: false, status: resp.status }; const data = await resp.json(); return { ok: true, status: data.status, hasSummary: typeof data.summary === 'object' && data.summary !== null, totalJournals: data.summary?.total_journals, journalsIsArray: Array.isArray(data.journals), }; }); if (!result.ok) { return { passed: false, message: `user-research endpoint returned HTTP ${result.status}` }; } const passed = result.status === 'success' && result.hasSummary && typeof result.totalJournals === 'number' && result.journalsIsArray; return { passed, message: passed ? `user-research contract OK (${result.totalJournals} journals for this user)` : `Contract violation: ${JSON.stringify(result)}` }; }, async globalJournalsContract(page, dataInstalled) { // Only exercised when the reference DB is installed: in the // first-install state this endpoint does not fast-fail — it // synchronously downloads + builds the reference DB inside the // request (multi-minute; see header comment), which would both // time out this sub-test and burn CI bandwidth. if (!dataInstalled) { return { passed: null, skipped: true, message: 'Reference DB not installed — querying /metrics/api/journals would trigger the synchronous fetch-on-first-use download; contract is covered on installed systems' }; } // status said the data is installed, so anything but a fast // success here (including 503) is a real contract violation. const result = await page.evaluate(async () => { const resp = await fetch('/metrics/api/journals?page=1&per_page=25&sort=quality&order=desc'); if (!resp.ok) return { ok: false, status: resp.status }; if (!resp.headers.get('content-type')?.includes('application/json')) { return { ok: false, status: resp.status, badContentType: true }; } const data = await resp.json(); return { ok: true, status: data.status, journalsIsArray: Array.isArray(data.journals), hasPagination: typeof data.pagination === 'object' && data.pagination !== null, }; }); if (!result.ok) { return { passed: false, message: `journals API failed despite status reporting data installed: HTTP ${result.status}${result.badContentType ? ' (non-JSON body)' : ''}` }; } const passed = result.status === 'success' && result.journalsIsArray && result.hasPagination; return { passed, message: passed ? 'journals API contract OK (success + journals[] + pagination)' : `Contract violation: ${JSON.stringify(result)}` }; }, }; // ============================================================================ // Main Test Runner // ============================================================================ async function main() { log.section('Journal Quality Dashboard Tests'); const ctx = await setupTest({ authenticate: true }); const results = new TestResults('Journal Quality Tests'); const { page } = ctx; const { baseUrl } = ctx.config; // Collect uncaught page exceptions for the whole run — a page that // throws during init is a regression even if individual asserts pass. const pageErrors = []; page.on('pageerror', (err) => pageErrors.push(err.message)); // Set per test below — whether the journal reference data is // installed on this server (pure filesystem check, always fast). // Gates every code path that could touch /metrics/api/journals, // because in the first-install state that endpoint synchronously // downloads the data inside the request (see header comment). let dataInstalled = false; const subTestTimeout = ctx.config.isCI ? 60000 : 30000; async function run(category, name, testFn) { try { const result = await withTimeout(testFn(page, dataInstalled), 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 { // Navigate once and wait for the full `load` event — the page's // init handler is bound to window load (after Vite module scripts). await navigateTo(page, `${baseUrl}${PAGE_PATH}`, { waitUntil: 'load' }); // Snapshotted once at suite start; not re-checked mid-run. dataInstalled = await page.evaluate(async () => { try { const resp = await fetch('/metrics/api/journal-data/status'); const data = await resp.json(); return data.available === true; } catch { return false; } }); log.info(`Journal reference data installed: ${dataInstalled}`); log.section('Page Structure & Init'); await run('Page', 'Init Completes', PageTests.initCompletes); await run('Page', 'Tab Bar Structure', PageTests.tabBarStructure); await run('Page', 'Your Research Tab Resolves', PageTests.yourResearchResolves); await run('Page', 'Tab Switching Works', PageTests.tabSwitchingWorks); await run('Page', 'Global DB Controls Present', PageTests.globalDbControlsPresent); await run('Page', 'Sources Banner Rendered', PageTests.sourcesBannerRendered); log.section('Threshold Slider'); await run('Threshold', 'Live Preview Updates', ThresholdTests.livePreviewUpdates); await run('Threshold', 'Change Persists To Settings', ThresholdTests.changePersistsToSettings); log.section('API Contracts'); await run('API', 'Journal Data Status Contract', ApiTests.journalDataStatusContract); await run('API', 'User Research Contract', ApiTests.userResearchContract); await run('API', 'Global Journals Contract', ApiTests.globalJournalsContract); log.section('Page Errors'); results.add( 'Errors', 'No Uncaught Page Exceptions', pageErrors.length === 0, pageErrors.length === 0 ? 'No uncaught exceptions during the whole run' : `Uncaught exceptions: ${pageErrors.slice(0, 3).join(' | ')}` ); } catch (error) { log.error(`Fatal error: ${error.message}`); console.error(error.stack); results.add('Fatal', 'Test Suite Execution', false, error.message); } finally { results.print(); results.save(); await teardownTest(ctx); process.exit(results.exitCode()); } } if (require.main === module) { main().catch(error => { console.error('Test runner failed:', error); process.exit(1); }); } module.exports = { PageTests, ThresholdTests, ApiTests };