Commit 5ab79dfc2cb for woocommerce

commit 5ab79dfc2cb85baf061f453847e9e6611d786e8f
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Wed Sep 16 00:43:13 2026 +0300

    [tests] Demote 4 Products block E2E titles to Jest and PHPUnit (#68667)

    * test(blocks): Cut Products block E2E from 7 titles to 3

    The Products block is the deprecated `core/query` variation registered
    under the `woocommerce/product-query` namespace. Its browser spec ran
    seven Playwright identities, and four of them spent a real page load
    proving contracts that never needed one.

    Three of those four were archive-route parity checks that scraped two
    title lists and asserted set equality. That assertion passes on two
    matching empty arrays and says nothing about ordering. The fourth
    repeated the add-to-cart journey character for character against a
    published post instead of the archive template, so its only real delta
    was the query mode.

    This adds the two lower-layer owners the split needs. A Jest suite
    covers `useAllowedControls`, the hook deciding which inspector controls
    a Products block offers, across its inherited, re-rendered and Post
    Editor branches. A PHPUnit method covers `ProductQuery::add_iapi_context`,
    the per-item Interactivity wiring the add-to-cart button depends on,
    asserting the exact namespace, key and decoded context on valid loop
    items and proving three kinds of invalid item are skipped.

    The retained Product Catalog title is rewritten rather than removed. It
    now clicks through the Product Collection upgrade and asserts the
    migrated query before saving and again after a reload, with cardinality
    floors on both sides, which closes the old hole of two matching
    singletons.

    The owner for the three archive-route titles is
    `RouteContextParityTest::test_product_collection_and_product_query_match_classic_route`.
    Its Products arm was written by this batch's slice commit but lives in a
    file batch 020 carries whole, so it ships in that pull request and is
    not yet on trunk. This branch should land after it.

    Consolidates the mega-branch slices:
    - Slice 069: test(blocks): Reduce Products block browser coverage

    Three later refinements to the retained parity title are folded in with
    it: "Verify Product Collection query parity", "Require multi-product
    query parity", and "Verify inherited collection parity".

    Refs TESTOPS-234
    Refs #68046

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

    * test(blocks): Poll for the upgraded block and drop two dead lines

    `getProductCollectionQuery` returns `{}` until the upgraded block reaches
    the editor store, so reading it once and dereferencing the result is the
    race the description flagged. Read it through `expect.poll` instead,
    asserting the two attributes that say the upgrade happened, then check
    `perPage` after. That is a condition on the block's attributes, not a
    wait for the editor to settle.

    Two removals in the PHP test. The `UnexpectedValueException` after
    `assertIsString` cannot run, because the assertion has already failed the
    test, and PHPStan does not analyse `tests/`, so nothing needed the type
    narrowing either. The `finally` deleting the products and the post
    duplicates the transaction rollback, which measurably still runs under
    `@runInSeparateProcess`: with the cleanup removed the suite leaves zero
    products and zero posts behind.

    Refs #68667

    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-products-block b/plugins/woocommerce/changelog/testops-234-products-block
new file mode 100644
index 00000000000..bd96ff283fc
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-234-products-block
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Move the Products block inspector policy to Jest and its per-item Interactivity context to PHPUnit, cutting the block's browser spec from seven titles to three while keeping the editor, hydrated cart, and Product Catalog journeys.
+
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-query/test/inspector-controls.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-query/test/inspector-controls.tsx
new file mode 100644
index 00000000000..967089d9cc6
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-query/test/inspector-controls.tsx
@@ -0,0 +1,72 @@
+/**
+ * External dependencies
+ */
+import { renderHook } from '@testing-library/react';
+import { isSiteEditorPage } from '@woocommerce/utils';
+
+/**
+ * Internal dependencies
+ */
+import { useAllowedControls } from '../utils';
+
+const mockAllowedControls = [ 'wooInherit', 'onSale' ];
+
+jest.mock( '@wordpress/data', () => ( {
+	...jest.requireActual( '@wordpress/data' ),
+	useSelect: jest.fn( ( select ) =>
+		select( () => ( {
+			getActiveBlockVariation: () => ( {
+				allowedControls: mockAllowedControls,
+			} ),
+		} ) )
+	),
+} ) );
+
+jest.mock( '@woocommerce/utils', () => ( {
+	...jest.requireActual( '@woocommerce/utils' ),
+	isSiteEditorPage: jest.fn(),
+} ) );
+
+const mockIsSiteEditorPage = isSiteEditorPage as jest.Mock;
+
+describe( 'Product Query inspector controls', () => {
+	it( 'shows only query inheritance for inherited Site Editor queries', () => {
+		mockIsSiteEditorPage.mockReturnValue( true );
+
+		const { result } = renderHook( () =>
+			useAllowedControls( {
+				query: { inherit: true },
+			} as never )
+		);
+
+		expect( result.current ).toEqual( [ 'wooInherit' ] );
+	} );
+
+	it( 'restores advanced controls when Site Editor query inheritance is disabled', () => {
+		mockIsSiteEditorPage.mockReturnValue( true );
+
+		const { result, rerender } = renderHook(
+			( { inherit } ) =>
+				useAllowedControls( {
+					query: { inherit },
+				} as never ),
+			{ initialProps: { inherit: true } }
+		);
+
+		rerender( { inherit: false } );
+
+		expect( result.current ).toEqual( mockAllowedControls );
+	} );
+
+	it( 'removes only query inheritance from Post Editor controls', () => {
+		mockIsSiteEditorPage.mockReturnValue( false );
+
+		const { result } = renderHook( () =>
+			useAllowedControls( {
+				query: { inherit: true },
+			} as never )
+		);
+
+		expect( result.current ).toEqual( [ 'onSale' ] );
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/changelog/testops-234-products-block b/plugins/woocommerce/client/blocks/changelog/testops-234-products-block
new file mode 100644
index 00000000000..bd96ff283fc
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/changelog/testops-234-products-block
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Move the Products block inspector policy to Jest and its per-item Interactivity context to PHPUnit, cutting the block's browser spec from seven titles to three while keeping the editor, hydrated cart, and Product Catalog journeys.
+
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/products/products.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/products/products.block_theme.spec.ts
index 15e345ec375..bd72b50a71d 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/products/products.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/products/products.block_theme.spec.ts
@@ -13,7 +13,8 @@ import {
  */
 import {
 	getProductsNameFromClassicTemplate,
-	getProductsNameFromProductQuery,
+	getProductCollectionQuery,
+	getProductsNameFromProductCollection,
 	insertProductsQuery,
 } from './utils';

@@ -28,41 +29,11 @@ const blockData: BlockData = {
 };

 const templates = {
-	// This test is disabled because archives are disabled for attributes by default. This can be uncommented when this is toggled on.
-	//'taxonomy-product_attribute': {
-	//	templateTitle: 'Product Attribute',
-	//	slug: 'taxonomy-product_attribute',
-	//	frontendPage: '/product-attribute/color/',
-	//	legacyBlockName: 'woocommerce/legacy-template',
-	//	needsCreation: false,
-	//},
-	'taxonomy-product_cat': {
-		templateTitle: 'Product Category',
-		slug: 'taxonomy-product_cat',
-		frontendPage: '/product-category/music/',
-		legacyBlockName: 'woocommerce/legacy-template',
-		needsCreation: true,
-	},
-	'taxonomy-product_tag': {
-		templateTitle: 'Product Tag',
-		slug: 'taxonomy-product_tag',
-		frontendPage: '/product-tag/recommended/',
-		legacyBlockName: 'woocommerce/legacy-template',
-		needsCreation: true,
-	},
 	'archive-product': {
 		templateTitle: 'Product Catalog',
 		slug: 'archive-product',
 		frontendPage: '/shop/',
 		legacyBlockName: 'woocommerce/legacy-template',
-		needsCreation: false,
-	},
-	'product-search-results': {
-		templateTitle: 'Product Search Results',
-		slug: 'product-search-results',
-		frontendPage: '/?s=shirt&post_type=product',
-		legacyBlockName: 'woocommerce/legacy-template',
-		needsCreation: false,
 	},
 };

@@ -124,27 +95,6 @@ test.describe( `${ blockData.name } Block `, () => {
 		const cartLink = page.getByRole( 'link', { name: 'View cart' } );
 		await expect( cartLink ).toBeVisible();
 	} );
-
-	test( 'product button should add product to the cart when not inheriting query from template', async ( {
-		admin,
-		editor,
-		page,
-	} ) => {
-		await admin.createNewPost();
-		await expect(
-			editor.canvas.getByLabel( /Add default block|Empty block/ )
-		).toBeVisible();
-		await insertProductsQuery( editor, { inherit: false } );
-		await editor.publishAndVisitPost();
-
-		const addToCartButton = page.getByRole( 'button', {
-			name: 'Add to cart: “Single”',
-		} );
-		await addToCartButton.click();
-		await expect( addToCartButton ).toHaveText( '1 in cart' );
-		const cartLink = page.getByRole( 'link', { name: 'View cart' } );
-		await expect( cartLink ).toBeVisible();
-	} );
 } );

 for ( const {
@@ -152,45 +102,62 @@ for ( const {
 	slug,
 	frontendPage,
 	legacyBlockName,
-	needsCreation,
 } of Object.values( templates ) ) {
 	test.describe( `${ templateTitle } template`, () => {
-		test( 'Products block matches with classic template block', async ( {
+		test( 'Product Collection matches with classic template block', async ( {
 			admin,
 			editor,
 			page,
 		} ) => {
-			if ( needsCreation ) {
-				await admin.visitSiteEditor( {
-					postType: 'wp_template',
-				} );
-				await editor.createTemplate( {
-					templateName: 'Products by Category',
-				} );
-			} else {
-				await admin.visitSiteEditor( {
-					postId: `${ BLOCK_THEME_SLUG }//${ slug }`,
-					postType: 'wp_template',
-					canvas: 'edit',
-				} );
-			}
+			await admin.visitSiteEditor( {
+				postId: `${ BLOCK_THEME_SLUG }//${ slug }`,
+				postType: 'wp_template',
+				canvas: 'edit',
+			} );
 			await editor.setContent( '' );
 			await insertProductsQuery( editor );
+			await page
+				.getByRole( 'button', {
+					name: 'Upgrade to Product Collection',
+				} )
+				.click();
+			const expectProductCollectionQuery = async () => {
+				// The helper returns `{}` until the upgraded block reaches the
+				// editor store, and reading it once would dereference that empty
+				// object. Poll for the shape first. This is a condition on the
+				// block's own attributes, not a wait for the editor to settle.
+				await expect
+					.poll( () => getProductCollectionQuery( page ) )
+					.toMatchObject( {
+						isProductCollectionBlock: true,
+						inherit: true,
+					} );
+
+				const query = await getProductCollectionQuery( page );
+				expect( query.perPage ).toBeGreaterThan( 1 );
+			};
+			await expectProductCollectionQuery();
+
 			await editor.insertBlock( { name: legacyBlockName } );
 			await editor.canvas.locator( 'body' ).click();

 			await editor.saveSiteEditorEntities( {
 				isOnlyCurrentEntityDirty: true,
 			} );
+			await page.reload();
+			await editor.canvas.locator( 'body' ).waitFor();
+			await expectProductCollectionQuery();

 			await page.goto( frontendPage );

 			const classicProducts =
 				await getProductsNameFromClassicTemplate( page );
-			const productQueryProducts =
-				await getProductsNameFromProductQuery( page );
+			const productCollectionProducts =
+				await getProductsNameFromProductCollection( page );

-			expect( classicProducts ).toEqual( productQueryProducts );
+			expect( classicProducts.length ).toBeGreaterThan( 1 );
+			expect( productCollectionProducts.length ).toBeGreaterThan( 1 );
+			expect( classicProducts ).toEqual( productCollectionProducts );
 		} );
 	} );
 }
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/products/utils.ts b/plugins/woocommerce/tests/e2e/tests/blocks/products/utils.ts
index 69ab43000da..a47c929b2e8 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/products/utils.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/products/utils.ts
@@ -9,11 +9,26 @@ export const getProductsNameFromClassicTemplate = async ( page: Page ) => {
 	return products.allTextContents();
 };

-export const getProductsNameFromProductQuery = async ( page: Page ) => {
-	const products = page.locator( '.wp-block-query .wp-block-post-title' );
+export const getProductsNameFromProductCollection = async ( page: Page ) => {
+	const products = page.locator(
+		'.wp-block-woocommerce-product-collection .wp-block-post-title'
+	);
 	return products.allTextContents();
 };

+export const getProductCollectionQuery = async ( page: Page ) =>
+	page.evaluate( () => {
+		const block = window.wp.data
+			.select( 'core/block-editor' )
+			.getBlocks()
+			.find(
+				( candidate: { name: string } ) =>
+					candidate.name === 'woocommerce/product-collection'
+			);
+
+		return block?.attributes.query ?? {};
+	} );
+
 export const productQueryInnerBlocksTemplate = [
 	{
 		name: 'core/post-template',
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductQuery.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductQuery.php
index b35fefa9bca..1e8a00206cc 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductQuery.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductQuery.php
@@ -3,6 +3,7 @@ namespace Automattic\WooCommerce\Tests\Blocks\BlockTypes;

 use Automattic\WooCommerce\Tests\Blocks\Mocks\ProductQueryMock;
 use Automattic\WooCommerce\Enums\ProductStockStatus;
+use WC_Helper_Product;

 /**
  * Tests for the ProductQuery block type
@@ -15,6 +16,77 @@ class ProductQuery extends \WP_UnitTestCase {
 	 */
 	private $block_instance;

+	/**
+	 * @testdox Should add Interactivity API context only to valid product loop items.
+	 *
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_add_iapi_context_updates_only_valid_product_loop_items(): void {
+		$product_ids   = array();
+		$product_ids[] = WC_Helper_Product::create_simple_product()->get_id();
+		$product_ids[] = WC_Helper_Product::create_simple_product()->get_id();
+		$post_id       = self::factory()->post->create();
+
+		$markup = sprintf(
+			'<ul><li id="first-product" class="wp-block-post post-%1$d"></li><li id="second-product" class="extra wp-block-post post-%2$d"></li><li id="missing-id" class="wp-block-post"></li><li id="malformed-id" class="wp-block-post post-not-a-number"></li><li id="non-product" class="wp-block-post post-%3$d"></li></ul>',
+			$product_ids[0],
+			$product_ids[1],
+			$post_id
+		);
+
+		$this->assertSame(
+			$markup,
+			$this->block_instance->add_iapi_context(
+				$markup,
+				array( 'attrs' => array( '__woocommerceNamespace' => 'another-block' ) )
+			),
+			'A different block namespace should leave the markup byte-identical.'
+		);
+
+		$processed_markup = $this->block_instance->add_iapi_context(
+			$markup,
+			array( 'attrs' => array( '__woocommerceNamespace' => 'woocommerce/product-query/product-template' ) )
+		);
+		$processor        = new \WP_HTML_Tag_Processor( $processed_markup );
+		$items            = array();
+
+		while ( $processor->next_tag( array( 'tag_name' => 'LI' ) ) ) {
+			$items[ $processor->get_attribute( 'id' ) ] = array(
+				'interactive' => $processor->get_attribute( 'data-wp-interactive' ),
+				'context'     => $processor->get_attribute( 'data-wp-context' ),
+				'key'         => $processor->get_attribute( 'data-wp-key' ),
+			);
+		}
+
+		foreach (
+			array(
+				'first-product'  => $product_ids[0],
+				'second-product' => $product_ids[1],
+			) as $item_id => $product_id
+		) {
+			$this->assertSame( 'woocommerce/products', $items[ $item_id ]['interactive'] );
+			$this->assertSame( 'product-item-' . $product_id, $items[ $item_id ]['key'] );
+			$context = $items[ $item_id ]['context'];
+			$this->assertIsString( $context );
+			list( $namespace, $json_context ) = explode( '::', $context, 2 );
+			$this->assertSame( 'woocommerce/products', $namespace );
+			$this->assertSame(
+				array(
+					'productId'   => $product_id,
+					'variationId' => null,
+				),
+				json_decode( $json_context, true )
+			);
+		}
+
+		foreach ( array( 'missing-id', 'malformed-id', 'non-product' ) as $item_id ) {
+			$this->assertNull( $items[ $item_id ]['interactive'], "$item_id should not become interactive." );
+			$this->assertNull( $items[ $item_id ]['context'], "$item_id should not receive product context." );
+			$this->assertNull( $items[ $item_id ]['key'], "$item_id should not receive an Interactivity API key." );
+		}
+	}
+
 	/**
 	 * Return starting point for parsed block test data.
 	 * Using a method instead of property to avoid sharing data between tests.