fix: enhance accessibility by adding aria-labels and data-testid attributes across various components
This commit is contained in:
@@ -936,7 +936,12 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
setFormData(prev => ({ ...prev, websocket_support: needsWebsockets || prev.websocket_support }))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-gray-900 border-gray-700 text-white" aria-label="Application Preset">
|
||||
<SelectTrigger
|
||||
id="application-preset"
|
||||
data-testid="application-preset"
|
||||
className="w-full bg-gray-900 border-gray-700 text-white"
|
||||
aria-label="Application Preset"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -1279,6 +1284,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
data-testid="proxy-host-cancel"
|
||||
className="px-6 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
@@ -1288,6 +1294,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
disabled={loading || testStatus === 'testing' || !formData.forward_host || !formData.forward_port}
|
||||
data-testid="proxy-host-test-connection"
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2 disabled:opacity-50 ${
|
||||
testStatus === 'success' ? 'bg-green-600 hover:bg-green-500 text-white' :
|
||||
testStatus === 'error' ? 'bg-red-600 hover:bg-red-500 text-white' :
|
||||
@@ -1304,6 +1311,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
data-testid="proxy-host-save"
|
||||
className="px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Saving...' : 'Save'}
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function Certificates() {
|
||||
|
||||
// Header actions
|
||||
const headerActions = (
|
||||
<Button onClick={() => setIsModalOpen(true)}>
|
||||
<Button onClick={() => setIsModalOpen(true)} data-testid="add-certificate-btn">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t('certificates.addCertificate')}
|
||||
</Button>
|
||||
@@ -71,7 +71,7 @@ export default function Certificates() {
|
||||
|
||||
{/* Upload Certificate Dialog */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent>
|
||||
<DialogContent data-testid="certificate-upload-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('certificates.uploadCertificate')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -88,6 +88,7 @@ export default function Certificates() {
|
||||
<Label htmlFor="cert-file">{t('certificates.certificatePem')}</Label>
|
||||
<input
|
||||
id="cert-file"
|
||||
data-testid="certificate-file-input"
|
||||
type="file"
|
||||
accept=".pem,.crt,.cer"
|
||||
onChange={(e) => setCertFile(e.target.files?.[0] || null)}
|
||||
@@ -99,6 +100,7 @@ export default function Certificates() {
|
||||
<Label htmlFor="key-file">{t('certificates.privateKeyPem')}</Label>
|
||||
<input
|
||||
id="key-file"
|
||||
data-testid="certificate-key-input"
|
||||
type="file"
|
||||
accept=".pem,.key"
|
||||
onChange={(e) => setKeyFile(e.target.files?.[0] || null)}
|
||||
|
||||
@@ -503,6 +503,7 @@ export default function ProxyHosts() {
|
||||
cell: (host) => (
|
||||
<Switch
|
||||
checked={host.enabled}
|
||||
aria-label={`${host.enabled ? 'Disable' : 'Enable'} proxy host ${host.name || host.domain_names}`}
|
||||
onCheckedChange={(checked) => updateHost(host.uuid, { enabled: checked })}
|
||||
/>
|
||||
),
|
||||
@@ -516,6 +517,7 @@ export default function ProxyHosts() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={`Edit proxy host ${host.name || host.domain_names}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEdit(host)
|
||||
@@ -527,6 +529,7 @@ export default function ProxyHosts() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-error hover:text-error hover:bg-error/10"
|
||||
aria-label={`Delete proxy host ${host.name || host.domain_names}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDelete(host.uuid)
|
||||
|
||||
@@ -28,11 +28,12 @@ export default function Settings() {
|
||||
}
|
||||
>
|
||||
{/* Tab Navigation */}
|
||||
<nav className="flex items-center gap-1 p-1 bg-surface-subtle rounded-lg w-fit">
|
||||
<nav aria-label={t('settings.title')} className="flex items-center gap-1 p-1 bg-surface-subtle rounded-lg w-fit">
|
||||
{navItems.map(({ path, label, icon: Icon }) => (
|
||||
<Link
|
||||
key={path}
|
||||
to={path}
|
||||
aria-current={isActive(path) ? 'page' : undefined}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-all duration-fast',
|
||||
isActive(path)
|
||||
|
||||
@@ -173,10 +173,15 @@ function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) {
|
||||
<div className="fixed inset-0 bg-black/50 z-40" onClick={handleClose} />
|
||||
|
||||
{/* Layer 2: Form container (z-50, pointer-events-none) */}
|
||||
<div className="fixed inset-0 flex items-center justify-center pointer-events-none z-50" role="dialog" aria-modal="true" aria-labelledby="invite-modal-title">
|
||||
<div className="fixed inset-0 flex items-center justify-center pointer-events-none z-50">
|
||||
|
||||
{/* Layer 3: Form content (pointer-events-auto) */}
|
||||
<div className="bg-dark-card border border-gray-800 rounded-lg w-full max-w-lg max-h-[90vh] overflow-y-auto pointer-events-auto">
|
||||
<div
|
||||
className="bg-dark-card border border-gray-800 rounded-lg w-full max-w-lg max-h-[90vh] overflow-y-auto pointer-events-auto"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="invite-modal-title"
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||
<h3 id="invite-modal-title" className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<UserPlus className="h-5 w-5" />
|
||||
@@ -253,10 +258,11 @@ function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) {
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
<label htmlFor="invite-user-role" className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
{t('users.role')}
|
||||
</label>
|
||||
<select
|
||||
id="invite-user-role"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as 'user' | 'admin')}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
@@ -269,10 +275,11 @@ function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) {
|
||||
{role === 'user' && (
|
||||
<>
|
||||
<div className="w-full">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
<label htmlFor="invite-permission-mode" className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
{t('users.permissionMode')}
|
||||
</label>
|
||||
<select
|
||||
id="invite-permission-mode"
|
||||
value={permissionMode}
|
||||
onChange={(e) => setPermissionMode(e.target.value as PermissionMode)}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
@@ -444,10 +451,15 @@ function PermissionsModal({ isOpen, onClose, user, proxyHosts }: PermissionsModa
|
||||
<div className="fixed inset-0 bg-black/50 z-40" onClick={onClose} />
|
||||
|
||||
{/* Layer 2: Form container (z-50, pointer-events-none) */}
|
||||
<div className="fixed inset-0 flex items-center justify-center pointer-events-none z-50" role="dialog" aria-modal="true" aria-labelledby="permissions-modal-title">
|
||||
<div className="fixed inset-0 flex items-center justify-center pointer-events-none z-50">
|
||||
|
||||
{/* Layer 3: Form content (pointer-events-auto) */}
|
||||
<div className="bg-dark-card border border-gray-800 rounded-lg w-full max-w-lg max-h-[90vh] overflow-y-auto pointer-events-auto">
|
||||
<div
|
||||
className="bg-dark-card border border-gray-800 rounded-lg w-full max-w-lg max-h-[90vh] overflow-y-auto pointer-events-auto"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="permissions-modal-title"
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||
<h3 id="permissions-modal-title" className="text-lg font-semibold text-white flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
@@ -460,10 +472,11 @@ function PermissionsModal({ isOpen, onClose, user, proxyHosts }: PermissionsModa
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="w-full">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
<label htmlFor="edit-permission-mode" className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||
{t('users.permissionMode')}
|
||||
</label>
|
||||
<select
|
||||
id="edit-permission-mode"
|
||||
value={permissionMode}
|
||||
onChange={(e) => setPermissionMode(e.target.value as PermissionMode)}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
|
||||
@@ -292,7 +292,6 @@ test.describe('Notification Providers', () => {
|
||||
});
|
||||
|
||||
await test.step('Verify provider appears in list', async () => {
|
||||
await page.waitForTimeout(1000);
|
||||
const providerInList = page.getByText(providerName);
|
||||
await expect(providerInList.first()).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
@@ -343,7 +342,6 @@ test.describe('Notification Providers', () => {
|
||||
});
|
||||
|
||||
await test.step('Verify provider created', async () => {
|
||||
await page.waitForTimeout(1000);
|
||||
const providerInList = page.getByText(providerName);
|
||||
await expect(providerInList.first()).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
@@ -515,7 +513,6 @@ test.describe('Notification Providers', () => {
|
||||
});
|
||||
|
||||
await test.step('Verify deletion', async () => {
|
||||
await page.waitForTimeout(1000);
|
||||
// Provider should be gone or success message shown
|
||||
const successIndicator = page.locator('[data-testid="toast-success"]')
|
||||
.or(page.getByRole('status').filter({ hasText: /deleted|removed/i }))
|
||||
@@ -714,7 +711,6 @@ test.describe('Notification Providers', () => {
|
||||
|
||||
await test.step('Attempt to save', async () => {
|
||||
await page.getByTestId('provider-save-btn').click();
|
||||
await page.waitForTimeout(500);
|
||||
});
|
||||
|
||||
await test.step('Verify name validation error', async () => {
|
||||
@@ -844,7 +840,6 @@ test.describe('Notification Providers', () => {
|
||||
});
|
||||
|
||||
await test.step('Verify template was created and appears in list', async () => {
|
||||
await page.waitForTimeout(1500);
|
||||
const templateInList = page.getByText(templateName);
|
||||
await expect(templateInList.first()).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
@@ -1002,7 +997,7 @@ test.describe('Notification Providers', () => {
|
||||
const saveButton = page.getByRole('button', { name: /save/i }).last();
|
||||
if (await saveButton.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await saveButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
await waitForLoadingComplete(page);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1153,7 +1148,7 @@ test.describe('Notification Providers', () => {
|
||||
const testButton = page.getByTestId('provider-test-btn');
|
||||
|
||||
// Wait for loading to complete and check for success icon
|
||||
await page.waitForTimeout(2000);
|
||||
await waitForLoadingComplete(page);
|
||||
const hasSuccessIcon = await testButton.locator('svg').evaluate((el) =>
|
||||
el.classList.contains('text-green-500') ||
|
||||
el.closest('button')?.querySelector('.text-green-500') !== null
|
||||
@@ -1195,7 +1190,7 @@ test.describe('Notification Providers', () => {
|
||||
|
||||
await test.step('Verify success feedback', async () => {
|
||||
// Wait for success icon (checkmark)
|
||||
await page.waitForTimeout(1500);
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
const testButton = page.getByTestId('provider-test-btn');
|
||||
const successIcon = testButton.locator('svg.text-green-500, svg[class*="green"]');
|
||||
@@ -1618,7 +1613,7 @@ test.describe('Notification Providers', () => {
|
||||
});
|
||||
|
||||
await test.step('Verify error feedback', async () => {
|
||||
await page.waitForTimeout(1500);
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
// Should show error icon (X)
|
||||
const testButton = page.getByTestId('provider-test-btn');
|
||||
|
||||
@@ -213,7 +213,7 @@ test.describe('SMTP Settings', () => {
|
||||
|
||||
await test.step('Attempt to save and verify validation', async () => {
|
||||
await saveButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
// Check for validation error
|
||||
const errorMessage = page.getByText(/invalid.*email|email.*format|valid.*email/i);
|
||||
@@ -238,7 +238,7 @@ test.describe('SMTP Settings', () => {
|
||||
await fromInput.fill('noreply@example.com');
|
||||
|
||||
// Should not show validation error for valid email
|
||||
await page.waitForTimeout(300);
|
||||
await waitForLoadingComplete(page);
|
||||
const inputHasError = await fromInput.evaluate((el) =>
|
||||
el.classList.contains('border-red-500')
|
||||
).catch(() => false);
|
||||
@@ -384,7 +384,7 @@ test.describe('SMTP Settings', () => {
|
||||
await hostInput.clear();
|
||||
await hostInput.fill(originalHost || 'smtp.test.local');
|
||||
await saveButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
await waitForToast(page, /saved|success/i, { type: 'success', timeout: 10000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -413,7 +413,7 @@ test.describe('SMTP Settings', () => {
|
||||
await saveButton.click();
|
||||
|
||||
// Wait for save to complete
|
||||
await page.waitForTimeout(1000);
|
||||
await waitForToast(page, /saved|success/i, { type: 'success', timeout: 10000 });
|
||||
|
||||
// After save, password field may be cleared or masked
|
||||
// The actual behavior depends on implementation
|
||||
@@ -442,7 +442,7 @@ test.describe('SMTP Settings', () => {
|
||||
await passwordInput.clear();
|
||||
await passwordInput.fill('initial-password');
|
||||
await saveButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
await waitForToast(page, /saved|success/i, { type: 'success', timeout: 10000 });
|
||||
});
|
||||
|
||||
await test.step('Reload page', async () => {
|
||||
@@ -757,7 +757,7 @@ test.describe('SMTP Settings', () => {
|
||||
|
||||
// Open select with Enter or Space
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(300);
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
// Check if listbox opened
|
||||
const listbox = page.getByRole('listbox');
|
||||
@@ -877,7 +877,7 @@ test.describe('SMTP Settings', () => {
|
||||
// Try to save with empty required field
|
||||
const saveButton = page.getByRole('button', { name: /save/i }).last();
|
||||
await saveButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
await test.step('Verify error announcement', async () => {
|
||||
|
||||
@@ -165,9 +165,7 @@ test.describe('User Management', () => {
|
||||
.getByRole('button', { name: /send.*invite/i })
|
||||
.first();
|
||||
await sendButton.click();
|
||||
|
||||
// Wait for invite creation
|
||||
await page.waitForTimeout(1000);
|
||||
await waitForToast(page, /invite.*sent|invite.*created|success/i, { type: 'success', timeout: 10000 });
|
||||
|
||||
// Close the modal - scope to dialog to avoid strict mode violation with Toast close buttons
|
||||
const closeButton = page.getByRole('dialog')
|
||||
@@ -941,7 +939,7 @@ test.describe('User Management', () => {
|
||||
});
|
||||
|
||||
await test.step('Verify user no longer in list', async () => {
|
||||
await page.waitForTimeout(500);
|
||||
await waitForLoadingComplete(page);
|
||||
const userRow = page.getByRole('row').filter({
|
||||
hasText: testUser.email,
|
||||
});
|
||||
@@ -1026,7 +1024,7 @@ test.describe('User Management', () => {
|
||||
await sendButton.click();
|
||||
|
||||
// Wait for success and close modal
|
||||
await page.waitForTimeout(2000);
|
||||
await waitForToast(page, /invite.*sent|invite.*created|success/i, { type: 'success', timeout: 10000 });
|
||||
const closeButton = page.getByRole('button', { name: /done|close|×/i }).first();
|
||||
if (await closeButton.isVisible()) {
|
||||
await closeButton.click();
|
||||
|
||||
@@ -243,22 +243,38 @@ export async function waitForLoadingComplete(
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for any loading indicator to disappear
|
||||
// Updated to be more specific and exclude pulsing UI badges
|
||||
// Wait for visible loading indicators to disappear.
|
||||
// Avoid broad class-based selectors (e.g. .loading, .spinner) to prevent
|
||||
// false positives from persistent layout/status elements.
|
||||
const loader = page.locator([
|
||||
'[role="progressbar"]:not([aria-label*="Challenge timeout progress"])',
|
||||
'[aria-busy="true"]',
|
||||
'.loading-spinner',
|
||||
'.loading',
|
||||
'.spinner',
|
||||
'[data-loading="true"]',
|
||||
'[data-testid="config-reload-overlay"]',
|
||||
'[data-testid="loading-spinner"]',
|
||||
'[role="status"][aria-label="Loading"]',
|
||||
'[role="status"][aria-label="Authenticating"]',
|
||||
'[role="status"][aria-label="Security Loading"]'
|
||||
].join(', '));
|
||||
|
||||
try {
|
||||
await expect(loader).toHaveCount(0, { timeout });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const count = await loader.count();
|
||||
if (count === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let visibleCount = 0;
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
if (await loader.nth(index).isVisible().catch(() => false)) {
|
||||
visibleCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return visibleCount;
|
||||
}, { timeout })
|
||||
.toBe(0);
|
||||
} catch (error) {
|
||||
if (page.isClosed()) {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user