Commit d1b4a6d1d26 for woocommerce

commit d1b4a6d1d265a80f25b8466d7a812e5fe205be18
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Mon Sep 14 17:25:24 2026 +0300

    [tests] Cut product export E2E from 4 titles to 1, moving the export screen to PHPUnit (#68649)

    test(products): Cut product export E2E from 4 titles to 1

    Three of the four product export browser titles were checking what the
    export screen renders when you arrive from the product list with a
    selection: which products are listed, what the clear link points at,
    and what the screen shows when nothing is selected. That is a view
    assertion, and a view is cheaper and more precisely testable from PHP
    than through wp-admin.

    The retained title, `preserves multiple selection through export and
    clear`, is the one that needs a browser: it starts on the product
    list, carries a real selection across a navigation, and then clears
    it. Nothing below the browser can prove that handoff.

    Two PHP additions take the rest:

    - WC_Admin_Product_Export_View_Test renders
      includes/admin/views/html-admin-page-product-export.php directly and
      asserts what the screen shows for a selection and for none.
    - WC_Product_CSV_Exporter_Test gains
      test_selected_product_ids_restrict_export_rows, which proves the
      exporter scopes its rows to the selected products and their
      variations.

    Knowingly dropped: the product list's "Export N selected" button is
    written by jQuery on a different screen, so neither PHP test can
    reach it. The retained title still covers it for two products; the
    singular N=1 wording and the default button text before any selection
    are no longer verified anywhere.

    class-wc-product-csv-exporter-test.php had moved on trunk since this
    work branched: #68567 added a regression test for exporting a product
    whose global attribute was deleted. Rather than take the frozen state
    blind and drop that method, trunk's copy was kept and only this
    branch's method re-applied on top. The file's diff against trunk is
    purely additive; both methods are present and the class goes from 5
    tests to 6.

    Carries the mega-branch commits:
    - aba55f9096 test(e2e): Reduce Product Export browser coverage
    - d4d6e9e52b test: Fix migration branch lint

    Refs TESTOPS-288
    Refs #68046

    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/testops-288-products-admin-export b/plugins/woocommerce/changelog/testops-288-products-admin-export
new file mode 100644
index 00000000000..6e3449c6d9a
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-288-products-admin-export
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Cut the product export E2E spec from 4 titles to 1; the export screen's rendering and the exporter's row scoping now covered by WC_Admin_Product_Export_View_Test and WC_Product_CSV_Exporter_Test.
+
diff --git a/plugins/woocommerce/tests/e2e/tests/product/product-export.spec.ts b/plugins/woocommerce/tests/e2e/tests/product/product-export.spec.ts
index 528e381b684..bd535945ab4 100644
--- a/plugins/woocommerce/tests/e2e/tests/product/product-export.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/product/product-export.spec.ts
@@ -68,53 +68,7 @@ const test = baseTest.extend( {
 } );

 test.describe( 'Product > Export Selected Products', () => {
-	test( 'should allow exporting a single selected simple product', async ( {
-		page,
-		productsFixture,
-	} ) => {
-		const simpleProduct = productsFixture.simple;
-
-		await test.step( 'Navigate to product list and select product', async () => {
-			await page.goto( 'wp-admin/edit.php?post_type=product' );
-			await page.locator( `#cb-select-${ simpleProduct.id }` ).check();
-		} );
-
-		const exportButton = page.locator(
-			'a.page-title-action[href*="page=product_exporter"]'
-		);
-
-		await test.step( 'Verify export button text and link for single selection', async () => {
-			await expect( exportButton ).toHaveText( 'Export 1 selected' );
-			const exportButtonHref = await exportButton.getAttribute( 'href' );
-			expect( exportButtonHref ).toContain(
-				`product_ids=${ simpleProduct.id }`
-			);
-			expect( exportButtonHref ).toContain( '_wpnonce=' );
-		} );
-
-		await test.step( 'Navigate to export page and verify UI elements', async () => {
-			await exportButton.click();
-			await expect( page.locator( '.wrap.woocommerce h1' ) ).toHaveText(
-				'Export Products'
-			);
-			await expect(
-				page.locator( '#selected-product-export-notice p' )
-			).toContainText(
-				'You are about to export 1 product. To export all products, clear your selection.'
-			);
-			await expect(
-				page.locator( 'input[name="product_ids"]' )
-			).toHaveValue( String( simpleProduct.id ) );
-			await expect(
-				page.locator( 'label[for="woocommerce-exporter-types"]' )
-			).toBeHidden();
-			await expect(
-				page.locator( 'label[for="woocommerce-exporter-category"]' )
-			).toBeHidden();
-		} );
-	} );
-
-	test( 'should allow exporting multiple selected products (simple and variable)', async ( {
+	test( 'preserves multiple selection through export and clear', async ( {
 		page,
 		productsFixture,
 	} ) => {
@@ -122,7 +76,7 @@ test.describe( 'Product > Export Selected Products', () => {
 		const variableProduct = productsFixture.variable;

 		await test.step( 'Navigate to product list and select multiple products', async () => {
-			await page.goto( 'wp-admin/edit.php?post_type=product' ); // Changed to page.goto
+			await page.goto( 'wp-admin/edit.php?post_type=product' );
 			await page.locator( `#cb-select-${ simpleProduct.id }` ).check();
 			await page.locator( `#cb-select-${ variableProduct.id }` ).check();
 		} );
@@ -134,7 +88,6 @@ test.describe( 'Product > Export Selected Products', () => {
 		await test.step( 'Verify export button text and link for multiple selections', async () => {
 			await expect( exportButton ).toHaveText( 'Export 2 selected' );
 			const exportButtonHref = await exportButton.getAttribute( 'href' );
-			// Use a regex to match product_ids in any order, allowing for both comma and URL-encoded comma.
 			expect( exportButtonHref ).toMatch(
 				new RegExp(
 					`product_ids=(${ simpleProduct.id }(,|%2C)${ variableProduct.id }|${ variableProduct.id }(,|%2C)${ simpleProduct.id })`
@@ -158,11 +111,11 @@ test.describe( 'Product > Export Selected Products', () => {
 				String( simpleProduct.id ),
 				String( variableProduct.id ),
 			]
-				.sort()
+				.toSorted()
 				.join( ',' );
 			const actualIds = ( await productIdsInput.inputValue() )
 				.split( ',' )
-				.sort()
+				.toSorted()
 				.join( ',' );
 			expect( actualIds ).toBe( expectedIds );
 			await expect(
@@ -171,85 +124,38 @@ test.describe( 'Product > Export Selected Products', () => {
 			await expect(
 				page.locator( 'label[for="woocommerce-exporter-category"]' )
 			).toBeHidden();
-		} );
-	} );
-
-	test( 'should allow clearing selection from the export page', async ( {
-		page,
-		productsFixture,
-	} ) => {
-		const simpleProduct = productsFixture.simple;
-
-		await test.step( 'Navigate to product list, select product, and go to export page', async () => {
-			await page.goto( 'wp-admin/edit.php?post_type=product' );
-			await page.locator( `#cb-select-${ simpleProduct.id }` ).check();
-			await page
-				.locator( 'a.page-title-action[href*="page=product_exporter"]' )
-				.click();
-		} );
-
-		await test.step( 'Verify export page notice and URL for selected product', async () => {
 			await expect(
-				page.locator( '#selected-product-export-notice p' )
-			).toContainText( 'You are about to export 1 product.' );
-			await expect( page.url() ).toContain(
-				`product_ids=${ simpleProduct.id }`
+				page.locator( '.woocommerce-exporter header p' )
+			).toHaveText(
+				'This tool allows you to generate and download a CSV file containing the selected products.'
 			);
 		} );

-		await test.step( "Click 'clear your selection' link", async () => {
+		await test.step( 'Clear the selected products', async () => {
 			await page
-				.locator( '.notice-info p a:has-text("clear your selection")' )
+				.getByRole( 'link', { name: 'clear your selection' } )
 				.click();
 		} );

-		await test.step( 'Verify redirect to general export page and UI elements', async () => {
-			await expect( page.url() ).not.toContain( 'product_ids=' );
-			await expect(
-				page.locator(
-					'.notice-info p:has-text("You are about to export")'
-				)
-			).toBeHidden();
-			await expect(
-				page.locator( 'label[for="woocommerce-exporter-types"]' )
-			).toBeVisible();
-			await expect(
-				page.locator( 'label[for="woocommerce-exporter-category"]' )
-			).toBeVisible();
-		} );
-	} );
-
-	test( 'should show the default export screen when no products are selected', async ( {
-		page,
-		productsFixture,
-	} ) => {
-		expect( productsFixture ).toBeDefined();
-		await test.step( 'Navigate to product list', async () => {
-			await page.goto( 'wp-admin/edit.php?post_type=product' );
-		} );
-
-		const exportButton = page.locator(
-			'a.page-title-action[href*="page=product_exporter"]'
-		);
-
-		await test.step( 'Verify default export button state and navigate to export page', async () => {
-			await expect( exportButton ).toHaveText( 'Export' );
-			await exportButton.click();
-		} );
-
-		await test.step( 'Verify UI elements for default export', async () => {
-			await expect( page.url() ).not.toContain( 'product_ids=' );
-			// Verify the selection-specific notice is NOT present
+		await test.step( 'Verify the default export state', async () => {
+			expect( page.url() ).not.toContain( 'product_ids=' );
 			await expect(
 				page.locator( '#selected-product-export-notice' )
 			).toBeHidden();
-			// Verify the standard filters ARE present
+			await expect(
+				page.locator( 'input[name="product_ids"]' )
+			).toHaveCount( 0 );
 			await expect(
 				page.locator( 'label[for="woocommerce-exporter-types"]' )
 			).toBeVisible();
 			await expect(
 				page.locator( 'label[for="woocommerce-exporter-category"]' )
 			).toBeVisible();
+			await expect(
+				page.locator( '.woocommerce-exporter header p' )
+			).toHaveText(
+				'This tool allows you to generate and download a CSV file containing a list of all products.'
+			);
 		} );
 	} );
 } );
diff --git a/plugins/woocommerce/tests/php/includes/admin/views/class-wc-admin-product-export-view-test.php b/plugins/woocommerce/tests/php/includes/admin/views/class-wc-admin-product-export-view-test.php
new file mode 100644
index 00000000000..f5b91b6a5d5
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/admin/views/class-wc-admin-product-export-view-test.php
@@ -0,0 +1,171 @@
+<?php
+/**
+ * Tests for the Product Export admin view.
+ *
+ * @package WooCommerce\Tests\Admin\Views
+ */
+
+declare( strict_types = 1 );
+
+/**
+ * Product Export admin view tests.
+ */
+class WC_Admin_Product_Export_View_Test extends WC_Unit_Test_Case {
+
+	/**
+	 * Load the dependencies used directly by the view.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		require_once WC_ABSPATH . 'includes/export/class-wc-product-csv-exporter.php';
+		require_once WC_ABSPATH . 'includes/admin/class-wc-admin-exporters.php';
+	}
+
+	/**
+	 * @testdox Selected products render the exact singular and plural export states.
+	 */
+	public function test_selected_products_render_exact_export_state(): void {
+		$product_ids = array();
+
+		try {
+			$first_product  = WC_Helper_Product::create_simple_product();
+			$product_ids[]  = $first_product->get_id();
+			$second_product = WC_Helper_Product::create_simple_product();
+			$product_ids[]  = $second_product->get_id();
+
+			$singular_output = $this->render_export_view( array( $first_product->get_id() ) );
+
+			$this->assertStringContainsString( 'You are about to export 1 product.', $singular_output );
+			$this->assertSame( (string) $first_product->get_id(), $this->get_hidden_product_ids( $singular_output ) );
+			$this->assertSelectedExportStructure( $singular_output );
+
+			$plural_output = $this->render_export_view( array( $first_product->get_id(), $second_product->get_id() ) );
+
+			$this->assertStringContainsString( 'You are about to export 2 products.', $plural_output );
+			$this->assertSame(
+				$first_product->get_id() . ',' . $second_product->get_id(),
+				$this->get_hidden_product_ids( $plural_output )
+			);
+			$this->assertSelectedExportStructure( $plural_output );
+		} finally {
+			foreach ( array_reverse( $product_ids ) as $product_id ) {
+				WC_Helper_Product::delete_product( $product_id );
+			}
+		}
+	}
+
+	/**
+	 * @testdox No selected products render the default export state.
+	 */
+	public function test_no_selected_products_render_default_export_state(): void {
+		$output = $this->render_export_view( array() );
+
+		$this->assertStringNotContainsString( 'id="selected-product-export-notice"', $output );
+		$this->assertNull( $this->get_hidden_product_ids( $output ) );
+		$this->assertStringContainsString( 'containing a list of all products', $output );
+		$this->assertStringContainsString( 'for="woocommerce-exporter-types"', $output );
+		$this->assertStringContainsString( 'for="woocommerce-exporter-category"', $output );
+	}
+
+	/**
+	 * Assert the selection-specific view structure.
+	 *
+	 * @param string $output Rendered view output.
+	 */
+	private function assertSelectedExportStructure( string $output ): void {
+		$this->assertStringContainsString( 'id="selected-product-export-notice"', $output );
+		$this->assertStringContainsString( 'clear your selection', $output );
+		$this->assertStringContainsString( 'containing the selected products', $output );
+		$this->assertStringNotContainsString( 'for="woocommerce-exporter-types"', $output );
+		$this->assertStringNotContainsString( 'for="woocommerce-exporter-category"', $output );
+
+		$this->assertMatchesRegularExpression(
+			'/<a href="(?![^"]*product_ids=)[^"]*page=product_exporter[^"]*">clear your selection<\/a>/',
+			$output,
+			'The clear link should retain the exporter route without the selected product IDs.'
+		);
+	}
+
+	/**
+	 * Get the hidden selected product IDs from rendered markup.
+	 *
+	 * @param string $output Rendered view output.
+	 * @return string|null
+	 */
+	private function get_hidden_product_ids( string $output ): ?string {
+		$processor = new WP_HTML_Tag_Processor( $output );
+
+		while ( $processor->next_tag( array( 'tag_name' => 'INPUT' ) ) ) {
+			if ( 'product_ids' === $processor->get_attribute( 'name' ) ) {
+				$value = $processor->get_attribute( 'value' );
+				return is_string( $value ) ? $value : null;
+			}
+		}
+
+		return null;
+	}
+
+	/**
+	 * Render the Product Export view with an isolated admin request.
+	 *
+	 * @param int[] $product_ids Selected product IDs.
+	 * @return string
+	 */
+	private function render_export_view( array $product_ids ): string {
+		global $wp_scripts;
+
+		$original_get           = $_GET; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Snapshot test globals before constructing the isolated request.
+		$original_request       = $_REQUEST; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Snapshot test globals before constructing the isolated request.
+		$original_user_id       = get_current_user_id();
+		$original_buffer_level  = ob_get_level();
+		$had_request_uri        = isset( $_SERVER['REQUEST_URI'] );
+		$original_request_uri   = $had_request_uri ? $_SERVER['REQUEST_URI'] : null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Preserve the exact pre-test server value for restoration.
+		$had_wp_scripts         = isset( $wp_scripts );
+		$original_scripts_queue = $had_wp_scripts ? $wp_scripts->queue : array();
+		$original_scripts_to_do = $had_wp_scripts ? $wp_scripts->to_do : array();
+		$admin_user_id          = self::factory()->user->create( array( 'role' => 'administrator' ) );
+		$output                 = '';
+
+		try {
+			wp_set_current_user( $admin_user_id );
+
+			$request_args = array( 'page' => 'product_exporter' );
+			if ( $product_ids ) {
+				$request_args['product_ids'] = implode( ',', $product_ids );
+				$request_args['_wpnonce']    = wp_create_nonce( 'export-selected-products' );
+			}
+			$_GET                   = $request_args;
+			$_REQUEST               = $request_args;
+			$_SERVER['REQUEST_URI'] = add_query_arg( $request_args, '/wp-admin/admin.php' );
+
+			ob_start();
+			include WC_ABSPATH . 'includes/admin/views/html-admin-page-product-export.php';
+			$output = (string) ob_get_clean();
+		} finally {
+			while ( ob_get_level() > $original_buffer_level ) {
+				ob_end_clean();
+			}
+
+			$_GET     = $original_get; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Restore the exact globals captured before the test request.
+			$_REQUEST = $original_request; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Restore the exact globals captured before the test request.
+			if ( $had_request_uri ) {
+				$_SERVER['REQUEST_URI'] = $original_request_uri;
+			} else {
+				unset( $_SERVER['REQUEST_URI'] );
+			}
+
+			if ( $had_wp_scripts ) {
+				$wp_scripts->queue = $original_scripts_queue;
+				$wp_scripts->to_do = $original_scripts_to_do;
+			} else {
+				unset( $wp_scripts );
+			}
+
+			wp_set_current_user( $original_user_id );
+			wp_delete_user( $admin_user_id );
+		}
+
+		return $output;
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/exporter/class-wc-product-csv-exporter-test.php b/plugins/woocommerce/tests/php/includes/exporter/class-wc-product-csv-exporter-test.php
index fd0137bede3..e9ac1e7d7e3 100644
--- a/plugins/woocommerce/tests/php/includes/exporter/class-wc-product-csv-exporter-test.php
+++ b/plugins/woocommerce/tests/php/includes/exporter/class-wc-product-csv-exporter-test.php
@@ -162,6 +162,60 @@ class WC_Product_CSV_Exporter_Test extends \WC_Unit_Test_Case {
 		}
 	}

+	/**
+	 * @testdox Selected product IDs restrict the export to those products and their variations.
+	 */
+	public function test_selected_product_ids_restrict_export_rows(): void {
+		$simple_product_ids = array();
+		$variable_product   = new WC_Product_Variable();
+
+		try {
+			$simple_product       = WC_Helper_Product::create_simple_product();
+			$simple_product_ids[] = $simple_product->get_id();
+
+			WC_Helper_Product::create_variation_product( $variable_product );
+			$variation_ids = $variable_product->get_children( 'edit' );
+
+			$unrelated_product    = WC_Helper_Product::create_simple_product();
+			$simple_product_ids[] = $unrelated_product->get_id();
+
+			$exporter = new WC_Product_CSV_Exporter();
+			$exporter->set_product_ids_to_export( array( $simple_product->get_id(), $variable_product->get_id() ) );
+			$exporter->prepare_data_to_export();
+
+			$exported_ids = array_map( 'intval', wp_list_pluck( $this->get_exported_data( $exporter ), 'id' ) );
+			$expected_ids = array_merge(
+				array( $simple_product->get_id(), $variable_product->get_id() ),
+				$variation_ids
+			);
+			sort( $exported_ids );
+			sort( $expected_ids );
+
+			$this->assertSame( $expected_ids, $exported_ids );
+			$this->assertCount( count( $expected_ids ), $exported_ids );
+			$this->assertNotContains( $unrelated_product->get_id(), $exported_ids );
+		} finally {
+			if ( $variable_product->get_id() ) {
+				$variation_ids = (array) wc_get_products(
+					array(
+						'parent' => $variable_product->get_id(),
+						'type'   => ProductType::VARIATION,
+						'return' => 'ids',
+						'limit'  => -1,
+					)
+				);
+				foreach ( $variation_ids as $variation_id ) {
+					WC_Helper_Product::delete_product( $variation_id );
+				}
+				WC_Helper_Product::delete_product( $variable_product->get_id() );
+			}
+
+			foreach ( array_reverse( $simple_product_ids ) as $product_id ) {
+				WC_Helper_Product::delete_product( $product_id );
+			}
+		}
+	}
+
 	/**
 	 * @testdox CSV data is written with an append-only fopen mode so write-only stream wrappers are supported.
 	 */