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

719 lines
31 KiB
JavaScript

#!/usr/bin/env node
/**
* Error Handling UI Tests
*
* Tests for 404, 401, 429 error handling, and form validation errors.
*
* Run: node test_error_handling_ci.js
*/
const { setupTest, teardownTest, TestResults, log, delay, navigateTo, withTimeout } = require('./test_lib');
/**
* Navigate with a single retry on timeout.
*
* Used for deliberately-bad URLs (404 probes) where a slow CI server can
* push the first goto past its 60s timeout. Without the retry, a single
* stuck navigation consumes the suite's wall-clock budget and triggers the
* SIGTERM that detaches the page for every subsequent sub-test.
*/
async function navigateToWithRetry(page, url) {
try {
return await navigateTo(page, url);
} catch (firstError) {
await delay(2000);
return await navigateTo(page, url);
}
}
// ============================================================================
// 404 Error Handling Tests
// ============================================================================
const Error404Tests = {
async nonExistentPageShows404(page, baseUrl) {
const response = await navigateToWithRetry(page, `${baseUrl}/nonexistent-page-12345`);
const result = await page.evaluate(() => {
const bodyText = document.body.textContent?.toLowerCase() || '';
return {
has404Text: bodyText.includes('404') || bodyText.includes('not found'),
hasErrorPage: !!document.querySelector('.error-page, .not-found, [class*="404"]'),
hasHomeLink: !!document.querySelector('a[href="/"], a[href*="home"]'),
pageTitle: document.title
};
});
const statusCode = response?.status();
const passed = statusCode === 404 || result.has404Text || result.hasErrorPage;
return {
passed,
message: passed
? `404 handled (status: ${statusCode}, has404Text: ${result.has404Text})`
: `Unexpected response for non-existent page (status: ${statusCode})`
};
},
async invalidResearchIdHandled(page, baseUrl) {
// The /results/<id> route always renders pages/results.html (status 200);
// not-found handling is client-side: results.js fetches /api/report/<id>,
// gets a 404, and calls showError() which injects an
// `.alert-danger` (with "Error loading research results: HTTP error 404")
// into #results-content. Assert the SPECIFIC outcome:
// 1. The results page actually rendered (#research-results container) —
// proves we did not land on login/a generic error page.
// 2. A targeted error element appeared inside #results-content
// (not the bare substring "error" anywhere in page chrome).
await navigateToWithRetry(page, `${baseUrl}/results/invalid-research-id-12345`);
// Confirm the results page chrome rendered before waiting on the async error.
await page.waitForSelector('#research-results #results-content', { timeout: 15000 });
// results.js renders the error asynchronously after the failed /api/report
// fetch. Wait for the targeted alert rather than reading body text once.
try {
await page.waitForFunction(() => {
const alert = document.querySelector('#results-content .alert-danger');
return !!alert && (alert.textContent || '').trim().length > 0;
}, { timeout: 15000 });
} catch {
// Targeted alert never appeared within 15s. Don't fail here — the DOM
// re-read below makes the final pass/fail decision — but log a hint so a
// slow render is distinguishable from "no error element rendered" during
// triage.
log('invalidResearchIdHandled: timed out waiting for #results-content .alert-danger (slow render?)');
}
const result = await page.evaluate(() => {
const onResultsPage = !!document.querySelector('#research-results');
const alert = document.querySelector('#results-content .alert-danger');
return {
onResultsPage,
hasTargetedError: !!alert,
errorText: alert?.textContent?.trim().substring(0, 120) || '',
currentPath: window.location.pathname
};
});
const passed = result.onResultsPage && result.hasTargetedError;
return {
passed,
message: passed
? `Invalid research ID surfaced targeted error on results page (path: ${result.currentPath}, error: "${result.errorText}")`
: `Invalid research ID not handled (onResultsPage: ${result.onResultsPage}, targetedError: ${result.hasTargetedError}, path: ${result.currentPath})`
};
},
async invalidDocumentIdHandled(page, baseUrl) {
// Use fetch instead of page navigation to avoid flaky domcontentloaded timeouts
// The Flask route returns a simple text "Document not found" with status 404
const result = await page.evaluate(async (url) => {
try {
const response = await fetch(`${url}/library/document/invalid-doc-id-12345`);
const text = await response.text();
const bodyText = text.toLowerCase();
return {
status: response.status,
hasErrorText: bodyText.includes('not found') || bodyText.includes('error'),
redirected: response.redirected,
finalUrl: response.url
};
} catch (e) {
return { error: e.message };
}
}, baseUrl);
if (result.error) {
return { passed: null, skipped: true, message: `Fetch failed: ${result.error}` };
}
const passed = result.status === 404 || result.hasErrorText || result.redirected;
return {
passed,
message: passed
? `Invalid document ID handled (status: ${result.status})`
: 'Invalid document ID not handled gracefully'
};
}
};
// ============================================================================
// 401 Authentication Error Tests
// ============================================================================
const Error401Tests = {
async unauthenticatedRedirectsToLogin(page, baseUrl) {
// Navigate to login page first to avoid pending XHR interference
await page.goto(`${baseUrl}/auth/login`, { waitUntil: 'domcontentloaded', timeout: 15000 });
// Clear cookies to simulate unauthenticated state
const client = await page.target().createCDPSession();
await client.send('Network.clearBrowserCookies');
await navigateTo(page, `${baseUrl}/settings/`);
const result = await page.evaluate(() => {
const currentPath = window.location.pathname;
const hasLoginForm = !!document.querySelector('form[action*="login"], input[type="password"], .login-form');
const bodyText = document.body.textContent?.toLowerCase() || '';
const hasLoginText = bodyText.includes('login') || bodyText.includes('sign in');
return {
currentPath,
redirectedToLogin: currentPath.includes('login') || currentPath.includes('auth'),
hasLoginForm,
hasLoginText
};
});
const passed = result.redirectedToLogin || result.hasLoginForm || result.hasLoginText;
return {
passed,
message: passed
? `Unauthenticated user redirected to login (path: ${result.currentPath})`
: 'Protected route accessible without authentication'
};
},
async apiUnauthorizedReturns401(page, baseUrl) {
// Clear cookies
const client = await page.target().createCDPSession();
await client.send('Network.clearBrowserCookies');
await navigateTo(page, `${baseUrl}/`);
const result = await page.evaluate(async (url) => {
try {
const response = await fetch(`${url}/api/history`);
return {
status: response.status,
is401or403: response.status === 401 || response.status === 403,
redirected: response.redirected,
finalUrl: response.url
};
} catch (e) {
return { error: e.message };
}
}, baseUrl);
if (result.error) {
return { passed: null, skipped: true, message: `API call failed: ${result.error}` };
}
const passed = result.is401or403 || result.redirected;
return {
passed,
message: passed
? `Unauthenticated API returns ${result.status} or redirects`
: `API accessible without auth (status: ${result.status})`
};
}
};
// ============================================================================
// API Error Response Tests
// ============================================================================
const ApiErrorTests = {
async apiMissingParamsReturns400(page, baseUrl) {
// Re-authenticate first
const ctx = await page.evaluate(() => ({ authenticated: !!document.cookie }));
await navigateTo(page, `${baseUrl}/`);
const result = await page.evaluate(async (url) => {
try {
// Try to start research without required params
const response = await fetch(`${url}/api/start_research`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}) // Empty body, missing required 'query'
});
const data = await response.json().catch(() => ({}));
return {
status: response.status,
is400: response.status === 400,
hasError: 'error' in data || 'message' in data,
errorMessage: data.error || data.message
};
} catch (e) {
return { error: e.message };
}
}, baseUrl);
if (result.error) {
return { passed: null, skipped: true, message: `API call failed: ${result.error}` };
}
// 400 or 422 are both acceptable for validation errors
const passed = result.status === 400 || result.status === 422 || result.hasError;
return {
passed,
message: passed
? `Missing params returns ${result.status} with error message`
: `Unexpected response for missing params (status: ${result.status})`
};
},
async apiInvalidIdReturns404(page, baseUrl) {
await navigateTo(page, `${baseUrl}/`);
const result = await page.evaluate(async (url) => {
try {
const response = await fetch(`${url}/api/research/nonexistent-id-12345`);
return {
status: response.status,
is404: response.status === 404
};
} catch (e) {
return { error: e.message };
}
}, baseUrl);
if (result.error) {
return { passed: null, skipped: true, message: `API call failed: ${result.error}` };
}
// Accept 401 as well — auth session may not carry over to the API request
const passed = result.is404 || result.status === 401;
return {
passed,
message: passed
? `Invalid research ID returns ${result.status}`
: `Invalid ID returns ${result.status} (expected 404 or 401)`
};
}
};
// ============================================================================
// Rate Limiting Tests
// ============================================================================
const RateLimitTests = {
async rateLimitingSectionRenders(page, baseUrl) {
// The metrics dashboard (pages/metrics.html) renders a server-side
// "Rate Limiting Analytics" section that is present regardless of LLM or
// collected data — its metric values default to 0/0%. This is the page
// that surfaces rate-limit info to the user, so assert the SPECIFIC
// server-rendered container + the stable rate-limiting element IDs,
// instead of skipping on a loose "rate limit" body-text substring (which
// matched the section heading and made the test never really fail).
await navigateTo(page, `${baseUrl}/metrics/`);
// The metrics route is @login_required: an unauthenticated request 302s
// to /auth/login. The 401-auth sub-tests earlier in this suite clear
// cookies, and on the shared server the re-auth can fail to fully restore
// a navigable session (the sibling rate-limiting-endpoint test skips on
// the same 401). Treat a login redirect as an environmental skip — but if
// we DID land on the metrics page, assert the section strictly (real fail).
const landingPath = page.url();
if (/\/auth\/login/.test(landingPath) || !(await page.$('#metrics'))) {
return { passed: null, skipped: true, message: `Metrics dashboard not reachable (session lost / redirected): ${landingPath}` };
}
// Page-specific container present — now require the server-rendered
// rate-limiting section. A timeout here is a REAL failure.
await page.waitForSelector('#metrics #rate-limit-success-rate', { timeout: 15000 });
const result = await page.evaluate(() => {
const onMetricsPage = !!document.querySelector('#metrics');
// Stable, server-rendered rate-limiting elements from metrics.html.
const successRate = document.querySelector('#rate-limit-success-rate');
const events = document.querySelector('#rate-limit-events');
const enginesTracked = document.querySelector('#engines-tracked');
const engineStatusGrid = document.querySelector('#engine-status-grid');
const chart = document.querySelector('#rate-limiting-chart');
// The section heading text confirms this is the rate-limiting block,
// not some other metric card reusing a similar id.
const heading = Array.from(document.querySelectorAll('h2'))
.find(h => /rate limiting analytics/i.test(h.textContent || ''));
return {
onMetricsPage,
hasSuccessRate: !!successRate,
hasEvents: !!events,
hasEnginesTracked: !!enginesTracked,
hasEngineStatusGrid: !!engineStatusGrid,
hasChart: !!chart,
hasHeading: !!heading,
successRateText: successRate?.textContent?.trim().substring(0, 20) || ''
};
});
const passed = result.onMetricsPage
&& result.hasHeading
&& result.hasSuccessRate
&& result.hasEvents
&& result.hasEnginesTracked
&& result.hasEngineStatusGrid
&& result.hasChart;
return {
passed,
message: passed
? `Rate Limiting Analytics section rendered on metrics dashboard (success-rate value: "${result.successRateText}")`
: `Rate limiting section incomplete (metricsPage: ${result.onMetricsPage}, heading: ${result.hasHeading}, successRate: ${result.hasSuccessRate}, events: ${result.hasEvents}, engines: ${result.hasEnginesTracked}, grid: ${result.hasEngineStatusGrid}, chart: ${result.hasChart})`
};
},
async rateLimitingStatusEndpoint(page, baseUrl) {
await navigateTo(page, `${baseUrl}/`);
const result = await page.evaluate(async (url) => {
try {
const response = await fetch(`${url}/metrics/api/rate-limiting/current`);
if (!response.ok) return { ok: false, status: response.status };
// Guard against HTML responses (e.g. login page redirects)
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
return { ok: false, status: response.status, error: `Non-JSON content-type: ${contentType}` };
}
const data = await response.json();
return {
ok: true,
status: response.status,
hasLimits: Object.keys(data).length > 0
};
} catch (e) {
return { ok: false, error: e.message };
}
}, baseUrl);
if (!result.ok && result.status === 404) {
return { passed: null, skipped: true, message: 'Rate limiting status endpoint not found' };
}
if (!result.ok && (result.status === 401 || result.status === 403)) {
return { passed: null, skipped: true, message: `Rate limiting endpoint requires auth (status ${result.status})` };
}
if (!result.ok && result.error && result.error.startsWith('Non-JSON content-type')) {
return { passed: null, skipped: true, message: `Rate limiting endpoint returned non-JSON (likely auth redirect)` };
}
return {
passed: result.ok,
message: result.ok
? 'Rate limiting status endpoint responds'
: `Rate limiting endpoint failed: ${result.error || 'status ' + result.status}`
};
}
};
// ============================================================================
// Form Validation Error Tests
// ============================================================================
const FormValidationTests = {
async emptyQueryShowsError(page, baseUrl) {
await navigateTo(page, `${baseUrl}/`);
// Try to submit empty research form
const result = await page.evaluate(() => {
const form = document.querySelector('form.research-form, form[action*="research"], #research-form');
const submitBtn = document.querySelector('button[type="submit"], .submit-btn, .btn-primary');
if (!form && !submitBtn) return { hasForm: false };
// Clear any existing query
const queryInput = document.querySelector('input[name*="query"], textarea[name*="query"], #query');
if (queryInput) queryInput.value = '';
// Click submit
if (submitBtn) submitBtn.click();
return new Promise(resolve => {
let attempts = 0;
const check = () => {
const hasError = !!document.querySelector(
'.error, .invalid-feedback, .form-error, .alert-danger, [class*="error"]'
);
const hasInvalidInput = !!document.querySelector('input:invalid, textarea:invalid');
const errorText = document.querySelector('.error, .invalid-feedback')?.textContent?.trim();
if (hasError || hasInvalidInput || ++attempts >= 15) {
resolve({
hasForm: true,
hasError,
hasInvalidInput,
errorText
});
} else {
setTimeout(check, 200);
}
};
setTimeout(check, 200);
});
});
if (!result.hasForm) {
return { passed: null, skipped: true, message: 'No research form found' };
}
const passed = result.hasError || result.hasInvalidInput;
return {
passed,
message: passed
? `Empty query validation works (error: ${result.errorText || 'shown'})`
: 'Empty query did not show validation error'
};
},
async invalidSettingsShowsError(page, baseUrl) {
await navigateTo(page, `${baseUrl}/settings/`);
const result = await page.evaluate(() => {
// Look for any numeric input and try to set invalid value
const numericInput = document.querySelector(
'input[type="number"], ' +
'input[name*="temperature"], ' +
'input[name*="iterations"]'
);
if (!numericInput) return { hasInput: false };
// Set invalid value
numericInput.value = '-999';
numericInput.dispatchEvent(new Event('change', { bubbles: true }));
numericInput.dispatchEvent(new Event('input', { bubbles: true }));
return new Promise(resolve => {
let attempts = 0;
const check = () => {
const hasError = !!document.querySelector(
'.error, .invalid-feedback, .form-error, [class*="error"]'
);
const hasInvalidInput = !!document.querySelector('input:invalid');
if (hasError || hasInvalidInput || ++attempts >= 15) {
resolve({
hasInput: true,
hasError,
hasInvalidInput
});
} else {
setTimeout(check, 200);
}
};
setTimeout(check, 200);
});
});
if (!result.hasInput) {
return { passed: null, skipped: true, message: 'No numeric input found to test validation' };
}
const passed = result.hasError || result.hasInvalidInput;
return {
passed,
message: passed
? 'Invalid settings value shows validation error'
: 'Invalid settings value did not trigger validation'
};
},
async requiredFieldsMarked(page, baseUrl) {
await navigateTo(page, `${baseUrl}/`);
const result = await page.evaluate(() => {
const requiredInputs = document.querySelectorAll('[required], .required');
const requiredLabels = document.querySelectorAll('label.required, label:has(+ [required])');
// Note: :contains() is not valid CSS - check for asterisks differently
const asteriskIndicators = document.querySelectorAll('.required-indicator, .asterisk');
// Check for labels that contain asterisks in their text content
const labelsWithAsterisk = Array.from(document.querySelectorAll('label')).filter(label => label.textContent.includes('*'));
return {
requiredInputCount: requiredInputs.length,
requiredLabelCount: requiredLabels.length,
hasAsterisks: asteriskIndicators.length > 0 ||
labelsWithAsterisk.length > 0 ||
document.body.innerHTML.includes('*</label>') ||
document.body.innerHTML.includes('required')
};
});
if (result.requiredInputCount === 0 && result.requiredLabelCount === 0) {
return { passed: null, skipped: true, message: 'No required field indicators found' };
}
return {
passed: true,
message: `Required fields marked (${result.requiredInputCount} inputs, ${result.requiredLabelCount} labels)`
};
}
};
// ============================================================================
// Main Test Runner
// ============================================================================
async function main() {
log.section('Error Handling Tests');
const ctx = await setupTest({ authenticate: true });
const results = new TestResults('Error Handling Tests');
const { page } = ctx;
const { baseUrl } = ctx.config;
// Per-sub-test timeout + about:blank recovery on failure.
//
// The suite has a wall-clock budget enforced externally (300s in CI). If
// any single sub-test hangs past that budget the runner SIGTERMs the
// process and every remaining sub-test cascades into "detached frame"
// errors. A per-sub-test timeout caps each call well below the suite
// budget; resetting to about:blank on failure prevents a half-loaded
// page from breaking the next test.
const subTestTimeout = ctx.config.isCI ? 60000 : 30000;
async function run(category, name, testFn) {
try {
const result = await withTimeout(testFn(), subTestTimeout, `${category}/${name}`);
if (result && 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 {
await page.goto('about:blank', { timeout: 5000 });
} catch {
// Best-effort recovery — don't mask the original failure.
}
}
}
try {
// 404 Error Tests
log.section('404 Errors');
await run('404', 'Non-existent Page Shows 404', () => Error404Tests.nonExistentPageShows404(page, baseUrl));
await run('404', 'Invalid Research ID Handled', () => Error404Tests.invalidResearchIdHandled(page, baseUrl));
await run('404', 'Invalid Document ID Handled', () => Error404Tests.invalidDocumentIdHandled(page, baseUrl));
// 401 Authentication Tests
log.section('401 Authentication');
await run('401', 'Unauthenticated Redirects To Login', () => Error401Tests.unauthenticatedRedirectsToLogin(page, baseUrl));
// After 401 test cleared cookies, re-authenticate on a fresh login page.
// We avoid waitForNavigation here — if login fails (CSRF, wrong password),
// it stays on /auth/login and waitForNavigation hangs for the full timeout.
// Instead: click submit, then poll for URL change or session cookie.
let reAuthOk = false;
try {
await page.goto(`${baseUrl}/auth/login`, { waitUntil: 'domcontentloaded', timeout: 15000 });
await page.waitForSelector('input[name="username"]', { timeout: 10000 });
await page.$eval('input[name="username"]', el => { el.value = ''; });
await page.$eval('input[name="password"]', el => { el.value = ''; });
await page.type('input[name="username"]', 'test_admin');
await page.type('input[name="password"]', 'testpass123');
await page.click('button[type="submit"]');
// Poll for up to 15s: either we leave /auth/login or get a session cookie
for (let i = 0; i < 30; i++) {
await delay(500);
const url = page.url();
if (!url.includes('/auth/login')) {
reAuthOk = true;
break;
}
const cookies = await page.cookies();
if (cookies.some(c => c.name === 'session')) {
// Have session cookie but still on login page — navigate away
await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: 15000 });
reAuthOk = true;
break;
}
}
} catch (reAuthError) {
log.warning(`Direct re-auth failed: ${reAuthError.message}`);
}
// Fallback: try ensureAuthenticated if direct login failed
if (!reAuthOk) {
try {
await withTimeout(
ctx.authHelper.ensureAuthenticated(),
60000,
'Re-authentication after 401 tests'
);
reAuthOk = true;
} catch (error) {
log.warning(`Re-authentication timed out: ${error.message}`);
}
}
if (reAuthOk) {
await run('401', 'API Unauthorized Returns 401', () => Error401Tests.apiUnauthorizedReturns401(page, baseUrl));
} else {
results.skip('401', 'API Unauthorized Returns 401', 'Skipped — re-authentication timed out');
}
// Re-authenticate again for remaining tests
if (!reAuthOk) {
try {
await withTimeout(
ctx.authHelper.ensureAuthenticated(),
60000,
'Re-authentication for API tests'
);
reAuthOk = true;
} catch (error) {
log.warning(`Re-authentication timed out again: ${error.message}`);
}
}
// API Error Tests (require authenticated session)
log.section('API Errors');
if (reAuthOk) {
await run('API', 'API Missing Params Returns 400', () => ApiErrorTests.apiMissingParamsReturns400(page, baseUrl));
await run('API', 'API Invalid ID Returns 404', () => ApiErrorTests.apiInvalidIdReturns404(page, baseUrl));
} else {
results.skip('API', 'API Missing Params Returns 400', 'Skipped — could not re-authenticate after 401 tests');
results.skip('API', 'API Invalid ID Returns 404', 'Skipped — could not re-authenticate after 401 tests');
}
// Rate Limiting Tests (require authenticated session)
log.section('Rate Limiting');
if (reAuthOk) {
await run('RateLimit', 'Rate Limiting Section Renders', () => RateLimitTests.rateLimitingSectionRenders(page, baseUrl));
await run('RateLimit', 'Rate Limiting Status Endpoint', () => RateLimitTests.rateLimitingStatusEndpoint(page, baseUrl));
} else {
results.skip('RateLimit', 'Rate Limiting Section Renders', 'Skipped — could not re-authenticate after 401 tests');
results.skip('RateLimit', 'Rate Limiting Status Endpoint', 'Skipped — could not re-authenticate after 401 tests');
}
// Form Validation Tests
log.section('Form Validation');
await run('Validation', 'Empty Query Shows Error', () => FormValidationTests.emptyQueryShowsError(page, baseUrl));
await run('Validation', 'Invalid Settings Shows Error', () => FormValidationTests.invalidSettingsShowsError(page, baseUrl));
await run('Validation', 'Required Fields Marked', () => FormValidationTests.requiredFieldsMarked(page, baseUrl));
} catch (error) {
log.error(`Fatal error: ${error.message}`);
console.error(error.stack);
} finally {
results.print();
results.save();
await teardownTest(ctx);
process.exit(results.exitCode());
}
}
// Run if executed directly
if (require.main === module) {
main().catch(error => {
console.error('Test runner failed:', error);
process.exit(1);
});
}
module.exports = { Error404Tests, Error401Tests, ApiErrorTests, RateLimitTests, FormValidationTests };