Commit 72ffaddddeb for woocommerce
commit 72ffaddddeb5d078536556c1c3233a2238ab97ce
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Thu Oct 15 16:25:27 2026 +0300
[tests] Compare the Products block with the classic template on three routes and cover Product Details, body classes, classic-theme patterns and decimal prices (#68870)
diff --git a/plugins/woocommerce/changelog/testops-234-restore-product-blocks-coverage b/plugins/woocommerce/changelog/testops-234-restore-product-blocks-coverage
new file mode 100644
index 00000000000..79d172bd6b1
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-234-restore-product-blocks-coverage
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Improve product-block regression coverage; no production change.
diff --git a/plugins/woocommerce/client/blocks/assets/js/utils/test/register-product-block-type.test.ts b/plugins/woocommerce/client/blocks/assets/js/utils/test/register-product-block-type.test.ts
index 7e60b7a7f4b..7693029859b 100644
--- a/plugins/woocommerce/client/blocks/assets/js/utils/test/register-product-block-type.test.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/utils/test/register-product-block-type.test.ts
@@ -152,6 +152,70 @@ describe( 'registerProductBlockType', () => {
expect( mockUnregisterBlockType ).not.toHaveBeenCalled();
} );
+ it( 'allows product blocks directly in a per-product template', () => {
+ mockPostType = 'wp_template';
+ mockTemplateSlug = 'single-product-v-neck-t-shirt';
+ mockIsSiteEditor = true;
+ const registerProductBlockType = loadRegistrationFunction();
+
+ registerProductBlockType( {
+ ...blockSettings,
+ name: 'woocommerce/per-product-template-block',
+ } );
+ mockContextSubscription();
+
+ expect( mockRegisterBlockType ).toHaveBeenCalledTimes( 2 );
+ expect( mockUnregisterBlockType ).toHaveBeenCalledWith(
+ 'woocommerce/per-product-template-block'
+ );
+ expect( mockRegisterBlockType ).toHaveBeenLastCalledWith(
+ expect.objectContaining( {
+ name: 'woocommerce/per-product-template-block',
+ title: 'Test product block',
+ } ),
+ expect.objectContaining( {
+ ancestor: undefined,
+ } )
+ );
+ } );
+
+ it( 'registers eligible product blocks in the pattern editor without the Site Editor store', () => {
+ mockPostType = 'wp_block';
+ mockTemplateSlug = 'on-sale-products-pattern';
+ mockIsSiteEditor = false;
+ const registerProductBlockType = loadRegistrationFunction();
+
+ registerProductBlockType(
+ {
+ ...blockSettings,
+ name: 'woocommerce/pattern-block',
+ },
+ { isAvailableOnPostEditor: true }
+ );
+ registerProductBlockType( {
+ ...blockSettings,
+ name: 'woocommerce/site-editor-only-block',
+ } );
+ mockContextSubscription();
+
+ expect( mockRegisterBlockType ).toHaveBeenCalledTimes( 1 );
+ expect( mockRegisterBlockType ).toHaveBeenCalledWith(
+ expect.objectContaining( {
+ name: 'woocommerce/pattern-block',
+ title: 'Test product block',
+ } ),
+ expect.objectContaining( {
+ ancestor: [ 'woocommerce/single-product' ],
+ } )
+ );
+ expect( mockRegisterBlockType ).not.toHaveBeenCalledWith(
+ expect.objectContaining( {
+ name: 'woocommerce/site-editor-only-block',
+ } ),
+ expect.anything()
+ );
+ } );
+
it( 're-registers a block with the ancestor required by each template', () => {
mockPostType = 'wp_template';
mockTemplateSlug = 'twentytwentyfour//coming-soon';
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/inspector-controls.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/inspector-controls.block_theme.spec.ts
index a192d7e60f9..824642a0666 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/inspector-controls.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/inspector-controls.block_theme.spec.ts
@@ -7,6 +7,7 @@ import { test as base, expect, BLOCK_THEME_SLUG } from '@woocommerce/e2e-utils';
* Internal dependencies
*/
import ProductCollectionPage, { SELECTORS } from './product-collection.page';
+import { getProductCollectionQuery } from '../products/utils';
const test = base.extend< { pageObject: ProductCollectionPage } >( {
pageObject: async ( { page, admin, editor }, use ) => {
@@ -295,6 +296,7 @@ test.describe( 'Product Collection: Inspector Controls', () => {
} );
test( 'Products can be filtered based on price range.', async ( {
+ page,
pageObject,
} ) => {
await pageObject.createNewPostAndInsertBlock();
@@ -313,12 +315,24 @@ test.describe( 'Product Collection: Inspector Controls', () => {
max: '17.21',
} );
+ await expect
+ .poll(
+ async () =>
+ ( await getProductCollectionQuery( page ) ).priceRange
+ )
+ .toEqual( { min: 15.28, max: 17.21 } );
await expect( pageObject.products ).toHaveCount( 2 );
await pageObject.setPriceRange( {
max: '17.29',
} );
+ await expect
+ .poll(
+ async () =>
+ ( await getProductCollectionQuery( page ) ).priceRange
+ )
+ .toMatchObject( { max: 17.29 } );
await expect( pageObject.products ).toHaveCount( 5 );
await pageObject.publishAndGoToFrontend();
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 bd72b50a71d..b41b62a50ff 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
@@ -4,8 +4,10 @@
import {
test,
expect,
+ Admin,
BlockData,
BLOCK_THEME_SLUG,
+ RequestUtils,
} from '@woocommerce/e2e-utils';
/**
@@ -15,6 +17,7 @@ import {
getProductsNameFromClassicTemplate,
getProductCollectionQuery,
getProductsNameFromProductCollection,
+ getProductsNameFromProductQuery,
insertProductsQuery,
} from './utils';
@@ -37,6 +40,36 @@ const templates = {
},
};
+const classicTemplateComparisonRoutes = {
+ 'archive-product': {
+ testTitle:
+ 'Products block matches the classic template block on the shop page',
+ templateTitle: 'Product Catalog',
+ slug: 'archive-product',
+ frontendPage: '/shop/',
+ legacyBlockName: 'woocommerce/legacy-template',
+ needsCreation: false,
+ },
+ 'taxonomy-product_cat': {
+ testTitle:
+ 'Products block matches the classic template block on a product category archive',
+ templateTitle: 'Products by Category',
+ slug: 'taxonomy-product_cat',
+ frontendPage: '/product-category/tshirts/',
+ legacyBlockName: 'woocommerce/legacy-template',
+ needsCreation: true,
+ },
+ 'product-search-results': {
+ testTitle:
+ 'Products block matches the classic template block on product search results',
+ templateTitle: 'Product Search Results',
+ slug: 'product-search-results',
+ frontendPage: '/?s=shirt&post_type=product',
+ legacyBlockName: 'woocommerce/legacy-template',
+ needsCreation: false,
+ },
+};
+
test.describe( `${ blockData.name } Block `, () => {
test( 'when Inherits Query From Template other options are hidden, show up otherwise', async ( {
admin,
@@ -161,3 +194,74 @@ for ( const {
} );
} );
}
+
+const openRouteTemplateForEditing = async (
+ admin: Admin,
+ requestUtils: RequestUtils,
+ {
+ templateTitle,
+ slug,
+ needsCreation,
+ }: { templateTitle: string; slug: string; needsCreation: boolean }
+) => {
+ if ( needsCreation ) {
+ const template = await requestUtils.createTemplate( 'wp_template', {
+ slug,
+ title: templateTitle,
+ content: '',
+ } );
+
+ await admin.visitSiteEditor( {
+ postId: template.id,
+ postType: 'wp_template',
+ canvas: 'edit',
+ } );
+ return;
+ }
+
+ await admin.visitSiteEditor( {
+ postId: `${ BLOCK_THEME_SLUG }//${ slug }`,
+ postType: 'wp_template',
+ canvas: 'edit',
+ } );
+};
+
+for ( const {
+ testTitle,
+ templateTitle,
+ slug,
+ frontendPage,
+ legacyBlockName,
+ needsCreation,
+} of Object.values( classicTemplateComparisonRoutes ) ) {
+ test.describe( `${ templateTitle } template, Products block`, () => {
+ test( testTitle, async ( { admin, editor, page, requestUtils } ) => {
+ await openRouteTemplateForEditing( admin, requestUtils, {
+ templateTitle,
+ slug,
+ needsCreation,
+ } );
+ await editor.setContent( '' );
+ await insertProductsQuery( editor );
+ await editor.insertBlock( { name: legacyBlockName } );
+ await editor.canvas.locator( 'body' ).click();
+
+ await editor.saveSiteEditorEntities( {
+ isOnlyCurrentEntityDirty: true,
+ } );
+
+ await page.goto( frontendPage );
+ await expect(
+ page.locator( '.woocommerce-loop-product__title' ).first()
+ ).toBeVisible();
+
+ const classicProducts =
+ await getProductsNameFromClassicTemplate( page );
+ const productQueryProducts =
+ await getProductsNameFromProductQuery( page );
+
+ expect( classicProducts.length ).toBeGreaterThan( 1 );
+ expect( classicProducts ).toEqual( productQueryProducts );
+ } );
+ } );
+}
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/products/utils.ts b/plugins/woocommerce/tests/e2e/tests/blocks/products/utils.ts
index a47c929b2e8..878124808d9 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/products/utils.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/products/utils.ts
@@ -9,6 +9,11 @@ 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' );
+ return products.allTextContents();
+};
+
export const getProductsNameFromProductCollection = async ( page: Page ) => {
const products = page.locator(
'.wp-block-woocommerce-product-collection .wp-block-post-title'
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
index 51461d96aab..8efcce53fd4 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
@@ -9,6 +9,7 @@ use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
use Automattic\WooCommerce\Tests\Blocks\BlockTypes\ProductCollection\Utils;
use Automattic\WooCommerce\Tests\Blocks\Mocks\ProductCollectionMock;
use Automattic\WooCommerce\Enums\ProductStockStatus;
+use Automattic\WooCommerce\Enums\TaxDisplayMode;
use Automattic\WooCommerce\Internal\ProductFilters\QueryClauses;
use WC_Helper_Product;
use WP_Query;
@@ -1457,6 +1458,50 @@ class QueryBuilder extends \WP_UnitTestCase {
$query['orderBy'] = 'title';
$query['order'] = 'asc';
break;
+ case 'decimal-minimum-price':
+ // Pin the two options that decide the branch, so the price range filter
+ // always builds its non-tax-adjusted clause. The transaction rolls them back.
+ update_option( 'woocommerce_calc_taxes', 'no' );
+ update_option( 'woocommerce_tax_display_shop', TaxDisplayMode::EXCLUSIVE );
+
+ $decimal_product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Decimal inspector target',
+ 'regular_price' => '15.50',
+ 'status' => 'publish',
+ 'stock_status' => ProductStockStatus::IN_STOCK,
+ )
+ );
+ $products[] = $decimal_product;
+ $query['post__in'][] = $decimal_product->get_id();
+ $query['priceRange'] = array(
+ 'min' => 15.28,
+ );
+ $query['orderBy'] = 'title';
+ $query['order'] = 'asc';
+ break;
+ case 'decimal-maximum-price':
+ // Pin the two options that decide the branch, so the price range filter
+ // always builds its non-tax-adjusted clause. The transaction rolls them back.
+ update_option( 'woocommerce_calc_taxes', 'no' );
+ update_option( 'woocommerce_tax_display_shop', TaxDisplayMode::EXCLUSIVE );
+
+ $decimal_product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Decimal inspector target',
+ 'regular_price' => '10.25',
+ 'status' => 'publish',
+ 'stock_status' => ProductStockStatus::IN_STOCK,
+ )
+ );
+ $products[] = $decimal_product;
+ $query['post__in'][] = $decimal_product->get_id();
+ $query['priceRange'] = array(
+ 'max' => 10.28,
+ );
+ $query['orderBy'] = 'title';
+ $query['order'] = 'asc';
+ break;
case 'inclusive-price':
$query['priceRange'] = array(
'min' => 10,
@@ -1560,19 +1605,21 @@ class QueryBuilder extends \WP_UnitTestCase {
*/
public function inspector_query_result_provider(): array {
return array(
- 'category' => array( 'category', array( 'Alpha inspector target' ) ),
- 'tag' => array( 'tag', array( 'Alpha inspector target' ) ),
- 'brand' => array( 'brand', array( 'Alpha inspector target' ) ),
- 'combined attrs' => array( 'attributes', array( 'Alpha inspector target' ) ),
- 'keyword search' => array( 'search', array( 'Alpha inspector target' ) ),
- 'stock' => array( 'stock', array( 'Alpha inspector target' ) ),
- 'minimum price' => array( 'minimum-price', array( 'Alpha inspector target', 'Zulu inspector distractor' ) ),
- 'maximum price' => array( 'maximum-price', array( 'Alpha inspector target', 'Below inspector distractor' ) ),
- 'inclusive price' => array( 'inclusive-price', array( 'Alpha inspector target' ) ),
- 'created before' => array( 'created-before', array( 'Alpha inspector target' ) ),
- 'created within' => array( 'created-within', array( 'Zulu inspector distractor' ) ),
- 'hand picked' => array( 'hand-picked', array( 'Zulu inspector distractor', 'Alpha inspector target' ) ),
- 'title descending' => array( 'title-descending', array( 'Zulu inspector distractor', 'Alpha inspector target' ) ),
+ 'category' => array( 'category', array( 'Alpha inspector target' ) ),
+ 'tag' => array( 'tag', array( 'Alpha inspector target' ) ),
+ 'brand' => array( 'brand', array( 'Alpha inspector target' ) ),
+ 'combined attrs' => array( 'attributes', array( 'Alpha inspector target' ) ),
+ 'keyword search' => array( 'search', array( 'Alpha inspector target' ) ),
+ 'stock' => array( 'stock', array( 'Alpha inspector target' ) ),
+ 'minimum price' => array( 'minimum-price', array( 'Alpha inspector target', 'Zulu inspector distractor' ) ),
+ 'maximum price' => array( 'maximum-price', array( 'Alpha inspector target', 'Below inspector distractor' ) ),
+ 'decimal minimum price' => array( 'decimal-minimum-price', array( 'Decimal inspector target' ) ),
+ 'decimal maximum price' => array( 'decimal-maximum-price', array( 'Alpha inspector target', 'Below inspector distractor', 'Decimal inspector target' ) ),
+ 'inclusive price' => array( 'inclusive-price', array( 'Alpha inspector target' ) ),
+ 'created before' => array( 'created-before', array( 'Alpha inspector target' ) ),
+ 'created within' => array( 'created-within', array( 'Zulu inspector distractor' ) ),
+ 'hand picked' => array( 'hand-picked', array( 'Zulu inspector distractor', 'Alpha inspector target' ) ),
+ 'title descending' => array( 'title-descending', array( 'Zulu inspector distractor', 'Alpha inspector target' ) ),
);
}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Templates/SingleProductTemplateTests.php b/plugins/woocommerce/tests/php/src/Blocks/Templates/SingleProductTemplateTests.php
index dd57bdb535b..343d033f3a8 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Templates/SingleProductTemplateTests.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Templates/SingleProductTemplateTests.php
@@ -12,6 +12,15 @@ use WP_UnitTestCase;
*/
class SingleProductTemplateTests extends WP_UnitTestCase {
+ /**
+ * Remove the image files the attachment fixtures wrote into the uploads directory,
+ * which the database rollback does not cover.
+ */
+ public function tearDown(): void {
+ $this->remove_added_uploads();
+ parent::tearDown();
+ }
+
/**
* @testdox Should not update Product Catalog template content so Single Product template logic does not leak into other templates.
*/
@@ -242,8 +251,73 @@ class SingleProductTemplateTests extends WP_UnitTestCase {
$this->assertContains( 'product', $filtered_classes );
$this->assertContains( $type_class, $filtered_classes );
} finally {
- // tear_down() nulls $GLOBALS['post'] and rolls back the fixtures; these two
- // globals it leaves exactly as the test left them.
+ // tear_down() nulls $GLOBALS['post'] and rolls back the fixtures;
+ // it leaves these two globals exactly as the test left them.
+ if ( $product_global_existed ) {
+ $GLOBALS['product'] = $product_global;
+ } else {
+ unset( $GLOBALS['product'] );
+ }
+
+ if ( $loop_global_existed ) {
+ $GLOBALS['woocommerce_loop'] = $loop_global;
+ } else {
+ unset( $GLOBALS['woocommerce_loop'] );
+ }
+ }
+ }
+
+ /**
+ * @testdox Should add has-post-thumbnail to the Single Product body classes only for a product that has an image.
+ */
+ public function test_update_single_product_content_adds_has_post_thumbnail_only_for_imaged_product() {
+ $product_global_existed = array_key_exists( 'product', $GLOBALS );
+ $product_global = $product_global_existed ? $GLOBALS['product'] : null;
+ $loop_global_existed = array_key_exists( 'woocommerce_loop', $GLOBALS );
+ $loop_global = $loop_global_existed ? $GLOBALS['woocommerce_loop'] : null;
+
+ try {
+ // The template under test installs its own body_class callback, so clear the
+ // stack first to isolate it. _restore_hooks() rebuilds the stack afterwards.
+ remove_all_filters( 'body_class' );
+
+ $imaged_product = \WC_Helper_Product::create_simple_product();
+ $imaged_product->set_image_id( $this->factory->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' ) );
+ $imaged_product->save();
+
+ $imageless_product = \WC_Helper_Product::create_simple_product();
+
+ $GLOBALS['product'] = $imaged_product;
+ $GLOBALS['post'] = get_post( $imaged_product->get_id() ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- The template reads the current post; tear_down() nulls it again.
+
+ $template = new \WP_Block_Template();
+ $template->slug = 'single-product';
+ $template->title = 'Single Product';
+ $template->content = '<!-- wp:woocommerce/product-price /-->';
+ $template->type = 'wp_template';
+
+ $single_product_template = new SingleProductTemplate();
+ $single_product_template->update_single_product_content( array( $template ) );
+
+ wc_reset_loop();
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Exercise the public filter installed by the template under test.
+ $imaged_classes = apply_filters( 'body_class', array() );
+
+ $this->assertContains( 'status-publish', $imaged_classes, 'The imaged product body classes must contain status-publish.' );
+ $this->assertContains( 'has-post-thumbnail', $imaged_classes, 'A product with an image must contribute has-post-thumbnail.' );
+
+ $GLOBALS['product'] = $imageless_product;
+ $GLOBALS['post'] = get_post( $imageless_product->get_id() ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- The template reads the current post; tear_down() nulls it again.
+
+ wc_reset_loop();
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Exercise the public filter installed by the template under test.
+ $imageless_classes = apply_filters( 'body_class', array() );
+
+ $this->assertContains( 'status-publish', $imageless_classes, 'The imageless product body classes must contain status-publish.' );
+ $this->assertNotContains( 'has-post-thumbnail', $imageless_classes, 'A product without an image must not contribute has-post-thumbnail.' );
+ } finally {
+ // tear_down() nulls $GLOBALS['post'] and rolls back the fixtures;
+ // it leaves these two globals exactly as the test left them.
if ( $product_global_existed ) {
$GLOBALS['product'] = $product_global;
} else {