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