Commit d26d770ef6d for woocommerce

commit d26d770ef6d6f51950ef932059aec038738074bb
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Thu Aug 13 22:50:55 2026 +0300

    [tests] [e2e] Add deterministic Product Collection sorting fixtures (#67512)

    * test: add Product Collection sort fixtures

    Context: Product Collection sorted collection coverage depended on shared Blocks fixtures that had no deterministic rating or sales data.

    Problem: The Top Rated Products and Best Sellers collection specs were skipped because their expected products could not be asserted reliably, and the rating-filter fixture expectation did not match WooCommerce's rounded low-rating bucket.

    Solution: Seed deterministic aggregate ratings and total sales after the parallel Blocks fixture scripts, validate the seeded meta and lookup data before continuing setup, unskip the Product Collection assertions, and align the rating-filter spec with the fixture-backed low-rating bucket.

    Refs #42492

    * chore: add Product Collection fixture changelog

    Context: The Product Collection fixture update changes WooCommerce Core E2E test infrastructure.

    Problem: WooCommerce package changes need a changelog entry so release tooling can account for the developer-facing test fixture update.

    Solution: Add a dev changelog entry for deterministic Product Collection sorting fixture data.

    Refs #42492

    * test: simplify Product Collection sort fixtures

    Context: Product Collection E2E coverage needs deterministic rating and sales aggregates without changing the semantics of shared Blocks fixtures.

    Problem: The initial fixture completed an order, recalculated Cap into a different rating-filter bucket, and wrote review aggregates without backing comments. Those side effects expanded the snapshot and required unrelated test changes.

    Solution: Seed only the average-rating and total-sales fields consumed by Product Collection sorting, preserve Cap and real reviews, and keep exact title expectations local to the collection specs.

    Refs #42492

    * test: Harden Product Collection sort fixtures

    The deterministic sorting seed could fail on an obsolete review-count assumption while the outer setup ignored its status. Indexing the catalog only by title could also leave duplicate products with stale aggregates and produce misleadingly green setup runs.

    Keep review ownership in the dedicated review fixture, reject duplicate titles before mutation, and propagate main seed failures through the outer setup. Document why lookup regeneration remains synchronous in the existing WP-CLI process.

    Refs #42492

    * fix(e2e): write Blocks rating fixtures through the product API

    The sorting fixture set each product's average rating with a bare
    update_post_meta() call after save(). That moves the aggregate the Top
    Rated collection sorts on, but it is not the only place a rating lives.

    WC_Product_Data_Store_CPT::update_visibility() rebuilds the `rated-N`
    product_visibility terms only when `average_rating` arrives as a changed
    property. Writing the meta directly never marks it changed, so the terms
    kept whatever the real review fixtures had left behind. The Rating Filter
    builds its options from wc_product_meta_lookup.average_rating but matches
    its results against those terms, so the two disagreed: /shop offered
    Rated 5, Rated 4 and Rated 3, where 5 and 3 returned nothing and 4
    returned Hoodie, which the lookup itself puts in bucket 5. Only Cap, the
    one product the fixture leaves alone, was coherent.

    Nothing asserts this today. It is a trap for the next filter spec.

    Setting the rating as a property instead persists the same meta through
    the data store and lets update_visibility() run. The rating count goes
    with it: an average with a zero count is a state no real store reaches,
    and it renders stars in every loop and block context while the single
    product template hides them. Each fixture gets the smallest distribution
    whose mean is its target.

    Refs #42492

    * test(e2e): assert rating propagation across every storage location

    The fixture validated the average rating in post meta only, while the
    sales validation already checked both post meta and the lookup table.

    A rating is read from three places: Top Rated sorts on the
    _wc_average_rating post meta, the Rating Filter builds its options from
    wc_product_meta_lookup.average_rating, and it matches its results
    against the `rated-N` product_visibility terms. Checking one of the
    three lets the other two drift silently, which is exactly how the
    visibility terms went stale in the first place.

    Assert all three, so a fixture that half-lands fails the seed instead of
    producing a subtly wrong environment for every Blocks spec.

    Refs #42492

    * perf(e2e): skip saving Blocks fixture products with nothing to change

    The seed loop ran save() on every product, but only 8 of the 18 carry a
    fixture value; the other 10 were set to the zero they already held.

    WC_Product_Data_Store_CPT::update() treats a no-change save as a save
    anyway: its else branch writes post_modified and post_modified_gmt,
    flushes the post cache, then still runs update_post_meta(),
    update_terms(), update_visibility() and update_attributes(). So every
    seed rewrote 10 rows to record that nothing had happened, and handed
    them a fresh modification timestamp that no longer meant anything.

    Guarding on get_changes() makes those products a genuine no-op. Verified
    against a fresh seed: the 8 products with fixture values are written and
    the other 10 keep their import timestamps.

    Refs #42492

    * style(e2e): quote script paths in the Blocks seed index

    Every `bash $script_dir/...` invocation was unquoted, so a checkout path
    containing a space would word-split into a wrong filename and the step
    would fail with a confusing "No such file or directory" rather than an
    obvious quoting error. Shellcheck flags it as SC2086.

    The `find` glob keeps its unquoted tail: quoting the whole pattern would
    stop the shell from expanding `parallel/*.sh`. Quoting only the variable
    gets the spaces handled without changing what the glob matches.

    Refs #42492

    * docs(e2e): record why the Blocks seed index does not set -e

    Only one of the six steps here checks its exit code, which reads like an
    oversight worth fixing with a single `set -euo pipefail` at the top.

    It is not. Trying it aborts the seed immediately: attributes.sh runs
    `wp wc product_attribute create` for Color and Size, and those return
    non-zero once the attributes exist. `wp site empty` deletes posts but
    leaves attribute taxonomies, so every re-seed of an existing environment
    hits it and the pipeline dies before products are imported — worse than
    the silence it was meant to fix.

    Write down that constraint, and why the one guarded step is guarded, so
    the next person reaches for idempotent steps rather than `set -e`.

    Refs #42492

    ---------

    Co-authored-by: Oleksandr Aratovskyi <79862886+oaratovskyi@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/42492-product-collection-sort-fixtures b/plugins/woocommerce/changelog/42492-product-collection-sort-fixtures
new file mode 100644
index 00000000000..5977106b6b8
--- /dev/null
+++ b/plugins/woocommerce/changelog/42492-product-collection-sort-fixtures
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Seed deterministic ratings and sales data for Product Collection Blocks E2E sorting tests.
diff --git a/plugins/woocommerce/tests/e2e/bin/blocks/scripts/index.sh b/plugins/woocommerce/tests/e2e/bin/blocks/scripts/index.sh
index fca3c5aec79..fbe00fb26da 100644
--- a/plugins/woocommerce/tests/e2e/bin/blocks/scripts/index.sh
+++ b/plugins/woocommerce/tests/e2e/bin/blocks/scripts/index.sh
@@ -1,18 +1,30 @@
 #!/usr/bin/env bash

+# This script deliberately does not `set -e`. Some steps below are not
+# idempotent: attributes.sh exits non-zero once the attributes it creates
+# already exist, which is every re-seed of an existing environment, since
+# `wp site empty` leaves attribute taxonomies in place. Under `set -e` the
+# seed would die there, before any product is imported. Guard steps
+# individually until those steps are made re-runnable.
+
 script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

 # Empty the site to remove unused pages and posts created by WP and Woo.
 wp site empty --yes

 # Attributes must be created before importing products.
-bash $script_dir/attributes.sh
+bash "$script_dir/attributes.sh"

 # Products must be created before anything else so the ids are deterministic.
-bash $script_dir/products.sh
+bash "$script_dir/products.sh"

 # Run all scripts in parallel at maximum 10 at a time.
-find $script_dir/parallel/*.sh -maxdepth 1 -type f | xargs -P10 -n1 bash
+find "$script_dir"/parallel/*.sh -maxdepth 1 -type f | xargs -P10 -n1 bash
+
+# Add deterministic ratings and sales data for product collection sorting.
+# Guarded because test-env-setup.sh snapshots the database once this returns, so
+# a half-seeded site would be restored before every test in the suite.
+bash "$script_dir/product-collection-sort-data.sh" || exit 1

 # Run rewrite script last to ensure all posts are created before running it.
-bash $script_dir/rewrite.sh
+bash "$script_dir/rewrite.sh"
diff --git a/plugins/woocommerce/tests/e2e/bin/blocks/scripts/product-collection-sort-data.sh b/plugins/woocommerce/tests/e2e/bin/blocks/scripts/product-collection-sort-data.sh
new file mode 100644
index 00000000000..7228ab3624e
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/bin/blocks/scripts/product-collection-sort-data.sh
@@ -0,0 +1,170 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+wp eval "$(cat <<'PHP'
+global $wpdb;
+
+$rating_fixtures = array(
+	'V-Neck T-Shirt'   => 5.0,
+	'Hoodie'           => 4.5,
+	'Hoodie with Logo' => 4.0,
+	'T-Shirt'          => 3.5,
+	'Beanie'           => 3.0,
+);
+
+$sales_fixtures = array(
+	'Album'             => 5,
+	'Hoodie'            => 4,
+	'Single'            => 3,
+	'Hoodie with Logo'  => 2,
+	'T-Shirt with Logo' => 1,
+);
+
+/**
+ * Build the smallest rating distribution whose mean is the requested average.
+ *
+ * Whole averages need a single bucket; half steps need the two buckets around
+ * them. Products carry a rating count alongside the average so their aggregate
+ * state stays reachable by a real store: an average with a zero count renders
+ * stars in loop and block contexts while the single product template hides
+ * them, which reads as a bug to the next spec that looks at it.
+ *
+ * @param float $rating Target average rating.
+ * @return array Rating counts keyed by star value.
+ */
+$rating_counts_for = static function ( $rating ) {
+	if ( $rating <= 0 ) {
+		return array();
+	}
+
+	$low  = (int) floor( $rating );
+	$high = (int) ceil( $rating );
+
+	return $low === $high ? array( $low => 1 ) : array( $low => 1, $high => 1 );
+};
+
+$products = array();
+
+foreach ( wc_get_products( array( 'limit' => -1 ) ) as $product ) {
+	$product_name = $product->get_name();
+
+	if ( isset( $products[ $product_name ] ) ) {
+		WP_CLI::error(
+			sprintf(
+				'Expected product names to be unique, but found "%1$s" on product IDs %2$d and %3$d.',
+				$product_name,
+				$products[ $product_name ]->get_id(),
+				$product->get_id()
+			)
+		);
+	}
+
+	$products[ $product_name ] = $product;
+}
+
+$required_product_names = array_unique(
+	array_merge( array_keys( $rating_fixtures ), array_keys( $sales_fixtures ), array( 'Cap' ) )
+);
+
+foreach ( $required_product_names as $product_name ) {
+	if ( ! isset( $products[ $product_name ] ) ) {
+		WP_CLI::error( sprintf( 'Could not find product "%s".', $product_name ) );
+	}
+}
+
+foreach ( $products as $product_name => $product ) {
+	$product->set_total_sales( $sales_fixtures[ $product_name ] ?? 0 );
+
+	// Cap is the existing one-star Rating Filter fixture. Leave its rating state intact.
+	if ( 'Cap' !== $product_name ) {
+		$target_rating = $rating_fixtures[ $product_name ] ?? 0;
+
+		// Write the rating as a product property rather than as post meta. Only a
+		// changed `average_rating` property makes the data store recompute the
+		// `rated-N` product_visibility terms that the Rating Filter matches on;
+		// a bare update_post_meta() call moves the sorting aggregate while
+		// leaving those terms behind, so the Rating Filter ends up offering
+		// options that its own results cannot satisfy.
+		$product->set_average_rating( $target_rating );
+		$product->set_rating_counts( $rating_counts_for( $target_rating ) );
+	}
+
+	// Products the fixtures do not name already hold the values set above, so
+	// saving them writes nothing but a fresh post_modified stamp.
+	if ( $product->get_changes() ) {
+		$product->save();
+	}
+}
+
+// This runs synchronously under WP-CLI and also repairs stale lookup rows on reruns.
+wc_update_product_lookup_tables();
+
+foreach ( $rating_fixtures as $product_name => $expected_rating ) {
+	$product_id = $products[ $product_name ]->get_id();
+
+	$actual_rating        = (float) get_post_meta( $product_id, '_wc_average_rating', true );
+	$actual_lookup_rating = (float) $wpdb->get_var(
+		$wpdb->prepare(
+			"SELECT average_rating FROM {$wpdb->wc_product_meta_lookup} WHERE product_id = %d",
+			$product_id
+		)
+	);
+
+	if (
+		abs( $actual_rating - $expected_rating ) > 0.001 ||
+		abs( $actual_lookup_rating - $expected_rating ) > 0.001
+	) {
+		WP_CLI::error(
+			sprintf(
+				'Expected "%s" average rating to be %s, got meta %s and lookup %s.',
+				$product_name,
+				$expected_rating,
+				$actual_rating,
+				$actual_lookup_rating
+			)
+		);
+	}
+
+	// The sorting aggregate and the Rating Filter read different storage, so
+	// assert the visibility term rather than assuming the save propagated.
+	$expected_term = 'rated-' . min( 5, (int) round( $expected_rating ) );
+	$actual_terms  = wp_get_post_terms( $product_id, 'product_visibility', array( 'fields' => 'names' ) );
+
+	if ( is_wp_error( $actual_terms ) || ! in_array( $expected_term, $actual_terms, true ) ) {
+		WP_CLI::error(
+			sprintf(
+				'Expected "%s" to carry the "%s" product_visibility term, got [%s].',
+				$product_name,
+				$expected_term,
+				is_wp_error( $actual_terms ) ? $actual_terms->get_error_message() : implode( ', ', $actual_terms )
+			)
+		);
+	}
+}
+
+foreach ( $sales_fixtures as $product_name => $expected_sales ) {
+	$product_id          = $products[ $product_name ]->get_id();
+	$actual_meta_sales   = (int) get_post_meta( $product_id, 'total_sales', true );
+	$actual_lookup_sales = (int) $wpdb->get_var(
+		$wpdb->prepare(
+			"SELECT total_sales FROM {$wpdb->wc_product_meta_lookup} WHERE product_id = %d",
+			$product_id
+		)
+	);
+
+	if ( $actual_meta_sales !== $expected_sales || $actual_lookup_sales !== $expected_sales ) {
+		WP_CLI::error(
+			sprintf(
+				'Expected "%s" total sales to be %d, got meta %d and lookup %d.',
+				$product_name,
+				$expected_sales,
+				$actual_meta_sales,
+				$actual_lookup_sales
+			)
+		);
+	}
+}
+
+PHP
+)"
diff --git a/plugins/woocommerce/tests/e2e/bin/blocks/test-env-setup.sh b/plugins/woocommerce/tests/e2e/bin/blocks/test-env-setup.sh
index 4c0958a7719..2382159fe94 100755
--- a/plugins/woocommerce/tests/e2e/bin/blocks/test-env-setup.sh
+++ b/plugins/woocommerce/tests/e2e/bin/blocks/test-env-setup.sh
@@ -8,7 +8,7 @@ wp_cli="wp-env --config .wp-env.e2e.json run cli"
 # Remove the database snapshot if it exists.
 $wp_cli -- rm -f blocks_e2e.sql
 # Run the main script in the container for better performance.
-$wp_cli -- bash wp-content/plugins/woocommerce/blocks-bin/playwright/scripts/index.sh
+$wp_cli -- bash wp-content/plugins/woocommerce/blocks-bin/playwright/scripts/index.sh || exit 1
 # Disable the LYS Coming Soon banner.
 $wp_cli -- wp option update woocommerce_coming_soon 'no'
 # Dismiss the site editor welcome guide for the admin user so it does not
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/collections.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/collections.block_theme.spec.ts
index c447fa5b384..d69486af313 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/collections.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/collections.block_theme.spec.ts
@@ -35,38 +35,29 @@ test.describe( 'Product Collection: Collections', () => {
 		await expect( pageObject.products ).toHaveCount( 0 );
 	} );

-	// When creating reviews programmatically the ratings are not propagated
-	// properly so products order by rating is undeterministic in test env.
-	// eslint-disable-next-line playwright/no-skipped-test
-	test.skip( 'Top Rated Products collection can be added and displays proper products', async ( {
+	test( 'Top Rated Products collection can be added and displays proper products', async ( {
 		pageObject,
 	} ) => {
 		await pageObject.createNewPostAndInsertBlock( 'topRated' );
-
 		const topRatedProducts = [
-			'V Neck T Shirt',
+			'V-Neck T-Shirt',
 			'Hoodie',
 			'Hoodie with Logo',
 			'T-Shirt',
 			'Beanie',
 		];

-		await expect( pageObject.products ).toHaveCount( 5 );
 		await expect( pageObject.productTitles ).toHaveText( topRatedProducts );

 		await pageObject.publishAndGoToFrontend();

-		await expect( pageObject.products ).toHaveCount( 5 );
+		await expect( pageObject.productTitles ).toHaveText( topRatedProducts );
 	} );

-	// There's no orders in test env so the order of Best Sellers
-	// is undeterministic in test env. Requires further work.
-	// eslint-disable-next-line playwright/no-skipped-test
-	test.skip( 'Best Sellers collection can be added and displays proper products', async ( {
+	test( 'Best Sellers collection can be added and displays proper products', async ( {
 		pageObject,
 	} ) => {
 		await pageObject.createNewPostAndInsertBlock( 'bestSellers' );
-
 		const bestSellersProducts = [
 			'Album',
 			'Hoodie',
@@ -75,14 +66,15 @@ test.describe( 'Product Collection: Collections', () => {
 			'T-Shirt with Logo',
 		];

-		await expect( pageObject.products ).toHaveCount( 5 );
 		await expect( pageObject.productTitles ).toHaveText(
 			bestSellersProducts
 		);

 		await pageObject.publishAndGoToFrontend();

-		await expect( pageObject.products ).toHaveCount( 5 );
+		await expect( pageObject.productTitles ).toHaveText(
+			bestSellersProducts
+		);
 	} );

 	test( 'On Sale Products collection can be added and displays proper products', async ( {