Commit 2836af6751a for woocommerce

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

    [tests] Move 9 Product Collection compatibility hook checks to PHPUnit (#68610)

    * test(blocks): Move 9 collection compatibility hook checks to PHPUnit

    The Product Collection compatibility layer spec ran nine browser
    titles, one per hook position. Each loaded the shop archive with a
    test plugin and checked one hook's element count and text. Which
    position a hook takes is a declaration in the layer's hook data, and
    placing its buffer before or after the block it belongs to is a PHP
    decision that needs no browser.

    Add a PHPUnit test for that: a provider covers the nine hook
    positions these templates use, and two more tests cover marking the
    inherited Product Collection tree and skipping unsupported contexts.
    Keep two browser titles, which now check the rendered order: the
    global hooks around the collection and the loop, and the per-product
    hooks inside every product.

    Consolidates the mega-branch slice:
    - Slice 054: test(blocks): Move collection compatibility hooks below
      E2E

    Refs TESTOPS-234
    Refs #68046

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

    * test(blocks): Drop the hook snapshot the base class already restores

    `WP_UnitTestCase_Base::_backup_hooks()` clones every `wp_filter` entry
    once per process and `_restore_hooks()` puts them all back on every
    tear down, so cloning fifteen of them by hand restated work the base
    class owns.

    Measured rather than assumed: a probe reading the callback counts for
    `woocommerce_before_main_content`, `render_block` and `template_include`
    at the start of a later test returns 14, 61 and 6 both with the snapshot
    and without it.

    The class is still 11 tests and 66 assertions, and flipping
    `woocommerce_before_main_content` to `after` in the layer's hook data
    still fails only the matching row.

    Refs #68610

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

    * test(blocks): Drop the wp_actions snapshot the base class also restores

    `_restore_hooks()` puts back `wp_actions`, `wp_filters` and
    `wp_current_filter` alongside `wp_filter`, from the same once-per-process
    baseline, so capturing eleven action counts by hand restated that too.
    Nothing in the tests reads those counts; the block was teardown only.

    Removing it orphaned `COMPATIBILITY_HOOKS`, whose only remaining use was
    feeding these two snapshots, so that goes as well. What is left in the
    tear down is the query globals and the shop page, which the base class
    does not own.

    The class is still 11 tests and 66 assertions, and flipping
    `woocommerce_before_main_content` to `after` in the layer's hook data
    still fails only the matching row.

    Refs #68610

    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-collection-compatibility b/plugins/woocommerce/changelog/testops-234-product-collection-compatibility
new file mode 100644
index 00000000000..d6cbddeec02
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-234-product-collection-compatibility
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Move 9 collection compatibility hook checks to PHPUnit; the spec keeps two rendered-order browser titles.
+
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/compatibility-layer.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/compatibility-layer.block_theme.spec.ts
index 49d17315790..e5eaaa2ce08 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/compatibility-layer.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/compatibility-layer.block_theme.spec.ts
@@ -8,73 +8,6 @@ import { test as base, expect } from '@woocommerce/e2e-utils';
  */
 import ProductCollectionPage from './product-collection.page';

-type Scenario = {
-	title: string;
-	dataTestId: string;
-	content: string;
-	amount: number;
-};
-
-const singleOccurrenceScenarios: Scenario[] = [
-	{
-		title: 'Before Main Content',
-		dataTestId: 'woocommerce_before_main_content',
-		content: 'Hook: woocommerce_before_main_content',
-		amount: 1,
-	},
-	{
-		title: 'After Main Content',
-		dataTestId: 'woocommerce_after_main_content',
-		content: 'Hook: woocommerce_after_main_content',
-		amount: 1,
-	},
-	{
-		title: 'Before Shop Loop',
-		dataTestId: 'woocommerce_before_shop_loop',
-		content: 'Hook: woocommerce_before_shop_loop',
-		amount: 1,
-	},
-	{
-		title: 'After Shop Loop',
-		dataTestId: 'woocommerce_after_shop_loop',
-		content: 'Hook: woocommerce_after_shop_loop',
-		amount: 1,
-	},
-];
-
-const multipleOccurrenceScenarios: Scenario[] = [
-	{
-		title: 'Before Shop Loop Item Title',
-		dataTestId: 'woocommerce_before_shop_loop_item_title',
-		content: 'Hook: woocommerce_before_shop_loop_item_title',
-		amount: 16,
-	},
-	{
-		title: 'Shop Loop Item Title',
-		dataTestId: 'woocommerce_shop_loop_item_title',
-		content: 'Hook: woocommerce_shop_loop_item_title',
-		amount: 16,
-	},
-	{
-		title: 'After Shop Loop Item Title',
-		dataTestId: 'woocommerce_after_shop_loop_item_title',
-		content: 'Hook: woocommerce_after_shop_loop_item_title',
-		amount: 16,
-	},
-	{
-		title: 'Before Shop Loop Item',
-		dataTestId: 'woocommerce_before_shop_loop_item',
-		content: 'Hook: woocommerce_before_shop_loop_item',
-		amount: 16,
-	},
-	{
-		title: 'After Shop Loop Item',
-		dataTestId: 'woocommerce_after_shop_loop_item',
-		content: 'Hook: woocommerce_after_shop_loop_item',
-		amount: 16,
-	},
-];
-
 const test = base.extend< { pageObject: ProductCollectionPage } >( {
 	pageObject: async ( { page, admin, editor }, use ) => {
 		const pageObject = new ProductCollectionPage( {
@@ -94,25 +27,112 @@ test.describe( 'Product Collection: Compatibility Layer', () => {
 		await pageObject.goToProductCatalogFrontend();
 	} );

-	for ( const scenario of singleOccurrenceScenarios ) {
-		test( `${ scenario.title } is attached to the page`, async ( {
-			pageObject,
-		} ) => {
-			const hooks = pageObject.locateByTestId( scenario.dataTestId );
+	test( 'renders global compatibility hooks around the inherited collection and loop', async ( {
+		page,
+		pageObject,
+	} ) => {
+		const globalHooks = [
+			'woocommerce_before_main_content',
+			'woocommerce_before_shop_loop',
+			'woocommerce_after_shop_loop',
+			'woocommerce_after_main_content',
+		];

-			await expect( hooks ).toHaveCount( scenario.amount );
-			await expect( hooks ).toHaveText( scenario.content );
-		} );
-	}
+		for ( const hookName of globalHooks ) {
+			const hook = pageObject.locateByTestId( hookName );
+			await expect( hook ).toHaveCount( 1 );
+			await expect( hook ).toHaveText( `Hook: ${ hookName }` );
+		}

-	for ( const scenario of multipleOccurrenceScenarios ) {
-		test( `${ scenario.title } is attached to the page`, async ( {
-			pageObject,
-		} ) => {
-			const hooks = pageObject.locateByTestId( scenario.dataTestId );
+		await expect(
+			page.locator( '.wp-block-woocommerce-product-collection' )
+		).toHaveCount( 1 );
+		await expect( pageObject.productTemplate ).toHaveCount( 1 );
+		await expect( pageObject.products.first() ).toBeVisible();

-			await expect( hooks ).toHaveCount( scenario.amount );
-			await expect( hooks.first() ).toHaveText( scenario.content );
-		} );
-	}
+		const structureSelector = [
+			'[data-testid="woocommerce_before_main_content"]',
+			'.wp-block-woocommerce-product-collection',
+			'[data-testid="woocommerce_before_shop_loop"]',
+			'.wc-block-product-template',
+			'[data-testid="woocommerce_after_shop_loop"]',
+			'[data-testid="woocommerce_after_main_content"]',
+		].join( ', ' );
+		const structure = await page
+			.locator( structureSelector )
+			.evaluateAll( ( nodes ) =>
+				nodes.map( ( node ) => {
+					const testId = node.getAttribute( 'data-testid' );
+					if ( testId ) {
+						return testId;
+					}
+
+					return node.classList.contains(
+						'wp-block-woocommerce-product-collection'
+					)
+						? 'product-collection'
+						: 'product-template';
+				} )
+			);
+
+		expect( structure ).toEqual( [
+			'woocommerce_before_main_content',
+			'product-collection',
+			'woocommerce_before_shop_loop',
+			'product-template',
+			'woocommerce_after_shop_loop',
+			'woocommerce_after_main_content',
+		] );
+	} );
+
+	test( 'renders compatibility hooks in order for every product', async ( {
+		pageObject,
+	} ) => {
+		await expect( pageObject.products.first() ).toBeVisible();
+		const productCount = await pageObject.products.count();
+		expect( productCount ).toBeGreaterThan( 0 );
+
+		const itemHooks = [
+			'woocommerce_before_shop_loop_item',
+			'woocommerce_before_shop_loop_item_title',
+			'woocommerce_shop_loop_item_title',
+			'woocommerce_after_shop_loop_item_title',
+			'woocommerce_after_shop_loop_item',
+		];
+
+		for ( const hookName of itemHooks ) {
+			const hooks = pageObject.locateByTestId( hookName );
+			await expect( hooks ).toHaveCount( productCount );
+			await expect( hooks ).toHaveText(
+				Array( productCount ).fill( `Hook: ${ hookName }` )
+			);
+		}
+
+		const productSequences = await pageObject.products.evaluateAll(
+			( products ) =>
+				products.map( ( product ) =>
+					Array.from(
+						product.querySelectorAll(
+							'[data-testid="woocommerce_before_shop_loop_item"], [data-testid="woocommerce_before_shop_loop_item_title"], .wp-block-post-title, [data-testid="woocommerce_shop_loop_item_title"], [data-testid="woocommerce_after_shop_loop_item_title"], [data-testid="woocommerce_after_shop_loop_item"]'
+						)
+					).map(
+						( node ) =>
+							node.getAttribute( 'data-testid' ) ??
+							'product-title'
+					)
+				)
+		);
+		const expectedSequence = [
+			'woocommerce_before_shop_loop_item',
+			'woocommerce_before_shop_loop_item_title',
+			'product-title',
+			'woocommerce_shop_loop_item_title',
+			'woocommerce_after_shop_loop_item_title',
+			'woocommerce_after_shop_loop_item',
+		];
+
+		expect( productSequences ).toEqual(
+			Array.from( { length: productCount }, () => expectedSequence )
+		);
+	} );
 } );
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Templates/ArchiveProductTemplatesCompatibilityTest.php b/plugins/woocommerce/tests/php/src/Blocks/Templates/ArchiveProductTemplatesCompatibilityTest.php
new file mode 100644
index 00000000000..d78a48a24e6
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/Templates/ArchiveProductTemplatesCompatibilityTest.php
@@ -0,0 +1,279 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\Templates;
+
+use Automattic\WooCommerce\Blocks\Templates\ArchiveProductTemplatesCompatibility;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the archive Product Collection hook compatibility layer.
+ */
+class ArchiveProductTemplatesCompatibilityTest extends WC_Unit_Test_Case {
+
+	/**
+	 * Query globals captured before each test.
+	 *
+	 * @var array<string, array{exists: bool, value: mixed}>
+	 */
+	private $query_snapshots = array();
+
+	/**
+	 * Shop page option captured before each test.
+	 *
+	 * @var mixed
+	 */
+	private $original_shop_page_id;
+
+	/**
+	 * Shop page created for the archive request.
+	 *
+	 * @var int
+	 */
+	private $shop_page_id = 0;
+
+	/**
+	 * @inheritdoc
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->original_shop_page_id = get_option( 'woocommerce_shop_page_id' );
+
+		foreach ( array( 'wp_query', 'wp_the_query' ) as $global_name ) {
+			$this->query_snapshots[ $global_name ] = array(
+				'exists' => array_key_exists( $global_name, $GLOBALS ),
+				'value'  => $GLOBALS[ $global_name ] ?? null,
+			);
+		}
+	}
+
+	/**
+	 * @inheritdoc
+	 */
+	public function tearDown(): void {
+		foreach ( $this->query_snapshots as $global_name => $snapshot ) {
+			if ( $snapshot['exists'] ) {
+				$GLOBALS[ $global_name ] = $snapshot['value']; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the exact pre-test query global.
+			} else {
+				unset( $GLOBALS[ $global_name ] ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore prior absence of the query global.
+			}
+		}
+
+		if ( false === $this->original_shop_page_id ) {
+			delete_option( 'woocommerce_shop_page_id' );
+		} else {
+			update_option( 'woocommerce_shop_page_id', $this->original_shop_page_id );
+		}
+		if ( $this->shop_page_id ) {
+			wp_delete_post( $this->shop_page_id, true );
+			$this->shop_page_id = 0;
+		}
+
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Injects $hook_name $position the inherited $block_name block
+	 * @dataProvider supported_hook_provider
+	 *
+	 * @param string $hook_name Hook to exercise.
+	 * @param string $block_name Target block name.
+	 * @param string $position Expected marker position relative to the block.
+	 */
+	public function test_injects_supported_hook_at_declared_position( string $hook_name, string $block_name, string $position ): void {
+		$sut    = $this->initialize_archive_compatibility();
+		$target = $this->get_marked_target_block( $sut, $block_name );
+
+		add_action(
+			$hook_name,
+			static function () {
+				echo esc_html( '__HOOK_MARKER__' );
+			}
+		);
+
+		$rendered = $sut->inject_hooks( '__BLOCK_SENTINEL__', $target );
+
+		$this->assertSame( 1, substr_count( $rendered, '__HOOK_MARKER__' ), 'The selected public hook should render exactly once.' );
+		$this->assertSame( 1, substr_count( $rendered, '__BLOCK_SENTINEL__' ), 'The inherited block sentinel should render exactly once.' );
+
+		$marker_position = strpos( $rendered, '__HOOK_MARKER__' );
+		$block_position  = strpos( $rendered, '__BLOCK_SENTINEL__' );
+		$this->assertIsInt( $marker_position );
+		$this->assertIsInt( $block_position );
+
+		if ( 'before' === $position ) {
+			$this->assertLessThan( $block_position, $marker_position, "{$hook_name} should render before {$block_name}." );
+		} else {
+			$this->assertGreaterThan( $block_position, $marker_position, "{$hook_name} should render after {$block_name}." );
+		}
+	}
+
+	/**
+	 * Supported public hook and block positions.
+	 *
+	 * @return array<string, array{string, string, string}>
+	 */
+	public function supported_hook_provider(): array {
+		return array(
+			'before main content'         => array( 'woocommerce_before_main_content', 'woocommerce/product-collection', 'before' ),
+			'after main content'          => array( 'woocommerce_after_main_content', 'woocommerce/product-collection', 'after' ),
+			'before shop loop'            => array( 'woocommerce_before_shop_loop', 'woocommerce/product-template', 'before' ),
+			'after shop loop'             => array( 'woocommerce_after_shop_loop', 'woocommerce/product-template', 'after' ),
+			'before shop loop item title' => array( 'woocommerce_before_shop_loop_item_title', 'core/post-title', 'before' ),
+			'shop loop item title'        => array( 'woocommerce_shop_loop_item_title', 'core/post-title', 'after' ),
+			'after shop loop item title'  => array( 'woocommerce_after_shop_loop_item_title', 'core/post-title', 'after' ),
+			'before shop loop item'       => array( 'woocommerce_before_shop_loop_item', 'core/null', 'before' ),
+			'after shop loop item'        => array( 'woocommerce_after_shop_loop_item', 'core/null', 'after' ),
+		);
+	}
+
+	/**
+	 * @testdox Marks an inherited Product Collection and all descendants
+	 */
+	public function test_marks_inherited_product_collection_tree(): void {
+		$sut  = $this->initialize_archive_compatibility();
+		$tree = array(
+			'blockName'   => 'woocommerce/product-collection',
+			'attrs'       => array( 'query' => array( 'inherit' => true ) ),
+			'innerBlocks' => array(
+				array(
+					'blockName'   => 'woocommerce/product-template',
+					'attrs'       => array(),
+					'innerBlocks' => array(
+						array(
+							'blockName'   => 'core/null',
+							'attrs'       => array(),
+							'innerBlocks' => array(
+								array(
+									'blockName'   => 'core/post-title',
+									'attrs'       => array(),
+									'innerBlocks' => array(),
+								),
+							),
+						),
+					),
+				),
+			),
+		);
+
+		$marked = $sut->update_render_block_data( $tree, $tree, null );
+
+		$this->assertSame( 1, $marked['attrs']['isInherited'] );
+		$this->assertSame( 1, $marked['innerBlocks'][0]['attrs']['isInherited'] );
+		$this->assertSame( 1, $marked['innerBlocks'][0]['innerBlocks'][0]['attrs']['isInherited'] );
+		$this->assertSame( 1, $marked['innerBlocks'][0]['innerBlocks'][0]['innerBlocks'][0]['attrs']['isInherited'] );
+	}
+
+	/**
+	 * @testdox Skips blocks outside the supported inherited archive contexts
+	 */
+	public function test_skips_unsupported_context(): void {
+		$sut = $this->initialize_archive_compatibility();
+		add_action(
+			'woocommerce_before_main_content',
+			static function () {
+				echo esc_html( '__HOOK_MARKER__' );
+			}
+		);
+		add_action(
+			'woocommerce_before_shop_loop',
+			static function () {
+				echo esc_html( '__HOOK_MARKER__' );
+			}
+		);
+
+		$this->go_to( home_url( '/' ) );
+		$GLOBALS['wp_the_query'] = $GLOBALS['wp_query']; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Keep the conditional query globals aligned for the non-archive control.
+
+		$non_archive = array(
+			'blockName'   => 'woocommerce/product-collection',
+			'attrs'       => array( 'query' => array( 'inherit' => true ) ),
+			'innerBlocks' => array(),
+		);
+		$this->assertSame( $non_archive, $sut->update_render_block_data( $non_archive, $non_archive, null ) );
+		$this->assertSame( '__BLOCK_SENTINEL__', $sut->inject_hooks( '__BLOCK_SENTINEL__', $non_archive ) );
+
+		$this->establish_shop_query();
+		$supported_not_inherited = array(
+			'blockName' => 'woocommerce/product-collection',
+			'attrs'     => array(),
+		);
+		$this->assertSame( '__BLOCK_SENTINEL__', $sut->inject_hooks( '__BLOCK_SENTINEL__', $supported_not_inherited ) );
+
+		$unsupported_inherited = array(
+			'blockName' => 'core/paragraph',
+			'attrs'     => array( 'isInherited' => 1 ),
+		);
+		$this->assertSame( '__BLOCK_SENTINEL__', $sut->inject_hooks( '__BLOCK_SENTINEL__', $unsupported_inherited ) );
+
+		$empty_product_template = array(
+			'blockName' => 'woocommerce/product-template',
+			'attrs'     => array( 'isInherited' => 1 ),
+		);
+		$this->assertSame( '', $sut->inject_hooks( '', $empty_product_template ) );
+	}
+
+	/**
+	 * Initializes the real compatibility layer in a Shop request.
+	 */
+	private function initialize_archive_compatibility(): ArchiveProductTemplatesCompatibility {
+		$this->establish_shop_query();
+		$sut = new ArchiveProductTemplatesCompatibility();
+		$sut->init();
+		// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- WordPress owns this hook; the test applies the real callback registered by init().
+		apply_filters( 'template_include', 'index.php' );
+
+		return $sut;
+	}
+
+	/**
+	 * Establishes and verifies the real Shop main query.
+	 */
+	private function establish_shop_query(): void {
+		if ( ! $this->shop_page_id ) {
+			$this->shop_page_id = self::factory()->post->create(
+				array(
+					'post_type'   => 'page',
+					'post_status' => 'publish',
+					'post_title'  => 'Compatibility Layer Shop',
+					'post_name'   => 'compatibility-layer-shop',
+				)
+			);
+			update_option( 'woocommerce_shop_page_id', $this->shop_page_id );
+		}
+
+		$this->go_to( get_permalink( $this->shop_page_id ) );
+		$GLOBALS['wp_the_query'] = $GLOBALS['wp_query']; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Product archive conditionals read the main query global.
+		$this->assertTrue( is_shop(), 'The compatibility contract must run under a real Shop main query.' );
+	}
+
+	/**
+	 * Marks an inherited tree and returns the requested target block.
+	 *
+	 * @param ArchiveProductTemplatesCompatibility $sut        Compatibility layer.
+	 * @param string                               $block_name Target block name.
+	 * @return array<string, mixed>
+	 */
+	private function get_marked_target_block( ArchiveProductTemplatesCompatibility $sut, string $block_name ): array {
+		$target = array(
+			'blockName'   => $block_name,
+			'attrs'       => array(),
+			'innerBlocks' => array(),
+		);
+
+		if ( 'woocommerce/product-collection' === $block_name ) {
+			$tree                   = $target;
+			$tree['attrs']['query'] = array( 'inherit' => true );
+		} else {
+			$tree = array(
+				'blockName'   => 'woocommerce/product-collection',
+				'attrs'       => array( 'query' => array( 'inherit' => true ) ),
+				'innerBlocks' => array( $target ),
+			);
+		}
+
+		$marked = $sut->update_render_block_data( $tree, $tree, null );
+
+		return 'woocommerce/product-collection' === $block_name ? $marked : $marked['innerBlocks'][0];
+	}
+}