Commit c8506e9a8f1 for woocommerce

commit c8506e9a8f18eae24c8107abed5c7787a3e56c60
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date:   Sun May 17 03:44:22 2026 +0300

    Add Playwright E2E coverage for the Review Order page (#65021)

diff --git a/plugins/woocommerce/tests/e2e-pw/tests/order/review-order-page.spec.ts b/plugins/woocommerce/tests/e2e-pw/tests/order/review-order-page.spec.ts
new file mode 100644
index 00000000000..1466ae224ea
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/tests/order/review-order-page.spec.ts
@@ -0,0 +1,618 @@
+/**
+ * E2E coverage for the Customer Review Request → Review Order page.
+ * Products/orders seeded via REST for speed; everything else via UI.
+ * Tracked as WOOPLUG-6601 (Linear).
+ */
+
+/**
+ * External dependencies
+ */
+import { request, type Locator } from '@playwright/test';
+import {
+	ApiClient,
+	WC_API_PATH,
+	WP_API_PATH,
+} from '@woocommerce/e2e-utils-playwright';
+
+/**
+ * Internal dependencies
+ */
+import { tags, expect, test } from '../../fixtures/fixtures';
+import { setOption, deleteOption } from '../../utils/options';
+import { random } from '../../utils/helpers';
+
+type SeededOrder = {
+	id: number;
+	key: string;
+};
+
+// `page` is logged-out (Review Order is guest-accessible via order key);
+// `restApi` is admin basic-auth.
+
+const FEATURE_FLAG_OPTION =
+	'woocommerce_feature_customer_review_request_enabled';
+
+test.describe(
+	'Customer Review Request — Review Order page',
+	{ tag: [ tags.SERVICES, tags.HPOS ] },
+	() => {
+		// Host page permalink. The page is created by the OrderReviews
+		// Endpoint on `init` after the feature flag is enabled; we look it up
+		// once in beforeAll and reuse it for every URL we build.
+		let hostPagePermalink = '';
+
+		test.beforeAll( async ( { baseURL, restApi } ) => {
+			await setOption(
+				request,
+				baseURL || '',
+				FEATURE_FLAG_OPTION,
+				'yes'
+			);
+
+			// First REST call after enabling the flag boots WP with init
+			// firing again; that's when the host page is created.
+			const { data } = await restApi.get(
+				`${ WP_API_PATH }/pages?slug=review-order`,
+				{ data: { _fields: [ 'id', 'link' ] } }
+			);
+			hostPagePermalink = data?.[ 0 ]?.link || '';
+
+			// Surface the host-page creation race as a clear setup failure
+			// instead of letting every scenario fail with a confusing 404.
+			if ( ! hostPagePermalink ) {
+				throw new Error(
+					'Review Order host page was not created after enabling the feature flag.'
+				);
+			}
+		} );
+
+		test.afterAll( async ( { baseURL } ) => {
+			await deleteOption( request, baseURL || '', FEATURE_FLAG_OPTION );
+		} );
+
+		// Matches `Endpoint::get_url()` for pretty permalinks (the e2e env
+		// runs /%postname%/): /review-order/{id}/?key={key}.
+		const reviewOrderUrl = ( order: SeededOrder ): string =>
+			`${ hostPagePermalink }${ order.id }/?key=${ order.key }`;
+
+		// Star inputs are CSS-hidden behind their <label>s for a11y; clicking
+		// the label triggers the radio without needing { force: true }.
+		const rateRow = ( row: Locator, stars: number ) =>
+			row
+				.locator( 'label.woocommerce-star-rating__star' )
+				.filter( {
+					hasText: new RegExp( `${ stars } out of 5 stars` ),
+				} )
+				.click();
+
+		// Cleanup helpers log on failure rather than throw — cleanup runs in
+		// `finally`, so throwing here would mask the real test error.
+		const cleanupProducts = async ( restApi: ApiClient, ids: number[] ) => {
+			for ( const id of ids ) {
+				if ( id <= 0 ) {
+					continue;
+				}
+				await restApi
+					.delete( `${ WC_API_PATH }/products/${ id }`, {
+						force: true,
+					} )
+					.catch( ( err ) => {
+						// eslint-disable-next-line no-console -- surface unexpected teardown errors without masking the test failure.
+						console.warn(
+							`Failed to delete product ${ id }:`,
+							err
+						);
+					} );
+			}
+		};
+
+		const cleanupOrder = async ( restApi: ApiClient, id: number ) => {
+			if ( id <= 0 ) {
+				return;
+			}
+			await restApi
+				.delete( `${ WC_API_PATH }/orders/${ id }`, { force: true } )
+				.catch( ( err ) => {
+					// eslint-disable-next-line no-console -- surface unexpected teardown errors without masking the test failure.
+					console.warn( `Failed to delete order ${ id }:`, err );
+				} );
+		};
+
+		const buildBillingEmail = ( prefix: string ): string =>
+			`${ prefix }+${ random() }@example.test`;
+
+		/**
+		 * Create N simple products plus a completed order containing them.
+		 * Cleans up its own partial state if any step throws.
+		 *
+		 * @param restApi        Playwright fixture rest client.
+		 * @param productConfigs One config per product. Each can override `reviews_allowed`.
+		 * @return Created order id + key + the product ids in input order + the billing email used.
+		 */
+		const seedCompletedOrder = async (
+			restApi: ApiClient,
+			productConfigs: Array< {
+				name?: string;
+				reviews_allowed?: boolean;
+			} >
+		): Promise< {
+			order: SeededOrder;
+			productIds: number[];
+			billingEmail: string;
+		} > => {
+			const productIds: number[] = [];
+			const billingEmail = buildBillingEmail( 'shopper' );
+			let orderId = 0;
+			try {
+				for ( const cfg of productConfigs ) {
+					const { data } = await restApi.post(
+						`${ WC_API_PATH }/products`,
+						{
+							name:
+								cfg.name ||
+								`Review Order Test Product ${ random() }`,
+							type: 'simple',
+							regular_price: '10',
+							reviews_allowed: cfg.reviews_allowed ?? true,
+						}
+					);
+					productIds.push( data.id );
+				}
+
+				const { data: order } = await restApi.post(
+					`${ WC_API_PATH }/orders`,
+					{
+						status: 'completed',
+						billing: {
+							first_name: 'Review',
+							last_name: 'Tester',
+							email: billingEmail,
+						},
+						line_items: productIds.map( ( id ) => ( {
+							product_id: id,
+							quantity: 1,
+						} ) ),
+					}
+				);
+				orderId = order.id;
+
+				return {
+					order: { id: order.id, key: order.order_key },
+					productIds,
+					billingEmail,
+				};
+			} catch ( err ) {
+				// Make sure a partial seed doesn't strand products / orders.
+				await cleanupOrder( restApi, orderId );
+				await cleanupProducts( restApi, productIds );
+				throw err;
+			}
+		};
+
+		/**
+		 * Create a variable product with `variationOptions.length` variations
+		 * of a single `Size` attribute, all reviewable, plus a completed order
+		 * containing each variation. Cleans up partial state on failure.
+		 */
+		const seedVariationOrder = async (
+			restApi: ApiClient,
+			variationOptions: string[]
+		): Promise< {
+			order: SeededOrder;
+			parentId: number;
+			variationIds: number[];
+			billingEmail: string;
+		} > => {
+			let parentId = 0;
+			let orderId = 0;
+			const variationIds: number[] = [];
+			const billingEmail = buildBillingEmail( 'variation-shopper' );
+			try {
+				const { data: parent } = await restApi.post(
+					`${ WC_API_PATH }/products`,
+					{
+						name: `Variable Review Test ${ random() }`,
+						type: 'variable',
+						attributes: [
+							{
+								name: 'Size',
+								visible: true,
+								variation: true,
+								options: variationOptions,
+							},
+						],
+					}
+				);
+				parentId = parent.id;
+
+				for ( const option of variationOptions ) {
+					const { data: variation } = await restApi.post(
+						`${ WC_API_PATH }/products/${ parent.id }/variations`,
+						{
+							regular_price: '10',
+							attributes: [ { name: 'Size', option } ],
+						}
+					);
+					variationIds.push( variation.id );
+				}
+
+				const { data: order } = await restApi.post(
+					`${ WC_API_PATH }/orders`,
+					{
+						status: 'completed',
+						billing: {
+							first_name: 'Variation',
+							last_name: 'Tester',
+							email: billingEmail,
+						},
+						line_items: variationIds.map( ( vid ) => ( {
+							product_id: parent.id,
+							variation_id: vid,
+							quantity: 1,
+						} ) ),
+					}
+				);
+				orderId = order.id;
+
+				return {
+					order: { id: order.id, key: order.order_key },
+					parentId,
+					variationIds,
+					billingEmail,
+				};
+			} catch ( err ) {
+				await cleanupOrder( restApi, orderId );
+				await cleanupProducts( restApi, [ parentId ] );
+				throw err;
+			}
+		};
+
+		test( 'Scenario 1 — happy path: rate a product, submit, see thank-you in place', async ( {
+			page,
+			restApi,
+		} ) => {
+			const { order, productIds } = await seedCompletedOrder( restApi, [
+				{ name: 'CRR Product A' },
+				{ name: 'CRR Product B' },
+			] );
+
+			try {
+				await page.goto( reviewOrderUrl( order ) );
+
+				await expect(
+					page.getByRole( 'heading', {
+						name: 'Review your order',
+					} )
+				).toBeVisible();
+				await expect(
+					page.locator( '.woocommerce-review-order__meta' )
+				).toContainText( `Order #${ order.id }` );
+
+				const rows = page.locator( '.woocommerce-review-order__item' );
+				await expect( rows ).toHaveCount( 2 );
+
+				const submit = page.locator(
+					'.woocommerce-review-order__submit'
+				);
+				await expect( submit ).toBeDisabled();
+
+				// Rate row A with 3 stars (label index counts from 1).
+				const firstRow = rows.nth( 0 );
+				await rateRow( firstRow, 3 );
+
+				// The dynamic caption reflects the chosen rating.
+				await expect(
+					firstRow.locator( '.woocommerce-star-rating__caption' )
+				).not.toBeEmpty();
+				await expect( submit ).toBeEnabled();
+
+				await firstRow.locator( 'textarea' ).fill( 'It was fine.' );
+
+				await submit.click();
+
+				await expect(
+					page.getByRole( 'heading', {
+						name: 'Thank you for your reviews',
+					} )
+				).toBeVisible();
+				// Meta line stays visible alongside the thank-you view.
+				await expect(
+					page.locator( '.woocommerce-review-order__meta' )
+				).toBeVisible();
+
+				// Verify the saved review via REST.
+				const reviewsResp = await restApi.get(
+					`${ WC_API_PATH }/products/reviews`,
+					{ product: productIds[ 0 ], status: 'approved' }
+				);
+				expect(
+					reviewsResp.data.find(
+						( r: { review: string; rating: number } ) =>
+							r.review.includes( 'It was fine' ) && r.rating === 3
+					)
+				).toBeTruthy();
+			} finally {
+				await cleanupOrder( restApi, order.id );
+				await cleanupProducts( restApi, productIds );
+			}
+		} );
+
+		test( 'Scenario 2 — refresh after partial submit pre-fills the submitted row', async ( {
+			page,
+			restApi,
+		} ) => {
+			const { order, productIds } = await seedCompletedOrder( restApi, [
+				{ name: 'CRR Refresh A' },
+				{ name: 'CRR Refresh B' },
+			] );
+			const url = reviewOrderUrl( order );
+
+			try {
+				await page.goto( url );
+
+				const rows = page.locator( '.woocommerce-review-order__item' );
+				const submit = page.locator(
+					'.woocommerce-review-order__submit'
+				);
+				const rowA = rows.nth( 0 );
+
+				await rateRow( rowA, 4 );
+				await rowA
+					.locator( 'textarea' )
+					.fill( 'Pre-filled by Scenario 2.' );
+				await submit.click();
+				await expect(
+					page.getByRole( 'heading', {
+						name: 'Thank you for your reviews',
+					} )
+				).toBeVisible();
+
+				// Refresh.
+				await page.goto( url );
+
+				const rowsAfter = page.locator(
+					'.woocommerce-review-order__item'
+				);
+				await expect( rowsAfter ).toHaveCount( 2 );
+
+				// Row A is pre-filled, row B is empty.
+				await expect(
+					rowsAfter.nth( 0 ).locator( 'textarea' )
+				).toHaveValue( 'Pre-filled by Scenario 2.' );
+				await expect(
+					rowsAfter
+						.nth( 0 )
+						.locator( 'input[type="radio"][value="4"]:checked' )
+				).toHaveCount( 1 );
+				await expect(
+					rowsAfter.nth( 1 ).locator( 'textarea' )
+				).toHaveValue( '' );
+
+				// Submit is disabled until a row diverges from its initial state.
+				await expect(
+					page.locator( '.woocommerce-review-order__submit' )
+				).toBeDisabled();
+				await rateRow( rowsAfter.nth( 1 ), 5 );
+				await expect(
+					page.locator( '.woocommerce-review-order__submit' )
+				).toBeEnabled();
+			} finally {
+				await cleanupOrder( restApi, order.id );
+				await cleanupProducts( restApi, productIds );
+			}
+		} );
+
+		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,
+		} ) => {
+			const { order, parentId } = await seedVariationOrder( restApi, [
+				'Small',
+				'Medium',
+			] );
+
+			try {
+				await page.goto( reviewOrderUrl( order ) );
+
+				const rows = page.locator( '.woocommerce-review-order__item' );
+				await expect( rows ).toHaveCount( 2 );
+
+				// Both rows show the variation attribute summary inside the title.
+				await expect(
+					rows
+						.nth( 0 )
+						.locator( '.woocommerce-review-order__item-variation' )
+				).toContainText( /Size:\s*Small/i );
+				await expect(
+					rows
+						.nth( 1 )
+						.locator( '.woocommerce-review-order__item-variation' )
+				).toContainText( /Size:\s*Medium/i );
+			} finally {
+				await cleanupOrder( restApi, order.id );
+				await cleanupProducts( restApi, [ parentId ] );
+			}
+		} );
+
+		test( 'Variations — submitting one variation leaves the sibling row open (per-variation tracking)', async ( {
+			page,
+			restApi,
+		} ) => {
+			const { order, parentId } = await seedVariationOrder( restApi, [
+				'Small',
+				'Medium',
+			] );
+
+			try {
+				await page.goto( reviewOrderUrl( order ) );
+
+				const rows = page.locator( '.woocommerce-review-order__item' );
+
+				// Submit only the Small variation.
+				await rateRow( rows.nth( 0 ), 5 );
+				await rows
+					.nth( 0 )
+					.locator( 'textarea' )
+					.fill( 'Small fit great.' );
+
+				await page
+					.locator( '.woocommerce-review-order__submit' )
+					.click();
+				await expect(
+					page.getByRole( 'heading', {
+						name: 'Thank you for your reviews',
+					} )
+				).toBeVisible();
+
+				// Reload — under per-variation tracking the sibling Medium row
+				// is still pending, so the form stays open with both rows
+				// visible. Under per-parent tracking, one review would close
+				// the whole form and the thank-you state would persist.
+				await page.goto( reviewOrderUrl( order ) );
+				await expect(
+					page.locator( '.woocommerce-review-order__item' )
+				).toHaveCount( 2 );
+				await expect(
+					page
+						.locator( '.woocommerce-review-order__item' )
+						.nth( 0 )
+						.locator( 'textarea' )
+				).toHaveValue( 'Small fit great.' );
+				await expect(
+					page
+						.locator( '.woocommerce-review-order__item' )
+						.nth( 1 )
+						.locator( 'textarea' )
+				).toHaveValue( '' );
+			} finally {
+				await cleanupOrder( restApi, order.id );
+				await cleanupProducts( restApi, [ parentId ] );
+			}
+		} );
+	}
+);