Commit 6ced0e09ea7 for woocommerce
commit 6ced0e09ea7e933194913fc1d0bfc9d80e710a18
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Mon Sep 14 17:24:51 2026 +0300
[tests] Cut linked products E2E from 4 titles to 2, moving relation clearing to PHPUnit (#68648)
test(products): Cut linked products E2E from 4 titles to 2
The linked products spec drove the classic editor four times: twice to
attach an up-sell or a cross-sell and see it on the storefront, and
twice to detach one and see it gone. Attaching is a browser journey
worth keeping, because it runs through the select2 search control that
only exists in the editor. Detaching is a persistence assertion
wearing a browser costume: it posts an empty field and checks the
front end stops rendering the relation.
The two `remove` titles go. WC_Meta_Box_Product_Data_Linked_Products_Test
takes that behavior through WC_Meta_Box_Product_Data::save(), asserting
both that the cleared relation is persisted as empty and that
woocommerce_upsell_display() and woocommerce_cross_sell_display() stop
rendering their sections. Those are the functions the single-product
and cart templates hook, so it is the same markup the browser titles
looked at.
What does not survive is the removal gesture itself. Nothing now
drives the select2 control's backspace and checks that it posts a
shorter array. The two retained titles exercise that same control in
the opposite direction, which is good evidence but is not proof.
`add up-sells` and `add cross-sells` stay byte-identical to the diff
base.
Three deliberate changes to the new PHPUnit file before landing:
- Five fixture names carried the campaign's internal slice
identifiers; they are renamed to neutral values.
- The clearing test seeded the relations and then asserted they were
empty, without ever checking that the seed had persisted. It would
have passed just as happily against a seed that stored nothing. It
now proves its own precondition first.
- The cross-sell assertions moved off the rendered markup and onto the
cart's own cross-sell list. The reason is below, because it is not a
preference.
The cross-sell markup could not be asserted here, and the first
version of this file was wrong to try.
woocommerce_cross_sell_display() begins `if ( is_checkout() ) return;`.
is_checkout() is true whenever WOOCOMMERCE_CHECKOUT is defined, and
tests/legacy/unit-tests/coupon/coupon.php defines it -- a plain PHP
define, which cannot be undone for the rest of the process. phpunit.xml
sets defaultTestSuite="wc-phpunit-legacy,wc-phpunit-main", so the legacy
suite always runs first and that constant is always set by the time
these tests run.
So on CI the renderer emitted nothing here, every time. The positive
test failed asserting that '' contains div class="cross-sells", on all
four PHP/WP matrix jobs including HPOS:off. Worse, the clearing test's
matching assertion -- that the section does *not* render -- passed
vacuously for the same reason, and would have kept passing if
cross-sells had rendered perfectly.
Reproducing it needs two tests rather than the full suite: run the
coupon test that defines the constant together with either of these and
the failure appears. Under --testsuite=wc-phpunit-main alone, which
excludes the legacy suite, both tests pass, which is why this was not
caught locally before.
WC()->cart->get_cross_sells() is the contract WC_Meta_Box_Product_Data
actually feeds, and it is assertable regardless of checkout state. Both
tests now assert it: exact IDs in submitted order, no upsell IDs
crossing over, and the cart product not cross-selling itself. That is
stronger than the string matching it replaces, and the clearing test
now has an assertion that can fail. The up-sell markup assertions are
untouched: woocommerce_upsell_display() has no such guard.
This is the linked products half of a batch that also covered product
images. The two shared no commit, helper or fixture, so they were
split; the images half is a separate PR.
Carries the mega-branch commit:
- ff6181c0ce test(e2e): Reduce linked products browser coverage
Refs TESTOPS-288
Refs #68046
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/testops-288-linked-products b/plugins/woocommerce/changelog/testops-288-linked-products
new file mode 100644
index 00000000000..20328ac95a1
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-288-linked-products
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Cut the linked products E2E spec from 4 titles to 2; clearing up-sells and cross-sells now covered by WC_Meta_Box_Product_Data_Linked_Products_Test.
+
diff --git a/plugins/woocommerce/tests/e2e/tests/product/product-linked-products.spec.ts b/plugins/woocommerce/tests/e2e/tests/product/product-linked-products.spec.ts
index e68e268f5a2..33668ad26bc 100644
--- a/plugins/woocommerce/tests/e2e/tests/product/product-linked-products.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/product/product-linked-products.spec.ts
@@ -154,59 +154,6 @@ test.describe(
} );
} );
- test( 'remove up-sells', async ( { page, restApi, products } ) => {
- // Add up-sells
- await restApi.put(
- `${ WC_API_PATH }/products/${ products.main.id }`,
- {
- upsell_ids: [ products.linked1.id ],
- }
- );
-
- // Verify up-sells are present, so we can assert the opposite after removing them
- // This should prevent a possible false negative result
- await test.step( 'verify the up-sells in the store frontend', async () => {
- await page.goto( products.main.permalink );
-
- await expect(
- page.locator( 'section.upsells' ).getByRole( 'heading', {
- name: products.linked1.name,
- } )
- ).toBeVisible();
- } );
-
- await navigate( page, products.main.id );
-
- await test.step( 'remove up-sells for a product', async () => {
- // Using backspace to remove the product because clicking the remove button is flaky
- const upsellTextBoxLocator = page
- .locator( 'p' )
- .filter( { hasText: 'Upsells' } )
- .getByRole( 'textbox' );
- await upsellTextBoxLocator.waitFor( { state: 'visible' } );
- await upsellTextBoxLocator.click();
- await page.keyboard.press( 'Backspace' );
-
- await expect(
- page.getByRole( 'listitem', {
- name: products.linked1.name,
- } )
- ).toBeHidden();
- } );
-
- await updateProduct( page );
-
- await test.step( 'verify the up-sells in the store frontend', async () => {
- await page.goto( products.main.permalink );
-
- await expect(
- page.locator( 'section.upsells' ).getByRole( 'heading', {
- name: products.linked1.name,
- } )
- ).toBeHidden();
- } );
- } );
-
test( 'add cross-sells', async ( { page, products } ) => {
await navigate( page, products.main.id );
@@ -281,54 +228,5 @@ test.describe(
).toBeVisible();
} );
} );
-
- test( 'remove cross-sells', async ( { page, restApi, products } ) => {
- // Add cross-sells
- await restApi.put(
- `${ WC_API_PATH }/products/${ products.main.id }`,
- {
- cross_sell_ids: [ products.linked1.id ],
- }
- );
-
- await navigate( page, products.main.id );
-
- await test.step( 'remove cross-sells for a product', async () => {
- // Using backspace to remove the product because clicking the remove button is flaky
- const crossSellTextBoxLocator = page
- .locator( 'p' )
- .filter( { hasText: 'Cross-sells' } )
- .getByRole( 'textbox' );
- await crossSellTextBoxLocator.waitFor( { state: 'visible' } );
- await crossSellTextBoxLocator.click();
- await page.keyboard.press( 'Backspace' );
-
- await expect(
- page.getByRole( 'listitem', {
- name: products.linked1.name,
- } )
- ).toBeHidden();
- } );
-
- await updateProduct( page );
-
- await test.step( 'verify the cross-sells in the store frontend', async () => {
- await page.goto( products.main.permalink );
-
- // add to cart and view proceed to checkout
- await page
- .getByRole( 'button', { name: 'Add to cart', exact: true } )
- .click();
- await page
- .getByRole( 'link', { name: 'View cart' } )
- .first()
- .click();
-
- // check for cross-sells
- await expect(
- page.getByText( products.linked1.name )
- ).toBeHidden();
- } );
- } );
}
);
diff --git a/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-product-data-linked-products-test.php b/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-product-data-linked-products-test.php
new file mode 100644
index 00000000000..79fcf096177
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-product-data-linked-products-test.php
@@ -0,0 +1,336 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * Tests for linked products saved through the classic product data meta box.
+ *
+ * @package WooCommerce\Tests\Admin\MetaBoxes
+ */
+
+/**
+ * Class WC_Meta_Box_Product_Data_Linked_Products_Test
+ */
+class WC_Meta_Box_Product_Data_Linked_Products_Test extends WC_Unit_Test_Case {
+
+ /**
+ * Product IDs created by the current test.
+ *
+ * @var int[]
+ */
+ private $product_ids = array();
+
+ /**
+ * Original POST data.
+ *
+ * @var array<string, mixed>
+ */
+ private $original_post_data;
+
+ /**
+ * Original WooCommerce cart.
+ *
+ * @var WC_Cart|null
+ */
+ private $original_cart;
+
+ /**
+ * Original global values and their presence.
+ *
+ * @var array<string, array{present: bool, value: mixed}>
+ */
+ private $original_globals = array();
+
+ /**
+ * Set up isolated request, cart, and template globals.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $this->original_post_data = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Test snapshots request state before exercising the real admin save seam.
+ $this->original_cart = WC()->cart;
+
+ foreach ( array( 'post', 'product', 'woocommerce_loop' ) as $global_name ) {
+ $this->original_globals[ $global_name ] = array(
+ 'present' => array_key_exists( $global_name, $GLOBALS ),
+ 'value' => $GLOBALS[ $global_name ] ?? null,
+ );
+ }
+
+ $_POST = array();
+ WC()->cart = new WC_Cart();
+ wc_reset_loop();
+ }
+
+ /**
+ * Restore request, cart, and template globals and delete fixtures.
+ */
+ public function tearDown(): void {
+ try {
+ if ( WC()->cart ) {
+ WC()->cart->empty_cart();
+ }
+
+ wp_reset_postdata();
+ wc_reset_loop();
+
+ foreach ( array_reverse( $this->product_ids ) as $product_id ) {
+ WC_Helper_Product::delete_product( $product_id );
+ }
+ } finally {
+ $_POST = $this->original_post_data;
+ WC()->cart = $this->original_cart;
+
+ 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 template global.
+ } else {
+ unset( $GLOBALS[ $global_name ] );
+ }
+ }
+
+ parent::tearDown();
+ }
+ }
+
+ /**
+ * @testdox The classic product save keeps upsells and cross-sells distinct and renders each in its own section.
+ */
+ public function test_save_persists_and_renders_distinct_linked_products(): void {
+ $products = $this->create_linked_product_fixtures();
+
+ $this->save_product_data(
+ $products['main'],
+ array(
+ 'upsell_ids' => array( $products['upsell_one']->get_id(), $products['upsell_two']->get_id() ),
+ 'crosssell_ids' => array( $products['cross_sell_one']->get_id(), $products['cross_sell_two']->get_id() ),
+ )
+ );
+
+ $fresh_main = wc_get_product( $products['main']->get_id() );
+ $this->assertInstanceOf( WC_Product::class, $fresh_main, 'The saved main product should reload from the data store.' );
+ $this->assertSame(
+ array( $products['upsell_one']->get_id(), $products['upsell_two']->get_id() ),
+ $fresh_main->get_upsell_ids( 'edit' ),
+ 'Upsell IDs should retain their exact submitted order.'
+ );
+ $this->assertSame(
+ array( $products['cross_sell_one']->get_id(), $products['cross_sell_two']->get_id() ),
+ $fresh_main->get_cross_sell_ids( 'edit' ),
+ 'Cross-sell IDs should retain their exact submitted order.'
+ );
+ $this->assertSame(
+ array(),
+ array_intersect( $fresh_main->get_upsell_ids( 'edit' ), $fresh_main->get_cross_sell_ids( 'edit' ) ),
+ 'Upsells and cross-sells should not be cross-wired.'
+ );
+
+ $upsell_output = $this->render_upsells( $fresh_main );
+ $this->assertStringContainsString( 'section class="up-sells upsells products"', $upsell_output, 'The classic upsell section should render.' );
+ $this->assertStringContainsString( $products['upsell_one']->get_name(), $upsell_output, 'The first upsell should render in the upsell section.' );
+ $this->assertStringContainsString( $products['upsell_two']->get_name(), $upsell_output, 'The second upsell should render in the upsell section.' );
+ $this->assertStringNotContainsString( $products['cross_sell_one']->get_name(), $upsell_output, 'Cross-sells must not render in the upsell section.' );
+ $this->assertStringNotContainsString( $products['cross_sell_two']->get_name(), $upsell_output, 'Cross-sells must not render in the upsell section.' );
+ $this->assertStringNotContainsString( $fresh_main->get_name(), $upsell_output, 'The main product must not render as its own upsell.' );
+
+ $cart_cross_sells = $this->cart_cross_sell_ids( $fresh_main );
+ $this->assertSame(
+ array( $products['cross_sell_one']->get_id(), $products['cross_sell_two']->get_id() ),
+ $cart_cross_sells,
+ 'The cart should offer exactly the saved cross-sells, in their submitted order.'
+ );
+ $this->assertSame(
+ array(),
+ array_intersect( $cart_cross_sells, array( $products['upsell_one']->get_id(), $products['upsell_two']->get_id() ) ),
+ 'Upsells must not reach the cart cross-sell list.'
+ );
+ $this->assertNotContains( $fresh_main->get_id(), $cart_cross_sells, 'The cart product must not cross-sell itself.' );
+ }
+
+ /**
+ * @testdox Omitting both linked-product fields clears their IDs and hides both classic sections.
+ */
+ public function test_save_clears_and_hides_linked_products(): void {
+ $products = $this->create_linked_product_fixtures();
+ $main = $products['main'];
+
+ $main->set_upsell_ids( array( $products['upsell_one']->get_id(), $products['upsell_two']->get_id() ) );
+ $main->set_cross_sell_ids( array( $products['cross_sell_one']->get_id(), $products['cross_sell_two']->get_id() ) );
+ $main->save();
+
+ // Prove the fixture actually persisted before clearing it. Without this the
+ // assertions below would pass just as happily against a seed that never
+ // stored anything, which is the one way this test could lie.
+ $seeded_main = wc_get_product( $main->get_id() );
+ $this->assertSame(
+ array( $products['upsell_one']->get_id(), $products['upsell_two']->get_id() ),
+ $seeded_main->get_upsell_ids( 'edit' ),
+ 'The fixture should store its upsell IDs before the clearing save.'
+ );
+ $this->assertSame(
+ array( $products['cross_sell_one']->get_id(), $products['cross_sell_two']->get_id() ),
+ $seeded_main->get_cross_sell_ids( 'edit' ),
+ 'The fixture should store its cross-sell IDs before the clearing save.'
+ );
+
+ $this->save_product_data( $main );
+
+ $fresh_main = wc_get_product( $main->get_id() );
+ $this->assertInstanceOf( WC_Product::class, $fresh_main, 'The cleared main product should reload from the data store.' );
+ $this->assertSame( array(), $fresh_main->get_upsell_ids( 'edit' ), 'An omitted upsell field should clear every stored upsell ID.' );
+ $this->assertSame( array(), $fresh_main->get_cross_sell_ids( 'edit' ), 'An omitted cross-sell field should clear every stored cross-sell ID.' );
+
+ $upsell_output = $this->render_upsells( $fresh_main );
+ $this->assertStringNotContainsString( 'section class="up-sells upsells products"', $upsell_output, 'An empty upsell collection should not render its section.' );
+ $this->assertLinkedProductNamesAbsent( $products, $upsell_output, 'upsell' );
+
+ $this->assertSame(
+ array(),
+ $this->cart_cross_sell_ids( $fresh_main ),
+ 'An empty cross-sell collection should leave the cart with nothing to cross-sell.'
+ );
+ }
+
+ /**
+ * Create one main product and two distinct products for each linked family.
+ *
+ * @return array{main: WC_Product, upsell_one: WC_Product, upsell_two: WC_Product, cross_sell_one: WC_Product, cross_sell_two: WC_Product}
+ */
+ private function create_linked_product_fixtures(): array {
+ $names = array(
+ 'main' => 'Linked Products Main Product',
+ 'upsell_one' => 'Linked Products Upsell Alpha',
+ 'upsell_two' => 'Linked Products Upsell Beta',
+ 'cross_sell_one' => 'Linked Products Cross-sell Gamma',
+ 'cross_sell_two' => 'Linked Products Cross-sell Delta',
+ );
+ $products = array();
+
+ foreach ( $names as $key => $name ) {
+ $product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'name' => $name,
+ 'regular_price' => '10',
+ )
+ );
+ $this->product_ids[] = $product->get_id();
+ $products[ $key ] = $product;
+ }
+
+ return $products;
+ }
+
+ /**
+ * Save a product through the real classic meta-box seam.
+ *
+ * @param WC_Product $product Product to save.
+ * @param array<string,mixed> $linked Optional linked-product fields.
+ */
+ private function save_product_data( WC_Product $product, array $linked = array() ): void {
+ $_POST = array_merge( // phpcs:ignore WordPress.Security.NonceVerification.Missing -- The test intentionally supplies the classic admin request.
+ array(
+ 'product-type' => 'simple',
+ '_sku' => '',
+ '_regular_price' => $product->get_regular_price( 'edit' ),
+ '_sale_price' => '',
+ '_visibility' => 'visible',
+ '_tax_status' => 'taxable',
+ '_tax_class' => '',
+ '_stock_status' => 'instock',
+ '_backorders' => 'no',
+ '_download_limit' => '',
+ '_download_expiry' => '',
+ '_low_stock_amount' => '',
+ 'comment_status' => 'open',
+ ),
+ $linked
+ );
+
+ $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 );
+ }
+
+ /**
+ * Render the classic upsell template for a product.
+ *
+ * @param WC_Product $main Main product.
+ * @return string
+ */
+ private function render_upsells( WC_Product $main ): string {
+ $GLOBALS['post'] = get_post( $main->get_id() ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Classic template setup requires the product post global.
+ $GLOBALS['product'] = $main; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Classic upsell rendering reads the product global.
+ wc_reset_loop();
+
+ return $this->capture_output(
+ static function (): void {
+ woocommerce_upsell_display( -1, 4, 'none', 'asc' );
+ }
+ );
+ }
+
+ /**
+ * Put the main product in a real cart and return the cross-sell IDs it exposes.
+ *
+ * The rendered `cart/cross-sells.php` section is deliberately not asserted here.
+ * Its renderer, woocommerce_cross_sell_display(), returns early when is_checkout()
+ * is true, and is_checkout() consults the WOOCOMMERCE_CHECKOUT constant, which
+ * tests/legacy/unit-tests/coupon/coupon.php defines for the remainder of the
+ * process. phpunit.xml runs the legacy suite before this one, so by the time these
+ * tests run that renderer emits nothing: a positive assertion on its markup fails,
+ * and a negative one passes without proving anything. A PHP define cannot be
+ * undone, so there is no way to restore the renderer mid-process. The cart's own
+ * cross-sell list is the contract this meta box feeds, and it is assertable.
+ *
+ * @param WC_Product $main Product to place in the cart.
+ * @return int[] Cross-sell IDs the cart offers, in order.
+ */
+ private function cart_cross_sell_ids( WC_Product $main ): array {
+ WC()->cart->empty_cart();
+ $cart_item_key = WC()->cart->add_to_cart( $main->get_id() );
+ $this->assertNotFalse( $cart_item_key, 'The main product should enter the real cart before its cross-sells are read.' );
+
+ return array_map( 'intval', array_values( WC()->cart->get_cross_sells() ) );
+ }
+
+ /**
+ * Capture output while restoring the caller's output-buffer depth on failure.
+ *
+ * @param callable(): void $callback Renderer.
+ * @return string
+ */
+ private function capture_output( callable $callback ): string {
+ $buffer_level = ob_get_level();
+ ob_start();
+
+ try {
+ $callback();
+ return (string) ob_get_clean();
+ } finally {
+ while ( ob_get_level() > $buffer_level ) {
+ ob_end_clean();
+ }
+ }
+ }
+
+ /**
+ * Assert that no linked fixture name appears in rendered output.
+ *
+ * @param array<string, WC_Product> $products Product fixtures.
+ * @param string $output Rendered output.
+ * @param string $family Human-readable family label.
+ */
+ private function assertLinkedProductNamesAbsent( array $products, string $output, string $family ): void {
+ foreach ( $products as $key => $product ) {
+ if ( 'main' === $key ) {
+ continue;
+ }
+
+ $this->assertStringNotContainsString(
+ $product->get_name(),
+ $output,
+ sprintf( 'Cleared %s output should omit %s.', $family, $product->get_name() )
+ );
+ }
+ }
+}