Files
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 lines
26 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 110 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 };