Commit b682d4fba7e for woocommerce
commit b682d4fba7e4e6fcd1de13a5634cc385e3454ab2
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Mon Sep 28 12:17:12 2026 +0300
[tests] Demote 3 Review Order page-state E2E tests to PHPUnit and Jest (#68638)
* test(order-review): Move review page states below E2E
The Review Order page spec runs eight browser titles on trunk: four
review journeys, three page-state checks, and one WP-CLI regression
title trunk added after the migration branch was cut. The three
page-state checks are the notice shown when a product has reviews
disabled, the empty state when an order has nothing to review, and
the inline error when someone submits text without a rating.
Move those three below the browser. The notice and the empty state
are rendered by PHP and already have PHPUnit owners on trunk, which
this commit does not touch. The inline error and the rest of the
page's client behaviour get a new jsdom suite for
client/legacy/js/frontend/order-review.js, covering the submit gate,
the rating guard, the dirty-row check, the error node and the AJAX
transport.
Keep the four journeys: the happy path, the pre-fill after a partial
submit, and the two variation cases. Between them they still
exercise the built asset and the theme-overridable row template.
Trunk's WP-CLI title stays untouched, so the file keeps five titles.
This spec is merged onto trunk's version rather than taken from the
migration branch. When the branch was cut this file had seven
titles; trunk has since added the WP-CLI rewrite-flush title and the
import it needs. Taking the branch's copy would have deleted both,
and that title is the only browser coverage of the fix it came with.
Consolidates the mega-branch commit, subject verbatim:
- test(order-reviews): Move page states below E2E
Refs TESTOPS-288
Refs #68046
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(order-reviews): Cover the empty-state nothing-to-review arm
The empty-state template has two arms keyed on $reviewed_count, and only
the positive one was covered. The heading, the body copy, and the absence
of the review form in the zero arm could all regress unnoticed.
An order whose only product has comments closed reaches that arm:
ItemEligibility::decide() marks the item SKIP, and skipped rows never set
$has_unreviewed_row, so the template renders with $reviewed_count at 0.
Proved by mutation: dropping the comments_open() guard from decide() makes
the test fail on the empty-state assertion; restoring it passes again.
Refs TESTOPS-288
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(order-reviews): Assert the disabled product renders no row and the rating-required copy is localized
The Review Order page states moved from Playwright into PHPUnit and
jsdom, and two behaviours the browser scenarios observed lost their
owner in the move.
The template counts a reviews-disabled item and then skips it, but the
PHPUnit owner only asserted that the notice and the form both render.
Dropping the skip left every test green while the disabled product got
a review row of its own. Separately, the four submission-outcome
strings the page localizes for `wc-order-review` were read by nothing:
the Jest suite injects its own copy, so the shipped wording could
change unnoticed.
Extend the notice test to count `class="woocommerce-review-order__item"`
occurrences and to name the two fixture products, so the reviewable one
is present and the disabled one absent. Add a test that drives
`gate_request()` and decodes the `wcOrderReview` payload from
`wp_scripts()`, asserting the four `i18n` strings verbatim. The script
and style registries are global and the base class does not own them,
so tear down the `wc-order-review` handle after every test to keep the
payload readable and the registries unchanged for later tests.
Rename `test_disabled_products_notice_renders_above_form` to
`test_reviews_disabled_item_renders_notice_and_no_form_row`: the test
never asserted the notice's position, and row suppression is now its
main claim.
Refs TESTOPS-288
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/testops-288-order-review-page b/plugins/woocommerce/changelog/testops-288-order-review-page
new file mode 100644
index 00000000000..e9c96e22a06
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-288-order-review-page
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Move Review Order page states below E2E; three page-state titles move to PHPUnit and jsdom.
+
diff --git a/plugins/woocommerce/client/legacy/js/frontend/test/order-review.js b/plugins/woocommerce/client/legacy/js/frontend/test/order-review.js
new file mode 100644
index 00000000000..45954498c01
--- /dev/null
+++ b/plugins/woocommerce/client/legacy/js/frontend/test/order-review.js
@@ -0,0 +1,153 @@
+/**
+ * @jest-environment jest-fixed-jsdom
+ */
+
+function TestFormData( form ) {
+ this.form = form;
+}
+
+describe( 'Review Order frontend behavior', () => {
+ let originalFetch;
+ let originalGlobalFetch;
+ let originalFormData;
+ let originalWcOrderReview;
+ let originalReadyState;
+
+ function initializeReviewOrder() {
+ originalReadyState = Object.getOwnPropertyDescriptor(
+ document,
+ 'readyState'
+ );
+ Object.defineProperty( document, 'readyState', {
+ configurable: true,
+ value: 'complete',
+ } );
+
+ jest.resetModules();
+ require( '../order-review' );
+
+ if ( originalReadyState ) {
+ Object.defineProperty( document, 'readyState', originalReadyState );
+ } else {
+ delete document.readyState;
+ }
+ }
+
+ beforeEach( () => {
+ originalFetch = window.fetch;
+ originalGlobalFetch = global.fetch;
+ originalFormData = window.FormData;
+ originalWcOrderReview = window.wcOrderReview;
+ } );
+
+ afterEach( () => {
+ document.body.replaceChildren();
+ jest.resetModules();
+ jest.restoreAllMocks();
+ window.fetch = originalFetch;
+ global.fetch = originalGlobalFetch;
+ window.FormData = originalFormData;
+
+ if ( undefined === originalWcOrderReview ) {
+ delete window.wcOrderReview;
+ } else {
+ window.wcOrderReview = originalWcOrderReview;
+ }
+ } );
+
+ test( 'dismisses disabled-products notice with the production hidden class', () => {
+ document.body.innerHTML = `
+ <div class="woocommerce-review-order__notice">
+ <button class="woocommerce-review-order__notice-dismiss" type="button">Dismiss</button>
+ </div>
+ `;
+
+ initializeReviewOrder();
+
+ const notice = document.querySelector(
+ '.woocommerce-review-order__notice'
+ );
+ notice
+ .querySelector( '.woocommerce-review-order__notice-dismiss' )
+ .click();
+
+ expect(
+ notice.classList.contains(
+ 'woocommerce-review-order__notice--hidden'
+ )
+ ).toBe( true );
+ } );
+
+ test( 'blocks a text-only review until a rating is selected', async () => {
+ const ajaxUrl = 'https://example.test/wp-admin/admin-ajax.php';
+ const ratingRequired = 'A rating is required before submitting.';
+ window.wcOrderReview = {
+ i18n: { rating_required: ratingRequired },
+ };
+ window.fetch = jest.fn( () =>
+ Promise.resolve( {
+ json: () =>
+ Promise.resolve( { success: true, data: { results: {} } } ),
+ } )
+ );
+ global.fetch = window.fetch;
+ window.FormData = TestFormData;
+ document.body.innerHTML = `
+ <form class="woocommerce-review-order__form" data-ajax-url="${ ajaxUrl }">
+ <div class="woocommerce-review-order__item" data-initial-rating="0" data-initial-text="">
+ <h3 class="woocommerce-review-order__item-title">Product</h3>
+ <div class="woocommerce-star-rating">
+ <input class="woocommerce-star-rating__input" name="rating" type="radio" value="5" data-label="5 stars" />
+ </div>
+ <textarea class="woocommerce-review-order__item-review-textarea" name="review"></textarea>
+ </div>
+ <button class="woocommerce-review-order__submit" type="submit">Submit</button>
+ </form>
+ `;
+
+ initializeReviewOrder();
+
+ const form = document.querySelector(
+ '.woocommerce-review-order__form'
+ );
+ const rating = document.querySelector(
+ '.woocommerce-star-rating__input'
+ );
+ const textarea = form.querySelector(
+ '.woocommerce-review-order__item-review-textarea'
+ );
+ const submit = form.querySelector(
+ '.woocommerce-review-order__submit'
+ );
+
+ expect( submit.disabled ).toBe( true );
+
+ textarea.value = 'Text-only review';
+ textarea.dispatchEvent( new Event( 'input', { bubbles: true } ) );
+
+ expect( submit.disabled ).toBe( false );
+ submit.click();
+
+ expect( form.querySelectorAll( '[role="alert"]' ) ).toHaveLength( 1 );
+ expect( form.querySelector( '[role="alert"]' ).textContent ).toBe(
+ ratingRequired
+ );
+ expect( window.fetch ).not.toHaveBeenCalled();
+
+ rating.checked = true;
+ rating.dispatchEvent( new Event( 'change', { bubbles: true } ) );
+
+ expect( form.querySelectorAll( '[role="alert"]' ) ).toHaveLength( 0 );
+ expect( submit.disabled ).toBe( false );
+
+ submit.click();
+
+ expect( window.fetch ).toHaveBeenCalledTimes( 1 );
+ expect( window.fetch ).toHaveBeenCalledWith(
+ ajaxUrl,
+ expect.objectContaining( { method: 'POST' } )
+ );
+
+ await Promise.resolve();
+ } );
+} );
diff --git a/plugins/woocommerce/tests/e2e/tests/order/review-order-page.spec.ts b/plugins/woocommerce/tests/e2e/tests/order/review-order-page.spec.ts
index 0042f59c1d7..16931f7bc30 100644
--- a/plugins/woocommerce/tests/e2e/tests/order/review-order-page.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/order/review-order-page.spec.ts
@@ -534,131 +534,10 @@ test.describe(
}
} );
- test( 'Scenario 3 — per-product reviews disabled hides the row and shows the dismissible notice', async ( {
- page,
- restApi,
- } ) => {
- const { order, productIds } = await seedCompletedOrder( restApi, [
- { name: 'CRR Reviewable' },
- {
- name: 'CRR Reviews Off',
- reviews_allowed: false,
- },
- ] );
-
- try {
- await page.goto( reviewOrderUrl( order ) );
-
- const rows = page.locator( '.woocommerce-review-order__item' );
- await expect( rows ).toHaveCount( 1 );
- await expect( rows.nth( 0 ) ).toContainText( 'CRR Reviewable' );
-
- const notice = page.locator(
- '.woocommerce-review-order__notice'
- );
- await expect( notice ).toBeVisible();
- await expect( notice ).toContainText(
- "Don't see all your products?"
- );
-
- await page
- .locator( '.woocommerce-review-order__notice-dismiss' )
- .click();
- await expect( notice ).toBeHidden();
- } finally {
- await cleanupOrder( restApi, order.id );
- await cleanupProducts( restApi, productIds );
- }
- } );
-
- test( 'Scenario 4 — order with no reviewable items renders the empty-state thank-you', async ( {
- page,
- restApi,
- } ) => {
- // All items have reviews_allowed:false → has_actionable_items()
- // returns false → empty-state renders. Same template branch the
- // site-wide-reviews-disabled gate hits, without mutating a global
- // option that could leak into other tests if this one times out.
- const { order, productIds } = await seedCompletedOrder( restApi, [
- { name: 'CRR No Reviews', reviews_allowed: false },
- ] );
-
- try {
- await page.goto( reviewOrderUrl( order ) );
-
- await expect(
- page.getByRole( 'heading', {
- name: 'Nothing to review here',
- } )
- ).toBeVisible();
- await expect(
- page.locator( '.woocommerce-review-order__form' )
- ).toHaveCount( 0 );
- await expect(
- page.locator( '.woocommerce-review-order__submit' )
- ).toHaveCount( 0 );
- } finally {
- await cleanupOrder( restApi, order.id );
- await cleanupProducts( restApi, productIds );
- }
- } );
-
// Note: cancellation-unschedules-action coverage lives in PHPUnit
// (SubmissionHandlerTest); the admin Scheduled Actions UI proved too
// fragile for E2E across shards.
- test( 'Scenario 6 — typing review text without a rating surfaces the inline error', async ( {
- page,
- restApi,
- } ) => {
- const { order, productIds } = await seedCompletedOrder( restApi, [
- { name: 'CRR Rating Required' },
- ] );
-
- try {
- await page.goto( reviewOrderUrl( order ) );
-
- const row = page
- .locator( '.woocommerce-review-order__item' )
- .first();
- await row.locator( 'textarea' ).fill( 'Loved it.' );
- await page
- .locator( '.woocommerce-review-order__submit' )
- .click();
-
- const error = row.locator(
- '.woocommerce-review-order__item-rating-error'
- );
- await expect( error ).toBeVisible();
- await expect( error ).toContainText(
- 'Please rate this product before submitting your review.'
- );
- // Form did not submit.
- await expect(
- page.getByRole( 'heading', {
- name: 'Thank you for your reviews',
- } )
- ).toHaveCount( 0 );
-
- // Selecting a rating clears the error.
- await rateRow( row, 5 );
- await expect( error ).toBeHidden();
-
- // Submitting now succeeds.
- await page
- .locator( '.woocommerce-review-order__submit' )
- .click();
- await expect(
- page.getByRole( 'heading', {
- name: 'Thank you for your reviews',
- } )
- ).toBeVisible();
- } finally {
- await cleanupOrder( restApi, order.id );
- await cleanupProducts( restApi, productIds );
- }
- } );
-
test( 'Variations — two variations of one parent render two distinct rows with their attribute summaries', async ( {
page,
restApi,
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/EndpointTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/EndpointTest.php
index 1c03a58ac89..98c8196f694 100644
--- a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/EndpointTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/EndpointTest.php
@@ -54,7 +54,10 @@ class EndpointTest extends WC_Unit_Test_Case {
}
/**
- * Reset $_GET, the global query, and any logged-in user between tests.
+ * Reset $_GET, the global query, any logged-in user, and the Review Order
+ * assets between tests. The script and style registries are global and the
+ * base class does not own them, so a test that reaches `enqueue_assets()`
+ * would otherwise leave the handle registered for the rest of the process.
*/
public function tearDown(): void {
$_GET = array();
@@ -65,6 +68,10 @@ class EndpointTest extends WC_Unit_Test_Case {
wp_reset_postdata();
wp_set_current_user( 0 );
delete_option( 'woocommerce_feature_customer_review_request_enabled' );
+ wp_dequeue_script( 'wc-order-review' );
+ wp_deregister_script( 'wc-order-review' );
+ wp_dequeue_style( 'wc-order-review' );
+ wp_deregister_style( 'wc-order-review' );
parent::tearDown();
}
@@ -548,12 +555,12 @@ class EndpointTest extends WC_Unit_Test_Case {
}
/**
- * @testdox The disabled-products info notice renders when at least one order item is STATUS_SKIP and the form is still active.
+ * @testdox An item whose product has reviews closed is counted by the info notice and renders no form row, while the reviewable item still gets one.
*/
- public function test_disabled_products_notice_renders_above_form(): void {
+ public function test_reviews_disabled_item_renders_notice_and_no_form_row(): void {
$order = OrderHelper::create_order();
- $reviewable = WC_Helper_Product::create_simple_product();
- $disabled = WC_Helper_Product::create_simple_product();
+ $reviewable = WC_Helper_Product::create_simple_product( true, array( 'name' => 'Reviewable Blue Lamp' ) );
+ $disabled = WC_Helper_Product::create_simple_product( true, array( 'name' => 'Closed Red Kettle' ) );
wp_update_post(
array(
'ID' => $disabled->get_id(),
@@ -575,6 +582,16 @@ class EndpointTest extends WC_Unit_Test_Case {
$this->assertStringContainsString( 'woocommerce-info woocommerce-review-order__notice', $html );
$this->assertStringContainsString( 'see all your products?', $html );
$this->assertStringContainsString( 'woocommerce-review-order__form', $html );
+
+ // `customer-review-order-row.php` opens each row with this exact class
+ // attribute, so the occurrence count is the number of rendered rows.
+ $this->assertSame(
+ 1,
+ substr_count( $html, 'class="woocommerce-review-order__item"' ),
+ 'Only the item whose product accepts reviews should render a form row.'
+ );
+ $this->assertStringContainsString( 'Reviewable Blue Lamp', $html );
+ $this->assertStringNotContainsString( 'Closed Red Kettle', $html );
}
/**
@@ -614,6 +631,83 @@ class EndpointTest extends WC_Unit_Test_Case {
$this->assertStringContainsString( 'Your feedback helps', $html );
}
+ /**
+ * @testdox The empty-state template renders the nothing-to-review arm, and no form, when the order has no reviewable items.
+ */
+ public function test_empty_state_template_renders_nothing_to_review(): void {
+ $order = OrderHelper::create_order();
+ $product = WC_Helper_Product::create_simple_product();
+ $order->set_billing_email( 'nothing@example.test' );
+ $order->set_status( OrderStatus::COMPLETED );
+ foreach ( $order->get_items() as $item ) {
+ $order->remove_item( $item->get_id() );
+ }
+ $order->add_product( $product, 1 );
+ $order->save();
+
+ // `ItemEligibility::decide()` marks an item SKIP when `comments_open()`
+ // is false, and skipped rows never set `$has_unreviewed_row`. The order
+ // therefore reaches the empty-state template with `$reviewed_count` at
+ // 0, which is the arm the thank-you test above does not exercise.
+ wp_update_post(
+ array(
+ 'ID' => $product->get_id(),
+ 'comment_status' => 'closed',
+ )
+ );
+
+ $_GET = array( 'key' => $order->get_order_key() );
+
+ $html = $this->render( $order->get_id() );
+
+ $this->assertStringContainsString( 'woocommerce-review-order--empty', $html );
+ $this->assertStringContainsString( 'Nothing to review here', $html );
+ $this->assertStringContainsString( 'There are no products on this order that are open for reviews right now.', $html );
+ $this->assertStringNotContainsString( 'woocommerce-review-order__form', $html );
+ }
+
+ /**
+ * @testdox The wc-order-review script is localized with the four submission-outcome strings the frontend renders.
+ */
+ public function test_enqueued_script_localizes_the_submission_outcome_strings(): void {
+ $page_id = (int) wc_get_page_id( Endpoint::PAGE_KEY );
+
+ $order = OrderHelper::create_order();
+ $order->set_status( OrderStatus::COMPLETED );
+ $order->save();
+
+ // Stage the globals gate_request() reads before it enqueues the assets:
+ // `is_page( review_order_page_id )`, the order id query var, and the key.
+ global $wp, $wp_query, $wp_the_query;
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test fixture: singular page query so is_page() returns true.
+ $wp_query = new WP_Query( array( 'page_id' => $page_id ) );
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test fixture: matching main query.
+ $wp_the_query = $wp_query;
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- test fixture: stage a fresh WP instance carrying our query var.
+ $wp = new \WP();
+ $wp->query_vars[ Endpoint::QUERY_VAR ] = (string) $order->get_id();
+ $_GET = array( 'key' => $order->get_order_key() );
+
+ $this->endpoint->gate_request();
+
+ $data = (string) wp_scripts()->get_data( 'wc-order-review', 'data' );
+ $this->assertStringStartsWith( 'var wcOrderReview = ', $data );
+
+ $payload = json_decode( rtrim( substr( $data, strpos( $data, '=' ) + 1 ), " \t\n;" ), true );
+ $this->assertIsArray( $payload, 'The localized wcOrderReview payload should be valid JSON.' );
+
+ $this->assertEquals(
+ array(
+ 'ok' => 'Thanks, your review is live.',
+ 'pending_moderation' => 'Thanks, your review is pending approval.',
+ 'error' => 'Something went wrong, please try again.',
+ 'rating_required' => 'Please rate this product before submitting your review.',
+ ),
+ $payload['i18n'] ?? null,
+ 'The frontend renders these strings verbatim, so the page must ship them unchanged.'
+ );
+ }
+
/**
* @testdox A pre-filled row exposes the existing rating and text via data-initial-* attributes so the JS dirty gate can detect edits.
*/