Commit a396db09bdd for woocommerce
commit a396db09bddcb8fdaa26c870bb1c9310a931776e
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Tue Sep 15 22:45:49 2026 +0300
[tests] Reduce legacy Price, Rating and Stock filter E2E tests from 26 to 8 (#68614)
* test(blocks): Move legacy filter block checks below E2E
The Price, Rating, and Stock filter specs ran 26 browser titles.
Nine of them opened a fresh post each to check what the editor
controls do: the wrapper heading, the display style, and the Apply
button toggle. Those are decided inside the blocks' own Edit
components. Which products survive a rating or stock filter is
decided by the merged Product Collection query, in PHP.
Add a Jest test for the editor mappings, with six cases over the
three wrapper templates and the Price, Rating, and Stock Edit
components, and two PHPUnit tests that build the merged query and
assert the exact products a rating and a stock filter return.
Keep eight browser titles for what only a browser proves: the Price
editor sequence, filtering the All Products block, the classic
product template, and Product Collection, and the deferred behavior
where nothing changes until Apply is clicked.
Consolidates the mega-branch slices:
- Slice 026: test(blocks): Move Rating and Stock filters below E2E
- Slice 055: test(blocks): Move Price Filter behavior below E2E
Refs TESTOPS-234
Refs #68046
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(blocks): Stop the query builder tests restoring base-class state
Both filter tests captured the query var they were about to set and put
it back in a finally block, then deleted their reviews, cleared the
review transients and deleted their products.
set_query_var() writes to $GLOBALS['wp_query']->query_vars, and
tear_down() replaces $wp_query with a fresh WP_Query before the next
test runs, so the restore never mattered. The reviews, transients and
products are all rows inside the transaction.
$product_ids_with_reviews only ever fed the delete loop, so the two
lines that appended to it go with it.
Refs TESTOPS-234
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(e2e): Require more than one product in the filter baselines
Each of the three filter specs reads the unfiltered /shop listing, then
asserts the list is not empty. That assertion cannot fail: it always
follows expect( productTitles.first() ).toBeVisible(), which has already
established there is at least one product.
It also leaves the real hazard uncovered. If /shop arrives already
filtered -- a leftover attribute or price filter in the URL, or a store
left narrowed by other work -- the baseline is the same single product
the filter is about to select, the post-filter assertion passes, and the
spec reports that filtering works while nothing was filtered. The deleted
"should show all products" titles were what pinned the real counts.
Asserting more than one product closes that without hard-coding a total
that fixture changes would invalidate.
Refs TESTOPS-234
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(e2e): Restore the use fixture parameter name in the price spec
The move below E2E renamed the templateCompiler fixture's use parameter
to provideTemplateCompiler in the price filter spec alone. Nothing needed
the rename, and every other Playwright fixture in the suite calls it
use.
Refs TESTOPS-234
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(e2e): Share the macrotask flush across the legacy filter specs
The price, rating and stock filter specs each pasted the same
MessageChannel round trip after page.clock.runFor(), four copies in
all. Move it to flushMacrotask() in the Blocks frontend utils, exported
from @woocommerce/e2e-utils, and call that instead.
Refs TESTOPS-234
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(blocks): Match the split currency symbol in the price filter test
The trunk merge brought in #67784, which renders the currency symbol
from FormattedMonetaryAmount in its own span inside a bdi, so it
respects the currency position in RTL. The "$" and the amount are now
separate text nodes, and the default getByText matcher only reads an
element's own text, so getByText( '$1' ) found nothing and the legacy
filter editor test failed in CI.
Use textContentMatcher, the helper #67784 moved its own price
assertions to, so the Price display check matches the text across the
symbol span again.
Refs TESTOPS-234
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-legacy-filter-blocks b/plugins/woocommerce/changelog/testops-234-legacy-filter-blocks
new file mode 100644
index 00000000000..fc12baff994
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-234-legacy-filter-blocks
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Reduce the legacy Price, Rating, and Stock filter E2E tests from 26 titles to 8; Jest owns the editor control mappings and PHPUnit owns the rating and stock filter query identity.
+
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/filter-wrapper/test/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/filter-wrapper/test/edit.tsx
new file mode 100644
index 00000000000..f5530c1216b
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/filter-wrapper/test/edit.tsx
@@ -0,0 +1,405 @@
+/**
+ * External dependencies
+ */
+import React from '@wordpress/element';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { InnerBlocks } from '@wordpress/block-editor';
+
+/**
+ * Internal dependencies
+ */
+import FilterWrapperEdit from '../edit';
+import PriceFilterEdit from '../../price-filter/edit';
+import RatingFilterEdit from '../../rating-filter/edit';
+import StockFilterEdit from '../../stock-filter/edit';
+import { textContentMatcher } from '../../../../../tests/utils/find-by-text';
+
+jest.mock( '@wordpress/block-editor', () => ( {
+ ...jest.requireActual( '@wordpress/block-editor' ),
+ useBlockProps: jest.fn( ( props = {} ) => props ),
+ InspectorControls: jest.fn( ( { children } ) => <div>{ children }</div> ),
+ InnerBlocks: jest.fn( ( { template } ) => (
+ <section>
+ <h3>{ template[ 0 ][ 1 ].content }</h3>
+ <div
+ data-testid="locked-filter-child"
+ data-block-name={ template[ 1 ][ 0 ] }
+ data-lock-remove={ String( template[ 1 ][ 1 ].lock.remove ) }
+ />
+ </section>
+ ) ),
+} ) );
+
+jest.mock( '@wordpress/components', () => {
+ const element = jest.requireActual( '@wordpress/element' );
+
+ return {
+ ...jest.requireActual( '@wordpress/components' ),
+ Disabled: jest.fn( ( { children } ) => <div>{ children }</div> ),
+ Notice: jest.fn( ( { children } ) => <div>{ children }</div> ),
+ PanelBody: jest.fn( ( { children } ) => <div>{ children }</div> ),
+ ToggleControl: jest.fn( ( { label, checked, onChange } ) => (
+ <span>
+ <input
+ type="checkbox"
+ aria-label={ label }
+ checked={ checked }
+ onChange={ ( event ) => onChange( event.target.checked ) }
+ />
+ { label }
+ </span>
+ ) ),
+ withSpokenMessages: jest.fn( ( Component ) => Component ),
+ __experimentalToggleGroupControl: jest.fn(
+ ( { children, label, onChange, value } ) => (
+ <fieldset>
+ <legend>{ label }</legend>
+ { element.Children.map( children, ( child ) =>
+ element.cloneElement( child, {
+ onSelect: onChange,
+ selectedValue: value,
+ } )
+ ) }
+ </fieldset>
+ )
+ ),
+ __experimentalToggleGroupControlOption: jest.fn(
+ ( { label, onSelect, selectedValue, value } ) => (
+ <span>
+ <input
+ type="radio"
+ aria-label={ label }
+ checked={ selectedValue === value }
+ onChange={ () => onSelect( value ) }
+ />
+ { label }
+ </span>
+ )
+ ),
+ __experimentalToolsPanel: jest.fn( ( { children } ) => (
+ <div>{ children }</div>
+ ) ),
+ __experimentalToolsPanelItem: jest.fn( ( { children } ) => (
+ <div>{ children }</div>
+ ) ),
+ };
+} );
+
+jest.mock( '@wordpress/data', () => ( {
+ ...jest.requireActual( '@wordpress/data' ),
+ useDispatch: jest.fn( () => ( {
+ removeBlock: jest.fn(),
+ replaceBlock: jest.fn(),
+ selectBlock: jest.fn(),
+ updateBlockAttributes: jest.fn(),
+ } ) ),
+} ) );
+
+jest.mock( '@woocommerce/editor-components/upgrade-downgrade-notice', () => ( {
+ UpgradeDowngradeNotice: jest.fn( ( { children } ) => (
+ <div>{ children }</div>
+ ) ),
+} ) );
+
+jest.mock( '@woocommerce/block-settings', () => ( {
+ ...jest.requireActual( '@woocommerce/block-settings' ),
+ blocksConfig: { productCount: 1 },
+} ) );
+
+jest.mock( '@woocommerce/base-context/hooks', () => {
+ const queryState = {};
+ const queryValues: string[] = [];
+ const setQueryState = jest.fn();
+ const collectionData = {
+ rating_counts: [
+ { rating: 1, count: 1 },
+ { rating: 5, count: 2 },
+ ],
+ stock_status_counts: [
+ { status: 'instock', count: 2 },
+ { status: 'outofstock', count: 1 },
+ ],
+ price_range: {
+ min_price: '100',
+ max_price: '5000',
+ currency_code: 'USD',
+ currency_symbol: '$',
+ currency_thousand_separator: ',',
+ currency_decimal_separator: '.',
+ currency_minor_unit: 2,
+ currency_prefix: '$',
+ currency_suffix: '',
+ },
+ };
+
+ return {
+ ...jest.requireActual( '@woocommerce/base-context/hooks' ),
+ useCollectionData: jest.fn( () => ( {
+ data: collectionData,
+ isLoading: false,
+ } ) ),
+ useQueryStateByContext: jest.fn( () => [ queryState ] ),
+ useQueryStateByKey: jest.fn( () => [ queryValues, setQueryState ] ),
+ };
+} );
+
+jest.mock( '@woocommerce/settings', () => {
+ const stockStatusOptions = {
+ instock: 'In stock',
+ outofstock: 'Out of stock',
+ };
+
+ return {
+ ...jest.requireActual( '@woocommerce/settings' ),
+ getSetting: jest.fn( ( key, defaultValue ) => {
+ if ( key === 'stockStatusOptions' ) {
+ return stockStatusOptions;
+ }
+ return defaultValue;
+ } ),
+ getSettingWithCoercion: jest.fn( ( key, defaultValue ) =>
+ key === 'hasFilterableProducts' ? true : defaultValue
+ ),
+ };
+} );
+
+jest.mock( '@wordpress/a11y', () => ( {
+ ...jest.requireActual( '@wordpress/a11y' ),
+ speak: jest.fn(),
+} ) );
+
+afterEach( () => {
+ jest.clearAllMocks();
+ jest.restoreAllMocks();
+} );
+
+describe( 'legacy filter editor ownership', () => {
+ it.each( [
+ {
+ filterType: 'price-filter',
+ heading: 'Filter by price',
+ },
+ {
+ filterType: 'rating-filter',
+ heading: 'Filter by rating',
+ },
+ {
+ filterType: 'stock-filter',
+ heading: 'Filter by stock status',
+ },
+ ] )( 'seeds the $filterType wrapper template', ( row ) => {
+ const WrapperEdit = FilterWrapperEdit as unknown as React.ComponentType<
+ Record< string, unknown >
+ >;
+
+ render(
+ <WrapperEdit
+ attributes={ {
+ filterType: row.filterType,
+ heading: row.heading,
+ } }
+ clientId="wrapper-client-id"
+ />
+ );
+
+ expect(
+ screen.getByRole( 'heading', { level: 3, name: row.heading } )
+ ).toBeInTheDocument();
+ expect( screen.getByTestId( 'locked-filter-child' ) ).toHaveAttribute(
+ 'data-block-name',
+ `woocommerce/${ row.filterType }`
+ );
+ expect( screen.getByTestId( 'locked-filter-child' ) ).toHaveAttribute(
+ 'data-lock-remove',
+ 'true'
+ );
+
+ const innerBlocksProps = ( InnerBlocks as unknown as jest.Mock ).mock
+ .calls[ 0 ][ 0 ];
+ expect( innerBlocksProps.allowedBlocks ).toEqual( [ 'core/heading' ] );
+ expect( innerBlocksProps.template ).toEqual( [
+ [ 'core/heading', { content: row.heading, level: 3 } ],
+ [
+ `woocommerce/${ row.filterType }`,
+ { heading: '', lock: { remove: true } },
+ ],
+ ] );
+ } );
+
+ it( 'maps Price display and Apply controls to preview behavior', async () => {
+ const user = userEvent.setup();
+ const setAttributes = jest.fn();
+ const attributes = {
+ heading: '',
+ headingLevel: 3,
+ showInputFields: true,
+ inlineInput: false,
+ showFilterButton: false,
+ };
+ const PriceEdit = PriceFilterEdit as unknown as React.ComponentType<
+ Record< string, unknown >
+ >;
+ const renderEdit = ( editAttributes: Record< string, unknown > ) => (
+ <PriceEdit
+ attributes={ editAttributes }
+ clientId="filter-client-id"
+ setAttributes={ setAttributes }
+ />
+ );
+
+ const { rerender } = render( renderEdit( attributes ) );
+
+ expect(
+ await screen.findByRole( 'textbox', {
+ name: 'Filter products by minimum price',
+ } )
+ ).toBeVisible();
+ expect(
+ screen.getByRole( 'textbox', {
+ name: 'Filter products by maximum price',
+ } )
+ ).toBeVisible();
+ expect(
+ screen.queryByRole( 'button', { name: 'Apply price filter' } )
+ ).not.toBeInTheDocument();
+
+ await user.click( screen.getByRole( 'radio', { name: 'Text' } ) );
+ expect( setAttributes ).toHaveBeenCalledTimes( 1 );
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ showInputFields: false,
+ } );
+
+ rerender( renderEdit( { ...attributes, showInputFields: false } ) );
+ expect(
+ screen.queryByRole( 'textbox', {
+ name: 'Filter products by minimum price',
+ } )
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole( 'textbox', {
+ name: 'Filter products by maximum price',
+ } )
+ ).not.toBeInTheDocument();
+ expect( screen.getByText( textContentMatcher( '$1' ) ) ).toBeVisible();
+ expect( screen.getByText( textContentMatcher( '$50' ) ) ).toBeVisible();
+
+ setAttributes.mockClear();
+ await user.click(
+ screen.getByRole( 'checkbox', {
+ name: "Show 'Apply filters' button",
+ } )
+ );
+ expect( setAttributes ).toHaveBeenCalledTimes( 1 );
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ showFilterButton: true,
+ } );
+
+ rerender(
+ renderEdit( {
+ ...attributes,
+ showInputFields: false,
+ showFilterButton: true,
+ } )
+ );
+ expect(
+ await screen.findByRole( 'button', { name: 'Apply price filter' } )
+ ).toBeVisible();
+ } );
+
+ it.each( [
+ {
+ Edit: RatingFilterEdit,
+ attributes: {
+ displayStyle: 'list',
+ isPreview: false,
+ selectType: 'multiple',
+ showCounts: false,
+ showFilterButton: false,
+ },
+ listOption: 'Rated 1 out of 5',
+ },
+ {
+ Edit: StockFilterEdit,
+ attributes: {
+ displayStyle: 'list',
+ heading: '',
+ headingLevel: 3,
+ isPreview: false,
+ selectType: 'multiple',
+ showCounts: false,
+ showFilterButton: false,
+ },
+ listOption: 'In stock',
+ },
+ ] )(
+ 'maps $listOption display and Apply controls to preview behavior',
+ async ( row ) => {
+ const user = userEvent.setup();
+ const setAttributes = jest.fn();
+ const Edit = row.Edit as unknown as React.ComponentType<
+ Record< string, unknown >
+ >;
+ const renderEdit = ( attributes: Record< string, unknown > ) => (
+ <Edit
+ attributes={ attributes }
+ clientId="filter-client-id"
+ setAttributes={ setAttributes }
+ />
+ );
+
+ const { rerender } = render( renderEdit( row.attributes ) );
+
+ expect(
+ await screen.findByRole( 'checkbox', {
+ name: row.listOption,
+ } )
+ ).toBeVisible();
+ expect(
+ screen.queryByRole( 'button', {
+ name: /^Apply (rating|stock) filter$/,
+ } )
+ ).not.toBeInTheDocument();
+
+ await user.click(
+ screen.getByRole( 'radio', { name: 'Dropdown' } )
+ );
+ expect( setAttributes ).toHaveBeenCalledTimes( 1 );
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ displayStyle: 'dropdown',
+ } );
+
+ rerender(
+ renderEdit( { ...row.attributes, displayStyle: 'dropdown' } )
+ );
+ expect(
+ screen.queryByRole( 'checkbox', { name: row.listOption } )
+ ).not.toBeInTheDocument();
+ expect( await screen.findByRole( 'combobox' ) ).toBeVisible();
+
+ setAttributes.mockClear();
+ await user.click(
+ screen.getByRole( 'checkbox', {
+ name: "Show 'Apply filters' button",
+ } )
+ );
+ expect( setAttributes ).toHaveBeenCalledTimes( 1 );
+ expect( setAttributes ).toHaveBeenCalledWith( {
+ showFilterButton: true,
+ } );
+
+ rerender(
+ renderEdit( {
+ ...row.attributes,
+ displayStyle: 'dropdown',
+ showFilterButton: true,
+ } )
+ );
+ expect(
+ await screen.findByRole( 'button', {
+ name: /^Apply (rating|stock) filter$/,
+ } )
+ ).toBeVisible();
+ }
+ );
+} );
diff --git a/plugins/woocommerce/client/blocks/changelog/testops-234-legacy-filter-blocks b/plugins/woocommerce/client/blocks/changelog/testops-234-legacy-filter-blocks
new file mode 100644
index 00000000000..fc12baff994
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/changelog/testops-234-legacy-filter-blocks
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Reduce the legacy Price, Rating, and Stock filter E2E tests from 26 titles to 8; Jest owns the editor control mappings and PHPUnit owns the rating and stock filter query identity.
+
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/price-filter/price-filter.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/price-filter/price-filter.block_theme.spec.ts
index 0420c13921b..a321d7c4b4f 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/price-filter/price-filter.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/price-filter/price-filter.block_theme.spec.ts
@@ -8,6 +8,7 @@ import {
BASE_URL,
wpCLI,
BLOCK_THEME_SLUG,
+ flushMacrotask,
} from '@woocommerce/e2e-utils';
const blockData = {
@@ -83,25 +84,21 @@ test.describe( `${ blockData.name } Block - editor side`, () => {
await editor.openDocumentSettingsSidebar();
} );
- test( "should allow changing the block's title", async ( { editor } ) => {
+ test( 'edits the title, display style, and Apply behavior', async ( {
+ page,
+ editor,
+ } ) => {
const textSelector =
'.wp-block-woocommerce-filter-wrapper .wp-block-heading';
-
const title = 'New Title';
await editor.canvas.locator( textSelector ).fill( title );
-
await expect( editor.canvas.locator( textSelector ) ).toHaveText(
title
);
- } );
- test( 'should allow changing the display style', async ( {
- page,
- editor,
- } ) => {
const priceFilterControls = await editor.getBlockByName(
- 'woocommerce/price-filter'
+ blockData.slug
);
await editor.selectBlocks( priceFilterControls );
@@ -133,16 +130,6 @@ test.describe( `${ blockData.name } Block - editor side`, () => {
name: 'Filter products by maximum',
} )
).toBeHidden();
- } );
-
- test( 'should allow toggling the visibility of the filter button', async ( {
- page,
- editor,
- } ) => {
- const priceFilterControls = await editor.getBlockByName(
- blockData.slug
- );
- await editor.selectBlocks( priceFilterControls );
await expect(
priceFilterControls.getByRole( 'button', {
@@ -201,27 +188,24 @@ test.describe( `${ blockData.name } Block - with All products Block`, () => {
} );
} );
- test( 'should show all products', async ( { frontendUtils } ) => {
+ test( 'filters the All Products block by maximum price', async ( {
+ page,
+ frontendUtils,
+ } ) => {
const allProductsBlock = await frontendUtils.getBlockByName(
'woocommerce/all-products'
);
-
- const img = allProductsBlock.locator( 'img' ).first();
-
- await expect( img ).not.toHaveAttribute(
- 'src',
- blockData.placeholderUrl
+ const productTitles = allProductsBlock.locator(
+ '.wc-block-grid__product-title'
);
- const products = allProductsBlock.getByRole( 'listitem' );
+ await expect( productTitles.first() ).toBeVisible();
+ // See the note on the other baselines: more than one product, so the test
+ // cannot pass against a shop page that was already filtered.
+ expect(
+ ( await productTitles.allTextContents() ).length
+ ).toBeGreaterThan( 1 );
- await expect( products ).toHaveCount( 9 );
- } );
-
- test( 'should show only products that match the filter', async ( {
- page,
- frontendUtils,
- } ) => {
// The price filter input is initially enabled, but it becomes disabled
// for the time it takes to fetch the data. To avoid setting the filter
// value before the input is properly initialized, we wait for the input
@@ -247,24 +231,19 @@ test.describe( `${ blockData.name } Block - with All products Block`, () => {
await maxPriceInput.fill( '$5' );
await maxPriceInput.press( 'Tab' );
- const allProductsBlock = await frontendUtils.getBlockByName(
- 'woocommerce/all-products'
- );
-
const img = allProductsBlock.locator( 'img' ).first();
await expect( img ).not.toHaveAttribute(
'src',
blockData.placeholderUrl
);
- const allProducts = allProductsBlock.getByRole( 'listitem' );
-
- await expect( allProducts ).toHaveCount( 1 );
- expect( page.url() ).toContain(
- blockData.urlSearchParamWhenFilterIsApplied
+ await expect( page ).toHaveURL(
+ new RegExp( blockData.urlSearchParamWhenFilterIsApplied )
);
+ await expect( productTitles ).toHaveText( [ 'Single' ] );
} );
} );
+
test.describe( `${ blockData.name } Block - with PHP classic template`, () => {
test.beforeEach( async ( { admin, page, editor } ) => {
await wpCLI(
@@ -290,22 +269,24 @@ test.describe( `${ blockData.name } Block - with PHP classic template`, () => {
await page.goto( '/shop' );
} );
- test( 'should show all products', async ( { frontendUtils } ) => {
+ test( 'filters the classic product template by maximum price', async ( {
+ page,
+ frontendUtils,
+ } ) => {
const legacyTemplate = await frontendUtils.getBlockByName(
'woocommerce/legacy-template'
);
+ const productTitles = legacyTemplate.locator(
+ '.woocommerce-loop-product__title'
+ );
- const products = legacyTemplate
- .getByRole( 'list' )
- .locator( '.product' );
-
- await expect( products ).toHaveCount( 16 );
- } );
+ await expect( productTitles.first() ).toBeVisible();
+ // See the note on the other baselines: more than one product, so the test
+ // cannot pass against a shop page that was already filtered.
+ expect(
+ ( await productTitles.allTextContents() ).length
+ ).toBeGreaterThan( 1 );
- test( 'should show only products that match the filter', async ( {
- page,
- frontendUtils,
- } ) => {
const maxPriceInput = page.getByRole( 'textbox', {
name: 'Filter products by maximum price',
} );
@@ -317,38 +298,35 @@ test.describe( `${ blockData.name } Block - with PHP classic template`, () => {
new RegExp( blockData.urlSearchParamWhenFilterIsApplied )
);
- const legacyTemplate = await frontendUtils.getBlockByName(
- 'woocommerce/legacy-template'
- );
-
- const products = legacyTemplate
- .getByRole( 'list' )
- .locator( '.product' );
-
- await expect( products ).toHaveCount( 1 );
+ await expect( productTitles ).toHaveText( [ 'Single' ] );
} );
} );
test.describe( `${ blockData.name } Block - with Product Collection`, () => {
- test( 'should show all products', async ( { page, templateCompiler } ) => {
- await templateCompiler.compile();
-
- await page.goto( '/shop' );
- const products = page
- .locator( '.wp-block-woocommerce-product-template' )
- .getByRole( 'listitem' );
-
- await expect( products ).toHaveCount( 16 );
- } );
-
- test( 'should show only products that match the filter', async ( {
+ test( 'filters Product Collection automatically and defers changes until Apply when configured', async ( {
page,
+ admin,
+ editor,
frontendUtils,
templateCompiler,
} ) => {
- await templateCompiler.compile();
+ await page.clock.install();
+ const template = await templateCompiler.compile();
+ const productTitles = page.locator(
+ '.wp-block-woocommerce-product-template .wp-block-post-title'
+ );
await page.goto( '/shop' );
+ await expect( productTitles.first() ).toBeVisible();
+ const automaticBaseline = ( await productTitles.allTextContents() ).map(
+ ( title ) => title.trim()
+ );
+ // Greater than one, not merely non-empty: an unfiltered /shop must show more
+ // than the single product the filter is about to leave behind. Asserting only
+ // that it is non-empty passes on a shop page that arrived already filtered,
+ // and then the post-filter assertion passes too, for the wrong reason.
+ expect( automaticBaseline.length ).toBeGreaterThan( 1 );
+
const maxPriceInput = page.getByRole( 'textbox', {
name: 'Filter products by maximum price',
} );
@@ -359,21 +337,7 @@ test.describe( `${ blockData.name } Block - with Product Collection`, () => {
await expect( page ).toHaveURL(
new RegExp( blockData.urlSearchParamWhenFilterIsApplied )
);
-
- const products = page
- .locator( '.wp-block-woocommerce-product-template' )
- .getByRole( 'listitem' );
-
- await expect( products ).toHaveCount( 1 );
- } );
-
- test( 'should refresh the page only if the user clicks on button', async ( {
- page,
- admin,
- editor,
- templateCompiler,
- } ) => {
- const template = await templateCompiler.compile();
+ await expect( productTitles ).toHaveText( [ 'Single' ] );
await admin.visitSiteEditor( {
postId: template.id,
@@ -393,31 +357,50 @@ test.describe( `${ blockData.name } Block - with Product Collection`, () => {
isOnlyCurrentEntityDirty: true,
} );
await page.goto( '/shop' );
-
- const maxPriceInput = page.getByRole( 'textbox', {
+ await expect( productTitles.first() ).toBeVisible();
+ const deferredMaxPriceInput = page.getByRole( 'textbox', {
name: 'Filter products by maximum price',
} );
-
- await maxPriceInput.dblclick();
- await maxPriceInput.fill( '$5' );
-
+ await expect( deferredMaxPriceInput ).toBeVisible();
+ await page.clock.pauseAt(
+ ( await page.evaluate( () => Date.now() ) ) + 1_000
+ );
+ const deferredBaseline = ( await productTitles.allTextContents() ).map(
+ ( title ) => title.trim()
+ );
+ // Greater than one, not merely non-empty: an unfiltered /shop must show more
+ // than the single product the filter is about to leave behind. Asserting only
+ // that it is non-empty passes on a shop page that arrived already filtered,
+ // and then the post-filter assertion passes too, for the wrong reason.
+ expect( deferredBaseline.length ).toBeGreaterThan( 1 );
+ const deferredUrl = page.url();
+
+ await deferredMaxPriceInput.dblclick();
+ await deferredMaxPriceInput.fill( '$5' );
+ await page.clock.runFor( 1_001 );
+ await flushMacrotask( page );
+ await expect( deferredMaxPriceInput ).toHaveValue( '$5' );
const resetPriceFilterButton = page.getByRole( 'button', {
name: 'Reset price filter',
} );
await expect( resetPriceFilterButton ).toBeVisible();
+ const applyButton = page.getByRole( 'button', {
+ name: 'Apply price filter',
+ } );
+ await expect( applyButton ).toBeVisible();
+ await expect( applyButton ).toBeEnabled();
- await page
- .getByRole( 'button', { name: 'Apply price filter' } )
- .click();
+ await page.clock.runFor( 501 );
+ await flushMacrotask( page );
+ await expect( page ).toHaveURL( deferredUrl );
+ await expect( productTitles ).toHaveText( deferredBaseline );
+
+ await page.clock.resume();
+ await applyButton.click();
await expect( page ).toHaveURL(
new RegExp( blockData.urlSearchParamWhenFilterIsApplied )
);
-
- const products = page
- .locator( '.wp-block-woocommerce-product-template' )
- .getByRole( 'listitem' );
-
- await expect( products ).toHaveCount( 1 );
+ await expect( productTitles ).toHaveText( [ 'Single' ] );
} );
} );
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/rating-filter/rating-filter.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/rating-filter/rating-filter.block_theme.spec.ts
index 974bf381921..6bb473062f6 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/rating-filter/rating-filter.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/rating-filter/rating-filter.block_theme.spec.ts
@@ -7,6 +7,7 @@ import {
wpCLI,
TemplateCompiler,
BLOCK_THEME_SLUG,
+ flushMacrotask,
} from '@woocommerce/e2e-utils';
// The Cap is the only seeded product in this bucket: its reviews are rated 1
@@ -26,83 +27,6 @@ const test = base.extend< { templateCompiler: TemplateCompiler } >( {
},
} );
-test.describe( `${ blockData.name } Block`, () => {
- test.beforeEach( async ( { admin, editor } ) => {
- await admin.createNewPost();
- await editor.insertBlock( {
- name: 'woocommerce/filter-wrapper',
- attributes: {
- filterType: 'rating-filter',
- heading: 'Filter By Rating',
- },
- } );
- await editor.openDocumentSettingsSidebar();
- } );
-
- test( "should allow changing the block's title", async ( { editor } ) => {
- const textSelector =
- '.wp-block-woocommerce-filter-wrapper .wp-block-heading';
-
- const title = 'New Title';
-
- await editor.canvas.locator( textSelector ).fill( title );
-
- await expect( editor.canvas.locator( textSelector ) ).toHaveText(
- title
- );
- } );
-
- test( 'should allow changing the display style', async ( {
- page,
- editor,
- } ) => {
- const stockFilter = await editor.getBlockByName( blockData.slug );
- await editor.selectBlocks( stockFilter );
-
- await expect(
- editor.canvas.getByRole( 'checkbox', { name: 'Rated 2 out of 5' } )
- ).toBeVisible();
-
- await page.getByLabel( 'DropDown' ).click();
-
- await expect(
- stockFilter.getByRole( 'checkbox', {
- name: 'In Stock',
- } )
- ).toBeHidden();
-
- await expect(
- editor.canvas.getByRole( 'checkbox', { name: 'Rated 2 out of 5' } )
- ).toBeHidden();
-
- await expect( editor.canvas.getByRole( 'combobox' ) ).toBeVisible();
- } );
-
- test( 'should allow toggling the visibility of the filter button', async ( {
- page,
- editor,
- } ) => {
- const priceFilterControls = await editor.getBlockByName(
- blockData.slug
- );
- await editor.selectBlocks( priceFilterControls );
-
- await expect(
- priceFilterControls.getByRole( 'button', {
- name: 'Apply',
- } )
- ).toBeHidden();
-
- await page.getByText( "Show 'Apply filters' button" ).click();
-
- await expect(
- priceFilterControls.getByRole( 'button', {
- name: 'Apply',
- } )
- ).toBeVisible();
- } );
-} );
-
test.describe( `${ blockData.name } Block - with PHP classic template`, () => {
test.beforeEach( async ( { admin, page, editor } ) => {
await wpCLI(
@@ -131,23 +55,7 @@ test.describe( `${ blockData.name } Block - with PHP classic template`, () => {
await page.goto( '/shop' );
} );
- test( 'should show all products', async ( { frontendUtils, page } ) => {
- const legacyTemplate = await frontendUtils.getBlockByName(
- 'woocommerce/legacy-template'
- );
-
- const products = legacyTemplate
- .getByRole( 'list' )
- .locator( '.product' );
-
- await expect( products ).toHaveCount( 16 );
-
- await expect(
- page.getByRole( 'checkbox', { name: 'Rated 2 out of 5' } )
- ).toBeVisible();
- } );
-
- test( 'should show only products that match the filter', async ( {
+ test( 'filters the classic product template by rating', async ( {
frontendUtils,
page,
} ) => {
@@ -155,41 +63,48 @@ test.describe( `${ blockData.name } Block - with PHP classic template`, () => {
.getByRole( 'checkbox', { name: 'Rated 2 out of 5' } )
.click();
+ await expect( page ).toHaveURL(
+ new RegExp( blockData.urlSearchParamWhenFilterIsApplied )
+ );
+
const legacyTemplate = await frontendUtils.getBlockByName(
'woocommerce/legacy-template'
);
-
const products = legacyTemplate
.getByRole( 'list' )
.locator( '.product' );
- await expect( page ).toHaveURL(
- new RegExp( blockData.urlSearchParamWhenFilterIsApplied )
- );
-
await expect( products ).toHaveCount( 1 );
+ await expect(
+ products.getByRole( 'heading', { name: 'Cap' } )
+ ).toBeVisible();
} );
} );
test.describe( `${ blockData.name } Block - with Product Collection`, () => {
- test( 'should show all products', async ( { page, templateCompiler } ) => {
- await templateCompiler.compile();
-
- await page.goto( '/shop' );
- const products = page
- .locator( '.wp-block-woocommerce-product-template' )
- .getByRole( 'listitem' );
-
- await expect( products ).toHaveCount( 16 );
- } );
-
- test( 'should show only products that match the filter', async ( {
+ test( 'filters Product Collection automatically and defers changes until Apply when configured', async ( {
page,
+ admin,
+ editor,
templateCompiler,
} ) => {
- await templateCompiler.compile();
+ await page.clock.install();
+ const template = await templateCompiler.compile();
+ const productTitles = page.locator(
+ '.wp-block-woocommerce-product-template .wp-block-post-title'
+ );
await page.goto( '/shop' );
+ await expect( productTitles.first() ).toBeVisible();
+ const automaticBaseline = ( await productTitles.allTextContents() ).map(
+ ( title ) => title.trim()
+ );
+ // Greater than one, not merely non-empty: an unfiltered /shop must show more
+ // than the single product the filter is about to leave behind. Asserting only
+ // that it is non-empty passes on a shop page that arrived already filtered,
+ // and then the post-filter assertion passes too, for the wrong reason.
+ expect( automaticBaseline.length ).toBeGreaterThan( 1 );
+
await page
.getByRole( 'checkbox', { name: 'Rated 2 out of 5' } )
.click();
@@ -197,21 +112,7 @@ test.describe( `${ blockData.name } Block - with Product Collection`, () => {
await expect( page ).toHaveURL(
new RegExp( blockData.urlSearchParamWhenFilterIsApplied )
);
-
- const products = page
- .locator( '.wp-block-woocommerce-product-template' )
- .getByRole( 'listitem' );
-
- await expect( products ).toHaveCount( 1 );
- } );
-
- test( 'should refresh the page only if the user clicks on button', async ( {
- page,
- admin,
- editor,
- templateCompiler,
- } ) => {
- const template = await templateCompiler.compile();
+ await expect( productTitles ).toHaveText( [ 'Cap' ] );
await admin.visitSiteEditor( {
postId: template.id,
@@ -220,31 +121,50 @@ test.describe( `${ blockData.name } Block - with Product Collection`, () => {
} );
const ratingFilterControls = await editor.getBlockByName(
- 'woocommerce/rating-filter'
+ blockData.slug
);
await expect( ratingFilterControls ).toBeVisible();
await editor.selectBlocks( ratingFilterControls );
await editor.openDocumentSettingsSidebar();
await page.getByText( "Show 'Apply filters' button" ).click();
-
await editor.saveSiteEditorEntities( {
isOnlyCurrentEntityDirty: true,
} );
- await page.goto( '/shop' );
-
- await page
- .getByRole( 'checkbox', { name: 'Rated 2 out of 5' } )
- .click();
- await page.getByRole( 'button', { name: 'Apply' } ).click();
+ await page.goto( '/shop' );
+ await expect( productTitles.first() ).toBeVisible();
+ const ratingFilterCheckbox = page.getByRole( 'checkbox', {
+ name: 'Rated 2 out of 5',
+ } );
+ await expect( ratingFilterCheckbox ).toBeVisible();
+ await page.clock.pauseAt(
+ ( await page.evaluate( () => Date.now() ) ) + 1_000
+ );
+ const deferredBaseline = ( await productTitles.allTextContents() ).map(
+ ( title ) => title.trim()
+ );
+ // Greater than one, not merely non-empty: an unfiltered /shop must show more
+ // than the single product the filter is about to leave behind. Asserting only
+ // that it is non-empty passes on a shop page that arrived already filtered,
+ // and then the post-filter assertion passes too, for the wrong reason.
+ expect( deferredBaseline.length ).toBeGreaterThan( 1 );
+ const deferredUrl = page.url();
+
+ await ratingFilterCheckbox.click();
+ await page.clock.runFor( 501 );
+ await flushMacrotask( page );
+ await expect( ratingFilterCheckbox ).toBeChecked();
+ const applyButton = page.getByRole( 'button', { name: 'Apply' } );
+ await expect( applyButton ).toBeVisible();
+ await expect( applyButton ).toBeEnabled();
+ await expect( page ).toHaveURL( deferredUrl );
+ await expect( productTitles ).toHaveText( deferredBaseline );
+
+ await page.clock.resume();
+ await applyButton.click();
await expect( page ).toHaveURL(
new RegExp( blockData.urlSearchParamWhenFilterIsApplied )
);
-
- const products = page
- .locator( '.wp-block-woocommerce-product-template' )
- .getByRole( 'listitem' );
-
- await expect( products ).toHaveCount( 1 );
+ await expect( productTitles ).toHaveText( [ 'Cap' ] );
} );
} );
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/stock-filter/stock-filter.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/stock-filter/stock-filter.block_theme.spec.ts
index 1df2cc03302..912b140d8d6 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/stock-filter/stock-filter.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/stock-filter/stock-filter.block_theme.spec.ts
@@ -7,6 +7,7 @@ import {
TemplateCompiler,
wpCLI,
BLOCK_THEME_SLUG,
+ flushMacrotask,
} from '@woocommerce/e2e-utils';
export const blockData = {
@@ -24,101 +25,12 @@ const test = base.extend< { templateCompiler: TemplateCompiler } >( {
},
} );
-test.describe( `${ blockData.name } Block`, () => {
- test.beforeEach( async ( { admin, editor } ) => {
- await admin.createNewPost();
- await editor.insertBlock( {
- name: 'woocommerce/filter-wrapper',
- attributes: {
- filterType: 'stock-filter',
- heading: 'Filter By Price',
- },
- } );
-
- await editor.openDocumentSettingsSidebar();
- } );
-
- test( "should allow changing the block's title", async ( { editor } ) => {
- const textSelector =
- '.wp-block-woocommerce-filter-wrapper .wp-block-heading';
-
- const title = 'New Title';
-
- await editor.canvas.locator( textSelector ).fill( title );
-
- await expect( editor.canvas.locator( textSelector ) ).toHaveText(
- title
- );
- } );
-
- test( 'should allow changing the display style', async ( {
- page,
- editor,
- } ) => {
- const stockFilter = await editor.getBlockByName( blockData.slug );
- await editor.selectBlocks( stockFilter );
-
- await expect(
- stockFilter.getByRole( 'checkbox', {
- name: 'In Stock',
- } )
- ).toBeVisible();
-
- await expect(
- stockFilter.getByRole( 'checkbox', {
- name: 'Out of Stock',
- } )
- ).toBeVisible();
-
- await page.getByLabel( 'DropDown' ).click();
-
- await expect(
- stockFilter.getByRole( 'checkbox', {
- name: 'In Stock',
- } )
- ).toBeHidden();
-
- await expect(
- stockFilter.getByRole( 'checkbox', {
- name: 'Out of Stock',
- } )
- ).toBeHidden();
-
- await expect( editor.canvas.getByRole( 'combobox' ) ).toBeVisible();
- } );
-
- test( 'should allow toggling the visibility of the filter button', async ( {
- page,
- editor,
- } ) => {
- const priceFilterControls = await editor.getBlockByName(
- blockData.slug
- );
- await editor.selectBlocks( priceFilterControls );
-
- await expect(
- priceFilterControls.getByRole( 'button', {
- name: 'Apply',
- } )
- ).toBeHidden();
-
- await page.getByText( "Show 'Apply filters' button" ).click();
-
- await expect(
- priceFilterControls.getByRole( 'button', {
- name: 'Apply',
- } )
- ).toBeVisible();
- } );
-} );
-
test.describe( `${ blockData.name } Block - with PHP classic template`, () => {
test.beforeEach( async ( { admin, page, editor } ) => {
await wpCLI(
'option update wc_blocks_use_blockified_product_grid_block_as_template false'
);
- // Refresh the page to ensure the wpCLI changes take effect
await page.reload();
await admin.visitSiteEditor( {
@@ -131,7 +43,7 @@ test.describe( `${ blockData.name } Block - with PHP classic template`, () => {
name: 'woocommerce/filter-wrapper',
attributes: {
filterType: 'stock-filter',
- heading: 'Filter By Price',
+ heading: 'Filter By Stock',
},
} );
await editor.saveSiteEditorEntities( {
@@ -140,85 +52,62 @@ test.describe( `${ blockData.name } Block - with PHP classic template`, () => {
await page.goto( '/shop' );
} );
- test( 'should show all products', async ( { frontendUtils } ) => {
- const legacyTemplate = await frontendUtils.getBlockByName(
- 'woocommerce/legacy-template'
- );
-
- const stockFilter = await frontendUtils.getBlockByName(
- 'woocommerce/filter-wrapper'
- );
-
- const products = legacyTemplate
- .getByRole( 'list' )
- .locator( '.product' );
-
- await expect( products ).toHaveCount( 16 );
-
- await expect( stockFilter.getByText( 'In Stock' ) ).toBeVisible();
- await expect( stockFilter.getByText( 'Out of Stock' ) ).toBeVisible();
- } );
-
- test( 'should show only products that match the filter', async ( {
+ test( 'filters the classic product template by stock status', async ( {
frontendUtils,
+ page,
} ) => {
const stockFilter = await frontendUtils.getBlockByName(
'woocommerce/filter-wrapper'
);
-
await stockFilter.getByText( 'Out of Stock' ).click();
+ await expect( page ).toHaveURL(
+ new RegExp( blockData.urlSearchParamWhenFilterIsApplied )
+ );
+
const legacyTemplate = await frontendUtils.getBlockByName(
'woocommerce/legacy-template'
);
-
const products = legacyTemplate
.getByRole( 'list' )
.locator( '.product' );
await expect( products ).toHaveCount( 1 );
+ await expect(
+ products.getByRole( 'heading', { name: 'T-Shirt with Logo' } )
+ ).toBeVisible();
} );
} );
test.describe( `${ blockData.name } Block - with Product Collection`, () => {
- test( 'should show all products', async ( { page, templateCompiler } ) => {
- await templateCompiler.compile();
-
- await page.goto( '/shop' );
- const products = page
- .locator( '.wp-block-woocommerce-product-template' )
- .getByRole( 'listitem' );
-
- await expect( products ).toHaveCount( 16 );
- } );
-
- test( 'should show only products that match the filter', async ( {
+ test( 'filters Product Collection automatically and defers changes until Apply when configured', async ( {
page,
+ admin,
+ editor,
templateCompiler,
} ) => {
- await templateCompiler.compile();
+ await page.clock.install();
+ const template = await templateCompiler.compile();
+ const productTitles = page.locator(
+ '.wp-block-woocommerce-product-template .wp-block-post-title'
+ );
await page.goto( '/shop' );
- await page.getByText( 'Out of Stock' ).click();
+ await expect( productTitles.first() ).toBeVisible();
+ const automaticBaseline = ( await productTitles.allTextContents() ).map(
+ ( title ) => title.trim()
+ );
+ // Greater than one, not merely non-empty: an unfiltered /shop must show more
+ // than the single product the filter is about to leave behind. Asserting only
+ // that it is non-empty passes on a shop page that arrived already filtered,
+ // and then the post-filter assertion passes too, for the wrong reason.
+ expect( automaticBaseline.length ).toBeGreaterThan( 1 );
+ await page.getByText( 'Out of Stock' ).click();
await expect( page ).toHaveURL(
new RegExp( blockData.urlSearchParamWhenFilterIsApplied )
);
-
- const products = page
- .locator( '.wp-block-woocommerce-product-template' )
- .getByRole( 'listitem' );
-
- await expect( products ).toHaveCount( 1 );
- } );
-
- test( 'should refresh the page only if the user clicks on button', async ( {
- page,
- admin,
- editor,
- templateCompiler,
- } ) => {
- const template = await templateCompiler.compile();
+ await expect( productTitles ).toHaveText( [ 'T-Shirt with Logo' ] );
await admin.visitSiteEditor( {
postId: template.id,
@@ -236,19 +125,41 @@ test.describe( `${ blockData.name } Block - with Product Collection`, () => {
await editor.saveSiteEditorEntities( {
isOnlyCurrentEntityDirty: true,
} );
- await page.goto( '/shop' );
-
- await page.getByText( 'Out of Stock' ).click();
- await page.getByRole( 'button', { name: 'Apply' } ).click();
+ await page.goto( '/shop' );
+ await expect( productTitles.first() ).toBeVisible();
+ const outOfStockFilter = page.getByRole( 'checkbox', {
+ name: 'Out of Stock',
+ } );
+ await expect( outOfStockFilter ).toBeVisible();
+ await page.clock.pauseAt(
+ ( await page.evaluate( () => Date.now() ) ) + 1_000
+ );
+ const deferredBaseline = ( await productTitles.allTextContents() ).map(
+ ( title ) => title.trim()
+ );
+ // Greater than one, not merely non-empty: an unfiltered /shop must show more
+ // than the single product the filter is about to leave behind. Asserting only
+ // that it is non-empty passes on a shop page that arrived already filtered,
+ // and then the post-filter assertion passes too, for the wrong reason.
+ expect( deferredBaseline.length ).toBeGreaterThan( 1 );
+ const deferredUrl = page.url();
+
+ await outOfStockFilter.click();
+ await page.clock.runFor( 501 );
+ await flushMacrotask( page );
+ await expect( outOfStockFilter ).toBeChecked();
+ const applyButton = page.getByRole( 'button', { name: 'Apply' } );
+ await expect( applyButton ).toBeVisible();
+ await expect( applyButton ).toBeEnabled();
+ await expect( page ).toHaveURL( deferredUrl );
+ await expect( productTitles ).toHaveText( deferredBaseline );
+
+ await page.clock.resume();
+ await applyButton.click();
await expect( page ).toHaveURL(
new RegExp( blockData.urlSearchParamWhenFilterIsApplied )
);
-
- const products = page
- .locator( '.wp-block-woocommerce-product-template' )
- .getByRole( 'listitem' );
-
- await expect( products ).toHaveCount( 1 );
+ await expect( productTitles ).toHaveText( [ 'T-Shirt with Logo' ] );
} );
} );
diff --git a/plugins/woocommerce/tests/e2e/utils/blocks/frontend/flush-macrotask.ts b/plugins/woocommerce/tests/e2e/utils/blocks/frontend/flush-macrotask.ts
new file mode 100644
index 00000000000..35a736c7597
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/utils/blocks/frontend/flush-macrotask.ts
@@ -0,0 +1,32 @@
+/**
+ * External dependencies
+ */
+import type { Page } from '@playwright/test';
+
+/**
+ * Let the page drain one macrotask turn.
+ *
+ * After `page.clock.runFor()` fires a timer, work that timer schedules through a
+ * `MessageChannel` (React's scheduler uses one) is still queued. Posting a
+ * message of our own and waiting for it puts the next assertion behind that work.
+ *
+ * @param page The page to flush.
+ */
+export const flushMacrotask = ( page: Page ): Promise< void > =>
+ page.evaluate(
+ () =>
+ new Promise< void >( ( resolve ) => {
+ const channel = new MessageChannel();
+ channel.port1.addEventListener(
+ 'message',
+ () => {
+ channel.port1.close();
+ channel.port2.close();
+ resolve();
+ },
+ { once: true }
+ );
+ channel.port1.start();
+ channel.port2.postMessage( null );
+ } )
+ );
diff --git a/plugins/woocommerce/tests/e2e/utils/blocks/frontend/index.ts b/plugins/woocommerce/tests/e2e/utils/blocks/frontend/index.ts
index a7b5d426561..b33b6b54087 100644
--- a/plugins/woocommerce/tests/e2e/utils/blocks/frontend/index.ts
+++ b/plugins/woocommerce/tests/e2e/utils/blocks/frontend/index.ts
@@ -1 +1,2 @@
+export * from './flush-macrotask';
export * from './frontend-utils.page';
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
index 0728e2bd3cc..d70242ae316 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/QueryBuilder.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Automattic\WooCommerce\Tests\Blocks\BlockTypes\ProductCollection;
use Automattic\WooCommerce\Blocks\BlockTypes\ProductCollection\Utils as ProductCollectionUtils;
+use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
use Automattic\WooCommerce\Tests\Blocks\BlockTypes\ProductCollection\Utils;
use Automattic\WooCommerce\Tests\Blocks\Mocks\ProductCollectionMock;
use Automattic\WooCommerce\Enums\ProductStockStatus;
@@ -346,6 +347,73 @@ class QueryBuilder extends \WP_UnitTestCase {
set_query_var( 'filter_stock_status', '' );
}
+ /**
+ * @testdox Rating filters return the matching published product identity.
+ */
+ public function test_filter_by_rating_returns_exact_products(): void {
+ $fixtures = new FixtureData();
+
+ $matching_product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'One-star query target',
+ 'regular_price' => '10',
+ 'status' => 'publish',
+ )
+ );
+ $fixtures->add_product_review( $matching_product->get_id(), 1 );
+
+ $non_matching_product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Five-star query distractor',
+ 'regular_price' => '10',
+ 'status' => 'publish',
+ )
+ );
+ $fixtures->add_product_review( $non_matching_product->get_id(), 5 );
+
+ set_query_var( 'rating_filter', '1' );
+
+ $merged_query = Utils::initialize_merged_query( $this->block_instance );
+ $query = new WP_Query( $merged_query );
+ $found_product_ids = wp_list_pluck( $query->posts, 'ID' );
+
+ $this->assertContains( $matching_product->get_id(), $found_product_ids, 'The one-star target should be returned.' );
+ $this->assertNotContains( $non_matching_product->get_id(), $found_product_ids, 'The five-star distractor should be excluded.' );
+ }
+
+ /**
+ * @testdox Stock filters return the matching published product identity.
+ */
+ public function test_filter_by_stock_status_returns_exact_products(): void {
+ $fixtures = new FixtureData();
+
+ $matching_product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Out-of-stock query target',
+ 'regular_price' => '10',
+ 'status' => 'publish',
+ 'stock_status' => ProductStockStatus::OUT_OF_STOCK,
+ )
+ );
+ $non_matching_product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'In-stock query distractor',
+ 'regular_price' => '10',
+ 'status' => 'publish',
+ 'stock_status' => ProductStockStatus::IN_STOCK,
+ )
+ );
+
+ set_query_var( 'filter_stock_status', ProductStockStatus::OUT_OF_STOCK );
+
+ $merged_query = Utils::initialize_merged_query( $this->block_instance );
+ $query = new WP_Query( $merged_query );
+ $found_product_ids = wp_list_pluck( $query->posts, 'ID' );
+
+ $this->assertContains( $matching_product->get_id(), $found_product_ids, 'The out-of-stock target should be returned.' );
+ $this->assertNotContains( $non_matching_product->get_id(), $found_product_ids, 'The in-stock distractor should be excluded.' );
+ }
+
/**
* Test merging time range queries.
*/