From 5e5bdd385e608c5df4948d43a6cf24c57d3f3d0c Mon Sep 17 00:00:00 2001 From: Khokan Sardar Date: Sat, 1 Aug 2026 00:21:49 +0530 Subject: [PATCH] Login and Registration: Reference every rendered notice in the login form. login_header() splits the login errors into a separate notice per severity and can display both at the same time: `#login_error` for errors and `#login-message` for informational messages. The `aria-describedby` logic on the username and password fields assumed only one of them existed, so when both were displayed, one notice was left unreferenced. It was visible on screen but never announced when focus reached either field. Two defects combined to cause this. `WP_Error::get_error_data()` was called without a code, so it fell back to the first error code and ignored the severity of every other item. The second branch then overwrote the attribute rather than appending to it, so only ever one ID could be emitted. Determine the severities the same way login_header() does, by passing each error code to `WP_Error::get_error_data()`, and reference every notice that is displayed, ordered to match the order they appear in. Reachable without a plugin: after a core update WordPress redirects to wp-login.php with `redirect_to` set to about.php?updated, which adds an informational message to the same WP_Error that a failed login populates. Add end to end coverage for all four states the form can be in. The logic lives inline in the `login` case of wp-login.php, a top-level script the PHPUnit suite does not load. Follow-up to [53707]. See #65777. --- src/wp-login.php | 29 ++++++-- tests/e2e/specs/login-form-notices.test.js | 77 ++++++++++++++++++++++ 2 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 tests/e2e/specs/login-form-notices.test.js diff --git a/src/wp-login.php b/src/wp-login.php index b6cafd3c50ae7..dc291c257c142 100644 --- a/src/wp-login.php +++ b/src/wp-login.php @@ -1496,17 +1496,34 @@ function wp_login_viewport_meta() { $rememberme = ! empty( $_POST['rememberme'] ); - $aria_describedby = ''; - $has_errors = $errors->has_errors(); + /* + * login_header() splits the errors into a separate notice per severity and + * can display both at once, so reference every notice that is displayed. + */ + $has_error_notice = false; + $has_message_notice = false; + + foreach ( $errors->get_error_codes() as $code ) { + if ( 'message' === $errors->get_error_data( $code ) ) { + $has_message_notice = true; + } else { + $has_error_notice = true; + } + } - if ( $has_errors ) { - $aria_describedby = ' aria-describedby="login_error"'; + // Ordered to match the order login_header() displays the notices in. + $describedby_ids = array(); + + if ( $has_error_notice ) { + $describedby_ids[] = 'login_error'; } - if ( $has_errors && 'message' === $errors->get_error_data() ) { - $aria_describedby = ' aria-describedby="login-message"'; + if ( $has_message_notice ) { + $describedby_ids[] = 'login-message'; } + $aria_describedby = $describedby_ids ? ' aria-describedby="' . implode( ' ', $describedby_ids ) . '"' : ''; + wp_enqueue_script( 'user-profile' ); wp_enqueue_script( 'wp-tooltip' ); ?> diff --git a/tests/e2e/specs/login-form-notices.test.js b/tests/e2e/specs/login-form-notices.test.js new file mode 100644 index 0000000000000..d0056b7192638 --- /dev/null +++ b/tests/e2e/specs/login-form-notices.test.js @@ -0,0 +1,77 @@ +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +/* + * login_header() displays a separate notice per error severity and can display + * both at once. Every notice displayed must be referenced by the login fields. + */ +test.describe( 'Login form notices', () => { + // The login form is only displayed to logged out users. + test.use( { storageState: { cookies: [], origins: [] } } ); + + async function submitInvalidCredentials( page ) { + await page.locator( '#user_login' ).fill( 'admin' ); + await page.locator( '#user_pass' ).fill( 'incorrect-password' ); + await page.locator( '#wp-submit' ).click(); + } + + test( 'should reference both notices when an error and a message are displayed', async ( { + page, + } ) => { + // Redirecting back to the About page after an update adds a message. + await page.goto( + '/wp-login.php?redirect_to=' + + encodeURIComponent( '/wp-admin/about.php?updated' ) + ); + await submitInvalidCredentials( page ); + + await expect( page.locator( '#login_error' ) ).toBeVisible(); + await expect( page.locator( '#login-message' ) ).toBeVisible(); + + await expect( page.locator( '#user_login' ) ).toHaveAttribute( + 'aria-describedby', + 'login_error login-message' + ); + await expect( page.locator( '#user_pass' ) ).toHaveAttribute( + 'aria-describedby', + 'login_error login-message' + ); + } ); + + test( 'should reference the error notice when only an error is displayed', async ( { + page, + } ) => { + await page.goto( '/wp-login.php' ); + await submitInvalidCredentials( page ); + + await expect( page.locator( '#user_login' ) ).toHaveAttribute( + 'aria-describedby', + 'login_error' + ); + } ); + + test( 'should reference the message notice when only a message is displayed', async ( { + page, + } ) => { + await page.goto( '/wp-login.php?loggedout=true' ); + + await expect( page.locator( '#user_login' ) ).toHaveAttribute( + 'aria-describedby', + 'login-message' + ); + } ); + + test( 'should not reference a notice when none is displayed', async ( { + page, + } ) => { + await page.goto( '/wp-login.php' ); + + expect( + await page + .locator( '#user_login' ) + .getAttribute( 'aria-describedby' ) + ).toBeNull(); + } ); +} );