Commit 3dd3249b0a0 for woocommerce

commit 3dd3249b0a0a7bf17c72d0d2452bb20ef9e4ec4e
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Mon Sep 14 17:36:52 2026 +0300

    [tests] Move 5 Add to Cart Form stepper E2E tests to Jest (#68655)

    * test(blocks): Move Add to Cart Form quantity logic below E2E

    The Add to Cart Form block's stepper had thirteen Playwright titles.
    Five of them asked what the quantity store does with a number: whether
    increase stops at max, whether decrease stops at min, whether a change
    event fires, and whether it stays silent at the boundary. Each paid a
    full editor round trip to insert a Single Product block, publish a
    post, and load the front end before touching a button.

    Those are pure functions over an input element. The browser proved
    nothing about them that a DOM fixture cannot, and it charged for a
    page load to do it.

    A new Jest suite registers the block's Interactivity API store through
    a virtual `@wordpress/interactivity` mock and drives `increaseQuantity`
    and `decreaseQuantity` against a constructed quantity control. Nine
    cases own the clamping at both bounds, the single bubbling change
    event, the silence when a click would cross a bound, decimal step
    rounding, the fallback defaults for absent and invalid numeric
    attributes, and the no-op when no input is present at all.

    Eight browser titles stay, including four stepper ones. The wiring is
    still proven in a real page by 'has the stepper mode working on the
    frontend', which clicks the rendered buttons and reads the rendered
    input.

    Consolidates the mega-branch slices:
    - df6d25dbeb: test(blocks): Move Add to Cart Form logic below E2E
    - 06eec2486c: test(blocks): split missing-input quantity observers

    Refs TESTOPS-234
    Refs #68046

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

    * test(blocks): Drop two assertions that cannot fail

    `toBeUndefined()` on the action's return value passes on every path,
    because neither `increaseQuantity` nor `decreaseQuantity` returns a
    value. `querySelector( 'input' )` being null restates the fixture, which
    only ever appends a button.

    The `changeEvents` length is the assertion doing the work, and it does:
    injecting an input into the wrapper when the selector finds none fails
    the increase case. The positive control lives in the same file, where
    increasing to max dispatches one bubbling change event.

    Refs #68655

    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-234-add-to-cart-form b/plugins/woocommerce/changelog/testops-234-add-to-cart-form
new file mode 100644
index 00000000000..46246c2b6f0
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-234-add-to-cart-form
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Reduce Add to Cart Form E2E tests from 13 to 8; Jest owns the quantity stepper's clamping, change-event dispatch, decimal rounding, and input defaults.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-form/test/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-form/test/frontend.ts
new file mode 100644
index 00000000000..d2e55ae1b48
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-form/test/frontend.ts
@@ -0,0 +1,214 @@
+type QuantityActions = {
+	increaseQuantity: ( event: { target: HTMLButtonElement } ) => void;
+	decreaseQuantity: ( event: { target: HTMLButtonElement } ) => void;
+};
+
+let mockRegisteredActions: QuantityActions | null = null;
+
+jest.mock(
+	'@wordpress/interactivity',
+	() => ( {
+		store: jest.fn( ( name: string, definition ) => {
+			if ( name === 'woocommerce/add-to-cart-form' ) {
+				mockRegisteredActions = definition.actions;
+			}
+
+			return definition;
+		} ),
+	} ),
+	{ virtual: true }
+);
+
+function loadActions(): QuantityActions {
+	mockRegisteredActions = null;
+	jest.isolateModules( () => jest.requireActual( '../frontend' ) );
+
+	if ( ! mockRegisteredActions ) {
+		throw new Error( 'Add to Cart Form store was not registered.' );
+	}
+
+	return mockRegisteredActions;
+}
+
+function createQuantityControl( {
+	value,
+	min,
+	max,
+	step,
+	inputType = 'number',
+}: {
+	value?: string;
+	min?: string;
+	max?: string;
+	step?: string;
+	inputType?: string;
+} = {} ) {
+	const wrapper = document.createElement( 'div' );
+	const decreaseButton = document.createElement( 'button' );
+	const input = document.createElement( 'input' );
+	const increaseButton = document.createElement( 'button' );
+	const changeEvents: Event[] = [];
+
+	input.type = inputType;
+	input.className = 'wc-block-components-quantity-selector__input';
+	if ( value !== undefined ) {
+		input.value = value;
+	}
+	if ( min !== undefined ) {
+		input.min = min;
+	}
+	if ( max !== undefined ) {
+		input.max = max;
+	}
+	if ( step !== undefined ) {
+		input.step = step;
+	}
+
+	wrapper.append( decreaseButton, input, increaseButton );
+	wrapper.addEventListener( 'change', ( event ) => {
+		changeEvents.push( event );
+	} );
+
+	return {
+		wrapper,
+		decreaseButton,
+		input,
+		increaseButton,
+		changeEvents,
+	};
+}
+
+describe( 'Add to Cart Form interactivity store', () => {
+	beforeEach( () => {
+		jest.resetModules();
+	} );
+
+	it.each( [
+		{ label: 'increase', action: 'increaseQuantity' },
+		{ label: 'decrease', action: 'decreaseQuantity' },
+	] as const )(
+		'ignores $label events without a quantity input',
+		( { action } ) => {
+			const actions = loadActions();
+			const button = document.createElement( 'button' );
+			const wrapper = document.createElement( 'div' );
+			const changeEvents: Event[] = [];
+
+			wrapper.append( button );
+			wrapper.addEventListener( 'change', ( event ) => {
+				changeEvents.push( event );
+			} );
+
+			actions[ action ]( { target: button } );
+
+			expect( changeEvents ).toHaveLength( 0 );
+		}
+	);
+
+	it.each( [
+		{
+			label: 'absent numeric values',
+			control: {},
+		},
+		{
+			label: 'invalid numeric values',
+			control: {
+				value: 'invalid',
+				min: 'invalid',
+				max: 'invalid',
+				step: 'invalid',
+				inputType: 'text',
+			},
+		},
+	] )( 'uses defaults for $label', ( { control } ) => {
+		const actions = loadActions();
+		const { increaseButton, input, changeEvents } =
+			createQuantityControl( control );
+
+		actions.increaseQuantity( { target: increaseButton } );
+
+		expect( input.value ).toBe( '1' );
+		expect( changeEvents ).toHaveLength( 1 );
+		expect( changeEvents[ 0 ].bubbles ).toBe( true );
+		expect( changeEvents[ 0 ].target ).toBe( input );
+	} );
+
+	it( 'rounds decimal steps to the input precision', () => {
+		const actions = loadActions();
+		const { increaseButton, input, changeEvents } = createQuantityControl( {
+			value: '0.1',
+			min: '0.1',
+			max: '0.3',
+			step: '0.1',
+		} );
+
+		actions.increaseQuantity( { target: increaseButton } );
+
+		expect( input.value ).toBe( '0.2' );
+		expect( changeEvents ).toHaveLength( 1 );
+	} );
+
+	it( 'increases exactly to max and dispatches one bubbling change event', () => {
+		const actions = loadActions();
+		const { increaseButton, input, changeEvents } = createQuantityControl( {
+			value: '8',
+			min: '2',
+			max: '10',
+			step: '2',
+		} );
+
+		actions.increaseQuantity( { target: increaseButton } );
+
+		expect( input.value ).toBe( '10' );
+		expect( changeEvents ).toHaveLength( 1 );
+		expect( changeEvents[ 0 ].bubbles ).toBe( true );
+		expect( changeEvents[ 0 ].target ).toBe( input );
+	} );
+
+	it( 'decreases exactly to min and dispatches one bubbling change event', () => {
+		const actions = loadActions();
+		const { decreaseButton, input, changeEvents } = createQuantityControl( {
+			value: '4',
+			min: '2',
+			max: '10',
+			step: '2',
+		} );
+
+		actions.decreaseQuantity( { target: decreaseButton } );
+
+		expect( input.value ).toBe( '2' );
+		expect( changeEvents ).toHaveLength( 1 );
+		expect( changeEvents[ 0 ].bubbles ).toBe( true );
+		expect( changeEvents[ 0 ].target ).toBe( input );
+	} );
+
+	it( 'rejects an increase above max without changing the input or dispatching an event', () => {
+		const actions = loadActions();
+		const { increaseButton, input, changeEvents } = createQuantityControl( {
+			value: '10',
+			min: '2',
+			max: '10',
+			step: '2',
+		} );
+
+		actions.increaseQuantity( { target: increaseButton } );
+
+		expect( input.value ).toBe( '10' );
+		expect( changeEvents ).toHaveLength( 0 );
+	} );
+
+	it( 'rejects a decrease below min without changing the input or dispatching an event', () => {
+		const actions = loadActions();
+		const { decreaseButton, input, changeEvents } = createQuantityControl( {
+			value: '2',
+			min: '2',
+			max: '10',
+			step: '2',
+		} );
+
+		actions.decreaseQuantity( { target: decreaseButton } );
+
+		expect( input.value ).toBe( '2' );
+		expect( changeEvents ).toHaveLength( 0 );
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/changelog/testops-234-add-to-cart-form b/plugins/woocommerce/client/blocks/changelog/testops-234-add-to-cart-form
new file mode 100644
index 00000000000..46246c2b6f0
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/changelog/testops-234-add-to-cart-form
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Reduce Add to Cart Form E2E tests from 13 to 8; Jest owns the quantity stepper's clamping, change-event dispatch, decimal rounding, and input defaults.
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/add-to-cart-form/add-to-cart-form.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/add-to-cart-form/add-to-cart-form.block_theme.spec.ts
index 9a585736473..31829986858 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/add-to-cart-form/add-to-cart-form.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/add-to-cart-form/add-to-cart-form.block_theme.spec.ts
@@ -22,12 +22,6 @@ const blockData = {
 	},
 };

-declare global {
-	interface Window {
-		eventFired: boolean;
-	}
-}
-
 class BlockUtils {
 	editor: Editor;
 	page: Page;
@@ -91,48 +85,6 @@ class BlockUtils {
 			'wc product create --name="Managed Stock" --regular_price=10 --manage_stock=true --stock_quantity=1 --user=admin'
 		);
 	}
-
-	/**
-	 * Sets the min, max, and step attributes for the input field.
-	 * This is useful for simulating extensions that set these attributes via woocommerce_quantity_input
-	 * https://github.com/woocommerce/woocommerce/blob/89945ca8fc4589c061ba2130bf72bf24dc9268bd/plugins/woocommerce/includes/wc-template-functions.php#L1877-L1878
-	 *
-	 */
-	async setMinMaxAndStep( {
-		min,
-		max,
-		step,
-	}: {
-		min: number;
-		max: number;
-		step: number;
-	} ) {
-		const input = this.page.locator( "input[type='number']" );
-		await input.evaluate(
-			( el: HTMLInputElement, data ) => {
-				el.setAttribute( 'min', data.min.toString() );
-				el.setAttribute( 'max', data.max.toString() );
-				el.setAttribute( 'step', data.step.toString() );
-				el.value = data.min.toString();
-			},
-			{ min, max, step }
-		);
-	}
-
-	/**
-	 * Adds an event listener to the quantity input field to check if the change event is fired.
-	 */
-	async addChangeEventListenerToQuantityInput() {
-		await this.page.evaluate( () => {
-			const inputEl = window.document.getElementsByClassName(
-				'wc-block-components-quantity-selector__input'
-			);
-
-			inputEl[ 0 ].addEventListener( 'change', () => {
-				window.eventFired = true;
-			} );
-		} );
-	}
 }

 const test = base.extend< { blockUtils: BlockUtils } >( {
@@ -340,168 +292,6 @@ test.describe( `${ blockData.name } Block`, () => {
 			await expect( minusButton ).toBeHidden();
 			await expect( plusButton ).toBeHidden();
 		} );
-
-		test( 'has the stepper mode working on the frontend with min, max, and step attributes', async ( {
-			admin,
-			editor,
-			blockUtils,
-			page,
-		} ) => {
-			await admin.createNewPost();
-			await editor.insertBlock( { name: 'woocommerce/single-product' } );
-
-			const productName = 'Hoodie with Logo';
-
-			await blockUtils.configureSingleProductBlock( productName );
-
-			await blockUtils.enableStepperMode();
-			await editor.publishAndVisitPost();
-
-			await blockUtils.setMinMaxAndStep( {
-				min: 2,
-				max: 10,
-				step: 2,
-			} );
-
-			const minusButton = page.getByLabel( `Reduce quantity` );
-			const plusButton = page.getByLabel( `Increase quantity` );
-
-			await expect( minusButton ).toBeVisible();
-			await expect( plusButton ).toBeVisible();
-
-			const input = page.getByLabel( 'Product quantity' );
-
-			await expect( input ).toHaveValue( '2' );
-			await minusButton.click();
-			await expect( input ).toHaveValue( '2' );
-			await plusButton.click();
-			await expect( input ).toHaveValue( '4' );
-			await plusButton.click();
-			await expect( input ).toHaveValue( '6' );
-			await plusButton.click();
-			await expect( input ).toHaveValue( '8' );
-			await plusButton.click();
-			await expect( input ).toHaveValue( '10' );
-			await plusButton.click();
-			await expect( input ).toHaveValue( '10' );
-		} );
-
-		test( 'should trigger input change event when plus stepper button is clicked', async ( {
-			admin,
-			editor,
-			blockUtils,
-			page,
-		} ) => {
-			await admin.createNewPost();
-			await editor.insertBlock( { name: 'woocommerce/single-product' } );
-
-			const productName = 'Hoodie with Logo';
-
-			await blockUtils.configureSingleProductBlock( productName );
-
-			await blockUtils.enableStepperMode();
-			await editor.publishAndVisitPost();
-
-			const plusButton = page.getByLabel( `Increase quantity` );
-
-			await blockUtils.addChangeEventListenerToQuantityInput();
-
-			await plusButton.click();
-
-			const eventFired = await page.evaluate( () => window.eventFired );
-
-			expect( eventFired ).toBe( true );
-		} );
-
-		test( 'should not trigger input change event when plus stepper button is clicked and the value exceeds the maximum limit', async ( {
-			admin,
-			editor,
-			blockUtils,
-			page,
-		} ) => {
-			await admin.createNewPost();
-			await editor.insertBlock( { name: 'woocommerce/single-product' } );
-
-			const productName = 'Hoodie with Logo';
-
-			await blockUtils.configureSingleProductBlock( productName );
-
-			await blockUtils.enableStepperMode();
-			await editor.publishAndVisitPost();
-			await blockUtils.setMinMaxAndStep( {
-				min: 1,
-				max: 4,
-				step: 1,
-			} );
-
-			const plusButton = page.getByLabel( `Increase quantity` );
-
-			for ( let i = 0; i < 5; i++ ) {
-				await plusButton.click();
-			}
-
-			await blockUtils.addChangeEventListenerToQuantityInput();
-
-			await plusButton.click();
-
-			const eventFired = await page.evaluate( () => window.eventFired );
-
-			expect( eventFired ).toBeUndefined();
-		} );
-		test( 'should trigger input change event when minus stepper button is clicked', async ( {
-			admin,
-			editor,
-			blockUtils,
-			page,
-		} ) => {
-			await admin.createNewPost();
-			await editor.insertBlock( { name: 'woocommerce/single-product' } );
-
-			const productName = 'Hoodie with Logo';
-
-			await blockUtils.configureSingleProductBlock( productName );
-
-			await blockUtils.enableStepperMode();
-			await editor.publishAndVisitPost();
-
-			const plusButton = page.getByLabel( `Increase quantity` );
-			await plusButton.click();
-			const minusButton = page.getByLabel( `Reduce quantity` );
-
-			await blockUtils.addChangeEventListenerToQuantityInput();
-
-			await minusButton.click();
-
-			const eventFired = await page.evaluate( () => window.eventFired );
-
-			expect( eventFired ).toBe( true );
-		} );
-		test( 'should not trigger input change event when minus stepper button is clicked and the value goes below the minimum limit', async ( {
-			admin,
-			editor,
-			blockUtils,
-			page,
-		} ) => {
-			await admin.createNewPost();
-			await editor.insertBlock( { name: 'woocommerce/single-product' } );
-
-			const productName = 'Hoodie with Logo';
-
-			await blockUtils.configureSingleProductBlock( productName );
-
-			await blockUtils.enableStepperMode();
-			await editor.publishAndVisitPost();
-
-			const minusButton = page.getByLabel( `Reduce quantity` );
-
-			await blockUtils.addChangeEventListenerToQuantityInput();
-
-			await minusButton.click();
-
-			const eventFired = await page.evaluate( () => window.eventFired );
-
-			expect( eventFired ).toBeUndefined();
-		} );
 	} );

 	test( 'can be migrated to the blockified Add to Cart + Options block', async ( {