Add reproducible browser and accessibility gates
This commit is contained in:
parent
4d31c441d0
commit
9ad8d27adf
1 changed files with 1887 additions and 103 deletions
152
web/test/components/accessibility.test.ts
Normal file
152
web/test/components/accessibility.test.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { cleanup, render, screen } from '@testing-library/svelte';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import axe from 'axe-core';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import Feedback from '../../src/components/Feedback.svelte';
|
||||
import ObjectBrowser from '../../src/components/ObjectBrowser.svelte';
|
||||
import UploadPanel from '../../src/components/UploadPanel.svelte';
|
||||
import WorkspaceHeader from '../../src/components/WorkspaceHeader.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('accessible component behavior', () => {
|
||||
test('root state has names, pressed state, and keyboard activation', async () => {
|
||||
const onselect = vi.fn(async () => {});
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(WorkspaceHeader, {
|
||||
root: 'personal',
|
||||
busy: false,
|
||||
onselect
|
||||
});
|
||||
|
||||
const personal = screen.getByRole('button', { name: 'Personal' });
|
||||
const shared = screen.getByRole('button', { name: 'Shared' });
|
||||
expect(personal.getAttribute('aria-pressed')).toBe('true');
|
||||
expect(shared.getAttribute('aria-pressed')).toBe('false');
|
||||
shared.focus();
|
||||
await user.keyboard('{Enter}');
|
||||
expect(onselect).toHaveBeenCalledWith('shared');
|
||||
await expectNoSeriousViolations(container);
|
||||
});
|
||||
|
||||
test('feedback exposes live notices, alerts, and retry names', async () => {
|
||||
const { container } = render(Feedback, {
|
||||
notice: 'Uploaded file.txt',
|
||||
error: 'Delete is pending',
|
||||
uploadPending: true,
|
||||
uploadFollowup: 'replay',
|
||||
deletePending: true,
|
||||
deleteFollowup: 'restart',
|
||||
onuploadretry: async () => {},
|
||||
ondeleteretry: async () => {}
|
||||
});
|
||||
|
||||
expect(screen.getByRole('status').textContent).toContain('Uploaded file.txt');
|
||||
expect(screen.getByRole('alert').textContent).toContain('Delete is pending');
|
||||
screen.getByRole('button', { name: 'Retry same upload' });
|
||||
screen.getByRole('button', { name: 'Start new delete operation' });
|
||||
await expectNoSeriousViolations(container);
|
||||
});
|
||||
|
||||
test('browser state names deletion controls and reports busy state', async () => {
|
||||
const rendered = render(ObjectBrowser, browserProps());
|
||||
|
||||
expect(screen.getByRole('textbox', { name: 'Search this root' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: 'Delete alpha.txt' })).toBeTruthy();
|
||||
expect(rendered.container.querySelector('.list')?.getAttribute('aria-busy')).toBe(
|
||||
'false'
|
||||
);
|
||||
await rendered.rerender({ ...browserProps(), busy: true });
|
||||
expect(rendered.container.querySelector('.list')?.getAttribute('aria-busy')).toBe(
|
||||
'true'
|
||||
);
|
||||
expect(
|
||||
(screen.getByRole('textbox', {
|
||||
name: 'Search this root'
|
||||
}) as HTMLInputElement).disabled
|
||||
).toBe(true);
|
||||
expect(
|
||||
(screen.getByRole('button', {
|
||||
name: 'Delete alpha.txt'
|
||||
}) as HTMLButtonElement).disabled
|
||||
).toBe(true);
|
||||
await expectNoSeriousViolations(rendered.container);
|
||||
});
|
||||
|
||||
test('post-delete focus moves to the logical surviving entry', async () => {
|
||||
const onfocused = vi.fn();
|
||||
const rendered = render(ObjectBrowser, {
|
||||
...browserProps(),
|
||||
onfocused
|
||||
});
|
||||
|
||||
await rendered.rerender({
|
||||
...browserProps(),
|
||||
entries: [
|
||||
{
|
||||
name: 'beta.txt',
|
||||
path: 'beta.txt',
|
||||
is_prefix: false,
|
||||
size: 4
|
||||
}
|
||||
],
|
||||
focusPath: 'beta.txt',
|
||||
onfocused
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(document.activeElement?.textContent).toContain('beta.txt');
|
||||
});
|
||||
expect(onfocused).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('upload input retains a native named keyboard alternative', async () => {
|
||||
const { container } = render(UploadPanel, {
|
||||
busy: false,
|
||||
onfile: async () => {}
|
||||
});
|
||||
expect(screen.getByLabelText('Choose file').getAttribute('type')).toBe('file');
|
||||
await expectNoSeriousViolations(container);
|
||||
});
|
||||
});
|
||||
|
||||
function browserProps() {
|
||||
return {
|
||||
root: 'personal' as const,
|
||||
entries: [
|
||||
{
|
||||
name: 'alpha.txt',
|
||||
path: 'alpha.txt',
|
||||
is_prefix: false,
|
||||
size: 5
|
||||
},
|
||||
{
|
||||
name: 'beta.txt',
|
||||
path: 'beta.txt',
|
||||
is_prefix: false,
|
||||
size: 4
|
||||
}
|
||||
],
|
||||
continuation: null,
|
||||
partial: false,
|
||||
busy: false,
|
||||
query: '',
|
||||
focusPath: null,
|
||||
onquery: () => {},
|
||||
onbrowse: async () => {},
|
||||
onopen: async () => {},
|
||||
ondelete: async () => {},
|
||||
onfocused: () => {}
|
||||
};
|
||||
}
|
||||
|
||||
async function expectNoSeriousViolations(container: HTMLElement) {
|
||||
const results = await axe.run(container, {
|
||||
rules: { 'color-contrast': { enabled: false } }
|
||||
});
|
||||
expect(
|
||||
results.violations.filter(({ impact }) =>
|
||||
impact === 'serious' || impact === 'critical'
|
||||
)
|
||||
).toEqual([]);
|
||||
}
|
||||
Loading…
Reference in a new issue