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
556 lines
26 KiB
JavaScript
556 lines
26 KiB
JavaScript
#!/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 };
|