Commit 656a6bfc2e4 for woocommerce
commit 656a6bfc2e42d01d90fc2d8c1d42237beda81271
Author: Luis Herranz <luisherranz@gmail.com>
Date: Mon Jul 6 10:53:49 2026 +0200
Fix iAPI's addToCart action mishandling cart lines differentiated by item meta (#65869)
* Make addCartItem endpoint selection a pure function of caller key-presence
Derive isUpdate from the caller-supplied key instead of a line matched by
id/variation, so a keyless add always issues add-item with a delta and never
an update-item even when an existing keyed line matches by product id. The
optimistic render and the delta-on-add-item amount derivation are unchanged.
* Apply keyless-add-item rule to batchAddCartItems endpoint selection
Derive isUpdate from the caller-supplied item.key instead of a line matched by
id/variation, mirroring the single-item addCartItem path. A keyless batch item
now always issues add-item with a delta and never update-item, even when an
existing keyed line matches by product id, so the server owns cart-line
identity for grouped/variation adds too. An explicit caller key still issues
update-item with the absolute quantity for that exact line. The per-item
optimistic render is unchanged.
* Add keyless-requires-delta invariant guard to addCartItem
Throw a clear error when addCartItem is called keyless with an absolute
quantity but no quantityToAdd. Keyless adds always issue add-item with a
delta, so an absolute quantity would be misread as a delta and compound
across rapid clicks. Keyed callers (mini-cart steppers) still pass an
absolute quantity with a key via update-item and remain unaffected.
* Suppress spurious notice-diff false positive for keyless meta-only adds
* Sum over matching cart lines for saved-for-later add-success detection
Replace the single-line findItemInCart before/after read in onClickMoveToCart
with a sum of quantity over every cart line matching the product (id for
simple products; id plus variation attributes via doesCartItemMatchAttributes
for variations), and remove the saved entry only when the total grew. A correct
add the server resolves as a new standalone line is no longer misread as a
failure. The cart store findItemInCart selector is unchanged.
* Sum over matching cart lines for wishlist add-success detection
Replace the single-line findItemInCart before/after read in
onClickAddToCart with a total-quantity sum over every cart line matching
the product (id for simple products; id + variation attributes for
variations). The server owns cart-line identity for adds, so a correct
add can land as a new standalone line; reading one id-matched line would
misread that as a failure and wrongly keep the wishlist entry. The entry
is now removed only when the summed quantity grew, mirroring the
saved-for-later correction so the two consumers stay consistent.
* Add store-level unit coverage for add-path cap error notice and 2-meta-line notice-absence
* Add cart-line-identity e2e test-helper plugin
Simulate a meta-differentiated cart line (bundle child / booking /
add-on / recipient stand-in) for the add-to-cart cart-line-identity
e2e flows. The helper hooks woocommerce_add_cart_item_data and, when a
flagged request carries the cart_line_identity_marker query/request
flag, attaches a unique cart_item_data entry so core's generate_cart_id
produces a distinct cart line for the same product id, without
modifying any product or shipping a real extension. It has effect only
when activated and changes no WooCommerce source behavior.
* Add cart-line-identity add-to-cart e2e specs
Automate E2E Flows 1-9 for the keyless add-to-cart cart-line-identity
behavior as Playwright specs under the blocks project. The meta-line is
simulated by the committed cart-line-identity helper plugin via a flagged
legacy add-to-cart navigation.
Asserted end-to-end (blocks-chromium): meta-only add creates a new
standalone line with the meta line unchanged and no error notice (1, 2);
existing standalone increments (3); standalone+meta only the standalone
increments (4); not-in-cart creates a line (5); variation re-add
increments and a different variation creates a new line via Add to Cart
with Options (6); mini-cart stepper updates the exact line with no
spurious notice and no extra line (7); meta-only keyless add cart-outcome
for the notice-showing consumers (8); genuine rejection leaves the cart
unchanged and surfaces the error (9).
The saved-for-later and wishlist blocks are gated behind the
product_wishlist feature flag and are not registered in the e2e
environment, so their consumer-specific notice/entry guarantees (no
spurious "quantity changed" info notice on a meta-only add; entry
preserved on a genuine rejection) are covered authoritatively at the
store/consumer unit level (Tasks 5, 6, 7); the cart-outcome substrate
those consumers rely on is asserted here.
* Align cart-store add/update endpoint-decision comments with the server-delegated rule
Refresh the inline rationale in addCartItem and batchAddCartItems so a future
maintainer reads endpoint choice as a pure function of caller key-presence
(keyless => add-item with a delta; keyed => update-item with an absolute
quantity) and understands the keyless path delegates cart-line identity to the
server. Add the iron-rule note at both in-place optimistic bumps: the bump is
render-only and must never feed back into endpoint selection or the posted
amount, warning off any future edit that would reintroduce id-based add/update
matching. Comments only; no code logic, control flow, or JSDoc changed.
* Document the keyless-requires-delta guard contract
Refine the inline rationale comment above the keyless-requires-delta
guard in addCartItem so its contract is self-documenting: a keyless add
must carry a quantityToAdd delta because rapid-click compounding relies
on deltas, and an absolute quantity is legitimate only on the keyed
stepper path (mini-cart / cart-block quantity controls), which targets a
known line via update-item and is intentionally exempt from the guard.
* Document the keyless-scoped pre-optimistic baseline notice diff
* Document sum-over-matching-lines success detection in the list consumers
Refine the inline rationale comments in saved-for-later's onClickMoveToCart
and wishlist's onClickAddToCart so a maintainer reading either add handler
understands that the list entry is removed only when the total quantity
across all matching cart lines grew, and why a single id-matched-line read
would misjudge the new-line case (it can resolve to the unchanged
pre-existing meta line both times and misread a correct add as a failure).
Preserves the still-accurate error-swallowing note, the server-reconciled
read note, and the variation-attribute mapping note.
* Document the cart-line-identity e2e helper plugin inline
Refine the new helper plugin's header and inline docblocks so a future blocks
e2e test author understands what it simulates (a meta-differentiated cart line:
bundle child / booking / add-on / recipient stand-in), how to activate it (the
woocommerce-blocks-test-cart-line-identity slug), and how toggling the marker
flag seeds the meta line before a plain add. States it is test-only and changes
no WooCommerce source. Tightens the header Description to one line to match the
sibling helper plugins in the same directory.
* Add changelog entry for the add-to-cart cart-line-identity fix
* Update mutation-batcher e2e tests to use quantityToAdd deltas
These tests called the keyless addCartItem with an absolute quantity, which the new keyless-requires-delta store guard now rejects, failing the 5 store-API tests. Switch them to quantityToAdd (the canonical keyless form already used by every production caller). The batcher behavior under test is unchanged: each call still issues one add-item request.
* Co-locate cart-store e2e suites under tests/blocks/cart-store
Move the two Interactivity API cart-store e2e suites — the cart-line-identity add-to-cart specs and the mutation-batcher regression suite — into a shared tests/blocks/cart-store/ folder with their helper utils, and drop the redundant "add-to-cart-" prefix from the cart-line-identity spec filename. Both exercise the generic woocommerce/cart store rather than any single block (mutation-batcher already lived as its own non-block folder), so grouping them makes that intent explicit. Pure file moves: the ../add-to-cart-with-options page object and ./utils imports still resolve, the Playwright blocks-chromium project globs the tree, and all 15 tests pass from the new location.
* Remove internal pipeline references from cart-store e2e comments
The cart-line-identity e2e spec and its utils referenced an internal development pipeline's artifacts — "Flow N" / "Task N" numbering drawn from its spec, design doc, and task list — that a reader of this repository cannot resolve. Strip those dangling cross-references: drop the "Flow N" prefixes from the test titles (keeping the descriptive part), and reword the header and inline comments to point at concrete things — the store and consumer unit tests, the helper plugin — instead of pipeline task numbers. Comment-only; no behavior change.
* Drop markdown emphasis from cart-store JSDoc comments
Remove **bold** markdown from a few JSDoc comments in the cart store and the saved-for-later / wishlist consumers; the emphasis markup renders as literal asterisks and adds noise in plain-text source. Comment-only; no behavior change.
* Replace per-line keyless-add baseline with per-product total exactness test threading suppressKeys to notice diff
* Remove private-spec references from cart store tests
* Fix prettier formatting in cart store and tests
* Refactor seedMetaLine e2e helper to avoid eslint-plugin-jsdoc crash
* Reconcile cart-store e2e suite onto trunk tests/e2e layout
* Drop internal "iron rule" jargon from cart store delta-math comments
Rephrases four comments in the keyless add / batch-add delta logic to describe
the invariant directly instead of naming it "the iron rule". Comments only; no
logic change.
* Remove forbidden non-null assertion in cart store batch capture
The batch-add path looked up a per-product capture with
`batchProductCaptures.get( token )!`, tripping the blocks
@typescript-eslint/no-non-null-assertion lint rule that failed CI. Replace
the `!` with a single guarded lookup; behavior is unchanged (in this branch
the token is always present) and the redundant Map lookup is gone.
---------
Co-authored-by: luisherranz <luis.herranz@automattic.com>
diff --git a/plugins/woocommerce/changelog/billow-69-add-to-cart-cart-line-identity b/plugins/woocommerce/changelog/billow-69-add-to-cart-cart-line-identity
new file mode 100644
index 00000000000..5c5e8a1eb82
--- /dev/null
+++ b/plugins/woocommerce/changelog/billow-69-add-to-cart-cart-line-identity
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Block add to cart: adding a product that is in the cart only inside a bundle (or only as a booking, add-on configuration, or recipient-differentiated line) now adds a new standalone line instead of showing an error or changing the existing line's quantity.
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/cart.ts b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/cart.ts
index f6570227d1f..7fdcf3f9d70 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/cart.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/cart.ts
@@ -134,9 +134,153 @@ const generateInfoNotice = ( message: string ): Notice => ( {
dismissible: true,
} );
+/**
+ * Computes the canonical product token for accumulating per-product totals
+ * across a batch.
+ *
+ * The token is stable: simple items produce `"<id>"` and variation items
+ * produce `"<id>|<attr1>=<val1>&..."` with attributes sorted alphabetically
+ * by name so insertion order differences do not produce different tokens.
+ *
+ * @param id The product id.
+ * @param variation The variation attributes, if any.
+ * @return A canonical string token that uniquely identifies this product.
+ */
+function productToken(
+ id: number,
+ variation?: CartVariationItem[] | SelectedAttributes[]
+): string {
+ if ( ! variation || variation.length === 0 ) {
+ return String( id );
+ }
+ const attrs = [ ...variation ]
+ .sort( ( a, b ) => a.attribute.localeCompare( b.attribute ) )
+ .map( ( v ) => `${ v.attribute }=${ v.value }` )
+ .join( '&' );
+ return `${ id }|${ attrs }`;
+}
+
+/**
+ * Returns `true` when the given cart line matches the product identified by
+ * `id` and `variation`, using the same matching logic as `findItemInCart`.
+ *
+ * Simple items match by `id` equality. Variation items additionally require
+ * `variation.length` equality and `doesCartItemMatchAttributes`.
+ *
+ * @param item The cart line to test.
+ * @param id The product id to match against.
+ * @param variation The variation attributes to match against, if any.
+ * @return `true` when the line belongs to the specified product.
+ */
+function lineMatchesProduct(
+ item: OptimisticCartItem | CartItem,
+ id: number,
+ variation?: CartVariationItem[] | SelectedAttributes[]
+): boolean {
+ if ( item.type === 'variation' ) {
+ if (
+ id !== item.id ||
+ ! item.variation ||
+ ! variation ||
+ item.variation.length !== variation.length
+ ) {
+ return false;
+ }
+ return doesCartItemMatchAttributes( item, variation );
+ }
+ return id === item.id;
+}
+
+/**
+ * Builds a `Set` of pre-existing cart-line keys to suppress from the
+ * "quantity changed" auto-update notice after a successful keyless add.
+ *
+ * For each product entry in `products`, computes:
+ * - `serverTotal` = sum of the committed server cart's lines matching that
+ * product (using the same matcher as `findItemInCart`).
+ * - `expectedTotal` = pre-add total + sum of posted deltas.
+ *
+ * When `serverTotal === expectedTotal`, the add was exact for that product
+ * (no server-initiated cap, redistribution, or concurrent change), so every
+ * pre-existing line key captured for that product is added to the returned
+ * set and will be skipped in the auto-UPDATE notice diff.
+ *
+ * When the totals diverge, the product's keys are left out of the set and
+ * the diff fires normally, reporting the server's actual quantity.
+ *
+ * Only keyless adds should call this helper. Keyed `update-item` changes
+ * must never populate `products`; leaving their line keys out of the
+ * suppression set ensures the "your change was undone" notice keeps firing.
+ *
+ * @param products Per-product capture records, one per added product.
+ * @param serverCart The committed server cart to sum against.
+ * @return The flat set of pre-existing line keys to suppress.
+ */
+function computeKeylessAddSuppressKeys(
+ products: Array< {
+ /** The product id used for matching. */
+ id: number;
+ /** The variation attributes used for matching, if any. */
+ variation?: CartVariationItem[] | SelectedAttributes[] | undefined;
+ /** Sum of all pre-add quantities across matching lines, captured before the optimistic bump. */
+ preAddTotal: number;
+ /** Sum of all posted deltas for this product in this add cycle. */
+ deltaTotal: number;
+ /** The pre-existing cart-line keys belonging to this product, captured before the optimistic bump. */
+ preExistingKeys: string[];
+ } >,
+ serverCart: Cart
+): Set< string > {
+ const suppressKeys = new Set< string >();
+ for ( const product of products ) {
+ const serverTotal = serverCart.items
+ .filter( ( item ) =>
+ lineMatchesProduct( item, product.id, product.variation )
+ )
+ .reduce( ( sum, item ) => sum + item.quantity, 0 );
+ const expectedTotal = product.preAddTotal + product.deltaTotal;
+ if ( serverTotal === expectedTotal ) {
+ for ( const key of product.preExistingKeys ) {
+ suppressKeys.add( key );
+ }
+ }
+ }
+ return suppressKeys;
+}
+
+/**
+ * Derives the auto-update and auto-removal info notices from the diff between
+ * the post-optimistic cart and the committed server cart.
+ *
+ * Auto-removal notices fire for lines present in `oldCart` that the server
+ * dropped entirely (stock removal, product deletion, etc.). Because
+ * `oldCart` is the post-optimistic snapshot, user-initiated removals are
+ * already absent and do not produce spurious notices.
+ *
+ * Auto-update notices fire for server lines whose quantity differs from the
+ * post-optimistic snapshot, with one suppression rule: any line whose key
+ * appears in `suppressKeys` is skipped unconditionally. The action populates
+ * `suppressKeys` with the pre-existing keys of products whose keyless add was
+ * exact (server total == pre-add total + posted delta), so a successful keyless
+ * add never emits a spurious "quantity changed" notice regardless of which
+ * server line received the delta. Genuine server changes (cap, clamp, concurrent
+ * mutation) still notify because they make the per-product totals diverge and
+ * the keys are left out of the set.
+ *
+ * Keyed `update-item` changes and `removeCartItem` never populate
+ * `suppressKeys` (the parameter defaults to an empty set), so their notice
+ * behavior is byte-for-byte unchanged.
+ *
+ * @param oldCart The post-optimistic cart snapshot used as the diff baseline.
+ * @param newCart The committed server cart to diff against.
+ * @param suppressKeys Keys of pre-existing lines whose product's add was exact;
+ * these lines are skipped in the auto-UPDATE filter.
+ * @return The list of info notices to surface to the shopper.
+ */
const getInfoNoticesFromCartUpdates = (
oldCart: Store[ 'state' ][ 'cart' ],
- newCart: Cart
+ newCart: Cart,
+ suppressKeys: Set< string > = new Set()
): Notice[] => {
const oldItems = oldCart.items;
const newItems = newCart.items;
@@ -151,12 +295,20 @@ const getInfoNoticesFromCartUpdates = (
);
// Items whose quantity was adjusted by the server (stock cap, sold-individually).
- // Comparing optimistic → server means intentional user changes are already
- // reflected in oldItems and will not trigger this notice.
+ // By default a line is compared optimistic → server, so intentional user
+ // changes are already reflected in oldItems and do not trigger this notice.
+ // Lines whose key appears in suppressKeys are skipped: the action proved that
+ // the product's add was exact (server total == expected total), so any
+ // quantity difference on those lines is an intentional add result, not a
+ // server-initiated change. Keyed update-item lines and removeCartItem lines
+ // are never in suppressKeys, so their notice behavior is unchanged.
const autoUpdatedToNotify = newItems.filter( ( item ) => {
if ( ! isCartItem( item ) ) {
return false;
}
+ if ( suppressKeys.has( item.key ) ) {
+ return false; // The action proved this product's add was exact.
+ }
const old = oldItems.find( ( o ) => o.key === item.key );
return old && item.quantity !== old.quantity;
} );
@@ -347,6 +499,27 @@ const { actions } = store< Store >(
);
}
+ // Keyless-requires-delta invariant. A keyless add always issues
+ // `add-item`, whose quantity is a delta added to the existing
+ // line; rapid-click compounding relies on that — each click sends
+ // its own delta and the server sums them (N -> N+1 -> N+2). An
+ // absolute `quantity` on a keyless add would be misread as a delta
+ // and corrupt that compounding, so keyless callers must pass
+ // `quantityToAdd`. An absolute `quantity` is legitimate only when
+ // paired with an explicit `key`: that is the keyed-stepper path
+ // (mini-cart / cart-block quantity controls), which targets one
+ // known line via `update-item` and sets its quantity outright.
+ // Those keyed callers are intentionally exempt from this guard.
+ if (
+ key === undefined &&
+ quantity !== undefined &&
+ quantityToAdd === undefined
+ ) {
+ throw new Error(
+ 'addCartItem: a keyless add must pass quantityToAdd (a delta), not an absolute quantity.'
+ );
+ }
+
const a11yModulePromise = import( '@wordpress/a11y' );
// Find existing item
@@ -371,9 +544,13 @@ const { actions } = store< Store >(
targetQuantity = 1;
}
- // Only treat as update if the item has a key (server-confirmed item).
- // Optimistic items don't have keys, so we should add them instead.
- const isUpdate = !! existingItem?.key;
+ // Endpoint selection is a pure function of the caller-supplied
+ // `key`, never of a line matched by id/variation. A keyless add
+ // always issues `add-item` with a delta, even when an existing
+ // line (including a server-keyed one) matches by product id, so
+ // the server owns cart-line identity for adds. Only an explicit
+ // caller `key` targets a specific line via `update-item`.
+ const isUpdate = !! key;
const endpoint = isUpdate ? 'update-item' : 'add-item';
// Track what changes we're making for notice comparison.
@@ -388,12 +565,20 @@ const { actions } = store< Store >(
// Prepare the item to send.
let itemToSend: OptimisticCartItem;
if ( isUpdate && existingItem ) {
- // Server-confirmed item: include the key for update-item endpoint.
+ // Caller-keyed update: target the exact line by key and send
+ // the absolute target quantity to the update-item endpoint.
itemToSend = { ...existingItem, quantity: targetQuantity };
} else {
- // New item or optimistic item: build fresh for add-item endpoint.
- // For optimistic items (existingItem without key), calculate delta
- // since add-item adds to existing quantity, not sets it.
+ // Keyless add: build a fresh payload for the add-item
+ // endpoint and never copy the matched line's key. The amount
+ // sent is always a delta — add-item adds to the existing
+ // quantity rather than setting it — so a match (by
+ // id/variation, possibly carrying a server key) only tells us
+ // how much delta is already accounted for in the running
+ // optimistic total; with no match we post the full target
+ // quantity. The matched line is never sent as an absolute
+ // quantity: the posted amount is a function of the delta,
+ // not of the match.
const quantityToSend = existingItem
? targetQuantity - existingItem.quantity
: targetQuantity;
@@ -408,6 +593,56 @@ const { actions } = store< Store >(
// Capture cart state after optimistic updates for notice comparison.
let cartAfterOptimistic: typeof state.cart | null = null;
+ // Per-product capture for the keyless-add exactness test.
+ // On the keyless path (!isUpdate), capture by value — before the
+ // optimistic bump mutates `existingItem.quantity` in place — the
+ // set of pre-existing matching line keys and their summed quantity.
+ // This is the single error-prone hotspot: `existingItem` is a live
+ // reference into `state.cart.items`; reading `.quantity` after the
+ // bump yields the post-bump value and silently corrupts the math.
+ // Stays empty on the keyed `update-item` path so the "your change
+ // was undone" notice keeps firing for steppers.
+ type ProductCapture = {
+ id: number;
+ variation?:
+ | CartVariationItem[]
+ | SelectedAttributes[]
+ | undefined;
+ preAddTotal: number;
+ deltaTotal: number;
+ preExistingKeys: string[];
+ };
+ const productCaptures: ProductCapture[] = [];
+ if ( ! isUpdate ) {
+ // Sum all pre-add quantities across every cart line matching
+ // this product (id + variation). A single product can occupy
+ // multiple lines (e.g. a meta line ordered before a standalone
+ // line). The per-product total lets us verify exactness even
+ // when the server grows a different line than the one the
+ // client bumped optimistically.
+ const preExistingKeys: string[] = [];
+ let preAddTotal = 0;
+ for ( const cartLine of state.cart.items ) {
+ if ( lineMatchesProduct( cartLine, id, variation ) ) {
+ preAddTotal += cartLine.quantity;
+ if ( cartLine.key ) {
+ preExistingKeys.push( cartLine.key );
+ }
+ }
+ }
+ // `itemToSend.quantity` is the posted delta (quantityToSend
+ // computed above). It is already computed before this capture
+ // block and does not depend on the optimistic state, so it is
+ // safe to read here.
+ productCaptures.push( {
+ id,
+ variation,
+ preAddTotal,
+ deltaTotal: itemToSend.quantity,
+ preExistingKeys,
+ } );
+ }
+
try {
const result = ( yield sendCartRequest( state, {
path: `/wc/store/v1/cart/${ endpoint }`,
@@ -415,7 +650,19 @@ const { actions } = store< Store >(
body: itemToSend,
applyOptimistic: () => {
if ( existingItem ) {
- // Update existing item's quantity (whether server-confirmed or optimistic).
+ // This in-place bump is render-only. It
+ // makes the common re-add flicker-free, but it must
+ // never feed back into endpoint selection or the
+ // posted amount — those are already fixed above as a
+ // pure function of key-presence and the delta. On a
+ // keyless add the match may bump a server-keyed line's
+ // rendered quantity (the accepted, self-correcting
+ // meta-only blip the server reconciles away); it must
+ // not flip the add into `update-item` or supply an
+ // absolute quantity. A future edit that lets this
+ // match drive the endpoint or the posted amount
+ // resurrects the original "cannot update bundle item"
+ // / wrong-line bug.
const isSoldIndividually =
isCartItem( existingItem ) &&
existingItem.sold_individually;
@@ -456,9 +703,18 @@ const { actions } = store< Store >(
cart &&
cartAfterOptimistic
) {
+ // Compute the suppression set: for each added product,
+ // check whether the server total matches the pre-add total
+ // plus the posted delta. If so, the add was exact and the
+ // pre-existing line keys are suppressed in the notice diff.
+ const suppressKeys = computeKeylessAddSuppressKeys(
+ productCaptures,
+ cart
+ );
const infoNotices = getInfoNoticesFromCartUpdates(
cartAfterOptimistic,
- cart
+ cart,
+ suppressKeys
);
const errorNotices =
cart.errors.map( generateErrorNotice );
@@ -493,8 +749,30 @@ const { actions } = store< Store >(
const quantityChanges: QuantityChanges = {};
try {
- // Submit each item through the batcher. They'll be
- // collected into a single batch request automatically.
+ // Per-product capture for the keyless-add exactness test,
+ // accumulated across all keyless batch items. The map key is a
+ // canonical product token (productToken(id, variation)) so that
+ // multiple batch items for the same product accumulate into one
+ // entry. The capture runs synchronously in the .map() below,
+ // before applyOptimistic runs (applyOptimistic is gated behind
+ // `await isNonceReady` inside sendCartRequest, so every
+ // existingItem.quantity read during the .map() sees the same
+ // pre-add cart). Keyed `update-item` items never contribute to
+ // this map, so the "your change was undone" notice keeps firing.
+ type BatchProductCapture = {
+ id: number;
+ variation?:
+ | CartVariationItem[]
+ | SelectedAttributes[]
+ | undefined;
+ preAddTotal: number;
+ deltaTotal: number;
+ preExistingKeys: string[];
+ };
+ const batchProductCaptures = new Map<
+ string,
+ BatchProductCapture
+ >();
const promises = items.map( ( item, index ) => {
const existingItem = state.findItemInCart( {
id: item.id,
@@ -509,11 +787,23 @@ const { actions } = store< Store >(
} else {
quantity = item.quantity ?? 1;
}
- const isUpdate = !! existingItem?.key;
+ // Endpoint selection is a pure function of the
+ // caller-supplied `key`, never of a line matched by
+ // id/variation. This mirrors the single-item
+ // `addCartItem` path: a keyless batch item always
+ // issues `add-item` with a delta, even when an existing
+ // line (including a server-keyed one) matches by product
+ // id, so the server owns cart-line identity for adds.
+ // Only an explicit caller `key` targets a specific line
+ // via `update-item`.
+ const isUpdate = !! item.key;
const endpoint = isUpdate ? 'update-item' : 'add-item';
let itemToSend: OptimisticCartItem;
if ( isUpdate && existingItem ) {
+ // Caller-keyed update: target the exact line by key
+ // and send the absolute target quantity to the
+ // update-item endpoint.
itemToSend = {
key: existingItem.key,
id: existingItem.id,
@@ -525,6 +815,15 @@ const { actions } = store< Store >(
existingItem.key as string,
];
} else {
+ // Keyless add: build a fresh payload for the add-item
+ // endpoint and never copy the matched line's key. As in
+ // addCartItem, the amount sent is always a delta —
+ // add-item adds to the existing quantity rather than
+ // setting it — so a match (by id/variation, possibly
+ // carrying a server key) only tells us how much delta is
+ // already accounted for; with no match we post the full
+ // target quantity. The matched line is never sent as an
+ // absolute quantity.
const quantityToSend = existingItem
? quantity - existingItem.quantity
: quantity;
@@ -539,6 +838,52 @@ const { actions } = store< Store >(
...( quantityChanges.productsPendingAdd ?? [] ),
item.id,
];
+
+ // Accumulate the per-product capture for the exactness
+ // test. On the first encounter of each product token,
+ // sum all pre-add quantities across every matching line
+ // and collect their keys — both captured as primitives
+ // here, before applyOptimistic mutates the cart. On
+ // subsequent encounters of the same product, add only
+ // the posted delta to the running deltaTotal.
+ const token = productToken(
+ item.id,
+ item.variation
+ );
+ if ( ! batchProductCaptures.has( token ) ) {
+ const preExistingKeys: string[] = [];
+ let preAddTotal = 0;
+ for ( const cartLine of state.cart.items ) {
+ if (
+ lineMatchesProduct(
+ cartLine,
+ item.id,
+ item.variation
+ )
+ ) {
+ preAddTotal += cartLine.quantity;
+ if ( cartLine.key ) {
+ preExistingKeys.push(
+ cartLine.key
+ );
+ }
+ }
+ }
+ batchProductCaptures.set( token, {
+ id: item.id,
+ variation: item.variation,
+ preAddTotal,
+ deltaTotal: quantityToSend,
+ preExistingKeys,
+ } );
+ } else {
+ // Same product seen again in this batch — add delta.
+ const capture =
+ batchProductCaptures.get( token );
+ if ( capture ) {
+ capture.deltaTotal += quantityToSend;
+ }
+ }
}
const isLastItem = index === items.length - 1;
@@ -549,6 +894,16 @@ const { actions } = store< Store >(
body: itemToSend,
applyOptimistic: () => {
if ( existingItem ) {
+ // As in addCartItem, this in-place
+ // bump is render-only and must never feed back into
+ // endpoint selection or the posted amount, which are
+ // already fixed above as a pure function of
+ // key-presence and the delta. Bumping a server-keyed
+ // line's rendered quantity on a keyless add is the
+ // accepted meta-only blip the server reconciles; it
+ // must not flip the add into `update-item` or post an
+ // absolute quantity. Letting this match drive the
+ // endpoint or amount reintroduces the bug.
existingItem.quantity = quantity;
} else {
state.cart.items.push( itemToSend );
@@ -599,9 +954,18 @@ const { actions } = store< Store >(
const cart = lastSuccess.value.data as Cart;
if ( showCartUpdatesNotices ) {
+ // Compute the suppression set from the accumulated
+ // per-product captures. For each product, if the server
+ // total equals preAddTotal + sum of posted deltas, the
+ // add was exact and the pre-existing keys are suppressed.
+ const suppressKeys = computeKeylessAddSuppressKeys(
+ [ ...batchProductCaptures.values() ],
+ cart
+ );
const infoNotices = getInfoNoticesFromCartUpdates(
cartAfterOptimistic,
- cart
+ cart,
+ suppressKeys
);
const errorNotices =
cart.errors.map( generateErrorNotice );
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/cart.ts b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/cart.ts
index 4f5384d8612..c7f6abbb0cc 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/cart.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/stores/woocommerce/test/cart.ts
@@ -1,7 +1,13 @@
+/**
+ * External dependencies
+ */
+import type { Cart, CartItem } from '@woocommerce/types';
+import type { Notice } from '@woocommerce/stores/store-notices';
+
/**
* Internal dependencies
*/
-import type { Store } from '../cart';
+import type { Store, OptimisticCartItem } from '../cart';
type MockStore = { state: Store[ 'state' ]; actions: Store[ 'actions' ] };
@@ -16,10 +22,21 @@ jest.mock(
() => ( {
getConfig: jest.fn(),
store: jest.fn( ( _name, definition ) => {
+ // The cart store calls `store()` twice: once to read `state` and
+ // once to register `actions`. Merge the definition's `state`
+ // descriptors (e.g. the `findItemInCart` selector) onto the shared
+ // mock state so the real selector runs against seeded cart lines,
+ // and carry the action generators through both calls.
+ if ( definition?.state ) {
+ Object.defineProperties(
+ mockState,
+ Object.getOwnPropertyDescriptors( definition.state )
+ );
+ }
mockRegisteredStore = {
state: mockState,
- actions: definition.actions,
- };
+ actions: definition?.actions ?? mockRegisteredStore?.actions,
+ } as MockStore;
return mockRegisteredStore;
} ),
} ),
@@ -30,7 +47,308 @@ jest.mock( '../legacy-events', () => ( {
triggerAddedToCartEvent: jest.fn(),
} ) );
+/**
+ * Captured representation of a single mutation sent through the batch endpoint.
+ */
+type CapturedRequest = {
+ /** The Store API path the mutation targeted, e.g. `/wc/store/v1/cart/add-item`. */
+ path: string;
+ /** The HTTP method of the mutation. */
+ method: string;
+ /** The parsed JSON body posted for the mutation. */
+ body: OptimisticCartItem;
+};
+
+/**
+ * Drives an Interactivity API async action generator to completion.
+ *
+ * Async actions are typed as `void` for consumers but are generators
+ * internally. Each yielded value is awaited (resolving the batched cart
+ * request and any dynamic imports) and the resolved value is fed back into the
+ * generator until it is done.
+ *
+ * When a yielded promise rejects, the rejection is routed back into the
+ * generator via `iterator.throw()` (mirroring the real Interactivity runtime),
+ * so the action's own `try/catch` runs — e.g. `addCartItem` catching a capped
+ * request and emitting an error notice. A rejection the generator does not
+ * catch re-throws here so `await runAction(...)` still rejects.
+ *
+ * @param action The async action return value cast to a generator.
+ * @return A promise that resolves once the generator has finished.
+ */
+async function runAction( action: unknown ): Promise< void > {
+ const iterator = action as Generator< unknown, unknown, unknown >;
+ let next = iterator.next();
+ while ( ! next.done ) {
+ try {
+ // eslint-disable-next-line no-await-in-loop
+ const resolved = await next.value;
+ next = iterator.next( resolved );
+ } catch ( error ) {
+ // Feed the rejection into the generator so its try/catch handles it.
+ next = iterator.throw( error );
+ }
+ }
+}
+
+/**
+ * Installs a `global.fetch` mock that records every mutation routed through the
+ * batch endpoint and replies with canned successful responses.
+ *
+ * The mock answers two request shapes the store issues:
+ * - The initial `GET /cart` refresh (no request body): returns an empty cart
+ * with a `Nonce` header so the store's nonce-ready gate resolves and queued
+ * mutations are allowed to flush.
+ * - The batch `POST` (a `{ requests: [...] }` body): records each mutation and
+ * replies with one successful `responses` entry per request, each carrying
+ * the current optimistic cart as the server state. Echoing the optimistic
+ * cart makes the mutation queue commit (rather than roll back), so the
+ * optimistic line changes the action applied survive reconciliation.
+ *
+ * @return The array that accumulates captured mutation requests.
+ */
+function mockBatchFetch(): CapturedRequest[] {
+ const captured: CapturedRequest[] = [];
+ global.fetch = jest.fn(
+ async ( _url: RequestInfo | URL, init?: RequestInit ) => {
+ // The GET refresh has no body; reply with an empty cart and a nonce.
+ if ( ! init?.body ) {
+ return new Response(
+ JSON.stringify( { items: [], totals: {}, errors: [] } ),
+ { headers: { Nonce: 'test-nonce-123' } }
+ );
+ }
+ const parsed = JSON.parse( init.body as string ) as {
+ requests: CapturedRequest[];
+ };
+ parsed.requests.forEach( ( request ) => captured.push( request ) );
+ // Echo the post-optimistic cart so the queue commits it as the
+ // server state instead of rolling back.
+ const serverCart = JSON.parse( JSON.stringify( mockState.cart ) );
+ const responses = parsed.requests.map( () => ( {
+ status: 200,
+ body: serverCart,
+ } ) );
+ return new Response( JSON.stringify( { responses } ), {
+ headers: { Nonce: 'test-nonce-123' },
+ } );
+ }
+ ) as unknown as typeof fetch;
+ return captured;
+}
+
+/**
+ * Loads a fresh copy of the cart store, resolves its nonce gate, and returns
+ * its registered actions.
+ *
+ * The module is re-required in isolation so each test starts from a clean
+ * mutation queue and a fresh module-level nonce-ready promise. The initial
+ * `refreshCartItems()` is then driven to completion so that the singleton
+ * nonce-ready promise resolves and queued mutations are allowed to flush; tests
+ * seed `state.cart` afterwards via {@link seedCart}.
+ *
+ * @return A promise resolving to the freshly registered cart store actions.
+ */
+async function loadCartStore(): Promise< Store[ 'actions' ] > {
+ jest.isolateModules( () => require( '../cart' ) );
+ const actions = mockRegisteredStore?.actions as Store[ 'actions' ];
+ // Drive the refresh so the module-level nonce-ready promise resolves.
+ await runAction( actions.refreshCartItems() );
+ return actions;
+}
+
+/**
+ * Seeds the shared mock state with the provided cart lines.
+ *
+ * @param items The cart lines to expose via `state.cart.items`.
+ */
+function seedCart( items: ( CartItem | OptimisticCartItem )[] ): void {
+ mockState.cart = {
+ items,
+ totals: {},
+ errors: [],
+ } as unknown as Store[ 'state' ][ 'cart' ];
+}
+
+/**
+ * Builds a minimal successful server cart payload from the provided lines.
+ *
+ * @param items The cart lines the server should report.
+ * @return A cart object shaped like a successful Store API cart response.
+ */
+function makeServerCart( items: CartItem[] ): Cart {
+ return {
+ items,
+ totals: {},
+ errors: [],
+ } as unknown as Cart;
+}
+
+/**
+ * Installs a `global.fetch` mock whose batch responses return a caller-supplied
+ * server cart instead of echoing the post-optimistic cart.
+ *
+ * This lets a test reproduce a server response that diverges from the
+ * optimistic state — e.g. a keyless add that the server resolves as a brand new
+ * standalone line while leaving a matched keyed meta line at its pre-add
+ * quantity. Each successful batch response carries `serverCart` as its body, so
+ * it becomes the action's `result.data` used for the notice diff.
+ *
+ * @param serverCart The cart the batch endpoint should report as server state.
+ */
+function mockBatchFetchReturning( serverCart: Cart ): void {
+ global.fetch = jest.fn(
+ async ( _url: RequestInfo | URL, init?: RequestInit ) => {
+ // The GET refresh has no body; reply with an empty cart and a nonce.
+ if ( ! init?.body ) {
+ return new Response(
+ JSON.stringify( { items: [], totals: {}, errors: [] } ),
+ { headers: { Nonce: 'test-nonce-123' } }
+ );
+ }
+ const parsed = JSON.parse( init.body as string ) as {
+ requests: CapturedRequest[];
+ };
+ const responses = parsed.requests.map( () => ( {
+ status: 200,
+ body: serverCart,
+ } ) );
+ return new Response( JSON.stringify( { responses } ), {
+ headers: { Nonce: 'test-nonce-123' },
+ } );
+ }
+ ) as unknown as typeof fetch;
+}
+
+/**
+ * Replaces the registered `updateNotices` action with a spy and returns the
+ * flat list of notices it receives across all invocations.
+ *
+ * The cart actions funnel every info/error notice through
+ * `actions.updateNotices`, resolved by property access at call time on the
+ * registered actions object. The caller `yield`s the result, and {@link
+ * runAction} only `await`s each yielded value; a yielded generator object would
+ * not be driven, so the spy records synchronously at call time and returns
+ * `undefined`. The spy is installed after {@link loadCartStore}.
+ *
+ * @return The accumulating list of notices passed to `updateNotices`.
+ */
+function spyOnUpdateNotices(): Notice[] {
+ const received: Notice[] = [];
+ const actions = mockRegisteredStore?.actions as Store[ 'actions' ];
+ actions.updateNotices = jest.fn( ( notices: Notice[] = [] ) => {
+ received.push( ...notices );
+ return undefined;
+ } ) as unknown as Store[ 'actions' ][ 'updateNotices' ];
+ return received;
+}
+
+/**
+ * Replaces the registered `showNoticeError` action with a spy and returns the
+ * list of errors it receives.
+ *
+ * The error/`catch` path of the cart actions surfaces a failed mutation by
+ * calling `actions.showNoticeError( error )` — the error-notice boundary,
+ * distinct from the auto-update info-notice boundary `actions.updateNotices`.
+ * Asserting on this spy proves a genuine cap surfaced as an error notice rather
+ * than an auto-update notice. The spy is installed after {@link loadCartStore}.
+ *
+ * @return The accumulating list of errors passed to `showNoticeError`.
+ */
+function spyOnShowNoticeError(): Error[] {
+ const received: Error[] = [];
+ const actions = mockRegisteredStore?.actions as Store[ 'actions' ];
+ actions.showNoticeError = jest.fn( ( error: Error ) => {
+ received.push( error );
+ return undefined;
+ } ) as unknown as Store[ 'actions' ][ 'showNoticeError' ];
+ return received;
+}
+
+/**
+ * Installs a `global.fetch` mock whose batch responses reject one targeted
+ * mutation with an HTTP error status, reproducing a genuine server cap.
+ *
+ * The GET refresh still resolves the nonce gate. Each batch request whose body
+ * targets `failForPath` gets a non-2xx response entry carrying the supplied
+ * error `code`/`message`; every other request echoes the post-optimistic cart
+ * as a success. A failed entry makes the mutation queue roll the optimistic
+ * change back (no successful server state for it) and reject that request's
+ * promise, which surfaces through the action's `catch` path.
+ *
+ * @param options Failure configuration.
+ * @param options.failForPath The Store API path whose mutation should fail,
+ * e.g. `/wc/store/v1/cart/add-item`.
+ * @param options.status The HTTP status to report for the failed mutation.
+ * @param options.code The error code carried in the failed response body.
+ * @param options.message The human-readable error message in the body.
+ * @return The array that accumulates captured mutation requests.
+ */
+function mockBatchFetchFailing( {
+ failForPath,
+ status = 400,
+ code = 'woocommerce_rest_cart_product_no_stock',
+ message = 'You cannot add that amount to the cart.',
+}: {
+ failForPath: string;
+ status?: number;
+ code?: string;
+ message?: string;
+} ): CapturedRequest[] {
+ const captured: CapturedRequest[] = [];
+ global.fetch = jest.fn(
+ async ( _url: RequestInfo | URL, init?: RequestInit ) => {
+ // The GET refresh has no body; reply with an empty cart and a nonce.
+ if ( ! init?.body ) {
+ return new Response(
+ JSON.stringify( { items: [], totals: {}, errors: [] } ),
+ { headers: { Nonce: 'test-nonce-123' } }
+ );
+ }
+ const parsed = JSON.parse( init.body as string ) as {
+ requests: CapturedRequest[];
+ };
+ parsed.requests.forEach( ( request ) => captured.push( request ) );
+ const serverCart = JSON.parse( JSON.stringify( mockState.cart ) );
+ const responses = parsed.requests.map( ( request ) =>
+ request.path === failForPath
+ ? { status, body: { code, message } }
+ : { status: 200, body: serverCart }
+ );
+ return new Response( JSON.stringify( { responses } ), {
+ headers: { Nonce: 'test-nonce-123' },
+ } );
+ }
+ ) as unknown as typeof fetch;
+ return captured;
+}
+
+/**
+ * Builds a minimal server-confirmed cart line carrying a key.
+ *
+ * @param overrides Partial cart-line fields to override the defaults.
+ * @return A cart line suitable for seeding `state.cart.items`.
+ */
+function makeKeyedLine( overrides: Partial< CartItem > = {} ): CartItem {
+ return {
+ key: 'server-key-abc',
+ id: 42,
+ type: 'simple',
+ quantity: 3,
+ name: 'Test Product',
+ sold_individually: false,
+ variation: [],
+ item_data: [],
+ ...overrides,
+ } as CartItem;
+}
+
describe( 'WooCommerce Cart Interactivity API Store', () => {
+ afterEach( () => {
+ jest.clearAllMocks();
+ delete ( mockState as Partial< Store[ 'state' ] > ).cart;
+ } );
+
it( 'refreshCartItems passes cache: no-store to fetch to prevent browser caching', () => {
const mockFetch = jest
.fn()
@@ -56,4 +374,1018 @@ describe( 'WooCommerce Cart Interactivity API Store', () => {
} )
);
} );
+
+ describe( 'addCartItem endpoint selection', () => {
+ it( 'issues add-item (never update-item) for a keyless add that matches a keyed line by product id', async () => {
+ const captured = mockBatchFetch();
+ const actions = await loadCartStore();
+ seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+
+ await runAction(
+ actions.addCartItem( {
+ id: 42,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ );
+
+ expect( captured ).toHaveLength( 1 );
+ expect( captured[ 0 ].path ).toBe( '/wc/store/v1/cart/add-item' );
+ expect( captured[ 0 ].path ).not.toContain( 'update-item' );
+ } );
+
+ it( 'posts the requested delta (not the matched line absolute quantity) for a keyless add against a keyed line', async () => {
+ const captured = mockBatchFetch();
+ const actions = await loadCartStore();
+ seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+
+ await runAction(
+ actions.addCartItem( {
+ id: 42,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ );
+
+ expect( captured[ 0 ].body.quantity ).toBe( 1 );
+ expect( captured[ 0 ].body.quantity ).not.toBe( 4 );
+ } );
+
+ it( 'accumulates the running optimistic delta across rapid keyless adds', async () => {
+ const captured = mockBatchFetch();
+ const actions = await loadCartStore();
+ seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+
+ // Two rapid keyless adds queued before the batch flushes. Each must
+ // post its own delta (1) computed against the running optimistic
+ // quantity, never an absolute quantity off the matched line.
+ await Promise.all( [
+ runAction(
+ actions.addCartItem( {
+ id: 42,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ ),
+ runAction(
+ actions.addCartItem( {
+ id: 42,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ ),
+ ] );
+
+ expect( captured ).toHaveLength( 2 );
+ expect(
+ captured.every( ( r ) => r.path.endsWith( 'add-item' ) )
+ ).toBe( true );
+ expect( captured[ 0 ].body.quantity ).toBe( 1 );
+ expect( captured[ 1 ].body.quantity ).toBe( 1 );
+ } );
+
+ it( 'never includes the matched line key in the request body for a keyless add', async () => {
+ const captured = mockBatchFetch();
+ const actions = await loadCartStore();
+ seedCart( [
+ makeKeyedLine( { id: 42, quantity: 3, key: 'server-key-abc' } ),
+ ] );
+
+ await runAction(
+ actions.addCartItem( {
+ id: 42,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ );
+
+ expect( captured[ 0 ].body.key ).toBeUndefined();
+ } );
+
+ it( 'issues update-item with the absolute quantity for an explicit key (key-first path unchanged)', async () => {
+ const captured = mockBatchFetch();
+ const actions = await loadCartStore();
+ seedCart( [
+ makeKeyedLine( { id: 42, quantity: 3, key: 'server-key-abc' } ),
+ ] );
+
+ await runAction(
+ actions.addCartItem( {
+ id: 42,
+ key: 'server-key-abc',
+ quantity: 5,
+ type: 'simple',
+ } )
+ );
+
+ expect( captured[ 0 ].path ).toBe(
+ '/wc/store/v1/cart/update-item'
+ );
+ expect( captured[ 0 ].body.quantity ).toBe( 5 );
+ expect( captured[ 0 ].body.key ).toBe( 'server-key-abc' );
+ } );
+
+ it( 'optimistically bumps a matched keyed line in place on a keyless re-add (no duplicate line)', async () => {
+ mockBatchFetch();
+ const actions = await loadCartStore();
+ seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+
+ await runAction(
+ actions.addCartItem( {
+ id: 42,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ );
+
+ expect( mockState.cart.items ).toHaveLength( 1 );
+ expect( mockState.cart.items[ 0 ].quantity ).toBe( 4 );
+ } );
+
+ it( 'optimistically pushes a new line when no line matches a keyless add', async () => {
+ mockBatchFetch();
+ const actions = await loadCartStore();
+ seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+
+ await runAction(
+ actions.addCartItem( {
+ id: 99,
+ quantityToAdd: 2,
+ type: 'simple',
+ } )
+ );
+
+ expect( mockState.cart.items ).toHaveLength( 2 );
+ const added = mockState.cart.items.find(
+ ( item ) => item.id === 99
+ );
+ expect( added ).toBeDefined();
+ expect( added?.quantity ).toBe( 2 );
+ } );
+
+ it( 'ignores the matched line item_data when deciding the endpoint and body for a keyless add', async () => {
+ const captured = mockBatchFetch();
+
+ // Same product id and quantity, only item_data differs. The
+ // add/update decision must not depend on item_data, so both adds
+ // must produce an identical endpoint and request body.
+ const richItemData = [
+ { key: 'subscription', value: 'monthly' },
+ ] as CartItem[ 'item_data' ];
+
+ const withEmptyItemData = await loadCartStore();
+ seedCart( [
+ makeKeyedLine( { id: 42, quantity: 3, item_data: [] } ),
+ ] );
+ await runAction(
+ withEmptyItemData.addCartItem( {
+ id: 42,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ );
+
+ const withRichItemData = await loadCartStore();
+ seedCart( [
+ makeKeyedLine( {
+ id: 42,
+ quantity: 3,
+ item_data: richItemData,
+ } ),
+ ] );
+ await runAction(
+ withRichItemData.addCartItem( {
+ id: 42,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ );
+
+ expect( captured ).toHaveLength( 2 );
+ expect( captured[ 0 ].path ).toBe( '/wc/store/v1/cart/add-item' );
+ expect( captured[ 1 ].path ).toBe( captured[ 0 ].path );
+ expect( captured[ 1 ].body ).toEqual( captured[ 0 ].body );
+ } );
+ } );
+
+ describe( 'addCartItem keyless-requires-delta invariant guard', () => {
+ it( 'throws when called keyless with an absolute quantity (no quantityToAdd)', async () => {
+ mockBatchFetch();
+ const actions = await loadCartStore();
+ seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+
+ await expect(
+ runAction(
+ actions.addCartItem( {
+ id: 42,
+ quantity: 5,
+ type: 'simple',
+ } )
+ )
+ ).rejects.toThrow();
+ } );
+
+ it( 'does not throw and proceeds on the add-item path for a keyless quantityToAdd delta', async () => {
+ const captured = mockBatchFetch();
+ const actions = await loadCartStore();
+ seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+
+ await expect(
+ runAction(
+ actions.addCartItem( {
+ id: 42,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ )
+ ).resolves.toBeUndefined();
+
+ expect( captured ).toHaveLength( 1 );
+ expect( captured[ 0 ].path ).toBe( '/wc/store/v1/cart/add-item' );
+ } );
+
+ it( 'does not throw for an explicit key with an absolute quantity (key-first stepper path unaffected)', async () => {
+ mockBatchFetch();
+ const actions = await loadCartStore();
+ seedCart( [
+ makeKeyedLine( { id: 42, quantity: 3, key: 'server-key-abc' } ),
+ ] );
+
+ await expect(
+ runAction(
+ actions.addCartItem( {
+ id: 42,
+ key: 'server-key-abc',
+ quantity: 5,
+ type: 'simple',
+ } )
+ )
+ ).resolves.toBeUndefined();
+ } );
+
+ it( 'still throws when both quantity and quantityToAdd are passed together', async () => {
+ mockBatchFetch();
+ const actions = await loadCartStore();
+ seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+
+ await expect(
+ runAction(
+ actions.addCartItem( {
+ id: 42,
+ quantity: 5,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ )
+ ).rejects.toThrow();
+ } );
+ } );
+
+ describe( 'batchAddCartItems endpoint selection', () => {
+ it( 'issues add-item (never update-item) for a keyless batch item that matches a keyed line by product id', async () => {
+ const captured = mockBatchFetch();
+ const actions = await loadCartStore();
+ seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+
+ await runAction(
+ actions.batchAddCartItems( [
+ {
+ id: 42,
+ quantityToAdd: 1,
+ type: 'simple',
+ },
+ ] )
+ );
+
+ expect( captured ).toHaveLength( 1 );
+ expect( captured[ 0 ].path ).toBe( '/wc/store/v1/cart/add-item' );
+ expect( captured[ 0 ].path ).not.toContain( 'update-item' );
+ } );
+
+ it( 'posts the requested delta (not the matched line absolute quantity) for a keyless batch item against a keyed line', async () => {
+ const captured = mockBatchFetch();
+ const actions = await loadCartStore();
+ seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+
+ await runAction(
+ actions.batchAddCartItems( [
+ {
+ id: 42,
+ quantityToAdd: 1,
+ type: 'simple',
+ },
+ ] )
+ );
+
+ expect( captured[ 0 ].body.quantity ).toBe( 1 );
+ expect( captured[ 0 ].body.quantity ).not.toBe( 4 );
+ } );
+
+ it( 'never includes the matched line key in the request body for a keyless batch item', async () => {
+ const captured = mockBatchFetch();
+ const actions = await loadCartStore();
+ seedCart( [
+ makeKeyedLine( { id: 42, quantity: 3, key: 'server-key-abc' } ),
+ ] );
+
+ await runAction(
+ actions.batchAddCartItems( [
+ {
+ id: 42,
+ quantityToAdd: 1,
+ type: 'simple',
+ },
+ ] )
+ );
+
+ expect( captured[ 0 ].body.key ).toBeUndefined();
+ } );
+
+ it( 'issues update-item with the absolute quantity for a batch item that supplies an explicit key', async () => {
+ const captured = mockBatchFetch();
+ const actions = await loadCartStore();
+ seedCart( [
+ makeKeyedLine( { id: 42, quantity: 3, key: 'server-key-abc' } ),
+ ] );
+
+ await runAction(
+ actions.batchAddCartItems( [
+ {
+ id: 42,
+ key: 'server-key-abc',
+ quantity: 5,
+ type: 'simple',
+ },
+ ] )
+ );
+
+ expect( captured[ 0 ].path ).toBe(
+ '/wc/store/v1/cart/update-item'
+ );
+ expect( captured[ 0 ].body.quantity ).toBe( 5 );
+ expect( captured[ 0 ].body.key ).toBe( 'server-key-abc' );
+ } );
+
+ it( 'derives the keyless add-item delta identically to the single-item addCartItem path', async () => {
+ // Same seeded keyed line and same keyless request through both
+ // paths must produce the same endpoint and posted quantity.
+ const singleCaptured = mockBatchFetch();
+ const singleActions = await loadCartStore();
+ seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+ await runAction(
+ singleActions.addCartItem( {
+ id: 42,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ );
+
+ const batchCaptured = mockBatchFetch();
+ const batchActions = await loadCartStore();
+ seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+ await runAction(
+ batchActions.batchAddCartItems( [
+ {
+ id: 42,
+ quantityToAdd: 1,
+ type: 'simple',
+ },
+ ] )
+ );
+
+ expect( batchCaptured ).toHaveLength( 1 );
+ expect( singleCaptured ).toHaveLength( 1 );
+ expect( batchCaptured[ 0 ].path ).toBe( singleCaptured[ 0 ].path );
+ expect( batchCaptured[ 0 ].body.quantity ).toBe(
+ singleCaptured[ 0 ].body.quantity
+ );
+ expect( batchCaptured[ 0 ].body.key ).toBe(
+ singleCaptured[ 0 ].body.key
+ );
+ } );
+
+ it( 'optimistically bumps a matched keyed line in place on a keyless batch re-add (no duplicate line)', async () => {
+ mockBatchFetch();
+ const actions = await loadCartStore();
+ seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+
+ await runAction(
+ actions.batchAddCartItems( [
+ {
+ id: 42,
+ quantityToAdd: 1,
+ type: 'simple',
+ },
+ ] )
+ );
+
+ expect( mockState.cart.items ).toHaveLength( 1 );
+ expect( mockState.cart.items[ 0 ].quantity ).toBe( 4 );
+ } );
+
+ it( 'optimistically pushes a new line when no line matches a keyless batch item', async () => {
+ mockBatchFetch();
+ const actions = await loadCartStore();
+ seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+
+ await runAction(
+ actions.batchAddCartItems( [
+ {
+ id: 99,
+ quantityToAdd: 2,
+ type: 'simple',
+ },
+ ] )
+ );
+
+ expect( mockState.cart.items ).toHaveLength( 2 );
+ const added = mockState.cart.items.find(
+ ( item ) => item.id === 99
+ );
+ expect( added ).toBeDefined();
+ expect( added?.quantity ).toBe( 2 );
+ } );
+ } );
+
+ describe( 'notice-diff suppression for keyless meta-only adds', () => {
+ // The quantity-changed info notice template the auto-UPDATE branch emits.
+ const QUANTITY_CHANGED = 'was changed to';
+
+ it( 'emits no quantity-changed notice for a keyless add resolved server-side as a new standalone line', async () => {
+ // The product is present only as a single keyed meta line at qty 3.
+ // A keyless add optimistically bumps that line to 4, but the server
+ // keeps the meta line at 3 and adds a separate standalone line. The
+ // keyless-scoped baseline (3) must be compared against the server
+ // quantity (3) so no spurious "quantity changed" notice fires.
+ mockBatchFetchReturning(
+ makeServerCart( [
+ makeKeyedLine( {
+ key: 'server-key-abc',
+ id: 42,
+ quantity: 3,
+ } ),
+ makeKeyedLine( {
+ key: 'server-key-new',
+ id: 42,
+ quantity: 1,
+ } ),
+ ] )
+ );
+ const actions = await loadCartStore();
+ seedCart( [
+ makeKeyedLine( { key: 'server-key-abc', id: 42, quantity: 3 } ),
+ ] );
+ const notices = spyOnUpdateNotices();
+
+ await runAction(
+ actions.addCartItem( {
+ id: 42,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ );
+
+ expect(
+ notices.some( ( n ) => n.notice.includes( QUANTITY_CHANGED ) )
+ ).toBe( false );
+ } );
+
+ it( 'emits no quantity-changed notice when only the first of two meta lines for the same product is bumped optimistically', async () => {
+ // The product is present as two distinct keyed meta lines (qty 3 and
+ // qty 2). A keyless add matches and optimistically bumps only the
+ // first line (server-key-1) to 4. The server keeps both meta lines at
+ // their pre-add quantities and adds a separate standalone line. The
+ // bumped line's pre-optimistic baseline (3) must be diffed against the
+ // server quantity (3) so no spurious notice fires; the untouched
+ // second line (still 2 in both snapshots) must not notify either.
+ mockBatchFetchReturning(
+ makeServerCart( [
+ makeKeyedLine( {
+ key: 'server-key-1',
+ id: 42,
+ quantity: 3,
+ } ),
+ makeKeyedLine( {
+ key: 'server-key-2',
+ id: 42,
+ quantity: 2,
+ } ),
+ makeKeyedLine( {
+ key: 'server-key-new',
+ id: 42,
+ quantity: 1,
+ } ),
+ ] )
+ );
+ const actions = await loadCartStore();
+ seedCart( [
+ makeKeyedLine( { key: 'server-key-1', id: 42, quantity: 3 } ),
+ makeKeyedLine( { key: 'server-key-2', id: 42, quantity: 2 } ),
+ ] );
+ const notices = spyOnUpdateNotices();
+
+ await runAction(
+ actions.addCartItem( {
+ id: 42,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ );
+
+ expect(
+ notices.some( ( n ) => n.notice.includes( QUANTITY_CHANGED ) )
+ ).toBe( false );
+ } );
+
+ it( 'still emits the quantity-changed notice for a keyed mini-cart stepper change returned at its pre-stepper quantity', async () => {
+ // A keyed update (explicit key + absolute quantity) is never recorded
+ // in the keyless baseline set, so the override does not apply. The
+ // server returning the line at its pre-stepper quantity (3) must still
+ // diff against the post-optimistic snapshot (5) and notify.
+ mockBatchFetchReturning(
+ makeServerCart( [
+ makeKeyedLine( {
+ key: 'server-key-abc',
+ id: 42,
+ quantity: 3,
+ } ),
+ ] )
+ );
+ const actions = await loadCartStore();
+ seedCart( [
+ makeKeyedLine( { key: 'server-key-abc', id: 42, quantity: 3 } ),
+ ] );
+ const notices = spyOnUpdateNotices();
+
+ await runAction(
+ actions.addCartItem( {
+ id: 42,
+ key: 'server-key-abc',
+ quantity: 5,
+ type: 'simple',
+ } )
+ );
+
+ expect(
+ notices.some( ( n ) => n.notice.includes( QUANTITY_CHANGED ) )
+ ).toBe( true );
+ } );
+
+ it( 'still emits the quantity-changed notice when a keyless-add-bumped line diverges from its captured baseline', async () => {
+ // A genuine concurrent server change: the matched keyed line is
+ // reported at quantity 7, which differs from its pre-optimistic
+ // baseline of 3. The notice must still fire for that line.
+ mockBatchFetchReturning(
+ makeServerCart( [
+ makeKeyedLine( {
+ key: 'server-key-abc',
+ id: 42,
+ quantity: 7,
+ } ),
+ ] )
+ );
+ const actions = await loadCartStore();
+ seedCart( [
+ makeKeyedLine( { key: 'server-key-abc', id: 42, quantity: 3 } ),
+ ] );
+ const notices = spyOnUpdateNotices();
+
+ await runAction(
+ actions.addCartItem( {
+ id: 42,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ );
+
+ expect(
+ notices.some(
+ ( n ) =>
+ n.notice.includes( QUANTITY_CHANGED ) &&
+ n.notice.includes( '7' )
+ )
+ ).toBe( true );
+ } );
+
+ it( 'suppresses the notice for a keyless batch add resolved server-side as a new standalone line', async () => {
+ // Same meta-only scenario through the batch path: the matched keyed
+ // line is bumped optimistically to 4, the server keeps it at 3 and
+ // adds a standalone line. The batch must capture the baseline (3) and
+ // suppress the spurious notice.
+ mockBatchFetchReturning(
+ makeServerCart( [
+ makeKeyedLine( {
+ key: 'server-key-abc',
+ id: 42,
+ quantity: 3,
+ } ),
+ makeKeyedLine( {
+ key: 'server-key-new',
+ id: 42,
+ quantity: 1,
+ } ),
+ ] )
+ );
+ const actions = await loadCartStore();
+ seedCart( [
+ makeKeyedLine( { key: 'server-key-abc', id: 42, quantity: 3 } ),
+ ] );
+ const notices = spyOnUpdateNotices();
+
+ await runAction(
+ actions.batchAddCartItems( [
+ {
+ id: 42,
+ quantityToAdd: 1,
+ type: 'simple',
+ },
+ ] )
+ );
+
+ expect(
+ notices.some( ( n ) => n.notice.includes( QUANTITY_CHANGED ) )
+ ).toBe( false );
+ } );
+
+ it( 'suppresses the quantity-changed notice for a keyless re-add when the server returns the line at pre-add + delta', async () => {
+ // Pre-add: matched line at qty 3. Keyless add delta: +1.
+ // Expected total: 3 + 1 = 4. Server returns the line at 4.
+ // Since serverTotal (4) === expectedTotal (4), the add was exact →
+ // no "quantity changed" notice must fire.
+ // This also indirectly guards the by-value pre-add capture: if
+ // preAddTotal were captured after the optimistic bump (reading 4
+ // instead of 3), expectedTotal would be 4+1=5 ≠ server 4, which
+ // would keep the key un-suppressed and fire the notice, failing
+ // this assertion.
+ mockBatchFetchReturning(
+ makeServerCart( [
+ makeKeyedLine( {
+ key: 'server-key-abc',
+ id: 42,
+ quantity: 4,
+ } ),
+ ] )
+ );
+ const actions = await loadCartStore();
+ seedCart( [
+ makeKeyedLine( { key: 'server-key-abc', id: 42, quantity: 3 } ),
+ ] );
+ const notices = spyOnUpdateNotices();
+
+ await runAction(
+ actions.addCartItem( {
+ id: 42,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ );
+
+ // Server total (4) === expected total (3+1=4) → suppress.
+ // No "quantity changed" notice must fire.
+ expect(
+ notices.some( ( n ) => n.notice.includes( QUANTITY_CHANGED ) )
+ ).toBe( false );
+ } );
+
+ it( 'suppresses the quantity-changed notice for a keyless batch re-add when the server total matches pre-add + sum of deltas', async () => {
+ // Pre-add: matched line at qty 3. Batch deltas: +1 and +1.
+ // A real /batch endpoint compounds server-side: each add-item
+ // sub-request runs sequentially against one WC_Cart session, so the
+ // server accumulates both deltas and lands at 5, not 4.
+ // Expected total: 3 + (1+1) = 5. Server returns the line at 5.
+ // Since serverTotal (5) === expectedTotal (5), suppress → no notice.
+ mockBatchFetchReturning(
+ makeServerCart( [
+ makeKeyedLine( {
+ key: 'server-key-abc',
+ id: 42,
+ quantity: 5,
+ } ),
+ ] )
+ );
+ const actions = await loadCartStore();
+ seedCart( [
+ makeKeyedLine( { key: 'server-key-abc', id: 42, quantity: 3 } ),
+ ] );
+ const notices = spyOnUpdateNotices();
+
+ await runAction(
+ actions.batchAddCartItems( [
+ { id: 42, quantityToAdd: 1, type: 'simple' },
+ { id: 42, quantityToAdd: 1, type: 'simple' },
+ ] )
+ );
+
+ // Server total (5) === expected total (3+1+1=5) → suppress.
+ // No "quantity changed" notice must fire.
+ expect(
+ notices.some( ( n ) => n.notice.includes( QUANTITY_CHANGED ) )
+ ).toBe( false );
+ } );
+
+ it( 'still emits the quantity-changed notice for a keyless batch re-add when the server total diverges from expected', async () => {
+ // Same setup: pre-add qty 3, batch (+1,+1), expectedTotal = 5.
+ // Server returns 6 (a genuine concurrent change or cap artefact).
+ // Since serverTotal (6) !== expectedTotal (5), do not suppress →
+ // the notice must fire reporting the server quantity 6.
+ mockBatchFetchReturning(
+ makeServerCart( [
+ makeKeyedLine( {
+ key: 'server-key-abc',
+ id: 42,
+ quantity: 6,
+ } ),
+ ] )
+ );
+ const actions = await loadCartStore();
+ seedCart( [
+ makeKeyedLine( { key: 'server-key-abc', id: 42, quantity: 3 } ),
+ ] );
+ const notices = spyOnUpdateNotices();
+
+ await runAction(
+ actions.batchAddCartItems( [
+ { id: 42, quantityToAdd: 1, type: 'simple' },
+ { id: 42, quantityToAdd: 1, type: 'simple' },
+ ] )
+ );
+
+ expect(
+ notices.some(
+ ( n ) =>
+ n.notice.includes( QUANTITY_CHANGED ) &&
+ n.notice.includes( '6' )
+ )
+ ).toBe( true );
+ } );
+
+ it( 'suppresses the notice for a keyless add when the client bumps a meta line but the server grows the standalone line', async () => {
+ // Product 42 occupies two lines: a meta-differentiated line ordered
+ // first (server-key-meta, qty 1) and a plain standalone line second
+ // (server-key-standalone, qty 1). findItemInCart matches the meta line
+ // first, so addCartItem bumps it optimistically. The server, however,
+ // grows the standalone line instead and leaves the meta line unchanged.
+ // Pre-add total: 1+1=2. Delta: +1. Expected total: 3.
+ // Server returns meta(1) + standalone(2) = 3 === expected → suppress
+ // for both pre-existing keys. No "quantity changed" notice must fire.
+ mockBatchFetchReturning(
+ makeServerCart( [
+ makeKeyedLine( {
+ key: 'server-key-meta',
+ id: 42,
+ quantity: 1,
+ name: 'Test Product',
+ } ),
+ makeKeyedLine( {
+ key: 'server-key-standalone',
+ id: 42,
+ quantity: 2,
+ name: 'Test Product',
+ } ),
+ ] )
+ );
+ const actions = await loadCartStore();
+ seedCart( [
+ makeKeyedLine( {
+ key: 'server-key-meta',
+ id: 42,
+ quantity: 1,
+ } ),
+ makeKeyedLine( {
+ key: 'server-key-standalone',
+ id: 42,
+ quantity: 1,
+ } ),
+ ] );
+ const notices = spyOnUpdateNotices();
+
+ await runAction(
+ actions.addCartItem( {
+ id: 42,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ );
+
+ // Server total (1+2=3) === expected total (1+1+1=3) → suppress.
+ expect(
+ notices.some( ( n ) => n.notice.includes( QUANTITY_CHANGED ) )
+ ).toBe( false );
+ } );
+
+ it( 'suppresses the notice for a keyless batch add when the client bumps a meta line but the server grows the standalone line, through the batch path', async () => {
+ // Same meta-line/standalone-line scenario through the batch path.
+ // Product 42 occupies two
+ // lines: meta first (qty 1) then standalone (qty 1). The batch item
+ // bumps the meta line optimistically; the server grows the standalone
+ // line. Pre-add total: 1+1=2. Delta: +1. Expected total: 3.
+ // Server returns meta(1)+standalone(2)=3 === expected → suppress.
+ mockBatchFetchReturning(
+ makeServerCart( [
+ makeKeyedLine( {
+ key: 'server-key-meta',
+ id: 42,
+ quantity: 1,
+ name: 'Test Product',
+ } ),
+ makeKeyedLine( {
+ key: 'server-key-standalone',
+ id: 42,
+ quantity: 2,
+ name: 'Test Product',
+ } ),
+ ] )
+ );
+ const actions = await loadCartStore();
+ seedCart( [
+ makeKeyedLine( {
+ key: 'server-key-meta',
+ id: 42,
+ quantity: 1,
+ } ),
+ makeKeyedLine( {
+ key: 'server-key-standalone',
+ id: 42,
+ quantity: 1,
+ } ),
+ ] );
+ const notices = spyOnUpdateNotices();
+
+ await runAction(
+ actions.batchAddCartItems( [
+ { id: 42, quantityToAdd: 1, type: 'simple' },
+ ] )
+ );
+
+ // Server total (1+2=3) === expected total (1+1+1=3) → suppress.
+ expect(
+ notices.some( ( n ) => n.notice.includes( QUANTITY_CHANGED ) )
+ ).toBe( false );
+ } );
+
+ it( 'suppresses the quantity-changed notice for a keyless variation re-add when the server returns the line at pre-add + delta', async () => {
+ // A variation line (type: variation, id: 42, variation: [Color:Red])
+ // is matched by id+variation. Keyless add delta: +1. Pre-add qty: 2.
+ // Expected total: 2+1=3. Server returns the variation line at 3.
+ // Since serverTotal (3) === expectedTotal (3) → suppress.
+ const colorRedVariation = [
+ { attribute: 'Color', value: 'Red' },
+ ] as CartItem[ 'variation' ];
+ mockBatchFetchReturning(
+ makeServerCart( [
+ {
+ ...makeKeyedLine( {
+ key: 'server-key-var',
+ id: 42,
+ quantity: 3,
+ } ),
+ type: 'variation',
+ variation: colorRedVariation,
+ } as CartItem,
+ ] )
+ );
+ const actions = await loadCartStore();
+ seedCart( [
+ {
+ ...makeKeyedLine( {
+ key: 'server-key-var',
+ id: 42,
+ quantity: 2,
+ } ),
+ type: 'variation',
+ variation: colorRedVariation,
+ } as CartItem,
+ ] );
+ const notices = spyOnUpdateNotices();
+
+ await runAction(
+ actions.addCartItem( {
+ id: 42,
+ quantityToAdd: 1,
+ type: 'variation',
+ variation: colorRedVariation,
+ } )
+ );
+
+ // Server total (3) === expected total (2+1=3) → suppress.
+ expect(
+ notices.some( ( n ) => n.notice.includes( QUANTITY_CHANGED ) )
+ ).toBe( false );
+ } );
+
+ it( 'leaves removeCartItem notice behavior unchanged (auto-DELETE still fires)', async () => {
+ // removeCartItem must not pass the new baseline; the auto-DELETE
+ // branch is untouched. Removing one of two lines while the server
+ // reports the OTHER line auto-removed must still emit a removal
+ // notice for that server-removed line.
+ mockBatchFetchReturning(
+ makeServerCart( [
+ makeKeyedLine( {
+ key: 'server-key-keep',
+ id: 42,
+ quantity: 3,
+ name: 'Kept Product',
+ } ),
+ ] )
+ );
+ const actions = await loadCartStore();
+ seedCart( [
+ makeKeyedLine( {
+ key: 'server-key-keep',
+ id: 42,
+ quantity: 3,
+ name: 'Kept Product',
+ } ),
+ makeKeyedLine( {
+ key: 'server-key-gone',
+ id: 7,
+ quantity: 1,
+ name: 'Vanished Product',
+ } ),
+ ] );
+ const notices = spyOnUpdateNotices();
+
+ await runAction( actions.removeCartItem( 'server-key-keep' ) );
+
+ // The server-removed line (server-key-gone) was present in the
+ // post-optimistic snapshot and absent from the server cart, so the
+ // auto-DELETE notice must still fire.
+ expect(
+ notices.some( ( n ) => n.notice.includes( 'Vanished Product' ) )
+ ).toBe( true );
+ } );
+ } );
+
+ describe( 'genuine add-path cap surfaces as an error notice (not an auto-update notice)', () => {
+ // The quantity-changed info notice template the auto-UPDATE branch emits.
+ const QUANTITY_CHANGED = 'was changed to';
+
+ it( 'routes an HTTP 400 add-item failure to an error notice and never to an auto-update notice', async () => {
+ // A plain keyless re-add the server caps (e.g. out of stock) returns a
+ // non-2xx batch entry. That rejects the mutation, so the action takes
+ // the throw/catch path: the failure must surface as an error notice
+ // via showNoticeError, not as an auto-update "quantity changed" notice
+ // through updateNotices.
+ mockBatchFetchFailing( {
+ failForPath: '/wc/store/v1/cart/add-item',
+ status: 400,
+ code: 'woocommerce_rest_cart_product_no_stock',
+ message: 'You cannot add that amount to the cart.',
+ } );
+ const actions = await loadCartStore();
+ seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+ const autoUpdateNotices = spyOnUpdateNotices();
+ const errors = spyOnShowNoticeError();
+
+ await runAction(
+ actions.addCartItem( {
+ id: 42,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ );
+
+ // The cap surfaced through the error-notice boundary carrying the
+ // server-supplied message and code.
+ expect( errors ).toHaveLength( 1 );
+ expect( errors[ 0 ].message ).toBe(
+ 'You cannot add that amount to the cart.'
+ );
+ expect( ( errors[ 0 ] as Error & { code?: string } ).code ).toBe(
+ 'woocommerce_rest_cart_product_no_stock'
+ );
+
+ // No auto-update "quantity changed" notice was emitted for the cap.
+ expect(
+ autoUpdateNotices.some( ( n ) =>
+ n.notice.includes( QUANTITY_CHANGED )
+ )
+ ).toBe( false );
+ } );
+
+ it( 'rolls the optimistic bump back when the add-item request is capped (HTTP 400)', async () => {
+ // The optimistic update bumps the matched line 3 -> 4 before the
+ // request flushes. Because the only mutation fails, the queue has no
+ // successful server state and must roll the cart back to its
+ // pre-cycle snapshot, leaving the line at its original quantity 3.
+ mockBatchFetchFailing( {
+ failForPath: '/wc/store/v1/cart/add-item',
+ status: 400,
+ } );
+ const actions = await loadCartStore();
+ seedCart( [ makeKeyedLine( { id: 42, quantity: 3 } ) ] );
+ spyOnShowNoticeError();
+
+ await runAction(
+ actions.addCartItem( {
+ id: 42,
+ quantityToAdd: 1,
+ type: 'simple',
+ } )
+ );
+
+ expect( mockState.cart.items ).toHaveLength( 1 );
+ expect( mockState.cart.items[ 0 ].quantity ).toBe( 3 );
+ } );
+ } );
} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/frontend.ts
index c78f8148c4e..616a668a9f7 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/frontend.ts
@@ -14,9 +14,17 @@ import type {
RawShopperListItem,
Store as ShopperListsStore,
} from '@woocommerce/stores/woocommerce/shopper-lists';
-import type { Store as WooCommerce } from '@woocommerce/stores/woocommerce/cart';
+import type {
+ SelectedAttributes,
+ Store as WooCommerce,
+} from '@woocommerce/stores/woocommerce/cart';
import { sanitizeHTML } from '@woocommerce/sanitize';
+/**
+ * Internal dependencies
+ */
+import { doesCartItemMatchAttributes } from '../../base/utils/variations/does-cart-item-match-attributes';
+
const universalLock =
'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
@@ -134,6 +142,51 @@ const formatVariationLabel = ( item: RawShopperListItem ): string => {
const getList = ( slug: string ) => shopperListsState.lists[ slug ] ?? null;
+/** A single line of the shared cart store, optimistic or server-confirmed. */
+type CartLine = WooCommerce[ 'state' ][ 'cart' ][ 'items' ][ number ];
+
+/**
+ * Sums the quantity across every cart line that represents the given product.
+ *
+ * The "did the add succeed?" check for Move to cart cannot read a single line:
+ * the server owns cart-line identity for adds, so a product already present as
+ * one line (e.g. a meta line) can be resolved into a new standalone line rather
+ * than an in-place bump. Reading one id-matched line would then see no growth
+ * and misread a correct add as a failure. Summing the quantity over all
+ * matching lines makes the comparison order-independent and robust to the
+ * server splitting or merging lines.
+ *
+ * Matching mirrors the cart store's `findItemInCart` product-matching: a simple
+ * product matches by `id`; a variation additionally requires the same number of
+ * variation attributes and the same attribute values, evaluated with
+ * `doesCartItemMatchAttributes` (the selector's own semantics).
+ *
+ * @param items The current cart lines (`cartState.cart.items`).
+ * @param id The product id to match.
+ * @param variation Selected variation attributes (cart shape). Empty/omitted
+ * for a simple product; provided to match variation lines.
+ * @return The total quantity across all matching lines (0 when none match).
+ */
+const sumMatchingCartQuantity = (
+ items: readonly CartLine[],
+ id: number,
+ variation?: SelectedAttributes[]
+): number =>
+ items.reduce( ( total, cartItem ) => {
+ let matches: boolean;
+ if ( cartItem.type === 'variation' ) {
+ matches =
+ id === cartItem.id &&
+ Array.isArray( cartItem.variation ) &&
+ Array.isArray( variation ) &&
+ cartItem.variation.length === variation.length &&
+ doesCartItemMatchAttributes( cartItem, variation );
+ } else {
+ matches = id === cartItem.id;
+ }
+ return matches ? total + cartItem.quantity : total;
+ }, 0 );
+
store< BlockStore >(
'woocommerce/saved-for-later',
{
@@ -263,15 +316,26 @@ store< BlockStore >(
// `cartActions.addCartItem` catches its own errors and
// surfaces them as store notices, so the yield resolves
- // the same way on success and failure. Snapshot the
- // matching line's quantity, run the add, then only remove
- // from the saved list if it actually grew.
- const lookup = {
- id: listItem.id,
- ...( isVariation && { variation } ),
- };
- const beforeItem = cartState.findItemInCart( lookup );
- const beforeQuantity = beforeItem?.quantity ?? 0;
+ // the same way on success and failure. To tell success from
+ // failure we have to read the cart ourselves. The server owns
+ // cart-line identity for adds, so a successful add can land as
+ // a brand new standalone line rather than bumping an existing
+ // one — e.g. when this product is in the cart only as a meta
+ // line (a bundle child, booking, or add-on configuration). A
+ // single id-matched read would misjudge that case: it can
+ // resolve to the unchanged pre-existing line both times, see no
+ // growth, and conclude the add failed even though a new line
+ // was correctly created. So instead, compare the total quantity
+ // across every line matching this product before and after the
+ // add, and only remove from the saved list when that total
+ // grew. Both reads observe the server-reconciled cart because
+ // `addCartItem` resolves only after the mutation batcher commits
+ // it, so the comparison is order-independent.
+ const beforeSum = sumMatchingCartQuantity(
+ cartState.cart.items,
+ listItem.id,
+ isVariation ? variation : undefined
+ );
pendingKeys[ listItem.key ] = true;
try {
@@ -282,10 +346,13 @@ store< BlockStore >(
...( isVariation && { variation } ),
} );
- const afterItem = cartState.findItemInCart( lookup );
- const afterQuantity = afterItem?.quantity ?? 0;
+ const afterSum = sumMatchingCartQuantity(
+ cartState.cart.items,
+ listItem.id,
+ isVariation ? variation : undefined
+ );
- if ( afterQuantity <= beforeQuantity ) {
+ if ( afterSum <= beforeSum ) {
return;
}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/test/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/test/frontend.ts
new file mode 100644
index 00000000000..3115571772a
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/saved-for-later/test/frontend.ts
@@ -0,0 +1,428 @@
+/**
+ * External dependencies
+ */
+import type {
+ OptimisticCartItem,
+ Store as WooCommerce,
+} from '@woocommerce/stores/woocommerce/cart';
+import type { RawShopperListItem } from '@woocommerce/stores/woocommerce/shopper-lists';
+
+type CartItems = WooCommerce[ 'state' ][ 'cart' ][ 'items' ];
+type BlockActions = {
+ onClickMoveToCart: () => Generator< unknown, void >;
+};
+
+// `frontend.ts` registers its block store under `woocommerce/saved-for-later`
+// and opens the shared `woocommerce` cart store plus the
+// `woocommerce/shopper-lists` store, all routed through the mocked `store()`.
+
+// Single shared context the mocked `getContext` returns for the row under test.
+let mockContext: {
+ listItem?: RawShopperListItem;
+ pendingKeys: Record< string, true >;
+};
+
+// The cart store's mutable line list and selector, controlled per test.
+let mockCartItems: CartItems;
+let mockFindItemInCart: jest.Mock;
+
+// Captured cart-store action spies.
+let mockAddCartItem: jest.Mock;
+
+// Captured shopper-lists `removeItem` spy and the block store's registered
+// actions, populated when `frontend.ts` calls the mocked `store()`.
+let mockRemoveItem: jest.Mock;
+let mockBlockActions: BlockActions | null;
+
+// Stands in for `doesCartItemMatchAttributes`; a test controls which seeded
+// lines count as variation matches.
+let mockAttributeMatcher: ( cartItem: OptimisticCartItem ) => boolean;
+
+jest.mock(
+ '@wordpress/interactivity',
+ () => ( {
+ getConfig: jest.fn(),
+ getContext: jest.fn( () => mockContext ),
+ getElement: jest.fn( () => ( { ref: null } ) ),
+ store: jest.fn( ( name: string, definition ) => {
+ if ( name === 'woocommerce/saved-for-later' ) {
+ mockBlockActions = definition?.actions ?? null;
+ return {
+ state: definition?.state,
+ actions: definition?.actions,
+ };
+ }
+ if ( name === 'woocommerce' ) {
+ return {
+ state: {
+ get cart() {
+ return { items: mockCartItems };
+ },
+ findItemInCart: ( ...args: unknown[] ) =>
+ mockFindItemInCart( ...args ),
+ },
+ actions: { addCartItem: mockAddCartItem },
+ };
+ }
+ // woocommerce/shopper-lists
+ return {
+ state: { lists: {} },
+ actions: { removeItem: mockRemoveItem },
+ };
+ } ),
+ } ),
+ { virtual: true }
+);
+
+// Side-effect store registrations `frontend.ts` imports for ordering only.
+jest.mock( '@woocommerce/stores/woocommerce/shopper-lists', () => ( {} ), {
+ virtual: true,
+} );
+jest.mock( '@woocommerce/stores/woocommerce/cart', () => ( {} ), {
+ virtual: true,
+} );
+jest.mock( '@woocommerce/sanitize', () => ( { sanitizeHTML: jest.fn() } ), {
+ virtual: true,
+} );
+
+// Matching is delegated to the cart store's `doesCartItemMatchAttributes`
+// semantics; the test controls which lines match via `mockAttributeMatcher`.
+jest.mock(
+ '../../../base/utils/variations/does-cart-item-match-attributes',
+ () => ( {
+ doesCartItemMatchAttributes: ( cartItem: OptimisticCartItem ) =>
+ mockAttributeMatcher( cartItem ),
+ } )
+);
+
+/**
+ * Drives an Interactivity API async action generator to completion.
+ *
+ * Each yielded value is awaited and fed back into the generator until done,
+ * mirroring how the iAPI runtime drives `*onClickMoveToCart`.
+ *
+ * @param action The async action return value, treated as a generator.
+ * @return A promise resolving once the generator finishes.
+ */
+async function runAction( action: unknown ): Promise< void > {
+ const iterator = action as Iterator< unknown, unknown, unknown >;
+ let next = iterator.next();
+ while ( ! next.done ) {
+ // eslint-disable-next-line no-await-in-loop
+ const resolved = await next.value;
+ next = iterator.next( resolved );
+ }
+}
+
+/**
+ * Builds a saved-for-later list item with sensible purchasable defaults.
+ *
+ * @param overrides Partial fields overriding the defaults.
+ * @return A list item suitable for the row context under test.
+ */
+function makeListItem(
+ overrides: Partial< RawShopperListItem > = {}
+): RawShopperListItem {
+ return {
+ key: 'list-key-1',
+ id: 42,
+ product_id: 42,
+ variation_id: 0,
+ quantity: 1,
+ is_live: true,
+ is_purchasable: true,
+ name: 'Saved Product',
+ permalink: null,
+ images: [],
+ variation: [],
+ prices: null,
+ price_html: '',
+ image_html: '',
+ date_added_gmt: '',
+ ...overrides,
+ };
+}
+
+/**
+ * Builds a minimal cart line for seeding `cart.items`.
+ *
+ * @param overrides Partial cart-line fields overriding the defaults.
+ * @return A cart line.
+ */
+function makeCartLine(
+ overrides: Partial< OptimisticCartItem > = {}
+): OptimisticCartItem {
+ return {
+ id: 42,
+ type: 'simple',
+ quantity: 1,
+ ...overrides,
+ } as OptimisticCartItem;
+}
+
+/**
+ * Loads a fresh copy of the saved-for-later frontend module so it registers its
+ * block store against the mocked `store()` and exposes its actions.
+ *
+ * @return The registered block-store actions.
+ */
+function loadBlockStore(): BlockActions {
+ mockBlockActions = null;
+ jest.isolateModules( () => require( '../frontend' ) );
+ if ( ! mockBlockActions ) {
+ throw new Error( 'Saved-for-later store was not registered.' );
+ }
+ return mockBlockActions;
+}
+
+describe( 'Saved-for-later onClickMoveToCart success detection', () => {
+ beforeEach( () => {
+ mockContext = { pendingKeys: {} };
+ mockCartItems = [];
+ mockFindItemInCart = jest.fn();
+ mockRemoveItem = jest.fn( () => undefined );
+ mockAttributeMatcher = () => false;
+ // By default the add resolves without mutating the cart; individual
+ // tests install an `addCartItem` that mutates `mockCartItems` to model
+ // the server-reconciled cart.
+ mockAddCartItem = jest.fn( () => Promise.resolve() );
+ } );
+
+ afterEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ it( 'removes the entry when a meta-only product gains a new standalone line (before-sum 1, after-sum 2)', async () => {
+ // The product exists only as a single meta line at quantity 1; the add
+ // resolves server-side as a separate standalone line, so the total
+ // quantity across matching lines goes 1 -> 2.
+ mockContext.listItem = makeListItem( { id: 42, quantity: 1 } );
+ mockCartItems = [ makeCartLine( { id: 42, quantity: 1 } ) ];
+ mockAddCartItem = jest.fn( () => {
+ mockCartItems = [
+ makeCartLine( { id: 42, quantity: 1 } ),
+ makeCartLine( { id: 42, quantity: 1, key: 'new-line' } ),
+ ];
+ return Promise.resolve();
+ } );
+
+ const actions = loadBlockStore();
+ await runAction( actions.onClickMoveToCart() );
+
+ expect( mockRemoveItem ).toHaveBeenCalledWith(
+ 'saved-for-later',
+ 'list-key-1'
+ );
+ } );
+
+ it( 'removes the entry when an existing standalone line is incremented (before-sum 1, after-sum 2)', async () => {
+ mockContext.listItem = makeListItem( { id: 42, quantity: 1 } );
+ mockCartItems = [ makeCartLine( { id: 42, quantity: 1 } ) ];
+ mockAddCartItem = jest.fn( () => {
+ mockCartItems = [ makeCartLine( { id: 42, quantity: 2 } ) ];
+ return Promise.resolve();
+ } );
+
+ const actions = loadBlockStore();
+ await runAction( actions.onClickMoveToCart() );
+
+ expect( mockRemoveItem ).toHaveBeenCalledWith(
+ 'saved-for-later',
+ 'list-key-1'
+ );
+ } );
+
+ it( 'preserves the entry when the server rejects the add and the cart is unchanged (before-sum equals after-sum)', async () => {
+ mockContext.listItem = makeListItem( { id: 42, quantity: 1 } );
+ mockCartItems = [ makeCartLine( { id: 42, quantity: 1 } ) ];
+ // addCartItem swallows the error and leaves the cart untouched.
+ mockAddCartItem = jest.fn( () => Promise.resolve() );
+
+ const actions = loadBlockStore();
+ await runAction( actions.onClickMoveToCart() );
+
+ expect( mockRemoveItem ).not.toHaveBeenCalled();
+ } );
+
+ it( 'sums over all matching lines rather than a single id-matched line', async () => {
+ // Two matching lines exist before (1 + 2 = 3). The add bumps one of them
+ // so the total becomes 4. A single-line read of just one matched line
+ // could miss the growth; summing all matching lines detects it.
+ mockContext.listItem = makeListItem( { id: 42, quantity: 1 } );
+ mockCartItems = [
+ makeCartLine( { id: 42, quantity: 1, key: 'a' } ),
+ makeCartLine( { id: 42, quantity: 2, key: 'b' } ),
+ ];
+ mockAddCartItem = jest.fn( () => {
+ mockCartItems = [
+ makeCartLine( { id: 42, quantity: 2, key: 'a' } ),
+ makeCartLine( { id: 42, quantity: 2, key: 'b' } ),
+ ];
+ return Promise.resolve();
+ } );
+
+ const actions = loadBlockStore();
+ await runAction( actions.onClickMoveToCart() );
+
+ expect( mockRemoveItem ).toHaveBeenCalledWith(
+ 'saved-for-later',
+ 'list-key-1'
+ );
+ } );
+
+ it( 'compares total quantity, not the cart-line count', async () => {
+ // The line count stays at 1 across the add (an in-place increment), so a
+ // count-based check would read no change. The total quantity grows
+ // 1 -> 3, so a quantity sum correctly detects success.
+ mockContext.listItem = makeListItem( { id: 42, quantity: 2 } );
+ mockCartItems = [ makeCartLine( { id: 42, quantity: 1 } ) ];
+ mockAddCartItem = jest.fn( () => {
+ mockCartItems = [ makeCartLine( { id: 42, quantity: 3 } ) ];
+ return Promise.resolve();
+ } );
+
+ const actions = loadBlockStore();
+ await runAction( actions.onClickMoveToCart() );
+
+ expect( mockRemoveItem ).toHaveBeenCalledWith(
+ 'saved-for-later',
+ 'list-key-1'
+ );
+ } );
+
+ it( 'is independent of cart-line ordering', async () => {
+ // Same lines as the multi-line case but seeded in reverse order before
+ // and after; the result must not depend on iteration order.
+ mockContext.listItem = makeListItem( { id: 42, quantity: 1 } );
+ mockCartItems = [
+ makeCartLine( { id: 99, quantity: 5, key: 'other' } ),
+ makeCartLine( { id: 42, quantity: 1, key: 'a' } ),
+ ];
+ mockAddCartItem = jest.fn( () => {
+ mockCartItems = [
+ makeCartLine( { id: 42, quantity: 1, key: 'new' } ),
+ makeCartLine( { id: 99, quantity: 5, key: 'other' } ),
+ makeCartLine( { id: 42, quantity: 1, key: 'a' } ),
+ ];
+ return Promise.resolve();
+ } );
+
+ const actions = loadBlockStore();
+ await runAction( actions.onClickMoveToCart() );
+
+ expect( mockRemoveItem ).toHaveBeenCalledWith(
+ 'saved-for-later',
+ 'list-key-1'
+ );
+ } );
+
+ it( 'ignores non-matching product lines when summing', async () => {
+ // A different product (id 99) churns its quantity across the add while
+ // the saved product (id 42) is unchanged. Summing only matching lines
+ // must read no growth and preserve the entry.
+ mockContext.listItem = makeListItem( { id: 42, quantity: 1 } );
+ mockCartItems = [
+ makeCartLine( { id: 42, quantity: 1 } ),
+ makeCartLine( { id: 99, quantity: 1, key: 'other' } ),
+ ];
+ mockAddCartItem = jest.fn( () => {
+ mockCartItems = [
+ makeCartLine( { id: 42, quantity: 1 } ),
+ makeCartLine( { id: 99, quantity: 8, key: 'other' } ),
+ ];
+ return Promise.resolve();
+ } );
+
+ const actions = loadBlockStore();
+ await runAction( actions.onClickMoveToCart() );
+
+ expect( mockRemoveItem ).not.toHaveBeenCalled();
+ } );
+
+ it( 'sums over lines matching id and variation attributes for a variation entry', async () => {
+ mockContext.listItem = makeListItem( {
+ id: 7,
+ variation_id: 7,
+ quantity: 1,
+ variation: [
+ {
+ raw_attribute: 'attribute_pa_color',
+ attribute: 'Color',
+ value: 'blue',
+ },
+ ],
+ } );
+ // Variation lines carry a same-length `variation` array (matching the
+ // selector's length guard); the attribute matcher decides which one is
+ // the saved product. One variation line of the saved product plus a
+ // same-id line with a different (non-matching) variation.
+ const variationAttr = [
+ {
+ raw_attribute: 'attribute_pa_color',
+ attribute: 'Color',
+ value: 'x',
+ },
+ ];
+ const matchingLine = makeCartLine( {
+ id: 7,
+ type: 'variation',
+ quantity: 1,
+ key: 'match',
+ variation: variationAttr,
+ } );
+ const otherVariationLine = makeCartLine( {
+ id: 7,
+ type: 'variation',
+ quantity: 4,
+ key: 'other-variation',
+ variation: variationAttr,
+ } );
+ mockCartItems = [ matchingLine, otherVariationLine ];
+ // Only the matching variation line satisfies the attribute matcher.
+ mockAttributeMatcher = ( cartItem ) => cartItem.key === 'match';
+ mockAddCartItem = jest.fn( () => {
+ mockCartItems = [
+ makeCartLine( {
+ id: 7,
+ type: 'variation',
+ quantity: 2,
+ key: 'match',
+ variation: variationAttr,
+ } ),
+ makeCartLine( {
+ id: 7,
+ type: 'variation',
+ quantity: 4,
+ key: 'other-variation',
+ variation: variationAttr,
+ } ),
+ ];
+ return Promise.resolve();
+ } );
+
+ const actions = loadBlockStore();
+ await runAction( actions.onClickMoveToCart() );
+
+ expect( mockRemoveItem ).toHaveBeenCalledWith(
+ 'saved-for-later',
+ 'list-key-1'
+ );
+ } );
+
+ it( 'does not call findItemInCart for the success check (consumer sums cart.items directly)', async () => {
+ // The before/after read sums `cart.items` itself; `findItemInCart` must
+ // not be the mechanism for the success comparison.
+ mockContext.listItem = makeListItem( { id: 42, quantity: 1 } );
+ mockCartItems = [ makeCartLine( { id: 42, quantity: 1 } ) ];
+ mockAddCartItem = jest.fn( () => {
+ mockCartItems = [ makeCartLine( { id: 42, quantity: 2 } ) ];
+ return Promise.resolve();
+ } );
+
+ const actions = loadBlockStore();
+ await runAction( actions.onClickMoveToCart() );
+
+ expect( mockFindItemInCart ).not.toHaveBeenCalled();
+ expect( mockRemoveItem ).toHaveBeenCalled();
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/frontend.ts
index a9d6451fcc3..ae777eb6523 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/frontend.ts
@@ -14,9 +14,17 @@ import type {
RawShopperListItem,
Store as ShopperListsStore,
} from '@woocommerce/stores/woocommerce/shopper-lists';
-import type { Store as WooCommerce } from '@woocommerce/stores/woocommerce/cart';
+import type {
+ SelectedAttributes,
+ Store as WooCommerce,
+} from '@woocommerce/stores/woocommerce/cart';
import { sanitizeHTML } from '@woocommerce/sanitize';
+/**
+ * Internal dependencies
+ */
+import { doesCartItemMatchAttributes } from '../../base/utils/variations/does-cart-item-match-attributes';
+
const universalLock =
'I acknowledge that using a private store means my plugin will inevitably break on the next store release.';
@@ -126,6 +134,51 @@ const formatVariationLabel = ( item: RawShopperListItem ): string => {
const getList = ( slug: string ) => shopperListsState.lists[ slug ] ?? null;
+/** A single line of the shared cart store, optimistic or server-confirmed. */
+type CartLine = WooCommerce[ 'state' ][ 'cart' ][ 'items' ][ number ];
+
+/**
+ * Sums the quantity across every cart line that represents the given product.
+ *
+ * The "did the add succeed?" check for Add to cart cannot read a single line:
+ * the server owns cart-line identity for adds, so a product already present as
+ * one line (e.g. a meta line) can be resolved into a new standalone line rather
+ * than an in-place bump. Reading one id-matched line would then see no growth
+ * and misread a correct add as a failure. Summing the quantity over all
+ * matching lines makes the comparison order-independent and robust to the
+ * server splitting or merging lines.
+ *
+ * Matching mirrors the cart store's `findItemInCart` product-matching: a simple
+ * product matches by `id`; a variation additionally requires the same number of
+ * variation attributes and the same attribute values, evaluated with
+ * `doesCartItemMatchAttributes` (the selector's own semantics).
+ *
+ * @param items The current cart lines (`cartState.cart.items`).
+ * @param id The product id to match.
+ * @param variation Selected variation attributes (cart shape). Empty/omitted
+ * for a simple product; provided to match variation lines.
+ * @return The total quantity across all matching lines (0 when none match).
+ */
+const sumMatchingCartQuantity = (
+ items: readonly CartLine[],
+ id: number,
+ variation?: SelectedAttributes[]
+): number =>
+ items.reduce( ( total, cartItem ) => {
+ let matches: boolean;
+ if ( cartItem.type === 'variation' ) {
+ matches =
+ id === cartItem.id &&
+ Array.isArray( cartItem.variation ) &&
+ Array.isArray( variation ) &&
+ cartItem.variation.length === variation.length &&
+ doesCartItemMatchAttributes( cartItem, variation );
+ } else {
+ matches = id === cartItem.id;
+ }
+ return matches ? total + cartItem.quantity : total;
+ }, 0 );
+
store< BlockStore >(
'woocommerce/wishlist',
{
@@ -240,17 +293,26 @@ store< BlockStore >(
// Wishlist always adds quantity 1 (no quantity column).
// `cartActions.addCartItem` catches its own errors and
// surfaces them as store notices, so the yield resolves
- // the same way on success and failure. Snapshot the
- // matching line's quantity, run the add, then only remove
- // from the wishlist if the cart line actually grew — that
- // guards against partial-stock and silent-failure paths
- // where we shouldn't drop the wishlist entry.
- const lookup = {
- id: listItem.id,
- ...( isVariation && { variation } ),
- };
- const beforeItem = cartState.findItemInCart( lookup );
- const beforeQuantity = beforeItem?.quantity ?? 0;
+ // the same way on success and failure. To tell success from
+ // failure we have to read the cart ourselves. The server owns
+ // cart-line identity for adds, so a successful add can land as
+ // a brand new standalone line rather than bumping an existing
+ // one — e.g. when this product is in the cart only as a meta
+ // line (a bundle child, booking, or add-on configuration). A
+ // single id-matched read would misjudge that case: it can
+ // resolve to the unchanged pre-existing line both times, see no
+ // growth, and conclude the add failed even though a new line
+ // was correctly created. So instead, compare the total quantity
+ // across every line matching this product before and after the
+ // add, and only remove from the wishlist when that total grew.
+ // Both reads observe the server-reconciled cart because
+ // `addCartItem` resolves only after the mutation batcher commits
+ // it, so the comparison is order-independent.
+ const beforeSum = sumMatchingCartQuantity(
+ cartState.cart.items,
+ listItem.id,
+ isVariation ? variation : undefined
+ );
pendingKeys[ listItem.key ] = true;
try {
@@ -261,10 +323,13 @@ store< BlockStore >(
...( isVariation && { variation } ),
} );
- const afterItem = cartState.findItemInCart( lookup );
- const afterQuantity = afterItem?.quantity ?? 0;
+ const afterSum = sumMatchingCartQuantity(
+ cartState.cart.items,
+ listItem.id,
+ isVariation ? variation : undefined
+ );
- if ( afterQuantity <= beforeQuantity ) {
+ if ( afterSum <= beforeSum ) {
return;
}
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/test/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/test/frontend.ts
new file mode 100644
index 00000000000..b58bc180eaa
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/wishlist/test/frontend.ts
@@ -0,0 +1,427 @@
+/**
+ * External dependencies
+ */
+import type {
+ OptimisticCartItem,
+ Store as WooCommerce,
+} from '@woocommerce/stores/woocommerce/cart';
+import type { RawShopperListItem } from '@woocommerce/stores/woocommerce/shopper-lists';
+
+type CartItems = WooCommerce[ 'state' ][ 'cart' ][ 'items' ];
+type BlockActions = {
+ onClickAddToCart: () => Generator< unknown, void >;
+};
+
+// `frontend.ts` registers its block store under `woocommerce/wishlist` and opens
+// the shared `woocommerce` cart store plus the `woocommerce/shopper-lists`
+// store, all routed through the mocked `store()`.
+
+// Single shared context the mocked `getContext` returns for the row under test.
+let mockContext: {
+ listItem?: RawShopperListItem;
+ pendingKeys: Record< string, true >;
+};
+
+// The cart store's mutable line list and selector, controlled per test.
+let mockCartItems: CartItems;
+let mockFindItemInCart: jest.Mock;
+
+// Captured cart-store action spies.
+let mockAddCartItem: jest.Mock;
+
+// Captured shopper-lists `removeItem` spy and the block store's registered
+// actions, populated when `frontend.ts` calls the mocked `store()`.
+let mockRemoveItem: jest.Mock;
+let mockBlockActions: BlockActions | null;
+
+// Stands in for `doesCartItemMatchAttributes`; a test controls which seeded
+// lines count as variation matches.
+let mockAttributeMatcher: ( cartItem: OptimisticCartItem ) => boolean;
+
+jest.mock(
+ '@wordpress/interactivity',
+ () => ( {
+ getConfig: jest.fn(),
+ getContext: jest.fn( () => mockContext ),
+ getElement: jest.fn( () => ( { ref: null } ) ),
+ store: jest.fn( ( name: string, definition ) => {
+ if ( name === 'woocommerce/wishlist' ) {
+ mockBlockActions = definition?.actions ?? null;
+ return {
+ state: definition?.state,
+ actions: definition?.actions,
+ };
+ }
+ if ( name === 'woocommerce' ) {
+ return {
+ state: {
+ get cart() {
+ return { items: mockCartItems };
+ },
+ findItemInCart: ( ...args: unknown[] ) =>
+ mockFindItemInCart( ...args ),
+ },
+ actions: { addCartItem: mockAddCartItem },
+ };
+ }
+ // woocommerce/shopper-lists
+ return {
+ state: { lists: {} },
+ actions: { removeItem: mockRemoveItem },
+ };
+ } ),
+ } ),
+ { virtual: true }
+);
+
+// Side-effect store registrations `frontend.ts` imports for ordering only.
+jest.mock( '@woocommerce/stores/woocommerce/shopper-lists', () => ( {} ), {
+ virtual: true,
+} );
+jest.mock( '@woocommerce/stores/woocommerce/cart', () => ( {} ), {
+ virtual: true,
+} );
+jest.mock( '@woocommerce/sanitize', () => ( { sanitizeHTML: jest.fn() } ), {
+ virtual: true,
+} );
+
+// Matching is delegated to the cart store's `doesCartItemMatchAttributes`
+// semantics; the test controls which lines match via `mockAttributeMatcher`.
+jest.mock(
+ '../../../base/utils/variations/does-cart-item-match-attributes',
+ () => ( {
+ doesCartItemMatchAttributes: ( cartItem: OptimisticCartItem ) =>
+ mockAttributeMatcher( cartItem ),
+ } )
+);
+
+/**
+ * Drives an Interactivity API async action generator to completion.
+ *
+ * Each yielded value is awaited and fed back into the generator until done,
+ * mirroring how the iAPI runtime drives `*onClickAddToCart`.
+ *
+ * @param action The async action return value, treated as a generator.
+ * @return A promise resolving once the generator finishes.
+ */
+async function runAction( action: unknown ): Promise< void > {
+ const iterator = action as Iterator< unknown, unknown, unknown >;
+ let next = iterator.next();
+ while ( ! next.done ) {
+ // eslint-disable-next-line no-await-in-loop
+ const resolved = await next.value;
+ next = iterator.next( resolved );
+ }
+}
+
+/**
+ * Builds a wishlist list item with sensible purchasable defaults.
+ *
+ * @param overrides Partial fields overriding the defaults.
+ * @return A list item suitable for the row context under test.
+ */
+function makeListItem(
+ overrides: Partial< RawShopperListItem > = {}
+): RawShopperListItem {
+ return {
+ key: 'list-key-1',
+ id: 42,
+ product_id: 42,
+ variation_id: 0,
+ quantity: 1,
+ is_live: true,
+ is_purchasable: true,
+ name: 'Wishlist Product',
+ permalink: null,
+ images: [],
+ variation: [],
+ prices: null,
+ price_html: '',
+ image_html: '',
+ date_added_gmt: '',
+ ...overrides,
+ };
+}
+
+/**
+ * Builds a minimal cart line for seeding `cart.items`.
+ *
+ * @param overrides Partial cart-line fields overriding the defaults.
+ * @return A cart line.
+ */
+function makeCartLine(
+ overrides: Partial< OptimisticCartItem > = {}
+): OptimisticCartItem {
+ return {
+ id: 42,
+ type: 'simple',
+ quantity: 1,
+ ...overrides,
+ } as OptimisticCartItem;
+}
+
+/**
+ * Loads a fresh copy of the wishlist frontend module so it registers its block
+ * store against the mocked `store()` and exposes its actions.
+ *
+ * @return The registered block-store actions.
+ */
+function loadBlockStore(): BlockActions {
+ mockBlockActions = null;
+ jest.isolateModules( () => require( '../frontend' ) );
+ if ( ! mockBlockActions ) {
+ throw new Error( 'Wishlist store was not registered.' );
+ }
+ return mockBlockActions;
+}
+
+describe( 'Wishlist onClickAddToCart success detection', () => {
+ beforeEach( () => {
+ mockContext = { pendingKeys: {} };
+ mockCartItems = [];
+ mockFindItemInCart = jest.fn();
+ mockRemoveItem = jest.fn( () => undefined );
+ mockAttributeMatcher = () => false;
+ // By default the add resolves without mutating the cart; individual
+ // tests install an `addCartItem` that mutates `mockCartItems` to model
+ // the server-reconciled cart.
+ mockAddCartItem = jest.fn( () => Promise.resolve() );
+ } );
+
+ afterEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ it( 'removes the entry when a meta-only product gains a new standalone line (before-sum 1, after-sum 2)', async () => {
+ // The product exists only as a single meta line at quantity 1; the add
+ // resolves server-side as a separate standalone line, so the total
+ // quantity across matching lines goes 1 -> 2.
+ mockContext.listItem = makeListItem( { id: 42 } );
+ mockCartItems = [ makeCartLine( { id: 42, quantity: 1 } ) ];
+ mockAddCartItem = jest.fn( () => {
+ mockCartItems = [
+ makeCartLine( { id: 42, quantity: 1 } ),
+ makeCartLine( { id: 42, quantity: 1, key: 'new-line' } ),
+ ];
+ return Promise.resolve();
+ } );
+
+ const actions = loadBlockStore();
+ await runAction( actions.onClickAddToCart() );
+
+ expect( mockRemoveItem ).toHaveBeenCalledWith(
+ 'wishlist',
+ 'list-key-1'
+ );
+ } );
+
+ it( 'removes the entry when an existing standalone line is incremented (before-sum 1, after-sum 2)', async () => {
+ mockContext.listItem = makeListItem( { id: 42 } );
+ mockCartItems = [ makeCartLine( { id: 42, quantity: 1 } ) ];
+ mockAddCartItem = jest.fn( () => {
+ mockCartItems = [ makeCartLine( { id: 42, quantity: 2 } ) ];
+ return Promise.resolve();
+ } );
+
+ const actions = loadBlockStore();
+ await runAction( actions.onClickAddToCart() );
+
+ expect( mockRemoveItem ).toHaveBeenCalledWith(
+ 'wishlist',
+ 'list-key-1'
+ );
+ } );
+
+ it( 'preserves the entry when the server rejects the add and the cart is unchanged (before-sum equals after-sum)', async () => {
+ mockContext.listItem = makeListItem( { id: 42 } );
+ mockCartItems = [ makeCartLine( { id: 42, quantity: 1 } ) ];
+ // addCartItem swallows the error and leaves the cart untouched.
+ mockAddCartItem = jest.fn( () => Promise.resolve() );
+
+ const actions = loadBlockStore();
+ await runAction( actions.onClickAddToCart() );
+
+ expect( mockRemoveItem ).not.toHaveBeenCalled();
+ } );
+
+ it( 'sums over all matching lines rather than a single id-matched line', async () => {
+ // Two matching lines exist before (1 + 2 = 3). The add bumps one of them
+ // so the total becomes 4. A single-line read of just one matched line
+ // could miss the growth; summing all matching lines detects it.
+ mockContext.listItem = makeListItem( { id: 42 } );
+ mockCartItems = [
+ makeCartLine( { id: 42, quantity: 1, key: 'a' } ),
+ makeCartLine( { id: 42, quantity: 2, key: 'b' } ),
+ ];
+ mockAddCartItem = jest.fn( () => {
+ mockCartItems = [
+ makeCartLine( { id: 42, quantity: 2, key: 'a' } ),
+ makeCartLine( { id: 42, quantity: 2, key: 'b' } ),
+ ];
+ return Promise.resolve();
+ } );
+
+ const actions = loadBlockStore();
+ await runAction( actions.onClickAddToCart() );
+
+ expect( mockRemoveItem ).toHaveBeenCalledWith(
+ 'wishlist',
+ 'list-key-1'
+ );
+ } );
+
+ it( 'compares total quantity, not the cart-line count', async () => {
+ // The line count stays at 1 across the add (an in-place increment), so a
+ // count-based check would read no change. The total quantity grows
+ // 1 -> 2, so a quantity sum correctly detects success.
+ mockContext.listItem = makeListItem( { id: 42 } );
+ mockCartItems = [ makeCartLine( { id: 42, quantity: 1 } ) ];
+ mockAddCartItem = jest.fn( () => {
+ mockCartItems = [ makeCartLine( { id: 42, quantity: 2 } ) ];
+ return Promise.resolve();
+ } );
+
+ const actions = loadBlockStore();
+ await runAction( actions.onClickAddToCart() );
+
+ expect( mockRemoveItem ).toHaveBeenCalledWith(
+ 'wishlist',
+ 'list-key-1'
+ );
+ } );
+
+ it( 'is independent of cart-line ordering', async () => {
+ // Same lines as the multi-line case but seeded in mixed order before and
+ // after; the result must not depend on iteration order.
+ mockContext.listItem = makeListItem( { id: 42 } );
+ mockCartItems = [
+ makeCartLine( { id: 99, quantity: 5, key: 'other' } ),
+ makeCartLine( { id: 42, quantity: 1, key: 'a' } ),
+ ];
+ mockAddCartItem = jest.fn( () => {
+ mockCartItems = [
+ makeCartLine( { id: 42, quantity: 1, key: 'new' } ),
+ makeCartLine( { id: 99, quantity: 5, key: 'other' } ),
+ makeCartLine( { id: 42, quantity: 1, key: 'a' } ),
+ ];
+ return Promise.resolve();
+ } );
+
+ const actions = loadBlockStore();
+ await runAction( actions.onClickAddToCart() );
+
+ expect( mockRemoveItem ).toHaveBeenCalledWith(
+ 'wishlist',
+ 'list-key-1'
+ );
+ } );
+
+ it( 'ignores non-matching product lines when summing', async () => {
+ // A different product (id 99) churns its quantity across the add while
+ // the wishlisted product (id 42) is unchanged. Summing only matching
+ // lines must read no growth and preserve the entry.
+ mockContext.listItem = makeListItem( { id: 42 } );
+ mockCartItems = [
+ makeCartLine( { id: 42, quantity: 1 } ),
+ makeCartLine( { id: 99, quantity: 1, key: 'other' } ),
+ ];
+ mockAddCartItem = jest.fn( () => {
+ mockCartItems = [
+ makeCartLine( { id: 42, quantity: 1 } ),
+ makeCartLine( { id: 99, quantity: 8, key: 'other' } ),
+ ];
+ return Promise.resolve();
+ } );
+
+ const actions = loadBlockStore();
+ await runAction( actions.onClickAddToCart() );
+
+ expect( mockRemoveItem ).not.toHaveBeenCalled();
+ } );
+
+ it( 'sums over lines matching id and variation attributes for a variation entry', async () => {
+ mockContext.listItem = makeListItem( {
+ id: 7,
+ variation_id: 7,
+ variation: [
+ {
+ raw_attribute: 'attribute_pa_color',
+ attribute: 'Color',
+ value: 'blue',
+ },
+ ],
+ } );
+ // Variation lines carry a same-length `variation` array (matching the
+ // selector's length guard); the attribute matcher decides which one is
+ // the wishlisted product. One variation line of the wishlisted product
+ // plus a same-id line with a different (non-matching) variation.
+ const variationAttr = [
+ {
+ raw_attribute: 'attribute_pa_color',
+ attribute: 'Color',
+ value: 'x',
+ },
+ ];
+ const matchingLine = makeCartLine( {
+ id: 7,
+ type: 'variation',
+ quantity: 1,
+ key: 'match',
+ variation: variationAttr,
+ } );
+ const otherVariationLine = makeCartLine( {
+ id: 7,
+ type: 'variation',
+ quantity: 4,
+ key: 'other-variation',
+ variation: variationAttr,
+ } );
+ mockCartItems = [ matchingLine, otherVariationLine ];
+ // Only the matching variation line satisfies the attribute matcher.
+ mockAttributeMatcher = ( cartItem ) => cartItem.key === 'match';
+ mockAddCartItem = jest.fn( () => {
+ mockCartItems = [
+ makeCartLine( {
+ id: 7,
+ type: 'variation',
+ quantity: 2,
+ key: 'match',
+ variation: variationAttr,
+ } ),
+ makeCartLine( {
+ id: 7,
+ type: 'variation',
+ quantity: 4,
+ key: 'other-variation',
+ variation: variationAttr,
+ } ),
+ ];
+ return Promise.resolve();
+ } );
+
+ const actions = loadBlockStore();
+ await runAction( actions.onClickAddToCart() );
+
+ expect( mockRemoveItem ).toHaveBeenCalledWith(
+ 'wishlist',
+ 'list-key-1'
+ );
+ } );
+
+ it( 'does not call findItemInCart for the success check (consumer sums cart.items directly)', async () => {
+ // The before/after read sums `cart.items` itself; `findItemInCart` must
+ // not be the mechanism for the success comparison.
+ mockContext.listItem = makeListItem( { id: 42 } );
+ mockCartItems = [ makeCartLine( { id: 42, quantity: 1 } ) ];
+ mockAddCartItem = jest.fn( () => {
+ mockCartItems = [ makeCartLine( { id: 42, quantity: 2 } ) ];
+ return Promise.resolve();
+ } );
+
+ const actions = loadBlockStore();
+ await runAction( actions.onClickAddToCart() );
+
+ expect( mockFindItemInCart ).not.toHaveBeenCalled();
+ expect( mockRemoveItem ).toHaveBeenCalled();
+ } );
+} );
diff --git a/plugins/woocommerce/tests/e2e/test-plugins/blocks/cart-line-identity.php b/plugins/woocommerce/tests/e2e/test-plugins/blocks/cart-line-identity.php
new file mode 100644
index 00000000000..49c43bac0ac
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/test-plugins/blocks/cart-line-identity.php
@@ -0,0 +1,99 @@
+<?php
+/**
+ * Plugin Name: WooCommerce Blocks Test Cart Line Identity
+ * Description: Simulates a meta-differentiated cart line for blocks e2e tests by marking flagged add-to-cart requests.
+ * Plugin URI: https://github.com/woocommerce/woocommerce
+ * Author: WooCommerce
+ *
+ * @package woocommerce-blocks-test-cart-line-identity
+ */
+
+/**
+ * What this simulates.
+ *
+ * Some cart lines share a product id with another line but are differentiated
+ * by item metadata: a bundle child, a booking, a Product Add-Ons configuration,
+ * a gift-card recipient. Those extensions are not installed in the e2e
+ * environment, so this helper stands in for one such "meta-differentiated"
+ * line. When the request carries the marker flag below, the added item gets a
+ * unique cart_item_data entry; because core's WC_Cart::generate_cart_id() folds
+ * cart_item_data into the cart-line hash, the flagged add lands as a line whose
+ * cart id differs from a plain (unflagged) add of the same product — exactly as
+ * a real meta-differentiated line does, without shipping a real extension.
+ *
+ * How to activate it.
+ *
+ * This is a test-only helper that ships no real behavior and modifies no
+ * WooCommerce source: with the flag absent (its default state for every
+ * untouched request) it is inert. An e2e test opts in by activating the plugin
+ * via its WordPress slug — the @package value above,
+ * "woocommerce-blocks-test-cart-line-identity" — e.g.
+ * requestUtils.activatePlugin( ... ), as the sibling helper plugins here are.
+ *
+ * How a test uses it.
+ *
+ * To set up the "product X is already in the cart as a meta line" precondition,
+ * a test issues one flagged add (append CART_LINE_IDENTITY_FLAG to the add-to-cart
+ * URL); that seeds the meta-differentiated line. A subsequent plain (unflagged)
+ * add of the same product through the block UI then exercises the behavior under
+ * test, and core resolves it as a separate standalone line. Toggling the marker
+ * is what produces the meta-differentiated line: a flagged add creates/extends a
+ * meta line, an unflagged add follows the normal standalone-line identity.
+ */
+
+declare(strict_types=1);
+
+/**
+ * Request flag a test toggles to mark an add as a meta line (see file header).
+ *
+ * Append it to the add-to-cart request (the Store API add-item URL or the legacy
+ * add-to-cart URL); when present, the added item receives the unique
+ * cart_item_data entry below. Absent, the add is left untouched.
+ */
+const CART_LINE_IDENTITY_FLAG = 'cart_line_identity_marker';
+
+/**
+ * cart_item_data key used to carry the marker on the cart line.
+ *
+ * The leading underscore keeps it out of the customer-visible item data list,
+ * matching how extensions store internal differentiators.
+ */
+const CART_LINE_IDENTITY_KEY = '_cart_line_identity';
+
+add_filter(
+ 'woocommerce_add_cart_item_data',
+ /**
+ * Attach a unique cart_item_data marker to a flagged add-to-cart request.
+ *
+ * Has effect only when the request carries the CART_LINE_IDENTITY_FLAG; a
+ * plain add returns the cart item data untouched, so it follows the normal
+ * (standalone-line) identity and increments any existing standalone line.
+ *
+ * The marker value is taken from the flag so a test can mint more than one
+ * distinct meta line if needed; an empty/bare flag falls back to a stable
+ * default value, which is enough to differentiate one meta line from the
+ * plain line.
+ *
+ * @param array $cart_item_data Existing cart item data.
+ * @return array Cart item data, with the marker added when the flag is present.
+ */
+ function ( $cart_item_data ) {
+ // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only test marker, no state change; nonce handled by the underlying add-to-cart request.
+ if ( ! isset( $_REQUEST[ CART_LINE_IDENTITY_FLAG ] ) ) {
+ return $cart_item_data;
+ }
+
+ $marker = sanitize_text_field( wp_unslash( $_REQUEST[ CART_LINE_IDENTITY_FLAG ] ) );
+ // phpcs:enable WordPress.Security.NonceVerification.Recommended
+
+ if ( '' === $marker ) {
+ $marker = 'meta-line';
+ }
+
+ $cart_item_data[ CART_LINE_IDENTITY_KEY ] = $marker;
+
+ return $cart_item_data;
+ },
+ 10,
+ 1
+);
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/cart-store/cart-line-identity.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/cart-store/cart-line-identity.block_theme.spec.ts
new file mode 100644
index 00000000000..c61db94138d
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/cart-store/cart-line-identity.block_theme.spec.ts
@@ -0,0 +1,368 @@
+/**
+ * External dependencies
+ */
+import { test as base, expect, guestFile, wpCLI } from '@woocommerce/e2e-utils';
+
+/**
+ * Internal dependencies
+ */
+import AddToCartWithOptionsPage from '../add-to-cart-with-options/add-to-cart-with-options.page';
+import {
+ CART_LINE_IDENTITY_PLUGIN,
+ PRODUCT_X,
+ cartLineRows,
+ productButton,
+ readCartLineQuantities,
+ seedMetaLine,
+} from './utils';
+
+/**
+ * E2E flows for "Block add-to-cart action respects cart-line identity".
+ *
+ * Background: on a keyless add (every add-to-cart consumer), the iAPI cart
+ * store now always issues `add-item` with the requested quantity delta and lets
+ * WooCommerce core's `generate_cart_id` decide match-or-create, instead of
+ * matching a line by product id on the client and converting the add into an
+ * `update-item`. The flows below exercise that behaviour against a real cart
+ * through the ProductButton, the Add to Cart with Options block, and the
+ * Mini-Cart stepper.
+ *
+ * A "meta line" — a stand-in for a bundle child / booking / add-on / recipient
+ * line — is simulated by the cart-line-identity helper plugin
+ * (`woocommerce-blocks-test-cart-line-identity`), which attaches a unique
+ * `cart_item_data` marker to a flagged add so core mints a distinct cart line
+ * for the same product id. See `./utils.ts`.
+ *
+ * Notice-showing consumers (saved-for-later, wishlist) are gated behind the
+ * `product_wishlist` feature flag and are not registered in this e2e
+ * environment, so their blocks cannot be placed on a page here. The notice
+ * outcomes those consumers depend on — no spurious "quantity changed" info
+ * notice on a meta-only add and entry preservation on a genuine rejection —
+ * are asserted authoritatively at the store/consumer unit level (the cart
+ * store and saved-for-later / wishlist frontend unit tests). The cart-outcome
+ * substrate those consumers rely on is still asserted end-to-end below: a
+ * meta-only keyless add creates a new standalone line with no error notice,
+ * and a genuinely rejected add leaves the cart unchanged and surfaces the
+ * server error.
+ */
+
+const test = base.extend< {
+ addToCartWithOptionsPage: AddToCartWithOptionsPage;
+} >( {
+ addToCartWithOptionsPage: async ( { page, admin, editor }, use ) => {
+ await use( new AddToCartWithOptionsPage( { page, admin, editor } ) );
+ },
+} );
+
+test.describe( 'Add to cart respects cart-line identity', () => {
+ test.describe( 'with a meta-differentiated line present', () => {
+ // Run as a guest so every test gets a brand-new browser context with no
+ // session cookie, i.e. an isolated empty cart — cart-line-identity
+ // outcomes are sensitive to leftover cart state, so no cross-test
+ // cleanup is needed. The same guest session cookie is shared, within a
+ // test, between the meta-line seeding navigation and the Store API add
+ // path.
+ test.use( { storageState: guestFile } );
+
+ // Activate in beforeEach because the helper is deactivated when the DB
+ // is reset; mirrors mini-cart.block_theme.spec.ts's item-data plugin.
+ test.beforeEach( async ( { requestUtils } ) => {
+ await requestUtils.activatePlugin( CART_LINE_IDENTITY_PLUGIN );
+ } );
+
+ test( 'Meta line present, no standalone — add creates a new line, meta line unchanged, no error notice', async ( {
+ page,
+ frontendUtils,
+ } ) => {
+ // Seed: product X is in the cart only as a single meta line (qty 1).
+ await seedMetaLine( page, PRODUCT_X.id );
+
+ await frontendUtils.goToShop();
+
+ // Keyless add of X through the ProductButton.
+ await frontendUtils.addToCart( PRODUCT_X.name );
+
+ // No error notice (in particular no "cannot update bundle
+ // item" / store error) appears on the archive.
+ await expect(
+ page.locator( '.wc-block-components-notice-banner.is-error' )
+ ).toHaveCount( 0 );
+
+ // Persisted cart: exactly two lines for X — the meta line still at
+ // quantity 1 plus a new standalone line at quantity 1.
+ await frontendUtils.goToCart();
+ expect(
+ await readCartLineQuantities( page, PRODUCT_X.name )
+ ).toEqual( [ 1, 1 ] );
+ } );
+
+ test( 'Both a standalone and a meta line — only the standalone increments', async ( {
+ page,
+ frontendUtils,
+ } ) => {
+ // Seed: product X as a meta line (qty 1)...
+ await seedMetaLine( page, PRODUCT_X.id );
+
+ // ...and as a standalone line (qty 1) via a plain ProductButton add.
+ await frontendUtils.goToShop();
+ await frontendUtils.addToCart( PRODUCT_X.name );
+
+ await frontendUtils.goToCart();
+ expect(
+ await readCartLineQuantities( page, PRODUCT_X.name )
+ ).toEqual( [ 1, 1 ] );
+
+ // Add X again through the ProductButton.
+ await frontendUtils.goToShop();
+ await frontendUtils.addToCart( PRODUCT_X.name );
+
+ await expect(
+ page.locator( '.wc-block-components-notice-banner.is-error' )
+ ).toHaveCount( 0 );
+
+ // The standalone line becomes quantity 2; the meta line stays at 1.
+ // No new line is created (still two lines total).
+ await frontendUtils.goToCart();
+ expect(
+ await readCartLineQuantities( page, PRODUCT_X.name )
+ ).toEqual( [ 1, 2 ] );
+ } );
+
+ test( 'Saved-for-later / wishlist meta-only add (cart outcome) — keyless add creates a new standalone line with no error notice', async ( {
+ page,
+ frontendUtils,
+ } ) => {
+ // Saved-for-later "Move to cart" and wishlist "Add to cart" call the
+ // same keyless `addCartItem` path this exercises (quantityToAdd, no
+ // key). Here we assert the cart outcome those consumers depend on —
+ // a new standalone line beside the untouched meta line, no error
+ // notice. The consumer-specific guarantees (list entry removed, no
+ // spurious "quantity changed" info notice shown to the shopper) are
+ // covered authoritatively by the store/consumer unit tests,
+ // because the saved-for-later and wishlist blocks are gated behind
+ // the `product_wishlist` feature flag and are not registered in
+ // this e2e environment.
+ await seedMetaLine( page, PRODUCT_X.id );
+
+ await frontendUtils.goToShop();
+ await frontendUtils.addToCart( PRODUCT_X.name );
+
+ await expect(
+ page.locator( '.wc-block-components-notice-banner.is-error' )
+ ).toHaveCount( 0 );
+
+ await frontendUtils.goToCart();
+ expect(
+ await readCartLineQuantities( page, PRODUCT_X.name )
+ ).toEqual( [ 1, 1 ] );
+ } );
+ } );
+
+ test.describe( 'without a meta-differentiated line', () => {
+ // Guest isolation, same rationale as above.
+ test.use( { storageState: guestFile } );
+
+ test( 'Existing standalone line — add increments it', async ( {
+ page,
+ frontendUtils,
+ } ) => {
+ await frontendUtils.goToShop();
+
+ // First add creates the standalone line at quantity 1.
+ await frontendUtils.addToCart( PRODUCT_X.name );
+
+ // Second add increments the same line.
+ await frontendUtils.addToCart( PRODUCT_X.name );
+
+ // One line, quantity 2 — not two lines.
+ await frontendUtils.goToCart();
+ expect(
+ await readCartLineQuantities( page, PRODUCT_X.name )
+ ).toEqual( [ 2 ] );
+ } );
+
+ test( 'Not in cart — add creates a new line, no error notice', async ( {
+ page,
+ frontendUtils,
+ } ) => {
+ await frontendUtils.goToShop();
+
+ await frontendUtils.addToCart( PRODUCT_X.name );
+
+ await expect(
+ page.locator( '.wc-block-components-notice-banner.is-error' )
+ ).toHaveCount( 0 );
+
+ // A single new standalone line at quantity 1.
+ await frontendUtils.goToCart();
+ expect(
+ await readCartLineQuantities( page, PRODUCT_X.name )
+ ).toEqual( [ 1 ] );
+ } );
+
+ test( 'Genuine rejection (cart outcome) — leaves the cart unchanged and surfaces the error', async ( {
+ page,
+ frontendUtils,
+ } ) => {
+ // Saved-for-later / wishlist preserve the list entry when the add is
+ // genuinely rejected (entry preservation is asserted at unit level
+ // since those blocks are not placeable here). The
+ // cart-outcome substrate that decision relies on is asserted
+ // end-to-end: a server-rejected add leaves the cart unchanged and
+ // the server error surfaces. A sold-individually product already in
+ // the cart is rejected by the Store API (HTTP 400), and that
+ // rejection surfaces via the cart store's error-notice path, which
+ // is not suppressed by `showCartUpdatesNotices: false`. A
+ // sold-individually line renders without a quantity input, so cart
+ // presence is asserted by counting cart rows rather than reading the
+ // (absent) quantity stepper.
+ await wpCLI(
+ `wc product update ${ PRODUCT_X.id } --sold_individually=true --user=1`
+ );
+
+ await frontendUtils.goToShop();
+
+ // First add succeeds: a single line for the product.
+ await frontendUtils.addToCart( PRODUCT_X.name );
+ await frontendUtils.goToCart();
+ await expect( cartLineRows( page, PRODUCT_X.name ) ).toHaveCount(
+ 1
+ );
+
+ // Second add is rejected by the server.
+ await frontendUtils.goToShop();
+ await productButton( page, PRODUCT_X.id ).click();
+
+ // The server error surfaces as an error notice.
+ await expect(
+ page
+ .locator( '.wc-block-components-notice-banner.is-error' )
+ .first()
+ ).toBeVisible();
+
+ // The cart is unchanged: still a single line for the product.
+ await frontendUtils.goToCart();
+ await expect( cartLineRows( page, PRODUCT_X.name ) ).toHaveCount(
+ 1
+ );
+ } );
+ } );
+
+ test.describe( 'variation handling (Add to Cart with Options)', () => {
+ test( 'Re-adding a variation increments its line; adding a different variation creates a new line', async ( {
+ page,
+ editor,
+ frontendUtils,
+ addToCartWithOptionsPage,
+ } ) => {
+ // Swap the single-product template's legacy Add to Cart form for the
+ // Add to Cart with Options block, then visit the variable product.
+ await addToCartWithOptionsPage.updateSingleProductTemplate();
+ await editor.saveSiteEditorEntities( {
+ isOnlyCurrentEntityDirty: true,
+ } );
+
+ await page.goto( '/product/v-neck-t-shirt/' );
+
+ const addToCartBlock = page.locator(
+ '.wp-block-add-to-cart-with-options'
+ );
+ const colorBlueOption = addToCartBlock
+ .getByRole( 'radiogroup', { name: 'Color' } )
+ .getByRole( 'radio', { name: 'Blue', exact: true } );
+ const colorRedOption = addToCartBlock
+ .getByRole( 'radiogroup', { name: 'Color' } )
+ .getByRole( 'radio', { name: 'Red', exact: true } );
+ const sizeLargeOption = addToCartBlock
+ .getByRole( 'radiogroup', { name: 'Size' } )
+ .getByRole( 'radio', { name: 'Large', exact: true } );
+ // Scope to the Add to Cart + Options block (so we don't pick the
+ // Related Products block's button) and target the submit button by
+ // its stable class: its accessible name changes from "Add to cart"
+ // to "N in cart" as the selected variation's cart quantity changes,
+ // so a name-based locator would stop matching after the first add.
+ const addToCartButton = addToCartBlock.locator(
+ '.single_add_to_cart_button'
+ );
+
+ // Select variation V (Blue, Large) and add it: creates V's line.
+ await colorBlueOption.click();
+ await sizeLargeOption.click();
+ await expect( addToCartButton ).not.toHaveClass( /\bdisabled\b/ );
+ await addToCartButton.click();
+ await expect( addToCartButton ).toHaveText( '1 in cart' );
+
+ // Add V again: increments V's existing line (no second V line).
+ await addToCartButton.click();
+ await expect( addToCartButton ).toHaveText( '2 in cart' );
+
+ // Select a different variation W (Red, Large), not in the cart: the
+ // button resets to "Add to cart" because W has no line yet.
+ await colorRedOption.click();
+ await expect( addToCartButton ).toHaveText( 'Add to cart' );
+
+ // Add W: creates a new, separate line for W.
+ await addToCartButton.click();
+ await expect( addToCartButton ).toHaveText( '1 in cart' );
+
+ // Persisted cart: V incremented to 2 and W added as a distinct line,
+ // so two lines for the product at quantities 1 and 2.
+ await frontendUtils.goToCart();
+ expect(
+ await readCartLineQuantities( page, 'V-Neck T-Shirt' )
+ ).toEqual( [ 1, 2 ] );
+ } );
+ } );
+
+ test.describe( 'known-line quantity change (Mini-Cart stepper)', () => {
+ test( 'Stepper updates the exact line with no spurious notice and no extra line', async ( {
+ page,
+ frontendUtils,
+ miniCartUtils,
+ } ) => {
+ await frontendUtils.goToShop();
+ await frontendUtils.addToCart( PRODUCT_X.name );
+ await miniCartUtils.openMiniCart();
+
+ const quantity = page.getByLabel(
+ `Quantity of ${ PRODUCT_X.name } in your cart.`
+ );
+ await expect( quantity ).toHaveValue( '1' );
+
+ // Increment via the stepper (keyed update-item path).
+ const batchIncrease = page.waitForResponse(
+ '**/wp-json/wc/store/v1/batch**'
+ );
+ await page
+ .getByRole( 'button', {
+ name: `Increase quantity of ${ PRODUCT_X.name }`,
+ } )
+ .click();
+ await batchIncrease;
+ await expect( quantity ).toHaveValue( '2' );
+
+ // Decrement back.
+ const batchReduce = page.waitForResponse(
+ '**/wp-json/wc/store/v1/batch**'
+ );
+ await page
+ .getByRole( 'button', {
+ name: `Reduce quantity of ${ PRODUCT_X.name }`,
+ } )
+ .click();
+ await batchReduce;
+ await expect( quantity ).toHaveValue( '1' );
+
+ // No spurious notice from the stepper change itself.
+ await expect(
+ page.locator( '.wc-block-components-notice-banner' )
+ ).toHaveCount( 0 );
+
+ // No extra line was created: exactly one line for the product.
+ await frontendUtils.goToCart();
+ expect(
+ await readCartLineQuantities( page, PRODUCT_X.name )
+ ).toEqual( [ 1 ] );
+ } );
+ } );
+} );
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/mutation-batcher/mutation-batcher.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/cart-store/mutation-batcher.block_theme.spec.ts
similarity index 87%
rename from plugins/woocommerce/tests/e2e/tests/blocks/mutation-batcher/mutation-batcher.block_theme.spec.ts
rename to plugins/woocommerce/tests/e2e/tests/blocks/cart-store/mutation-batcher.block_theme.spec.ts
index 9a2153466fb..44ec555c04d 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/mutation-batcher/mutation-batcher.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/cart-store/mutation-batcher.block_theme.spec.ts
@@ -45,9 +45,9 @@ test.describe( 'Mutation Batcher', () => {
const { actions } = store( 'woocommerce', {}, { lock: unlockKey } );
// Three calls with no await between them — same microtick.
- const p1 = actions.addCartItem( { id: 15, quantity: 1 } );
- const p2 = actions.addCartItem( { id: 16, quantity: 1 } );
- const p3 = actions.addCartItem( { id: 17, quantity: 1 } );
+ const p1 = actions.addCartItem( { id: 15, quantityToAdd: 1 } );
+ const p2 = actions.addCartItem( { id: 16, quantityToAdd: 1 } );
+ const p3 = actions.addCartItem( { id: 17, quantityToAdd: 1 } );
await Promise.all( [ p1, p2, p3 ] );
} );
@@ -77,9 +77,9 @@ test.describe( 'Mutation Batcher', () => {
const { actions } = store( 'woocommerce', {}, { lock: unlockKey } );
// Each await breaks the microtick — each call becomes its own batch.
- await actions.addCartItem( { id: 18, quantity: 1 } );
- await actions.addCartItem( { id: 19, quantity: 1 } );
- await actions.addCartItem( { id: 20, quantity: 1 } );
+ await actions.addCartItem( { id: 18, quantityToAdd: 1 } );
+ await actions.addCartItem( { id: 19, quantityToAdd: 1 } );
+ await actions.addCartItem( { id: 20, quantityToAdd: 1 } );
} );
// Each call should have produced its own batch request.
@@ -109,17 +109,17 @@ test.describe( 'Mutation Batcher', () => {
const { actions } = store( 'woocommerce', {}, { lock: unlockKey } );
// Batch 1: two sync calls
- const p1 = actions.addCartItem( { id: 21, quantity: 1 } );
- const p2 = actions.addCartItem( { id: 22, quantity: 1 } );
+ const p1 = actions.addCartItem( { id: 21, quantityToAdd: 1 } );
+ const p2 = actions.addCartItem( { id: 22, quantityToAdd: 1 } );
await Promise.all( [ p1, p2 ] );
// Batch 2: one call after await
- await actions.addCartItem( { id: 23, quantity: 1 } );
+ await actions.addCartItem( { id: 23, quantityToAdd: 1 } );
// Batch 3: three sync calls
- const p3 = actions.addCartItem( { id: 24, quantity: 1 } );
- const p4 = actions.addCartItem( { id: 25, quantity: 1 } );
- const p5 = actions.addCartItem( { id: 26, quantity: 1 } );
+ const p3 = actions.addCartItem( { id: 24, quantityToAdd: 1 } );
+ const p4 = actions.addCartItem( { id: 25, quantityToAdd: 1 } );
+ const p5 = actions.addCartItem( { id: 26, quantityToAdd: 1 } );
await Promise.all( [ p3, p4, p5 ] );
} );
@@ -157,9 +157,9 @@ test.describe( 'Mutation Batcher', () => {
}
// Now add 3 products synchronously (one batch).
- const p1 = actions.addCartItem( { id: 15, quantity: 1 } );
- const p2 = actions.addCartItem( { id: 16, quantity: 1 } );
- const p3 = actions.addCartItem( { id: 17, quantity: 1 } );
+ const p1 = actions.addCartItem( { id: 15, quantityToAdd: 1 } );
+ const p2 = actions.addCartItem( { id: 16, quantityToAdd: 1 } );
+ const p3 = actions.addCartItem( { id: 17, quantityToAdd: 1 } );
await Promise.all( [ p1, p2, p3 ] );
// Return the product IDs now in the cart.
@@ -249,9 +249,9 @@ test.describe( 'Mutation Batcher', () => {
await actions.refreshCartItems();
// Mix valid and invalid product IDs — all in one microtick.
- const p1 = actions.addCartItem( { id: 15, quantity: 1 } );
- const p2 = actions.addCartItem( { id: 999999, quantity: 1 } ); // Invalid
- const p3 = actions.addCartItem( { id: 16, quantity: 1 } );
+ const p1 = actions.addCartItem( { id: 15, quantityToAdd: 1 } );
+ const p2 = actions.addCartItem( { id: 999999, quantityToAdd: 1 } ); // Invalid
+ const p3 = actions.addCartItem( { id: 16, quantityToAdd: 1 } );
// addCartItem catches errors internally so all promises resolve.
await Promise.allSettled( [ p1, p2, p3 ] );
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/cart-store/utils.ts b/plugins/woocommerce/tests/e2e/tests/blocks/cart-store/utils.ts
new file mode 100644
index 00000000000..c3c2ab7c309
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/cart-store/utils.ts
@@ -0,0 +1,132 @@
+/**
+ * External dependencies
+ */
+import { Page, Locator } from '@playwright/test';
+
+/**
+ * Activation slug of the cart-line-identity helper plugin
+ * (`test-plugins/blocks/cart-line-identity.php`). When active, an add-to-cart
+ * request that carries the `cart_line_identity_marker` flag receives a unique
+ * `cart_item_data` entry, so core's `generate_cart_id` mints a distinct cart
+ * line for the same product id — a stand-in for a bundle child / booking /
+ * add-on / recipient (meta-differentiated) line.
+ */
+export const CART_LINE_IDENTITY_PLUGIN =
+ 'woocommerce-blocks-test-cart-line-identity';
+
+/**
+ * Request flag the helper plugin reads to mark an add as a meta line.
+ *
+ * Kept identical to `CART_LINE_IDENTITY_FLAG` in the helper plugin.
+ */
+export const CART_LINE_IDENTITY_FLAG = 'cart_line_identity_marker';
+
+/**
+ * Sample-data product used as "product X" throughout the simple-product flows.
+ *
+ * Beanie is a simple product present on the first shop page, so its
+ * ProductButton is clickable from `/shop` without pagination, and it is not a
+ * prefix of another product on that page that would make an exact-label match
+ * ambiguous (we still scope clicks to its `li.post-<id>` wrapper to be safe).
+ */
+export const PRODUCT_X = {
+ id: 10,
+ name: 'Beanie',
+} as const;
+
+/**
+ * Seeds "product X is in the cart as a single meta-differentiated line".
+ *
+ * Navigates to a flagged legacy add-to-cart URL. The helper plugin attaches a
+ * unique `cart_item_data` marker to this add, so it lands as a cart line whose
+ * cart id differs from a plain (unflagged) add of the same product. The browser
+ * shares the same WooCommerce session cookie that the Store API add path uses,
+ * so a later keyless add of X operates against this same cart and is resolved
+ * by the server as a separate standalone line.
+ *
+ * The helper plugin must already be active (activate it in `beforeEach`).
+ *
+ * @param page The Playwright page.
+ * @param productId The product id to seed as a meta line.
+ * @param marker The marker value (defaults to the helper's own default).
+ */
+export const seedMetaLine = async (
+ page: Page,
+ productId: number,
+ marker?: string
+) => {
+ const markerValue = marker ?? 'meta-line';
+ await page.goto(
+ `/?add-to-cart=${ productId }&${ CART_LINE_IDENTITY_FLAG }=${ markerValue }`
+ );
+};
+
+/**
+ * The ProductButton for a given product on the shop archive.
+ *
+ * The shop renders one list item per product (`li.post-<id>`); scoping the
+ * button to that wrapper targets exactly one product's Add to cart control even
+ * when another product's name shares a prefix.
+ *
+ * @param page The Playwright page (must be on the shop archive).
+ * @param productId The product id whose button to return.
+ */
+export const productButton = ( page: Page, productId: number ): Locator =>
+ page.locator( `li.post-${ productId }` ).getByRole( 'button' );
+
+/**
+ * All cart-line quantity inputs for a product on the blocks Cart page.
+ *
+ * Every cart line for the product (standalone or meta) renders a quantity input
+ * labelled `Quantity of <name> in your cart.`, so the returned locator's
+ * `count()` is the number of distinct cart lines for that product and each
+ * `nth(i)` exposes a line's quantity via `inputValue()`/`toHaveValue()`.
+ *
+ * @param page The Playwright page (must be on the Cart page).
+ * @param productName The product display name.
+ */
+export const cartLineQuantities = (
+ page: Page,
+ productName: string
+): Locator => page.getByLabel( `Quantity of ${ productName } in your cart.` );
+
+/**
+ * Reads each cart line's quantity for a product, sorted ascending.
+ *
+ * Sorting makes the assertion order-independent: the cart can render the meta
+ * line and the standalone line in either order, but `[ 1, 1 ]` or `[ 1, 2 ]`
+ * uniquely describes the outcome regardless of which row came first.
+ *
+ * @param page The Playwright page (must be on the Cart page).
+ * @param productName The product display name.
+ * @return The quantities of every cart line for the product, ascending.
+ */
+export const readCartLineQuantities = async (
+ page: Page,
+ productName: string
+): Promise< number[] > => {
+ const inputs = cartLineQuantities( page, productName );
+ const count = await inputs.count();
+ const quantities: number[] = [];
+ for ( let i = 0; i < count; i++ ) {
+ quantities.push( Number( await inputs.nth( i ).inputValue() ) );
+ }
+ return quantities.sort( ( a, b ) => a - b );
+};
+
+/**
+ * All cart-line rows for a product on the blocks Cart page.
+ *
+ * Unlike {@link cartLineQuantities}, this counts the cart-item rows themselves
+ * (`.wc-block-cart-items__row`), so it works even for a product whose quantity
+ * input is absent — a sold-individually product renders a line with no quantity
+ * stepper. The returned locator's `count()` is the number of cart lines for the
+ * product, regardless of whether each line exposes an editable quantity.
+ *
+ * @param page The Playwright page (must be on the Cart page).
+ * @param productName The product display name.
+ */
+export const cartLineRows = ( page: Page, productName: string ): Locator =>
+ page.locator( '.wc-block-cart-items__row', {
+ hasText: new RegExp( productName ),
+ } );