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’s status is “%s”—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’s status is “%s”—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' ),
+ ),
+ ),
+ );
+ }
+}