Commit 4a87ac3a65c for woocommerce
commit 4a87ac3a65ca97166b1991b9efc72c328e4021a1
Author: Luis Herranz <luisherranz@gmail.com>
Date: Fri Jul 17 07:46:38 2026 +0200
Mini-Cart: fix stray separator and harden item-data rendering for hidden or malformed entries (#66688)
* Extract Mini-Cart item-data visibility helpers into a pure module
* Add e2e coverage for Mini-Cart item-data no-error and separator-placement paths
* Add e2e coverage for Mini-Cart trailing malformed, hidden, and mixed item-data entries
* Add changelog entry
* Address Mini-Cart review feedback
---------
Co-authored-by: luisherranz <luis.herranz@automattic.com>
diff --git a/plugins/woocommerce/changelog/woo6-57-codex-review-mini-cart-item-details-can-crash-when-no-item b/plugins/woocommerce/changelog/woo6-57-codex-review-mini-cart-item-details-can-crash-when-no-item
new file mode 100644
index 00000000000..f2df1dfdc14
--- /dev/null
+++ b/plugins/woocommerce/changelog/woo6-57-codex-review-mini-cart-item-details-can-crash-when-no-item
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Mini-Cart: prevent a stray trailing separator after hidden or malformed item data entries and harden item data rendering so it no longer depends on the Interactivity API swallowing an error when an item has no item data.
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/frontend.ts
index 00a58ee82d2..b3609ee3126 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/frontend.ts
@@ -26,6 +26,13 @@ import {
} from '../../../../packages/prices/utils/currency';
import { CartItem, Currency } from '../../types';
import { translateJQueryEventToNative } from '../../base/stores/woocommerce/legacy-events';
+import {
+ getEntryFieldRaw,
+ isItemDataEntryVisible,
+ isLastVisibleEntry,
+ buildCartItemDataAttr,
+} from './utils/item-data';
+import type { ItemData, CartItemDataAttr } from './utils/item-data';
const universalLock =
'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
@@ -134,21 +141,6 @@ type CartItemContext = {
cartItem: CartItem;
};
-type ItemData = {
- raw_attribute?: string | undefined;
- value?: string | undefined;
- display?: string;
- attribute?: string;
- hidden?: boolean | string | number;
-} & ( { key: string; name?: never } | { key?: never; name: string } );
-
-type CartItemDataAttr = {
- value: string;
- name: string;
- className: string;
- hidden: boolean;
-};
-
type DataProperty = 'item_data' | 'variation';
const trimWords = ( html: string, maxWords = 15 ): string => {
@@ -423,33 +415,29 @@ store< MiniCart >(
);
/**
- * Returns the raw API value for an item_data field. Used by both innerHTML
- * callbacks and the cartItemDataAttr getter.
+ * Resolves the item_data or variation entry for the current wp-each
+ * iteration, falling back to the cart item's first entry when no
+ * per-iteration context is set.
*/
-function getItemDataRaw( field: 'name' | 'value' ): string {
+function resolveDataItemAttr(): ItemData | undefined {
const { itemData, dataProperty } = getContext< {
itemData: ItemData;
dataProperty: DataProperty;
} >();
- const dataItemAttr =
+ return (
+ itemData ||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
- itemData || cartItemState.cartItem[ dataProperty ]?.[ 0 ];
-
- if ( ! dataItemAttr ) {
- return '';
- }
-
- if ( field === 'name' ) {
- return (
- dataItemAttr.key ||
- dataItemAttr.attribute ||
- dataItemAttr.name ||
- ''
- );
- }
+ cartItemState.cartItem[ dataProperty ]?.[ 0 ]
+ );
+}
- return dataItemAttr.display || dataItemAttr.value || '';
+/**
+ * Returns the raw API value for an item_data field. Used by both innerHTML
+ * callbacks and the cartItemDataAttr getter.
+ */
+function getItemDataRaw( field: 'name' | 'value' ): string {
+ return getEntryFieldRaw( resolveDataItemAttr(), field );
}
const { state: cartItemState } = store(
@@ -791,48 +779,12 @@ const { state: cartItemState } = store(
: true;
},
- get cartItemDataAttr(): CartItemDataAttr | null {
- const rawName = getItemDataRaw( 'name' );
- const rawValue = getItemDataRaw( 'value' );
-
- if ( ! rawName && ! rawValue ) {
- return null;
- }
-
- const nameTxt = document.createElement( 'textarea' );
- nameTxt.innerHTML = rawName;
- const valueTxt = document.createElement( 'textarea' );
- valueTxt.innerHTML = rawValue;
-
- const { itemData, dataProperty } = getContext< {
- itemData: ItemData;
- dataProperty: DataProperty;
- } >();
- const dataItemAttr =
- itemData || cartItemState.cartItem[ dataProperty ]?.[ 0 ];
- const hiddenValue = dataItemAttr?.hidden;
-
- return {
- name: nameTxt.value ? nameTxt.value + ':' : '',
- value: valueTxt.value,
- className: `wc-block-components-product-details__${ nameTxt.value
- .replace( /([a-z])([A-Z])/g, '$1-$2' )
- .replace( /<[^>]*>/g, '' )
- .replace( /[\s_&]+/g, '-' )
- .toLowerCase() }`,
- hidden:
- hiddenValue === true ||
- hiddenValue === 'true' ||
- hiddenValue === '1' ||
- hiddenValue === 1,
- };
+ get cartItemDataAttr(): CartItemDataAttr {
+ return buildCartItemDataAttr( resolveDataItemAttr() );
},
get cartItemDataAttrHidden(): boolean {
- return (
- cartItemState.cartItemDataAttr === null ||
- !! cartItemState.cartItemDataAttr?.hidden
- );
+ return ! isItemDataEntryVisible( resolveDataItemAttr() );
},
// Used to index cart item data attributes for wp-each-key.
@@ -878,29 +830,15 @@ const { state: cartItemState } = store(
dataProperty: DataProperty;
} >();
- const items = cartItemState.cartItem[ dataProperty ];
- if ( ! items || items.length === 0 ) {
- return true;
- }
-
- // Filter out hidden items
- const visibleItems = items.filter(
- ( item: ItemData ) =>
- ! (
- item.hidden === true ||
- item.hidden === 'true' ||
- item.hidden === '1' ||
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- ( item.hidden as any ) === 1
- )
- );
-
- if ( visibleItems.length === 0 ) {
- return true;
- }
+ // The cart item's `item_data`/`variation` array elements are
+ // structurally compatible with `ItemData` for every field
+ // these pure helpers read (name/value/hidden sources), even
+ // though the two array types aren't nominally identical.
+ const items = cartItemState.cartItem[
+ dataProperty
+ ] as ItemData[];
- const lastItem = visibleItems[ visibleItems.length - 1 ];
- return itemData === lastItem;
+ return isLastVisibleEntry( items, itemData );
},
},
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/utils/item-data.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/utils/item-data.ts
new file mode 100644
index 00000000000..9cc0508f4b1
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/utils/item-data.ts
@@ -0,0 +1,175 @@
+/**
+ * Pure, side-effect-free helpers backing the Mini-Cart product table's
+ * per-entry `item_data`/`variation` rendering.
+ *
+ * This module must stay free of top-level side effects and must not import
+ * `@wordpress/interactivity` so it can be unit-tested with zero mocking.
+ * `frontend.ts`'s store getters resolve the Interactivity context and
+ * delegate the actual decision-making to these functions.
+ */
+
+/**
+ * Raw `item_data` or `variation` entry as returned by the Store API.
+ *
+ * An entry is malformed when it has no usable name and no usable value; see
+ * `isItemDataEntryVisible`.
+ */
+export type ItemData = {
+ /** Raw (non-display) attribute name, used by variation entries. */
+ raw_attribute?: string | undefined;
+ /** Raw (non-display) value. */
+ value?: string | undefined;
+ /** Display-ready value; preferred over `value` when present. */
+ display?: string;
+ /** Variation attribute name; preferred over `name` when present. */
+ attribute?: string;
+ /** Truthy under `true`, `'true'`, `'1'`, or `1` marks the entry hidden. */
+ hidden?: boolean | string | number;
+} & (
+ | {
+ /** Item-data entry key. Mutually exclusive with `name`. */
+ key: string;
+ name?: never;
+ }
+ | {
+ key?: never;
+ /** Variation attribute name. Mutually exclusive with `key`. */
+ name: string;
+ }
+ );
+
+/**
+ * Normalized, entity-decoded representation of an `ItemData` entry, ready
+ * for rendering. Always fully populated, so markup bindings never need to
+ * guard against `null`. See `buildCartItemDataAttr`.
+ */
+export type CartItemDataAttr = {
+ /** Entity-decoded entry value; empty string when the entry has no usable value. */
+ value: string;
+ /** Entity-decoded entry name, suffixed with `:`; empty string when the entry has no usable name. */
+ name: string;
+ /** BEM-style modifier class derived from `name`, e.g. `wc-block-components-product-details__color`. */
+ className: string;
+};
+
+/**
+ * Extracts the raw (pre-entity-decoding) name or value of an item-data or
+ * variation entry, exactly as the Store API returns it.
+ *
+ * @param {ItemData | undefined} entry Entry to read from, or `undefined`
+ * when no entry is available.
+ * @param {'name'|'value'} field Which raw field to extract.
+ * @return {string} The raw field value, or an empty string when the entry
+ * is `undefined` or the field has no usable value.
+ */
+export function getEntryFieldRaw(
+ entry: ItemData | undefined,
+ field: 'name' | 'value'
+): string {
+ if ( ! entry ) {
+ return '';
+ }
+
+ if ( field === 'name' ) {
+ return entry.key || entry.attribute || entry.name || '';
+ }
+
+ return entry.display || entry.value || '';
+}
+
+/**
+ * Determines whether an entry's `hidden` field marks it as explicitly
+ * hidden.
+ *
+ * @param {ItemData | undefined} entry Entry to check, or `undefined`.
+ * @return {boolean} True when `hidden` is truthy under any of `true`,
+ * `'true'`, `'1'`, or `1`.
+ */
+export function isEntryHiddenFlag( entry: ItemData | undefined ): boolean {
+ const hiddenValue = entry?.hidden;
+
+ return (
+ hiddenValue === true ||
+ hiddenValue === 'true' ||
+ hiddenValue === '1' ||
+ hiddenValue === 1
+ );
+}
+
+/**
+ * Single source of truth for whether an item-data or variation entry
+ * contributes anything visible to the Mini-Cart. Every visibility-dependent
+ * behavior (content-hiding, " / " separator placement) derives from this one
+ * predicate, so they can never disagree.
+ *
+ * @param {ItemData | undefined} entry Entry to check, or `undefined`.
+ * @return {boolean} True when the entry has a usable name or value and is
+ * not hidden-flagged.
+ */
+export function isItemDataEntryVisible( entry: ItemData | undefined ): boolean {
+ const hasUsableName = !! getEntryFieldRaw( entry, 'name' );
+ const hasUsableValue = !! getEntryFieldRaw( entry, 'value' );
+
+ return ( hasUsableName || hasUsableValue ) && ! isEntryHiddenFlag( entry );
+}
+
+/**
+ * Determines whether `entry` is the last visible entry of `items`, i.e. the
+ * one whose trailing " / " separator must be suppressed.
+ *
+ * Uses referential identity (`===`) against the visible subset of `items`,
+ * matching the wp-each iteration's per-entry context object.
+ *
+ * @param {ItemData[] | undefined} items List of entries the Mini-Cart is
+ * rendering (`item_data` or
+ * `variation`), or `undefined`.
+ * @param {ItemData | undefined} entry Entry to test.
+ * @return {boolean} True when there are no items, no visible items, or
+ * `entry` is the last visible one.
+ */
+export function isLastVisibleEntry(
+ items: ItemData[] | undefined,
+ entry: ItemData | undefined
+): boolean {
+ if ( ! items || items.length === 0 ) {
+ return true;
+ }
+
+ const visibleItems = items.filter( isItemDataEntryVisible );
+
+ if ( visibleItems.length === 0 ) {
+ return true;
+ }
+
+ return entry === visibleItems[ visibleItems.length - 1 ];
+}
+
+/**
+ * Builds the normalized, entity-decoded `CartItemDataAttr` for an entry.
+ * Always returns a fully-populated object — never `null` or `undefined` —
+ * so markup bindings can dereference its properties unconditionally.
+ *
+ * @param {ItemData | undefined} entry Entry to build from, or `undefined`.
+ * @return {CartItemDataAttr} The normalized entry. A malformed or empty
+ * entry produces empty `name`/`value`,
+ * and `className: 'wc-block-components-product-details__'`.
+ */
+export function buildCartItemDataAttr(
+ entry: ItemData | undefined
+): CartItemDataAttr {
+ const nameTxt = document.createElement( 'textarea' );
+ nameTxt.innerHTML = getEntryFieldRaw( entry, 'name' );
+
+ const valueTxt = document.createElement( 'textarea' );
+ valueTxt.innerHTML = getEntryFieldRaw( entry, 'value' );
+
+ return {
+ name: nameTxt.value ? nameTxt.value + ':' : '',
+ value: valueTxt.value,
+ className: `wc-block-components-product-details__${ nameTxt.value
+ .replace( /([a-z])([A-Z])/g, '$1-$2' )
+ .replace( /<[^>]*>/g, '' )
+ .replace( /[\s_&]+/g, '-' )
+ .toLowerCase() }`,
+ };
+}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/utils/test/item-data.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/utils/test/item-data.ts
new file mode 100644
index 00000000000..c24f25bd040
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/mini-cart/utils/test/item-data.ts
@@ -0,0 +1,399 @@
+/**
+ * Internal dependencies
+ */
+import {
+ ItemData,
+ getEntryFieldRaw,
+ isEntryHiddenFlag,
+ isItemDataEntryVisible,
+ isLastVisibleEntry,
+ buildCartItemDataAttr,
+} from '../item-data';
+
+/**
+ * Builds a well-formed, visible `item_data`-shaped entry.
+ */
+const entry = ( key: string, value = 'value', overrides = {} ): ItemData => ( {
+ key,
+ value,
+ ...overrides,
+} );
+
+/**
+ * Builds an entry with a truthy `hidden` flag, otherwise well-formed.
+ */
+const hidden = ( key: string, value = 'value' ): ItemData =>
+ entry( key, value, { hidden: true } );
+
+/**
+ * Builds a malformed entry: no usable name and no usable value.
+ */
+const malformed = (): ItemData => ( { key: '' } );
+
+describe( 'getEntryFieldRaw tests', () => {
+ test( 'returns an empty string for an undefined entry', () => {
+ expect( getEntryFieldRaw( undefined, 'name' ) ).toBe( '' );
+ expect( getEntryFieldRaw( undefined, 'value' ) ).toBe( '' );
+ } );
+
+ test( 'name: prefers key over attribute over name', () => {
+ expect(
+ getEntryFieldRaw(
+ { key: 'the-key', attribute: 'the-attribute' } as ItemData,
+ 'name'
+ )
+ ).toBe( 'the-key' );
+ expect(
+ getEntryFieldRaw(
+ {
+ name: 'the-name',
+ attribute: 'the-attribute',
+ } as ItemData,
+ 'name'
+ )
+ ).toBe( 'the-attribute' );
+ expect(
+ getEntryFieldRaw( { name: 'the-name' } as ItemData, 'name' )
+ ).toBe( 'the-name' );
+ } );
+
+ test( 'name: falls back to an empty string when nothing usable is present', () => {
+ expect( getEntryFieldRaw( { key: '' } as ItemData, 'name' ) ).toBe(
+ ''
+ );
+ } );
+
+ test( 'value: prefers display over value', () => {
+ expect(
+ getEntryFieldRaw(
+ { key: 'k', display: 'the-display', value: 'the-value' },
+ 'value'
+ )
+ ).toBe( 'the-display' );
+ expect(
+ getEntryFieldRaw( { key: 'k', value: 'the-value' }, 'value' )
+ ).toBe( 'the-value' );
+ } );
+
+ test( 'value: falls back to an empty string when nothing usable is present', () => {
+ expect( getEntryFieldRaw( { key: 'k' }, 'value' ) ).toBe( '' );
+ } );
+} );
+
+describe( 'isEntryHiddenFlag tests', () => {
+ test.each( [
+ [ true, true ],
+ [ 'true', true ],
+ [ '1', true ],
+ [ 1, true ],
+ [ false, false ],
+ [ undefined, false ],
+ [ 0, false ],
+ [ 'yes', false ],
+ ] )( 'hidden: %p -> %p', ( hiddenValue, expected ) => {
+ expect(
+ isEntryHiddenFlag( { key: 'k', hidden: hiddenValue } as ItemData )
+ ).toBe( expected );
+ } );
+
+ test( 'returns false for an undefined entry', () => {
+ expect( isEntryHiddenFlag( undefined ) ).toBe( false );
+ } );
+} );
+
+describe( 'isItemDataEntryVisible tests', () => {
+ test( 'true for an entry with a usable name and no hidden flag', () => {
+ expect( isItemDataEntryVisible( { key: 'Color' } as ItemData ) ).toBe(
+ true
+ );
+ } );
+
+ test( 'true for an entry with only a usable value and no hidden flag', () => {
+ expect(
+ isItemDataEntryVisible( { key: '', value: 'Red' } as ItemData )
+ ).toBe( true );
+ } );
+
+ test( 'false for an entry with neither a usable name nor value, regardless of hidden', () => {
+ expect( isItemDataEntryVisible( { key: '' } as ItemData ) ).toBe(
+ false
+ );
+ expect(
+ isItemDataEntryVisible( {
+ key: '',
+ hidden: true,
+ } as ItemData )
+ ).toBe( false );
+ expect(
+ isItemDataEntryVisible( {
+ key: '',
+ hidden: false,
+ } as ItemData )
+ ).toBe( false );
+ } );
+
+ test( 'false for an undefined entry', () => {
+ expect( isItemDataEntryVisible( undefined ) ).toBe( false );
+ } );
+
+ test.each( [ true, 'true', '1', 1 ] )(
+ 'false for a well-formed entry whose hidden flag is %p',
+ ( hiddenValue ) => {
+ expect(
+ isItemDataEntryVisible( {
+ key: 'Color',
+ value: 'Red',
+ hidden: hiddenValue,
+ } as ItemData )
+ ).toBe( false );
+ }
+ );
+
+ test.each( [ false, undefined ] )(
+ 'true for a well-formed entry whose hidden flag is %p',
+ ( hiddenValue ) => {
+ expect(
+ isItemDataEntryVisible( {
+ key: 'Color',
+ value: 'Red',
+ hidden: hiddenValue,
+ } as ItemData )
+ ).toBe( true );
+ }
+ );
+} );
+
+describe( 'isLastVisibleEntry tests', () => {
+ test( 'undefined items list -> true', () => {
+ expect( isLastVisibleEntry( undefined, entry( 'Color' ) ) ).toBe(
+ true
+ );
+ } );
+
+ test( 'empty items list -> true', () => {
+ expect( isLastVisibleEntry( [], entry( 'Color' ) ) ).toBe( true );
+ } );
+
+ test( 'single-entry list, entry is visible -> true for that entry', () => {
+ const only = entry( 'Color' );
+ expect( isLastVisibleEntry( [ only ], only ) ).toBe( true );
+ } );
+
+ test( 'single-entry list, entry is malformed -> true (no visible items)', () => {
+ const only = malformed();
+ expect( isLastVisibleEntry( [ only ], only ) ).toBe( true );
+ } );
+
+ test( 'all-visible list: only the last entry is the last-visible one', () => {
+ const first = entry( 'Color' );
+ const middle = entry( 'Size' );
+ const last = entry( 'Material' );
+ const items = [ first, middle, last ];
+
+ expect( isLastVisibleEntry( items, first ) ).toBe( false );
+ expect( isLastVisibleEntry( items, middle ) ).toBe( false );
+ expect( isLastVisibleEntry( items, last ) ).toBe( true );
+ } );
+
+ test( 'malformed entry in first position is skipped', () => {
+ const bad = malformed();
+ const visible1 = entry( 'Color' );
+ const visible2 = entry( 'Size' );
+ const items = [ bad, visible1, visible2 ];
+
+ expect( isLastVisibleEntry( items, bad ) ).toBe( false );
+ expect( isLastVisibleEntry( items, visible1 ) ).toBe( false );
+ expect( isLastVisibleEntry( items, visible2 ) ).toBe( true );
+ } );
+
+ test( 'malformed entry in middle position is skipped', () => {
+ const visible1 = entry( 'Color' );
+ const bad = malformed();
+ const visible2 = entry( 'Size' );
+ const items = [ visible1, bad, visible2 ];
+
+ expect( isLastVisibleEntry( items, visible1 ) ).toBe( false );
+ expect( isLastVisibleEntry( items, bad ) ).toBe( false );
+ expect( isLastVisibleEntry( items, visible2 ) ).toBe( true );
+ } );
+
+ test( 'malformed entry in last position does not suppress the real last visible entry', () => {
+ const visible1 = entry( 'Color' );
+ const visible2 = entry( 'Size' );
+ const bad = malformed();
+ const items = [ visible1, visible2, bad ];
+
+ expect( isLastVisibleEntry( items, visible1 ) ).toBe( false );
+ expect( isLastVisibleEntry( items, visible2 ) ).toBe( true );
+ expect( isLastVisibleEntry( items, bad ) ).toBe( false );
+ } );
+
+ test( 'multiple consecutive malformed entries at the end are all skipped', () => {
+ const visible1 = entry( 'Color' );
+ const bad1 = malformed();
+ const bad2 = malformed();
+ const items = [ visible1, bad1, bad2 ];
+
+ expect( isLastVisibleEntry( items, visible1 ) ).toBe( true );
+ expect( isLastVisibleEntry( items, bad1 ) ).toBe( false );
+ expect( isLastVisibleEntry( items, bad2 ) ).toBe( false );
+ } );
+
+ test( 'single hidden entry at the end is skipped', () => {
+ const visible1 = entry( 'Color' );
+ const hiddenLast = hidden( 'Size' );
+ const items = [ visible1, hiddenLast ];
+
+ expect( isLastVisibleEntry( items, visible1 ) ).toBe( true );
+ expect( isLastVisibleEntry( items, hiddenLast ) ).toBe( false );
+ } );
+
+ test( 'multiple consecutive hidden entries at the end are all skipped', () => {
+ const visible1 = entry( 'Color' );
+ const hidden1 = hidden( 'Size' );
+ const hidden2 = hidden( 'Material' );
+ const items = [ visible1, hidden1, hidden2 ];
+
+ expect( isLastVisibleEntry( items, visible1 ) ).toBe( true );
+ expect( isLastVisibleEntry( items, hidden1 ) ).toBe( false );
+ expect( isLastVisibleEntry( items, hidden2 ) ).toBe( false );
+ } );
+
+ test( 'mixed trailing malformed + hidden entries, malformed then hidden', () => {
+ const visible1 = entry( 'Color' );
+ const bad = malformed();
+ const hiddenLast = hidden( 'Size' );
+ const items = [ visible1, bad, hiddenLast ];
+
+ expect( isLastVisibleEntry( items, visible1 ) ).toBe( true );
+ expect( isLastVisibleEntry( items, bad ) ).toBe( false );
+ expect( isLastVisibleEntry( items, hiddenLast ) ).toBe( false );
+ } );
+
+ test( 'mixed trailing malformed + hidden entries, hidden then malformed', () => {
+ const visible1 = entry( 'Color' );
+ const hiddenMiddle = hidden( 'Size' );
+ const bad = malformed();
+ const items = [ visible1, hiddenMiddle, bad ];
+
+ expect( isLastVisibleEntry( items, visible1 ) ).toBe( true );
+ expect( isLastVisibleEntry( items, hiddenMiddle ) ).toBe( false );
+ expect( isLastVisibleEntry( items, bad ) ).toBe( false );
+ } );
+} );
+
+describe( 'buildCartItemDataAttr tests', () => {
+ test( 'never returns null or undefined', () => {
+ expect( buildCartItemDataAttr( undefined ) ).not.toBeNull();
+ expect( buildCartItemDataAttr( undefined ) ).not.toBeUndefined();
+ expect( buildCartItemDataAttr( malformed() ) ).not.toBeNull();
+ } );
+
+ test( 'malformed/empty entry produces empty normalized values', () => {
+ expect( buildCartItemDataAttr( undefined ) ).toEqual( {
+ name: '',
+ value: '',
+ className: 'wc-block-components-product-details__',
+ } );
+ expect( buildCartItemDataAttr( malformed() ) ).toEqual( {
+ name: '',
+ value: '',
+ className: 'wc-block-components-product-details__',
+ } );
+ } );
+
+ test( 'plain text entry', () => {
+ expect(
+ buildCartItemDataAttr( {
+ key: 'Gift Message',
+ value: 'Happy Birthday!',
+ } )
+ ).toEqual( {
+ name: 'Gift Message:',
+ value: 'Happy Birthday!',
+ className: 'wc-block-components-product-details__gift-message',
+ } );
+ } );
+
+ test( 'HTML-in-display value entry', () => {
+ expect(
+ buildCartItemDataAttr( {
+ key: 'Engraving',
+ value: 'Best Wishes',
+ display: '<em>Best Wishes</em>',
+ } )
+ ).toEqual( {
+ name: 'Engraving:',
+ value: '<em>Best Wishes</em>',
+ className: 'wc-block-components-product-details__engraving',
+ } );
+ } );
+
+ test( 'entity-encoded value entry', () => {
+ expect(
+ buildCartItemDataAttr( {
+ key: 'Size',
+ value: '1 < 2',
+ } )
+ ).toEqual( {
+ name: 'Size:',
+ value: '1 < 2',
+ className: 'wc-block-components-product-details__size',
+ } );
+ } );
+
+ test( 'entity-encoded tag entry', () => {
+ expect(
+ buildCartItemDataAttr( {
+ key: 'Note',
+ value: '<b>important</b>',
+ } )
+ ).toEqual( {
+ name: 'Note:',
+ value: '<b>important</b>',
+ className: 'wc-block-components-product-details__note',
+ } );
+ } );
+
+ test( 'ampersand in attribute value entry (variation-style)', () => {
+ expect(
+ buildCartItemDataAttr( {
+ raw_attribute: 'Shade',
+ name: 'Shade',
+ value: 'Red & Blue',
+ } as ItemData )
+ ).toEqual( {
+ name: 'Shade:',
+ value: 'Red & Blue',
+ className: 'wc-block-components-product-details__shade',
+ } );
+ } );
+
+ test( 'derives className from the decoded name: camelCase splitting, tag stripping, and whitespace/underscore/ampersand collapsing', () => {
+ expect(
+ buildCartItemDataAttr( {
+ key: 'giftMessage <b>Type</b> foo_bar & baz',
+ value: 'x',
+ } )
+ ).toEqual( {
+ name: 'giftMessage <b>Type</b> foo_bar & baz:',
+ value: 'x',
+ className:
+ 'wc-block-components-product-details__gift-message-type-foo-bar-baz',
+ } );
+ } );
+
+ test( 'explicitly-hidden well-formed entry is normalized without visibility state', () => {
+ expect(
+ buildCartItemDataAttr( {
+ key: 'Color',
+ value: 'Red',
+ hidden: true,
+ } )
+ ).toEqual( {
+ name: 'Color:',
+ value: 'Red',
+ className: 'wc-block-components-product-details__color',
+ } );
+ } );
+} );
diff --git a/plugins/woocommerce/tests/e2e/test-plugins/blocks/item-data-display-hidden.php b/plugins/woocommerce/tests/e2e/test-plugins/blocks/item-data-display-hidden.php
new file mode 100644
index 00000000000..d1e00292a5c
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/test-plugins/blocks/item-data-display-hidden.php
@@ -0,0 +1,77 @@
+<?php
+/**
+ * Plugin Name: WooCommerce Blocks Test Item Data Display Hidden
+ * Description: Adds a trailing explicitly-hidden item_data entry (or two consecutive ones) to cart items for Mini-Cart e2e tests.
+ * Plugin URI: https://github.com/woocommerce/woocommerce
+ * Author: WooCommerce
+ *
+ * @package woocommerce-blocks-test-item-data-display-hidden
+ */
+
+declare(strict_types=1);
+
+final class Item_Data_Display_Hidden_Test_Fixture {
+ /**
+ * Register fixture hooks.
+ *
+ * @internal
+ */
+ public function __construct() {
+ add_action( 'woocommerce_init', array( $this, 'handle_woocommerce_init' ) );
+ }
+
+ /**
+ * Register the item data filter.
+ *
+ * @internal
+ */
+ public function handle_woocommerce_init(): void {
+ add_filter(
+ 'woocommerce_get_item_data',
+ array( $this, 'handle_woocommerce_get_item_data' ),
+ 10,
+ 2
+ );
+ }
+
+ /**
+ * Add explicitly hidden item data entries.
+ *
+ * @internal
+ *
+ * @param array $item_data Existing item data.
+ * @param array $cart_item Cart item data.
+ * @return array Filtered item data.
+ */
+ public function handle_woocommerce_get_item_data( array $item_data, array $cart_item ): array {
+ // A well-formed leading entry, so there is always a defined
+ // "last visible entry" to check the separator against.
+ $item_data[] = array(
+ 'key' => 'Gift Message',
+ 'value' => 'Happy Birthday!',
+ );
+
+ // Well-formed (usable key/value) but explicitly hidden.
+ $item_data[] = array(
+ 'key' => 'Secret',
+ 'value' => 'v',
+ 'hidden' => true,
+ );
+
+ // A cart line added with quantity 2 instead ends with two
+ // *consecutive* explicitly-hidden entries, so both the
+ // single- and double-trailing-hidden scenarios are covered
+ // by this one fixture.
+ if ( 2 === (int) ( $cart_item['quantity'] ?? 0 ) ) {
+ $item_data[] = array(
+ 'key' => 'Secret 2',
+ 'value' => 'v2',
+ 'hidden' => true,
+ );
+ }
+
+ return $item_data;
+ }
+}
+
+new Item_Data_Display_Hidden_Test_Fixture();
diff --git a/plugins/woocommerce/tests/e2e/test-plugins/blocks/item-data-display-malformed.php b/plugins/woocommerce/tests/e2e/test-plugins/blocks/item-data-display-malformed.php
new file mode 100644
index 00000000000..cb80ebfa9eb
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/test-plugins/blocks/item-data-display-malformed.php
@@ -0,0 +1,76 @@
+<?php
+/**
+ * Plugin Name: WooCommerce Blocks Test Item Data Display Malformed
+ * Description: Adds a trailing malformed item_data entry (or two consecutive ones) to cart items for Mini-Cart e2e tests.
+ * Plugin URI: https://github.com/woocommerce/woocommerce
+ * Author: WooCommerce
+ *
+ * @package woocommerce-blocks-test-item-data-display-malformed
+ */
+
+declare(strict_types=1);
+
+final class Item_Data_Display_Malformed_Test_Fixture {
+ /**
+ * Register fixture hooks.
+ *
+ * @internal
+ */
+ public function __construct() {
+ add_action( 'woocommerce_init', array( $this, 'handle_woocommerce_init' ) );
+ }
+
+ /**
+ * Register the item data filter.
+ *
+ * @internal
+ */
+ public function handle_woocommerce_init(): void {
+ add_filter(
+ 'woocommerce_get_item_data',
+ array( $this, 'handle_woocommerce_get_item_data' ),
+ 10,
+ 2
+ );
+ }
+
+ /**
+ * Add malformed item data entries.
+ *
+ * @internal
+ *
+ * @param array $item_data Existing item data.
+ * @param array $cart_item Cart item data.
+ * @return array Filtered item data.
+ */
+ public function handle_woocommerce_get_item_data( array $item_data, array $cart_item ): array {
+ // A well-formed leading entry, so there is always a defined
+ // "last visible entry" to check the separator against.
+ $item_data[] = array(
+ 'key' => 'Gift Message',
+ 'value' => 'Happy Birthday!',
+ );
+
+ // Malformed: no usable key/attribute/name and no usable
+ // display/value.
+ $item_data[] = array(
+ 'key' => '',
+ 'value' => '',
+ );
+
+ // A cart line added with quantity 2 instead ends with two
+ // *consecutive* malformed entries, so both the single- and
+ // double-trailing-malformed scenarios are covered by this
+ // one fixture.
+ if ( 2 === (int) ( $cart_item['quantity'] ?? 0 ) ) {
+ $item_data[] = array(
+ 'key' => '',
+ 'value' => '',
+ );
+ }
+
+ return $item_data;
+ }
+}
+
+new Item_Data_Display_Malformed_Test_Fixture();
diff --git a/plugins/woocommerce/tests/e2e/test-plugins/blocks/item-data-display-mixed.php b/plugins/woocommerce/tests/e2e/test-plugins/blocks/item-data-display-mixed.php
new file mode 100644
index 00000000000..6cf540eb8e5
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/test-plugins/blocks/item-data-display-mixed.php
@@ -0,0 +1,85 @@
+<?php
+/**
+ * Plugin Name: WooCommerce Blocks Test Item Data Display Mixed
+ * Description: Adds a trailing mix of a malformed entry and an explicitly-hidden entry, in both orders, to cart items for Mini-Cart e2e tests.
+ * Plugin URI: https://github.com/woocommerce/woocommerce
+ * Author: WooCommerce
+ *
+ * @package woocommerce-blocks-test-item-data-display-mixed
+ */
+
+declare(strict_types=1);
+
+final class Item_Data_Display_Mixed_Test_Fixture {
+ /**
+ * Register fixture hooks.
+ *
+ * @internal
+ */
+ public function __construct() {
+ add_action( 'woocommerce_init', array( $this, 'handle_woocommerce_init' ) );
+ }
+
+ /**
+ * Register the item data filter.
+ *
+ * @internal
+ */
+ public function handle_woocommerce_init(): void {
+ add_filter(
+ 'woocommerce_get_item_data',
+ array( $this, 'handle_woocommerce_get_item_data' ),
+ 10,
+ 2
+ );
+ }
+
+ /**
+ * Add mixed malformed and hidden item data entries.
+ *
+ * @internal
+ *
+ * @param array $item_data Existing item data.
+ * @param array $cart_item Cart item data.
+ * @return array Filtered item data.
+ */
+ public function handle_woocommerce_get_item_data( array $item_data, array $cart_item ): array {
+ // A well-formed leading entry, so there is always a defined
+ // "last visible entry" to check the separator against.
+ $item_data[] = array(
+ 'key' => 'Gift Message',
+ 'value' => 'Happy Birthday!',
+ );
+
+ // A cart line added with quantity 2 ends with a hidden
+ // entry followed by a malformed one (the reverse order);
+ // otherwise (quantity 1) it ends with a malformed entry
+ // followed by a hidden one. Both orders are covered by
+ // this one fixture.
+ if ( 2 === (int) ( $cart_item['quantity'] ?? 0 ) ) {
+ $item_data[] = array(
+ 'key' => 'Secret',
+ 'value' => 'v',
+ 'hidden' => true,
+ );
+ $item_data[] = array(
+ 'key' => '',
+ 'value' => '',
+ );
+ } else {
+ $item_data[] = array(
+ 'key' => '',
+ 'value' => '',
+ );
+ $item_data[] = array(
+ 'key' => 'Secret',
+ 'value' => 'v',
+ 'hidden' => true,
+ );
+ }
+
+ return $item_data;
+ }
+}
+
+new Item_Data_Display_Mixed_Test_Fixture();
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/mini-cart/mini-cart.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/mini-cart/mini-cart.block_theme.spec.ts
index 19596aaa996..1bf9957e2dc 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/mini-cart/mini-cart.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/mini-cart/mini-cart.block_theme.spec.ts
@@ -2,6 +2,7 @@
* External dependencies
*/
import { test, expect, BlockData, wpCLI } from '@woocommerce/e2e-utils';
+import type { Page } from '@playwright/test';
/**
* Internal dependencies
@@ -185,6 +186,53 @@ test.describe( `${ blockData.name } Block`, () => {
await checkProductLink( page );
} );
+ test( 'should render the product table with no client-side error for a product with no item data', async ( {
+ page,
+ frontendUtils,
+ miniCartUtils,
+ } ) => {
+ const pageErrors: string[] = [];
+ const consoleErrors: string[] = [];
+
+ // Register listeners before navigating so nothing that happens during
+ // the mini-cart's render is missed. This product has no variation and
+ // no `woocommerce_get_item_data` additions (no fixture plugin is
+ // activated in this describe block), which is the plain rendering
+ // path the item-data/separator getters must not throw on.
+ page.on( 'pageerror', ( error ) => {
+ pageErrors.push( error.message );
+ } );
+ page.on( 'console', ( message ) => {
+ // Exclude the browser's own "failed to load resource" network
+ // diagnostics: they are not JS errors raised by page code (no
+ // `console.error` call is involved) and can fire for reasons
+ // entirely unrelated to the item-data/separator logic under
+ // test, e.g. an unrelated missing stylesheet.
+ if (
+ message.type() === 'error' &&
+ ! message.text().includes( 'Failed to load resource' )
+ ) {
+ consoleErrors.push( message.text() );
+ }
+ } );
+
+ await frontendUtils.goToShop();
+ await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
+ await miniCartUtils.openMiniCart();
+
+ // The product table renders normally: name, price, quantity.
+ await checkProductLink( page );
+ await expect(
+ page.locator( '.wc-block-components-product-price' ).first()
+ ).toBeVisible();
+ await expect(
+ page.getByLabel( 'Quantity of Polo in your cart.' )
+ ).toHaveValue( '1' );
+
+ expect( pageErrors ).toEqual( [] );
+ expect( consoleErrors ).toEqual( [] );
+ } );
+
test( 'should show subtotal, view cart button and checkout button', async ( {
page,
frontendUtils,
@@ -545,6 +593,382 @@ test.describe( `${ blockData.name } Block (item data)`, () => {
await expect( noteValue ).toBeVisible();
await expect( noteValue.locator( 'b' ) ).toHaveCount( 0 );
} );
+
+ test( 'should show a separator between item-data entries but not after the last one', async ( {
+ page,
+ frontendUtils,
+ miniCartUtils,
+ } ) => {
+ await frontendUtils.goToShop();
+ await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
+ await miniCartUtils.openMiniCart();
+
+ const dialog = page.getByRole( 'dialog' );
+ await expect( dialog ).toBeVisible();
+
+ // The fixture plugin injects 4 well-formed entries (Gift Message,
+ // Engraving, Size, Note) onto the cart item. Confirm all 4 rendered
+ // before counting separators, so a count mismatch below can only be
+ // attributed to separator placement, not to a missing entry.
+ const entryNames = dialog.locator(
+ '.wc-block-components-product-details__name'
+ );
+ await expect( entryNames ).toHaveCount( 4 );
+
+ // Each entry's trailing separator is a `span[aria-hidden="true"]`;
+ // the store getter that drives its `hidden` binding removes the
+ // `hidden` attribute exactly for the entries that should show a
+ // separator. Count only the ones that are actually visible (not
+ // `hidden`) — using `textContent` with a regex would also match the
+ // text of `hidden` separators and give a false pass/fail.
+ const visibleSeparators = dialog.locator(
+ '.wc-block-components-product-details span[aria-hidden="true"]:not([hidden])'
+ );
+ await expect( visibleSeparators ).toHaveCount( 3 );
+ } );
+} );
+
+/**
+ * Registers page-error and console-error listeners for a defect scenario
+ * (trailing malformed/hidden item-data entries). Must be called before
+ * navigating so nothing that happens during the mini-cart's render is
+ * missed — mirrors the listener setup used by the "no client-side error"
+ * test above.
+ *
+ * @param {Page} page Playwright page to observe.
+ * @return {{pageErrors: string[], consoleErrors: string[]}} Arrays that
+ * accumulate as
+ * errors fire.
+ */
+const trackErrors = ( page: Page ) => {
+ const pageErrors: string[] = [];
+ const consoleErrors: string[] = [];
+
+ page.on( 'pageerror', ( error ) => {
+ pageErrors.push( error.message );
+ } );
+ page.on( 'console', ( message ) => {
+ // Exclude the browser's own "failed to load resource" network
+ // diagnostics: they are not JS errors raised by page code (no
+ // `console.error` call is involved) and can fire for reasons
+ // entirely unrelated to the item-data/separator logic under test.
+ if (
+ message.type() === 'error' &&
+ ! message.text().includes( 'Failed to load resource' )
+ ) {
+ consoleErrors.push( message.text() );
+ }
+ } );
+
+ return { pageErrors, consoleErrors };
+};
+
+test.describe( `${ blockData.name } Block (item data - malformed trailing entries)`, () => {
+ test.use( {
+ storageState: {
+ origins: [],
+ cookies: [],
+ },
+ } );
+
+ // Activate in beforeEach because the DB is reset after every test.
+ test.beforeEach( async ( { requestUtils } ) => {
+ await requestUtils.activatePlugin(
+ 'woocommerce-blocks-test-item-data-display-malformed'
+ );
+ } );
+
+ test( 'should render no client-side error and no trailing separator when the item data ends with a single malformed entry', async ( {
+ page,
+ frontendUtils,
+ miniCartUtils,
+ } ) => {
+ const { pageErrors, consoleErrors } = trackErrors( page );
+
+ await frontendUtils.goToShop();
+ // Quantity 1 selects the fixture's [visible, malformed] scenario.
+ await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
+ await miniCartUtils.openMiniCart();
+
+ const dialog = page.getByRole( 'dialog' );
+ await expect( dialog ).toBeVisible();
+
+ // Both entries render into the DOM (the malformed one hidden);
+ // confirm the DOM count before checking visibility, so a mismatch
+ // below can only be attributed to visibility/separator logic, not
+ // to a missing entry.
+ const allNames = dialog.locator(
+ '.wc-block-components-product-details__name'
+ );
+ await expect( allNames ).toHaveCount( 2 );
+
+ // The malformed entry renders no visible name/value: only the
+ // well-formed "Gift Message" entry is visible. Using `:visible`
+ // filters out elements hidden via the `hidden` attribute, unlike a
+ // plain `textContent` check which would also match hidden text.
+ const visibleNames = dialog.locator(
+ '.wc-block-components-product-details__name:visible'
+ );
+ await expect( visibleNames ).toHaveCount( 1 );
+ await expect( visibleNames ).toContainText( [ 'Gift Message' ] );
+
+ // The last (and only) visible entry shows no trailing separator,
+ // and no stray separator appears anywhere else in the list. Count
+ // only separators that are actually visible. `:visible` accounts
+ // for ancestor visibility, so a malformed/hidden entry's separator
+ // span — whose own `hidden` attribute is absent because it is not
+ // the last *visible* entry, but whose entry wrapper is hidden — is
+ // correctly excluded. A plain `:not([hidden])` attribute check
+ // would miss the hidden ancestor and a `textContent` regex would
+ // match hidden text.
+ const visibleSeparators = dialog.locator(
+ '.wc-block-components-product-details span[aria-hidden="true"]:visible'
+ );
+ await expect( visibleSeparators ).toHaveCount( 0 );
+
+ expect( pageErrors ).toEqual( [] );
+ expect( consoleErrors ).toEqual( [] );
+ } );
+
+ test( 'should render no client-side error and no trailing separator when the item data ends with two consecutive malformed entries', async ( {
+ page,
+ frontendUtils,
+ miniCartUtils,
+ } ) => {
+ const { pageErrors, consoleErrors } = trackErrors( page );
+
+ await frontendUtils.goToShop();
+ // Adding the same product twice merges into a single cart line with
+ // quantity 2, which selects the fixture's
+ // [visible, malformed, malformed] scenario.
+ await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
+ await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
+ await miniCartUtils.openMiniCart();
+
+ const dialog = page.getByRole( 'dialog' );
+ await expect( dialog ).toBeVisible();
+
+ const allNames = dialog.locator(
+ '.wc-block-components-product-details__name'
+ );
+ await expect( allNames ).toHaveCount( 3 );
+
+ const visibleNames = dialog.locator(
+ '.wc-block-components-product-details__name:visible'
+ );
+ await expect( visibleNames ).toHaveCount( 1 );
+ await expect( visibleNames ).toContainText( [ 'Gift Message' ] );
+
+ // Count only separators that are actually visible. `:visible`
+ // accounts for ancestor visibility, so a malformed/hidden entry's
+ // separator span (hidden via its entry wrapper, not its own
+ // attribute) is correctly excluded — unlike `:not([hidden])`.
+ const visibleSeparators = dialog.locator(
+ '.wc-block-components-product-details span[aria-hidden="true"]:visible'
+ );
+ await expect( visibleSeparators ).toHaveCount( 0 );
+
+ expect( pageErrors ).toEqual( [] );
+ expect( consoleErrors ).toEqual( [] );
+ } );
+} );
+
+test.describe( `${ blockData.name } Block (item data - hidden trailing entries)`, () => {
+ test.use( {
+ storageState: {
+ origins: [],
+ cookies: [],
+ },
+ } );
+
+ // Activate in beforeEach because the DB is reset after every test.
+ test.beforeEach( async ( { requestUtils } ) => {
+ await requestUtils.activatePlugin(
+ 'woocommerce-blocks-test-item-data-display-hidden'
+ );
+ } );
+
+ test( 'should render no client-side error and no trailing separator when the item data ends with a single explicitly-hidden entry', async ( {
+ page,
+ frontendUtils,
+ miniCartUtils,
+ } ) => {
+ const { pageErrors, consoleErrors } = trackErrors( page );
+
+ await frontendUtils.goToShop();
+ // Quantity 1 selects the fixture's [visible, hidden] scenario.
+ await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
+ await miniCartUtils.openMiniCart();
+
+ const dialog = page.getByRole( 'dialog' );
+ await expect( dialog ).toBeVisible();
+
+ const allNames = dialog.locator(
+ '.wc-block-components-product-details__name'
+ );
+ await expect( allNames ).toHaveCount( 2 );
+
+ // The explicitly-hidden entry renders no visible name/value ("Secret"
+ // never appears among the visible entries): only the well-formed
+ // "Gift Message" entry is visible.
+ const visibleNames = dialog.locator(
+ '.wc-block-components-product-details__name:visible'
+ );
+ await expect( visibleNames ).toHaveCount( 1 );
+ await expect( visibleNames ).toContainText( [ 'Gift Message' ] );
+
+ // Count only separators that are actually visible. `:visible`
+ // accounts for ancestor visibility, so a malformed/hidden entry's
+ // separator span (hidden via its entry wrapper, not its own
+ // attribute) is correctly excluded — unlike `:not([hidden])`.
+ const visibleSeparators = dialog.locator(
+ '.wc-block-components-product-details span[aria-hidden="true"]:visible'
+ );
+ await expect( visibleSeparators ).toHaveCount( 0 );
+
+ expect( pageErrors ).toEqual( [] );
+ expect( consoleErrors ).toEqual( [] );
+ } );
+
+ test( 'should render no client-side error and no trailing separator when the item data ends with two consecutive explicitly-hidden entries', async ( {
+ page,
+ frontendUtils,
+ miniCartUtils,
+ } ) => {
+ const { pageErrors, consoleErrors } = trackErrors( page );
+
+ await frontendUtils.goToShop();
+ // Adding the same product twice merges into a single cart line with
+ // quantity 2, which selects the fixture's
+ // [visible, hidden, hidden] scenario.
+ await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
+ await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
+ await miniCartUtils.openMiniCart();
+
+ const dialog = page.getByRole( 'dialog' );
+ await expect( dialog ).toBeVisible();
+
+ const allNames = dialog.locator(
+ '.wc-block-components-product-details__name'
+ );
+ await expect( allNames ).toHaveCount( 3 );
+
+ const visibleNames = dialog.locator(
+ '.wc-block-components-product-details__name:visible'
+ );
+ await expect( visibleNames ).toHaveCount( 1 );
+ await expect( visibleNames ).toContainText( [ 'Gift Message' ] );
+
+ // Count only separators that are actually visible. `:visible`
+ // accounts for ancestor visibility, so a malformed/hidden entry's
+ // separator span (hidden via its entry wrapper, not its own
+ // attribute) is correctly excluded — unlike `:not([hidden])`.
+ const visibleSeparators = dialog.locator(
+ '.wc-block-components-product-details span[aria-hidden="true"]:visible'
+ );
+ await expect( visibleSeparators ).toHaveCount( 0 );
+
+ expect( pageErrors ).toEqual( [] );
+ expect( consoleErrors ).toEqual( [] );
+ } );
+} );
+
+test.describe( `${ blockData.name } Block (item data - mixed trailing entries)`, () => {
+ test.use( {
+ storageState: {
+ origins: [],
+ cookies: [],
+ },
+ } );
+
+ // Activate in beforeEach because the DB is reset after every test.
+ test.beforeEach( async ( { requestUtils } ) => {
+ await requestUtils.activatePlugin(
+ 'woocommerce-blocks-test-item-data-display-mixed'
+ );
+ } );
+
+ test( 'should show no trailing separator when the item data ends with a malformed entry followed by a hidden entry', async ( {
+ page,
+ frontendUtils,
+ miniCartUtils,
+ } ) => {
+ const { pageErrors, consoleErrors } = trackErrors( page );
+
+ await frontendUtils.goToShop();
+ // Quantity 1 selects the fixture's
+ // [visible, malformed, hidden] scenario.
+ await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
+ await miniCartUtils.openMiniCart();
+
+ const dialog = page.getByRole( 'dialog' );
+ await expect( dialog ).toBeVisible();
+
+ const allNames = dialog.locator(
+ '.wc-block-components-product-details__name'
+ );
+ await expect( allNames ).toHaveCount( 3 );
+
+ const visibleNames = dialog.locator(
+ '.wc-block-components-product-details__name:visible'
+ );
+ await expect( visibleNames ).toHaveCount( 1 );
+ await expect( visibleNames ).toContainText( [ 'Gift Message' ] );
+
+ // Count only separators that are actually visible. `:visible`
+ // accounts for ancestor visibility, so a malformed/hidden entry's
+ // separator span (hidden via its entry wrapper, not its own
+ // attribute) is correctly excluded — unlike `:not([hidden])`.
+ const visibleSeparators = dialog.locator(
+ '.wc-block-components-product-details span[aria-hidden="true"]:visible'
+ );
+ await expect( visibleSeparators ).toHaveCount( 0 );
+
+ expect( pageErrors ).toEqual( [] );
+ expect( consoleErrors ).toEqual( [] );
+ } );
+
+ test( 'should show no trailing separator when the item data ends with a hidden entry followed by a malformed entry', async ( {
+ page,
+ frontendUtils,
+ miniCartUtils,
+ } ) => {
+ const { pageErrors, consoleErrors } = trackErrors( page );
+
+ await frontendUtils.goToShop();
+ // Adding the same product twice merges into a single cart line with
+ // quantity 2, which selects the fixture's
+ // [visible, hidden, malformed] scenario (the reverse order).
+ await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
+ await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
+ await miniCartUtils.openMiniCart();
+
+ const dialog = page.getByRole( 'dialog' );
+ await expect( dialog ).toBeVisible();
+
+ const allNames = dialog.locator(
+ '.wc-block-components-product-details__name'
+ );
+ await expect( allNames ).toHaveCount( 3 );
+
+ const visibleNames = dialog.locator(
+ '.wc-block-components-product-details__name:visible'
+ );
+ await expect( visibleNames ).toHaveCount( 1 );
+ await expect( visibleNames ).toContainText( [ 'Gift Message' ] );
+
+ // Count only separators that are actually visible. `:visible`
+ // accounts for ancestor visibility, so a malformed/hidden entry's
+ // separator span (hidden via its entry wrapper, not its own
+ // attribute) is correctly excluded — unlike `:not([hidden])`.
+ const visibleSeparators = dialog.locator(
+ '.wc-block-components-product-details span[aria-hidden="true"]:visible'
+ );
+ await expect( visibleSeparators ).toHaveCount( 0 );
+
+ expect( pageErrors ).toEqual( [] );
+ expect( consoleErrors ).toEqual( [] );
+ } );
} );
test.describe( `${ blockData.name } Block (variation attributes)`, () => {