Commit 8ca7212b605 for woocommerce
commit 8ca7212b6052981c7521a4df03794da862e2787e
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Mon Sep 14 17:29:55 2026 +0300
[tests] Move 7 Cart and Checkout notice E2E tests to PHPUnit (#68580)
* test(blocks): Move 7 Cart and Checkout notice E2E tests to PHPUnit
The Cart and Checkout notice specs ran ten browser titles, five per
theme, to check which notice template the Notices service picks: block
or classic markup, packaged or overridden by a child theme, with or
without the classic-theme opt-in filter.
That choice is a pure PHP decision in Notices::get_notices_template()
and its hooks. A browser run added a page load, a cart, and a coupon
to each case without proving anything the service does not already
decide on the server, and the coupon inline-notice title ran twice,
once per theme, on the same client code.
Add NoticesTest, which runs the full matrix of opt-in filter, override
kind, and template name for a block theme and a classic theme, plus a
pass-through check for unrelated templates and the KSES allow list.
Keep one browser title per theme as the wiring proof: the child-theme
block-template override on a block theme, the coupon inline notice,
and the opt-in filter on a classic theme. The E2E titles go from 10
to 3.
Consolidates the mega-branch slices:
- Slice blocks-flow-09-notices: test(blocks): Rebalance notice template
coverage; test(blocks): make notice fixture paths portable; test:
Isolate final PHPUnit fixtures
Refs TESTOPS-234
Refs #68046
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(changelog): Correct the retained browser title count
The entry said the change keeps one browser title per theme. It keeps
three: two in the block-theme spec and one in the classic-theme spec.
Refs TESTOPS-234
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/testops-234-cart-checkout-notices b/plugins/woocommerce/changelog/testops-234-cart-checkout-notices
new file mode 100644
index 00000000000..4f69e76e001
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-234-cart-checkout-notices
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Move 7 Cart and Checkout notice template E2E tests to a new NoticesTest PHPUnit matrix, keeping two block-theme browser titles and one classic-theme browser title.
+
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-checkout-block-notices.shopper.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-checkout-block-notices.shopper.block_theme.spec.ts
index 08e43e64a71..b2678d644ef 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-checkout-block-notices.shopper.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-checkout-block-notices.shopper.block_theme.spec.ts
@@ -6,9 +6,7 @@ import {
test as base,
wpCLI,
BLOCK_THEME_SLUG,
- BLOCK_CHILD_THEME_WITH_BLOCK_NOTICES_FILTER_SLUG,
BLOCK_CHILD_THEME_WITH_BLOCK_NOTICES_TEMPLATE_SLUG,
- BLOCK_CHILD_THEME_WITH_CLASSIC_NOTICES_TEMPLATE_SLUG,
} from '@woocommerce/e2e-utils';
/**
@@ -44,54 +42,6 @@ test.describe( 'Shopper → Notice Templates', () => {
await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
} );
- test( 'default block notice templates, except for coupon errors, are visible', async ( {
- frontendUtils,
- page,
- } ) => {
- await frontendUtils.goToCartShortcode();
- await page.getByPlaceholder( 'Coupon code' ).fill( 'testcoupon' );
- await page.getByRole( 'button', { name: 'Apply coupon' } ).click();
-
- await expect(
- page.getByText( 'Coupon code applied successfully.', {
- exact: true,
- } )
- ).toBeVisible();
-
- // We're explicitly checking the CSS classes of the block notices, and that the SVG is visible.
- await expect(
- page.locator( '.wc-block-components-notice-banner.is-success svg' )
- ).toBeVisible();
-
- await page.reload();
- await page.getByPlaceholder( 'Coupon code' ).fill( 'testcoupon' );
- await page.getByRole( 'button', { name: 'Apply coupon' } ).click();
-
- await expect(
- page.getByText( 'Coupon code "testcoupon" already applied!', {
- exact: true,
- } )
- ).toBeVisible();
-
- // We're explicitly checking the CSS classes of the block notices, and that the SVG is hidden.
- await expect(
- page.locator( '.wc-block-components-notice-banner.is-error svg' )
- ).toBeHidden();
-
- await page.getByLabel( 'Remove Polo from cart' ).click();
-
- await expect(
- page.getByText( 'Your cart is currently empty.', {
- exact: true,
- } )
- ).toBeVisible();
-
- // We're explicitly checking the CSS classes of the block notices, and that the SVG is visible.
- await expect(
- page.locator( '.wc-block-components-notice-banner.is-success svg' )
- ).toBeVisible();
- } );
-
test( 'custom block notice templates, except for coupon errors, are visible by template overwrite', async ( {
requestUtils,
frontendUtils,
@@ -110,8 +60,6 @@ test.describe( 'Shopper → Notice Templates', () => {
'BLOCK SUCCESS NOTICE: Coupon code applied successfully.'
)
).toBeVisible();
-
- // We're explicitly checking the CSS classes of the block notices, and that the SVG is visible.
await expect(
page.locator( '.wc-block-components-notice-banner.is-success svg' )
).toBeVisible();
@@ -125,8 +73,6 @@ test.describe( 'Shopper → Notice Templates', () => {
'BLOCK ERROR NOTICE: Coupon code "testcoupon" already applied!'
)
).toBeVisible();
-
- // We're explicitly checking the CSS classes of the block notices, and that the SVG is hidden.
await expect(
page.locator( '.wc-block-components-notice-banner.is-error svg' )
).toBeHidden();
@@ -136,118 +82,6 @@ test.describe( 'Shopper → Notice Templates', () => {
await expect(
page.getByText( 'BLOCK INFO NOTICE: Your cart is currently empty.' )
).toBeVisible();
-
- // We're explicitly checking the CSS classes of the block notices, and that the SVG is visible.
- await expect(
- page.locator( '.wc-block-components-notice-banner.is-success svg' )
- ).toBeVisible();
-
- await requestUtils.activateTheme( BLOCK_THEME_SLUG );
- } );
-
- test( 'classic notice templates, except for coupon errors, are visible by template overwrite', async ( {
- requestUtils,
- frontendUtils,
- page,
- } ) => {
- await requestUtils.activateTheme(
- BLOCK_CHILD_THEME_WITH_CLASSIC_NOTICES_TEMPLATE_SLUG
- );
-
- await frontendUtils.goToCartShortcode();
- await page.getByPlaceholder( 'Coupon code' ).fill( 'testcoupon' );
- await page.getByRole( 'button', { name: 'Apply coupon' } ).click();
-
- await expect(
- page.getByText(
- 'CLASSIC SUCCESS NOTICE: Coupon code applied successfully.'
- )
- ).toBeVisible();
-
- // We're explicitly checking the CSS classes of the classic notices.
- await expect(
- page.locator( '.woocommerce-notices-wrapper .woocommerce-message' )
- ).toBeVisible();
-
- await page.reload();
- await page.getByPlaceholder( 'Coupon code' ).fill( 'testcoupon' );
- await page.getByRole( 'button', { name: 'Apply coupon' } ).click();
-
- await expect(
- page.getByText(
- 'CLASSIC ERROR NOTICE: Coupon code "testcoupon" already applied!'
- )
- ).toBeVisible();
-
- // We're explicitly checking the CSS classes of the classic notices.
- await expect(
- page.locator( '.woocommerce-notices-wrapper .woocommerce-error' )
- ).toBeHidden();
-
- await page.getByLabel( 'Remove Polo from cart' ).click();
-
- await expect(
- page.getByText(
- 'CLASSIC INFO NOTICE: Your cart is currently empty.'
- )
- ).toBeVisible();
-
- // We're explicitly checking the CSS classes of the classic notices.
- await expect(
- page.locator( '.woocommerce-notices-wrapper .woocommerce-info' )
- ).toBeVisible();
-
- await requestUtils.activateTheme( BLOCK_THEME_SLUG );
- } );
-
- test( 'default classic notice templates cannot be triggered by filter', async ( {
- requestUtils,
- frontendUtils,
- page,
- } ) => {
- await requestUtils.activateTheme(
- BLOCK_CHILD_THEME_WITH_BLOCK_NOTICES_FILTER_SLUG
- );
-
- await frontendUtils.goToCartShortcode();
- await page.getByPlaceholder( 'Coupon code' ).fill( 'testcoupon' );
- await page.getByRole( 'button', { name: 'Apply coupon' } ).click();
-
- await expect(
- page.getByText( 'Coupon code applied successfully.', {
- exact: true,
- } )
- ).toBeVisible();
-
- // We're explicitly checking the CSS classes and that the SVG is visible.
- await expect(
- page.locator( '.wc-block-components-notice-banner.is-success svg' )
- ).toBeVisible();
-
- await page.reload();
- await page.getByPlaceholder( 'Coupon code' ).fill( 'testcoupon' );
- await page.getByRole( 'button', { name: 'Apply coupon' } ).click();
-
- await expect(
- page.getByText( 'Coupon code "testcoupon" already applied!', {
- exact: true,
- } )
- ).toBeVisible();
-
- // We're explicitly checking the CSS classes and that the SVG is hidden.
- await expect(
- page.locator( '.wc-block-components-notice-banner.is-error svg' )
- ).toBeHidden();
-
- await page.getByLabel( 'Remove Polo from cart' ).click();
-
- await expect(
- page.getByText( 'Your cart is currently empty.', {
- exact: true,
- } )
- ).toBeVisible();
-
- // We're explicitly checking the CSS classes and that the SVG is visible.
await expect(
page.locator( '.wc-block-components-notice-banner.is-success svg' )
).toBeVisible();
@@ -271,12 +105,9 @@ test.describe( 'Shopper → Notice Templates', () => {
}
)
).toBeVisible();
-
- // We're explicitly checking the CSS classes of the block notices, and that the SVG is hidden.
await expect(
page.locator( '.wc-block-components-notice-banner.is-error svg' )
).toBeHidden();
-
await expect( page.locator( '.coupon-error-notice' ) ).toBeVisible();
} );
} );
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-checkout-block-notices.shopper.classic_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-checkout-block-notices.shopper.classic_theme.spec.ts
index a66c4af8177..bc6a902899f 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-checkout-block-notices.shopper.classic_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-checkout-block-notices.shopper.classic_theme.spec.ts
@@ -6,19 +6,14 @@ import {
test as base,
wpCLI,
CLASSIC_THEME_SLUG,
- CLASSIC_CHILD_THEME_WITH_CLASSIC_NOTICES_TEMPLATE_SLUG,
CLASSIC_CHILD_THEME_WITH_BLOCK_NOTICES_FILTER_SLUG,
- CLASSIC_CHILD_THEME_WITH_BLOCK_NOTICES_TEMPLATE_SLUG,
} from '@woocommerce/e2e-utils';
/**
* Internal dependencies
*/
import { CheckoutPage } from '../checkout/checkout.page';
-import {
- REGULAR_PRICED_PRODUCT_NAME,
- INVALID_COUPON,
-} from '../checkout/constants';
+import { REGULAR_PRICED_PRODUCT_NAME } from '../checkout/constants';
const test = base.extend< { checkoutPageObject: CheckoutPage } >( {
checkoutPageObject: async ( { page }, use ) => {
@@ -46,160 +41,6 @@ test.describe( 'Shopper → Notice Templates', () => {
await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
} );
- test( 'default classic notice templates, except for coupon errors, are visible', async ( {
- frontendUtils,
- page,
- } ) => {
- await frontendUtils.goToCartShortcode();
- await page.getByPlaceholder( 'Coupon code' ).fill( 'testcoupon' );
- await page.getByRole( 'button', { name: 'Apply coupon' } ).click();
-
- await expect(
- page.getByText( 'Coupon code applied successfully.', {
- exact: true,
- } )
- ).toBeVisible();
-
- // We're explicitly checking the CSS classes of the classic notices.
- await expect(
- page.locator( '.woocommerce-notices-wrapper .woocommerce-message' )
- ).toBeVisible();
-
- await page.reload();
- await page.getByPlaceholder( 'Coupon code' ).fill( 'testcoupon' );
- await page.getByRole( 'button', { name: 'Apply coupon' } ).click();
-
- await expect(
- page.getByText( 'Coupon code "testcoupon" already applied!', {
- exact: true,
- } )
- ).toBeVisible();
-
- // We're explicitly checking the CSS classes of the classic notices.
- await expect(
- page.locator( '.woocommerce-notices-wrapper .woocommerce-error' )
- ).toBeHidden();
-
- await page.getByLabel( 'Remove Polo from cart' ).click();
-
- await expect(
- page.getByText( 'Your cart is currently empty.', { exact: true } )
- ).toBeVisible();
-
- // We're explicitly checking the CSS classes of the classic notices.
- await expect(
- page.locator( '.woocommerce-notices-wrapper .woocommerce-info' )
- ).toBeVisible();
- } );
-
- test( 'custom classic notice templates, except for coupon errors, are visible by template overwrite', async ( {
- requestUtils,
- frontendUtils,
- page,
- } ) => {
- await requestUtils.activateTheme(
- CLASSIC_CHILD_THEME_WITH_CLASSIC_NOTICES_TEMPLATE_SLUG
- );
-
- await frontendUtils.goToCartShortcode();
- await page.getByPlaceholder( 'Coupon code' ).fill( 'testcoupon' );
- await page.getByRole( 'button', { name: 'Apply coupon' } ).click();
-
- await expect(
- page.getByText(
- 'CLASSIC SUCCESS NOTICE: Coupon code applied successfully.'
- )
- ).toBeVisible();
-
- // We're explicitly checking the CSS classes of the classic notices.
- await expect(
- page.locator( '.woocommerce-notices-wrapper .woocommerce-message' )
- ).toBeVisible();
-
- await page.reload();
- await page.getByPlaceholder( 'Coupon code' ).fill( 'testcoupon' );
- await page.getByRole( 'button', { name: 'Apply coupon' } ).click();
-
- await expect(
- page.getByText(
- 'CLASSIC ERROR NOTICE: Coupon code "testcoupon" already applied!'
- )
- ).toBeVisible();
-
- // We're explicitly checking the CSS classes of the classic notices.
- await expect(
- page.locator( '.woocommerce-notices-wrapper .woocommerce-error' )
- ).toBeHidden();
-
- await page.getByLabel( 'Remove Polo from cart' ).click();
-
- await expect(
- page.getByText(
- 'CLASSIC INFO NOTICE: Your cart is currently empty.'
- )
- ).toBeVisible();
-
- // We're explicitly checking the CSS classes of the classic notices.
- await expect(
- page.locator( '.woocommerce-notices-wrapper .woocommerce-info' )
- ).toBeVisible();
-
- await requestUtils.activateTheme( CLASSIC_THEME_SLUG );
- } );
-
- test( 'custom block notice templates, except for coupon errors, are visible by template overwrite', async ( {
- requestUtils,
- frontendUtils,
- page,
- } ) => {
- await requestUtils.activateTheme(
- CLASSIC_CHILD_THEME_WITH_BLOCK_NOTICES_TEMPLATE_SLUG
- );
-
- await frontendUtils.goToCartShortcode();
- await page.getByPlaceholder( 'Coupon code' ).fill( 'testcoupon' );
- await page.getByRole( 'button', { name: 'Apply coupon' } ).click();
-
- await expect(
- page.getByText(
- 'BLOCK SUCCESS NOTICE: Coupon code applied successfully.'
- )
- ).toBeVisible();
-
- // We're explicitly checking the CSS classes of the block notices, and that the SVG is visible.
- await expect(
- page.locator( '.wc-block-components-notice-banner.is-success svg' )
- ).toBeVisible();
-
- await page.reload();
- await page.getByPlaceholder( 'Coupon code' ).fill( 'testcoupon' );
- await page.getByRole( 'button', { name: 'Apply coupon' } ).click();
-
- await expect(
- page.getByText(
- 'BLOCK ERROR NOTICE: Coupon code "testcoupon" already applied!'
- )
- ).toBeVisible();
-
- // We're explicitly checking the CSS classes of the block notices, and that the SVG is hidden.
- await expect(
- page.locator( '.wc-block-components-notice-banner.is-error svg' )
- ).toBeHidden();
-
- await page.getByLabel( 'Remove Polo from cart' ).click();
-
- await expect(
- page.getByText( 'BLOCK INFO NOTICE: Your cart is currently empty.' )
- ).toBeVisible();
-
- // We're explicitly checking the CSS classes of the block notices, and that the SVG is visible.
- await expect(
- page.locator( '.wc-block-components-notice-banner.is-success svg' )
- ).toBeVisible();
-
- await requestUtils.activateTheme( CLASSIC_THEME_SLUG );
- } );
-
test( 'default block notice templates, except for coupon errors, are visible by filter', async ( {
requestUtils,
frontendUtils,
@@ -216,8 +57,6 @@ test.describe( 'Shopper → Notice Templates', () => {
await expect(
page.getByText( 'Coupon code applied successfully.' )
).toBeVisible();
-
- // We're explicitly checking the CSS classes and that the SVG is visible.
await expect(
page.locator( '.wc-block-components-notice-banner.is-success svg' )
).toBeVisible();
@@ -229,8 +68,6 @@ test.describe( 'Shopper → Notice Templates', () => {
await expect(
page.getByText( 'Coupon code "testcoupon" already applied!' )
).toBeVisible();
-
- // We're explicitly checking the CSS classes and that the SVG is hidden.
await expect(
page.locator( '.wc-block-components-notice-banner.is-error svg' )
).toBeHidden();
@@ -240,37 +77,10 @@ test.describe( 'Shopper → Notice Templates', () => {
await expect(
page.getByText( 'Your cart is currently empty.' )
).toBeVisible();
-
- // We're explicitly checking the CSS classes and that the SVG is visible.
await expect(
page.locator( '.wc-block-components-notice-banner.is-success svg' )
).toBeVisible();
await requestUtils.activateTheme( CLASSIC_THEME_SLUG );
} );
-
- test( 'coupon inline notice is visible', async ( {
- frontendUtils,
- page,
- } ) => {
- await frontendUtils.goToCartShortcode();
- await page.getByPlaceholder( 'Coupon code' ).fill( INVALID_COUPON );
- await page.getByRole( 'button', { name: 'Apply coupon' } ).click();
-
- await expect(
- page.getByText(
- `Coupon "${ INVALID_COUPON }" cannot be applied because it does not exist.`,
- {
- exact: true,
- }
- )
- ).toBeVisible();
-
- // We're explicitly checking the CSS classes of the block notices, and that the SVG is hidden.
- await expect(
- page.locator( '.wc-block-components-notice-banner.is-error svg' )
- ).toBeHidden();
-
- await expect( page.locator( '.coupon-error-notice' ) ).toBeVisible();
- } );
} );
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/NoticesTest.php b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/NoticesTest.php
new file mode 100644
index 00000000000..639f8fe51fa
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/NoticesTest.php
@@ -0,0 +1,486 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\Domain\Services;
+
+use Automattic\WooCommerce\Blocks\Domain\Package;
+use Automattic\WooCommerce\Blocks\Domain\Services\Notices;
+use WC_Unit_Test_Case;
+use WP_Hook;
+
+/**
+ * Tests for the Notices service.
+ */
+class NoticesTest extends WC_Unit_Test_Case {
+
+ /**
+ * Original active theme stylesheet slug.
+ *
+ * @var string|null
+ */
+ private static $original_theme = null;
+
+ /**
+ * Hooks restored after each test.
+ *
+ * @var string[]
+ */
+ private const SNAPSHOT_HOOKS = array(
+ 'after_setup_theme',
+ 'wc_get_template',
+ 'wp_head',
+ 'woocommerce_kses_notice_allowed_tags',
+ 'woocommerce_use_block_notices_in_classic_theme',
+ 'doing_it_wrong_trigger_error',
+ );
+
+ /**
+ * Action counts restored after each test.
+ *
+ * @var string[]
+ */
+ private const SNAPSHOT_ACTION_COUNTS = array(
+ 'after_setup_theme',
+ 'wp_head',
+ );
+
+ /**
+ * Original hook state.
+ *
+ * @var array<string, WP_Hook|null>
+ */
+ private $hook_snapshots = array();
+
+ /**
+ * Original action counts.
+ *
+ * @var array<string, int|null>
+ */
+ private $action_counts = array();
+
+ /**
+ * Original style queue state.
+ *
+ * @var array<string, mixed>
+ */
+ private $style_snapshot = array();
+
+ /**
+ * Theme directories registered before each test.
+ *
+ * @var string[]
+ */
+ private $theme_directories = array();
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ global $wp_theme_directories;
+
+ $this->theme_directories = $wp_theme_directories;
+ register_theme_directory( trailingslashit( WC_ABSPATH ) . 'tests/e2e/themes/blocks' );
+ search_theme_directories( true );
+ if ( null === self::$original_theme ) {
+ self::$original_theme = get_stylesheet();
+ }
+ $this->snapshot_hooks();
+ $this->snapshot_action_counts();
+ $this->snapshot_styles();
+ $this->reset_wc_blocks_style_queue();
+ wc_clear_template_cache();
+ }
+
+ /**
+ * Restore test fixtures.
+ */
+ public function tearDown(): void {
+ global $wp_theme_directories;
+
+ if ( null !== self::$original_theme && get_stylesheet() !== self::$original_theme ) {
+ switch_theme( self::$original_theme );
+ }
+
+ $this->restore_hooks();
+ $this->restore_action_counts();
+ $this->restore_styles();
+ wc_clear_template_cache();
+ $wp_theme_directories = $this->theme_directories; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the exact theme roots registered before this test.
+ search_theme_directories( true );
+
+ parent::tearDown();
+ }
+
+ /**
+ * Restore the original theme after the class completes.
+ */
+ public static function tearDownAfterClass(): void {
+ if ( null !== self::$original_theme && get_stylesheet() !== self::$original_theme ) {
+ switch_theme( self::$original_theme );
+ }
+ }
+
+ /**
+ * @testdox Notices activate the expected hooks, locate the expected template, and enqueue styles for every block-theme branch.
+ */
+ public function test_block_theme_notice_template_matrix(): void {
+ $this->setExpectedIncorrectUsage( 'WP_Block_Templates_Registry::register' );
+ add_filter( 'doing_it_wrong_trigger_error', array( $this, 'suppress_block_template_registry_notice' ), 10, 4 );
+
+ foreach ( array( false, true ) as $opt_in_filter ) {
+ foreach ( array( 'none', 'block', 'classic' ) as $fixture_kind ) {
+ foreach ( array( 'notices/error.php', 'notices/notice.php', 'notices/success.php' ) as $template_name ) {
+ $this->assert_notice_template_row( 'twentytwentyfour', $opt_in_filter, $fixture_kind, $template_name );
+ }
+ }
+ }
+ }
+
+ /**
+ * @testdox Notices activate the expected hooks, locate the expected template, and enqueue styles for every classic-theme branch.
+ */
+ public function test_classic_theme_notice_template_matrix(): void {
+ foreach ( array( false, true ) as $opt_in_filter ) {
+ foreach ( array( 'none', 'block', 'classic' ) as $fixture_kind ) {
+ foreach ( array( 'notices/error.php', 'notices/notice.php', 'notices/success.php' ) as $template_name ) {
+ $this->assert_notice_template_row( 'storefront', $opt_in_filter, $fixture_kind, $template_name );
+ }
+ }
+ }
+ }
+
+ /**
+ * @testdox Notices leave unrelated templates untouched.
+ */
+ public function test_unrelated_templates_pass_through_unchanged(): void {
+ $base_template = wc_locate_template( 'cart/cart-empty.php' );
+ $resolved = ( new Notices( new Package( 'test', WC_ABSPATH ) ) )->get_notices_template(
+ $base_template,
+ 'cart/cart-empty.php',
+ array(),
+ '',
+ trailingslashit( WC_ABSPATH ) . 'templates/'
+ );
+
+ $this->assertSame( $base_template, $resolved, 'The service should not replace templates outside the three notice files.' );
+ $this->assertFalse( wp_style_is( 'wc-blocks-style', 'enqueued' ), 'Passing through an unrelated template must not enqueue block notice styles.' );
+ }
+
+ /**
+ * @testdox Notices preserve existing allowed tags and add the exact SVG/path allow-list.
+ */
+ public function test_notice_kses_tags_preserve_existing_tags_and_add_svg_support(): void {
+ $sut = new Notices( new Package( 'test', WC_ABSPATH ) );
+ $allowed_tags = array(
+ 'a' => array(
+ 'href' => true,
+ ),
+ );
+
+ $sut->init();
+
+ /**
+ * Run the notices allow-list filter through the live callback stack.
+ *
+ * @since 11.1.0
+ */
+ $result = apply_filters( 'woocommerce_kses_notice_allowed_tags', $allowed_tags );
+
+ $this->assertArrayHasKey( 'a', $result, 'Existing allow-listed tags should be preserved.' );
+ $this->assertSame( $allowed_tags['a'], $result['a'], 'Existing allow-listed tag attributes should remain unchanged.' );
+ $this->assertSame(
+ array(
+ 'aria-hidden' => true,
+ 'xmlns' => true,
+ 'width' => true,
+ 'height' => true,
+ 'viewbox' => true,
+ 'focusable' => true,
+ ),
+ $result['svg'] ?? null,
+ 'The filter should add the exact SVG attributes used by block notices.'
+ );
+ $this->assertSame(
+ array(
+ 'd' => true,
+ ),
+ $result['path'] ?? null,
+ 'The filter should add the exact SVG path attributes used by block notices.'
+ );
+ }
+
+ /**
+ * Snapshot the hooks touched by the suite.
+ */
+ private function snapshot_hooks(): void {
+ foreach ( self::SNAPSHOT_HOOKS as $hook_name ) {
+ $this->hook_snapshots[ $hook_name ] = isset( $GLOBALS['wp_filter'][ $hook_name ] ) ? clone $GLOBALS['wp_filter'][ $hook_name ] : null;
+ }
+ }
+
+ /**
+ * Restore the hooks touched by the suite.
+ */
+ private function restore_hooks(): void {
+ foreach ( self::SNAPSHOT_HOOKS as $hook_name ) {
+ if ( $this->hook_snapshots[ $hook_name ] instanceof WP_Hook ) {
+ $GLOBALS['wp_filter'][ $hook_name ] = clone $this->hook_snapshots[ $hook_name ]; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the exact hook stack captured at setUp.
+ } elseif ( isset( $GLOBALS['wp_filter'][ $hook_name ] ) ) {
+ unset( $GLOBALS['wp_filter'][ $hook_name ] ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Remove callbacks registered by this test.
+ }
+ }
+ }
+
+ /**
+ * Snapshot the action counts touched by the suite.
+ */
+ private function snapshot_action_counts(): void {
+ foreach ( self::SNAPSHOT_ACTION_COUNTS as $action_name ) {
+ $this->action_counts[ $action_name ] = $GLOBALS['wp_actions'][ $action_name ] ?? null;
+ }
+ }
+
+ /**
+ * Restore the action counts touched by the suite.
+ */
+ private function restore_action_counts(): void {
+ foreach ( self::SNAPSHOT_ACTION_COUNTS as $action_name ) {
+ if ( null !== $this->action_counts[ $action_name ] ) {
+ $GLOBALS['wp_actions'][ $action_name ] = $this->action_counts[ $action_name ]; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the action count captured at setUp.
+ } elseif ( isset( $GLOBALS['wp_actions'][ $action_name ] ) ) {
+ unset( $GLOBALS['wp_actions'][ $action_name ] ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Remove counts introduced by this test.
+ }
+ }
+ }
+
+ /**
+ * Snapshot the style queue state touched by the suite.
+ */
+ private function snapshot_styles(): void {
+ $wp_styles = wp_styles();
+ $this->style_snapshot = array(
+ 'queue' => $wp_styles->queue,
+ 'done' => $wp_styles->done,
+ 'to_do' => $wp_styles->to_do,
+ 'groups' => $wp_styles->groups,
+ 'args' => $wp_styles->args,
+ );
+ }
+
+ /**
+ * Restore the style queue state touched by the suite.
+ */
+ private function restore_styles(): void {
+ $wp_styles = wp_styles();
+ $wp_styles->queue = $this->style_snapshot['queue'];
+ $wp_styles->done = $this->style_snapshot['done'];
+ $wp_styles->to_do = $this->style_snapshot['to_do'];
+ $wp_styles->groups = $this->style_snapshot['groups'];
+ $wp_styles->args = $this->style_snapshot['args'];
+ }
+
+ /**
+ * Remove wc-blocks-style from the working queue so each test starts clean.
+ */
+ private function reset_wc_blocks_style_queue(): void {
+ $wp_styles = wp_styles();
+ $wp_styles->queue = array_values( array_diff( $wp_styles->queue, array( 'wc-blocks-style' ) ) );
+ $wp_styles->done = array_values( array_diff( $wp_styles->done, array( 'wc-blocks-style' ) ) );
+ $wp_styles->to_do = array_values( array_diff( $wp_styles->to_do, array( 'wc-blocks-style' ) ) );
+ unset( $wp_styles->args['wc-blocks-style'] );
+ }
+
+ /**
+ * Assert one row from the notices matrix.
+ *
+ * @param string $theme_slug Active base theme slug.
+ * @param bool $opt_in_filter Whether the classic-theme opt-in filter returns true.
+ * @param string $fixture_kind Fixture type.
+ * @param string $template_name Notice template to resolve.
+ */
+ private function assert_notice_template_row( string $theme_slug, bool $opt_in_filter, string $fixture_kind, string $template_name ): void {
+ $this->restore_hooks();
+ $this->restore_action_counts();
+ $this->restore_styles();
+ $this->reset_wc_blocks_style_queue();
+ wc_clear_template_cache();
+
+ $sut = new Notices( new Package( 'test', WC_ABSPATH ) );
+ $active_theme_slug = $this->get_active_theme_slug( $theme_slug, $fixture_kind );
+
+ if ( 'twentytwentyfour' === $theme_slug ) {
+ add_filter( 'doing_it_wrong_trigger_error', array( $this, 'suppress_block_template_registry_notice' ), 10, 4 );
+ }
+
+ switch_theme( $active_theme_slug );
+ add_filter(
+ 'woocommerce_use_block_notices_in_classic_theme',
+ $opt_in_filter ? '__return_true' : '__return_false'
+ );
+
+ $sut->init();
+
+ $this->assertSame(
+ 10,
+ has_filter( 'woocommerce_kses_notice_allowed_tags', array( $sut, 'add_kses_notice_allowed_tags' ) ),
+ 'The SVG/path allow-list callback should always be registered at priority 10.'
+ );
+
+ /**
+ * Trigger the service's theme-setup gate after init() has registered its callbacks.
+ *
+ * @since 11.1.0
+ */
+ do_action( 'after_setup_theme' );
+
+ $service_is_active = wp_is_block_theme() || $opt_in_filter;
+ $expected_path = $this->get_expected_template_path( $theme_slug, $fixture_kind, $template_name, $service_is_active );
+ $result = $this->resolve_notice_template( $template_name );
+
+ $this->assertSame(
+ $service_is_active ? 10 : false,
+ has_filter( 'wc_get_template', array( $sut, 'get_notices_template' ) ),
+ 'The wc_get_template callback should match the theme/filter activation branch.'
+ );
+ $this->assertSame(
+ $service_is_active ? 10 : false,
+ has_action( 'wp_head', array( $sut, 'enqueue_notice_styles' ) ),
+ 'The wp_head enqueue callback should match the theme/filter activation branch.'
+ );
+ $this->assertSame(
+ $expected_path,
+ $result['located'],
+ 'The resolved template path should follow the exact theme/filter/fixture matrix.'
+ );
+ $this->assertSame(
+ $service_is_active && 'none' === $fixture_kind,
+ $result['style_enqueued_after_resolution'],
+ 'The packaged block template should enqueue wc-blocks-style during resolution only when no child override handled the template.'
+ );
+
+ ob_start();
+ /**
+ * Mirror the front-end head render so the shared stylesheet enqueue can run.
+ *
+ * @since 11.1.0
+ */
+ do_action( 'wp_head' );
+ ob_end_clean();
+
+ $this->assertSame(
+ $service_is_active,
+ wp_style_is( 'wc-blocks-style', 'enqueued' ),
+ 'The shared notice stylesheet should only be enqueued after wp_head when block notices are active.'
+ );
+ }
+
+ /**
+ * Resolve a notice template through the real Woo template loader.
+ *
+ * @param string $template_name Template name.
+ * @return array{located: string, style_enqueued_after_resolution: bool}
+ */
+ private function resolve_notice_template( string $template_name ): array {
+ $captured_template = '';
+ $capture_callback = static function ( $name, $template_path, $located ) use ( $template_name, &$captured_template ): void {
+ if ( $template_name === $name ) {
+ $captured_template = $located;
+ }
+ };
+
+ wc_clear_template_cache();
+ add_action( 'woocommerce_before_template_part', $capture_callback, 10, 3 );
+
+ try {
+ wc_get_template_html(
+ $template_name,
+ array(
+ 'notices' => array(
+ array(
+ 'notice' => 'Test notice',
+ 'data' => array(),
+ ),
+ ),
+ )
+ );
+ } finally {
+ remove_action( 'woocommerce_before_template_part', $capture_callback, 10 );
+ }
+
+ $this->assertNotSame( '', $captured_template, 'Resolving the notice template should capture the located path from the real Woo template loader.' );
+
+ return array(
+ 'located' => $captured_template,
+ 'style_enqueued_after_resolution' => wp_style_is( 'wc-blocks-style', 'enqueued' ),
+ );
+ }
+
+ /**
+ * Resolve the active theme slug for a matrix row.
+ *
+ * @param string $theme_slug Active theme slug.
+ * @param string $fixture_kind Fixture type.
+ * @return string
+ */
+ private function get_active_theme_slug( string $theme_slug, string $fixture_kind ): string {
+ if ( 'none' === $fixture_kind ) {
+ return $theme_slug;
+ }
+
+ return 'twentytwentyfour' === $theme_slug
+ ? sprintf( 'twentytwentyfour-child__%s-notices-template', $fixture_kind )
+ : sprintf( 'storefront-child__%s-notices-template', $fixture_kind );
+ }
+
+ /**
+ * Compute the exact expected resolved template path for a matrix row.
+ *
+ * @param string $theme_slug Active theme slug.
+ * @param string $fixture_kind Fixture type.
+ * @param string $template_name Template name.
+ * @param bool $service_is_active Whether block notices are active.
+ * @return string
+ */
+ private function get_expected_template_path(
+ string $theme_slug,
+ string $fixture_kind,
+ string $template_name,
+ bool $service_is_active
+ ): string {
+ if ( 'none' !== $fixture_kind ) {
+ $active_theme_slug = $this->get_active_theme_slug( $theme_slug, $fixture_kind );
+
+ return trailingslashit( get_theme_root( $active_theme_slug ) ) . $active_theme_slug . '/woocommerce/' . $template_name;
+ }
+
+ if ( $service_is_active ) {
+ return trailingslashit( WC_ABSPATH ) . 'templates/block-' . $template_name;
+ }
+
+ return trailingslashit( WC_ABSPATH ) . 'templates/' . $template_name;
+ }
+
+ /**
+ * Suppress the unrelated block template registry duplicate warning during block-theme setup.
+ *
+ * @param bool $trigger_error Whether WordPress should trigger the warning.
+ * @param string $function_name Function reporting incorrect usage.
+ * @param string $message Warning message.
+ * @param string $version Version that introduced the warning.
+ * @return bool
+ */
+ public function suppress_block_template_registry_notice( bool $trigger_error, string $function_name, string $message, string $version ): bool {
+ unset( $version );
+
+ if (
+ 'WP_Block_Templates_Registry::register' === $function_name
+ && str_contains( $message, 'Template "woocommerce//archive-product" is already registered.' )
+ ) {
+ return false;
+ }
+
+ return $trigger_error;
+ }
+}