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.' );
+ }
+}