项目文件夹

文件
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

556 行
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 };