Commit a4d1296ff80 for woocommerce
commit a4d1296ff8074cf219186b1d7d0ca7a0bddae05d
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Tue Sep 15 15:53:26 2026 +0300
[tests] Reduce Product Collection frontend E2E tests from 37 to 20 (#68618)
* test(blocks): Reduce Product Collection frontend E2E tests from 37 to 20
Two specs ran thirty-seven browser titles over what the Product
Collection and Product Template blocks do at runtime: which query
the block sends, which attributes it saves, what the product
template renders, where a collection thinks it is, and which
extensibility events fire. Most of those titles published a post or
a template and read one value back.
The query, the saved attributes, the location and the events are
decided in PHP and in the block's own JavaScript. Add PHPUnit tests
for the renderer's state, the route's query parity and the
controller's directives, and Jest tests for the editor contracts,
the frontend events, the product template's edit path and its
location helper.
Keep nineteen browser titles for what only a browser proves: the
Product Elements a collection renders after save, reload and
navigation, both responsive layouts, pagination with two blocks,
classic-template parity, the migration from the deprecated Products
block, and the three extensibility events a shopper's clicks fire.
Add one browser title the migration would otherwise have removed
outright: an empty collection must render nothing on a published
page, and a No Results block must still reach a shopper. No other
test in the suite covers that.
Consolidates the mega-branch slices:
- Slice 028: test(blocks): Move Product Collection behavior below E2E
- Slice 077: test(blocks): Move Product Collection events below E2E
- Slice 069: test(blocks): Reduce Products block browser coverage
- Slice 029: test(blocks): Move Product Collection controls below E2E
Refs TESTOPS-234
Refs #68046
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(e2e): Add an empty Product Collection render canary
Moving the Product Collection runtime tests below the browser takes
the three titles that covered an empty collection and the No Results
block. After that move, no test anywhere in the suite proves what a
shopper sees: that an empty collection leaves nothing on a published
page, and that a No Results block still reaches them.
Add one title for both. It publishes a Product Catalog collection
filtered to an impossible price range, which must show No results
found, followed by a Featured collection filtered the same way,
which must leave nothing. That order matters: the collection that
renders comes first, so a render state that leaks into the next
block is visible to the test.
Refs TESTOPS-234
Refs #68046
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(blocks): Stop the Product Collection renderer test snapshotting hooks
Both tests cloned the five render hooks at the top and put them back in
a finally block. _restore_hooks() rebuilds $wp_filter from the suite's
baseline after every test, so the snapshot only repeated it; neither
test read the restored stacks back.
The remove_all_filters() call inside the first test stays, because that
one has to hold while the test runs: it is what makes the Renderer
constructor the only thing registered on those hooks.
The script-module dequeue and the $GLOBALS['product'] restore stay too.
wp_script_modules() and that global are not reset by the base class.
Refs TESTOPS-234
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(blocks): Drop process isolation from the route parity test
RouteContextParityTest carried @runInSeparateProcess with a data
provider, so PHPUnit forked a child per provider row. Each child
re-requires tests/legacy/bootstrap.php, which reinstalls the store and
commits woocommerce_custom_orders_table_enabled over its own connection,
outside the parent's rolled-back transaction. Three rows meant three
mid-suite resets of the shared test database, and a flipped order store
for every test that followed in the parent.
This is the same mechanism that turned unrelated tests red on the
HPOS-enabled job and that the coupon and email-editor tests were already
corrected for; this file was missed. The finally block restores the five
route globals, which is the state that actually needed containing, and
the transaction covers the options and fixtures. The three tests pass
in-process and the whole suite is unchanged at 15102 tests and 58766
assertions.
Also correct the RendererTest teardown comment, which claimed the
script-module queue was the only thing there the base class does not
reset. Enhancing the markup also runs render_interactivity_notices_region(),
which writes wp_interactivity_state( 'woocommerce/store-notices', ... ).
Nothing reads it and the values are the same every time, so it is left
alone on purpose -- but the comment should not tell the next person
sweeping this file that it is already handled.
Refs TESTOPS-234
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(blocks): Stop the route parity test restoring rolled-back rows
The parity test deleted its products and terms and put two options
back in its finally block. All of those are rows inside the per-test
transaction, and the base class flushes the object cache before each
test, so the rollback already undoes them.
The route globals, wp_reset_postdata() and wc_reset_loop() stay:
$post, $product and $woocommerce_loop are process state the base
class does not reset.
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-product-collection-frontend b/plugins/woocommerce/changelog/testops-234-product-collection-frontend
new file mode 100644
index 00000000000..3bbb9e5572a
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-234-product-collection-frontend
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Reduce Product Collection frontend E2E tests from 37 to 20; PHPUnit owns the renderer's state and route parity, Jest owns the editor contracts and the product template's request, and one new browser title keeps the empty-collection and No Results render.
+
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/test/editor-contracts.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/test/editor-contracts.tsx
new file mode 100644
index 00000000000..345553c2667
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/test/editor-contracts.tsx
@@ -0,0 +1,443 @@
+/**
+ * External dependencies
+ */
+import { act, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import {
+ createBlock,
+ getCategories,
+ getBlockType,
+ parse,
+ registerBlockType,
+ serialize,
+ setCategories,
+ unregisterBlockType,
+} from '@wordpress/blocks';
+import { store as coreStore } from '@wordpress/core-data';
+import { store as blockEditorStore } from '@wordpress/block-editor';
+import { dispatch, select } from '@wordpress/data';
+import { applyFilters, removeFilter } from '@wordpress/hooks';
+import { useCallback, useState } from '@wordpress/element';
+import { CORE_EDITOR_STORE } from '@woocommerce/utils';
+
+/**
+ * Internal dependencies
+ */
+import metadata from '../block.json';
+import productTemplateMetadata from '../../product-template/block.json';
+import save from '../save';
+import productTemplateSave from '../../product-template/save';
+import ProductCollectionContent from '../edit/product-collection-content';
+import DefaultQueryOrderByControl from '../edit/inspector-controls/order-by-control/default-query-order-by-control';
+import {
+ DEFAULT_ATTRIBUTES,
+ DEFAULT_QUERY,
+ PRODUCT_COLLECTION_BLOCK_NAME,
+} from '../constants';
+import {
+ addProductCollectionToQueryPaginationParentOrAncestor,
+ getDefaultValueOfFilterable,
+ getDefaultValueOfInherit,
+} from '../utils';
+import {
+ CoreCollectionNames,
+ LayoutOptions,
+ ProductCollectionAttributes,
+} from '../types';
+import { LocationType } from '../../product-template/utils';
+
+jest.mock( '../edit/inspector-controls', () => () => null );
+jest.mock( '../edit/inspector-advanced-controls', () => () => null );
+jest.mock( '../edit/toolbar-controls', () => () => null );
+jest.mock(
+ '../edit/inspector-controls/order-by-control/order-by-control',
+ () => {
+ const React = jest.requireActual( 'react' );
+
+ return ( { label, onChange, orderOptions, selectedValue } ) =>
+ React.createElement(
+ 'select',
+ {
+ 'aria-label': label,
+ onChange: ( event ) => onChange( event.target.value ),
+ value: selectedValue,
+ },
+ orderOptions.map( ( option ) =>
+ React.createElement(
+ 'option',
+ { key: option.value, value: option.value },
+ option.label
+ )
+ )
+ );
+ }
+);
+
+const registeredBlockTypes: string[] = [];
+let originalBlockCategories: ReturnType< typeof getCategories >;
+
+beforeAll( () => {
+ originalBlockCategories = getCategories();
+ if (
+ ! originalBlockCategories.some(
+ ( category ) => category.slug === 'woocommerce'
+ )
+ ) {
+ setCategories( [
+ ...originalBlockCategories,
+ { slug: 'woocommerce', title: 'WooCommerce' },
+ ] );
+ }
+
+ if ( ! getBlockType( productTemplateMetadata.name ) ) {
+ registerBlockType( productTemplateMetadata, {
+ edit: () => null,
+ save: productTemplateSave,
+ } );
+ registeredBlockTypes.push( productTemplateMetadata.name );
+ }
+
+ if ( ! getBlockType( metadata.name ) ) {
+ registerBlockType( metadata, {
+ edit: () => null,
+ save,
+ } );
+ registeredBlockTypes.push( metadata.name );
+ }
+} );
+
+afterAll( () => {
+ registeredBlockTypes.forEach( ( name ) => unregisterBlockType( name ) );
+ setCategories( originalBlockCategories );
+} );
+
+describe( 'Product Collection editor contracts', () => {
+ it.each( [
+ {
+ caseName: 'default collection in a post',
+ collection: undefined,
+ inherit: false,
+ },
+ {
+ caseName: 'named collection in a post',
+ collection: CoreCollectionNames.ON_SALE,
+ inherit: false,
+ },
+ {
+ caseName: 'default collection in an archive template',
+ collection: undefined,
+ inherit: true,
+ },
+ {
+ caseName: 'named collection in an archive template',
+ collection: CoreCollectionNames.ON_SALE,
+ inherit: true,
+ },
+ ] )(
+ 'round-trips $caseName through real metadata',
+ ( { collection, inherit } ) => {
+ const productTemplate = createBlock( productTemplateMetadata.name );
+ const block = createBlock(
+ PRODUCT_COLLECTION_BLOCK_NAME,
+ {
+ __privatePreviewState: {
+ isPreview: true,
+ previewMessage: 'local preview',
+ },
+ collection,
+ displayLayout: {
+ columns: 4,
+ shrinkColumns: true,
+ type: LayoutOptions.GRID,
+ },
+ query: {
+ ...DEFAULT_QUERY,
+ inherit,
+ perPage: 8,
+ },
+ },
+ [ productTemplate ]
+ );
+ const serialized = serialize( block );
+ const [ parsed ] = parse( serialized );
+
+ expect( parsed.attributes.collection ).toBe( collection );
+ expect( parsed.attributes.query ).toMatchObject( {
+ inherit,
+ order: 'asc',
+ orderBy: 'title',
+ perPage: 8,
+ } );
+ expect( parsed.attributes.displayLayout ).toEqual( {
+ columns: 4,
+ shrinkColumns: true,
+ type: LayoutOptions.GRID,
+ } );
+ expect( parsed.innerBlocks ).toHaveLength( 1 );
+ expect( parsed.innerBlocks[ 0 ].name ).toBe(
+ productTemplateMetadata.name
+ );
+ expect( parsed.attributes ).not.toHaveProperty(
+ '__privatePreviewState'
+ );
+ expect( serialized ).not.toContain( '__privatePreviewState' );
+ }
+ );
+
+ it( 'adds Product Collection to the real Pagination metadata filter', () => {
+ addProductCollectionToQueryPaginationParentOrAncestor();
+
+ try {
+ const settings = applyFilters(
+ 'blocks.registerBlockType',
+ { ancestor: [ 'core/query' ] },
+ 'core/query-pagination'
+ ) as { ancestor: string[] };
+
+ expect( settings.ancestor ).toEqual( [
+ 'core/query',
+ PRODUCT_COLLECTION_BLOCK_NAME,
+ ] );
+ } finally {
+ removeFilter(
+ 'blocks.registerBlockType',
+ 'woocommerce/add-product-collection-block-to-parent-array-of-pagination-block'
+ );
+ }
+ } );
+} );
+
+describe( 'Product Collection page-context defaults', () => {
+ afterEach( () => {
+ dispatch( blockEditorStore ).resetBlocks( [] );
+ jest.restoreAllMocks();
+ } );
+
+ it.each( [
+ {
+ caseName: 'Product Catalog archive inheritance',
+ getDefault: getDefaultValueOfInherit,
+ property: 'inherit',
+ templateSlug: 'archive-product',
+ },
+ {
+ caseName: 'Product Attribute archive inheritance',
+ getDefault: getDefaultValueOfInherit,
+ property: 'inherit',
+ templateSlug: 'taxonomy-product_attribute',
+ },
+ {
+ caseName: 'Product Search Results archive inheritance',
+ getDefault: getDefaultValueOfInherit,
+ property: 'inherit',
+ templateSlug: 'product-search-results',
+ },
+ {
+ caseName: 'Product Category archive inheritance',
+ getDefault: getDefaultValueOfInherit,
+ property: 'inherit',
+ templateSlug: 'taxonomy-product_cat',
+ },
+ {
+ caseName: 'Product Tag archive inheritance',
+ getDefault: getDefaultValueOfInherit,
+ property: 'inherit',
+ templateSlug: 'taxonomy-product_tag',
+ },
+ {
+ caseName: 'Product Brand archive inheritance',
+ getDefault: getDefaultValueOfInherit,
+ property: 'inherit',
+ templateSlug: 'taxonomy-product_brand',
+ },
+ {
+ caseName: 'post filtering',
+ getDefault: getDefaultValueOfFilterable,
+ property: 'filterable',
+ templateSlug: 'post',
+ },
+ {
+ caseName: 'home filtering',
+ getDefault: getDefaultValueOfFilterable,
+ property: 'filterable',
+ templateSlug: 'home',
+ },
+ {
+ caseName: 'index filtering',
+ getDefault: getDefaultValueOfFilterable,
+ property: 'filterable',
+ templateSlug: 'index',
+ },
+ {
+ caseName: 'Single Product filtering',
+ getDefault: getDefaultValueOfFilterable,
+ property: 'filterable',
+ templateSlug: 'single-product',
+ },
+ ] as const )(
+ 'allows only the first collection to own $caseName',
+ ( { getDefault, property, templateSlug } ) => {
+ jest.spyOn(
+ select( CORE_EDITOR_STORE ) as unknown as {
+ getEditedPostSlug: () => string;
+ },
+ 'getEditedPostSlug'
+ ).mockReturnValue( templateSlug );
+
+ dispatch( blockEditorStore ).resetBlocks( [] );
+ expect( getDefault() ).toBe( true );
+
+ const firstCollection = createBlock(
+ PRODUCT_COLLECTION_BLOCK_NAME,
+ {
+ query: {
+ ...DEFAULT_QUERY,
+ [ property ]: true,
+ },
+ }
+ );
+ dispatch( blockEditorStore ).resetBlocks( [ firstCollection ] );
+ expect( getDefault() ).toBe( false );
+
+ dispatch( blockEditorStore ).updateBlockAttributes(
+ firstCollection.clientId,
+ {
+ query: {
+ ...DEFAULT_QUERY,
+ [ property ]: false,
+ },
+ }
+ );
+ expect( getDefault() ).toBe( true );
+ }
+ );
+} );
+
+const createPreviewAttributes = (): ProductCollectionAttributes => ( {
+ ...DEFAULT_ATTRIBUTES,
+ convertedFromProducts: false,
+ filterable: false,
+ hideControls: [],
+ query: {
+ ...DEFAULT_QUERY,
+ inherit: true,
+ },
+ queryContext: [ { page: 1 } ],
+ queryId: 1,
+ templateSlug: '',
+} );
+
+const GenericArchivePreview = ( {
+ isSelected,
+ taxonomy,
+}: {
+ isSelected: boolean;
+ taxonomy: string | null;
+} ) => {
+ const [ attributes, setAttributes ] = useState( createPreviewAttributes() );
+ const setBlockAttributes = useCallback(
+ ( updates: Partial< ProductCollectionAttributes > ) => {
+ setAttributes( ( current ) => ( {
+ ...current,
+ ...updates,
+ } ) );
+ },
+ []
+ );
+
+ return (
+ <ProductCollectionContent
+ attributes={ attributes }
+ clientId={ `preview-${ taxonomy ?? 'attribute' }` }
+ context={ { templateSlug: '' } }
+ insertBlocksAfter={ () => undefined }
+ isSelected={ isSelected }
+ isUsingReferencePreviewMode={ false }
+ location={ {
+ sourceData: { taxonomy, termId: null },
+ type: LocationType.Archive,
+ } }
+ name={ PRODUCT_COLLECTION_BLOCK_NAME }
+ onReplace={ () => undefined }
+ openCollectionSelectionModal={ () => undefined }
+ setAttributes={ setBlockAttributes }
+ tracksLocation="product-archive"
+ />
+ );
+};
+
+describe( 'generic archive previews', () => {
+ it.each( [
+ [ 'tag', 'product_tag' ],
+ [ 'category', 'product_cat' ],
+ [ 'attribute', null ],
+ ] )( 'shows the %s preview only while selected', async ( _, taxonomy ) => {
+ const { rerender } = render(
+ <GenericArchivePreview isSelected taxonomy={ taxonomy } />
+ );
+
+ expect(
+ await screen.findByTestId( 'product-collection-preview-button' )
+ ).toBeVisible();
+
+ rerender(
+ <GenericArchivePreview isSelected={ false } taxonomy={ taxonomy } />
+ );
+ await waitFor( () =>
+ expect(
+ screen.queryByTestId( 'product-collection-preview-button' )
+ ).not.toBeInTheDocument()
+ );
+
+ rerender( <GenericArchivePreview isSelected taxonomy={ taxonomy } /> );
+ expect(
+ await screen.findByTestId( 'product-collection-preview-button' )
+ ).toBeVisible();
+ } );
+} );
+
+describe( 'default catalog order control', () => {
+ it( 'writes the selected default order to the site entity', async () => {
+ const user = userEvent.setup();
+ const coreSelectors = select( coreStore );
+ const coreActions = dispatch( coreStore );
+ const getEditedEntityRecord = jest
+ .spyOn( coreSelectors, 'getEditedEntityRecord' )
+ .mockReturnValue( {
+ woocommerce_default_catalog_orderby: 'menu_order',
+ } );
+ const editEntityRecord = jest
+ .spyOn( coreActions, 'editEntityRecord' )
+ .mockReturnValue( undefined );
+ const trackInteraction = jest.fn();
+
+ try {
+ render(
+ <DefaultQueryOrderByControl
+ trackInteraction={ trackInteraction }
+ />
+ );
+ await act( async () => {
+ await user.selectOptions(
+ screen.getByRole( 'combobox', {
+ name: 'Default sort by',
+ } ),
+ 'price-desc'
+ );
+ } );
+
+ expect( editEntityRecord ).toHaveBeenCalledWith(
+ 'root',
+ 'site',
+ undefined,
+ {
+ woocommerce_default_catalog_orderby: 'price-desc',
+ }
+ );
+ expect( trackInteraction ).toHaveBeenCalledWith( 'default-order' );
+ } finally {
+ getEditedEntityRecord.mockRestore();
+ editEntityRecord.mockRestore();
+ }
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/test/frontend-events.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/test/frontend-events.ts
new file mode 100644
index 00000000000..5548e610527
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/test/frontend-events.ts
@@ -0,0 +1,183 @@
+/**
+ * External dependencies
+ */
+import type { ProductsStore } from '@woocommerce/stores/woocommerce/products';
+
+/**
+ * Internal dependencies
+ */
+import { CoreCollectionNames } from '../types';
+
+type ProductCollectionStoreDescriptor = {
+ actions: {
+ viewProduct: () => Generator;
+ };
+ callbacks: {
+ onRender: () => Generator;
+ };
+};
+
+const mockGetContext = jest.fn();
+const mockGetElement = jest.fn();
+
+let mockContext: { collection: CoreCollectionNames } | null = null;
+let mockProductsState: ProductsStore[ 'state' ];
+let mockProductCollectionDescriptor: ProductCollectionStoreDescriptor | null =
+ null;
+
+jest.mock(
+ '@wordpress/interactivity',
+ () => ( {
+ getContext: mockGetContext,
+ getElement: mockGetElement,
+ store: jest.fn( ( namespace, descriptor ) => {
+ if ( namespace === 'woocommerce/products' ) {
+ return { state: mockProductsState };
+ }
+
+ if ( namespace === 'woocommerce/product-collection' ) {
+ mockProductCollectionDescriptor = descriptor;
+ return descriptor;
+ }
+
+ return {};
+ } ),
+ } ),
+ { virtual: true }
+);
+
+const getProductCollectionStore = (): ProductCollectionStoreDescriptor => {
+ if ( ! mockProductCollectionDescriptor ) {
+ throw new Error( 'Product collection store was not registered.' );
+ }
+
+ return mockProductCollectionDescriptor;
+};
+
+const runGenerator = ( callback: () => Generator ) => {
+ const generator = callback();
+ let result = generator.next();
+
+ while ( ! result.done ) {
+ result = generator.next();
+ }
+};
+
+describe( 'product collection frontend events', () => {
+ beforeEach( () => {
+ mockContext = null;
+ mockProductsState = {
+ productInContext: null,
+ } as ProductsStore[ 'state' ];
+ mockProductCollectionDescriptor = null;
+ mockGetContext.mockImplementation( () => mockContext );
+ mockGetElement.mockReset();
+
+ jest.resetModules();
+ jest.isolateModules( () => {
+ jest.requireActual( '../frontend' );
+ } );
+ } );
+
+ afterEach( () => {
+ document.body.replaceChildren();
+ mockContext = null;
+ mockProductsState = {} as ProductsStore[ 'state' ];
+ mockProductCollectionDescriptor = null;
+ mockGetContext.mockReset();
+ mockGetElement.mockReset();
+ jest.clearAllMocks();
+ jest.resetModules();
+ } );
+
+ it( 'dispatches one product-list-rendered event for each render callback', () => {
+ const collection = CoreCollectionNames.RELATED;
+ const events: CustomEvent[] = [];
+ const listener = ( event: Event ) =>
+ events.push( event as CustomEvent );
+
+ mockContext = { collection };
+ document.addEventListener(
+ 'wc-blocks_product_list_rendered',
+ listener
+ );
+
+ try {
+ const { callbacks } = getProductCollectionStore();
+
+ runGenerator( callbacks.onRender );
+ runGenerator( callbacks.onRender );
+ runGenerator( callbacks.onRender );
+
+ expect( events ).toHaveLength( 3 );
+ expect(
+ events.map( ( { detail, bubbles, cancelable } ) => ( {
+ detail,
+ bubbles,
+ cancelable,
+ } ) )
+ ).toEqual( [
+ {
+ detail: { collection },
+ bubbles: true,
+ cancelable: true,
+ },
+ {
+ detail: { collection },
+ bubbles: true,
+ cancelable: true,
+ },
+ {
+ detail: { collection },
+ bubbles: true,
+ cancelable: true,
+ },
+ ] );
+ } finally {
+ document.removeEventListener(
+ 'wc-blocks_product_list_rendered',
+ listener
+ );
+ }
+ } );
+
+ it( 'dispatches a viewed-product event only when the context product has an ID', () => {
+ const collection = CoreCollectionNames.RELATED;
+ const events: CustomEvent[] = [];
+ const listener = ( event: Event ) =>
+ events.push( event as CustomEvent );
+
+ mockContext = { collection };
+ mockProductsState.productInContext = {
+ id: 42,
+ } as ProductsStore[ 'state' ][ 'productInContext' ];
+ document.addEventListener( 'wc-blocks_viewed_product', listener );
+
+ try {
+ const { actions } = getProductCollectionStore();
+
+ runGenerator( actions.viewProduct );
+
+ expect( events ).toHaveLength( 1 );
+ expect( {
+ detail: events[ 0 ].detail,
+ bubbles: events[ 0 ].bubbles,
+ cancelable: events[ 0 ].cancelable,
+ } ).toEqual( {
+ detail: { collection, productId: 42 },
+ bubbles: true,
+ cancelable: true,
+ } );
+
+ mockProductsState.productInContext = null;
+ runGenerator( actions.viewProduct );
+
+ expect( events ).toHaveLength( 1 );
+ } finally {
+ document.removeEventListener(
+ 'wc-blocks_viewed_product',
+ listener
+ );
+ }
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/test/query-attributes.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/test/query-attributes.ts
index da0cd4190fa..6afcff8e905 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/test/query-attributes.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-collection/test/query-attributes.ts
@@ -53,6 +53,22 @@ describe( 'getUpdatedQuery', () => {
} );
} );
+ it( 'preserves existing taxonomy filters when adding product categories', () => {
+ expect(
+ getUpdatedQuery(
+ getQuery( {
+ taxQuery: { product_tag: [ 32 ] },
+ } ),
+ {
+ taxQuery: { product_cat: [ 31 ] },
+ }
+ ).taxQuery
+ ).toEqual( {
+ product_tag: [ 32 ],
+ product_cat: [ 31 ],
+ } );
+ } );
+
it( 'preserves taxQuery when an update does not include taxonomy changes', () => {
expect(
getUpdatedQuery(
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-template/test/edit.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-template/test/edit.tsx
new file mode 100644
index 00000000000..cc245267c7f
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-template/test/edit.tsx
@@ -0,0 +1,294 @@
+/**
+ * External dependencies
+ */
+import { act, render } from '@testing-library/react';
+import { store as blockEditorStore } from '@wordpress/block-editor';
+import {
+ createBlock,
+ getBlockType,
+ registerBlockType,
+ unregisterBlockType,
+} from '@wordpress/blocks';
+import { store as coreStore } from '@wordpress/core-data';
+import { dispatch, select } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import ProductTemplateEdit from '../edit';
+import { LocationType } from '../utils';
+
+type EntityQuery = Record< string, unknown >;
+const noProducts: never[] = [];
+const noTaxonomies: never[] = [];
+const resolvedTaxonomyRecords = {
+ product_cat: { id: 81, slug: 'hoodies' },
+ product_tag: { id: 91, slug: 'recommended' },
+} as const;
+const requiredBlockTypes = [
+ 'woocommerce/product-template',
+ 'woocommerce/single-product',
+];
+const registeredBlockTypes: string[] = [];
+
+beforeAll( () => {
+ requiredBlockTypes.forEach( ( name ) => {
+ if ( getBlockType( name ) ) {
+ return;
+ }
+
+ registerBlockType( name, {
+ apiVersion: 3,
+ title: name,
+ category: 'widgets',
+ edit: () => null,
+ save: () => null,
+ } );
+ registeredBlockTypes.push( name );
+ } );
+} );
+
+afterAll( () => {
+ registeredBlockTypes.forEach( ( name ) => unregisterBlockType( name ) );
+} );
+
+const createProps = ( {
+ clientId = 'product-template-client-id',
+ inherit = false,
+ postId,
+ templateSlug = '',
+}: {
+ clientId?: string;
+ inherit?: boolean;
+ postId?: number;
+ templateSlug?: string;
+} = {} ) => ( {
+ attributes: {},
+ clientId,
+ context: {
+ __privateProductCollectionPreviewState: undefined,
+ displayLayout: {
+ columns: 3,
+ shrinkColumns: false,
+ type: 'flex',
+ },
+ query: {
+ exclude: [],
+ inherit,
+ offset: 0,
+ order: 'asc',
+ orderBy: 'menu_order',
+ pages: 0,
+ perPage: 4,
+ search: '',
+ taxQuery: {},
+ },
+ queryContext: [ { page: 1 } ],
+ queryContextIncludes: [],
+ postId,
+ templateSlug,
+ },
+ insertBlocksAfter: jest.fn(),
+ isSelected: false,
+ name: 'woocommerce/product-template',
+ onReplace: jest.fn(),
+ setAttributes: jest.fn(),
+ __unstableLayoutClassNames: '',
+} );
+
+const getProductQuery = ( getEntityRecords: jest.SpyInstance ) => {
+ const call = [ ...getEntityRecords.mock.calls ]
+ .reverse()
+ .find(
+ ( [ kind, name, query ] ) =>
+ kind === 'postType' &&
+ name === 'product' &&
+ ! ( query as EntityQuery ).slug
+ );
+
+ expect( call ).toBeDefined();
+ return call?.[ 2 ] as EntityQuery;
+};
+
+describe( 'ProductTemplateEdit request context', () => {
+ let getEntityRecords: jest.SpyInstance;
+ let getTaxonomies: jest.SpyInstance;
+ let getEditedEntityRecord: jest.SpyInstance;
+
+ beforeEach( () => {
+ act( () => {
+ dispatch( blockEditorStore ).resetBlocks( [] );
+ } );
+
+ const coreSelectors = select( coreStore );
+ const selectEntityRecords =
+ coreSelectors.getEntityRecords.bind( coreSelectors );
+
+ getEntityRecords = jest
+ .spyOn( coreSelectors, 'getEntityRecords' )
+ .mockImplementation( ( kind, name, query ) => {
+ if ( kind === 'taxonomy' ) {
+ const record =
+ resolvedTaxonomyRecords[
+ name as keyof typeof resolvedTaxonomyRecords
+ ];
+ if ( record?.slug === ( query as EntityQuery ).slug ) {
+ return [ record ];
+ }
+ }
+
+ if (
+ kind === 'postType' &&
+ name === 'product' &&
+ ! ( query as EntityQuery ).slug
+ ) {
+ return noProducts;
+ }
+
+ return selectEntityRecords( kind, name, query );
+ } );
+ getTaxonomies = jest
+ .spyOn( coreSelectors, 'getTaxonomies' )
+ .mockReturnValue( noTaxonomies );
+ getEditedEntityRecord = jest
+ .spyOn( coreSelectors, 'getEditedEntityRecord' )
+ .mockReturnValue( {
+ woocommerce_default_catalog_orderby: 'price-desc',
+ } );
+ } );
+
+ afterEach( () => {
+ getEntityRecords.mockRestore();
+ getTaxonomies.mockRestore();
+ getEditedEntityRecord.mockRestore();
+ act( () => {
+ dispatch( blockEditorStore ).resetBlocks( [] );
+ } );
+ } );
+
+ it( 'adds only the product ID for a product location', () => {
+ const productTemplate = createBlock( 'woocommerce/product-template' );
+ const singleProduct = createBlock( 'woocommerce/single-product', {}, [
+ productTemplate,
+ ] );
+ act( () => {
+ dispatch( blockEditorStore ).resetBlocks( [ singleProduct ] );
+ } );
+
+ render(
+ <ProductTemplateEdit
+ { ...createProps( {
+ clientId: productTemplate.clientId,
+ postId: 101,
+ templateSlug: 'taxonomy-product_cat',
+ } ) }
+ />
+ );
+
+ expect( getProductQuery( getEntityRecords ) ).toMatchObject( {
+ productCollectionLocation: {
+ sourceData: { productId: 101 },
+ type: LocationType.Product,
+ },
+ } );
+ const sourceData = (
+ getProductQuery( getEntityRecords ).productCollectionLocation as {
+ sourceData: EntityQuery;
+ }
+ ).sourceData;
+ expect( sourceData ).not.toHaveProperty( 'taxonomy' );
+ expect( sourceData ).not.toHaveProperty( 'termId' );
+ } );
+
+ it( 'adds only taxonomy fields for an archive location', () => {
+ render(
+ <ProductTemplateEdit
+ { ...createProps( {
+ templateSlug: 'taxonomy-product_cat',
+ } ) }
+ />
+ );
+
+ expect( getProductQuery( getEntityRecords ) ).toMatchObject( {
+ productCollectionLocation: {
+ sourceData: {
+ taxonomy: 'product_cat',
+ termId: null,
+ },
+ type: LocationType.Archive,
+ },
+ } );
+ const sourceData = (
+ getProductQuery( getEntityRecords ).productCollectionLocation as {
+ sourceData: EntityQuery;
+ }
+ ).sourceData;
+ expect( sourceData ).not.toHaveProperty( 'productId' );
+ } );
+
+ it.each( [
+ {
+ taxonomy: 'product_cat',
+ termId: 81,
+ templateSlug: 'taxonomy-product_cat-hoodies',
+ wrongTaxonomy: 'product_tag',
+ },
+ {
+ taxonomy: 'product_tag',
+ termId: 91,
+ templateSlug: 'taxonomy-product_tag-recommended',
+ wrongTaxonomy: 'product_cat',
+ },
+ ] )(
+ 'adds the resolved $taxonomy ID to an inherited product request',
+ ( { taxonomy, termId, templateSlug, wrongTaxonomy } ) => {
+ render(
+ <ProductTemplateEdit
+ { ...createProps( {
+ inherit: true,
+ templateSlug,
+ } ) }
+ />
+ );
+
+ const request = getProductQuery( getEntityRecords );
+ expect( request[ taxonomy ] ).toBe( termId );
+ expect( request ).not.toHaveProperty( wrongTaxonomy );
+ }
+ );
+
+ it( 'does not add source fields for a site location', () => {
+ render( <ProductTemplateEdit { ...createProps() } /> );
+
+ const location = getProductQuery( getEntityRecords )
+ .productCollectionLocation as {
+ sourceData: EntityQuery;
+ type: LocationType;
+ };
+
+ expect( location ).toEqual( {
+ sourceData: {},
+ type: LocationType.Site,
+ } );
+ expect( location.sourceData ).toEqual( {} );
+ expect( location.sourceData ).not.toHaveProperty( 'productId' );
+ expect( location.sourceData ).not.toHaveProperty( 'taxonomy' );
+ expect( location.sourceData ).not.toHaveProperty( 'termId' );
+ } );
+
+ it( 'uses the site default catalog order for inherited requests', () => {
+ render(
+ <ProductTemplateEdit
+ { ...createProps( {
+ inherit: true,
+ templateSlug: 'archive-product',
+ } ) }
+ />
+ );
+
+ expect( getProductQuery( getEntityRecords ) ).toMatchObject( {
+ order: 'desc',
+ orderby: 'price',
+ } );
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/product-template/test/utils.tsx b/plugins/woocommerce/client/blocks/assets/js/blocks/product-template/test/utils.tsx
new file mode 100644
index 00000000000..f3597291bee
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/product-template/test/utils.tsx
@@ -0,0 +1,291 @@
+/**
+ * External dependencies
+ */
+import { act, renderHook, waitFor } from '@testing-library/react';
+import {
+ createBlock,
+ getBlockType,
+ registerBlockType,
+ unregisterBlockType,
+} from '@wordpress/blocks';
+import { store as blockEditorStore } from '@wordpress/block-editor';
+import { store as coreStore } from '@wordpress/core-data';
+import { dispatch } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import {
+ LocationType,
+ useGetLocation,
+ useProductCollectionQueryContext,
+} from '../utils';
+
+const requiredBlockTypes = [
+ 'core/paragraph',
+ 'woocommerce/product-collection',
+ 'woocommerce/product-template',
+ 'woocommerce/single-product',
+];
+const registeredBlockTypes: string[] = [];
+
+beforeAll( () => {
+ requiredBlockTypes.forEach( ( name ) => {
+ if ( getBlockType( name ) ) {
+ return;
+ }
+
+ registerBlockType( name, {
+ apiVersion: 3,
+ title: name,
+ category: 'widgets',
+ attributes:
+ name === 'woocommerce/product-collection'
+ ? {
+ collection: { type: 'string' },
+ forcePageReload: {
+ type: 'boolean',
+ default: false,
+ },
+ }
+ : {},
+ edit: () => null,
+ save: () => null,
+ } );
+ registeredBlockTypes.push( name );
+ } );
+} );
+
+afterAll( () => {
+ registeredBlockTypes.forEach( ( name ) => unregisterBlockType( name ) );
+} );
+
+const primeEntityResolution = (
+ kind: 'postType' | 'taxonomy',
+ name: 'product' | 'product_cat' | 'product_tag',
+ slug: string,
+ records: { id: number; slug: string }[]
+) => {
+ const query = {
+ _fields: [ 'id' ],
+ slug,
+ };
+ const actions = dispatch( coreStore );
+
+ actions.addEntities( [
+ {
+ baseURL: `/wp/v2/${ name }`,
+ kind,
+ name,
+ },
+ ] );
+ actions.receiveEntityRecords( kind, name, records, query );
+ actions.finishResolution( 'getEntityRecords', [ kind, name, query ] );
+};
+
+describe( 'useGetLocation', () => {
+ beforeEach( () => {
+ act( () => {
+ dispatch( blockEditorStore ).resetBlocks( [] );
+ } );
+ } );
+
+ afterEach( () => {
+ act( () => {
+ dispatch( blockEditorStore ).resetBlocks( [] );
+ } );
+ } );
+
+ it( 'resolves a product template slug into a numeric product location', async () => {
+ primeEntityResolution( 'postType', 'product', 'cap', [
+ { id: 71, slug: 'cap' },
+ ] );
+
+ const { result } = renderHook( () =>
+ useGetLocation(
+ { templateSlug: 'single-product-cap' },
+ 'standalone-product-template'
+ )
+ );
+
+ expect( result.current ).toEqual( {
+ type: LocationType.Product,
+ sourceData: { productId: null },
+ } );
+ await waitFor( () =>
+ expect( result.current ).toEqual( {
+ type: LocationType.Product,
+ sourceData: { productId: 71 },
+ } )
+ );
+ } );
+
+ it( 'resolves category location into taxonomy request', async () => {
+ primeEntityResolution( 'taxonomy', 'product_cat', 'hoodies', [
+ { id: 81, slug: 'hoodies' },
+ ] );
+
+ const { result } = renderHook( () =>
+ useGetLocation(
+ { templateSlug: 'taxonomy-product_cat-hoodies' },
+ 'category-product-template'
+ )
+ );
+
+ expect( result.current ).toEqual( {
+ type: LocationType.Archive,
+ sourceData: { taxonomy: 'product_cat', termId: null },
+ } );
+ await waitFor( () =>
+ expect( result.current ).toEqual( {
+ type: LocationType.Archive,
+ sourceData: { taxonomy: 'product_cat', termId: 81 },
+ } )
+ );
+ } );
+
+ it( 'resolves a tag template slug into a numeric taxonomy location', async () => {
+ primeEntityResolution( 'taxonomy', 'product_tag', 'recommended', [
+ { id: 91, slug: 'recommended' },
+ ] );
+
+ const { result } = renderHook( () =>
+ useGetLocation(
+ { templateSlug: 'taxonomy-product_tag-recommended' },
+ 'tag-product-template'
+ )
+ );
+
+ await waitFor( () =>
+ expect( result.current ).toEqual( {
+ type: LocationType.Archive,
+ sourceData: { taxonomy: 'product_tag', termId: 91 },
+ } )
+ );
+ } );
+
+ it.each( [
+ {
+ caseName: 'a generic category template',
+ context: { templateSlug: 'taxonomy-product_cat' },
+ expected: {
+ type: LocationType.Archive,
+ sourceData: { taxonomy: 'product_cat', termId: null },
+ },
+ },
+ {
+ caseName: 'a generic tag template',
+ context: { templateSlug: 'taxonomy-product_tag' },
+ expected: {
+ type: LocationType.Archive,
+ sourceData: { taxonomy: 'product_tag', termId: null },
+ },
+ },
+ {
+ caseName: 'a generic product attribute template',
+ context: { templateSlug: 'taxonomy-product_attribute' },
+ expected: {
+ type: LocationType.Archive,
+ sourceData: { taxonomy: null, termId: null },
+ },
+ },
+ {
+ caseName: 'an ordinary post',
+ context: { templateSlug: 'single' },
+ expected: { type: LocationType.Site, sourceData: {} },
+ },
+ ] )( 'returns the context for $caseName', ( { context, expected } ) => {
+ const { result } = renderHook( () =>
+ useGetLocation( context, 'generic-product-template' )
+ );
+
+ expect( result.current ).toEqual( expected );
+ } );
+
+ it( 'gives Single Product block context precedence over the template', () => {
+ const productTemplate = createBlock( 'woocommerce/product-template' );
+ const singleProduct = createBlock( 'woocommerce/single-product', {}, [
+ productTemplate,
+ ] );
+ act( () => {
+ dispatch( blockEditorStore ).resetBlocks( [ singleProduct ] );
+ } );
+
+ const { result } = renderHook( () =>
+ useGetLocation(
+ {
+ postId: 101,
+ templateSlug: 'taxonomy-product_cat',
+ },
+ productTemplate.clientId
+ )
+ );
+
+ expect( result.current ).toEqual( {
+ type: LocationType.Product,
+ sourceData: { productId: 101 },
+ } );
+ } );
+} );
+
+describe( 'useProductCollectionQueryContext', () => {
+ afterEach( () => {
+ act( () => {
+ dispatch( blockEditorStore ).resetBlocks( [] );
+ } );
+ } );
+
+ it( 'includes only requested truthy Product Collection attributes', () => {
+ const productTemplate = createBlock( 'woocommerce/product-template' );
+ const productCollection = createBlock(
+ 'woocommerce/product-collection',
+ {
+ collection: 'woocommerce/product-collection/on-sale',
+ forcePageReload: false,
+ },
+ [ productTemplate ]
+ );
+ act( () => {
+ dispatch( blockEditorStore ).resetBlocks( [ productCollection ] );
+ } );
+
+ const { result, rerender } = renderHook(
+ ( { includes } ) =>
+ useProductCollectionQueryContext( {
+ clientId: productTemplate.clientId,
+ queryContextIncludes: includes,
+ } ),
+ {
+ initialProps: {
+ includes: [ 'collection' ],
+ },
+ }
+ );
+
+ expect( result.current ).toEqual( {
+ collection: 'woocommerce/product-collection/on-sale',
+ } );
+
+ rerender( { includes: [ 'forcePageReload' ] } );
+ expect( result.current ).toEqual( {} );
+
+ rerender( { includes: [] } );
+ expect( result.current ).toEqual( {} );
+ } );
+
+ it( 'returns null outside Product Collection', () => {
+ const paragraph = createBlock( 'core/paragraph' );
+ act( () => {
+ dispatch( blockEditorStore ).resetBlocks( [ paragraph ] );
+ } );
+
+ const { result } = renderHook( () =>
+ useProductCollectionQueryContext( {
+ clientId: paragraph.clientId,
+ queryContextIncludes: [ 'collection' ],
+ } )
+ );
+
+ expect( result.current ).toBeNull();
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/changelog/testops-234-product-collection-frontend b/plugins/woocommerce/client/blocks/changelog/testops-234-product-collection-frontend
new file mode 100644
index 00000000000..3bbb9e5572a
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/changelog/testops-234-product-collection-frontend
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Reduce Product Collection frontend E2E tests from 37 to 20; PHPUnit owns the renderer's state and route parity, Jest owns the editor contracts and the product template's request, and one new browser title keeps the empty-collection and No Results render.
+
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/extensibility-events.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/extensibility-events.block_theme.spec.ts
index b08b2f8ba11..99563d84edb 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/extensibility-events.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/extensibility-events.block_theme.spec.ts
@@ -54,34 +54,6 @@ test.describe( 'Product Collection: Extensibility Events', () => {
.toBe( 2 );
} );
- test( 'emits one wc-blocks_product_list_rendered event per block', async ( {
- pageObject,
- page,
- } ) => {
- // Adding three blocks in total
- await pageObject.createNewPostAndInsertBlock();
- await pageObject.insertProductCollection();
- await pageObject.chooseCollectionInPost();
- await pageObject.insertProductCollection();
- await pageObject.chooseCollectionInPost();
-
- await page.addInitScript( () => {
- let eventFired = 0;
- window.document.addEventListener(
- 'wc-blocks_product_list_rendered',
- () => {
- window.eventFired = ++eventFired;
- }
- );
- } );
-
- await pageObject.publishAndGoToFrontend();
-
- await expect
- .poll( async () => await page.evaluate( 'window.eventFired' ) )
- .toBe( 3 );
- } );
-
test.describe( 'wc-blocks_viewed_product is emitted', () => {
let promise: Promise< { productId?: number; collection?: string } >;
@@ -109,19 +81,6 @@ test.describe( 'Product Collection: Extensibility Events', () => {
await pageObject.publishAndGoToFrontend();
} );
- test( 'when Product Image is clicked', async ( { page } ) => {
- await page
- .locator( '[data-block-name="woocommerce/product-image"]' )
- .nth( 0 )
- .click();
-
- const { collection, productId } = await promise;
- expect( collection ).toEqual(
- 'woocommerce/product-collection/featured'
- );
- expect( productId ).toEqual( expect.any( Number ) );
- } );
-
test( 'when Product Title is clicked', async ( { page } ) => {
await page
.locator( '.wp-block-post-title' )
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/product-collection.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/product-collection.block_theme.spec.ts
index 2db05a89819..49c6015a217 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/product-collection.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/product-collection/product-collection.block_theme.spec.ts
@@ -2,13 +2,7 @@
* External dependencies
*/
import { Request } from '@playwright/test';
-import {
- test as base,
- expect,
- wpCLI,
- BASE_URL,
- BLOCK_THEME_SLUG,
-} from '@woocommerce/e2e-utils';
+import { test as base, expect, wpCLI, BASE_URL } from '@woocommerce/e2e-utils';
/**
* Internal dependencies
@@ -30,25 +24,6 @@ const test = base.extend< { pageObject: ProductCollectionPage } >( {
} );
test.describe( 'Product Collection', () => {
- test( 'Renders product collection block correctly with 9 items', async ( {
- pageObject,
- } ) => {
- await pageObject.createNewPostAndInsertBlock();
- await expect( pageObject.products ).toHaveCount( 9 );
- await expect( pageObject.productImages ).toHaveCount( 9 );
- await expect( pageObject.productTitles ).toHaveCount( 9 );
- await expect( pageObject.productPrices ).toHaveCount( 9 );
- await expect( pageObject.addToCartButtons ).toHaveCount( 9 );
-
- await pageObject.publishAndGoToFrontend();
-
- await expect( pageObject.products ).toHaveCount( 9 );
- await expect( pageObject.productImages ).toHaveCount( 9 );
- await expect( pageObject.productTitles ).toHaveCount( 9 );
- await expect( pageObject.productPrices ).toHaveCount( 9 );
- await expect( pageObject.addToCartButtons ).toHaveCount( 9 );
- } );
-
test( 'Can be migrated to from Products (Deprecated) block', async ( {
page,
editor,
@@ -87,175 +62,106 @@ test.describe( 'Product Collection', () => {
).toBeVisible();
} );
- test.describe( 'when no results are found', () => {
- test.beforeEach( async ( { admin } ) => {
- await admin.createNewPost();
- } );
-
- test( 'does not render', async ( { page, editor, pageObject } ) => {
- await pageObject.insertProductCollection();
- await pageObject.chooseCollectionInPost( 'featured' );
- await pageObject.addFilter( 'Price Range' );
- await pageObject.setPriceRange( {
- max: '1',
- } );
-
- const featuredBlock = editor.canvas.getByLabel( 'Block: Featured' );
-
- await expect(
- featuredBlock.getByText( 'Featured products' )
- ).toBeVisible();
- // The "No results found" info is rendered in editor for all collections.
- await expect(
- featuredBlock.getByText( 'No products to display' )
- ).toBeVisible();
-
- await pageObject.publishAndGoToFrontend();
-
- const content = page.locator( 'main' );
-
- await expect( content ).not.toContainText( 'Featured products' );
- await expect( content ).not.toContainText(
- 'No products to display'
- );
- } );
-
- // This test ensures the runtime render state is correctly reset for
- // each block.
- test( 'does not prevent subsequent blocks from render', async ( {
- page,
- pageObject,
- } ) => {
- await pageObject.insertProductCollection();
- await pageObject.chooseCollectionInPost( 'featured' );
- await pageObject.addFilter( 'Price Range' );
- await pageObject.setPriceRange( {
- max: '1',
- } );
-
- await pageObject.insertProductCollection();
- await pageObject.chooseCollectionInPost( 'topRated' );
-
- await pageObject.refreshLocators( 'editor' );
- await expect( pageObject.products ).toHaveCount( 5 );
-
- await pageObject.publishAndGoToFrontend();
-
- await pageObject.refreshLocators( 'frontend' );
- await expect( pageObject.products ).toHaveCount( 5 );
- await expect( page.locator( 'main' ) ).not.toContainText(
- 'Featured products'
- );
- } );
+ test( 'renders all Product Elements after save, reload, and frontend navigation', async ( {
+ page,
+ editor,
+ pageObject,
+ } ) => {
+ await pageObject.createNewPostAndInsertBlock();
+ await expect(
+ editor.canvas.locator( '[data-testid="product-image"]:visible' )
+ ).toHaveCount( 9 );
- test( 'renders if No Results block is present', async ( {
- page,
- editor,
- pageObject,
- } ) => {
- await pageObject.insertProductCollection();
- await pageObject.chooseCollectionInPost( 'productCatalog' );
- await pageObject.addFilter( 'Price Range' );
- await pageObject.setPriceRange( {
- max: '1',
- } );
+ await pageObject.insertProductElements();
+ const postId = await editor.publishPost();
+ await page.reload();
+ await expect(
+ editor.canvas.getByLabel( 'Block: Product Collection' ).first()
+ ).toBeVisible();
+ await pageObject.refreshLocators( 'editor' );
+ await expect( pageObject.products ).toHaveCount( 9 );
- await expect(
- editor.canvas.getByText( 'No results found' )
- ).toBeVisible();
+ await page.goto( `/?p=${ postId }` );
+ await pageObject.refreshLocators( 'frontend' );
- await pageObject.publishAndGoToFrontend();
+ await expect( pageObject.products ).toHaveCount( 9 );
+ await expect( pageObject.productImages ).toHaveCount( 9 );
+ await expect( pageObject.productTitles ).toHaveCount( 9 );
+ await expect( pageObject.productPrices ).toHaveCount( 9 );
+ await expect( pageObject.addToCartButtons ).toHaveCount( 9 );
- await expect( page.getByText( 'No results found' ) ).toBeVisible();
+ const beanie = pageObject.products.filter( {
+ has: page
+ .locator( SELECTORS.productTitle )
+ .filter( { hasText: /^Beanie$/ } ),
} );
+ await expect( beanie ).toHaveCount( 1 );
+ await expect(
+ beanie.locator( SELECTORS.productImage.onFrontend )
+ ).toHaveCount( 1 );
+ await expect(
+ beanie.locator( SELECTORS.addToCartButton.onFrontend )
+ ).toContainText( 'Add to cart' );
+
+ for ( const content of [
+ 'Beanie',
+ '$20.00 Original price was: $20.00.$18.00Current price is: $18.00.',
+ 'woo-beanie',
+ 'This is a simple product.',
+ 'Accessories',
+ 'Recommended',
+ 'SaleProduct on sale',
+ ] ) {
+ await expect( beanie ).toContainText( content );
+ }
} );
- test.describe( 'Renders correctly with all Product Elements', () => {
- const expectedProductContent = [
- 'Beanie', // core/post-title
- '$20.00 Original price was: $20.00.$18.00Current price is: $18.00.', // woocommerce/product-price
- 'woo-beanie', // woocommerce/product-sku
- 'This is a simple product.', // core/post-excerpt
- 'Accessories', // core/post-terms - product_cat
- 'Recommended', // core/post-terms - product_tag
- 'SaleProduct on sale', // woocommerce/product-sale-badge
- 'Add to cart', // woocommerce/product-button
- ];
-
- test( 'In a post', async ( { page, editor, pageObject } ) => {
- await pageObject.createNewPostAndInsertBlock();
-
- await expect(
- editor.canvas.locator( '[data-testid="product-image"]:visible' )
- ).toHaveCount( 9 );
-
- await pageObject.insertProductElements();
- await pageObject.publishAndGoToFrontend();
-
- for ( const content of expectedProductContent ) {
- await expect(
- page.locator( '.wc-block-product-template' )
- ).toContainText( content );
- }
- } );
-
- test( 'In a Product Archive (Product Catalog)', async ( {
- page,
- editor,
- pageObject,
- } ) => {
- await pageObject.goToEditorTemplate();
-
- await expect(
- editor.canvas.locator( '[data-testid="product-image"]:visible' )
- ).toHaveCount( 16 );
-
- await pageObject.insertProductElements();
- await editor.saveSiteEditorEntities( {
- isOnlyCurrentEntityDirty: true,
- } );
- await pageObject.goToProductCatalogFrontend();
+ // The suite's only browser proof that an empty collection renders nothing on a
+ // published page, and that a No Results block still reaches a shopper.
+ test( 'Empty collection renders nothing, and No Results renders where that block is present', async ( {
+ page,
+ admin,
+ editor,
+ pageObject,
+ } ) => {
+ await admin.createNewPost();
- // Workaround for the issue with the product change not being
- // reflected in the frontend yet.
- try {
- await page.getByText( 'woo-beanie' ).waitFor();
- } catch ( _error ) {
- await page.reload();
- }
-
- for ( const content of expectedProductContent ) {
- await expect(
- page.locator( '.wc-block-product-template' )
- ).toContainText( content );
- }
- } );
+ // The collection with a No Results block goes first: its render state must not
+ // leak into the empty collection that follows it.
+ await pageObject.insertProductCollection();
+ await pageObject.chooseCollectionInPost( 'productCatalog' );
+ await pageObject.addFilter( 'Price Range' );
+ await pageObject.setPriceRange( { max: '1' } );
+ await expect(
+ editor.canvas.getByText( 'No results found' )
+ ).toBeVisible();
- test( 'On a Home Page', async ( { page, editor, pageObject } ) => {
- await pageObject.goToHomePageAndInsertCollection();
+ await pageObject.insertProductCollection();
+ await pageObject.chooseCollectionInPost( 'featured' );
+ await pageObject.addFilter( 'Price Range' );
+ await pageObject.setPriceRange( { max: '1' } );
- await expect(
- editor.canvas.locator( '[data-testid="product-image"]:visible' )
- ).toHaveCount( 9 );
+ const featuredBlock = editor.canvas.getByLabel( 'Block: Featured' );
+ await expect(
+ featuredBlock.getByText( 'Featured products' )
+ ).toBeVisible();
+ await expect(
+ featuredBlock.getByText( 'No products to display' )
+ ).toBeVisible();
- await pageObject.insertProductElements();
- await editor.saveSiteEditorEntities( {
- isOnlyCurrentEntityDirty: true,
- } );
- await pageObject.goToHomePageFrontend();
+ await pageObject.publishAndGoToFrontend();
- for ( const content of expectedProductContent ) {
- await expect(
- page.locator( '.wc-block-product-template' )
- ).toContainText( content );
- }
- } );
+ const content = page.locator( 'main' );
+ await expect( content ).not.toContainText( 'Featured products' );
+ await expect( content ).not.toContainText( 'No products to display' );
+ await expect( page.getByText( 'No results found' ) ).toBeVisible();
} );
test.describe( 'Responsive', () => {
test.beforeEach( async ( { pageObject } ) => {
await pageObject.createNewPostAndInsertBlock();
} );
+
test( 'Block with shrink columns ENABLED correctly displays as grid', async ( {
pageObject,
} ) => {
@@ -263,8 +169,6 @@ test.describe( 'Product Collection', () => {
const productTemplate = pageObject.productTemplate;
await expect( productTemplate ).toHaveCSS( 'display', 'grid' );
- // By default there should be 3 columns, so grid-template-columns
- // should be compiled to three values
await expect( productTemplate ).toHaveCSS(
'grid-template-columns',
/^\d+(\.\d+)?px \d+(\.\d+)?px \d+(\.\d+)?px$/
@@ -272,11 +176,9 @@ test.describe( 'Product Collection', () => {
await pageObject.setViewportSize( {
height: 667,
- width: 390, // iPhone 12 Pro
+ width: 390,
} );
- // Verifies grid-template-columns compiles to two numbers,
- // which means there are two columns on mobile.
await expect( productTemplate ).toHaveCSS(
'grid-template-columns',
/^\d+(\.\d+)?px \d+(\.\d+)?px$/
@@ -290,13 +192,9 @@ test.describe( 'Product Collection', () => {
await pageObject.publishAndGoToFrontend();
const productTemplate = pageObject.productTemplate;
-
await expect( productTemplate ).not.toHaveCSS( 'display', 'grid' );
const firstProduct = pageObject.products.first();
-
- // In the original viewport size, we expect the product width to be less than the parent width
- // because we will have more than 1 column
let productSize = await firstProduct.boundingBox();
let parentSize = await firstProduct
.locator( 'xpath=..' )
@@ -307,11 +205,9 @@ test.describe( 'Product Collection', () => {
await pageObject.setViewportSize( {
height: 667,
- width: 390, // iPhone 12 Pro
+ width: 390,
} );
- // In the smaller viewport size, we expect the product width to be (approximately) the same as the parent width
- // because we will have only 1 column
productSize = await firstProduct.boundingBox();
parentSize = await firstProduct.locator( 'xpath=..' ).boundingBox();
expect( productSize?.width ).toBeCloseTo(
@@ -320,37 +216,28 @@ test.describe( 'Product Collection', () => {
} );
} );
- test.describe( 'With other blocks', () => {
- test( 'In Single Product block', async ( { admin, pageObject } ) => {
- await admin.createNewPost();
- await pageObject.insertProductCollectionInSingleProductBlock();
- await pageObject.chooseCollectionInPost( 'featured' );
- await pageObject.refreshLocators( 'editor' );
-
- const featuredProducts = [
- 'Cap',
- 'Hoodie with Zipper',
- 'Sunglasses',
- 'V-Neck T-Shirt',
- ];
- const featuredProductsPrices = [
- 'Previous price:$18.00Discounted price:$16.00',
- '$45.00',
- '$90.00',
- 'Price between $15.00 and $20.00$15.00 — $20.00',
- ];
-
- await expect( pageObject.products ).toHaveCount( 4 );
- // This verifies if Core's block context is provided
- await expect( pageObject.productTitles ).toHaveText(
- featuredProducts
- );
- // This verifies if Blocks's product context is provided
- await expect( pageObject.productPrices ).toHaveText(
- featuredProductsPrices
- );
- } );
+ test( 'In Single Product block', async ( { admin, pageObject } ) => {
+ await admin.createNewPost();
+ await pageObject.insertProductCollectionInSingleProductBlock();
+ await pageObject.chooseCollectionInPost( 'featured' );
+ await pageObject.refreshLocators( 'editor' );
+
+ await expect( pageObject.products ).toHaveCount( 4 );
+ await expect( pageObject.productTitles ).toHaveText( [
+ 'Cap',
+ 'Hoodie with Zipper',
+ 'Sunglasses',
+ 'V-Neck T-Shirt',
+ ] );
+ await expect( pageObject.productPrices ).toHaveText( [
+ 'Previous price:$18.00Discounted price:$16.00',
+ '$45.00',
+ '$90.00',
+ 'Price between $15.00 and $20.00$15.00 — $20.00',
+ ] );
+ } );
+ test.describe( 'With other blocks', () => {
test( 'With multiple Pagination blocks', async ( {
admin,
editor,
@@ -375,293 +262,126 @@ test.describe( 'Product Collection', () => {
} );
} );
- test.describe( 'Location is recognized', () => {
- const filterRequest = ( request: Request ) => {
- const url = request.url();
+ test( 'resolves specific product and taxonomy requests', async ( {
+ admin,
+ page,
+ pageObject,
+ editor,
+ wpCoreVersion,
+ } ) => {
+ const getLocationParams = ( request: Request ) =>
+ new URL( request.url() ).searchParams;
+ const isProductCollectionRequest = ( request: Request ) => {
+ const params = getLocationParams( request );
return (
- url.includes( 'wp/v2/product' ) &&
- url.includes( 'isProductCollectionBlock=true' )
+ request.url().includes( 'wp/v2/product' ) &&
+ params.get( 'isProductCollectionBlock' ) === 'true'
);
};
-
- const filterProductRequest = ( request: Request ) => {
- const url = request.url();
- const searchParams = new URLSearchParams( request.url() );
-
+ const isSpecificProductRequest = ( request: Request ) => {
+ const params = getLocationParams( request );
return (
- url.includes( 'wp/v2/product' ) &&
- searchParams.get( 'isProductCollectionBlock' ) === 'true' &&
- !! searchParams.get(
- `productCollectionLocation[sourceData][productId]`
+ isProductCollectionRequest( request ) &&
+ params.get( 'productCollectionLocation[type]' ) === 'product' &&
+ !! params.get(
+ 'productCollectionLocation[sourceData][productId]'
)
);
};
-
- const getLocationDetailsFromRequest = (
- request: Request,
- locationType?: string
- ) => {
- const searchParams = new URLSearchParams( request.url() );
-
- if ( locationType === 'product' ) {
- return {
- type: searchParams.get( 'productCollectionLocation[type]' ),
- productId: searchParams.get(
- `productCollectionLocation[sourceData][productId]`
- ),
- };
- }
-
- if ( locationType === 'archive' ) {
- return {
- type: searchParams.get( 'productCollectionLocation[type]' ),
- taxonomy: searchParams.get(
- `productCollectionLocation[sourceData][taxonomy]`
- ),
- termId: searchParams.get(
- `productCollectionLocation[sourceData][termId]`
- ),
- };
- }
-
- return {
- type: searchParams.get( 'productCollectionLocation[type]' ),
- sourceData: searchParams.get(
- `productCollectionLocation[sourceData]`
- ),
- };
- };
-
- test( 'as product in specific Single Product template', async ( {
- admin,
- page,
- pageObject,
- editor,
- wpCoreVersion,
- } ) => {
- await admin.visitSiteEditor( { path: '/wp_template' } );
-
- await page
- .getByRole( 'button', {
- name:
- wpCoreVersion >= 6.8
- ? 'Add Template'
- : 'Add New Template',
- } )
- .click();
-
- await page
- .getByRole( 'button', { name: 'Single Item: Product' } )
- .click();
-
- await page
- .getByRole( 'option', {
- name: `Cap ${ BASE_URL }/product/cap/`,
- } )
- .click();
- await page
- .getByRole( 'button', {
- name: 'Skip',
- } )
- .click();
-
- await editor.insertBlockUsingGlobalInserter(
- pageObject.BLOCK_NAME
- );
-
- await editor.closeGlobalBlockInserter();
-
- const locationRequestPromise =
- page.waitForRequest( filterProductRequest );
- await pageObject.chooseCollectionInTemplate( 'featured' );
- const locationRequest = await locationRequestPromise;
-
- const { type, productId } = getLocationDetailsFromRequest(
- locationRequest,
- 'product'
- );
-
- expect( type ).toBe( 'product' );
- expect( productId ).toBeTruthy();
- } );
- test( 'as category in Products by Category template', async ( {
- admin,
- editor,
- pageObject,
- page,
- } ) => {
- await admin.visitSiteEditor( {
- postType: 'wp_template',
- } );
- await editor.createTemplate( {
- templateName: 'Products by Category',
- } );
- await editor.insertBlockUsingGlobalInserter(
- pageObject.BLOCK_NAME
- );
-
- const locationRequestPromise = page.waitForRequest( filterRequest );
- await pageObject.chooseCollectionInTemplate( 'featured' );
- const locationRequest = await locationRequestPromise;
- const { type, taxonomy, termId } = getLocationDetailsFromRequest(
- locationRequest,
- 'archive'
- );
-
- expect( type ).toBe( 'archive' );
- expect( taxonomy ).toBe( 'product_cat' );
- // Field is sent as a null but browser converts it to empty string
- expect( termId ).toBe( '' );
- } );
-
- test( 'as tag in Products by Tag template', async ( {
- admin,
- editor,
- pageObject,
- page,
- } ) => {
- await admin.visitSiteEditor( {
- postType: 'wp_template',
- } );
- await editor.createTemplate( {
- templateName: 'Products by Tag',
- } );
- await editor.insertBlockUsingGlobalInserter(
- pageObject.BLOCK_NAME
- );
-
- const locationRequestPromise = page.waitForRequest( filterRequest );
- await pageObject.chooseCollectionInTemplate( 'featured' );
- const locationRequest = await locationRequestPromise;
- const { type, taxonomy, termId } = getLocationDetailsFromRequest(
- locationRequest,
- 'archive'
- );
-
- expect( type ).toBe( 'archive' );
- expect( taxonomy ).toBe( 'product_tag' );
- // Field is sent as a null but browser converts it to empty string
- expect( termId ).toBe( '' );
- } );
-
- test( 'as site in post', async ( {
- admin,
- editor,
- pageObject,
- page,
- } ) => {
- await admin.createNewPost();
- await editor.insertBlockUsingGlobalInserter(
- pageObject.BLOCK_NAME
- );
-
- const locationRequestPromise = page.waitForRequest( filterRequest );
- await pageObject.chooseCollectionInPost( 'featured' );
- const locationRequest = await locationRequestPromise;
- const { type, sourceData } =
- getLocationDetailsFromRequest( locationRequest );
-
- expect( type ).toBe( 'site' );
- // Field is not sent at all. URLSearchParams get method returns a null
- // if field is not available.
- expect( sourceData ).toBe( null );
- } );
-
- test( 'as product in Single Product block in post', async ( {
- admin,
- pageObject,
- page,
- } ) => {
- await admin.createNewPost();
- await pageObject.insertProductCollectionInSingleProductBlock();
- const locationRequestPromise =
- page.waitForRequest( filterProductRequest );
- await pageObject.chooseCollectionInPost( 'featured' );
- const locationRequest = await locationRequestPromise;
- const { type, productId } = getLocationDetailsFromRequest(
- locationRequest,
- 'product'
+ const isSpecificCategoryRequest = ( request: Request ) => {
+ const params = getLocationParams( request );
+ return (
+ isProductCollectionRequest( request ) &&
+ params.get( 'productCollectionLocation[type]' ) === 'archive' &&
+ params.get(
+ 'productCollectionLocation[sourceData][taxonomy]'
+ ) === 'product_cat' &&
+ !! params.get( 'productCollectionLocation[sourceData][termId]' )
);
+ };
- expect( type ).toBe( 'product' );
- expect( productId ).toBeTruthy();
- } );
- } );
-
- test.describe( 'Query Context in Editor', () => {
- test( 'Collections: collection should be present in query context', async ( {
- pageObject,
- } ) => {
- const url = await pageObject.setupAndFetchQueryContextURL( {
- collection: 'onSale',
- } );
-
- const collectionName = url.searchParams.get(
- 'productCollectionQueryContext[collection]'
- );
- expect( collectionName ).toBeTruthy();
- expect( collectionName ).toBe(
- 'woocommerce/product-collection/on-sale'
- );
+ await admin.visitSiteEditor( { path: '/wp_template' } );
+ await page
+ .getByRole( 'button', {
+ name:
+ wpCoreVersion >= 6.8 ? 'Add Template' : 'Add New Template',
+ } )
+ .click();
+ await page
+ .getByRole( 'button', { name: 'Single Item: Product' } )
+ .click();
+ await page
+ .getByRole( 'option', {
+ name: `Cap ${ BASE_URL }/product/cap/`,
+ } )
+ .click();
+ await page.getByRole( 'button', { name: 'Skip' } ).click();
+ await editor.insertBlockUsingGlobalInserter( pageObject.BLOCK_NAME );
+ await editor.closeGlobalBlockInserter();
+
+ const productRequestPromise = page.waitForRequest(
+ isSpecificProductRequest
+ );
+ await pageObject.chooseCollectionInTemplate( 'featured' );
+ const productParams = getLocationParams( await productRequestPromise );
+ expect(
+ productParams.get(
+ 'productCollectionLocation[sourceData][productId]'
+ )
+ ).toMatch( /^[1-9]\d*$/ );
+ expect(
+ productParams.get(
+ 'productCollectionLocation[sourceData][taxonomy]'
+ )
+ ).toBeNull();
+ expect(
+ productParams.get( 'productCollectionLocation[sourceData][termId]' )
+ ).toBeNull();
+ await editor.saveSiteEditorEntities( {
+ isOnlyCurrentEntityDirty: true,
} );
- } );
- test.describe( 'Preview mode in generic archive templates', () => {
- const genericArchiveTemplates = [
- {
- name: 'Products by Tag',
- path: `${ BLOCK_THEME_SLUG }//taxonomy-product_tag`,
- needsCreation: true,
- },
- {
- name: 'Products by Category',
- path: `${ BLOCK_THEME_SLUG }//taxonomy-product_cat`,
- needsCreation: true,
- },
- {
- name: 'Products by Attribute',
- path: `${ BLOCK_THEME_SLUG }//taxonomy-product_attribute`,
- },
- ];
-
- genericArchiveTemplates.forEach( ( { name, path, needsCreation } ) => {
- test( `${ name } template`, async ( {
- admin,
- editor,
- pageObject,
- } ) => {
- if ( needsCreation ) {
- await admin.visitSiteEditor( {
- postType: 'wp_template',
- } );
- await editor.createTemplate( {
- templateName: name,
- } );
- } else {
- await pageObject.goToEditorTemplate( path );
- }
- await pageObject.focusProductCollection();
-
- const previewButtonLocator = editor.canvas.getByTestId(
- SELECTORS.previewButtonTestID
- );
-
- // The preview button should be visible
- await expect( previewButtonLocator ).toBeVisible();
-
- // The preview button should be hidden when the block is not selected.
- // Changing focus.
- const otherBlockSelector = editor.canvas.getByLabel(
- 'Block: Archive Title'
- );
- await editor.selectBlocks( otherBlockSelector );
- await expect( previewButtonLocator ).toBeHidden();
-
- // Preview button should be visible again when the block is selected.
- await pageObject.focusProductCollection();
- await expect( previewButtonLocator ).toBeVisible();
- } );
- } );
+ await admin.visitSiteEditor( { path: '/wp_template' } );
+ const categoriesLoaded = page.waitForResponse( ( response ) =>
+ response.url().includes( 'wp-json/wp/v2/product_cat' )
+ );
+ await page
+ .getByRole( 'button', {
+ name:
+ wpCoreVersion >= 6.8 ? 'Add Template' : 'Add New Template',
+ } )
+ .click();
+ await categoriesLoaded;
+ await page
+ .getByRole( 'button', { name: 'Products by Category' } )
+ .click();
+ await page
+ .getByRole( 'button', { name: 'For a specific item' } )
+ .click();
+ await page.getByRole( 'option', { name: 'Hoodies' } ).click();
+
+ const categoryRequestPromise = page.waitForRequest(
+ isSpecificCategoryRequest
+ );
+ await page.getByRole( 'option', { name: 'Fallback content' } ).click();
+ const categoryParams = getLocationParams(
+ await categoryRequestPromise
+ );
+ expect(
+ categoryParams.get(
+ 'productCollectionLocation[sourceData][termId]'
+ )
+ ).toMatch( /^[1-9]\d*$/ );
+ expect(
+ categoryParams.get(
+ 'productCollectionLocation[sourceData][taxonomy]'
+ )
+ ).toBe( 'product_cat' );
+ expect(
+ categoryParams.get(
+ 'productCollectionLocation[sourceData][productId]'
+ )
+ ).toBeNull();
} );
// Tests for regressions of https://github.com/woocommerce/woocommerce/pull/47994
@@ -735,51 +455,34 @@ test.describe( 'Product Collection', () => {
} );
const templates = [
- // This test is disabled because archives are disabled for attributes by default. This can be uncommented when this is toggled on.
- //'taxonomy-product_attribute': {
- // templateTitle: 'Product Attribute',
- // slug: 'taxonomy-product_attribute',
- // frontendPage: '/product-attribute/color/',
- // legacyBlockName: 'woocommerce/legacy-template',
- //},
{
templateTitle: 'Product Category',
slug: 'taxonomy-product_cat',
frontendPage: '/product-category/music/',
legacyBlockName: 'woocommerce/legacy-template',
- expectedProductsCount: 2,
},
{
templateTitle: 'Product Tag',
slug: 'taxonomy-product_tag',
frontendPage: '/product-tag/recommended/',
legacyBlockName: 'woocommerce/legacy-template',
- expectedProductsCount: 2,
},
{
templateTitle: 'Product Catalog',
slug: 'archive-product',
frontendPage: '/shop/',
legacyBlockName: 'woocommerce/legacy-template',
- expectedProductsCount: 16,
},
{
templateTitle: 'Product Search Results',
slug: 'product-search-results',
frontendPage: '/?s=shirt&post_type=product',
legacyBlockName: 'woocommerce/legacy-template',
- expectedProductsCount: 3,
},
];
templates.forEach(
- ( {
- templateTitle,
- slug,
- frontendPage,
- legacyBlockName,
- expectedProductsCount,
- } ) => {
+ ( { templateTitle, slug, frontendPage, legacyBlockName } ) => {
test.describe( `${ templateTitle } template`, () => {
test( 'Product Collection block matches with classic template block', async ( {
pageObject,
@@ -789,11 +492,13 @@ test.describe( 'Product Collection', () => {
page,
} ) => {
await pageObject.refreshLocators( 'frontend' );
-
await page.goto( frontendPage );
const productCollectionProductNames =
await pageObject.getProductNames();
+ expect(
+ productCollectionProductNames.length
+ ).toBeGreaterThan( 0 );
const template = await requestUtils.createTemplate(
'wp_template',
@@ -809,27 +514,21 @@ test.describe( 'Product Collection', () => {
postType: 'wp_template',
canvas: 'edit',
} );
-
await expect(
editor.getCustomHtmlBlockContentLocator( 'placeholder' )
).toBeVisible();
-
await editor.insertBlock( { name: legacyBlockName } );
-
await editor.saveSiteEditorEntities( {
isOnlyCurrentEntityDirty: true,
} );
await page.goto( frontendPage );
-
const classicProducts = page.locator(
'.woocommerce-loop-product__title'
);
-
- await expect( classicProducts ).toHaveCount(
- expectedProductsCount
+ expect( await classicProducts.count() ).toBeGreaterThan(
+ 0
);
-
const classicProductsNames =
await classicProducts.allTextContents();
@@ -884,116 +583,4 @@ test.describe( 'Product Collection', () => {
);
} );
} );
-
- test.describe( 'Editor: In taxonomies templates', () => {
- test( 'Products by specific category template displays products from this category', async ( {
- admin,
- page,
- editor,
- wpCoreVersion,
- } ) => {
- await wpCLI(
- 'option update woocommerce_default_catalog_orderby price'
- );
-
- const expectedProducts = [
- 'Hoodie',
- 'Hoodie with Logo',
- 'Hoodie with Zipper',
- ];
-
- await admin.visitSiteEditor( { path: '/wp_template' } );
-
- await page
- .getByRole( 'button', {
- name:
- wpCoreVersion >= 6.8
- ? 'Add Template'
- : 'Add New Template',
- } )
- .click();
-
- // We need to wait for Product categories to load. Otherwise clicking
- // on Products by Category might direct the user to the generic
- // template.
- await admin.page.waitForResponse( ( response ) => {
- return response.url().includes( 'wp-json/wp/v2/product_cat' );
- } );
-
- await page
- .getByRole( 'button', { name: 'Products by Category' } )
- .click();
- await page
- .getByRole( 'button', { name: 'For a specific item' } )
- .click();
- await page
- .getByRole( 'option', {
- name: `Hoodies`,
- } )
- .click();
- await page
- .getByRole( 'option', { name: 'Fallback content' } )
- .click();
-
- const products = editor.canvas.getByLabel( 'Block: Title' );
-
- await expect( products ).toHaveText( expectedProducts );
-
- await wpCLI(
- 'option update woocommerce_default_catalog_orderby menu_order'
- );
- } );
- test( 'Products by specific tag template displays products from this tag', async ( {
- admin,
- page,
- editor,
- wpCoreVersion,
- } ) => {
- await wpCLI(
- 'option update woocommerce_default_catalog_orderby price'
- );
-
- const expectedProducts = [ 'Beanie', 'Hoodie' ];
-
- await admin.visitSiteEditor( { path: '/wp_template' } );
-
- await page
- .getByRole( 'button', {
- name:
- wpCoreVersion >= 6.8
- ? 'Add Template'
- : 'Add New Template',
- } )
- .click();
-
- // We need to wait for Product tags to load. Otherwise clicking
- // on Products by Tag might direct the user to the generic template.
- await admin.page.waitForResponse( ( response ) => {
- return response.url().includes( 'wp-json/wp/v2/product_tag' );
- } );
-
- await page
- .getByRole( 'button', { name: 'Products by Tag' } )
- .click();
- await page
- .getByRole( 'button', { name: 'For a specific item' } )
- .click();
- await page
- .getByRole( 'option', {
- name: `Recommended`,
- } )
- .click();
- await page
- .getByRole( 'option', { name: 'Fallback content' } )
- .click();
-
- const products = editor.canvas.getByLabel( 'Block: Title' );
-
- await expect( products ).toHaveText( expectedProducts );
-
- await wpCLI(
- 'option update woocommerce_default_catalog_orderby menu_order'
- );
- } );
- } );
} );
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/ControllerTest.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/ControllerTest.php
index f506c1ee700..a2ab3fd2141 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/ControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/ControllerTest.php
@@ -78,4 +78,92 @@ class ControllerTest extends WC_Unit_Test_Case {
'Product Filters should be configured to reload when the inherited Product Collection forces page reload.'
);
}
+
+ /**
+ * @testdox Should add the viewed-product action to a linked Product Collection title.
+ */
+ public function test_adds_view_product_directive_to_linked_product_title(): void {
+ $block_content = '<h2 class="wp-block-post-title"><a href="https://example.com/product">Product</a></h2>';
+ $instance = $this->create_post_title_block(
+ array(
+ '__woocommerceNamespace' => 'woocommerce/product-collection/product-title',
+ 'isLink' => true,
+ )
+ );
+
+ $rendered = $this->sut->add_product_title_click_event_directives( $block_content, array(), $instance );
+
+ $this->assertSame(
+ 1,
+ substr_count( $rendered, 'data-wp-on--click="woocommerce/product-collection::actions.viewProduct"' ),
+ 'The linked Product Collection title should receive exactly one viewed-product action.'
+ );
+ }
+
+ /**
+ * @testdox Should leave unrelated or unlinked Product Title markup byte-identical.
+ * @dataProvider provide_uninstrumented_product_title_cases
+ *
+ * @param array $attributes Block attributes.
+ * @param string $block_content Rendered block content.
+ */
+ public function test_does_not_add_view_product_directive_to_unrelated_titles( array $attributes, string $block_content ): void {
+ $instance = $this->create_post_title_block( $attributes );
+
+ $this->assertSame(
+ $block_content,
+ $this->sut->add_product_title_click_event_directives( $block_content, array(), $instance )
+ );
+ }
+
+ /**
+ * Cases that must not receive the viewed-product action.
+ *
+ * @return array<string, array{0: array<string, mixed>, 1: string}>
+ */
+ public function provide_uninstrumented_product_title_cases(): array {
+ $linked_title = '<h2 class="wp-block-post-title"><a href="https://example.com/product">Product</a></h2>';
+
+ return array(
+ 'wrong namespace' => array(
+ array(
+ '__woocommerceNamespace' => 'woocommerce/single-product/product-title',
+ 'isLink' => true,
+ ),
+ $linked_title,
+ ),
+ 'link disabled' => array(
+ array(
+ '__woocommerceNamespace' => 'woocommerce/product-collection/product-title',
+ 'isLink' => false,
+ ),
+ $linked_title,
+ ),
+ 'missing anchor' => array(
+ array(
+ '__woocommerceNamespace' => 'woocommerce/product-collection/product-title',
+ 'isLink' => true,
+ ),
+ '<h2 class="wp-block-post-title">Product</h2>',
+ ),
+ );
+ }
+
+ /**
+ * Create a real Post Title block instance for the public render filter.
+ *
+ * @param array $attributes Block attributes.
+ * @return \WP_Block
+ */
+ private function create_post_title_block( array $attributes ): \WP_Block {
+ return new \WP_Block(
+ array(
+ 'blockName' => 'core/post-title',
+ 'attrs' => $attributes,
+ 'innerBlocks' => array(),
+ 'innerHTML' => '',
+ 'innerContent' => array(),
+ )
+ );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/RendererTest.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/RendererTest.php
new file mode 100644
index 00000000000..c99a3cd7dea
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/RendererTest.php
@@ -0,0 +1,189 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\BlockTypes\ProductCollection;
+
+use Automattic\WooCommerce\Blocks\BlockTypes\ProductCollection\Renderer;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the Product Collection render lifecycle.
+ */
+class RendererTest extends WC_Unit_Test_Case {
+
+ /**
+ * Hooks registered by the renderer constructor.
+ *
+ * @var string[]
+ */
+ private const RENDER_HOOKS = array(
+ 'render_block_woocommerce/product-collection',
+ 'render_block_woocommerce/product-template',
+ 'render_block_woocommerce/product-collection-no-results',
+ 'render_block_core/query-pagination',
+ 'render_block_context',
+ );
+
+ /**
+ * @testdox Should reset result and No Results state between Product Collection renders.
+ */
+ public function test_resets_render_state_between_collections(): void {
+ $global_product = $GLOBALS['product'] ?? null;
+ $had_product = array_key_exists( 'product', $GLOBALS );
+
+ try {
+ foreach ( self::RENDER_HOOKS as $hook_name ) {
+ remove_all_filters( $hook_name );
+ }
+
+ new Renderer();
+
+ $collection_block = array(
+ 'blockName' => 'woocommerce/product-collection',
+ 'attrs' => array(
+ 'query' => array(
+ 'isProductCollectionBlock' => false,
+ ),
+ ),
+ );
+ $populated_wrapper = '<div class="wp-block-woocommerce-product-collection">Populated collection</div>';
+ $empty_wrapper = '<div class="wp-block-woocommerce-product-collection">Empty collection wrapper</div>';
+ $no_results = '<p>No results found</p>';
+
+ apply_filters( 'render_block_woocommerce/product-template', '<ul><li>Product</li></ul>' );
+ $this->assertSame(
+ $populated_wrapper,
+ apply_filters( 'render_block_woocommerce/product-collection', $populated_wrapper, $collection_block ),
+ 'A populated Product Collection should render its wrapper.'
+ );
+
+ $this->assertSame(
+ '',
+ apply_filters( 'render_block_woocommerce/product-collection', $empty_wrapper, $collection_block ),
+ 'An empty collection must not inherit the previous collection result state.'
+ );
+
+ apply_filters( 'render_block_woocommerce/product-template', '' );
+ $this->assertSame(
+ $no_results,
+ apply_filters( 'render_block_woocommerce/product-collection-no-results', $no_results ),
+ 'The explicit No Results block should pass through unchanged.'
+ );
+ $this->assertSame(
+ $empty_wrapper,
+ apply_filters( 'render_block_woocommerce/product-collection', $empty_wrapper, $collection_block ),
+ 'A collection with an explicit No Results block should render its wrapper.'
+ );
+
+ apply_filters( 'render_block_woocommerce/product-template', '' );
+ $this->assertSame(
+ '',
+ apply_filters( 'render_block_woocommerce/product-collection', $empty_wrapper, $collection_block ),
+ 'An empty collection must not inherit the previous collection No Results state.'
+ );
+
+ apply_filters( 'render_block_woocommerce/product-template', '<ul><li>Another product</li></ul>' );
+ $this->assertSame(
+ $populated_wrapper,
+ apply_filters( 'render_block_woocommerce/product-collection', $populated_wrapper, $collection_block ),
+ 'A later populated collection should render after the empty and No Results cases.'
+ );
+ } finally {
+ if ( $had_product ) {
+ $GLOBALS['product'] = $global_product; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the exact product global captured before rendering.
+ } else {
+ unset( $GLOBALS['product'] );
+ }
+ }
+ }
+
+ /**
+ * @testdox Should add one render event initializer to each Product Collection.
+ */
+ public function test_adds_one_render_event_init_per_collection(): void {
+ $script_module_id = 'woocommerce/product-collection';
+ $module_was_enqueued = in_array( $script_module_id, wp_script_modules()->get_queue(), true );
+
+ try {
+ $renderer = new Renderer();
+
+ foreach ( array( 'featured', 'on-sale', 'best-sellers' ) as $collection ) {
+ $block_content = '<div class="wp-block-woocommerce-product-collection">Products</div>';
+ $block = array(
+ 'blockName' => 'woocommerce/product-collection',
+ 'attrs' => array(
+ 'collection' => $collection,
+ 'forcePageReload' => true,
+ 'query' => array(
+ 'isProductCollectionBlock' => true,
+ ),
+ ),
+ );
+
+ $rendered = $renderer->enhance_product_collection_with_interactivity( $block_content, $block );
+ $processor = new \WP_HTML_Tag_Processor( $rendered );
+
+ $this->assertTrue(
+ $processor->next_tag( array( 'class_name' => 'wp-block-woocommerce-product-collection' ) ),
+ "The {$collection} Product Collection root should remain present."
+ );
+ $this->assertSame(
+ 'woocommerce/product-collection',
+ $processor->get_attribute( 'data-wp-interactive' ),
+ "The {$collection} Product Collection should use the real interactive namespace."
+ );
+ $this->assertSame(
+ 1,
+ substr_count( $rendered, 'data-wp-interactive="woocommerce/product-collection"' ),
+ "The {$collection} Product Collection should declare its interactive namespace exactly once."
+ );
+ $this->assertSame(
+ 'callbacks.onRender',
+ $processor->get_attribute( 'data-wp-init' ),
+ "The {$collection} Product Collection should initialize its render event callback."
+ );
+ $this->assertSame(
+ 1,
+ substr_count( $rendered, 'data-wp-init="callbacks.onRender"' ),
+ "The {$collection} Product Collection should initialize its render event exactly once."
+ );
+
+ $context = json_decode( (string) $processor->get_attribute( 'data-wp-context' ), true );
+ $this->assertIsArray( $context, "The {$collection} Product Collection context should be valid JSON." );
+ $this->assertSame(
+ $collection,
+ $context['collection'] ?? null,
+ "The {$collection} Product Collection should retain its collection context."
+ );
+ }
+
+ $non_collection_content = '<div class="wp-block-query">Posts</div>';
+ $this->assertSame(
+ $non_collection_content,
+ $renderer->enhance_product_collection_with_interactivity(
+ $non_collection_content,
+ array(
+ 'attrs' => array(
+ 'query' => array(
+ 'isProductCollectionBlock' => false,
+ ),
+ ),
+ )
+ ),
+ 'Non-Product Collection markup should remain byte-identical.'
+ );
+ } finally {
+ // _restore_hooks() rewinds every RENDER_HOOKS stack on its own, so only
+ // process state needs undoing here. The script-module queue is one such
+ // piece. The other is the Interactivity store: enhancing the markup runs
+ // render_interactivity_notices_region(), which writes
+ // wp_interactivity_state( 'woocommerce/store-notices', ... ). That one is
+ // left alone deliberately -- it writes the same namespaced values every
+ // time and no test reads them -- but it is not reset for us either, so a
+ // test that starts asserting on that namespace has to restore it.
+ if ( ! $module_was_enqueued ) {
+ wp_dequeue_script_module( $script_module_id );
+ }
+ }
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/RouteContextParityTest.php b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/RouteContextParityTest.php
new file mode 100644
index 00000000000..728374d1325
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/BlockTypes/ProductCollection/RouteContextParityTest.php
@@ -0,0 +1,327 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\BlockTypes\ProductCollection;
+
+use WC_Helper_Product;
+use WC_Product;
+use WC_Unit_Test_Case;
+use WP_Query;
+
+/**
+ * Tests Product Collection parity with WooCommerce's classic product loop.
+ */
+class RouteContextParityTest extends WC_Unit_Test_Case {
+
+ /**
+ * @testdox Should render the same ordered products as the classic loop for $route_label.
+ *
+ * Do not reach for process isolation to contain the route globals this sets up.
+ * A forked child re-runs tests/legacy/bootstrap.php, which reinstalls the store
+ * and writes woocommerce_custom_orders_table_enabled over its own connection,
+ * outside the parent's rolled-back transaction -- once per provider row. That
+ * flips the order store for every test that follows in the parent process. The
+ * finally below restores the five globals, which is what actually needs undoing.
+ *
+ * @dataProvider route_context_provider
+ *
+ * @param string $route_label Route label.
+ * @param string $legacy_template Legacy Template block template attribute.
+ * @param string[] $expected_products Expected ordered product names.
+ */
+ public function test_product_collection_and_product_query_match_classic_route( string $route_label, string $legacy_template, array $expected_products ): void {
+ $global_presence = array(
+ 'post' => array_key_exists( 'post', $GLOBALS ),
+ 'product' => array_key_exists( 'product', $GLOBALS ),
+ 'woocommerce_loop' => array_key_exists( 'woocommerce_loop', $GLOBALS ),
+ 'wp_query' => array_key_exists( 'wp_query', $GLOBALS ),
+ 'wp_the_query' => array_key_exists( 'wp_the_query', $GLOBALS ),
+ );
+
+ global $post, $product, $woocommerce_loop, $wp_query, $wp_the_query;
+
+ $original_globals = array(
+ 'post' => $post ?? null,
+ 'product' => $product ?? null,
+ 'woocommerce_loop' => $woocommerce_loop ?? null,
+ 'wp_query' => $wp_query ?? null,
+ 'wp_the_query' => $wp_the_query ?? null,
+ );
+
+ try {
+ update_option( 'posts_per_page', 20 );
+ update_option( 'woocommerce_default_catalog_orderby', 'menu_order' );
+
+ $category_id = self::factory()->term->create(
+ array(
+ 'taxonomy' => 'product_cat',
+ 'name' => 'Parity category',
+ 'slug' => 'parity-category',
+ )
+ );
+ $tag_id = self::factory()->term->create(
+ array(
+ 'taxonomy' => 'product_tag',
+ 'name' => 'Parity tag',
+ 'slug' => 'parity-tag',
+ )
+ );
+ $term_ids = array(
+ 'product_cat' => $category_id,
+ 'product_tag' => $tag_id,
+ );
+
+ $this->create_product( 'Parity Shirt A', 30, $category_id, $tag_id );
+ $this->create_product( 'Parity Shirt B', 10, $category_id );
+ $this->create_product( 'Parity Shirt C', 20, null, $tag_id );
+ $this->create_product( 'Parity Catalog D', 40 );
+
+ $route = $this->get_route_url( $route_label, $term_ids );
+ $this->go_to( $route );
+ $wp_the_query = $wp_query; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- WP_UnitTestCase::go_to() does not identify its query as main for WooCommerce's pre_get_posts hook.
+ $this->prepare_route_product_query( $route_label, $wp_query );
+ wc_reset_loop();
+ wc_setup_loop(
+ array(
+ 'total' => $wp_query->post_count,
+ 'total_pages' => 1,
+ 'per_page' => 20,
+ 'current_page' => 1,
+ )
+ );
+
+ $this->assertInstanceOf( WP_Query::class, $wp_query, 'The route should establish a real main query.' );
+ $this->assertGreaterThan( 0, $wp_query->post_count, 'The prepared WooCommerce route query should contain products.' );
+ $this->assertGreaterThan( 0, wc_get_loop_prop( 'total' ), 'The prepared WooCommerce route loop should contain products.' );
+ $wp_query->rewind_posts();
+
+ $classic_products = $this->extract_product_names(
+ $this->render_legacy_template( $legacy_template ),
+ 'woocommerce-loop-product__title'
+ );
+ $wp_query->rewind_posts();
+ $product_query_products = $this->extract_product_names(
+ $this->render_product_query(),
+ 'wp-block-post-title'
+ );
+ $wp_query->rewind_posts();
+ $product_collection_products = $this->extract_product_names(
+ $this->render_product_collection(),
+ 'wp-block-post-title'
+ );
+
+ $this->assertNotEmpty( $product_query_products, 'The Products route result should not be empty.' );
+ $this->assertNotEmpty( $product_collection_products, 'The Product Collection route result should not be empty.' );
+ $this->assertNotEmpty( $classic_products, 'The classic route query result should not be empty.' );
+ $this->assertSame( $expected_products, $product_query_products, 'Products should render the expected ordered identities.' );
+ $this->assertSame( $expected_products, $product_collection_products, 'Product Collection should render the expected ordered identities.' );
+ $this->assertSame( $expected_products, $classic_products, 'The classic route should expose the expected ordered identities.' );
+ $this->assertSame( $classic_products, $product_query_products, 'Products and the classic route should have strict ordered parity.' );
+ $this->assertSame( $classic_products, $product_collection_products, 'Product Collection and the classic route should have strict ordered parity.' );
+ } finally {
+ wp_reset_postdata();
+ wc_reset_loop();
+
+ foreach ( $original_globals as $global_name => $global_value ) {
+ if ( $global_presence[ $global_name ] ) {
+ $GLOBALS[ $global_name ] = $global_value; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the exact route global captured before the provider row.
+ } else {
+ unset( $GLOBALS[ $global_name ] );
+ }
+ }
+ }
+ }
+
+ /**
+ * Run WooCommerce's real main-product-query preparation for a test route.
+ *
+ * @param string $route_label Route label.
+ * @param WP_Query $query Main route query.
+ */
+ private function prepare_route_product_query( string $route_label, WP_Query $query ): void {
+ if ( 'search' === $route_label ) {
+ $query->is_search = true;
+ $query->is_post_type_archive = true;
+ $query->is_archive = true;
+ $query->is_404 = false;
+ }
+
+ $query->set( 'post_type', 'product' );
+ $query->set( 'posts_per_page', 20 );
+ WC()->query->product_query( $query );
+ $query->get_posts();
+ $query->rewind_posts();
+ }
+
+ /**
+ * Route contexts and their deterministic identities.
+ *
+ * @return array<string, array{string, string, string[]}>
+ */
+ public function route_context_provider(): array {
+ return array(
+ 'product category' => array(
+ 'category',
+ 'taxonomy-product_cat',
+ array( 'Parity Shirt B', 'Parity Shirt A' ),
+ ),
+ 'product tag' => array(
+ 'tag',
+ 'taxonomy-product_tag',
+ array( 'Parity Shirt C', 'Parity Shirt A' ),
+ ),
+ 'product search' => array(
+ 'search',
+ 'product-search-results',
+ array( 'Parity Shirt B', 'Parity Shirt C', 'Parity Shirt A' ),
+ ),
+ );
+ }
+
+ /**
+ * Create a visible product with deterministic catalog ordering.
+ *
+ * @param string $name Product name.
+ * @param int $menu_order Menu order.
+ * @param int|null $category_id Product category ID.
+ * @param int|null $tag_id Product tag ID.
+ * @return int Product ID.
+ */
+ private function create_product( string $name, int $menu_order, ?int $category_id = null, ?int $tag_id = null ): int {
+ $product = WC_Helper_Product::create_simple_product(
+ true,
+ array(
+ 'name' => $name,
+ 'regular_price' => '10',
+ )
+ );
+ $this->assertInstanceOf( WC_Product::class, $product, 'The route fixture should create a WooCommerce product.' );
+ $product->set_name( $name );
+ $product->set_status( 'publish' );
+ $product->set_catalog_visibility( 'visible' );
+ $product->set_menu_order( $menu_order );
+ $product->save();
+
+ if ( null !== $category_id ) {
+ wp_set_object_terms( $product->get_id(), array( $category_id ), 'product_cat' );
+ }
+ if ( null !== $tag_id ) {
+ wp_set_object_terms( $product->get_id(), array( $tag_id ), 'product_tag' );
+ }
+
+ return $product->get_id();
+ }
+
+ /**
+ * Resolve the provider row to a real frontend route.
+ *
+ * @param string $route_label Route label.
+ * @param array<string, int> $term_ids Product term IDs.
+ * @return string Route URL.
+ */
+ private function get_route_url( string $route_label, array $term_ids ): string {
+ switch ( $route_label ) {
+ case 'category':
+ return (string) get_term_link( $term_ids['product_cat'], 'product_cat' );
+ case 'tag':
+ return (string) get_term_link( $term_ids['product_tag'], 'product_tag' );
+ case 'search':
+ return home_url( '/?s=Parity+Shirt&post_type=product&orderby=menu_order&order=ASC' );
+ default:
+ $this->fail( 'Unknown route provider row.' );
+ }
+ }
+
+ /**
+ * Render a minimal inherited Product Collection through the registered blocks.
+ *
+ * @return string Rendered block markup.
+ */
+ private function render_product_collection(): string {
+ $attributes = array(
+ 'queryId' => 28,
+ 'query' => array(
+ 'inherit' => true,
+ 'isProductCollectionBlock' => true,
+ ),
+ 'displayLayout' => array(
+ 'type' => 'flex',
+ 'columns' => 3,
+ 'shrinkColumns' => true,
+ ),
+ );
+
+ return do_blocks(
+ sprintf(
+ '<!-- wp:woocommerce/product-collection %1$s --><div class="wp-block-woocommerce-product-collection"><!-- wp:woocommerce/product-template --><!-- wp:post-title /--><!-- /wp:woocommerce/product-template --></div><!-- /wp:woocommerce/product-collection -->',
+ wp_json_encode( $attributes )
+ )
+ );
+ }
+
+ /**
+ * Render a minimal inherited Products block through the registered blocks.
+ *
+ * @return string Rendered block markup.
+ */
+ private function render_product_query(): string {
+ $attributes = array(
+ 'namespace' => 'woocommerce/product-query',
+ 'queryId' => 69,
+ 'query' => array(
+ 'inherit' => true,
+ 'postType' => 'product',
+ ),
+ );
+
+ return do_blocks(
+ sprintf(
+ '<!-- wp:query %1$s --><div class="wp-block-query"><!-- wp:post-template %2$s --><!-- wp:post-title /--><!-- /wp:post-template --></div><!-- /wp:query -->',
+ wp_json_encode( $attributes ),
+ wp_json_encode( array( '__woocommerceNamespace' => 'woocommerce/product-query/product-template' ) )
+ )
+ );
+ }
+
+ /**
+ * Render the registered Legacy Template block.
+ *
+ * @param string $template Template attribute.
+ * @return string Rendered block markup.
+ */
+ private function render_legacy_template( string $template ): string {
+ return do_blocks(
+ sprintf(
+ '<!-- wp:woocommerce/legacy-template %s /-->',
+ wp_json_encode( array( 'template' => $template ) )
+ )
+ );
+ }
+
+ /**
+ * Extract normalized product names from a rendered block.
+ *
+ * @param string $markup Rendered markup.
+ * @param string $class_name Product-title class.
+ * @return string[] Product names.
+ */
+ private function extract_product_names( string $markup, string $class_name ): array {
+ $document = new \DOMDocument();
+ $previous_libxml_setting = libxml_use_internal_errors( true );
+ $loaded = $document->loadHTML( '<!DOCTYPE html><html><body>' . $markup . '</body></html>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );
+ libxml_clear_errors();
+ libxml_use_internal_errors( $previous_libxml_setting );
+ $this->assertTrue( $loaded, 'Rendered product markup should be parseable HTML.' );
+
+ $xpath = new \DOMXPath( $document );
+ $nodes = $xpath->query( "//*[contains(concat(' ', normalize-space(@class), ' '), ' {$class_name} ')]" );
+ $this->assertNotFalse( $nodes, 'The rendered product-title query should be valid.' );
+
+ $names = array();
+ foreach ( $nodes as $node ) {
+ $names[] = trim( html_entity_decode( $node->textContent, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOMNode defines this public property name.
+ }
+
+ return $names;
+ }
+}