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.
 	 */