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

543 lines
21 KiB
JavaScript

#!/usr/bin/env node
/**
* Form Validation ARIA Support Tests
*
* Tests the FormValidator utility on the research form:
* - Inline error display with ldr-field-invalid class
* - aria-invalid attribute toggling
* - aria-describedby linking to error element
* - aria-required on query textarea
* - Error clears after typing valid input
* - Validation fires on blur
*
* Run: node test_form_validation_aria_ci.js
*/
const { setupTest, teardownTest, TestResults, log, navigateTo, withTimeout } = require('./test_lib');
// Helpers used by the validation tests below. We wait on real DOM state instead
// of fixed delays so the tests stay correct on slow CI runners without padding
// the wall-clock time on fast ones.
async function waitForQueryReady(page, timeout = 5000) {
await page.waitForSelector('#query', { visible: true, timeout });
// The FormValidator hook is what actually gates "loading UI shown / not shown"
// on submit. addValidation() inserts a .ldr-field-error sibling next to #query,
// so we wait for that DOM marker — it proves the validator is registered, not
// just that the class is on window.
await page.waitForFunction(
() => {
const q = document.querySelector('#query');
if (!q || typeof window.FormValidator !== 'function') return false;
const sibling = q.nextElementSibling;
return !!sibling && sibling.classList.contains('ldr-field-error');
},
{ timeout }
).catch(() => {});
}
async function waitForValidationState(page, expectInvalid, timeout = 2000) {
await page.waitForFunction(
(wantInvalid) => {
const q = document.querySelector('#query');
if (!q) return false;
const isInvalid = q.classList.contains('ldr-field-invalid');
return wantInvalid ? isInvalid : !isInvalid;
},
{ timeout },
expectInvalid
).catch(() => {});
}
// ============================================================================
// ARIA Attribute Tests
// ============================================================================
const AriaAttributeTests = {
async queryHasAriaRequired(page, baseUrl) {
await navigateTo(page, `${baseUrl}/`);
const result = await page.evaluate(() => {
const query = document.querySelector('#query');
return {
exists: !!query,
ariaRequired: query?.getAttribute('aria-required'),
hasHtmlRequired: query?.hasAttribute('required')
};
});
if (!result.exists) {
return { passed: null, skipped: true, message: 'Query textarea not found' };
}
return {
passed: result.ariaRequired === 'true' && !result.hasHtmlRequired,
message: `aria-required="${result.ariaRequired}", HTML required=${result.hasHtmlRequired}`
};
},
async formValidatorLoaded(page, baseUrl) {
await navigateTo(page, `${baseUrl}/`);
// Wait for the FormValidator module to actually attach instead of a fixed delay.
await page.waitForFunction(
() => typeof window.FormValidator === 'function' &&
typeof window.formValidators === 'object' &&
window.formValidators !== null,
{ timeout: 5000 }
).catch(() => {});
const result = await page.evaluate(() => {
return {
hasFormValidator: typeof window.FormValidator === 'function',
hasFormValidators: typeof window.formValidators === 'object' && window.formValidators !== null
};
});
return {
passed: result.hasFormValidator && result.hasFormValidators,
message: `FormValidator=${result.hasFormValidator}, formValidators=${result.hasFormValidators}`
};
}
};
// ============================================================================
// Inline Validation Tests
// ============================================================================
const InlineValidationTests = {
async emptySubmitShowsInlineError(page, baseUrl) {
await navigateTo(page, `${baseUrl}/`);
await waitForQueryReady(page);
// Clear query and submit
await page.evaluate(() => {
const query = document.querySelector('#query');
if (query) {
query.value = '';
query.dispatchEvent(new Event('input', { bubbles: true }));
}
});
// Click submit
await page.click('button[type="submit"], .start-research, #start-research').catch(() => {});
await waitForValidationState(page, true);
const result = await page.evaluate(() => {
const query = document.querySelector('#query');
if (!query) return { exists: false };
const hasInvalidClass = query.classList.contains('ldr-field-invalid');
const ariaInvalid = query.getAttribute('aria-invalid');
// Find the error element
const errorId = query.getAttribute('aria-describedby');
let errorElement = null;
let errorText = '';
let errorVisible = false;
if (errorId) {
// aria-describedby may contain multiple IDs
const ids = errorId.split(' ');
for (const id of ids) {
const el = document.getElementById(id);
if (el && el.classList.contains('ldr-field-error')) {
errorElement = el;
errorText = el.textContent;
errorVisible = window.getComputedStyle(el).display !== 'none';
break;
}
}
}
return {
exists: true,
hasInvalidClass,
ariaInvalid,
hasErrorElement: !!errorElement,
errorText,
errorVisible
};
});
if (!result.exists) {
return { passed: null, skipped: true, message: 'Query textarea not found' };
}
const passed = result.hasInvalidClass &&
result.ariaInvalid === 'true' &&
result.hasErrorElement &&
result.errorVisible &&
result.errorText.length > 0;
return {
passed,
message: `invalid-class=${result.hasInvalidClass}, aria-invalid="${result.ariaInvalid}", ` +
`error-visible=${result.errorVisible}, error="${result.errorText}"`
};
},
async blurOnEmptyShowsError(page, baseUrl) {
await navigateTo(page, `${baseUrl}/`);
await waitForQueryReady(page);
// Clear query field
await page.evaluate(() => {
const query = document.querySelector('#query');
if (query) {
query.value = '';
query.dispatchEvent(new Event('input', { bubbles: true }));
}
});
// Focus then blur the query field
await page.focus('#query');
await page.waitForFunction(() => document.activeElement?.id === 'query', { timeout: 1000 }).catch(() => {});
// Click somewhere else to trigger blur
await page.$eval('#query', el => el.blur());
await waitForValidationState(page, true);
const result = await page.evaluate(() => {
const query = document.querySelector('#query');
if (!query) return { exists: false };
return {
exists: true,
hasInvalidClass: query.classList.contains('ldr-field-invalid'),
ariaInvalid: query.getAttribute('aria-invalid')
};
});
if (!result.exists) {
return { passed: null, skipped: true, message: 'Query textarea not found' };
}
return {
passed: result.hasInvalidClass && result.ariaInvalid === 'true',
message: `After blur on empty: invalid-class=${result.hasInvalidClass}, aria-invalid="${result.ariaInvalid}"`
};
},
async errorClearsOnValidSubmit(page, baseUrl) {
await navigateTo(page, `${baseUrl}/`);
await waitForQueryReady(page);
// First trigger the error by submitting empty
await page.evaluate(() => {
const query = document.querySelector('#query');
if (query) {
query.value = '';
query.dispatchEvent(new Event('input', { bubbles: true }));
}
});
await page.click('button[type="submit"], .start-research, #start-research').catch(() => {});
await waitForValidationState(page, true);
// Verify error is shown
const errorShown = await page.evaluate(() => {
const query = document.querySelector('#query');
return query?.classList.contains('ldr-field-invalid') === true;
});
if (!errorShown) {
return { passed: null, skipped: true, message: 'Could not trigger validation error first' };
}
// Now type a valid query - this should clear the error on next submit attempt
await page.focus('#query');
await page.keyboard.type('Test research query');
// Wait for the typed value to land in the field instead of a fixed sleep.
await page.waitForFunction(
() => (document.querySelector('#query')?.value || '').includes('Test research'),
{ timeout: 2000 }
).catch(() => {});
// Verify the text was actually entered
const queryValue = await page.evaluate(() => {
const query = document.querySelector('#query');
return query?.value || '';
});
if (!queryValue.includes('Test research')) {
return { passed: null, skipped: true, message: `Text entry failed, value="${queryValue}"` };
}
// Intercept form submission to prevent navigation that would destroy page state
await page.evaluate(() => {
const form = document.querySelector('#query')?.closest('form');
if (form) {
form.addEventListener('submit', (e) => { e.preventDefault(); }, { once: true });
}
});
// Click submit - the validation should pass and clear errors
await page.click('button[type="submit"], .start-research, #start-research').catch(() => {});
// Wait for the ldr-field-invalid class to clear instead of a fixed sleep.
await waitForValidationState(page, false);
const result = await page.evaluate(() => {
const query = document.querySelector('#query');
if (!query) return { exists: false };
return {
exists: true,
hasInvalidClass: query.classList.contains('ldr-field-invalid'),
ariaInvalid: query.getAttribute('aria-invalid')
};
});
if (!result.exists) {
return { passed: null, skipped: true, message: 'Query textarea not found after submit' };
}
return {
passed: !result.hasInvalidClass && result.ariaInvalid !== 'true',
message: `After valid submit: invalid-class=${result.hasInvalidClass}, aria-invalid="${result.ariaInvalid}"`
};
},
async noLoadingUiOnEmptySubmit(page, baseUrl) {
// This test asserts no loading UI appears on an empty submit. A previous test
// leaves the page on `/` (navigateTo skips when paths match) with a stale
// .ldr-loading-overlay from a half-finished real submission. We need a
// genuinely fresh page here, so force a reload via page.goto instead of
// relying on the same-path optimization.
await page.goto(`${baseUrl}/`, { waitUntil: 'domcontentloaded' });
await waitForQueryReady(page);
// Clear query
await page.evaluate(() => {
const query = document.querySelector('#query');
if (query) {
query.value = '';
query.dispatchEvent(new Event('input', { bubbles: true }));
}
});
// Submit and immediately check for loading overlay
await page.click('button[type="submit"], .start-research, #start-research').catch(() => {});
// Check immediately - there should be no loading overlay or spinner
const result = await page.evaluate(() => {
const overlay = document.querySelector('.ldr-loading-overlay');
const btn = document.querySelector('button[type="submit"], .start-research, #start-research');
const btnDisabled = btn?.disabled;
const btnHasSpinner = btn?.innerHTML?.includes('fa-spinner');
return {
hasOverlay: !!overlay,
btnDisabled: !!btnDisabled,
btnHasSpinner: !!btnHasSpinner
};
});
return {
passed: !result.hasOverlay && !result.btnDisabled && !result.btnHasSpinner,
message: `On empty submit: overlay=${result.hasOverlay}, btn-disabled=${result.btnDisabled}, spinner=${result.btnHasSpinner}`
};
}
};
// ============================================================================
// Error Element Structure Tests
// ============================================================================
const ErrorElementTests = {
async errorElementHasAriaLive(page, baseUrl) {
await navigateTo(page, `${baseUrl}/`);
await waitForQueryReady(page);
// Trigger validation to create error element
await page.evaluate(() => {
const query = document.querySelector('#query');
if (query) {
query.value = '';
query.dispatchEvent(new Event('input', { bubbles: true }));
}
});
await page.focus('#query');
await page.waitForFunction(() => document.activeElement?.id === 'query', { timeout: 1000 }).catch(() => {});
await page.$eval('#query', el => el.blur());
await waitForValidationState(page, true);
const result = await page.evaluate(() => {
const query = document.querySelector('#query');
if (!query) return { exists: false };
const describedBy = query.getAttribute('aria-describedby');
if (!describedBy) return { exists: true, hasDescribedBy: false };
const ids = describedBy.split(' ');
for (const id of ids) {
const el = document.getElementById(id);
if (el && el.classList.contains('ldr-field-error')) {
return {
exists: true,
hasDescribedBy: true,
errorId: id,
ariaLive: el.getAttribute('aria-live'),
className: el.className
};
}
}
return { exists: true, hasDescribedBy: true, errorElementFound: false };
});
if (!result.exists) {
return { passed: null, skipped: true, message: 'Query textarea not found' };
}
if (!result.hasDescribedBy) {
return { passed: false, message: 'No aria-describedby on query field' };
}
return {
passed: result.ariaLive === 'polite',
message: `Error element: id="${result.errorId}", aria-live="${result.ariaLive}", class="${result.className}"`
};
},
async errorPositionedAfterTextarea(page, baseUrl) {
await navigateTo(page, `${baseUrl}/`);
await waitForQueryReady(page);
// Trigger validation
await page.evaluate(() => {
const query = document.querySelector('#query');
if (query) query.value = '';
});
await page.focus('#query');
await page.waitForFunction(() => document.activeElement?.id === 'query', { timeout: 1000 }).catch(() => {});
await page.$eval('#query', el => el.blur());
await waitForValidationState(page, true);
const result = await page.evaluate(() => {
const query = document.querySelector('#query');
if (!query) return { exists: false };
const nextSibling = query.nextElementSibling;
const isErrorElement = nextSibling?.classList.contains('ldr-field-error');
const searchHints = query.parentNode?.querySelector('.ldr-search-hints');
return {
exists: true,
nextSiblingIsError: isErrorElement,
nextSiblingClass: nextSibling?.className,
hintsExist: !!searchHints
};
});
if (!result.exists) {
return { passed: null, skipped: true, message: 'Query textarea not found' };
}
return {
passed: result.nextSiblingIsError,
message: `Next sibling after textarea: class="${result.nextSiblingClass}", hints exist=${result.hintsExist}`
};
},
async focusMovesToFieldOnError(page, baseUrl) {
// Headless Chrome focus management after form submission is unreliable
if (process.env.CI) {
return { passed: null, skipped: true, message: 'CI: headless Chrome focus management after form submit is unreliable' };
}
await navigateTo(page, `${baseUrl}/`);
await waitForQueryReady(page);
// Clear and submit
await page.evaluate(() => {
const query = document.querySelector('#query');
if (query) {
query.value = '';
query.dispatchEvent(new Event('input', { bubbles: true }));
// Focus something else first
document.body.focus();
}
});
await page.click('button[type="submit"], .start-research, #start-research').catch(() => {});
await waitForValidationState(page, true);
const result = await page.evaluate(() => {
const activeElement = document.activeElement;
return {
focusedId: activeElement?.id,
focusedTag: activeElement?.tagName?.toLowerCase()
};
});
return {
passed: result.focusedId === 'query',
message: `Focus after empty submit: id="${result.focusedId}", tag="${result.focusedTag}"`
};
}
};
// ============================================================================
// Main Test Runner
// ============================================================================
async function main() {
log.section('Form Validation ARIA Support Tests');
const ctx = await setupTest({ authenticate: true });
const results = new TestResults('Form Validation ARIA Tests');
const { page } = ctx;
const { baseUrl } = ctx.config;
const subTestTimeout = ctx.config.isCI ? 60000 : 30000;
async function run(category, name, testFn) {
try {
const result = await withTimeout(
testFn(page, baseUrl),
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 {
// ARIA Attribute Tests
log.section('ARIA Attributes');
await run('ARIA', 'Query has aria-required', (p, u) => AriaAttributeTests.queryHasAriaRequired(p, u));
await run('ARIA', 'FormValidator loaded', (p, u) => AriaAttributeTests.formValidatorLoaded(p, u));
// Inline Validation Tests
log.section('Inline Validation');
await run('Validation', 'Empty submit shows inline error', (p, u) => InlineValidationTests.emptySubmitShowsInlineError(p, u));
await run('Validation', 'Blur on empty shows error', (p, u) => InlineValidationTests.blurOnEmptyShowsError(p, u));
await run('Validation', 'Error clears on valid submit', (p, u) => InlineValidationTests.errorClearsOnValidSubmit(p, u));
await run('Validation', 'No loading UI on empty submit', (p, u) => InlineValidationTests.noLoadingUiOnEmptySubmit(p, u));
// Error Element Structure Tests
log.section('Error Element Structure');
await run('Structure', 'Error element has aria-live=polite', (p, u) => ErrorElementTests.errorElementHasAriaLive(p, u));
await run('Structure', 'Error positioned after textarea', (p, u) => ErrorElementTests.errorPositionedAfterTextarea(p, u));
await run('Structure', 'Focus moves to field on error', (p, u) => ErrorElementTests.focusMovesToFieldOnError(p, u));
} catch (error) {
log.error(`Fatal error: ${error.message}`);
console.error(error.stack);
} 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 = { AriaAttributeTests, InlineValidationTests, ErrorElementTests };