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