Commit ddf8dd862b6 for woocommerce

commit ddf8dd862b6f064bed0adbe8ebacf61b3e2dc472
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Tue Sep 15 13:07:08 2026 +0300

    [tests] Reduce Product Button E2E tests from 7 to 3 (#68595)

    * test(blocks): Reduce Product Button E2E tests from 7 to 2

    The Product Button block ran seven browser titles across a block
    theme spec and a classic theme spec. Three block theme titles checked
    server-rendered markup or script handling that PHPUnit reaches: that
    the button renders, that the legacy add-to-cart script is dequeued in
    a block theme, and that the add-to-cart text filter applies. The
    classic theme spec repeated the block theme journeys.

    Add PHPUnit tests for the filtered button text and the block theme
    dequeue, keep the two add-to-cart journeys (with AJAX on and off),
    and delete the classic theme spec. The AJAX-off journey no longer
    switches the setting back at the end; the per-test database restore
    does that.

    Consolidates the mega-branch slices:
    - Slice 071: test(blocks): Reduce Product Button browser coverage
    - refactor(e2e): simplify migrated Blocks test contracts (this spec
      only)

    Refs TESTOPS-234
    Refs #68046

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * test(blocks): Keep the classic-theme add-to-cart title and name the fixtures

    Under a classic theme the legacy `add-to-cart.js` loads next to the
    block's own script, and nothing at a lower layer exercises the two
    together. Restore that one title, without the unused
    `productCollectionPage` fixture the old file carried and without the
    visibility title the PHP render test owns. The block-theme file is
    unchanged, so the spec count is 7 to 3 rather than 7 to 2.

    Rename the two PHPUnit product fixtures. `Slice 071` is an internal work
    item, not a description of what the fixture is for.

    Refs #68595

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * test(blocks): Stop the Product Button tests restoring base-class state

    setUp() snapshotted two options only to put them back in tearDown(),
    which the transaction rollback already does, so the override goes and
    setUp() just writes them. The script-policy test also cloned the
    wp_enqueue_scripts hook stack, and two tests removed a filter and
    deleted their product in a finally -- all three covered by
    _restore_hooks() and the rollback.

    This class extends WP_UnitTestCase directly, not WC_Unit_Test_Case, so
    clear_wc_singleton_state() never runs for it. That is why the
    WC()->cart restore stays, alongside the WP_Scripts queue and registry,
    the active theme and $GLOBALS['product'] -- none of which the base
    teardown touches.

    Refs TESTOPS-234

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * test(blocks): Fire wp_enqueue_scripts and restore the AJAX setting

    Three review points from the automated pass.

    The changelog said 7 to 2. The supplied files keep three titles: two
    block-theme and one classic-theme.

    The AJAX-disabled journey turned woocommerce_enable_ajax_add_to_cart off
    and left it off. Trunk restored it at the end of the test and this PR
    dropped that line, so it is a regression rather than an inherited leak.
    The restore is back, in a finally so a failing assertion still reaches
    it. The setting is store-wide with no E2E-side rollback, and
    product-button.classic_theme.spec.ts asserts on the "1 in cart" button
    that only appears with AJAX on.

    The script-policy test called dequeue_add_to_cart_scripts() directly,
    which proves the method works but not that firing the hook reaches it.
    It now fires wp_enqueue_scripts, so a later callback re-enqueueing the
    handle would fail the assertion. _restore_hooks() rewinds the
    $wp_actions count and $wp_current_filter that firing leaves behind.

    Checked the new assertion is not vacuous by mutating production twice:
    emptying dequeue_add_to_cart_scripts() fails on the dequeue assertion,
    and removing the add_action() registration fails earlier on the
    callback lookup. ProductButton.php restored byte-identical after both.

    Whole suite on this branch: 15095 tests, 58681 assertions, no failures.

    Refs TESTOPS-234

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * test(blocks): Let WooCommerce enqueue the handle the dequeue test checks

    The test registered 'wc-add-to-cart' itself and then asserted, with the
    message "WooCommerce should register the real legacy handle", that it was
    registered -- restating what the test had just done three lines earlier
    while claiming to verify production.

    Worse, it pinned the same literal handle on both sides of the check. If
    core ever folds the legacy script into another handle,
    dequeue_add_to_cart_scripts() becomes a silent no-op in production and
    this test stays green, which is exactly the regression the deleted
    "should not enqueue add-to-cart-script" E2E title used to catch.

    Turn on woocommerce_enable_ajax_add_to_cart and call
    WC_Frontend_Scripts::load_scripts() instead, so production both registers
    and enqueues the handle. The block's callback is added during render and
    therefore runs after load_scripts() in the same priority-10 bucket, so
    the net effect is still what the test asserts.

    Verified by mutation: emptying dequeue_add_to_cart_scripts() and removing
    its add_action() registration each turn the test red, and production was
    restored byte-identical afterwards.

    Also restore WP_Styles alongside WP_Scripts. Firing wp_enqueue_scripts
    writes to both -- WooCommerce's frontend styles, and core attaching the
    theme's global stylesheet to the shared 'global-styles' handle, which
    outlives the switch_theme() in the same finally.

    Suite at 15095 tests and 58680 assertions, one fewer than before: two
    assertions removed, one added.

    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-product-button b/plugins/woocommerce/changelog/testops-234-product-button
new file mode 100644
index 00000000000..750550983ba
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-234-product-button
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Reduce Product Button E2E tests from 7 to 3; PHPUnit owns the add-to-cart text filter and the block theme script dequeue.
+
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/product-button/product-button.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/product-button/product-button.block_theme.spec.ts
index c1d36e06f0e..38068b4a726 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/product-button/product-button.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/product-button/product-button.block_theme.spec.ts
@@ -13,23 +13,6 @@ test.describe( `${ blockData.name } Block`, () => {
 		await frontendUtils.goToShop();
 	} );

-	test( 'should be visible', async ( { frontendUtils } ) => {
-		const blocks = await frontendUtils.getBlockByName( blockData.slug );
-		await expect( blocks ).toHaveCount(
-			blockData.selectors.frontend.productsToDisplay
-		);
-	} );
-
-	test( 'should not enqueue add-to-cart-script', async ( { page } ) => {
-		let isScriptEnqueued = false;
-		page.on( 'request', ( request ) => {
-			if ( request.url().includes( 'add-to-cart.min.js' ) )
-				isScriptEnqueued = true;
-		} );
-		await page.reload();
-		expect( isScriptEnqueued ).toBe( false );
-	} );
-
 	test( 'should add product to the cart', async ( {
 		frontendUtils,
 		page,
@@ -67,54 +50,47 @@ test.describe( `${ blockData.name } Block`, () => {
 		page,
 		admin,
 	} ) => {
+		// The setting is store-wide and nothing in the E2E lifecycle puts it back, so
+		// product-button.classic_theme.spec.ts would run with AJAX still disabled and
+		// never see the "1 in cart" button it asserts on.
 		await handleAddToCartAjaxSetting( admin, page, {
 			isChecked: true,
 		} );
-		await frontendUtils.goToShop();

-		const blocks = await frontendUtils.getBlockByName( blockData.slug );
-		const block = blocks.first();
-		const button = block.getByRole( 'link' );
+		try {
+			await frontendUtils.goToShop();

-		const productId = await button.getAttribute( 'data-product_id' );
+			const blocks = await frontendUtils.getBlockByName( blockData.slug );
+			const block = blocks.first();
+			const button = block.getByRole( 'link' );

-		const productNameLocator = page.locator( `li.post-${ productId } h2` );
-		await expect( productNameLocator ).not.toBeEmpty();
+			const productId = await button.getAttribute( 'data-product_id' );

-		const productName =
-			( await productNameLocator.textContent() ) as string;
+			const productNameLocator = page.locator(
+				`li.post-${ productId } h2`
+			);
+			await expect( productNameLocator ).not.toBeEmpty();

-		await block.click();
+			const productName =
+				( await productNameLocator.textContent() ) as string;

-		await expect(
-			page.locator( `a[href*="cart=${ productId }"]` )
-		).toBeVisible();
+			await block.click();

-		await frontendUtils.goToCheckout();
+			await expect(
+				page.locator( `a[href*="cart=${ productId }"]` )
+			).toBeVisible();

-		const productElement = page.getByText( productName, {
-			exact: true,
-		} );
+			await frontendUtils.goToCheckout();

-		await expect( productElement ).toBeVisible();
+			const productElement = page.getByText( productName, {
+				exact: true,
+			} );

-		await handleAddToCartAjaxSetting( admin, page, {
-			isChecked: false,
-		} );
-	} );
-
-	test( 'the filter `woocommerce_product_add_to_cart_text` should be applied', async ( {
-		requestUtils,
-		frontendUtils,
-	} ) => {
-		await requestUtils.activatePlugin(
-			'woocommerce-blocks-test-custom-add-to-cart-button-text'
-		);
-		await frontendUtils.goToShop();
-		const blocks = await frontendUtils.getBlockByName( blockData.slug );
-		const buttonWithNewText = blocks.getByText( 'Buy Now' );
-		await expect( buttonWithNewText ).toHaveCount(
-			blockData.selectors.frontend.productsToDisplay
-		);
+			await expect( productElement ).toBeVisible();
+		} finally {
+			await handleAddToCartAjaxSetting( admin, page, {
+				isChecked: false,
+			} );
+		}
 	} );
 } );
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/product-button/product-button.classic_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/product-button/product-button.classic_theme.spec.ts
index 9ecad5f986b..74a5f8af618 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/product-button/product-button.classic_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/product-button/product-button.classic_theme.spec.ts
@@ -1,41 +1,22 @@
 /**
  * External dependencies
  */
-import {
-	expect,
-	test as base,
-	CLASSIC_THEME_SLUG,
-} from '@woocommerce/e2e-utils';
+import { expect, test, CLASSIC_THEME_SLUG } from '@woocommerce/e2e-utils';

 /**
  * Internal dependencies
  */
 import { blockData } from './utils';
-import ProductCollectionPage from '../product-collection/product-collection.page';

-const test = base.extend< { productCollectionPage: ProductCollectionPage } >( {
-	productCollectionPage: async ( { page, admin, editor }, use ) => {
-		const pageObject = new ProductCollectionPage( {
-			page,
-			admin,
-			editor,
-		} );
-		await use( pageObject );
-	},
-} );
 test.describe( `${ blockData.name } Block`, () => {
 	test.beforeEach( async ( { page, requestUtils } ) => {
 		await requestUtils.activateTheme( CLASSIC_THEME_SLUG );
 		await page.goto( '/product-collection/' );
 	} );

-	test( 'should be visible', async ( { frontendUtils } ) => {
-		const blocks = await frontendUtils.getBlockByName( blockData.slug );
-		await expect( blocks ).toHaveCount(
-			blockData.selectors.frontend.productsToDisplay
-		);
-	} );
-
+	// The block's only classic-theme coverage. Here the legacy `add-to-cart.js`
+	// loads next to the block's own script, and nothing at a lower layer
+	// exercises the two together, so this journey stays in the browser.
 	test( 'should add product to the cart', async ( {
 		frontendUtils,
 		page,
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/product-button/utils.ts b/plugins/woocommerce/tests/e2e/tests/blocks/product-button/utils.ts
index 6ed0388ef0b..0e576977d5c 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/product-button/utils.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/product-button/utils.ts
@@ -8,12 +8,6 @@ export const blockData = {
 	name: 'Product Button',
 	slug: 'woocommerce/product-button',
 	mainClass: '.wc-block-product-button',
-	selectors: {
-		frontend: {
-			productsToDisplay: 16,
-		},
-		editor: {},
-	},
 };

 export const handleAddToCartAjaxSetting = async (
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductButton.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductButton.php
index 870ea066977..7d69343184c 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductButton.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductButton.php
@@ -4,6 +4,7 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Tests\Blocks\BlockTypes;

+use Automattic\WooCommerce\Blocks\BlockTypes\ProductButton as ProductButtonBlock;
 use WC_Helper_Product;

 /**
@@ -12,49 +13,185 @@ use WC_Helper_Product;
 class ProductButton extends \WP_UnitTestCase {

 	/**
-	 * Previous WooCommerce options to restore after each test.
+	 * Set up test options.
 	 *
-	 * @var array<string, mixed>
+	 * Both writes land inside the per-test transaction, which tear_down() rolls back.
 	 */
-	private $previous_options = array();
+	protected function setUp(): void {
+		parent::setUp();
+
+		update_option( 'woocommerce_cart_redirect_after_add', 'no' );
+		update_option( 'woocommerce_enable_ajax_add_to_cart', 'no' );
+	}

 	/**
-	 * Set up test options.
+	 * Render the Product Button block for a product.
+	 *
+	 * @param \WC_Product $product Product to render.
+	 * @return string Rendered block markup.
 	 */
-	protected function setUp(): void {
-		parent::setUp();
+	private function render_product_button( \WC_Product $product ): string {
+		return do_blocks(
+			'<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><!-- wp:woocommerce/product-button /--><!-- /wp:woocommerce/single-product -->'
+		);
+	}
+
+	/**
+	 * @testdox Filtered add-to-cart text renders one visible Product Button for the expected product.
+	 */
+	public function test_filtered_add_to_cart_text_renders_visible_button(): void {
+		$had_product      = array_key_exists( 'product', $GLOBALS );
+		$previous_product = $GLOBALS['product'] ?? null;
+		$product          = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'name'          => 'Product Button add-to-cart text fixture',
+				'regular_price' => '10',
+			)
+		);
+		$filter           = static function (): string {
+			return 'Buy Now';
+		};

-		foreach ( array( 'woocommerce_cart_redirect_after_add', 'woocommerce_enable_ajax_add_to_cart' ) as $option_name ) {
-			$this->previous_options[ $option_name ] = get_option( $option_name, null );
-			update_option( $option_name, 'no' );
+		add_filter( 'woocommerce_product_add_to_cart_text', $filter );
+
+		try {
+			$markup    = $this->render_product_button( $product );
+			$processor = new \WP_HTML_Tag_Processor( $markup );
+			$wrappers  = 0;
+
+			while (
+				$processor->next_tag(
+					array(
+						'tag_name'   => 'div',
+						'class_name' => 'wc-block-components-product-button',
+					)
+				)
+			) {
+				++$wrappers;
+			}
+
+			$this->assertSame( 1, $wrappers, 'The registered composition should render exactly one Product Button wrapper.' );
+
+			$processor = new \WP_HTML_Tag_Processor( $markup );
+			$this->assertTrue(
+				$processor->next_tag(
+					array(
+						'tag_name'   => 'a',
+						'class_name' => 'wc-block-components-product-button__button',
+					)
+				),
+				'The non-AJAX registered Product Button should render a link.'
+			);
+			$this->assertSame( (string) $product->get_id(), $processor->get_attribute( 'data-product_id' ), 'The rendered button should identify the fixture product.' );
+			$this->assertStringContainsString( '>Buy Now</span>', $markup, 'The real product text filter should reach escaped Product Button markup.' );
+		} finally {
+			// _restore_hooks() drops the filter and the rollback drops the product;
+			// the product global is this test's own to put back.
+			if ( $had_product ) {
+				$GLOBALS['product'] = $previous_product; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the exact pre-test product global.
+			} else {
+				unset( $GLOBALS['product'] );
+			}
 		}
 	}

 	/**
-	 * Restore test options.
+	 * @testdox A block-theme Product Button dequeues the legacy add-to-cart script through its registered frontend action.
 	 */
-	protected function tearDown(): void {
-		foreach ( $this->previous_options as $option_name => $value ) {
-			if ( null === $value ) {
-				delete_option( $option_name );
+	public function test_block_theme_dequeues_legacy_add_to_cart_script(): void {
+		global $wp_filter;
+
+		$original_theme      = get_stylesheet();
+		$had_product         = array_key_exists( 'product', $GLOBALS );
+		$previous_product    = $GLOBALS['product'] ?? null;
+		$previous_cart       = WC()->cart;
+		$scripts             = wp_scripts();
+		$previous_queue      = $scripts->queue;
+		$previous_registered = $scripts->registered;
+		$styles              = wp_styles();
+		$previous_styles     = $styles->queue;
+		$previous_style_reg  = $styles->registered;
+		$product             = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'name'          => 'Product Button script policy fixture',
+				'regular_price' => '10',
+			)
+		);
+
+		try {
+			switch_theme( 'twentytwentyfour' );
+			$this->assertTrue( wp_is_block_theme(), 'The script policy should run under a real block theme.' );
+
+			// Let WooCommerce enqueue the handle rather than standing in for it. A test
+			// that registers 'wc-add-to-cart' itself pins the same literal on both
+			// sides, so core folding the legacy script into another handle would leave
+			// dequeue_add_to_cart_scripts() a silent no-op with this test still green --
+			// which is the regression the deleted E2E title used to catch.
+			update_option( 'woocommerce_enable_ajax_add_to_cart', 'yes' );
+			\WC_Frontend_Scripts::load_scripts();
+			$this->assertTrue( wp_script_is( 'wc-add-to-cart', 'enqueued' ), 'WooCommerce should enqueue the legacy handle when AJAX add to cart is on.' );
+
+			$markup   = $this->render_product_button( $product );
+			$callback = $this->get_dequeue_callback( $wp_filter['wp_enqueue_scripts'] ?? null );
+			$this->assertNotSame( '', $markup, 'The registered Product Button should render before its frontend action runs.' );
+			$this->assertNotNull( $callback, 'Rendering should queue the Product Button legacy-script callback.' );
+
+			// Fire the hook rather than calling the callback, so the assertion covers the
+			// whole stack: a later callback re-enqueueing the handle would fail here and
+			// would not if the method were invoked on its own. `_restore_hooks()` rewinds
+			// the `$wp_actions` count and `$wp_current_filter` that firing it leaves.
+			// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Firing core's own frontend action, not declaring one.
+			do_action( 'wp_enqueue_scripts' );
+			$this->assertFalse( wp_script_is( 'wc-add-to-cart', 'enqueued' ), 'The registered block-theme callback should dequeue the legacy handle.' );
+		} finally {
+			// _restore_hooks() rewinds wp_enqueue_scripts and the rollback takes the
+			// product back. WP_Scripts, WC()->cart and the product global survive both:
+			// this class extends WP_UnitTestCase, so there is no WooCommerce teardown.
+			$scripts->queue      = $previous_queue;
+			$scripts->registered = $previous_registered;
+			// Firing wp_enqueue_scripts writes to WP_Styles too -- WooCommerce's own
+			// frontend styles, plus core attaching the theme's global stylesheet to the
+			// shared 'global-styles' handle, which outlives the switch_theme() below.
+			$styles->queue      = $previous_styles;
+			$styles->registered = $previous_style_reg;
+			switch_theme( $original_theme );
+			WC()->cart = $previous_cart;
+
+			if ( $had_product ) {
+				$GLOBALS['product'] = $previous_product; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the exact pre-test product global.
 			} else {
-				update_option( $option_name, $value );
+				unset( $GLOBALS['product'] );
 			}
 		}
-
-		parent::tearDown();
 	}

 	/**
-	 * Render the Product Button block for a product.
+	 * Find the Product Button's registered dequeue callback.
 	 *
-	 * @param \WC_Product $product Product to render.
-	 * @return string Rendered block markup.
+	 * @param \WP_Hook|null $hook Enqueue hook after rendering.
+	 * @return array{ProductButtonBlock, string}|null
 	 */
-	private function render_product_button( \WC_Product $product ): string {
-		return do_blocks(
-			'<!-- wp:woocommerce/single-product {"productId":' . $product->get_id() . '} --><!-- wp:woocommerce/product-button /--><!-- /wp:woocommerce/single-product -->'
-		);
+	private function get_dequeue_callback( ?\WP_Hook $hook ): ?array {
+		if ( ! $hook ) {
+			return null;
+		}
+
+		foreach ( $hook->callbacks as $callbacks ) {
+			foreach ( $callbacks as $callback ) {
+				$function = $callback['function'];
+				if (
+					is_array( $function ) &&
+					$function[0] instanceof ProductButtonBlock &&
+					'dequeue_add_to_cart_scripts' === $function[1]
+				) {
+					return $function;
+				}
+			}
+		}
+
+		return null;
 	}

 	/**
@@ -80,11 +217,7 @@ class ProductButton extends \WP_UnitTestCase {
 		};

 		add_filter( 'woocommerce_loop_add_to_cart_args', $filter );
-		try {
-			$this->render_product_button( $product );
-		} finally {
-			remove_filter( 'woocommerce_loop_add_to_cart_args', $filter );
-		}
+		$this->render_product_button( $product );

 		$this->assertIsArray( $filtered_args );
 		$this->assertArrayHasKey( 'rel', $filtered_args['attributes'] );