Commit 65a7573fe01 for woocommerce

commit 65a7573fe0183a24e8508e222fffc008bd26d817
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Tue Oct 27 15:49:58 2026 +0200

    [tests] Render the product data panel checkboxes, the all-view Approve review action and the reviews screen scripts in PHPUnit (#68855)

    * test(products): Render the product data panel and assert the Virtual and Downloadable checkboxes

    WC_Meta_Box_Product_Data_Test only exercised save(). Nothing asserted
    that the classic Product data panel renders the product type option
    checkboxes, so dropping either one from the view loop stayed green.

    Render the panel through WC_Meta_Box_Product_Data::output() for a
    virtual product and for a downloadable one, then assert with
    WP_HTML_Tag_Processor that each checkbox exists, posts under its own
    name, and carries the checked attribute only for the matching product.
    The panel views read the edited post from the global, so $post joins
    the globals the class already captures and restores.

    Refs TESTOPS-288

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(reviews): Reset the comment view globals between reviews list table tests

    The reviews list table reads the current view from the $comment_status
    and $comment_type globals, and WordPress teardown resets neither. The
    tests that set them left them set, so test_set_review_type expected a
    null type only when its data set ran first, and test_get_views expected
    comment_type=other in its URLs without ever setting it. Under
    --order-by=random the class failed on every seed tried.

    Capture and unset both globals in setUp(), restore them in tearDown(),
    and set comment_type in test_get_views to the value its expected URLs
    carry. Seeds 11, 4242 and 20260917 now pass.

    Refs TESTOPS-288

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(reviews): Assert the all-view row actions offer Approve and Unapprove

    handle_row_actions() builds its links down two branches. Every data set
    of the existing test pinned $comment_status to a filtered view, so the
    branch that serves the default all view was never rendered and could be
    removed without a failure.

    Add a test that runs the same seam with the view set to all, and with no
    view set at all, and asserts that both the Approve and the Unapprove link
    appear with their own aria-labels and with the data-wp-lists values that
    dim the row in place rather than removing it. Rename the existing test
    and its provider to say that they cover a status-filtered view.

    Refs TESTOPS-288

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(reviews): Assert the reviews screen enqueues the comment scripts

    load_javascript() enqueues core's admin-comments script and calls
    enqueue_comment_hotkeys_js(). Together they are what makes the review
    row actions work without a page reload: approve, spam, trash with undo,
    and the keyboard shortcuts for a user who turned them on. Nothing
    asserted either one, so dropping them left the screen rendering its row
    actions as plain links.

    Assert both enqueues on the reviews screen and their absence elsewhere,
    with the current user opted into comment shortcuts so the hotkeys path
    runs. WordPress registers the admin-only default scripts once, when the
    registry is first built, which in a test run happens before any screen
    is current, so both tests make an admin screen current, rebuild the
    registry, and restore it afterwards.

    Refs TESTOPS-288

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test: Verify review approval in the all-reviews E2E

    The moderation E2E approved reviews from Pending, leaving the All view's
    in-place approval behavior covered only by literal PHP markup assertions.

    Return to All before approving and check that the page does not navigate,
    the row stays visible, and Unapprove appears. Keep the Approved-view check
    to verify persistence. Remove the superseded PHPUnit case and link parser.

    The updated E2E fails when All uses the filtered view's delete action,
    and passes after restoring its dim action. All 191 affected PHP tests pass.

    Refs TESTOPS-288

    * chore: Update the review coverage changelog

    Describe the revised product editor and review moderation test coverage
    in a dev-only comment. This keeps the internal test change out of merchant
    release notes and removes the outdated PHPUnit-only description.

    Refs TESTOPS-288

    ---------

    Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/testops-288-restore-product-editor-and-reviews-coverage b/plugins/woocommerce/changelog/testops-288-restore-product-editor-and-reviews-coverage
new file mode 100644
index 00000000000..d10194755da
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-288-restore-product-editor-and-reviews-coverage
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Improve classic product editor and review moderation tests; no production change.
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 c3519f748fb..a4604e919b9 100644
--- a/plugins/woocommerce/tests/e2e/tests/product/product-reviews.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/product/product-reviews.spec.ts
@@ -2,6 +2,7 @@
  * External dependencies
  */
 import { faker } from '@faker-js/faker';
+import { Frame } from '@playwright/test';
 import { WC_API_PATH } from '@woocommerce/e2e-utils-playwright';

 /**
@@ -116,25 +117,9 @@ test.describe( 'Product Reviews', () => {
 			).toContainText( review.product_name );
 		} );

-		// Canary for the moderation actions. This batch moved review edit and reply
-		// to ReviewsAjaxTest, but approve, spam and trash have no PHP coverage of
-		// their state transition: ReviewsListTableTest::test_handle_row_actions
-		// asserts only that the row renders those links. Nothing else in the suite
-		// moderates a review.
-		//
-		// Every step checks the review's new status by loading the matching
-		// filtered view rather than by looking at the row's own buttons. The list
-		// renders both Approve and Unapprove in the all view regardless of status,
-		// so asserting on them proves nothing -- an earlier version of this test
-		// did exactly that and survived two mutations of the approve path.
-		//
-		// 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.
+		// Approve from All must update the row in place through WordPress JavaScript.
+		// Filtered views also verify that each moderation action persisted its status.
+		// Wait for AJAX before navigating, since the row changes before the request finishes.
 		test( 'can approve, spam and trash a product review', async ( {
 			page,
 			reviews,
@@ -204,7 +189,24 @@ test.describe( 'Product Reviews', () => {
 				await page.goto( `${ reviewsUrl }&comment_status=moderated` );
 				await expect( rowFor( moderated.id ) ).toBeVisible();

-				await moderate( moderated.id, 'Approve' );
+				await page.goto( reviewsUrl );
+				const navigatedFrames: Frame[] = [];
+				const recordNavigation = ( frame: Frame ) => {
+					navigatedFrames.push( frame );
+				};
+				page.on( 'framenavigated', recordNavigation );
+				try {
+					await moderate( moderated.id, 'Approve' );
+					await expect( rowFor( moderated.id ) ).toBeVisible();
+					await expect(
+						rowFor( moderated.id ).getByRole( 'button', {
+							name: 'Unapprove',
+						} )
+					).toBeVisible();
+					expect( navigatedFrames ).not.toContain( page.mainFrame() );
+				} finally {
+					page.off( 'framenavigated', recordNavigation );
+				}

 				await page.goto( `${ reviewsUrl }&comment_status=approved` );
 				await expect( rowFor( moderated.id ) ).toBeVisible();
diff --git a/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-product-data-test.php b/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-product-data-test.php
index 4032850dd5d..540eb0ff681 100644
--- a/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-product-data-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-product-data-test.php
@@ -41,7 +41,7 @@ class WC_Meta_Box_Product_Data_Test extends WC_Unit_Test_Case {
 	 * The base class already empties the request globals, rolls back every
 	 * database write and resets the current user, so only the two things it
 	 * does not own are captured here: the static meta-box error list and the
-	 * three product globals the classic save seam sets.
+	 * post and product globals the classic save and render seams set.
 	 */
 	public function setUp(): void {
 		parent::setUp();
@@ -49,7 +49,7 @@ class WC_Meta_Box_Product_Data_Test extends WC_Unit_Test_Case {
 		$this->original_meta_box_errors = WC_Admin_Meta_Boxes::$meta_box_errors;
 		$this->download_directories     = wc_get_container()->get( Download_Directories::class );

-		foreach ( array( 'product', 'product_object', 'thepostid' ) as $global_name ) {
+		foreach ( array( 'post', 'product', 'product_object', 'thepostid' ) as $global_name ) {
 			$this->original_globals[ $global_name ] = array(
 				'present' => array_key_exists( $global_name, $GLOBALS ),
 				'value'   => $GLOBALS[ $global_name ] ?? null,
@@ -62,7 +62,7 @@ class WC_Meta_Box_Product_Data_Test extends WC_Unit_Test_Case {
 	}

 	/**
-	 * Restore the meta-box error list and the product globals.
+	 * Restore the meta-box error list and the post and product globals.
 	 */
 	public function tearDown(): void {
 		try {
@@ -157,6 +157,97 @@ class WC_Meta_Box_Product_Data_Test extends WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox The product data panel renders the Virtual and Downloadable checkboxes with the stored product state.
+	 * @dataProvider product_type_option_provider
+	 *
+	 * @param bool $virtual Whether the rendered product is virtual.
+	 * @param bool $downloadable Whether the rendered product is downloadable.
+	 */
+	public function test_output_renders_the_virtual_and_downloadable_checkboxes( bool $virtual, bool $downloadable ): void {
+		$product = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'name'         => 'Product Type Option Product',
+				'status'       => 'publish',
+				'virtual'      => $virtual,
+				'downloadable' => $downloadable,
+			)
+		);
+
+		$panel = $this->render_product_data_panel( $product );
+
+		$this->assert_product_type_checkbox( $panel, '_virtual', $virtual );
+		$this->assert_product_type_checkbox( $panel, '_downloadable', $downloadable );
+	}
+
+	/**
+	 * Provide named product type option rows.
+	 *
+	 * @return array<string, array{bool, bool}>
+	 */
+	public static function product_type_option_provider(): array {
+		return array(
+			'virtual only'      => array( true, false ),
+			'downloadable only' => array( false, true ),
+		);
+	}
+
+	/**
+	 * Render the classic product data panel for a product.
+	 *
+	 * @param WC_Product $product Product to render.
+	 * @return string
+	 */
+	private function render_product_data_panel( WC_Product $product ): string {
+		$post = get_post( $product->get_id() );
+		$this->assertInstanceOf( WP_Post::class, $post, 'The product fixture should have a persisted post.' );
+
+		$GLOBALS['post'] = $post; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- The panel views read the edited post from the global; tearDown restores it.
+
+		ob_start();
+		try {
+			WC_Meta_Box_Product_Data::output( $post );
+		} finally {
+			$panel = (string) ob_get_clean();
+		}
+
+		return $panel;
+	}
+
+	/**
+	 * Assert one product type option checkbox and its checked state.
+	 *
+	 * @param string $panel Rendered product data panel.
+	 * @param string $checkbox_id Value of the checkbox `id` and `name` attributes.
+	 * @param bool   $expected_checked Whether the checkbox should carry the `checked` attribute.
+	 */
+	private function assert_product_type_checkbox( string $panel, string $checkbox_id, bool $expected_checked ): void {
+		$tags       = new WP_HTML_Tag_Processor( $panel );
+		$attributes = null;
+
+		while ( $tags->next_tag( array( 'tag_name' => 'INPUT' ) ) ) {
+			if ( $checkbox_id === $tags->get_attribute( 'id' ) ) {
+				$attributes = array(
+					'type'    => $tags->get_attribute( 'type' ),
+					'name'    => $tags->get_attribute( 'name' ),
+					'checked' => $tags->get_attribute( 'checked' ),
+				);
+				break;
+			}
+		}
+
+		$this->assertIsArray( $attributes, "The product data panel should render the {$checkbox_id} checkbox." );
+		$this->assertSame( 'checkbox', $attributes['type'], "The {$checkbox_id} field should render as a checkbox." );
+		$this->assertSame( $checkbox_id, $attributes['name'], "The {$checkbox_id} checkbox should post under its own name." );
+
+		if ( $expected_checked ) {
+			$this->assertSame( 'checked', $attributes['checked'], "The {$checkbox_id} checkbox should be checked." );
+		} else {
+			$this->assertNull( $attributes['checked'], "The {$checkbox_id} checkbox should not be checked." );
+		}
+	}
+
 	/**
 	 * @testdox The classic editor saves MPNs, preserving omitted values and allowing explicit clearing.
 	 * @testWith [null, "ORIGINAL"]
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/ProductReviews/ReviewsListTableTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/ProductReviews/ReviewsListTableTest.php
index 84fdfa5d9c8..90addee2f11 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/ProductReviews/ReviewsListTableTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/ProductReviews/ReviewsListTableTest.php
@@ -22,6 +22,49 @@ use WC_Unit_Test_Case;
  */
 class ReviewsListTableTest extends WC_Unit_Test_Case {

+	/**
+	 * Presence and value of the comment view globals before the test.
+	 *
+	 * @var array<string, array{present: bool, value: mixed}>
+	 */
+	private $original_comment_globals = array();
+
+	/**
+	 * Start every test from an unset comment view.
+	 *
+	 * The list table reads the current view from `$comment_status` and `$comment_type`, and WordPress
+	 * teardown does not reset either one, so a test that leaves one set decides what the next test sees.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		foreach ( array( 'comment_status', 'comment_type' ) as $global_name ) {
+			$this->original_comment_globals[ $global_name ] = array(
+				'present' => array_key_exists( $global_name, $GLOBALS ),
+				'value'   => $GLOBALS[ $global_name ] ?? null,
+			);
+
+			unset( $GLOBALS[ $global_name ] );
+		}
+	}
+
+	/**
+	 * Restore the comment view globals.
+	 */
+	public function tearDown(): void {
+		try {
+			foreach ( $this->original_comment_globals as $global_name => $global ) {
+				if ( $global['present'] ) {
+					$GLOBALS[ $global_name ] = $global['value']; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the exact pre-test comment view global.
+				} else {
+					unset( $GLOBALS[ $global_name ] );
+				}
+			}
+		} finally {
+			parent::tearDown();
+		}
+	}
+
 	/**
 	 * Returns a new instance of the {@see \Automattic\WooCommerce\Internal\Admin\ProductReviews\ReviewsListTable} class.
 	 *
@@ -138,10 +181,10 @@ class ReviewsListTableTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox `handle_row_actions` displays admin action links pertaining each review or comment.
+	 * @testdox `handle_row_actions` displays the admin action links for a review in a status-filtered view.
 	 *
 	 * @covers \Automattic\WooCommerce\Internal\Admin\ProductReviews\ReviewsListTable::handle_row_actions()
-	 * @dataProvider data_provider_test_handle_row_actions
+	 * @dataProvider data_provider_test_handle_row_actions_in_a_status_filtered_view
 	 *
 	 * @param string $review_status  The review status.
 	 * @param string $column_name    The current column name being output.
@@ -151,7 +194,7 @@ class ReviewsListTableTest extends WC_Unit_Test_Case {
 	 * @return void
 	 * @throws ReflectionException If the method does not exist.
 	 */
-	public function test_handle_row_actions( string $review_status, string $column_name, string $primary_column, bool $user_can_edit ): void {
+	public function test_handle_row_actions_in_a_status_filtered_view( string $review_status, string $column_name, string $primary_column, bool $user_can_edit ): void {
 		global $comment_status;

 		$comment_status = 'test'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
@@ -270,8 +313,8 @@ class ReviewsListTableTest extends WC_Unit_Test_Case {
 		$this->assertSame( $expected[ $review_status ], $actual );
 	}

-	/** @see test_handle_row_actions */
-	public function data_provider_test_handle_row_actions(): Generator {
+	/** @see test_handle_row_actions_in_a_status_filtered_view */
+	public function data_provider_test_handle_row_actions_in_a_status_filtered_view(): Generator {
 		yield 'Not the primary column'   => array( 'foo', 'bar', 'baz', true );
 		yield 'User cannot edit reviews' => array( 'test', 'foo', 'foo', false );
 		yield 'Approved review'          => array( '1', 'foo', 'foo', true );
@@ -1907,8 +1950,9 @@ class ReviewsListTableTest extends WC_Unit_Test_Case {
 	 * @throws ReflectionException If the method doesn't exist.
 	 */
 	public function test_get_views(): void {
-		global $comment_status;
+		global $comment_status, $comment_type;
 		$comment_status = 'all'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+		$comment_type   = 'other'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited

 		$list_table = $this->get_reviews_list_table();
 		$method     = ( new ReflectionClass( $list_table ) )->getMethod( 'get_views' );
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/ProductReviews/ReviewsTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/ProductReviews/ReviewsTest.php
index e72f882d711..3bbe6e4a4df 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/ProductReviews/ReviewsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/ProductReviews/ReviewsTest.php
@@ -10,6 +10,7 @@ use ReflectionException;
 use stdClass;
 use WC_Unit_Test_Case;
 use WP_Comment;
+use WP_Screen;
 use WPDieException;
 use WPAjaxDieContinueException;

@@ -397,6 +398,105 @@ class ReviewsTest extends WC_Unit_Test_Case {
 		];
 	}

+	/**
+	 * @testdox `load_javascript` enqueues both core comment scripts that drive the review row actions on the reviews screen.
+	 *
+	 * @covers \Automattic\WooCommerce\Internal\Admin\ProductReviews\Reviews::load_javascript()
+	 *
+	 * @return void
+	 */
+	public function test_load_javascript_enqueues_the_comment_scripts_on_the_reviews_screen(): void {
+		$reviews = wc_get_container()->get( Reviews::class );
+
+		$this->turn_on_comment_hotkeys_for_the_current_user();
+		$this->set_admin_screen( 'product_page_product-reviews' );
+		$previous_scripts = $this->reset_registered_scripts();
+
+		try {
+			$this->assertTrue( $reviews->is_reviews_page(), 'The reviews page should be the current screen.' );
+			$this->assertTrue( wp_script_is( 'admin-comments', 'registered' ), 'The core admin-comments script should be registered on an admin screen.' );
+			$this->assertTrue( wp_script_is( 'jquery-table-hotkeys', 'registered' ), 'The core jquery-table-hotkeys script should be registered on an admin screen.' );
+
+			$reviews->load_javascript();
+
+			$this->assertTrue(
+				wp_script_is( 'admin-comments', 'enqueued' ),
+				'The reviews screen should enqueue the core admin-comments script, which runs the approve, spam and trash row actions without a page reload.'
+			);
+			$this->assertTrue(
+				wp_script_is( 'jquery-table-hotkeys', 'enqueued' ),
+				'The reviews screen should enqueue the core jquery-table-hotkeys script for a user who has comment keyboard shortcuts turned on.'
+			);
+		} finally {
+			$GLOBALS['wp_scripts'] = $previous_scripts; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restoring the registry drops everything the test enqueued.
+		}
+	}
+
+	/**
+	 * @testdox `load_javascript` leaves both core comment scripts alone outside the reviews screen.
+	 *
+	 * @covers \Automattic\WooCommerce\Internal\Admin\ProductReviews\Reviews::load_javascript()
+	 *
+	 * @return void
+	 */
+	public function test_load_javascript_does_not_enqueue_the_comment_scripts_outside_the_reviews_screen(): void {
+		$reviews = wc_get_container()->get( Reviews::class );
+
+		$this->turn_on_comment_hotkeys_for_the_current_user();
+		$this->set_admin_screen( 'edit-product' );
+		$previous_scripts = $this->reset_registered_scripts();
+
+		try {
+			$this->assertFalse( $reviews->is_reviews_page(), 'The products list should not count as the reviews page.' );
+			$this->assertTrue( wp_script_is( 'admin-comments', 'registered' ), 'The core admin-comments script should be registered on an admin screen.' );
+			$this->assertTrue( wp_script_is( 'jquery-table-hotkeys', 'registered' ), 'The core jquery-table-hotkeys script should be registered on an admin screen.' );
+
+			$reviews->load_javascript();
+
+			$this->assertFalse( wp_script_is( 'admin-comments', 'enqueued' ), 'Only the reviews screen should enqueue the core admin-comments script.' );
+			$this->assertFalse( wp_script_is( 'jquery-table-hotkeys', 'enqueued' ), 'Only the reviews screen should enqueue the core jquery-table-hotkeys script.' );
+		} finally {
+			$GLOBALS['wp_scripts'] = $previous_scripts; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restoring the registry drops everything the test enqueued.
+		}
+	}
+
+	/**
+	 * Turn the core comment keyboard shortcuts on for the current user.
+	 *
+	 * `enqueue_comment_hotkeys_js()` enqueues its script only for a user who has them on.
+	 *
+	 * @return void
+	 */
+	private function turn_on_comment_hotkeys_for_the_current_user(): void {
+		wp_set_current_user( $this->factory()->user->create( array( 'role' => 'administrator' ) ) );
+		update_user_option( get_current_user_id(), 'comment_shortcuts', 'true' );
+	}
+
+	/**
+	 * Make an admin screen current without running the `current_screen` callbacks.
+	 *
+	 * @param string $hook_name Screen hook name.
+	 * @return void
+	 */
+	private function set_admin_screen( string $hook_name ): void {
+		$GLOBALS['current_screen'] = WP_Screen::get( $hook_name ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- tearDown restores the pre-test screen.
+	}
+
+	/**
+	 * Rebuild the script registry so it reflects the current screen.
+	 *
+	 * WordPress registers the admin-only default scripts once, when the registry is first built, which
+	 * in a test run happens long before a screen is current.
+	 *
+	 * @return mixed The registry to restore.
+	 */
+	private function reset_registered_scripts() {
+		$previous_scripts      = $GLOBALS['wp_scripts'] ?? null;
+		$GLOBALS['wp_scripts'] = null; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- The caller restores the registry.
+
+		return $previous_scripts;
+	}
+
 	/**
 	 * @testdox `get_bulk_action_notice_messages` the appropriate admin notice is displayed after a product review bulk action is processed.
 	 *