Commit 169b7a913d3 for woocommerce
commit 169b7a913d3c78aeb5571baba3a60b9660cd5a48
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Tue Sep 15 16:02:43 2026 +0300
[tests] Make 9 retained Blocks E2E specs own their fixtures (#68579)
* test(blocks): Make 9 retained Blocks E2E specs own their fixtures
Nine Blocks E2E specs stay in the browser suite on purpose: all
products, cart taxes, featured category and product, both Mini-Cart
personas, Product Filters frontend, gallery thumbnails, and the shop
page template. None of their titles moves to a lower layer.
They passed only when the shared database happened to look a certain
way: attribute term IDs 1 and 2 for Color and Size, a tax rate left by
another spec, product thumbnails that other specs also edited, and
locators or waits that matched stray elements or raced a rewrite
flush. In a persistent environment those assumptions break in any
order.
Resolve attribute IDs at run time and pass them to the legacy filters
content template, create a test-owned tax rate, upload and clean up
dedicated media for the image-editing titles, scope the Mini-Cart
inserter and title locators, wait for the real media-edit response,
reload once after the shop permalink change, and drop a toPass()
retry around the gallery scroll assertions. Every title keeps its
name and its assertions.
Consolidates the mega-branch slices:
- Slice terminalization-retained-system: test(e2e): Stabilize Blocks
acceptance fixtures
- Slice terminalization-final-retained-system: test(e2e): Preserve
Mini Cart assertion failures; refactor(e2e): simplify migrated
Blocks test contracts
Refs TESTOPS-234
Refs #68046
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(e2e): Clean up the global tax rate the taxes spec creates
The Blocks tax visibility spec posted a 20% standard-class rate and left
it in place. Every later spec in the worker then priced shared fixture
products with tax applied.
The rate cannot be scoped: the test's subject is the store-wide "Enable
tax rate calculations" option, and it shops with fixture products that
other specs also use, so `withScopedTaxClass` does not fit — it asserts
that tax calculation stays on, which this test deliberately turns off.
Wrap the body in try/finally instead and put both the rate and the
option back, so a failure part-way through cannot leak either.
Refs TESTOPS-234
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(e2e): Wait for the iAPI Mini Cart before picking a title branch
`count()` samples the DOM once with no auto-waiting, so a title block
that had not finished rendering sent the assertion down the legacy
branch, where it then failed looking for markup that was never going to
appear.
Wait for the block instead and treat the timeout as the legacy build.
Refs TESTOPS-234
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(e2e): Restore the fixtures the featured-block specs were leaving behind
Three review asks, all about this PR's own premise that these specs own
their fixtures.
featured-product pointed Album's _thumbnail_id at the uploaded media and
then deleted that media, leaving the product with a thumbnail id for an
attachment that no longer exists. It now captures the id first and puts
it back, deleting the meta when Album had no thumbnail to begin with.
The capture goes through `get_post_thumbnail_id`, which returns 0 for a
missing key rather than exiting non-zero the way `wp post meta get`
does, and wpCLI rejects on a non-zero exit.
featured-category created a `Test Category` term and reassigned Cap's
categories, undoing neither. `wc product update --categories` replaces
the list rather than appending, so Cap's own categories are captured
before the reassignment and restored after, and the term is deleted.
The legacy-filters content template was reformatted end to end, single
quotes and line breaks and all, when only two attribute ids needed to
change. Trunk's formatting is back and the diff against trunk is now
the four lines that carry the ids. Nothing enforces a formatter on that
path -- prettier reports trunk's copy as unformatted too -- so the
reformatting was noise hiding the change.
Also nest the two REST restores in the taxes spec, so a failed option
write still reaches the tax-rate delete.
Refs TESTOPS-234
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(e2e): Share one product-attribute id lookup between the two specs
getProductAttributeIds in all-products and getColorAttributeId in
product-filters-frontend were the same helper written twice: the same
`wc product_attribute list` call, the same JSON extraction out of the
CLI output, the same id validation. Only the return shape differed.
Move it to tests/e2e/utils/blocks/product-attributes.ts, returning the
colour and size ids, and have both specs read from it. The colour
lookup loses its extra `name === 'Color'` check; the slug is the unique
key, and the name is the part a translation or a rename would move.
The shared copy throws with the slug in the message rather than calling
expect(), since a util has no test context to fail through.
Typecheck on tests/e2e is unchanged: the same four pre-existing
window.wc errors in product-filters-frontend before and after, and none
in the new file.
Refs TESTOPS-234
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(e2e): Read WP-CLI output past npm's banner in the featured specs
Both featured-block restores captured their original value with
stdout.trim(). The Blocks wpCLI helper shells out to `npm run
wp-env:e2e run cli -- wp ...`, and npm writes its two banner lines to
stdout, not stderr, so trim() returns the banner followed by the value.
That value then goes straight back into a WP-CLI command. The banner
starts with "> ", so the shell reads it as a redirection: the restore
writes a junk file, exits non-zero, and because wpCLI is promisify(exec)
it rejects inside the finally -- failing the test on every run and
masking whatever the body actually did.
Verified rather than reasoned about: npm run against a throwaway package
returns "\n> probe@1.0.0 cli\n> echo 0\n\n0" on stdout, where trim()
keeps all of it and the anchored match returns "0".
Take the value on its own line instead, which is the idiom this same
file already uses for productId two lines earlier and which
wp-cli.ts:113 documents as "npm adds prefix lines to stdout". The
category list needs a marker rather than a bare digit match, because an
empty term list prints nothing at all and would otherwise be
indistinguishable from a failed read. The banner echoes the marker text
back, but always behind "> ", so the line anchor excludes it.
Also stop the category restore writing an unread value: originalCategoryIds
defaulted to [], so a throw between reading the product and reading its
terms would have cleared Cap's real categories on the way out. It is now
optional, and the restore is guarded on having actually captured it.
Refs TESTOPS-234
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(e2e): Read the created category id from --porcelain output
The featured-category spec took the new category id with
stdout.match( /\d+/g )?.pop(): the last run of digits anywhere in the
command's stdout. That stdout carries npm's banner, which echoes the full
command back, image id included, and the value picked is force-deleted
in the finally block. It only landed on the right number because WP-CLI's
"Success: Created product_cat <id>." happened to be the last line.
Ask WooCommerce's CLI for --porcelain, which prints nothing but the id,
and match it on its own line the way productId is read a few lines up.
If nothing matches, fail with the raw output instead of carrying an
undefined id into the product update and the cleanup.
Checked against real output from the local E2E container: the porcelain
stdout is the banner followed by the bare id, the anchored match returns
it, and wp term get confirms that id carries the created slug. Both
featured specs pass locally, 5 of 5 with the blocks setup.
Refs TESTOPS-234
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/testops-234-blocks-acceptance-fixture-stabilization b/plugins/woocommerce/changelog/testops-234-blocks-acceptance-fixture-stabilization
new file mode 100644
index 00000000000..ab12ca3b416
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-234-blocks-acceptance-fixture-stabilization
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Make nine retained Blocks E2E specs own their fixtures: resolve attribute IDs at run time, create their own tax rate and media, and scope the flaky locators and waits.
+
diff --git a/plugins/woocommerce/tests/e2e/content-templates/blocks/post_legacy-filters-with-all-products.handlebars b/plugins/woocommerce/tests/e2e/content-templates/blocks/post_legacy-filters-with-all-products.handlebars
index 7a55cc4a8f3..478d53df5a1 100644
--- a/plugins/woocommerce/tests/e2e/content-templates/blocks/post_legacy-filters-with-all-products.handlebars
+++ b/plugins/woocommerce/tests/e2e/content-templates/blocks/post_legacy-filters-with-all-products.handlebars
@@ -4,12 +4,12 @@
<div class="wp-block-woocommerce-price-filter is-loading" data-showinputfields="true" data-showfilterbutton="false" data-heading="Filter by price" data-heading-level="3"><span aria-hidden="true" class="wc-block-product-categories__placeholder"></span></div>
<!-- /wp:woocommerce/price-filter -->
-<!-- wp:woocommerce/attribute-filter {"attributeId":1,"displayStyle":"dropdown","heading":"Filter by Color"} -->
-<div class="wp-block-woocommerce-attribute-filter is-loading" data-attribute-id="1" data-show-counts="true" data-query-type="or" data-heading="Filter by Color" data-heading-level="3" data-display-style="dropdown"><span aria-hidden="true" class="wc-block-product-attribute-filter__placeholder"></span></div>
+<!-- wp:woocommerce/attribute-filter {"attributeId":{{colorAttributeId}},"displayStyle":"dropdown","heading":"Filter by Color"} -->
+<div class="wp-block-woocommerce-attribute-filter is-loading" data-attribute-id="{{colorAttributeId}}" data-show-counts="true" data-query-type="or" data-heading="Filter by Color" data-heading-level="3" data-display-style="dropdown"><span aria-hidden="true" class="wc-block-product-attribute-filter__placeholder"></span></div>
<!-- /wp:woocommerce/attribute-filter -->
-<!-- wp:woocommerce/attribute-filter {"attributeId":2,"heading":"Filter by Size"} -->
-<div class="wp-block-woocommerce-attribute-filter is-loading" data-attribute-id="2" data-show-counts="true" data-query-type="or" data-heading="Filter by Size" data-heading-level="3"><span aria-hidden="true" class="wc-block-product-attribute-filter__placeholder"></span></div>
+<!-- wp:woocommerce/attribute-filter {"attributeId":{{sizeAttributeId}},"heading":"Filter by Size"} -->
+<div class="wp-block-woocommerce-attribute-filter is-loading" data-attribute-id="{{sizeAttributeId}}" data-show-counts="true" data-query-type="or" data-heading="Filter by Size" data-heading-level="3"><span aria-hidden="true" class="wc-block-product-attribute-filter__placeholder"></span></div>
<!-- /wp:woocommerce/attribute-filter -->
<!-- wp:woocommerce/active-filters -->
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/all-products/all-products.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/all-products/all-products.block_theme.spec.ts
index 67edb5b250c..b3fc42ca95a 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/all-products/all-products.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/all-products/all-products.block_theme.spec.ts
@@ -4,14 +4,11 @@
import {
BLOCK_THEME_SLUG,
expect,
+ getProductAttributeIds,
PostCompiler,
test as base,
} from '@woocommerce/e2e-utils';
-/**
- * Internal dependencies
- */
-
const BLOCK_NAME = 'woocommerce/all-products';
const test = base.extend< { postCompiler: PostCompiler } >( {
@@ -65,7 +62,9 @@ test.describe( `${ BLOCK_NAME } Block`, () => {
page,
postCompiler,
} ) => {
- const post = await postCompiler.compile();
+ const post = await postCompiler.compile(
+ await getProductAttributeIds()
+ );
const productsResponse = page.waitForResponse(
( response ) =>
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-checkout-block-taxes.shopper.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-checkout-block-taxes.shopper.block_theme.spec.ts
index a6e59899124..16aced00d60 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-checkout-block-taxes.shopper.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/cart/cart-checkout-block-taxes.shopper.block_theme.spec.ts
@@ -28,75 +28,112 @@ test.describe( 'Shopper → Taxes', () => {
page,
checkoutPageObject,
} ) => {
- // Turn off tax display.
- await requestUtils.rest( {
- method: 'PUT',
- path: 'wc/v3/settings/general/woocommerce_calc_taxes',
- data: { value: 'no' },
+ // The rate has to be global. This test's subject is the store-wide
+ // "Enable tax rate calculations" option, and it shops with shared
+ // fixture products, so `withScopedTaxClass` does not apply: it asserts
+ // that tax calculation stays on, and scoping would mean reassigning
+ // products other specs also use. Both the rate and the option are put
+ // back in `finally` instead, so neither follows later specs.
+ const { id: taxRateId } = await requestUtils.rest< { id: number } >( {
+ method: 'POST',
+ path: 'wc/v3/taxes',
+ data: {
+ rate: '20',
+ name: 'Blocks tax visibility rate',
+ class: 'standard',
+ },
} );
- await frontendUtils.goToShop();
- await frontendUtils.addToCart( DISCOUNTED_PRODUCT_NAME );
- await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
- await frontendUtils.goToCart();
- let cartSidebar = page.locator(
- '.wp-block-woocommerce-cart-totals-block'
- );
- const taxRow = cartSidebar
- .locator( '.wc-block-components-totals-taxes' )
- .getByText( 'Tax' );
- await expect( taxRow ).toBeHidden();
+ try {
+ // Turn off tax display.
+ await requestUtils.rest( {
+ method: 'PUT',
+ path: 'wc/v3/settings/general/woocommerce_calc_taxes',
+ data: { value: 'no' },
+ } );
+ await frontendUtils.goToShop();
+ await frontendUtils.addToCart( DISCOUNTED_PRODUCT_NAME );
+ await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
+ await frontendUtils.goToCart();
- // Move to Checkout and look for Tax row.
- await frontendUtils.goToCheckout();
- let checkoutSidebar = page.locator(
- '.wp-block-woocommerce-checkout-totals-block'
- );
- const checkoutTaxRow = checkoutSidebar
- .locator( '.wc-block-components-totals-taxes' )
- .getByText( 'Tax' );
- await expect( checkoutTaxRow ).toBeHidden();
+ let cartSidebar = page.locator(
+ '.wp-block-woocommerce-cart-totals-block'
+ );
+ const taxRow = cartSidebar
+ .locator( '.wc-block-components-totals-taxes' )
+ .getByText( 'Tax' );
+ await expect( taxRow ).toBeHidden();
- // Check out and look for tax on order confirmation page.
- await checkoutPageObject.fillInCheckoutWithTestData();
- await checkoutPageObject.placeOrder();
- const taxOnOrderConfirmation = page.getByText( 'Tax:' );
- await expect( taxOnOrderConfirmation ).toBeHidden();
+ // Move to Checkout and look for Tax row.
+ await frontendUtils.goToCheckout();
+ let checkoutSidebar = page.locator(
+ '.wp-block-woocommerce-checkout-totals-block'
+ );
+ const checkoutTaxRow = checkoutSidebar
+ .locator( '.wc-block-components-totals-taxes' )
+ .getByText( 'Tax' );
+ await expect( checkoutTaxRow ).toBeHidden();
- // Empty the cart (it should be empty already, but just in case).
- await frontendUtils.emptyCart();
+ // Check out and look for tax on order confirmation page.
+ await checkoutPageObject.fillInCheckoutWithTestData();
+ await checkoutPageObject.placeOrder();
+ const taxOnOrderConfirmation = page.getByText( 'Tax:' );
+ await expect( taxOnOrderConfirmation ).toBeHidden();
- // Turn on tax display.
- await requestUtils.rest( {
- method: 'PUT',
- path: 'wc/v3/settings/general/woocommerce_calc_taxes',
- data: { value: 'yes' },
- } );
- await frontendUtils.goToShop();
- await frontendUtils.addToCart( DISCOUNTED_PRODUCT_NAME );
- await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
- await frontendUtils.goToCart();
+ // Empty the cart (it should be empty already, but just in case).
+ await frontendUtils.emptyCart();
+
+ // Turn on tax display.
+ await requestUtils.rest( {
+ method: 'PUT',
+ path: 'wc/v3/settings/general/woocommerce_calc_taxes',
+ data: { value: 'yes' },
+ } );
+ await frontendUtils.goToShop();
+ await frontendUtils.addToCart( DISCOUNTED_PRODUCT_NAME );
+ await frontendUtils.addToCart( REGULAR_PRICED_PRODUCT_NAME );
+ await frontendUtils.goToCart();
- cartSidebar = page.locator( '.wp-block-woocommerce-cart-totals-block' );
- const visibleTaxRow = cartSidebar
- .locator( '.wc-block-components-totals-taxes' )
- .getByText( 'Tax' );
- await expect( visibleTaxRow ).toBeVisible();
+ cartSidebar = page.locator(
+ '.wp-block-woocommerce-cart-totals-block'
+ );
+ const visibleTaxRow = cartSidebar
+ .locator( '.wc-block-components-totals-taxes' )
+ .getByText( 'Tax' );
+ await expect( visibleTaxRow ).toBeVisible();
- // Move to Checkout and look for Tax row.
- await frontendUtils.goToCheckout();
- checkoutSidebar = page.locator(
- '.wp-block-woocommerce-checkout-totals-block'
- );
- const visibleCheckoutTaxRow = checkoutSidebar
- .locator( '.wc-block-components-totals-taxes' )
- .getByText( 'Tax' );
- await expect( visibleCheckoutTaxRow ).toBeVisible();
+ // Move to Checkout and look for Tax row.
+ await frontendUtils.goToCheckout();
+ checkoutSidebar = page.locator(
+ '.wp-block-woocommerce-checkout-totals-block'
+ );
+ const visibleCheckoutTaxRow = checkoutSidebar
+ .locator( '.wc-block-components-totals-taxes' )
+ .getByText( 'Tax' );
+ await expect( visibleCheckoutTaxRow ).toBeVisible();
- // Check out and look for tax on order confirmation page.
- await checkoutPageObject.fillInCheckoutWithTestData();
- await checkoutPageObject.placeOrder();
- const visibleTaxOnOrderConfirmation = page.getByText( 'Tax:' );
- await expect( visibleTaxOnOrderConfirmation ).toBeVisible();
+ // Check out and look for tax on order confirmation page.
+ await checkoutPageObject.fillInCheckoutWithTestData();
+ await checkoutPageObject.placeOrder();
+ const visibleTaxOnOrderConfirmation = page.getByText( 'Tax:' );
+ await expect( visibleTaxOnOrderConfirmation ).toBeVisible();
+ } finally {
+ // Restore the store-wide baseline: tax calculation on, and no
+ // standard-class rate for the specs that run after this one. Nested so a
+ // failed option write still reaches the rate delete.
+ try {
+ await requestUtils.rest( {
+ method: 'PUT',
+ path: 'wc/v3/settings/general/woocommerce_calc_taxes',
+ data: { value: 'yes' },
+ } );
+ } finally {
+ await requestUtils.rest( {
+ method: 'DELETE',
+ path: `wc/v3/taxes/${ taxRateId }`,
+ params: { force: true },
+ } );
+ }
+ }
} );
} );
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/featured-category/featured-category.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/featured-category/featured-category.block_theme.spec.ts
index 675e46bce52..6c76a6420c6 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/featured-category/featured-category.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/featured-category/featured-category.block_theme.spec.ts
@@ -1,6 +1,7 @@
/**
* External dependencies
*/
+import path from 'path';
import { test, expect, wpCLI } from '@woocommerce/e2e-utils';
const blockData = {
@@ -29,40 +30,126 @@ test.describe( `${ blockData.slug } Block`, () => {
).toBeVisible();
} );
- test( 'image can be edited', async ( { editor, admin } ) => {
- await test.step( 'Create a product category with an image', async () => {
- // Get the id of the image associated to the Cap product (for example).
- const productCliOutput = await wpCLI(
- `post list --post_type=product --title=Cap --field=ID`
- );
- const productId = productCliOutput.stdout.match( /\d+/g )?.pop();
- const mediaCliOutput = await wpCLI(
- `post meta get ${ productId } _thumbnail_id`
- );
- const mediaId = mediaCliOutput.stdout.match( /\d+/g )?.pop();
+ test( 'image can be edited', async ( { editor, admin, requestUtils } ) => {
+ let editedMediaId: number | undefined;
+ let productId: string | undefined;
+ let categoryId: string | undefined;
+ let originalCategoryIds: string[] | undefined;
+ const media = await requestUtils.uploadMedia(
+ path.resolve( __dirname, '../../../test-data/images/image-01.png' )
+ );
- // Create a product category with that image.
- const categoryCliOutput = await wpCLI(
- `wc product_cat create --name="Test Category" --slug="test-category" --image='{ "id": ${ mediaId } }' --user=1`
- );
- const categoryId = categoryCliOutput.stdout.match( /\d+/g )?.pop();
- await wpCLI(
- `wc product update ${ productId } --categories='[ { "id": ${ categoryId } } ]' --user=1`
- );
- } );
+ try {
+ await test.step( 'Create a product category with an image', async () => {
+ const productCliOutput = await wpCLI(
+ `post list --post_type=product --title=Cap --field=ID`
+ );
+ productId = productCliOutput.stdout.match( /^\d+$/m )?.[ 0 ];
+ if ( ! productId ) {
+ throw new Error(
+ `Failed to find Cap product: ${ productCliOutput.stdout }`
+ );
+ }
- await admin.createNewPost();
- await editor.insertBlock( { name: blockData.slug } );
- const blockLocator = await editor.getBlockByName( blockData.slug );
- await blockLocator.getByText( 'Test Category' ).click();
- await blockLocator.getByText( 'Done' ).click();
- await editor.clickBlockToolbarButton( 'Edit category image' );
- await editor.clickBlockToolbarButton( 'Rotate' );
- await editor.page
- .getByRole( 'button', { name: 'Apply', exact: true } )
- .click();
- await expect(
- editor.canvas.locator( 'img[alt="Test Category"][src*="-edited"]' )
- ).toBeVisible();
+ // `wc product update --categories` replaces the list rather than
+ // appending to it, so Cap's own categories have to come back below.
+ //
+ // npm writes its own banner to stdout ahead of the command output,
+ // and a product with no categories prints nothing at all, so the
+ // value goes inside a marker that can be matched on its own line.
+ // The banner echoes the command back, marker text included, but
+ // always behind a "> " prefix, which the anchor excludes.
+ const categoriesCliOutput = await wpCLI(
+ `eval 'echo "CATEGORY_IDS[" . implode( ",", wp_get_post_terms( ${ productId }, "product_cat", array( "fields" => "ids" ) ) ) . "]";'`
+ );
+ const capturedCategoryIds = categoriesCliOutput.stdout.match(
+ /^CATEGORY_IDS\[([\d,]*)\]$/m
+ );
+ if ( ! capturedCategoryIds ) {
+ throw new Error(
+ `Failed to read Cap's product categories: ${ categoriesCliOutput.stdout }`
+ );
+ }
+ originalCategoryIds = capturedCategoryIds[ 1 ]
+ .split( ',' )
+ .filter( Boolean );
+
+ // --porcelain prints only the new term id. Match it on its own line,
+ // like productId above: without that, the last number anywhere in
+ // stdout wins, and this id is force-deleted in the finally below.
+ const categoryCliOutput = await wpCLI(
+ `wc product_cat create --name="Test Category" --slug="test-category" --image='{ "id": ${ media.id } }' --porcelain --user=1`
+ );
+ categoryId =
+ categoryCliOutput.stdout.match( /^[1-9]\d*$/m )?.[ 0 ];
+ if ( ! categoryId ) {
+ throw new Error(
+ `Failed to read the created category id: ${ categoryCliOutput.stdout }`
+ );
+ }
+ await wpCLI(
+ `wc product update ${ productId } --categories='[ { "id": ${ categoryId } } ]' --user=1`
+ );
+ } );
+
+ await admin.createNewPost();
+ await editor.insertBlock( { name: blockData.slug } );
+ const blockLocator = await editor.getBlockByName( blockData.slug );
+ await blockLocator.getByText( 'Test Category' ).click();
+ await blockLocator.getByText( 'Done' ).click();
+ await editor.clickBlockToolbarButton( 'Edit category image' );
+ await editor.clickBlockToolbarButton( 'Rotate' );
+ const editImageResponse = editor.page.waitForResponse(
+ ( response ) =>
+ response.request().method() === 'POST' &&
+ new URL( response.url() ).pathname ===
+ `/wp-json/wp/v2/media/${ media.id }/edit`
+ );
+ await editor.page
+ .getByRole( 'button', { name: 'Apply', exact: true } )
+ .click();
+ const editResponse = await editImageResponse;
+ expect( editResponse.status() ).toBe( 201 );
+ const editedMedia = await editResponse.json();
+ if (
+ typeof editedMedia.id !== 'number' ||
+ ! Number.isInteger( editedMedia.id )
+ ) {
+ throw new Error( 'The image edit did not return a media ID.' );
+ }
+ editedMediaId = editedMedia.id;
+ await expect(
+ editor.canvas.locator(
+ 'img[alt="Test Category"][src*="-edited"]'
+ )
+ ).toBeVisible();
+ } finally {
+ try {
+ if ( productId && originalCategoryIds ) {
+ const categories = originalCategoryIds
+ .map( ( id ) => `{ "id": ${ id } }` )
+ .join( ', ' );
+ await wpCLI(
+ `wc product update ${ productId } --categories='[ ${ categories } ]' --user=1`
+ );
+ }
+ } finally {
+ try {
+ if ( categoryId ) {
+ await wpCLI(
+ `wc product_cat delete ${ categoryId } --force=true --user=1`
+ );
+ }
+ } finally {
+ try {
+ if ( editedMediaId !== undefined ) {
+ await requestUtils.deleteMedia( editedMediaId );
+ }
+ } finally {
+ await requestUtils.deleteMedia( media.id );
+ }
+ }
+ }
+ }
} );
} );
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/featured-product/featured-product.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/featured-product/featured-product.block_theme.spec.ts
index 14db8f7e21c..874c4784643 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/featured-product/featured-product.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/featured-product/featured-product.block_theme.spec.ts
@@ -1,7 +1,8 @@
/**
* External dependencies
*/
-import { expect, test } from '@woocommerce/e2e-utils';
+import path from 'path';
+import { expect, test, wpCLI } from '@woocommerce/e2e-utils';
const blockData = {
slug: 'woocommerce/featured-product',
@@ -29,19 +30,98 @@ test.describe( `${ blockData.slug } Block`, () => {
).toBeVisible();
} );
- test( 'image can be edited', async ( { editor, admin } ) => {
- await admin.createNewPost();
- await editor.insertBlock( { name: blockData.slug } );
- const blockLocator = await editor.getBlockByName( blockData.slug );
- await blockLocator.getByText( 'Album' ).click();
- await blockLocator.getByText( 'Done' ).click();
- await editor.clickBlockToolbarButton( 'Edit product image' );
- await editor.clickBlockToolbarButton( 'Rotate' );
- await editor.page
- .getByRole( 'button', { name: 'Apply', exact: true } )
- .click();
- await expect(
- editor.canvas.locator( 'img[alt="Album"][src*="-edited"]' )
- ).toBeVisible();
+ test( 'image can be edited', async ( { editor, admin, requestUtils } ) => {
+ let editedMediaId: number | undefined;
+ let productId: string | undefined;
+ let originalThumbnailId: string | undefined;
+ const media = await requestUtils.uploadMedia(
+ path.resolve( __dirname, '../../../test-data/images/image-01.png' )
+ );
+
+ try {
+ const productCliOutput = await wpCLI(
+ 'post list --post_type=product --title=Album --field=ID'
+ );
+ productId = productCliOutput.stdout.match( /^\d+$/m )?.[ 0 ];
+ if ( ! productId ) {
+ throw new Error(
+ `Failed to find Album product: ${ productCliOutput.stdout }`
+ );
+ }
+
+ // The uploaded media is deleted below, so Album's own thumbnail has to go
+ // back or the product is left pointing at an attachment that no longer
+ // exists. `get_post_thumbnail_id` returns 0 rather than erroring when the
+ // meta key is absent, unlike `wp post meta get`.
+ const thumbnailCliOutput = await wpCLI(
+ `eval 'echo (string) get_post_thumbnail_id( ${ productId } );'`
+ );
+ // npm writes its own banner to stdout ahead of the command output, so
+ // match the digits on their own line the way the product lookup above
+ // does. Trimming the whole buffer keeps the banner, and feeding that
+ // back into a shell command makes the restore fail on its own `>`.
+ originalThumbnailId =
+ thumbnailCliOutput.stdout.match( /^\d+$/m )?.[ 0 ];
+ if ( ! originalThumbnailId ) {
+ throw new Error(
+ `Failed to read Album's thumbnail ID: ${ thumbnailCliOutput.stdout }`
+ );
+ }
+
+ await wpCLI(
+ `post meta update ${ productId } _thumbnail_id ${ media.id }`
+ );
+
+ await admin.createNewPost();
+ await editor.insertBlock( { name: blockData.slug } );
+ const blockLocator = await editor.getBlockByName( blockData.slug );
+ await blockLocator.getByText( 'Album' ).click();
+ await blockLocator.getByText( 'Done' ).click();
+ await editor.clickBlockToolbarButton( 'Edit product image' );
+ await editor.clickBlockToolbarButton( 'Rotate' );
+ const editImageResponse = editor.page.waitForResponse(
+ ( response ) =>
+ response.request().method() === 'POST' &&
+ new URL( response.url() ).pathname ===
+ `/wp-json/wp/v2/media/${ media.id }/edit`
+ );
+ await editor.page
+ .getByRole( 'button', { name: 'Apply', exact: true } )
+ .click();
+ const editResponse = await editImageResponse;
+ expect( editResponse.status() ).toBe( 201 );
+ const editedMedia = await editResponse.json();
+ if (
+ typeof editedMedia.id !== 'number' ||
+ ! Number.isInteger( editedMedia.id )
+ ) {
+ throw new Error( 'The image edit did not return a media ID.' );
+ }
+ editedMediaId = editedMedia.id;
+ await expect(
+ editor.canvas.locator( 'img[alt="Album"][src*="-edited"]' )
+ ).toBeVisible();
+ } finally {
+ try {
+ if ( productId && originalThumbnailId !== undefined ) {
+ // get_post_thumbnail_id() echoes 0 when the meta key is absent.
+ const hadThumbnail = originalThumbnailId !== '0';
+
+ await wpCLI(
+ hadThumbnail
+ ? `post meta update ${ productId } _thumbnail_id ${ originalThumbnailId }`
+ : `post meta delete ${ productId } _thumbnail_id`
+ );
+ }
+ } finally {
+ try {
+ if ( editedMediaId !== undefined ) {
+ await requestUtils.deleteMedia( editedMediaId );
+ }
+ } finally {
+ await requestUtils.deleteMedia( media.id );
+ }
+ }
+ }
} );
} );
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/mini-cart/mini-cart-block.merchant.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/mini-cart/mini-cart-block.merchant.block_theme.spec.ts
index 3f317dadafe..0f7af7f87d9 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/mini-cart/mini-cart-block.merchant.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/mini-cart/mini-cart-block.merchant.block_theme.spec.ts
@@ -50,10 +50,14 @@ test.describe( 'Merchant → Mini Cart', () => {
.getByRole( 'searchbox', { name: 'Search' } )
.fill( blockData.slug );
- const miniCartButton = editor.page.getByRole( 'option', {
- name: blockData.name,
- } );
+ const miniCartButton = editor.page
+ .getByRole( 'listbox', { name: 'Blocks' } )
+ .getByRole( 'option', {
+ name: blockData.name,
+ exact: true,
+ } );
+ await expect( miniCartButton ).toHaveCount( 1 );
await expect( miniCartButton ).toBeVisible();
await expect( miniCartButton ).toBeDisabled();
} );
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/mini-cart/mini-cart-block.shopper.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/mini-cart/mini-cart-block.shopper.block_theme.spec.ts
index c16d2346d55..697382d961c 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/mini-cart/mini-cart-block.shopper.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/mini-cart/mini-cart-block.shopper.block_theme.spec.ts
@@ -67,7 +67,9 @@ test.describe( 'Shopper → Notices', () => {
const cookies = await page.context().cookies();
await noJsContext.addCookies( cookies );
- await noJsPage.goto( currentUrl );
+ await noJsPage.goto( currentUrl, {
+ waitUntil: 'domcontentloaded',
+ } );
// Verify error notice banner is rendered in SSR output (not client-side JS).
// Note: The notice text content contains HTML and is rendered client-side via
@@ -88,14 +90,24 @@ test.describe( 'Shopper → Notices', () => {
productCollectionPage,
} ) => {
const checkMiniCartTitle = async ( itemCount: number ) => {
- try {
- // iAPI Mini Cart.
- const miniCartTitleLabelBlock = page.locator(
- '[data-block-name="woocommerce/mini-cart-title-label-block"]'
+ const miniCartTitleLabelBlock = page.locator(
+ '[data-block-name="woocommerce/mini-cart-title-label-block"]'
+ );
+
+ // `count()` samples the DOM once and never waits, so a title block
+ // that is still rendering would send us down the legacy branch and
+ // fail there on markup that was never going to appear. Wait for it
+ // instead, and treat the timeout as "this build ships the legacy
+ // Mini Cart".
+ const usesIapiMiniCart = await miniCartTitleLabelBlock
+ .waitFor( { state: 'visible', timeout: 5000 } )
+ .then(
+ () => true,
+ () => false
);
- await expect( miniCartTitleLabelBlock ).toBeVisible( {
- timeout: 1000,
- } );
+
+ if ( usesIapiMiniCart ) {
+ // iAPI Mini Cart.
const miniCartTitleItemsCounterBlock = page.locator(
'[data-block-name="woocommerce/mini-cart-title-items-counter-block"]'
);
@@ -106,9 +118,11 @@ test.describe( 'Shopper → Notices', () => {
await expect( miniCartTitleItemsCounterBlock ).toContainText(
String( itemCount )
);
- } catch ( e ) {
+ } else {
// Legacy React Mini Cart.
- await expect( page.getByText( 'Your cart' ) ).toBeVisible();
+ await expect(
+ page.getByText( 'Your cart', { exact: true } )
+ ).toBeVisible();
await expect(
page.getByText(
`(${ itemCount } item${ itemCount > 1 ? 's' : '' })`
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/product-filters/product-filters-frontend.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/product-filters/product-filters-frontend.block_theme.spec.ts
index 9be2b47dd90..471adbae8f1 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/product-filters/product-filters-frontend.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/product-filters/product-filters-frontend.block_theme.spec.ts
@@ -1,7 +1,12 @@
/**
* External dependencies
*/
-import { TemplateCompiler, test as base, expect } from '@woocommerce/e2e-utils';
+import {
+ TemplateCompiler,
+ getProductAttributeIds,
+ test as base,
+ expect,
+} from '@woocommerce/e2e-utils';
const test = base.extend< { templateCompiler: TemplateCompiler } >( {
templateCompiler: async ( { requestUtils }, use ) => {
@@ -15,9 +20,10 @@ const test = base.extend< { templateCompiler: TemplateCompiler } >( {
test.describe( 'woocommerce/product-filters - Frontend', () => {
test.describe( 'Overlay', () => {
test.beforeEach( async ( { templateCompiler, page } ) => {
+ const { colorAttributeId } = await getProductAttributeIds();
await templateCompiler.compile( {
attributes: {
- attributeId: 1,
+ attributeId: colorAttributeId,
},
} );
@@ -139,10 +145,11 @@ test.describe( 'woocommerce/product-filters - Frontend', () => {
const templateCompiler = await requestUtils.createTemplateFromFile(
'archive-product_multiple-product-filters'
);
+ const { colorAttributeId } = await getProductAttributeIds();
await templateCompiler.compile( {
attributes: {
- attributeId: 1,
+ attributeId: colorAttributeId,
},
} );
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/product-gallery/inner-blocks/product-gallery-thumbnails/product-gallery-thumbnails.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/product-gallery/inner-blocks/product-gallery-thumbnails/product-gallery-thumbnails.block_theme.spec.ts
index 144d93d0e71..298db9e5604 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/product-gallery/inner-blocks/product-gallery-thumbnails/product-gallery-thumbnails.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/product-gallery/inner-blocks/product-gallery-thumbnails/product-gallery-thumbnails.block_theme.spec.ts
@@ -166,7 +166,7 @@ test.describe( 'Product Gallery Thumbnails block', () => {
await expect( thumbnailsSizeInput ).toHaveValue( '25' );
await expect( async () => {
- // Set size to 10%
+ // Set size to 50%
await thumbnailsSizeInput.fill( '50' );
const viewerBox = await viewerBlock.boundingBox();
@@ -197,27 +197,18 @@ test.describe( 'Product Gallery Thumbnails block', () => {
'.wc-block-product-gallery-thumbnails__thumbnail'
);
- // Get the last thumbnail
- const lastThumbnail = thumbnails.last();
-
- await expect( async () => {
- await page.reload();
- // Check if overflow classes are present initially
- await expect( thumbnailsContainer ).toHaveClass(
- /wc-block-product-gallery-thumbnails--overflow-bottom/
- );
+ await expect( thumbnailsContainer ).toHaveClass(
+ /wc-block-product-gallery-thumbnails--overflow-bottom/
+ );
- // Scroll to the last thumbnail
- await lastThumbnail.scrollIntoViewIfNeeded();
+ const lastThumbnail = thumbnails.last();
+ await lastThumbnail.scrollIntoViewIfNeeded();
- // Verify the last thumbnail is visible
- await expect( lastThumbnail ).toBeVisible();
+ await expect( lastThumbnail ).toBeVisible();
- // After scrolling to the end, the bottom overflow should be gone
- await expect( thumbnailsContainer ).not.toHaveClass(
- /wc-block-product-gallery-thumbnails--overflow-bottom/
- );
- } ).toPass( { timeout: 3_000 } );
+ await expect( thumbnailsContainer ).not.toHaveClass(
+ /wc-block-product-gallery-thumbnails--overflow-bottom/
+ );
} );
} );
} );
diff --git a/plugins/woocommerce/tests/e2e/tests/blocks/templates/shop-page.block_theme.spec.ts b/plugins/woocommerce/tests/e2e/tests/blocks/templates/shop-page.block_theme.spec.ts
index b80fff72738..f2c50fbbcd3 100644
--- a/plugins/woocommerce/tests/e2e/tests/blocks/templates/shop-page.block_theme.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/blocks/templates/shop-page.block_theme.spec.ts
@@ -59,6 +59,9 @@ test.describe( 'Shop page', () => {
expect( updatedShopPage.slug ).toBe( 'market' );
await page.goto( 'market/' );
+ // The first request processes WooCommerce's queued rewrite flush. Reload
+ // once so WordPress resolves the updated product archive rules.
+ await page.reload();
await expectShopTemplateToBeLoaded( page );
} );
} );
diff --git a/plugins/woocommerce/tests/e2e/utils/blocks/index.ts b/plugins/woocommerce/tests/e2e/utils/blocks/index.ts
index 786424cb31a..fc54dbe7f96 100644
--- a/plugins/woocommerce/tests/e2e/utils/blocks/index.ts
+++ b/plugins/woocommerce/tests/e2e/utils/blocks/index.ts
@@ -8,6 +8,7 @@ export * from './frontend';
export * from './local-pickup';
export * from './mini-cart';
export * from './performance';
+export * from './product-attributes';
export * from './request-utils';
export * from './shipping';
diff --git a/plugins/woocommerce/tests/e2e/utils/blocks/product-attributes.ts b/plugins/woocommerce/tests/e2e/utils/blocks/product-attributes.ts
new file mode 100644
index 00000000000..071f9ddbfff
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/utils/blocks/product-attributes.ts
@@ -0,0 +1,57 @@
+/**
+ * Internal dependencies
+ */
+import { wpCLI } from './wp-cli';
+
+/**
+ * Read the ids of the sample-data product attributes.
+ *
+ * The ids are assigned when the sample data is imported and differ between
+ * environments, so a spec that names an attribute in a template or a block
+ * attribute has to look them up rather than hard-code 1 and 2.
+ */
+export const getProductAttributeIds = async (): Promise< {
+ colorAttributeId: number;
+ sizeAttributeId: number;
+} > => {
+ const { stdout } = await wpCLI(
+ 'wc product_attribute list --format=json --user=1'
+ );
+ const firstBracket = stdout.indexOf( '[' );
+ const lastBracket = stdout.lastIndexOf( ']' );
+
+ if ( firstBracket < 0 || lastBracket <= firstBracket ) {
+ throw new Error( 'Product attribute CLI output did not contain JSON.' );
+ }
+
+ const attributes = JSON.parse(
+ stdout.slice( firstBracket, lastBracket + 1 )
+ ) as Array< { id: number | string; slug: string } >;
+
+ const getAttributeId = ( slug: string ): number => {
+ const matchingAttributes = attributes.filter(
+ ( attribute ) => attribute.slug === slug
+ );
+
+ if ( matchingAttributes.length !== 1 ) {
+ throw new Error(
+ `Expected exactly one "${ slug }" product attribute, found ${ matchingAttributes.length }.`
+ );
+ }
+
+ const attributeId = Number( matchingAttributes[ 0 ].id );
+
+ if ( ! Number.isSafeInteger( attributeId ) || attributeId < 1 ) {
+ throw new Error(
+ `The "${ slug }" product attribute has an unusable id: ${ matchingAttributes[ 0 ].id }.`
+ );
+ }
+
+ return attributeId;
+ };
+
+ return {
+ colorAttributeId: getAttributeId( 'pa_color' ),
+ sizeAttributeId: getAttributeId( 'pa_size' ),
+ };
+};