Commit f24a690f30c for woocommerce

commit f24a690f30cd449e1ef3beb61ce8bcac30fb0d12
Author: Mike Jolley <mike.jolley@me.com>
Date:   Wed Apr 8 11:58:03 2026 +0100

    Add unit tests for cart and checkout SlotFill components (#63950)

    * Add unit tests for cart and checkout SlotFill components

    Add tests for the 4 public SlotFill components (ExperimentalOrderMeta,
    ExperimentalDiscountsMeta, ExperimentalOrderShippingPackages,
    ExperimentalOrderLocalPickupPackages) and 3 consumer blocks that render
    their slots, ensuring fills render correctly, fillProps are forwarded,
    and slots are always mounted regardless of consumer state.

    Includes a shared test utility (renderSlotFill/getFillProps) and a
    missing @woocommerce/sanitize jest moduleNameMapper entry.

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

    * Fix CI lint and test failures in SlotFill tests

    - Move test-utils.tsx out of test/ directory so Jest doesn't pick it up
      as a test file (it has no tests, only utility exports)
    - Remove unused imports (useEditorContext, useShippingData) from
      shipping-rates-control slotfill test
    - Fix prettier formatting issues across test files
    - Fix @woocommerce/dependency-group lint error in test-utils.tsx

    Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control/test/slotfill.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control/test/slotfill.tsx
new file mode 100644
index 00000000000..009ad8bd99e
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/shipping-rates-control/test/slotfill.tsx
@@ -0,0 +1,87 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+import { useStoreCart } from '@woocommerce/base-context';
+
+/**
+ * Internal dependencies
+ */
+import ShippingRatesControl from '..';
+
+jest.mock( '@woocommerce/base-context', () => ( {
+	useStoreCart: jest.fn(),
+	useEditorContext: jest.fn( () => ( { isEditor: false } ) ),
+	useShippingData: jest.fn( () => ( {
+		hasSelectedLocalPickup: false,
+		selectedRates: {},
+	} ) ),
+} ) );
+
+jest.mock( '@woocommerce/base-hooks', () => ( {
+	usePrevious: jest.fn(),
+} ) );
+
+const mockSlotRender = jest.fn( () => <div data-testid="shipping-slot" /> );
+jest.mock( '@woocommerce/blocks-checkout', () => {
+	const MockFill = ( { children }: { children: React.ReactNode } ) => (
+		<>{ children }</>
+	);
+	MockFill.Slot = ( props: Record< string, unknown > ) =>
+		mockSlotRender( props );
+	return { ExperimentalOrderShippingPackages: MockFill };
+} );
+
+const defaultProps = {
+	shippingRates: [],
+	isLoadingRates: false,
+	className: 'test-class',
+	collapsible: false,
+	showItems: false,
+	noResultsMessage: <span>No rates</span>,
+	renderOption: jest.fn(),
+	context: 'woocommerce/checkout',
+};
+
+describe( 'ShippingRatesControl slot rendering', () => {
+	beforeEach( () => {
+		jest.clearAllMocks();
+		( useStoreCart as jest.Mock ).mockReturnValue( {
+			extensions: { 'ship-ext': true },
+			receiveCart: jest.fn(),
+			cartTotals: {},
+		} );
+	} );
+
+	it( 'renders ExperimentalOrderShippingPackages.Slot with correct props when not loading', () => {
+		render( <ShippingRatesControl { ...defaultProps } /> );
+
+		expect( screen.getByTestId( 'shipping-slot' ) ).toBeInTheDocument();
+
+		expect( mockSlotRender ).toHaveBeenCalledWith(
+			expect.objectContaining( {
+				context: 'woocommerce/checkout',
+				extensions: { 'ship-ext': true },
+				collapsible: false,
+				showItems: false,
+			} )
+		);
+
+		const slotProps = mockSlotRender.mock.calls[ 0 ][ 0 ];
+		expect( slotProps.cart ).not.toHaveProperty( 'receiveCart' );
+		expect( slotProps ).toHaveProperty( 'components' );
+		expect( slotProps ).toHaveProperty( 'renderOption' );
+		expect( slotProps ).toHaveProperty( 'noResultsMessage' );
+	} );
+
+	it( 'does not render the slot when rates are loading', () => {
+		render(
+			<ShippingRatesControl { ...defaultProps } isLoadingRates={ true } />
+		);
+
+		expect(
+			screen.queryByTestId( 'shipping-slot' )
+		).not.toBeInTheDocument();
+		expect( mockSlotRender ).not.toHaveBeenCalled();
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/cart/inner-blocks/cart-order-summary-block/test/slotfills.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/cart/inner-blocks/cart-order-summary-block/test/slotfills.tsx
new file mode 100644
index 00000000000..aa910cbe464
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/cart/inner-blocks/cart-order-summary-block/test/slotfills.tsx
@@ -0,0 +1,54 @@
+/**
+ * External dependencies
+ */
+import { render } from '@testing-library/react';
+import { useStoreCart } from '@woocommerce/base-context/hooks';
+
+/**
+ * Internal dependencies
+ */
+import { OrderMetaSlotFill } from '../slotfills';
+
+jest.mock( '@woocommerce/base-context/hooks', () => ( {
+	useStoreCart: jest.fn(),
+} ) );
+
+const mockSlotRender = jest.fn( () => <div data-testid="order-meta-slot" /> );
+jest.mock( '@woocommerce/blocks-checkout', () => {
+	const MockFill = ( { children }: { children: React.ReactNode } ) => (
+		<>{ children }</>
+	);
+	MockFill.Slot = ( props: Record< string, unknown > ) =>
+		mockSlotRender( props );
+	return { ExperimentalOrderMeta: MockFill };
+} );
+
+describe( 'Cart OrderMetaSlotFill', () => {
+	beforeEach( () => {
+		jest.clearAllMocks();
+	} );
+
+	it( 'always renders ExperimentalOrderMeta.Slot with cart context and correct props', () => {
+		( useStoreCart as jest.Mock ).mockReturnValue( {
+			extensions: { 'my-ext': true },
+			receiveCart: jest.fn(),
+			cartTotals: { total: '1000' },
+		} );
+
+		render( <OrderMetaSlotFill /> );
+
+		expect( mockSlotRender ).toHaveBeenCalledWith(
+			expect.objectContaining( {
+				context: 'woocommerce/cart',
+				extensions: { 'my-ext': true },
+				cart: expect.objectContaining( {
+					cartTotals: { total: '1000' },
+				} ),
+			} )
+		);
+
+		// receiveCart should be excluded from cart props.
+		const slotProps = mockSlotRender.mock.calls[ 0 ][ 0 ];
+		expect( slotProps.cart ).not.toHaveProperty( 'receiveCart' );
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-discount/test/block.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-discount/test/block.tsx
new file mode 100644
index 00000000000..7aa188154bd
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/checkout/inner-blocks/checkout-order-summary-discount/test/block.tsx
@@ -0,0 +1,97 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+import { useStoreCart } from '@woocommerce/base-context/hooks';
+
+/**
+ * Internal dependencies
+ */
+import Block from '../block';
+
+jest.mock( '@woocommerce/base-context/hooks', () => ( {
+	useStoreCart: jest.fn(),
+	useStoreCartCoupons: jest.fn( () => ( {
+		removeCoupon: jest.fn(),
+		isRemovingCoupon: false,
+	} ) ),
+	useOrderSummaryLoadingState: jest.fn( () => ( { isLoading: false } ) ),
+} ) );
+
+const mockSlotRender = jest.fn( () => <div data-testid="discount-slot" /> );
+jest.mock( '@woocommerce/blocks-checkout', () => {
+	const MockFill = ( { children }: { children: React.ReactNode } ) => (
+		<>{ children }</>
+	);
+	MockFill.Slot = ( props: Record< string, unknown > ) =>
+		mockSlotRender( props );
+	return {
+		ExperimentalDiscountsMeta: MockFill,
+		applyCheckoutFilter: jest.fn(
+			( { defaultValue }: { defaultValue: unknown } ) => defaultValue
+		),
+	};
+} );
+
+const mockCartData = {
+	cartTotals: {
+		currency_code: 'USD',
+		currency_symbol: '$',
+		currency_minor_unit: 2,
+		currency_decimal_separator: '.',
+		currency_thousand_separator: ',',
+		currency_prefix: '$',
+		currency_suffix: '',
+		total_discount: '0',
+		total_discount_tax: '0',
+	},
+	cartCoupons: [],
+	extensions: { some: 'data' },
+	receiveCart: jest.fn(),
+};
+
+describe( 'Checkout Order Summary Discount Block', () => {
+	beforeEach( () => {
+		jest.clearAllMocks();
+		( useStoreCart as jest.Mock ).mockReturnValue( mockCartData );
+	} );
+
+	it( 'renders the DiscountsMeta slot with checkout context when no coupons', () => {
+		render( <Block /> );
+
+		expect( screen.getByTestId( 'discount-slot' ) ).toBeInTheDocument();
+		expect( mockSlotRender ).toHaveBeenCalledWith(
+			expect.objectContaining( {
+				context: 'woocommerce/checkout',
+				extensions: { some: 'data' },
+			} )
+		);
+
+		const slotProps = mockSlotRender.mock.calls[ 0 ][ 0 ];
+		expect( slotProps.cart ).not.toHaveProperty( 'receiveCart' );
+	} );
+
+	it( 'still renders the DiscountsMeta slot when coupons are present', () => {
+		( useStoreCart as jest.Mock ).mockReturnValue( {
+			...mockCartData,
+			cartCoupons: [
+				{
+					code: 'SAVE10',
+					label: 'SAVE10',
+					totals: {
+						total_discount: '1000',
+						total_discount_tax: '0',
+					},
+				},
+			],
+			cartTotals: {
+				...mockCartData.cartTotals,
+				total_discount: '1000',
+			},
+		} );
+
+		render( <Block className="test-class" /> );
+
+		expect( screen.getByTestId( 'discount-slot' ) ).toBeInTheDocument();
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/packages/checkout/components/discounts-meta/test/index.tsx b/plugins/woocommerce/client/blocks/packages/checkout/components/discounts-meta/test/index.tsx
new file mode 100644
index 00000000000..bde36f8d19f
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/packages/checkout/components/discounts-meta/test/index.tsx
@@ -0,0 +1,58 @@
+/**
+ * External dependencies
+ */
+import { screen } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import ExperimentalDiscountsMeta from '..';
+import { renderSlotFill, getFillProps } from '../../../slot/test-utils';
+
+const defaultSlotProps = {
+	extensions: {},
+	cart: {},
+	context: 'woocommerce/checkout',
+};
+
+describe( 'ExperimentalDiscountsMeta', () => {
+	it( 'renders fill content inside the slot', () => {
+		renderSlotFill( ExperimentalDiscountsMeta, defaultSlotProps );
+
+		expect( screen.getByTestId( 'fill-content' ) ).toBeInTheDocument();
+	} );
+
+	it( 'wraps the slot in a TotalsWrapper with expected classes', () => {
+		const { container } = renderSlotFill( ExperimentalDiscountsMeta, {
+			...defaultSlotProps,
+			className: 'my-discount-class',
+		} );
+
+		const wrapper = container.querySelector(
+			'.wc-block-components-totals-wrapper.slot-wrapper'
+		);
+		expect( wrapper ).toBeInTheDocument();
+
+		const slot = container.querySelector(
+			'.wc-block-components-discounts-meta'
+		);
+		expect( slot ).toBeInTheDocument();
+		expect( slot ).toHaveClass( 'my-discount-class' );
+	} );
+
+	it( 'passes extensions, cart, and context via fillProps', () => {
+		const extensions = { 'discount-ext': { active: true } };
+		const cart = { coupons: [ { code: 'SAVE10' } ] };
+		const context = 'woocommerce/cart';
+
+		const fillProps = getFillProps( ExperimentalDiscountsMeta, {
+			extensions,
+			cart,
+			context,
+		} );
+
+		expect( fillProps ).toEqual(
+			expect.objectContaining( { extensions, cart, context } )
+		);
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/packages/checkout/components/order-local-pickup-packages/test/index.tsx b/plugins/woocommerce/client/blocks/packages/checkout/components/order-local-pickup-packages/test/index.tsx
new file mode 100644
index 00000000000..7ef645ccfc7
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/packages/checkout/components/order-local-pickup-packages/test/index.tsx
@@ -0,0 +1,56 @@
+/**
+ * External dependencies
+ */
+import { screen } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import ExperimentalOrderLocalPickupPackages from '..';
+import { renderSlotFill, getFillProps } from '../../../slot/test-utils';
+
+describe( 'ExperimentalOrderLocalPickupPackages', () => {
+	const defaultSlotProps = {
+		extensions: {},
+		cart: {},
+		components: {},
+		renderPickupLocation: jest.fn(),
+	};
+
+	it( 'renders fill content inside the slot with expected classes', () => {
+		const { container } = renderSlotFill(
+			ExperimentalOrderLocalPickupPackages,
+			defaultSlotProps
+		);
+
+		expect( screen.getByTestId( 'fill-content' ) ).toBeInTheDocument();
+		expect(
+			container.querySelector(
+				'.wc-block-components-local-pickup-rates-control'
+			)
+		).toBeInTheDocument();
+	} );
+
+	it( 'passes all expected fillProps', () => {
+		const extensions = { 'pickup-ext': { locations: [] } };
+		const cart = { items: [ { id: 1 } ] };
+		const components = { PickupOption: () => null };
+		const renderPickupLocation = jest.fn();
+
+		const fillProps = getFillProps( ExperimentalOrderLocalPickupPackages, {
+			extensions,
+			cart,
+			components,
+			renderPickupLocation,
+		} );
+
+		expect( fillProps ).toEqual(
+			expect.objectContaining( {
+				extensions,
+				cart,
+				components,
+				renderPickupLocation,
+			} )
+		);
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/packages/checkout/components/order-meta/test/index.tsx b/plugins/woocommerce/client/blocks/packages/checkout/components/order-meta/test/index.tsx
new file mode 100644
index 00000000000..53e32bb5ff1
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/packages/checkout/components/order-meta/test/index.tsx
@@ -0,0 +1,58 @@
+/**
+ * External dependencies
+ */
+import { screen } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import ExperimentalOrderMeta from '..';
+import { renderSlotFill, getFillProps } from '../../../slot/test-utils';
+
+const defaultSlotProps = {
+	extensions: {},
+	cart: {},
+	context: 'woocommerce/checkout',
+};
+
+describe( 'ExperimentalOrderMeta', () => {
+	it( 'renders fill content inside the slot', () => {
+		renderSlotFill( ExperimentalOrderMeta, defaultSlotProps );
+
+		expect( screen.getByTestId( 'fill-content' ) ).toBeInTheDocument();
+	} );
+
+	it( 'wraps the slot in a TotalsWrapper with expected classes', () => {
+		const { container } = renderSlotFill( ExperimentalOrderMeta, {
+			...defaultSlotProps,
+			className: 'custom-class',
+		} );
+
+		const wrapper = container.querySelector(
+			'.wc-block-components-totals-wrapper.slot-wrapper'
+		);
+		expect( wrapper ).toBeInTheDocument();
+
+		const slot = container.querySelector(
+			'.wc-block-components-order-meta'
+		);
+		expect( slot ).toBeInTheDocument();
+		expect( slot ).toHaveClass( 'custom-class' );
+	} );
+
+	it( 'passes extensions, cart, and context via fillProps', () => {
+		const extensions = { 'my-extension': { key: 'value' } };
+		const cart = { items: [], totals: {} };
+		const context = 'woocommerce/cart';
+
+		const fillProps = getFillProps( ExperimentalOrderMeta, {
+			extensions,
+			cart,
+			context,
+		} );
+
+		expect( fillProps ).toEqual(
+			expect.objectContaining( { extensions, cart, context } )
+		);
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/packages/checkout/components/order-shipping-packages/test/index.tsx b/plugins/woocommerce/client/blocks/packages/checkout/components/order-shipping-packages/test/index.tsx
new file mode 100644
index 00000000000..dee192f8420
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/packages/checkout/components/order-shipping-packages/test/index.tsx
@@ -0,0 +1,70 @@
+/**
+ * External dependencies
+ */
+import { screen } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import ExperimentalOrderShippingPackages from '..';
+import { renderSlotFill, getFillProps } from '../../../slot/test-utils';
+
+describe( 'ExperimentalOrderShippingPackages', () => {
+	const defaultSlotProps = {
+		extensions: {},
+		cart: {},
+		components: {},
+		context: 'woocommerce/checkout',
+		noResultsMessage: 'No shipping options.',
+		renderOption: jest.fn(),
+		collapsible: false,
+		showItems: false,
+	};
+
+	it( 'renders fill content inside the slot with expected classes', () => {
+		const { container } = renderSlotFill(
+			ExperimentalOrderShippingPackages,
+			{ ...defaultSlotProps, className: 'custom-shipping' }
+		);
+
+		expect( screen.getByTestId( 'fill-content' ) ).toBeInTheDocument();
+
+		const slot = container.querySelector(
+			'.wc-block-components-shipping-rates-control'
+		);
+		expect( slot ).toBeInTheDocument();
+		expect( slot ).toHaveClass( 'custom-shipping' );
+	} );
+
+	it( 'passes all expected fillProps', () => {
+		const extensions = { 'shipping-ext': { zones: [] } };
+		const cart = { shippingRates: [ { rate: '5.00' } ] };
+		const components = { ShippingRate: () => null };
+		const renderOption = jest.fn();
+
+		const fillProps = getFillProps( ExperimentalOrderShippingPackages, {
+			extensions,
+			cart,
+			components,
+			context: 'woocommerce/cart',
+			noResultsMessage: 'No options available',
+			renderOption,
+			collapsible: true,
+			showItems: true,
+		} );
+
+		expect( fillProps ).toEqual(
+			expect.objectContaining( {
+				extensions,
+				cart,
+				components,
+				context: 'woocommerce/cart',
+				noResultsMessage: 'No options available',
+				renderOption,
+				collapsible: true,
+				collapse: true,
+				showItems: true,
+			} )
+		);
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/packages/checkout/slot/test-utils.tsx b/plugins/woocommerce/client/blocks/packages/checkout/slot/test-utils.tsx
new file mode 100644
index 00000000000..bee29d66682
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/packages/checkout/slot/test-utils.tsx
@@ -0,0 +1,66 @@
+/**
+ * External dependencies
+ */
+import { render } from '@testing-library/react';
+import { Provider as SlotFillProvider } from 'wordpress-components-slotfill/build-module/slot-fill'; // eslint-disable-line @typescript-eslint/no-unused-vars -- Provider is used as JSX.
+
+type SlotFillComponent = {
+	( props: { children: React.ReactNode } ): JSX.Element;
+	Slot: ( props: Record< string, unknown > ) => JSX.Element;
+};
+
+/**
+ * Renders a SlotFill pair within a SlotFillProvider.
+ *
+ * Places a spy component inside the Fill that captures the fillProps
+ * passed via cloneElement. Returns both the render result and a
+ * jest.fn() that was called with the received fillProps.
+ *
+ * @param Fill      The Fill component (e.g. ExperimentalOrderMeta).
+ * @param slotProps Props passed to Fill.Slot (including fillProps).
+ * @return Object with render result and fillPropsSpy.
+ */
+export const renderSlotFill = (
+	Fill: SlotFillComponent,
+	slotProps: Record< string, unknown > = {}
+) => {
+	const fillPropsSpy = jest.fn();
+
+	// A component that captures all props it receives (merged via cloneElement)
+	// and calls the spy with them for assertion.
+	const FillPropsSpy = ( props: Record< string, unknown > ) => {
+		fillPropsSpy( props );
+		return <div data-testid="fill-content" />;
+	};
+
+	const result = render(
+		<SlotFillProvider>
+			<Fill>
+				<FillPropsSpy />
+			</Fill>
+			<Fill.Slot { ...slotProps } />
+		</SlotFillProvider>
+	);
+
+	return { ...result, fillPropsSpy };
+};
+
+/**
+ * Renders a SlotFill and returns the fillProps received by the fill child.
+ *
+ * Unlike JSON-based approaches, this preserves function references and
+ * other non-serializable values.
+ *
+ * @param Fill      The Fill component.
+ * @param slotProps Props to pass to Fill.Slot.
+ * @return The fillProps object.
+ */
+export const getFillProps = (
+	Fill: SlotFillComponent,
+	slotProps: Record< string, unknown > = {}
+): Record< string, unknown > => {
+	const { fillPropsSpy } = renderSlotFill( Fill, slotProps );
+
+	expect( fillPropsSpy ).toHaveBeenCalled();
+	return fillPropsSpy.mock.calls[ 0 ][ 0 ];
+};
diff --git a/plugins/woocommerce/client/blocks/tests/js/jest.config.json b/plugins/woocommerce/client/blocks/tests/js/jest.config.json
index e1d14755539..0207c4da582 100644
--- a/plugins/woocommerce/client/blocks/tests/js/jest.config.json
+++ b/plugins/woocommerce/client/blocks/tests/js/jest.config.json
@@ -14,6 +14,7 @@
 		"@wordpress/core-data": "<rootDir>/node_modules/@wordpress/core-data",
 		"@wordpress/components": "<rootDir>/node_modules/@wordpress/components",
 		"@woocommerce/data": "<rootDir>/node_modules/@woocommerce/data/build",
+		"@woocommerce/sanitize": "<rootDir>/node_modules/@woocommerce/sanitize/src/index.ts",
 		"@woocommerce/atomic-blocks": "assets/js/atomic/blocks",
 		"@woocommerce/atomic-utils": "assets/js/atomic/utils",
 		"@woocommerce/icons": "assets/js/icons",