Commit e43ef6ffb2f for woocommerce

commit e43ef6ffb2f6224dd7fd00cd09cedefe04f181c5
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Tue Sep 15 23:58:39 2026 +0300

    [tests] Cut Products-admin E2E from 15 titles to 7, moving 4 behaviors to PHPUnit (#68646)

    * test(products): Cut Products-admin E2E from 15 titles to 7

    The four classic Products-admin browser specs spent most of their run
    time re-proving that the product data meta box and the bulk edit
    handler persist what was posted to them. That is a save-and-reload
    assertion, and the browser is the slowest place to make it.

    Eleven titles leave and three arrive, so the four specs go from 15
    browser titles to 6 here, and to 7 once the canary in the next commit
    lands. Only four of the eleven have their behavior genuinely relocated
    to PHPUnit:

    - The virtual and downloadable shapes of `can create a simple ...
      product` become data provider rows on
      WC_Meta_Box_Product_Data_Test::test_save_persists_simple_product_field_matrix,
      which posts a complete classic $_POST straight at
      WC_Meta_Box_Product_Data::save().
    - The two bulk sale-price edge cases become rows on
      WC_Admin_Post_Types_Test::test_bulk_edit_sale_price_edge_cases,
      against bulk_edit_save() and set_new_price().

    The remaining seven are consolidations and duplicates, not moves:

    - product-delete: three titles become one trash-then-permanently-delete
      lifecycle. The list-table row action and the permanent delete are
      both kept; the edit screen's Move to Trash link is not.
    - product-tags-attributes: four titles become one attribute archive
      walk. Two of the four were largely inert: their product-grid
      assertions sat inside `for ( let i = 0; i < locator.count(); ++i )`
      loops, and count() returns a Promise, so the loop bodies never ran.

    What stays in the browser is what only the browser can prove: that the
    admin screens are wired to those handlers, and that a shopper reaches
    an archive from a product page.

    The new PHPUnit fixtures were renamed off the campaign's internal
    slice identifiers before landing.

    Carries the mega-branch commits:
    - fdf1e2853f test(e2e): Consolidate product deletion lifecycle
    - e2166a1d2a test(products): Remove duplicate taxonomy journeys
    - 891869f309 test(products): Move simple product types below E2E
    - fb1bd73c26 test(products): Move bulk price edges below E2E

    Refs TESTOPS-288
    Refs #68046

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

    * test(e2e): Add a product tag archive canary

    The previous commit replaced this spec's four titles with a single
    attribute archive walk. That left `Products tagged <name>` with no
    browser coverage anywhere in the suite: the Product Tag block spec
    exercises the block in the post editor, and page-loads.spec.ts covers
    the admin Products > Tags screen. Neither loads the front-end archive,
    and the lower-layer test that covers the tag route lives in a
    different pull request in this split.

    Add one title that walks the shopper's path: open a product, click its
    tag, and assert the archive heading, the `Products tagged` text and
    the result count. It reuses the fixtures already in the file and
    removes the tag in the same failure-safe teardown.

    Note what this does and does not guard. On this environment's block
    theme the product page renders its tag link through the core
    post-terms block, not WooCommerce's classic product meta: there is no
    `tagged_as` span on the page, and mutating wc_get_product_tag_list()
    leaves the title green. What the title does guard is WooCommerce's
    own rendering of the tag archive it lands on. Breaking the result
    count in templates/loop/result-count.php turns it red at
    `Showing all 3 results`, which is the assertion it exists for.

    Refs TESTOPS-288
    Refs #68046

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

    * test(products): Stop the product admin tests restoring base-class state

    The classic product data meta-box test snapshotted $_POST and the
    current user in setUp(), created an administrator only to delete it
    again, tracked the products it made so it could delete them, and put
    the approved-download-directory mode back. The base class already
    empties the request globals before every test, rolls back every
    database write -- the user, the products and that mode option are all
    rows -- and resets the current user afterwards.

    What it does not own, and what stays: the static
    WC_Admin_Meta_Boxes::$meta_box_errors list, and the product,
    product_object and thepostid globals the classic save seam sets. The
    post global left the snapshot because tear_down() nulls it.

    The bulk-edit test also restored woocommerce_manage_stock, which the
    rollback covers.

    Same 31 tests and 154 assertions before and after.

    Refs TESTOPS-288

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

    * test(products): Stop claiming bulk edit leaves unselected products alone

    The test created a third product, wrote prices and stock to it, and then
    asserted those same values back. The bulk_edit() helper loops only over
    the products handed to it, so that product never reached
    bulk_and_quick_edit_save_post() -- the assertion read the fixture, not
    the result of anything.

    Selection is not WooCommerce's to make: core's bulk-edit loop walks
    $_REQUEST['post'] and calls the WooCommerce handler once per selected
    post. A test that drives the handler directly cannot observe that loop,
    so the regression the old name promised -- bulk edit leaking onto
    unticked products -- was never in scope here.

    Drop the third product and name the test after the percentage price and
    stock maths it does verify, with a note saying why the unselected case is
    absent rather than leaving the next reader to rediscover it.

    Refs TESTOPS-288

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

    ---------

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

diff --git a/plugins/woocommerce/changelog/testops-288-products-admin-edit b/plugins/woocommerce/changelog/testops-288-products-admin-edit
new file mode 100644
index 00000000000..aa2a00d37a7
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-288-products-admin-edit
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Cut the classic Products-admin E2E specs from 15 titles to 7; simple product field persistence and bulk price editing now covered by WC_Meta_Box_Product_Data_Test and WC_Admin_Post_Types_Test.
+
diff --git a/plugins/woocommerce/tests/e2e/tests/product/product-create-simple.spec.ts b/plugins/woocommerce/tests/e2e/tests/product/product-create-simple.spec.ts
index b255a13fb0b..ee0e0e7199f 100644
--- a/plugins/woocommerce/tests/e2e/tests/product/product-create-simple.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/product/product-create-simple.spec.ts
@@ -16,15 +16,6 @@ import {
 } from '../../utils/data';

 const productData = {
-	virtual: {
-		name: `Virtual product ${ Date.now() }`,
-		description: `Virtual product longer description`,
-		shortDescription: `Virtual product short description`,
-		regularPrice: '100.05',
-		sku: `0_${ Date.now() }`,
-		virtual: true,
-		purchaseNote: 'Virtual product purchase note',
-	},
 	'non virtual': {
 		name: `Simple product ${ Date.now() }`,
 		description: `<b>Simple product HTML description.</b> <em>This should be italic.</em>`,
@@ -39,15 +30,6 @@ const productData = {
 		},
 		purchaseNote: 'Simple product purchase note',
 	},
-	downloadable: {
-		name: `Downloadable product ${ Date.now() }`,
-		regularPrice: '100.05',
-		description: `Downloadable product longer description`,
-		shortDescription: `Downloadable product short description`,
-		sku: `2_${ Date.now() }`,
-		purchaseNote: 'Downloadable product purchase note',
-		fileName: 'e2e-product.zip',
-	},
 };

 function removeHtmlTags( str: string ) {
@@ -205,70 +187,27 @@ for ( const productType of Object.keys( productData ) ) {
 				).toBeVisible();
 			} );

-			// eslint-disable-next-line playwright/no-conditional-in-test
-			if ( productData[ productType ].shipping ) {
-				await test.step( 'add shipping details', async () => {
-					await page
-						.locator( '#woocommerce-product-data' )
-						.getByRole( 'link', { name: 'Shipping' } )
-						.click();
-					await expect(
-						page.getByText( 'Shipping class', { exact: true } )
-					).toBeVisible();
-					await page
-						.locator( '#_weight' )
-						.fill( productData[ productType ].shipping.weight );
-					await page
-						.getByPlaceholder( 'Length', { exact: true } )
-						.fill( productData[ productType ].shipping.length );
-					await page
-						.getByPlaceholder( 'Width' )
-						.fill( productData[ productType ].shipping.width );
-					await page
-						.getByPlaceholder( 'Height' )
-						.fill( productData[ productType ].shipping.height );
-				} );
-			}
-
-			// eslint-disable-next-line playwright/no-conditional-in-test
-			if ( productData[ productType ].virtual ) {
-				await test.step( 'add virtual product details', async () => {
-					await page
-						.getByRole( 'checkbox', { name: 'Virtual' } )
-						.check();
-					await expect(
-						page.getByRole( 'checkbox', { name: 'Virtual' } )
-					).toBeChecked();
-				} );
-			}
-
-			// eslint-disable-next-line playwright/no-conditional-in-test
-			if ( productData[ productType ].downloadable ) {
-				await test.step( 'add downloadable product details', async () => {
-					await page.getByLabel( 'Downloadable' ).check();
-					await expect(
-						page.getByLabel( 'Downloadable' )
-					).toBeChecked();
-
-					// Add a download link
-					await page
-						.locator( '#woocommerce-product-data' )
-						.getByRole( 'link', { name: 'General' } )
-						.click();
-					await page
-						.getByRole( 'link', { name: 'Add File' } )
-						.click();
-					await page
-						.getByPlaceholder( 'File name' )
-						.fill( productData[ productType ].fileName );
-					await page
-						.getByPlaceholder( 'https://' )
-						.fill(
-							`https://example.com/${ productData[ productType ].fileName }`
-						);
-					await page.getByPlaceholder( 'Never' ).fill( '365' );
-				} );
-			}
+			await test.step( 'add shipping details', async () => {
+				await page
+					.locator( '#woocommerce-product-data' )
+					.getByRole( 'link', { name: 'Shipping' } )
+					.click();
+				await expect(
+					page.getByText( 'Shipping class', { exact: true } )
+				).toBeVisible();
+				await page
+					.locator( '#_weight' )
+					.fill( productData[ productType ].shipping.weight );
+				await page
+					.getByPlaceholder( 'Length', { exact: true } )
+					.fill( productData[ productType ].shipping.length );
+				await page
+					.getByPlaceholder( 'Width' )
+					.fill( productData[ productType ].shipping.width );
+				await page
+					.getByPlaceholder( 'Height' )
+					.fill( productData[ productType ].shipping.height );
+			} );

 			await test.step( 'publish the product', async () => {
 				await page
diff --git a/plugins/woocommerce/tests/e2e/tests/product/product-delete.spec.ts b/plugins/woocommerce/tests/e2e/tests/product/product-delete.spec.ts
index ec27325b84c..8dca05b9e04 100644
--- a/plugins/woocommerce/tests/e2e/tests/product/product-delete.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/product/product-delete.spec.ts
@@ -61,50 +61,16 @@ const test = baseTest.extend( {
 	},
 } );

-test( 'can delete a product from edit view', async ( { page, product } ) => {
-	const editUrl = `wp-admin/post.php?post=${ product.id }&action=edit`;
-
-	await test.step( 'Navigate to product edit page', async () => {
-		await page.goto( editUrl );
-	} );
-
-	await test.step( 'Move product to trash', async () => {
-		await page.getByRole( 'link', { name: 'Move to Trash' } ).click();
-	} );
-
-	await test.step( 'Verify product was trashed', async () => {
-		// Verify displayed message
-		await expect( page.locator( '#message' ).last() ).toContainText(
-			'1 product moved to the Trash.'
-		);
-
-		// Verify the product is now in the trash
-		await page.goto(
-			`wp-admin/edit.php?post_status=trash&post_type=product`
-		);
-		await expect( page.locator( `#post-${ product.id }` ) ).toBeVisible();
-
-		// Verify the product cannot be edited via direct URL
-		await page.goto( editUrl );
-		await expect(
-			page.getByText(
-				'You cannot edit this item because it is in the Trash. Please restore it and try again.'
-			)
-		).toBeVisible();
-	} );
-} );
-
-test( 'can quick delete a product from product list', async ( {
+test( 'can trash and permanently delete a product from the product list', async ( {
 	page,
 	product,
 } ) => {
-	await test.step( 'Navigate to products list page', async () => {
+	const editUrl = `wp-admin/post.php?post=${ product.id }&action=edit`;
+
+	await test.step( 'Navigate to products list page and trash exact product row', async () => {
 		await page.goto(
 			`wp-admin/edit.php?post_type=product&s=${ product.name }`
 		);
-	} );
-
-	await test.step( 'Move product to trash', async () => {
 		await deleteProductViaRowAction( page, product.id );
 	} );

@@ -121,37 +87,23 @@ test( 'can quick delete a product from product list', async ( {
 		await expect( page.locator( `#post-${ product.id }` ) ).toBeVisible();

 		// Verify the product cannot be edited via direct URL
-		await page.goto( `wp-admin/post.php?post=${ product.id }&action=edit` );
+		await page.goto( editUrl );
 		await expect(
 			page.getByText(
 				'You cannot edit this item because it is in the Trash. Please restore it and try again.'
 			)
 		).toBeVisible();
 	} );
-} );
-
-test( 'can permanently delete a product from trash list', async ( {
-	page,
-	product,
-	restApi,
-} ) => {
-	// trash the product
-	await restApi.delete( `${ WC_API_PATH }/products/${ product.id }`, {
-		force: false,
-	} );

-	await test.step( 'Navigate to products trash list page', async () => {
+	await test.step( 'Return to Trash and permanently delete exact product row', async () => {
 		await page.goto(
 			`wp-admin/edit.php?post_status=trash&post_type=product`
 		);
-	} );
-
-	await test.step( 'Permanently delete the product', async () => {
 		await deleteProductViaRowAction( page, product.id );
 	} );

 	await test.step( 'Verify product was permanently deleted', async () => {
-		await page.goto( `wp-admin/post.php?post=${ product.id }&action=edit` );
+		await page.goto( editUrl );
 		await expect(
 			page.getByText(
 				'You attempted to edit an item that does not exist. Perhaps it was deleted?'
diff --git a/plugins/woocommerce/tests/e2e/tests/product/product-edit.spec.ts b/plugins/woocommerce/tests/e2e/tests/product/product-edit.spec.ts
index 002c2a65644..628d8b75867 100644
--- a/plugins/woocommerce/tests/e2e/tests/product/product-edit.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/product/product-edit.spec.ts
@@ -333,130 +333,3 @@ test(
 		} );
 	}
 );
-
-test(
-	'can decrease the sale price if the product was not previously in sale when bulk editing products',
-	{ tag: [ tags.SERVICES ] },
-	async ( { page, products } ) => {
-		await page.goto( `wp-admin/edit.php?post_type=product` );
-
-		const salePriceDecrease = 10;
-
-		await test.step( 'Update products with the "Sale > Decrease existing sale price" option', async () => {
-			await page.goto( `wp-admin/edit.php?post_type=product` );
-
-			await selectAllProducts( page, products );
-
-			const bulkActionSelect = page.locator(
-				'#bulk-action-selector-top'
-			);
-			const bulkEditOption = bulkActionSelect.locator( 'option', {
-				hasText: /^(Bulk edit|Edit)$/,
-			} );
-			await bulkActionSelect.selectOption( {
-				label: ( await bulkEditOption.textContent() ) ?? undefined,
-			} );
-			await page.locator( '#doaction' ).click();
-
-			await page
-				.locator( 'select[name="change_sale_price"]' )
-				.selectOption(
-					'Decrease existing sale price by (fixed amount or %):'
-				);
-			await page
-				.getByPlaceholder( 'Enter sale price ($)' )
-				.fill( `${ salePriceDecrease }%` );
-
-			await saveBulkProductChanges( page );
-		} );
-
-		await test.step( 'Verify products have a sale price', async () => {
-			for ( const product of products ) {
-				await page.goto( `product/${ product.slug }` );
-
-				const expectedSalePrice = (
-					product.regular_price *
-					( 1 - salePriceDecrease / 100 )
-				).toFixed( 2 );
-
-				await expect
-					.soft(
-						await page
-							.locator( 'ins' )
-							.getByText( `$${ expectedSalePrice }` )
-							.count()
-					)
-					.toBeGreaterThan( 0 );
-			}
-		} );
-	}
-);
-
-test(
-	'increasing the sale price from 0 does not change the sale price when bulk editing products',
-	{ tag: [ tags.SERVICES ] },
-	async ( { page, restApi, products } ) => {
-		let product;
-		await restApi
-			.post( `${ WC_API_PATH }/products`, {
-				id: 0,
-				name: faker.commerce.productName(),
-				type: 'simple',
-				regular_price: '100',
-				sale_price: '0',
-				manage_stock: true,
-				stock_quantity: 10,
-				stock_status: 'instock',
-			} )
-			.then( ( response ) => {
-				product = response.data;
-				// For cleanup: products from this list are deleted in the fixture
-				products.push( product );
-			} );
-
-		const salePriceIncrease = 10;
-
-		await test.step( 'Update products with the "Sale > Increase existing sale price" option', async () => {
-			await page.goto( `wp-admin/edit.php?post_type=product` );
-
-			await selectProduct( page, product );
-
-			const bulkActionSelect = page.locator(
-				'#bulk-action-selector-top'
-			);
-			const bulkEditOption = bulkActionSelect.locator( 'option', {
-				hasText: /^(Bulk edit|Edit)$/,
-			} );
-			await bulkActionSelect.selectOption( {
-				label: ( await bulkEditOption.textContent() ) ?? undefined,
-			} );
-			await page.locator( '#doaction' ).click();
-			await page
-				.locator( 'select[name="change_sale_price"]' )
-				.selectOption(
-					'Increase existing sale price by (fixed amount or %):'
-				);
-
-			await page
-				.getByPlaceholder( 'Enter sale price ($)' )
-				.fill( `${ salePriceIncrease }%` );
-
-			await saveBulkProductChanges( page );
-		} );
-
-		await test.step( 'Verify products have a sale price', async () => {
-			await page.goto( `product/${ product.slug }` );
-
-			const expectedSalePrice = '$0.00';
-
-			await expect
-				.soft(
-					await page
-						.locator( 'ins' )
-						.getByText( expectedSalePrice )
-						.count()
-				)
-				.toBeGreaterThan( 0 );
-		} );
-	}
-);
diff --git a/plugins/woocommerce/tests/e2e/tests/product/product-tags-attributes.spec.ts b/plugins/woocommerce/tests/e2e/tests/product/product-tags-attributes.spec.ts
index fc36d9e49c6..c637ff51143 100644
--- a/plugins/woocommerce/tests/e2e/tests/product/product-tags-attributes.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/product/product-tags-attributes.spec.ts
@@ -1,44 +1,29 @@
 /**
  * External dependencies
  */
-import {
-	getCanvas,
-	goToPageEditor,
-	insertBlockByShortcut,
-	publishPage,
-	WC_API_PATH,
-	WP_API_PATH,
-} from '@woocommerce/e2e-utils-playwright';
+import { WC_API_PATH } from '@woocommerce/e2e-utils-playwright';

 /**
  * Internal dependencies
  */
 import { tags, test, expect, request } from '../../fixtures/fixtures';
 import { ADMIN_STATE_PATH } from '../../playwright.config';
-import { fillPageTitle } from '../../utils/editor';
-import { getFakeProduct, getFakeTag, getFakeAttribute } from '../../utils/data';
+import { getFakeProduct, getFakeAttribute, getFakeTag } from '../../utils/data';
 import { setOption } from '../../utils/options';

-const pageTitle = 'Product Showcase';
-
-const productTagName1 = getFakeTag().name;
-const productTagName2 = getFakeTag().name;
-const productTagName3 = getFakeTag().name;
-
 // Both the attribute and its term must be unique: the attribute creates a
 // global `pa_*` taxonomy and the term a global term within it, so a fixed name
 // would collide across parallel workers.
 const productAttributeName = getFakeAttribute().name;
 const productAttributeTerm = getFakeAttribute().name;
+// The tag creates a global `product_tag` term, so a fixed name would collide
+// across parallel workers, exactly as the attribute names above would.
+const productTagName = getFakeTag().name;

-let product1Id: number,
-	product2Id: number,
-	product3Id: number,
-	product1Slug: string,
-	productTag1Id: number,
-	productTag2Id: number,
-	productTag3Id: number,
-	attributeId: number;
+const productIds: number[] = [];
+let product1Slug = '';
+let attributeId = 0;
+let productTagId = 0;

 test.describe(
 	'Browse product tags and attributes from the product page',
@@ -51,29 +36,6 @@ test.describe(
 		const product3 = getFakeProduct();

 		test.beforeAll( async ( { restApi } ) => {
-			// add product tags
-			await restApi
-				.post( `${ WC_API_PATH }/products/tags`, {
-					name: productTagName1,
-				} )
-				.then( ( response ) => {
-					productTag1Id = response.data.id;
-				} );
-			await restApi
-				.post( `${ WC_API_PATH }/products/tags`, {
-					name: productTagName2,
-				} )
-				.then( ( response ) => {
-					productTag2Id = response.data.id;
-				} );
-			await restApi
-				.post( `${ WC_API_PATH }/products/tags`, {
-					name: productTagName3,
-				} )
-				.then( ( response ) => {
-					productTag3Id = response.data.id;
-				} );
-
 			// add product attribute
 			await restApi
 				.post( `${ WC_API_PATH }/products/attributes`, {
@@ -92,19 +54,18 @@ test.describe(
 				}
 			);

+			// add product tag
+			const tagResponse = await restApi.post(
+				`${ WC_API_PATH }/products/tags`,
+				{ name: productTagName }
+			);
+			productTagId = tagResponse.data.id;
+
 			// add products
 			await restApi
 				.post( `${ WC_API_PATH }/products`, {
 					...product1,
-					tags: [
-						{ id: productTag1Id },
-						{
-							id: productTag2Id,
-						},
-						{
-							id: productTag3Id,
-						},
-					],
+					tags: [ { id: productTagId } ],
 					attributes: [
 						{
 							id: attributeId,
@@ -114,18 +75,13 @@ test.describe(
 					],
 				} )
 				.then( ( response ) => {
-					product1Id = response.data.id;
+					productIds.push( response.data.id );
 					product1Slug = response.data.slug;
 				} );
 			await restApi
 				.post( `${ WC_API_PATH }/products`, {
 					...product2,
-					tags: [
-						{ id: productTag1Id },
-						{
-							id: productTag2Id,
-						},
-					],
+					tags: [ { id: productTagId } ],
 					attributes: [
 						{
 							id: attributeId,
@@ -135,12 +91,12 @@ test.describe(
 					],
 				} )
 				.then( ( response ) => {
-					product2Id = response.data.id;
+					productIds.push( response.data.id );
 				} );
 			await restApi
 				.post( `${ WC_API_PATH }/products`, {
 					...product3,
-					tags: [ { id: productTag1Id } ],
+					tags: [ { id: productTagId } ],
 					attributes: [
 						{
 							id: attributeId,
@@ -150,68 +106,61 @@ test.describe(
 					],
 				} )
 				.then( ( response ) => {
-					product3Id = response.data.id;
+					productIds.push( response.data.id );
 				} );
 		} );

 		test.afterAll( async ( { restApi } ) => {
-			await restApi.post( `${ WC_API_PATH }/products/batch`, {
-				delete: [ product1Id, product2Id, product3Id ],
-			} );
-			await restApi.post( `${ WC_API_PATH }/products/tags/batch`, {
-				delete: [ productTag1Id, productTag2Id, productTag3Id ],
-			} );
-			await restApi.post( `${ WC_API_PATH }/products/attributes/batch`, {
-				delete: [ attributeId ],
-			} );
-
-			const pages = await restApi.get( `${ WP_API_PATH }/pages` );
-
-			for ( const page of pages.data ) {
-				if ( page.title.rendered === pageTitle ) {
-					await restApi.delete(
-						`${ WP_API_PATH }/pages/${ page.id }`,
+			const cleanupErrors: unknown[] = [];
+
+			if ( productIds.length > 0 ) {
+				try {
+					await restApi.post( `${ WC_API_PATH }/products/batch`, {
+						delete: productIds,
+					} );
+				} catch ( error ) {
+					cleanupErrors.push( error );
+				}
+			}
+			if ( attributeId > 0 ) {
+				try {
+					await restApi.post(
+						`${ WC_API_PATH }/products/attributes/batch`,
 						{
-							data: {
-								force: true,
-							},
+							delete: [ attributeId ],
 						}
 					);
+				} catch ( error ) {
+					cleanupErrors.push( error );
 				}
 			}
-		} );

-		test( 'should see shop catalog with all its products', async ( {
-			page,
-		} ) => {
-			await page.goto( 'shop/' );
-			await expect(
-				page.getByRole( 'heading', { name: 'Shop' } )
-			).toBeVisible();
-			await expect(
-				page.locator( '.woocommerce-ordering' )
-			).toBeVisible();
-
-			const addToCart = page.getByRole( 'add_to_cart_button' );
-			for ( let i = 0; i < addToCart.count(); ++i )
-				await expect( addToCart.nth( i ) ).toBeVisible();
-
-			const productPrice = page.getByRole( 'woocommerce-Price-amount' );
-			for ( let i = 0; i < productPrice.count(); ++i )
-				await expect( productPrice.nth( i ) ).toBeVisible();
-
-			const productTitle = page.getByRole(
-				'woocommerce-loop-product__title'
-			);
-			for ( let i = 0; i < productTitle.count(); ++i )
-				await expect( productTitle.nth( i ) ).toBeVisible();
+			if ( productTagId > 0 ) {
+				try {
+					await restApi.post(
+						`${ WC_API_PATH }/products/tags/batch`,
+						{
+							delete: [ productTagId ],
+						}
+					);
+				} catch ( error ) {
+					cleanupErrors.push( error );
+				}
+			}

-			const productImage = page.getByRole( 'wp-post-image' );
-			for ( let i = 0; i < productImage.count(); ++i )
-				await expect( productImage.nth( i ) ).toBeVisible();
+			if ( cleanupErrors.length > 0 ) {
+				throw new AggregateError(
+					cleanupErrors,
+					'Failed to clean up Product Tags and Attributes fixtures.'
+				);
+			}
 		} );

-		test( 'should see and sort tags page with all the products', async ( {
+		// Canary for the product tag archive. The titles this batch removed were
+		// the only browser coverage of `Products tagged <name>`, and the lower
+		// test that covers the tag route lives in another PR, so this keeps one
+		// real browser assertion on the archive a shopper actually reaches.
+		test( 'can navigate from a product tag to its archive', async ( {
 			page,
 		} ) => {
 			// Navigate straight to the product by slug. Going through the
@@ -219,13 +168,17 @@ test.describe(
 			// workers' products can push this one onto a later page where the
 			// click would fail.
 			await page.goto( `product/${ product1Slug }` );
-			await page.getByRole( 'link', { name: productTagName1 } ).click();
+
+			await page
+				.getByRole( 'link', { name: productTagName, exact: true } )
+				.click();
+
 			await expect(
-				page.getByRole( 'heading', { name: productTagName1 } )
+				page.getByRole( 'heading', { name: productTagName } )
 			).toBeVisible();
 			await expect(
 				page.getByText(
-					new RegExp( `Products tagged .*${ productTagName1 }.*` )
+					new RegExp( `Products tagged .*${ productTagName }.*` )
 				)
 			).toBeVisible();
 			await expect(
@@ -233,7 +186,7 @@ test.describe(
 			).toBeVisible();
 		} );

-		test( 'should see and sort attributes page with all its products', async ( {
+		test( 'can navigate from a product attribute to its archive', async ( {
 			page,
 			baseURL,
 		} ) => {
@@ -297,50 +250,5 @@ test.describe(
 				page.getByText( 'Showing all 3 results' )
 			).toBeVisible();
 		} );
-
-		test( 'can see products showcase', async ( { page } ) => {
-			// create as a merchant a new page with Product Collection block
-			await goToPageEditor( { page } );
-			await fillPageTitle( page, pageTitle );
-			await insertBlockByShortcut( page, 'Product Collection' );
-			const canvas = await getCanvas( page );
-
-			// Product Collection requires choosing some collection.
-			await canvas
-				.locator(
-					'[data-type="woocommerce/product-collection"] .components-placeholder'
-				)
-				.getByRole( 'button', {
-					name: 'create your own',
-				} )
-				.click();
-
-			await publishPage( page, pageTitle );
-
-			// go to created page with products showcase
-			await page.goto( 'product-showcase' );
-			await expect(
-				page.getByRole( 'heading', { name: pageTitle } )
-			).toBeVisible();
-			expect(
-				await page
-					.getByRole( 'button', { name: 'Add to cart' } )
-					.count()
-			).toBeGreaterThan( 0 );
-
-			const productPrice = page.locator( '.woocommerce-Price-amount' );
-			for ( let i = 0; i < productPrice.count(); ++i )
-				await expect( productPrice.nth( i ) ).toBeVisible();
-
-			const productTitle = page.locator(
-				'.woocommerce-loop-product__title'
-			);
-			for ( let i = 0; i < productTitle.count(); ++i )
-				await expect( productTitle.nth( i ) ).toBeVisible();
-
-			const productImage = page.locator( '.wp-post-image' );
-			for ( let i = 0; i < productImage.count(); ++i )
-				await expect( productImage.nth( i ) ).toBeVisible();
-		} );
 	}
 );
diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-post-types-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-post-types-test.php
index e8d044996cd..6eddb72d69d 100644
--- a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-post-types-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-post-types-test.php
@@ -310,6 +310,179 @@ class WC_Admin_Post_Types_Test extends WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox Bulk Edit applies percentage price and stock changes to each edited product.
+	 *
+	 * Deliberately not a "leaves unselected products alone" test. Selection happens in
+	 * core's bulk-edit loop over $_REQUEST['post'], which calls the WooCommerce handler
+	 * once per selected post; this test drives that handler directly, so an untouched
+	 * third product would only ever read back the values the test itself wrote.
+	 */
+	public function test_bulk_edit_applies_percentage_price_and_stock_changes(): void {
+		$first_product  = WC_Helper_Product::create_simple_product();
+		$second_product = WC_Helper_Product::create_simple_product();
+
+		$first_product->set_regular_price( '100' );
+		$first_product->set_sale_price( '80' );
+		$first_product->set_manage_stock( true );
+		$first_product->set_stock_quantity( 10 );
+		$first_product->save();
+
+		$second_product->set_regular_price( '12.34' );
+		$second_product->set_sale_price( '10' );
+		$second_product->set_manage_stock( true );
+		$second_product->set_stock_quantity( 4 );
+		$second_product->save();
+
+		update_option( 'woocommerce_manage_stock', 'yes' );
+
+		$this->bulk_edit(
+			array( $first_product, $second_product ),
+			array(
+				'change_regular_price' => '2',
+				'_regular_price'       => '10%',
+				'change_sale_price'    => '4',
+				'_sale_price'          => '10%',
+				'_manage_stock'        => 'yes',
+				'change_stock'         => '2',
+				'_stock'               => '10',
+			)
+		);
+
+		$this->assert_product_prices_and_stock( $first_product->get_id(), '110', '99', 20 );
+		$this->assert_product_prices_and_stock( $second_product->get_id(), '13.57', '12.21', 14 );
+	}
+
+	/**
+	 * @testdox Bulk Edit persists sale-price edge cases.
+	 * @dataProvider bulk_edit_sale_price_edge_case_provider
+	 *
+	 * @param string              $initial_regular_price Initial regular price.
+	 * @param string              $initial_sale_price Initial sale price.
+	 * @param array<string,mixed> $request_data Bulk Edit request fields.
+	 * @param string              $expected_regular_price Expected regular price.
+	 * @param string              $expected_sale_price Expected sale price.
+	 */
+	public function test_bulk_edit_sale_price_edge_cases(
+		string $initial_regular_price,
+		string $initial_sale_price,
+		array $request_data,
+		string $expected_regular_price,
+		string $expected_sale_price
+	): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_regular_price( $initial_regular_price );
+		$product->set_sale_price( $initial_sale_price );
+		$product->save();
+
+		$this->bulk_edit( array( $product ), $request_data );
+
+		/** @var WC_Product $updated_product */
+		$updated_product = wc_get_product( $product->get_id() );
+		$this->assertSame( $expected_regular_price, $updated_product->get_regular_price( 'edit' ), 'Bulk Edit should persist the expected regular price.' );
+		$this->assertSame( $expected_sale_price, $updated_product->get_sale_price( 'edit' ), 'Bulk Edit should persist the expected sale price.' );
+	}
+
+	/**
+	 * Provides Bulk Edit price operations and expected persisted values.
+	 *
+	 * @return array<string, array{string, string, array<string, string>, string, string}>
+	 */
+	public static function bulk_edit_sale_price_edge_case_provider(): array {
+		return array(
+			'fixed regular increase'             => array(
+				'100',
+				'80',
+				array(
+					'change_regular_price' => '2',
+					'_regular_price'       => '10',
+				),
+				'110',
+				'80',
+			),
+			'percentage regular increase'        => array(
+				'100',
+				'80',
+				array(
+					'change_regular_price' => '2',
+					'_regular_price'       => '10%',
+				),
+				'110',
+				'80',
+			),
+			'fixed sale decrease'                => array(
+				'100',
+				'80',
+				array(
+					'change_sale_price' => '3',
+					'_sale_price'       => '5',
+				),
+				'100',
+				'75',
+			),
+			'percentage sale decrease'           => array(
+				'100',
+				'80',
+				array(
+					'change_sale_price' => '3',
+					'_sale_price'       => '10%',
+				),
+				'100',
+				'72',
+			),
+			'regular basis percentage'           => array(
+				'100',
+				'80',
+				array(
+					'change_sale_price' => '4',
+					'_sale_price'       => '10%',
+				),
+				'100',
+				'90',
+			),
+			'absent sale percentage decrease'    => array(
+				'100',
+				'',
+				array(
+					'change_sale_price' => '3',
+					'_sale_price'       => '10%',
+				),
+				'100',
+				'90',
+			),
+			'literal zero sale increase'         => array(
+				'100',
+				'0',
+				array(
+					'change_sale_price' => '2',
+					'_sale_price'       => '10%',
+				),
+				'100',
+				'0',
+			),
+			'empty change-to restoration'        => array(
+				'100',
+				'80',
+				array(
+					'change_sale_price' => '1',
+					'_sale_price'       => '',
+				),
+				'100',
+				'',
+			),
+			'two-decimal regular-basis rounding' => array(
+				'12.34',
+				'',
+				array(
+					'change_sale_price' => '4',
+					'_sale_price'       => '10%',
+				),
+				'12.34',
+				'11.11',
+			),
+		);
+	}
+
 	/**
 	 * @testdox Quick Edit stores sale boundaries in the site timezone.
 	 */
@@ -371,6 +544,68 @@ class WC_Admin_Post_Types_Test extends WC_Unit_Test_Case {
 		$this->sut->bulk_and_quick_edit_save_post( $product->get_id(), get_post( $product->get_id() ) );
 	}

+	/**
+	 * Submit a Bulk Edit request for selected products.
+	 *
+	 * @param WC_Product[]        $products Products to edit.
+	 * @param array<string,mixed> $request_data Request fields.
+	 */
+	private function bulk_edit( array $products, array $request_data ): void {
+		$_REQUEST = array_merge(
+			array(
+				'woocommerce_bulk_edit'        => '1',
+				'woocommerce_quick_edit_nonce' => wp_create_nonce( 'woocommerce_quick_edit_nonce' ),
+				'change_regular_price'         => '',
+				'_regular_price'               => '',
+				'change_sale_price'            => '',
+				'_sale_price'                  => '',
+				'change_weight'                => '',
+				'_weight'                      => '',
+				'change_dimensions'            => '',
+				'_length'                      => '',
+				'_width'                       => '',
+				'_height'                      => '',
+				'_tax_status'                  => '',
+				'_tax_class'                   => '',
+				'_shipping_class'              => '',
+				'_visibility'                  => '',
+				'_featured'                    => '',
+				'_sold_individually'           => '',
+				'_manage_stock'                => '',
+				'_backorders'                  => '',
+				'change_stock'                 => '',
+				'_stock'                       => '',
+				'_stock_status'                => '',
+				'change_cogs_value'            => '',
+				'_cogs_value'                  => '',
+			),
+			$request_data
+		);
+
+		foreach ( $products as $product ) {
+			/** @var WP_Post $post */
+			$post = get_post( $product->get_id() );
+			$this->sut->bulk_and_quick_edit_save_post( $product->get_id(), $post );
+		}
+	}
+
+	/**
+	 * Assert freshly reloaded product prices and stock.
+	 *
+	 * @param int    $product_id Product ID.
+	 * @param string $regular_price Expected regular price.
+	 * @param string $sale_price Expected sale price.
+	 * @param int    $stock_quantity Expected stock quantity.
+	 */
+	private function assert_product_prices_and_stock( int $product_id, string $regular_price, string $sale_price, int $stock_quantity ): void {
+		/** @var WC_Product $product */
+		$product = wc_get_product( $product_id );
+
+		$this->assertSame( $regular_price, $product->get_regular_price( 'edit' ), 'Bulk Edit should persist the expected regular price.' );
+		$this->assertSame( $sale_price, $product->get_sale_price( 'edit' ), 'Bulk Edit should persist the expected sale price.' );
+		$this->assertSame( $stock_quantity, $product->get_stock_quantity( 'edit' ), 'Bulk Edit should persist the expected stock quantity.' );
+	}
+
 	/**
 	 * Assert a stored sale date.
 	 *
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
new file mode 100644
index 00000000000..31329f6a6bf
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-product-data-test.php
@@ -0,0 +1,235 @@
+<?php
+/**
+ * Tests for the classic product data meta box.
+ *
+ * @package WooCommerce\Tests\Admin\MetaBoxes
+ */
+
+declare( strict_types = 1 );
+
+use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register as Download_Directories;
+
+/**
+ * Class WC_Meta_Box_Product_Data_Test.
+ */
+class WC_Meta_Box_Product_Data_Test extends WC_Unit_Test_Case {
+
+	/**
+	 * Original meta-box errors.
+	 *
+	 * @var string[]
+	 */
+	private $original_meta_box_errors;
+
+	/**
+	 * Original product-related globals and their presence.
+	 *
+	 * @var array<string, array{present: bool, value: mixed}>
+	 */
+	private $original_globals = array();
+
+	/**
+	 * Approved download directory register.
+	 *
+	 * @var Download_Directories
+	 */
+	private $download_directories;
+
+	/**
+	 * Set up an isolated classic admin request.
+	 *
+	 * 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.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$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 ) {
+			$this->original_globals[ $global_name ] = array(
+				'present' => array_key_exists( $global_name, $GLOBALS ),
+				'value'   => $GLOBALS[ $global_name ] ?? null,
+			);
+		}
+
+		WC_Admin_Meta_Boxes::$meta_box_errors = array();
+		$this->download_directories->set_mode( Download_Directories::MODE_DISABLED );
+		wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) );
+	}
+
+	/**
+	 * Restore the meta-box error list and the product globals.
+	 */
+	public function tearDown(): void {
+		try {
+			WC_Admin_Meta_Boxes::$meta_box_errors = $this->original_meta_box_errors;
+
+			foreach ( $this->original_globals as $global_name => $global ) {
+				if ( $global['present'] ) {
+					$GLOBALS[ $global_name ] = $global['value']; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the exact pre-test product global.
+				} else {
+					unset( $GLOBALS[ $global_name ] );
+				}
+			}
+		} finally {
+			parent::tearDown();
+		}
+	}
+
+	/**
+	 * @testdox The classic product save persists the simple product field matrix.
+	 * @dataProvider simple_product_type_provider
+	 *
+	 * @param bool $virtual Whether the submitted simple product is virtual.
+	 * @param bool $downloadable Whether the submitted simple product is downloadable.
+	 */
+	public function test_save_persists_simple_product_field_matrix( bool $virtual, bool $downloadable ): void {
+		$product = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'name'   => 'Field Matrix Simple Product',
+				'status' => 'publish',
+			)
+		);
+
+		$sku         = 'sku-' . wp_generate_uuid4();
+		$download_id = 'download-' . wp_generate_uuid4();
+		$download    = array(
+			'id'   => $download_id,
+			'name' => 'Field matrix download',
+			'file' => 'https://downloads.example.com/field-matrix/product.pdf',
+		);
+
+		$this->save_product_data( $product, $sku, $virtual, $downloadable, $download );
+
+		$fresh_product = wc_get_product( $product->get_id() );
+		$this->assertInstanceOf( WC_Product::class, $fresh_product, 'The saved product should reload from the data store.' );
+		$this->assertSame( '100.05', $fresh_product->get_regular_price( 'edit' ), 'The regular price should retain the submitted value.' );
+		$this->assertSame( $sku, $fresh_product->get_sku( 'edit' ), 'The unique SKU should retain the submitted value.' );
+		$this->assertSame( 'Field matrix purchase note', $fresh_product->get_purchase_note( 'edit' ), 'The purchase note should retain the submitted value.' );
+		$this->assertSame( $virtual, $fresh_product->get_virtual( 'edit' ), 'The virtual flag should retain the submitted value.' );
+		$this->assertSame( $downloadable, $fresh_product->get_downloadable( 'edit' ), 'The downloadable flag should retain the submitted value.' );
+		$this->assertSame( $downloadable ? 365 : -1, $fresh_product->get_download_expiry( 'edit' ), 'The download expiry should retain the submitted value.' );
+		$this->assertSame( array(), WC_Admin_Meta_Boxes::$meta_box_errors, 'The classic save should not add meta-box errors.' );
+
+		$this->assert_custom_attribute( $fresh_product );
+
+		if ( ! $virtual && ! $downloadable ) {
+			$this->assertSame( '2', $fresh_product->get_weight( 'edit' ), 'The physical product weight should retain the submitted value.' );
+			$this->assertSame( '20', $fresh_product->get_length( 'edit' ), 'The physical product length should retain the submitted value.' );
+			$this->assertSame( '10', $fresh_product->get_width( 'edit' ), 'The physical product width should retain the submitted value.' );
+			$this->assertSame( '30', $fresh_product->get_height( 'edit' ), 'The physical product height should retain the submitted value.' );
+		}
+
+		if ( $downloadable ) {
+			$downloads = $fresh_product->get_downloads( 'edit' );
+			$this->assertCount( 1, $downloads, 'The downloadable product should contain exactly one saved download.' );
+			$saved_download = current( $downloads );
+			$this->assertInstanceOf( WC_Product_Download::class, $saved_download, 'The saved download should be normalized to a WC_Product_Download object.' );
+			$this->assertSame(
+				$download,
+				array(
+					'id'   => $saved_download->get_id(),
+					'name' => $saved_download->get_name(),
+					'file' => $saved_download->get_file(),
+				),
+				'The saved download should retain its posted ID, name, and URL.'
+			);
+		} else {
+			$this->assertSame( array(), $fresh_product->get_downloads( 'edit' ), 'A non-downloadable product should not retain download records.' );
+		}
+	}
+
+	/**
+	 * Provide named simple product type rows.
+	 *
+	 * @return array<string, array{bool, bool}>
+	 */
+	public static function simple_product_type_provider(): array {
+		return array(
+			'physical'     => array( false, false ),
+			'virtual'      => array( true, false ),
+			'downloadable' => array( false, true ),
+		);
+	}
+
+	/**
+	 * Save a product through the public classic product data meta-box seam.
+	 *
+	 * @param WC_Product                                    $product Product to save.
+	 * @param string                                        $sku Unique SKU to submit.
+	 * @param bool                                          $virtual Whether to submit the virtual flag.
+	 * @param bool                                          $downloadable Whether to submit the downloadable flag.
+	 * @param array{id: string, name: string, file: string} $download Download data to submit.
+	 */
+	private function save_product_data( WC_Product $product, string $sku, bool $virtual, bool $downloadable, array $download ): void {
+		$_POST = array( // phpcs:ignore WordPress.Security.NonceVerification.Missing -- The test intentionally supplies the complete classic admin request.
+			'product-type'           => 'simple',
+			'_sku'                   => $sku,
+			'_global_unique_id'      => '',
+			'_purchase_note'         => 'Field matrix purchase note',
+			'_visibility'            => 'visible',
+			'_tax_status'            => 'taxable',
+			'_tax_class'             => '',
+			'_weight'                => '2',
+			'_length'                => '20',
+			'_width'                 => '10',
+			'_height'                => '30',
+			'product_shipping_class' => '0',
+			'upsell_ids'             => array(),
+			'crosssell_ids'          => array(),
+			'_regular_price'         => '100.05',
+			'_sale_price'            => '',
+			'_sale_price_dates_from' => '',
+			'_sale_price_dates_to'   => '',
+			'_manage_stock'          => '',
+			'_stock'                 => '',
+			'_low_stock_amount'      => '',
+			'_backorders'            => 'no',
+			'_stock_status'          => 'instock',
+			'_download_limit'        => '',
+			'_download_expiry'       => $downloadable ? '365' : '',
+			'_wc_file_names'         => $downloadable ? array( $download['name'] ) : array(),
+			'_wc_file_urls'          => $downloadable ? array( $download['file'] ) : array(),
+			'_wc_file_hashes'        => $downloadable ? array( $download['id'] ) : array(),
+			'_product_url'           => '',
+			'_button_text'           => '',
+			'comment_status'         => 'open',
+			'attribute_names'        => array( 'Field matrix attribute' ),
+			'attribute_values'       => array( 'Field matrix value' ),
+			'attribute_position'     => array( 0 ),
+			'attribute_visibility'   => array( 0 => '1' ),
+			'attribute_variation'    => array(),
+		);
+
+		if ( $virtual ) {
+			$_POST['_virtual'] = 'yes'; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- The test intentionally supplies the classic admin request.
+		}
+
+		if ( $downloadable ) {
+			$_POST['_downloadable'] = 'yes'; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- The test intentionally supplies the classic admin request.
+		}
+
+		$post = get_post( $product->get_id() );
+		$this->assertInstanceOf( WP_Post::class, $post, 'The product fixture should have a persisted post.' );
+		WC_Meta_Box_Product_Data::save( $product->get_id(), $post );
+	}
+
+	/**
+	 * Assert the one submitted custom attribute.
+	 *
+	 * @param WC_Product $product Freshly saved product.
+	 */
+	private function assert_custom_attribute( WC_Product $product ): void {
+		$attributes = $product->get_attributes( 'edit' );
+		$this->assertCount( 1, $attributes, 'The saved product should contain exactly one custom attribute.' );
+		$attribute = current( $attributes );
+		$this->assertInstanceOf( WC_Product_Attribute::class, $attribute, 'The saved custom attribute should be normalized to a WC_Product_Attribute object.' );
+		$this->assertSame( 'Field matrix attribute', $attribute->get_name(), 'The custom attribute name should retain the submitted value.' );
+		$this->assertSame( array( 'Field matrix value' ), $attribute->get_options(), 'The custom attribute value should retain the submitted value.' );
+	}
+}