Commit bb00f2d6134 for woocommerce

commit bb00f2d6134241fe4d9e2318de96672fa07e24ce
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Thu Oct 8 17:13:50 2026 +0300

    [tests] Stabilize the review moderation and product search E2E specs (#69110)

    * test(e2e): wait for review moderation requests before navigating

    The review moderation canary loaded the next filtered view right after
    clicking Unapprove or Approve. The list dims the row before its AJAX
    request finishes, so the navigation could cancel the request, or the
    filtered view could be served before the status changed. The spam and
    trash steps waited only for the row to hide, which the list also does
    before the response arrives.

    Wait for each dim-comment or delete-comment response, and require it
    to succeed, before loading the next view. With two seconds of added
    server latency on those actions, the previous spec failed 5 of 5 runs
    on the moderated view; this one passes 5 of 5.

    Refs WOOAIRR-360

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * test(e2e): give the product search spec a unique product name

    The partial search test requires exactly one row whose link matches
    the product name. The name came from getFakeProduct(), which draws
    from about 9,700 adjective, material and product combinations that
    about 20 other specs also use, so a product another spec created with
    the same name would make the row count 2. The earlier .row-title
    locator was no safer: toBeVisible() fails in strict mode on two
    matches.

    Append a random suffix to this spec's product name. With a
    same-named product created alongside it, the previous spec failed on
    the row count and this one passes.

    Refs WOOAIRR-376

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * test(e2e): match the moderation response to the clicked review

    The moderation canary waited for any dim-comment or delete-comment
    response. The steps run one at a time, so no other moderation request
    could satisfy the wait first, but the predicate still did not say
    which review or transition it expected.

    Parse the POST body and require the review ID plus the fields each
    action sends: new=unapproved or new=approved for dim-comment, spam=1
    or trash=1 for delete-comment. The wait now also proves the right
    button was clicked; getByRole matches "Approve" inside "Unapprove".
    Expecting new=unapproved for the Approve step makes the wait time out.

    Refs WOOAIRR-360

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * test(e2e): make every fake product name unique across workers

    getFakeProduct() named products with faker.commerce.productName()
    alone, so parallel workers could create two products with the same
    name. Two specs already worked around this with their own random
    suffix, while the product review fixture still collided: its filter
    test picks the first select2 option for the review's product name,
    which can belong to another worker's product.

    Append an 8-character random suffix inside getFakeProduct(), so every
    caller gets a unique name, and drop the per-spec suffixes. The partial
    search test now searches the second half of the name, which holds the
    suffix; the first half matched other workers' products that share the
    same adjective and material. No other caller slices or reshapes the
    generated name.

    Refs WOOAIRR-376

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * test(e2e): check the moderation response reports the new status

    The moderation helper asserted response.ok(), but admin-ajax.php
    answers failures with HTTP 200 too: dim-comment and delete-comment
    return -1, 0 or a wp_error payload. A failed action only surfaced
    later, on the filtered view. The helper also created the response
    wait before hovering and clicking, so a failed click left the wait
    pending and teardown reported a second, misleading error.

    Assert that the response carries the comment_approved value the
    action should produce (0, 1, spam or trash), which both success paths
    of _wp_ajax_delete_comment_response() return. Await the response and
    the click together, and declare each action's fields and status once
    in a lookup table next to rowFor.

    Refs WOOAIRR-360

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * chore: widen the E2E hygiene changelog comment to the shared helper

    The entry named only the review moderation and product search specs,
    but getFakeProduct() now gives every caller a unique product name.

    Refs WOOAIRR-376

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/test-wooairr-e2e-hygiene b/plugins/woocommerce/changelog/test-wooairr-e2e-hygiene
new file mode 100644
index 00000000000..1ae84cc34cc
--- /dev/null
+++ b/plugins/woocommerce/changelog/test-wooairr-e2e-hygiene
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Stabilize E2E tests that look up products by name or moderate reviews; no production change.
diff --git a/plugins/woocommerce/tests/e2e/tests/product/product-linked-products.spec.ts b/plugins/woocommerce/tests/e2e/tests/product/product-linked-products.spec.ts
index 33668ad26bc..efdcb035b62 100644
--- a/plugins/woocommerce/tests/e2e/tests/product/product-linked-products.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/product/product-linked-products.spec.ts
@@ -3,7 +3,6 @@
  */
 import { WC_API_PATH } from '@woocommerce/e2e-utils-playwright';
 import type { Page } from '@playwright/test';
-import { faker } from '@faker-js/faker';

 /**
  * Internal dependencies
@@ -19,18 +18,8 @@ const test = baseTest.extend( {
 		const products = {};

 		for ( const key of Object.values( keys ) ) {
-			// `getFakeProduct` names products with `faker.commerce.productName()`,
-			// which is NOT globally unique — concurrent workers can generate the
-			// same name. This spec searches the store-wide product picker by name
-			// and selects the matching option, so a duplicate name yields 2+
-			// options and a strict-mode violation. Suffix a random token to make
-			// each product name unique across workers.
-			const fakeProduct = getFakeProduct();
-			fakeProduct.name = `${
-				fakeProduct.name
-			} ${ faker.string.alphanumeric( 8 ) }`;
 			await restApi
-				.post( `${ WC_API_PATH }/products`, fakeProduct )
+				.post( `${ WC_API_PATH }/products`, getFakeProduct() )
 				.then( ( response ) => {
 					products[ key ] = response.data;
 				} );
diff --git a/plugins/woocommerce/tests/e2e/tests/product/product-reviews.spec.ts b/plugins/woocommerce/tests/e2e/tests/product/product-reviews.spec.ts
index f2f9f45c4d7..c3519f748fb 100644
--- a/plugins/woocommerce/tests/e2e/tests/product/product-reviews.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/product/product-reviews.spec.ts
@@ -131,6 +131,10 @@ test.describe( 'Product Reviews', () => {
 		// Spam and trash use different rows on purpose: once a review is spammed
 		// its actions become Not Spam and Delete Permanently, so Trash is only
 		// reachable from the main list.
+		//
+		// The list dims or hides the row before its AJAX request finishes, so each
+		// action waits for the response before loading the next view. Otherwise the
+		// navigation can cancel the request, or read the status before it changes.
 		test( 'can approve, spam and trash a product review', async ( {
 			page,
 			reviews,
@@ -140,22 +144,67 @@ test.describe( 'Product Reviews', () => {
 			const moderated = reviews[ 0 ];
 			const trashed = reviews[ 1 ];

+			// What each row action posts to admin-ajax.php, and the comment_approved
+			// value the response reports once the action succeeds.
+			const moderationActions = {
+				Unapprove: {
+					fields: { action: 'dim-comment', new: 'unapproved' },
+					status: '0',
+				},
+				Approve: {
+					fields: { action: 'dim-comment', new: 'approved' },
+					status: '1',
+				},
+				Spam: {
+					fields: { action: 'delete-comment', spam: '1' },
+					status: 'spam',
+				},
+				Trash: {
+					fields: { action: 'delete-comment', trash: '1' },
+					status: 'trash',
+				},
+			};
+
 			const rowFor = ( id: number ) => page.locator( `#comment-${ id }` );
+			const moderate = async (
+				id: number,
+				action: keyof typeof moderationActions
+			) => {
+				const { fields, status } = moderationActions[ action ];
+				const expectedFields = { id: String( id ), ...fields };
+
+				await rowFor( id ).hover();
+				const [ response ] = await Promise.all( [
+					page.waitForResponse( ( candidate ) => {
+						if ( ! candidate.url().includes( 'admin-ajax.php' ) ) {
+							return false;
+						}
+						const posted = new URLSearchParams(
+							candidate.request().postData() ?? ''
+						);
+						return Object.entries( expectedFields ).every(
+							( [ key, value ] ) => posted.get( key ) === value
+						);
+					} ),
+					rowFor( id )
+						.getByRole( 'button', { name: action } )
+						.click(),
+				] );
+				// admin-ajax.php reports failures with HTTP 200 too, so check the
+				// status the handler sends back rather than the response code.
+				expect( await response.text() ).toContain(
+					`<status><![CDATA[${ status }]]></status>`
+				);
+			};

 			await test.step( 'unapprove, then approve again', async () => {
 				await page.goto( reviewsUrl );
-				await rowFor( moderated.id ).hover();
-				await rowFor( moderated.id )
-					.getByRole( 'button', { name: 'Unapprove' } )
-					.click();
+				await moderate( moderated.id, 'Unapprove' );

 				await page.goto( `${ reviewsUrl }&comment_status=moderated` );
 				await expect( rowFor( moderated.id ) ).toBeVisible();

-				await rowFor( moderated.id ).hover();
-				await rowFor( moderated.id )
-					.getByRole( 'button', { name: 'Approve' } )
-					.click();
+				await moderate( moderated.id, 'Approve' );

 				await page.goto( `${ reviewsUrl }&comment_status=approved` );
 				await expect( rowFor( moderated.id ) ).toBeVisible();
@@ -163,10 +212,7 @@ test.describe( 'Product Reviews', () => {

 			await test.step( 'mark as spam', async () => {
 				await page.goto( reviewsUrl );
-				await rowFor( moderated.id ).hover();
-				await rowFor( moderated.id )
-					.getByRole( 'button', { name: 'Spam' } )
-					.click();
+				await moderate( moderated.id, 'Spam' );
 				await expect( rowFor( moderated.id ) ).toBeHidden();

 				await page.goto( `${ reviewsUrl }&comment_status=spam` );
@@ -175,10 +221,7 @@ test.describe( 'Product Reviews', () => {

 			await test.step( 'trash', async () => {
 				await page.goto( reviewsUrl );
-				await rowFor( trashed.id ).hover();
-				await rowFor( trashed.id )
-					.getByRole( 'button', { name: 'Trash' } )
-					.click();
+				await moderate( trashed.id, 'Trash' );
 				await expect( rowFor( trashed.id ) ).toBeHidden();

 				await page.goto( `${ reviewsUrl }&comment_status=trash` );
diff --git a/plugins/woocommerce/tests/e2e/tests/product/product-search.spec.ts b/plugins/woocommerce/tests/e2e/tests/product/product-search.spec.ts
index 871aa9d7c39..5b41ad2503f 100644
--- a/plugins/woocommerce/tests/e2e/tests/product/product-search.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/product/product-search.spec.ts
@@ -53,8 +53,9 @@ test.describe( 'Products > Search and View a product', () => {
 	test( 'can find and open a product from a partial search', async ( {
 		page,
 	} ) => {
+		// Search the second half of the name, which holds its random suffix, so
+		// products from other workers don't match.
 		const searchString = testProduct.name.substring(
-			0,
 			testProduct.name.length / 2
 		);

diff --git a/plugins/woocommerce/tests/e2e/utils/data.ts b/plugins/woocommerce/tests/e2e/utils/data.ts
index 485d86b2433..0a7ac9c97b2 100644
--- a/plugins/woocommerce/tests/e2e/utils/data.ts
+++ b/plugins/woocommerce/tests/e2e/utils/data.ts
@@ -49,11 +49,16 @@ export function getFakeCustomer() {
 	return getFakeUser( 'customer' );
 }

+// The name ends in random characters because `faker.commerce.productName()` is
+// not unique: parallel workers can generate the same name, and specs that
+// search the store by product name would then find the wrong product.
 export function getFakeProduct( options: any = {} ) {
 	const dec = options.dec ?? 2;

 	return {
-		name: `${ faker.commerce.productName() }`,
+		name: `${ faker.commerce.productName() } ${ faker.string.alphanumeric(
+			8
+		) }`,
 		description: faker.commerce.productDescription(),
 		regular_price: options.regular_price
 			? options.regular_price