@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest';
22import { render , screen } from '@testing-library/react' ;
33import userEvent from '@testing-library/user-event' ;
44import LoginPage from './LoginPage' ;
5+ import { checkA11y } from '../test/a11y' ;
56
67describe ( 'LoginPage' , ( ) => {
78 it ( 'renders with title and subtitle' , ( ) => {
@@ -68,4 +69,181 @@ describe('LoginPage', () => {
6869 expect ( screen . getByText ( 'Signing in...' ) ) . toBeInTheDocument ( ) ;
6970 resolveLogin ! ( ) ;
7071 } ) ;
72+
73+ // Accessibility tests
74+
75+ it ( 'should have no accessibility violations' , async ( ) => {
76+ const { container } = render (
77+ < LoginPage
78+ title = "TestApp"
79+ subtitle = "Sign in to continue"
80+ onLogin = { vi . fn ( ) }
81+ /> ,
82+ ) ;
83+ await checkA11y ( container ) ;
84+ } ) ;
85+
86+ it ( 'should have no accessibility violations when error is shown' , async ( ) => {
87+ const { container } = render (
88+ < LoginPage
89+ title = "TestApp"
90+ error = "Invalid credentials"
91+ onLogin = { vi . fn ( ) }
92+ /> ,
93+ ) ;
94+ await checkA11y ( container ) ;
95+ } ) ;
96+
97+ // Semantic HTML structure
98+
99+ it ( 'wraps the page in a <main> landmark' , ( ) => {
100+ render ( < LoginPage title = "App" onLogin = { vi . fn ( ) } /> ) ;
101+ expect ( screen . getByRole ( 'main' ) ) . toBeInTheDocument ( ) ;
102+ } ) ;
103+
104+ it ( 'renders a <form> element for the login form' , ( ) => {
105+ render ( < LoginPage title = "App" onLogin = { vi . fn ( ) } /> ) ;
106+ expect ( screen . getByRole ( 'form' , { name : 'Login form' } ) ) . toBeInTheDocument ( ) ;
107+ } ) ;
108+
109+ it ( 'has a heading with the app title' , ( ) => {
110+ render ( < LoginPage title = "MyApp" onLogin = { vi . fn ( ) } /> ) ;
111+ expect ( screen . getByRole ( 'heading' , { name : 'MyApp' , level : 1 } ) ) . toBeInTheDocument ( ) ;
112+ } ) ;
113+
114+ // ARIA attributes
115+
116+ it ( 'labels username input with a <label> element via htmlFor' , ( ) => {
117+ render ( < LoginPage title = "App" usernameLabel = "Email address" onLogin = { vi . fn ( ) } /> ) ;
118+ const input = screen . getByLabelText ( 'Email address' ) ;
119+ expect ( input ) . toBeInTheDocument ( ) ;
120+ expect ( input . tagName ) . toBe ( 'INPUT' ) ;
121+ } ) ;
122+
123+ it ( 'labels password input with a <label> element via htmlFor' , ( ) => {
124+ render ( < LoginPage title = "App" onLogin = { vi . fn ( ) } /> ) ;
125+ const input = screen . getByLabelText ( 'Password' ) ;
126+ expect ( input ) . toBeInTheDocument ( ) ;
127+ expect ( input ) . toHaveAttribute ( 'type' , 'password' ) ;
128+ } ) ;
129+
130+ it ( 'marks username input as required via aria-required' , ( ) => {
131+ render ( < LoginPage title = "App" onLogin = { vi . fn ( ) } /> ) ;
132+ const input = screen . getByLabelText ( 'Username' ) ;
133+ expect ( input ) . toHaveAttribute ( 'aria-required' , 'true' ) ;
134+ } ) ;
135+
136+ it ( 'marks password input as required via aria-required' , ( ) => {
137+ render ( < LoginPage title = "App" onLogin = { vi . fn ( ) } /> ) ;
138+ const input = screen . getByLabelText ( 'Password' ) ;
139+ expect ( input ) . toHaveAttribute ( 'aria-required' , 'true' ) ;
140+ } ) ;
141+
142+ it ( 'sets aria-busy on the form while loading' , async ( ) => {
143+ let resolveLogin : ( ) => void ;
144+ const loginPromise = new Promise < void > ( ( resolve ) => {
145+ resolveLogin = resolve ;
146+ } ) ;
147+ const onLogin = vi . fn ( ) . mockReturnValue ( loginPromise ) ;
148+ const user = userEvent . setup ( ) ;
149+
150+ render ( < LoginPage title = "App" onLogin = { onLogin } /> ) ;
151+
152+ const form = screen . getByRole ( 'form' , { name : 'Login form' } ) ;
153+ expect ( form ) . toHaveAttribute ( 'aria-busy' , 'false' ) ;
154+
155+ await user . type ( screen . getByLabelText ( 'Username' ) , 'u' ) ;
156+ await user . type ( screen . getByLabelText ( 'Password' ) , 'p' ) ;
157+ await user . click ( screen . getByRole ( 'button' , { name : 'Sign In' } ) ) ;
158+
159+ expect ( form ) . toHaveAttribute ( 'aria-busy' , 'true' ) ;
160+ resolveLogin ! ( ) ;
161+ } ) ;
162+
163+ it ( 'sets aria-disabled on button when loading' , async ( ) => {
164+ let resolveLogin : ( ) => void ;
165+ const loginPromise = new Promise < void > ( ( resolve ) => {
166+ resolveLogin = resolve ;
167+ } ) ;
168+ const onLogin = vi . fn ( ) . mockReturnValue ( loginPromise ) ;
169+ const user = userEvent . setup ( ) ;
170+
171+ render ( < LoginPage title = "App" onLogin = { onLogin } /> ) ;
172+
173+ await user . type ( screen . getByLabelText ( 'Username' ) , 'u' ) ;
174+ await user . type ( screen . getByLabelText ( 'Password' ) , 'p' ) ;
175+ await user . click ( screen . getByRole ( 'button' , { name : 'Sign In' } ) ) ;
176+
177+ const button = screen . getByRole ( 'button' , { name : 'Signing in...' } ) ;
178+ expect ( button ) . toHaveAttribute ( 'aria-disabled' , 'true' ) ;
179+ expect ( button ) . toBeDisabled ( ) ;
180+ resolveLogin ! ( ) ;
181+ } ) ;
182+
183+ // Error message announcement
184+
185+ it ( 'renders error message container with aria-live="polite"' , ( ) => {
186+ render ( < LoginPage title = "App" error = "Bad credentials" onLogin = { vi . fn ( ) } /> ) ;
187+ const liveRegion = screen . getByRole ( 'status' ) ;
188+ expect ( liveRegion ) . toHaveAttribute ( 'aria-live' , 'polite' ) ;
189+ expect ( liveRegion ) . toHaveAttribute ( 'aria-atomic' , 'true' ) ;
190+ } ) ;
191+
192+ it ( 'error message has role="alert"' , ( ) => {
193+ render ( < LoginPage title = "App" error = "Bad credentials" onLogin = { vi . fn ( ) } /> ) ;
194+ expect ( screen . getByRole ( 'alert' ) ) . toHaveTextContent ( 'Bad credentials' ) ;
195+ } ) ;
196+
197+ it ( 'inputs reference error element via aria-describedby when error is shown' , ( ) => {
198+ render ( < LoginPage title = "App" error = "Oops" onLogin = { vi . fn ( ) } /> ) ;
199+ const usernameInput = screen . getByLabelText ( 'Username' ) ;
200+ const errorEl = screen . getByRole ( 'alert' ) ;
201+ expect ( usernameInput ) . toHaveAttribute ( 'aria-describedby' , errorEl . id ) ;
202+ } ) ;
203+
204+ it ( 'inputs do not have aria-describedby when there is no error' , ( ) => {
205+ render ( < LoginPage title = "App" onLogin = { vi . fn ( ) } /> ) ;
206+ const usernameInput = screen . getByLabelText ( 'Username' ) ;
207+ expect ( usernameInput ) . not . toHaveAttribute ( 'aria-describedby' ) ;
208+ } ) ;
209+
210+ // Keyboard navigation
211+
212+ it ( 'can tab from username to password to submit button' , async ( ) => {
213+ const user = userEvent . setup ( ) ;
214+ render ( < LoginPage title = "App" onLogin = { vi . fn ( ) } /> ) ;
215+
216+ const usernameInput = screen . getByLabelText ( 'Username' ) ;
217+ const passwordInput = screen . getByLabelText ( 'Password' ) ;
218+ const submitButton = screen . getByRole ( 'button' , { name : 'Sign In' } ) ;
219+
220+ expect ( usernameInput ) . toHaveFocus ( ) ;
221+
222+ await user . tab ( ) ;
223+ expect ( passwordInput ) . toHaveFocus ( ) ;
224+
225+ await user . tab ( ) ;
226+ expect ( submitButton ) . toHaveFocus ( ) ;
227+ } ) ;
228+
229+ it ( 'submits the form when pressing Enter on the submit button' , async ( ) => {
230+ const onLogin = vi . fn ( ) . mockResolvedValue ( undefined ) ;
231+ const user = userEvent . setup ( ) ;
232+
233+ render ( < LoginPage title = "App" onLogin = { onLogin } /> ) ;
234+
235+ await user . type ( screen . getByLabelText ( 'Username' ) , 'testuser' ) ;
236+ await user . type ( screen . getByLabelText ( 'Password' ) , 'testpass' ) ;
237+
238+ await user . tab ( ) ;
239+ await user . keyboard ( '{Enter}' ) ;
240+
241+ expect ( onLogin ) . toHaveBeenCalledWith ( 'testuser' , 'testpass' ) ;
242+ } ) ;
243+
244+ it ( 'submit button has type="submit"' , ( ) => {
245+ render ( < LoginPage title = "App" onLogin = { vi . fn ( ) } /> ) ;
246+ const button = screen . getByRole ( 'button' , { name : 'Sign In' } ) ;
247+ expect ( button ) . toHaveAttribute ( 'type' , 'submit' ) ;
248+ } ) ;
71249} ) ;
0 commit comments