Commit dcf0f0fa7e8 for woocommerce

commit dcf0f0fa7e87222159a15216c9e69b091f2d3448
Author: Alefe Souza <contact@alefesouza.com>
Date:   Mon Sep 21 12:36:49 2026 -0300

    Add the notices wrapper class to classic notices that rendered outside it (#68716)

diff --git a/plugins/woocommerce/changelog/31485-consistent-notices-wrapper b/plugins/woocommerce/changelog/31485-consistent-notices-wrapper
new file mode 100644
index 00000000000..2913d0f654b
--- /dev/null
+++ b/plugins/woocommerce/changelog/31485-consistent-notices-wrapper
@@ -0,0 +1,4 @@
+Significance: patch
+Type: enhancement
+
+Use consistent markup for classic notices by wrapping them all in `woocommerce-notices-wrapper`.
diff --git a/plugins/woocommerce/client/legacy/js/frontend/checkout.js b/plugins/woocommerce/client/legacy/js/frontend/checkout.js
index d2410a532e3..de08ae10dc9 100644
--- a/plugins/woocommerce/client/legacy/js/frontend/checkout.js
+++ b/plugins/woocommerce/client/legacy/js/frontend/checkout.js
@@ -876,7 +876,7 @@ jQuery( function ( $ ) {
 					// Add notices returned by this event.
 					if ( rendersNotices ) {
 						$form.prepend(
-							'<div class="woocommerce-NoticeGroup woocommerce-NoticeGroup-updateOrderReview">' +
+							'<div class="woocommerce-notices-wrapper woocommerce-NoticeGroup woocommerce-NoticeGroup-updateOrderReview">' +
 								data.messages +
 								'</div>'
 						); // eslint-disable-line max-len
@@ -1140,7 +1140,7 @@ jQuery( function ( $ ) {
 				'.woocommerce-NoticeGroup-checkout, .woocommerce-error, .woocommerce-message, .is-error, .is-success'
 			).remove();
 			wc_checkout_form.$checkout_form.prepend(
-				'<div class="woocommerce-NoticeGroup woocommerce-NoticeGroup-checkout">' +
+				'<div class="woocommerce-notices-wrapper woocommerce-NoticeGroup woocommerce-NoticeGroup-checkout">' +
 					error_message +
 					'</div>'
 			); // eslint-disable-line max-len
diff --git a/plugins/woocommerce/client/legacy/js/frontend/test/checkout-place-order-api.js b/plugins/woocommerce/client/legacy/js/frontend/test/checkout-place-order-api.js
index 16b15195746..0af5fd4cbac 100644
--- a/plugins/woocommerce/client/legacy/js/frontend/test/checkout-place-order-api.js
+++ b/plugins/woocommerce/client/legacy/js/frontend/test/checkout-place-order-api.js
@@ -29,6 +29,9 @@ describe( 'createCheckoutPlaceOrderApi', () => {
 	// Fire a handler that checkout.js delegated off document.body, with `this`
 	// bound to the element that would have matched the selector.
 	let triggerDelegatedBodyEvent;
+	// Fire a handler that checkout.js bound directly on the checkout form, with
+	// `this` bound to the form the way jQuery would.
+	let triggerFormEvent;

 	beforeEach( () => {
 		capturedApi = null;
@@ -100,7 +103,9 @@ describe( 'createCheckoutPlaceOrderApi', () => {

 		$form = {
 			addClass: jest.fn( () => $form ),
+			removeClass: jest.fn( () => $form ),
 			block: jest.fn( () => $form ),
+			unblock: jest.fn( () => $form ),
 			data: jest.fn(),
 			is: jest.fn( () => false ),
 			length: 1,
@@ -153,8 +158,21 @@ describe( 'createCheckoutPlaceOrderApi', () => {
 			triggerHandler: jest.fn( () => true ),
 		};

-		// Add methods to $form for checkout.js initialization
-		$form.on = jest.fn( () => $form );
+		// Add methods to $form for checkout.js initialization. Direct bindings
+		// are recorded so a test can fire them; delegated ones are ignored.
+		const formEventHandlers = {};
+		$form.on = jest.fn( ( event, selectorOrHandler ) => {
+			if ( typeof selectorOrHandler === 'function' ) {
+				formEventHandlers[ event ] = selectorOrHandler;
+			}
+			return $form;
+		} );
+		triggerFormEvent = ( event ) => {
+			if ( ! formEventHandlers[ event ] ) {
+				throw new Error( 'No direct ' + event + ' handler on form.checkout' );
+			}
+			return formEventHandlers[ event ].call( $form );
+		};
 		$form.attr = jest.fn( () => $form );

 		// Default mock for unhandled selectors - provides all common jQuery methods
@@ -650,7 +668,7 @@ describe( 'createCheckoutPlaceOrderApi', () => {

 			expect( $form.prepend ).toHaveBeenCalledWith(
 				expect.stringContaining(
-					'woocommerce-NoticeGroup-updateOrderReview'
+					'<div class="woocommerce-notices-wrapper woocommerce-NoticeGroup woocommerce-NoticeGroup-updateOrderReview">'
 				)
 			);
 			expect( $form.prepend ).toHaveBeenCalledWith(
@@ -828,4 +846,31 @@ describe( 'createCheckoutPlaceOrderApi', () => {
 			expect( jQueryMock.scroll_to_notices ).toHaveBeenCalledTimes( 1 );
 		} );
 	} );
+
+	describe( 'Place order error notices', () => {
+		test( 'should render a failed place order inside the shared notices wrapper', () => {
+			global.window.wc_checkout_params.i18n_checkout_error =
+				'Something went wrong.';
+
+			triggerFormEvent( 'submit' );
+
+			const request = capturedAjaxRequests.find( ( options ) =>
+				options.url.includes( 'wc-ajax=checkout' )
+			);
+			expect( request ).toBeDefined();
+
+			request.error( {}, 'error', 'Internal Server Error' );
+
+			expect( $form.prepend ).toHaveBeenCalledTimes( 1 );
+			expect( $form.prepend ).toHaveBeenCalledWith(
+				expect.stringContaining(
+					'<div class="woocommerce-notices-wrapper woocommerce-NoticeGroup woocommerce-NoticeGroup-checkout">'
+				)
+			);
+			expect( $form.prepend ).toHaveBeenCalledWith(
+				expect.stringContaining( 'Something went wrong.' )
+			);
+			expect( jQueryMock.scroll_to_notices ).toHaveBeenCalledTimes( 1 );
+		} );
+	} );
 } );
diff --git a/plugins/woocommerce/includes/class-wc-ajax.php b/plugins/woocommerce/includes/class-wc-ajax.php
index e655f812cc9..c465c9e0c45 100644
--- a/plugins/woocommerce/includes/class-wc-ajax.php
+++ b/plugins/woocommerce/includes/class-wc-ajax.php
@@ -379,12 +379,12 @@ class WC_AJAX {
 				'fragments' => apply_filters(
 					'woocommerce_update_order_review_fragments',
 					array(
-						'form.woocommerce-checkout' => wc_print_notice(
+						'form.woocommerce-checkout' => '<div class="woocommerce-notices-wrapper">' . wc_print_notice(
 							esc_html__( 'Sorry, your session has expired.', 'woocommerce' ) . ' <a href="' . esc_url( wc_get_page_permalink( 'shop' ) ) . '" class="wc-backward">' . esc_html__( 'Return to shop', 'woocommerce' ) . '</a>',
 							'error',
 							array(),
 							true
-						),
+						) . '</div>',
 					)
 				),
 			)
diff --git a/plugins/woocommerce/includes/class-wc-shortcodes.php b/plugins/woocommerce/includes/class-wc-shortcodes.php
index 5203aedff58..3b20e4032d9 100644
--- a/plugins/woocommerce/includes/class-wc-shortcodes.php
+++ b/plugins/woocommerce/includes/class-wc-shortcodes.php
@@ -667,7 +667,7 @@ class WC_Shortcodes {
 		if ( ! function_exists( 'wc_print_notices' ) ) {
 			return '';
 		}
-		return '<div class="woocommerce">' . wc_print_notices( true ) . '</div>';
+		return '<div class="woocommerce woocommerce-notices-wrapper">' . wc_print_notices( true ) . '</div>';
 	}

 	/**
diff --git a/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-checkout.php b/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-checkout.php
index 1514c1576f8..951fd1562bf 100644
--- a/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-checkout.php
+++ b/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-checkout.php
@@ -72,6 +72,16 @@ class WC_Shortcode_Checkout {
 		}
 	}

+	/**
+	 * Print a notice inside the shared notices wrapper.
+	 *
+	 * @param string $message     Notice text.
+	 * @param string $notice_type Notice type: error, success or notice.
+	 */
+	private static function print_notice( $message, $notice_type ): void {
+		echo '<div class="woocommerce-notices-wrapper">' . wc_print_notice( $message, $notice_type, array(), true ) . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wc_print_notice() returns kses-filtered markup.
+	}
+
 	/**
 	 * Show the pay page.
 	 *
@@ -97,7 +107,7 @@ class WC_Shortcode_Checkout {

 				// Logged out customer does not have permission to pay for this order.
 				if ( ! current_user_can( 'pay_for_order', $order_id ) && ! is_user_logged_in() ) {
-					wc_print_notice( esc_html__( 'Please log in to your account below to continue to the payment form.', 'woocommerce' ), 'notice' );
+					self::print_notice( esc_html__( 'Please log in to your account below to continue to the payment form.', 'woocommerce' ), 'notice' );
 					woocommerce_login_form(
 						array(
 							'redirect' => $order->get_checkout_payment_url(),
@@ -110,7 +120,7 @@ class WC_Shortcode_Checkout {
 				if ( ! $order->get_user_id() && is_user_logged_in() ) {
 					// If order has does not have same billing email then current logged in user then show warning.
 					if ( $order->get_billing_email() !== wp_get_current_user()->user_email ) {
-						wc_print_notice( __( 'You are paying for a guest order. Please continue with payment only if you recognize this order.', 'woocommerce' ), 'error' );
+						self::print_notice( __( 'You are paying for a guest order. Please continue with payment only if you recognize this order.', 'woocommerce' ), 'error' );
 					}
 				}

@@ -240,7 +250,7 @@ class WC_Shortcode_Checkout {
 				);

 			} catch ( Exception $e ) {
-				wc_print_notice( $e->getMessage(), 'error' );
+				self::print_notice( $e->getMessage(), 'error' );
 			}
 		} elseif ( $order_id ) {

@@ -256,13 +266,13 @@ class WC_Shortcode_Checkout {

 				} else {
 					/* translators: %s: order status */
-					wc_print_notice( sprintf( __( 'This order&rsquo;s status is &ldquo;%s&rdquo;&mdash;it cannot be paid for. Please contact us if you need assistance.', 'woocommerce' ), wc_get_order_status_name( $order->get_status() ) ), 'error' );
+					self::print_notice( sprintf( __( 'This order&rsquo;s status is &ldquo;%s&rdquo;&mdash;it cannot be paid for. Please contact us if you need assistance.', 'woocommerce' ), wc_get_order_status_name( $order->get_status() ) ), 'error' );
 				}
 			} else {
-				wc_print_notice( __( 'Sorry, this order is invalid and cannot be paid for.', 'woocommerce' ), 'error' );
+				self::print_notice( __( 'Sorry, this order is invalid and cannot be paid for.', 'woocommerce' ), 'error' );
 			}
 		} else {
-			wc_print_notice( __( 'Invalid order.', 'woocommerce' ), 'error' );
+			self::print_notice( __( 'Invalid order.', 'woocommerce' ), 'error' );
 		}

 		do_action( 'after_woocommerce_pay' );
@@ -356,7 +366,7 @@ class WC_Shortcode_Checkout {
 		// For non-guest orders, require the user to be logged in before showing this page.
 		if ( $verify_known_shoppers && $order_customer_id && get_current_user_id() !== $order_customer_id ) {
 			wc_get_template( 'checkout/order-received.php', array( 'order' => false ) );
-			wc_print_notice( esc_html__( 'Please log in to your account to view this order.', 'woocommerce' ), 'notice' );
+			self::print_notice( esc_html__( 'Please log in to your account to view this order.', 'woocommerce' ), 'notice' );
 			woocommerce_login_form( array( 'redirect' => $order->get_checkout_order_received_url() ) );
 			return;
 		}
diff --git a/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-order-tracking.php b/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-order-tracking.php
index 977090d7860..1204671ca98 100644
--- a/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-order-tracking.php
+++ b/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-order-tracking.php
@@ -45,9 +45,9 @@ class WC_Shortcode_Order_Tracking {
 			$order_email = empty( $_REQUEST['order_email'] ) ? '' : sanitize_email( wp_unslash( $_REQUEST['order_email'] ) );

 			if ( ! $order_id ) {
-				wc_print_notice( __( 'Please enter a valid order ID', 'woocommerce' ), 'error' );
+				$notice = wc_print_notice( __( 'Please enter a valid order ID', 'woocommerce' ), 'error', array(), true );
 			} elseif ( ! $order_email ) {
-				wc_print_notice( __( 'Please enter a valid email address', 'woocommerce' ), 'error' );
+				$notice = wc_print_notice( __( 'Please enter a valid email address', 'woocommerce' ), 'error', array(), true );
 			} else {
 				$order = wc_get_order( apply_filters( 'woocommerce_shortcode_order_tracking_order_id', $order_id ) );

@@ -61,11 +61,15 @@ class WC_Shortcode_Order_Tracking {
 					);
 					return;
 				} else {
-					wc_print_notice( __( 'Sorry, the order could not be found. Please contact us if you are having difficulty finding your order details.', 'woocommerce' ), 'error' );
+					$notice = wc_print_notice( __( 'Sorry, the order could not be found. Please contact us if you are having difficulty finding your order details.', 'woocommerce' ), 'error', array(), true );
 				}
 			}
 		}

+		if ( ! empty( $notice ) ) {
+			echo '<div class="woocommerce-notices-wrapper">' . $notice . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wc_print_notice() returns kses-filtered markup.
+		}
+
 		wc_get_template( 'order/form-tracking.php' );
 	}
 }
diff --git a/plugins/woocommerce/includes/wc-template-functions.php b/plugins/woocommerce/includes/wc-template-functions.php
index 61e6918a56a..a7ae30f526a 100644
--- a/plugins/woocommerce/includes/wc-template-functions.php
+++ b/plugins/woocommerce/includes/wc-template-functions.php
@@ -4466,7 +4466,7 @@ function wc_empty_cart_message() {

 	// Return the notice within a consistent wrapper element. This is targeted by some scripts such as cart.js.
 	// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
-	echo '<div class="wc-empty-cart-message">' . $notice . '</div>';
+	echo '<div class="woocommerce-notices-wrapper wc-empty-cart-message">' . $notice . '</div>';
 }

 /**
diff --git a/plugins/woocommerce/templates/auth/form-grant-access.php b/plugins/woocommerce/templates/auth/form-grant-access.php
index 65ede67e472..46045d4fe89 100644
--- a/plugins/woocommerce/templates/auth/form-grant-access.php
+++ b/plugins/woocommerce/templates/auth/form-grant-access.php
@@ -12,7 +12,7 @@
  *
  * @see https://woocommerce.com/document/template-structure/
  * @package WooCommerce\Templates\Auth
- * @version 8.8.0
+ * @version 11.3.0
  */

 defined( 'ABSPATH' ) || exit;
@@ -27,7 +27,7 @@ defined( 'ABSPATH' ) || exit;
 	?>
 </h1>

-<?php wc_print_notices(); ?>
+<?php woocommerce_output_all_notices(); ?>

 <p>
 	<?php
diff --git a/plugins/woocommerce/templates/auth/form-login.php b/plugins/woocommerce/templates/auth/form-login.php
index 4725e1d21f1..ff11a09ebcf 100644
--- a/plugins/woocommerce/templates/auth/form-login.php
+++ b/plugins/woocommerce/templates/auth/form-login.php
@@ -12,7 +12,7 @@
  *
  * @see     https://woocommerce.com/document/template-structure/
  * @package WooCommerce\Templates\Auth
- * @version 10.5.0
+ * @version 11.3.0
  */

 defined( 'ABSPATH' ) || exit;
@@ -26,7 +26,7 @@ do_action( 'woocommerce_auth_page_header' ); ?>
 	?>
 </h1>

-<?php wc_print_notices(); ?>
+<?php woocommerce_output_all_notices(); ?>

 <p>
 	<?php
diff --git a/plugins/woocommerce/templates/myaccount/form-order-withdrawal.php b/plugins/woocommerce/templates/myaccount/form-order-withdrawal.php
index b3892bf9f2a..4a6524c5a01 100644
--- a/plugins/woocommerce/templates/myaccount/form-order-withdrawal.php
+++ b/plugins/woocommerce/templates/myaccount/form-order-withdrawal.php
@@ -12,7 +12,7 @@
  *
  * @see https://woocommerce.com/document/template-structure/
  * @package WooCommerce\Templates
- * @version 11.1.0
+ * @version 11.3.0
  */

 defined( 'ABSPATH' ) || exit;
@@ -47,7 +47,7 @@ $secondary_button_class = implode( ' ', array_merge( $button_classes, array( 'wo
 ?>

 <div class="woocommerce-order-withdrawal-content">
-	<?php wc_print_notices(); ?>
+	<?php woocommerce_output_all_notices(); ?>

 	<?php if ( 'confirmation' === $screen ) : ?>
 		<p><strong><?php esc_html_e( 'Your withdrawal has been submitted.', 'woocommerce' ); ?></strong></p>
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
index 80d9edef239..d87556ea1ae 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
@@ -3048,6 +3048,34 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
 		$this->assertArrayNotHasKey( $product_two->get_id(), $response, 'A product outside the include allowlist must not be part of the results.' );
 	}

+	/**
+	 * @testdox An expired checkout session should return its notice inside the shared notices wrapper.
+	 */
+	public function test_update_order_review_expired_wraps_notice(): void {
+		$original_post = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Preserve test globals before building the request.
+
+		try {
+			WC()->cart->empty_cart();
+
+			$_POST = array(
+				'security'  => wp_create_nonce( 'update-order-review' ),
+				'post_data' => '',
+			);
+
+			$response = $this->do_ajax( 'woocommerce_update_order_review' );
+
+			$this->assertIsArray( $response, 'The expired checkout update should return a JSON array.' );
+			$this->assertArrayHasKey( 'form.woocommerce-checkout', $response['fragments'] );
+			$this->assertMatchesRegularExpression(
+				'#^<div class="woocommerce-notices-wrapper">\s*<(ul|div) class="[^"]*(woocommerce-error|is-error)[^"]*"[^>]*>.*Sorry, your session has expired\..*</div>$#s',
+				$response['fragments']['form.woocommerce-checkout'],
+				'The replacement fragment should be the expired notice inside the notices wrapper.'
+			);
+		} finally {
+			$_POST = $original_post;
+		}
+	}
+
 	/**
 	 * Does the 'hard work' of triggering an ajax endpoint and capturing the response.
 	 *
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-shortcodes-test.php b/plugins/woocommerce/tests/php/includes/class-wc-shortcodes-test.php
index a90615a8d90..4d12b390550 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-shortcodes-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-shortcodes-test.php
@@ -476,4 +476,22 @@ class WC_Shortcodes_Test extends WC_Unit_Test_Case {

 		$this->assertMatchesRegularExpression( '/This content is password[- ]protected/', $product_page );
 	}
+
+	/**
+	 * @testdox The shop_messages shortcode should render queued notices inside the shared notices wrapper.
+	 */
+	public function test_shop_messages_shortcode_uses_notices_wrapper(): void {
+		wc_clear_notices();
+		wc_add_notice( 'Shortcode notice.', 'success' );
+
+		$markup = WC_Shortcodes::shop_messages();
+
+		$this->assertStringStartsWith(
+			'<div class="woocommerce woocommerce-notices-wrapper">',
+			$markup,
+			'The shortcode should keep the woocommerce class and add the notices wrapper class on the same element.'
+		);
+		$this->assertStringContainsString( 'Shortcode notice.', $markup );
+		$this->assertSame( 0, wc_notice_count(), 'Rendering the shortcode should clear the notice queue.' );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/shortcodes/class-wc-shortcode-checkout-test.php b/plugins/woocommerce/tests/php/includes/shortcodes/class-wc-shortcode-checkout-test.php
index 409da421f6d..6900d4c4472 100644
--- a/plugins/woocommerce/tests/php/includes/shortcodes/class-wc-shortcode-checkout-test.php
+++ b/plugins/woocommerce/tests/php/includes/shortcodes/class-wc-shortcode-checkout-test.php
@@ -199,6 +199,53 @@ class WC_Shortcode_Checkout_Test extends WC_Unit_Test_Case {
 		$this->assertStringContainsString( 'Sorry, this order is invalid and cannot be paid for.', $output );
 	}

+	/**
+	 * @testdox Pay page notices should render inside the shared notices wrapper.
+	 */
+	public function test_order_pay_prints_notices_inside_notices_wrapper(): void {
+		global $wp;
+
+		$order = WC_Helper_Order::create_order( 0 );
+
+		$wp->query_vars['order-pay'] = $order->get_id();
+		$_GET['pay_for_order']       = 'true';
+		$_GET['key']                 = 'not-the-order-key';
+
+		ob_start();
+		WC_Shortcode_Checkout::output( array() );
+		$output = (string) ob_get_clean();
+
+		$this->assertMatchesRegularExpression(
+			'#<div class="woocommerce-notices-wrapper">\s*<(ul|div) class="[^"]*(woocommerce-error|is-error)[^"]*"[^>]*>.*Sorry, this order is invalid and cannot be paid for\..*</div>#s',
+			$output,
+			'The pay page error should be wrapped in the notices wrapper.'
+		);
+	}
+
+	/**
+	 * @testdox The order received login prompt should render inside the shared notices wrapper.
+	 */
+	public function test_order_received_prints_login_notice_inside_notices_wrapper(): void {
+		global $wp;
+
+		$customer = $this->factory->user->create( array( 'role' => 'customer' ) );
+		$order    = WC_Helper_Order::create_order( $customer );
+		wp_set_current_user( 0 );
+
+		$wp->query_vars['order-received'] = $order->get_id();
+		$_GET['key']                      = $order->get_order_key();
+
+		ob_start();
+		WC_Shortcode_Checkout::output( array() );
+		$output = (string) ob_get_clean();
+
+		$this->assertMatchesRegularExpression(
+			'#<div class="woocommerce-notices-wrapper">\s*<div class="[^"]*(woocommerce-info|is-info)[^"]*"[^>]*>.*Please log in to your account to view this order\..*</div>#s',
+			$output,
+			'The order received login prompt should be wrapped in the notices wrapper.'
+		);
+	}
+
 	/**
 	 * @testdox The pay page should pre-select the method a merchant assigned to an admin-created order, keeping the others available.
 	 */
diff --git a/plugins/woocommerce/tests/php/includes/shortcodes/class-wc-shortcode-order-tracking-test.php b/plugins/woocommerce/tests/php/includes/shortcodes/class-wc-shortcode-order-tracking-test.php
new file mode 100644
index 00000000000..6bf55a6da84
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/shortcodes/class-wc-shortcode-order-tracking-test.php
@@ -0,0 +1,63 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * Tests for WC_Shortcode_Order_Tracking.
+ *
+ * @package WooCommerce\Tests\Shortcodes
+ */
+
+/**
+ * Class WC_Shortcode_Order_Tracking_Test.
+ */
+class WC_Shortcode_Order_Tracking_Test extends WC_Unit_Test_Case {
+
+	/**
+	 * The tracking form template builds its action URL from the global post.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$GLOBALS['post'] = $this->factory->post->create_and_get( array( 'post_type' => 'page' ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+	}
+
+	/**
+	 * Restore the request and global post state touched by this test.
+	 */
+	public function tearDown(): void {
+		unset( $_REQUEST['orderid'], $_REQUEST['order_email'], $_REQUEST['woocommerce-order-tracking-nonce'] );
+		unset( $GLOBALS['post'] );
+
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Tracking form errors should render inside the shared notices wrapper, above the form.
+	 */
+	public function test_prints_validation_error_inside_notices_wrapper(): void {
+		$_REQUEST['orderid']                          = '';
+		$_REQUEST['order_email']                      = 'shopper@example.com';
+		$_REQUEST['woocommerce-order-tracking-nonce'] = wp_create_nonce( 'woocommerce-order_tracking' );
+
+		ob_start();
+		WC_Shortcode_Order_Tracking::output( array() );
+		$output = (string) ob_get_clean();
+
+		$this->assertMatchesRegularExpression(
+			'#<div class="woocommerce-notices-wrapper">\s*<(ul|div) class="[^"]*(woocommerce-error|is-error)[^"]*"[^>]*>.*Please enter a valid order ID.*</div>.*<form[^>]*track_order#s',
+			$output,
+			'The validation error should be wrapped and printed before the tracking form.'
+		);
+	}
+
+	/**
+	 * @testdox The tracking form should render no wrapper when nothing was submitted.
+	 */
+	public function test_prints_no_wrapper_without_a_submission(): void {
+		ob_start();
+		WC_Shortcode_Order_Tracking::output( array() );
+		$output = (string) ob_get_clean();
+
+		$this->assertStringNotContainsString( 'woocommerce-notices-wrapper', $output );
+		$this->assertStringContainsString( 'track_order', $output );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/wc-template-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-template-functions-test.php
index 9f5a670e688..c7d8c8720ab 100644
--- a/plugins/woocommerce/tests/php/includes/wc-template-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-template-functions-test.php
@@ -357,4 +357,20 @@ class WC_Template_Functions_Tests extends \WC_Unit_Test_Case {
 		$this->assertSame( array(), $warnings, 'Rendering the attributes should not raise warnings.' );
 		$this->assertStringContainsString( 'Matte', $markup );
 	}
+
+	/**
+	 * @testdox The empty cart message should be wrapped in the shared notices wrapper.
+	 */
+	public function test_empty_cart_message_uses_notices_wrapper(): void {
+		ob_start();
+		wc_empty_cart_message();
+		$markup = (string) ob_get_clean();
+
+		$this->assertStringContainsString(
+			'<div class="woocommerce-notices-wrapper wc-empty-cart-message">',
+			$markup,
+			'The empty cart message should keep its own class and gain the notices wrapper class.'
+		);
+		$this->assertStringContainsString( 'Your cart is currently empty.', $markup );
+	}
 }
diff --git a/plugins/woocommerce/tests/php/templates/NoticesWrapperTemplatesTest.php b/plugins/woocommerce/tests/php/templates/NoticesWrapperTemplatesTest.php
new file mode 100644
index 00000000000..b5bd4c269dc
--- /dev/null
+++ b/plugins/woocommerce/tests/php/templates/NoticesWrapperTemplatesTest.php
@@ -0,0 +1,77 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Templates;
+
+use WC_Unit_Test_Case;
+
+/**
+ * Tests that templates which print their own notices do so inside the shared notices wrapper.
+ */
+class NoticesWrapperTemplatesTest extends WC_Unit_Test_Case {
+
+	/**
+	 * @testdox Templates that print notices should render them inside the shared notices wrapper.
+	 * @dataProvider templates_provider
+	 *
+	 * @param string $template Template path relative to the templates directory.
+	 * @param array  $args     Arguments passed to the template.
+	 */
+	public function test_template_renders_notices_inside_wrapper( string $template, array $args ): void {
+		wc_add_notice( 'Template notice.', 'error' );
+
+		$html = wc_get_template_html( $template, $args );
+
+		$wrapper_start = strpos( $html, '<div class="woocommerce-notices-wrapper">' );
+		$notice_start  = strpos( $html, 'Template notice.' );
+		$wrapper_end   = false === $wrapper_start ? false : strpos( $html, '</div>', $wrapper_start );
+
+		$this->assertNotFalse( $wrapper_start, "{$template} should print the notices wrapper." );
+		$this->assertNotFalse( $notice_start, "{$template} should print the queued notice." );
+		$this->assertTrue(
+			$wrapper_start < $notice_start && $notice_start < $wrapper_end,
+			"{$template} should print the queued notice inside the notices wrapper."
+		);
+		$this->assertSame( 0, wc_notice_count(), "Rendering {$template} should clear the notice queue." );
+	}
+
+	/**
+	 * Templates that print notices, with the arguments they need to render.
+	 *
+	 * @return array<string, array{string, array<string, mixed>}>
+	 */
+	public function templates_provider(): array {
+		$user = wp_get_current_user();
+
+		return array(
+			'auth/form-login.php'                 => array(
+				'auth/form-login.php',
+				array(
+					'app_name'     => 'Test App',
+					'return_url'   => 'https://example.com/return',
+					'redirect_url' => 'https://example.com/authorize',
+				),
+			),
+			'auth/form-grant-access.php'          => array(
+				'auth/form-grant-access.php',
+				array(
+					'app_name'     => 'Test App',
+					'callback_url' => 'https://example.com/callback',
+					'return_url'   => 'https://example.com/return',
+					'scope'        => 'read',
+					'permissions'  => array( 'View coupons' ),
+					'granted_url'  => 'https://example.com/granted',
+					'logout_url'   => 'https://example.com/logout',
+					'user'         => $user,
+				),
+			),
+			'myaccount/form-order-withdrawal.php' => array(
+				'myaccount/form-order-withdrawal.php',
+				array(
+					'screen' => 'confirmation',
+					'data'   => array( 'email' => 'customer@example.com' ),
+				),
+			),
+		);
+	}
+}