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
719 lines
31 KiB
JavaScript
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 };
|