Commit b0a6dea0af3 for woocommerce

commit b0a6dea0af3c77f07a9c7a07eafe6c13c60ee9be
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Mon Sep 14 17:24:00 2026 +0300

    [tests] Cut product image E2E from 6 titles to 3, moving persistence and rendering to PHPUnit (#68647)

    test(products): Cut product image E2E from 6 titles to 3

    The product images spec drove the classic editor six times to prove
    that a featured image and a gallery persist and render. Persistence is
    a save-and-reload assertion and rendering is a template assertion;
    neither needs a browser to be trustworthy, and each browser title paid
    for a full wp-admin page load to make one.

    Three titles remain. The help tip alignment title is unchanged: it is
    a layout assertion that only a browser can make. The other five become
    two journeys that each drive the editor once, `can manage the product
    image through the classic editor` and `can manage the product gallery
    through the classic editor`, built from test.step() rather than from
    one title per operation.

    WC_Meta_Box_Product_Images_Test takes the behavior underneath. It
    posts through WC_Meta_Box_Product_Images::save() and asserts the
    ordered gallery lifecycle, then renders the saved product and asserts
    that the featured image, the gallery images and the placeholder appear
    in the right places. It creates its own attachments, so it does not
    depend on the seeded uploads.

    Two of the carried commits are stabilization rather than coverage
    moves, and neither adds a wait, a retry or a timeout:

    - 4471e83c98 scopes the `Set product image` locator to `#postimagediv`
      instead of matching it page-wide.
    - 0edd0ce597 stops asserting the absence of an image through the
      front-end placeholder's alt text and asserts the editor's own
      control instead, which removes an assumption about what the
      front end renders in an isolated run.

    This PR is the images half of a batch that also covered up-sells and
    cross-sells. The two shared no commit, helper or fixture, so they were
    split; the linked products half follows separately.

    Carries the mega-branch commits:
    - 405ebe4f13 test(products): Move product image persistence below E2E
    - 4471e83c98 fix(e2e): synchronize Core admin interactions
    - 0edd0ce597 test(e2e): Fix isolated CI assumptions

    Refs TESTOPS-288
    Refs #68046

    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/testops-288-product-images b/plugins/woocommerce/changelog/testops-288-product-images
new file mode 100644
index 00000000000..0e0dc4f2781
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-288-product-images
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Cut the product images E2E spec from 6 titles to 3; image and gallery persistence and rendering now covered by WC_Meta_Box_Product_Images_Test.
+
diff --git a/plugins/woocommerce/tests/e2e/tests/product/product-images.spec.ts b/plugins/woocommerce/tests/e2e/tests/product/product-images.spec.ts
index e2de803de23..9056cf68cb9 100644
--- a/plugins/woocommerce/tests/e2e/tests/product/product-images.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/product/product-images.spec.ts
@@ -28,6 +28,10 @@ async function addImageFromLibrary(
 	const dataId = await imageLocator.getAttribute( 'data-id' );
 	await expect( imageLocator ).toBeChecked();
 	await page.getByRole( 'button', { name: actionButtonName } ).click();
+	await expect( page.locator( '.media-modal' ) ).toBeHidden();
+	if ( ! dataId ) {
+		throw new Error( `Media library item ${ imageName } has no data-id.` );
+	}
 	return dataId;
 }

@@ -96,23 +100,6 @@ const test = baseTest.extend( {

 		await use( productWithImage );
 	},
-	productWithGallery: async ( { restApi, product }, use ) => {
-		const imageIds = await Promise.all(
-			[ 'image-01', 'image-02', 'image-03' ].map(
-				async ( slug ) => ( await getMediaBySlug( slug ) ).id
-			)
-		);
-		let productWithGallery;
-		await restApi
-			.put( `${ WC_API_PATH }/products/${ product.id }`, {
-				images: imageIds.map( ( id ) => ( { id } ) ),
-			} )
-			.then( ( response ) => {
-				productWithGallery = response.data;
-			} );
-
-		await use( productWithGallery );
-	},
 } );

 test.describe( 'Products > Product Images', () => {
@@ -147,99 +134,72 @@ test.describe( 'Products > Product Images', () => {
 		).toHaveCount( 1 );
 	} );

-	test( 'can set product image', async ( { page, product } ) => {
-		await test.step( 'Navigate to product edit page', async () => {
+	test( 'can manage the product image through the classic editor', async ( {
+		page,
+		product,
+	} ) => {
+		await test.step( 'Set the featured image', async () => {
 			await page.goto(
 				`wp-admin/post.php?post=${ product.id }&action=edit`
 			);
-		} );
-
-		await test.step( 'Set product image', async () => {
-			// TODO: WP 7.0 compat - WP 7.0 changed the featured image metabox link
-			// to a button. Simplify when WP 7.0 is the minimum supported version.
 			await page
 				.getByRole( 'link', { name: 'Set product image' } )
 				.or( page.getByRole( 'button', { name: 'Set product image' } ) )
 				.click();
 			await addImageFromLibrary( page, 'image-01', 'Set product image' );
-
-			// Wait for the product image thumbnail to be updated.
-			// Clicking the "Update" button before this happens will not update the image.
-			// Use src* (contains) instead of src$ (ends with) to match duplicated images, like image-01-1.png
 			await expect(
 				page.locator( '#set-post-thumbnail img[src*="image-01"]' )
 			).toBeVisible();
-
 			await page
 				.locator( '#publishing-action' )
 				.getByRole( 'button', { name: 'Update' } )
 				.click();
-		} );
-
-		await test.step( 'Verify product image was set', async () => {
-			// Verify product was updated
 			await expect( page.getByText( 'Product updated.' ) ).toBeVisible();

-			// Verify image in store frontend
 			await page.goto( product.permalink );
 			await expect(
-				page.locator( `img.wp-post-image[src*="image-01"]` )
+				page.locator( 'img.wp-post-image[src*="image-01"]' )
 			).toBeVisible();
 		} );
-	} );

-	test( 'can update the product image', async ( {
-		page,
-		productWithImage,
-	} ) => {
-		await test.step( 'Navigate to product edit page', async () => {
+		await test.step( 'Replace the featured image', async () => {
 			await page.goto(
-				`wp-admin/post.php?post=${ productWithImage.id }&action=edit`
+				`wp-admin/post.php?post=${ product.id }&action=edit`
 			);
-		} );
-
-		await test.step( 'Update product image', async () => {
 			await page.locator( '#set-post-thumbnail' ).click();
 			await addImageFromLibrary( page, 'image-02', 'Set product image' );
-
-			// Wait for the product image thumbnail to be updated.
-			// Clicking the "Update" button before this happens will not update the image.
-			// Use src* (contains) instead of src$ (ends with) to match duplicated images, like image-01-1.png
 			await expect(
 				page.locator( '#set-post-thumbnail img[src*="image-02"]' )
 			).toBeVisible();
-
 			await page
 				.locator( '#publishing-action' )
 				.getByRole( 'button', { name: 'Update' } )
 				.click();
-		} );
-
-		await test.step( 'Verify product image was set', async () => {
-			// Verify product was updated
 			await expect( page.getByText( 'Product updated.' ) ).toBeVisible();

-			// Verify image in store frontend
-			await page.goto( productWithImage.permalink );
+			await page.goto( product.permalink );
 			await expect(
-				page.locator( `img.wp-post-image[src*="image-02"]` )
+				page.locator( 'img.wp-post-image[src*="image-02"]' )
 			).toBeVisible();
+			await expect(
+				page.locator(
+					'.woocommerce-product-gallery__wrapper img[src*="image-01"]'
+				)
+			).toHaveCount( 0 );
 		} );
-	} );

-	test( 'can delete the product image', async ( {
-		page,
-		productWithImage,
-	} ) => {
-		await test.step( 'Navigate to product edit page', async () => {
+		await test.step( 'Clear the featured image', async () => {
 			await page.goto(
-				`wp-admin/post.php?post=${ productWithImage.id }&action=edit`
+				`wp-admin/post.php?post=${ product.id }&action=edit`
 			);
-		} );
-
-		await test.step( 'Remove product image', async () => {
-			// TODO: WP 7.0 compat - WP 7.0 changed the featured image metabox link
-			// to a button. Simplify when WP 7.0 is the minimum supported version.
+			const setProductImage = page
+				.locator( '#postimagediv' )
+				.getByRole( 'link', { name: 'Set product image' } )
+				.or(
+					page
+						.locator( '#postimagediv' )
+						.getByRole( 'button', { name: 'Set product image' } )
+				);
 			await page
 				.getByRole( 'link', { name: 'Remove product image' } )
 				.or(
@@ -248,142 +208,155 @@ test.describe( 'Products > Product Images', () => {
 					} )
 				)
 				.click();
-			await expect(
-				page.getByRole( 'link', { name: 'Set product image' } ).or(
-					page.getByRole( 'button', {
-						name: 'Set product image',
-					} )
-				)
-			).toBeVisible();
-
+			await expect( setProductImage ).toBeVisible();
 			await page
 				.locator( '#publishing-action' )
 				.getByRole( 'button', { name: 'Update' } )
 				.click();
-		} );
-
-		await test.step( 'Verify product image was removed', async () => {
-			// Verify product was updated
 			await expect( page.getByText( 'Product updated.' ) ).toBeVisible();

-			// Verify image in store frontend
-			await page.goto( productWithImage.permalink );
+			await page.goto(
+				`wp-admin/post.php?post=${ product.id }&action=edit`
+			);
+			await expect( setProductImage ).toBeVisible();
+
+			await page.goto( product.permalink );
 			await expect(
-				page.getByAltText( 'Awaiting product image' )
-			).toBeVisible();
+				page.locator(
+					'.woocommerce-product-gallery__wrapper img[src*="image-"]'
+				)
+			).toHaveCount( 0 );
 		} );
 	} );

-	test( 'can create a product gallery', async ( {
+	test( 'can manage the product gallery through the classic editor', async ( {
 		page,
 		productWithImage,
 	} ) => {
-		const images = [ 'image-02', 'image-03' ];
+		let image02Id = '';
+		let image03Id = '';

-		await test.step( 'Navigate to product edit page', async () => {
+		await test.step( 'Create the ordered product gallery', async () => {
 			await page.goto(
 				`wp-admin/post.php?post=${ productWithImage.id }&action=edit`
 			);
-		} );
-
-		await test.step( 'Add product gallery images', async () => {
-			const imageSelector = '#product_images_container img';
-			let initialImagesCount = await page
-				.locator( imageSelector )
-				.count();
-
-			for ( const image of images ) {
-				await page
-					.getByRole( 'link', {
-						name: 'Add product gallery images',
-					} )
-					.click();
-				const dataId = await addImageFromLibrary(
-					page,
-					image,
-					'Add to gallery'
-				);
+			await page
+				.getByRole( 'link', {
+					name: 'Add product gallery images',
+				} )
+				.click();
+			image02Id = await addImageFromLibrary(
+				page,
+				'image-02',
+				'Add to gallery'
+			);
+			await page
+				.getByRole( 'link', {
+					name: 'Add product gallery images',
+				} )
+				.click();
+			image03Id = await addImageFromLibrary(
+				page,
+				'image-03',
+				'Add to gallery'
+			);

-				await expect(
-					page.locator( `li[data-attachment_id="${ dataId }"]` ),
-					'thumbnail should be visible'
-				).toBeVisible();
-				const currentImagesCount = await page
-					.locator( imageSelector )
-					.count();
-				await expect(
-					currentImagesCount,
-					'number of images should increase'
-				).toEqual( initialImagesCount + 1 );
-				initialImagesCount = currentImagesCount;
-			}
+			const galleryItems = page.locator(
+				'#product_images_container li[data-attachment_id]'
+			);
+			await expect( galleryItems ).toHaveCount( 2 );
+			await expect( galleryItems.nth( 0 ) ).toHaveAttribute(
+				'data-attachment_id',
+				image02Id
+			);
+			await expect( galleryItems.nth( 1 ) ).toHaveAttribute(
+				'data-attachment_id',
+				image03Id
+			);

 			await page
 				.locator( '#publishing-action' )
 				.getByRole( 'button', { name: 'Update' } )
 				.click();
-		} );
+			await expect( page.getByText( 'Product updated.' ) ).toBeVisible();

-		await test.step( 'Verify product gallery', async () => {
-			// Verify gallery in store frontend
 			await page.goto( productWithImage.permalink );
-			await expect(
-				page
-					.locator( `.woocommerce-product-gallery ol img` )
-					.nth( images.length ),
-				'all gallery images should be visible'
-			).toBeVisible(); // +1 for the featured image
+			const frontendImages = page.locator(
+				'.woocommerce-product-gallery__wrapper a[href*="/uploads/"] > img'
+			);
+			await expect( frontendImages ).toHaveCount( 3 );
+			await expect( frontendImages.nth( 0 ) ).toHaveAttribute(
+				'src',
+				/image-01/
+			);
+			await expect( frontendImages.nth( 1 ) ).toHaveAttribute(
+				'src',
+				/image-02/
+			);
+			await expect( frontendImages.nth( 2 ) ).toHaveAttribute(
+				'src',
+				/image-03/
+			);
 		} );
-	} );

-	test( 'can update a product gallery', async ( {
-		page,
-		productWithGallery,
-	} ) => {
-		let imagesCount;
-
-		await test.step( 'Navigate to product edit page', async () => {
+		await test.step( 'Remove one gallery image and preserve order', async () => {
 			await page.goto(
-				`wp-admin/post.php?post=${ productWithGallery.id }&action=edit`
+				`wp-admin/post.php?post=${ productWithImage.id }&action=edit`
 			);
-		} );
-
-		await test.step( 'Remove images from product gallery', async () => {
-			const imageSelector = '#product_images_container img';
-			imagesCount = await page.locator( imageSelector ).count();
-
+			const image02Row = page.locator(
+				`#product_images_container li[data-attachment_id="${ image02Id }"]`
+			);
+			await image02Row.hover();
+			await image02Row.getByRole( 'link', { name: /Delete/ } ).click();
+			await expect( image02Row ).toHaveCount( 0 );
+			await expect(
+				page.locator(
+					`#product_images_container li[data-attachment_id="${ image03Id }"]`
+				)
+			).toBeVisible();
 			await page
-				.getByRole( 'link', {
-					name: 'Add product gallery images',
-				} )
-				.scrollIntoViewIfNeeded();
-
-			await page.locator( imageSelector ).first().hover();
-			await page.getByRole( 'link', { name: ' Delete' } ).click();
+				.locator( '#publishing-action' )
+				.getByRole( 'button', { name: 'Update' } )
+				.click();
+			await expect( page.getByText( 'Product updated.' ) ).toBeVisible();

-			await expect(
-				await page.locator( imageSelector ).count(),
-				'number of images should decrease'
-			).toEqual( imagesCount - 1 );
+			await page.goto( productWithImage.permalink );
+			const frontendImages = page.locator(
+				'.woocommerce-product-gallery__wrapper a[href*="/uploads/"] > img'
+			);
+			await expect( frontendImages ).toHaveCount( 2 );
+			await expect( frontendImages.nth( 0 ) ).toHaveAttribute(
+				'src',
+				/image-01/
+			);
+			await expect( frontendImages.nth( 1 ) ).toHaveAttribute(
+				'src',
+				/image-03/
+			);
+		} );

+		await test.step( 'Clear the remaining gallery image', async () => {
+			await page.goto(
+				`wp-admin/post.php?post=${ productWithImage.id }&action=edit`
+			);
+			const image03Row = page.locator(
+				`#product_images_container li[data-attachment_id="${ image03Id }"]`
+			);
+			await image03Row.hover();
+			await image03Row.getByRole( 'link', { name: /Delete/ } ).click();
+			await expect( image03Row ).toHaveCount( 0 );
 			await page
 				.locator( '#publishing-action' )
 				.getByRole( 'button', { name: 'Update' } )
 				.click();
-		} );
+			await expect( page.getByText( 'Product updated.' ) ).toBeVisible();

-		await test.step( 'Verify product gallery', async () => {
-			// Verify gallery in store frontend
-			await page.goto( productWithGallery.permalink );
-			const selector = `.woocommerce-product-gallery ol img`;
-			await expect(
-				page.locator( selector ).nth( imagesCount - 1 ),
-				'gallery images should be visible'
-			).toBeVisible();
-			await expect(
-				page.locator( selector ).nth( imagesCount ),
-				'one gallery image should not be visible'
-			).toBeHidden();
+			await page.goto( productWithImage.permalink );
+			const frontendImages = page.locator(
+				'.woocommerce-product-gallery__wrapper a[href*="/uploads/"] > img'
+			);
+			await expect( frontendImages ).toHaveCount( 1 );
+			await expect( frontendImages ).toHaveAttribute( 'src', /image-01/ );
 		} );
 	} );
 } );
diff --git a/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-product-images-test.php b/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-product-images-test.php
new file mode 100644
index 00000000000..575d8efa27a
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-product-images-test.php
@@ -0,0 +1,291 @@
+<?php
+/**
+ * Tests for the product images meta box.
+ *
+ * @package WooCommerce\Tests\Admin
+ */
+
+declare( strict_types = 1 );
+
+use Automattic\WooCommerce\Internal\ProductGallery\ProductMediaGallery;
+
+require_once WC_ABSPATH . 'includes/admin/meta-boxes/class-wc-meta-box-product-images.php';
+
+/**
+ * Tests for WC_Meta_Box_Product_Images.
+ */
+class WC_Meta_Box_Product_Images_Test extends WC_Unit_Test_Case {
+
+	/**
+	 * Product IDs created by a test.
+	 *
+	 * @var int[]
+	 */
+	private $product_ids = array();
+
+	/**
+	 * Attachment IDs created by a test.
+	 *
+	 * @var int[]
+	 */
+	private $attachment_ids = array();
+
+	/**
+	 * Whether the product media gallery feature option existed before the test.
+	 *
+	 * @var bool
+	 */
+	private $had_product_media_gallery_option;
+
+	/**
+	 * Original product media gallery feature option value.
+	 *
+	 * @var mixed
+	 */
+	private $original_product_media_gallery_option;
+
+	/**
+	 * Whether the product media gallery feature option was autoloaded before the test.
+	 *
+	 * @var bool
+	 */
+	private $product_media_gallery_option_was_autoloaded;
+
+	/**
+	 * Whether the POST global existed before the test.
+	 *
+	 * @var bool
+	 */
+	private $had_post_global;
+
+	/**
+	 * Original POST global value.
+	 *
+	 * @var mixed
+	 */
+	private $original_post_global;
+
+	/**
+	 * Set up test state.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$missing_option                                    = new stdClass();
+		$this->original_product_media_gallery_option       = get_option( ProductMediaGallery::ENABLE_OPTION_NAME, $missing_option );
+		$this->had_product_media_gallery_option            = $this->original_product_media_gallery_option !== $missing_option;
+		$this->product_media_gallery_option_was_autoloaded = array_key_exists( ProductMediaGallery::ENABLE_OPTION_NAME, wp_load_alloptions() );
+		$this->had_post_global                             = array_key_exists( '_POST', $GLOBALS );
+		$this->original_post_global                        = $GLOBALS['_POST'] ?? null;
+
+		update_option( ProductMediaGallery::ENABLE_OPTION_NAME, 'no' );
+	}
+
+	/**
+	 * Restore test state.
+	 */
+	public function tearDown(): void {
+		foreach ( $this->product_ids as $product_id ) {
+			wp_delete_post( $product_id, true );
+		}
+
+		foreach ( $this->attachment_ids as $attachment_id ) {
+			wp_delete_attachment( $attachment_id, true );
+		}
+
+		if ( $this->had_product_media_gallery_option ) {
+			update_option( ProductMediaGallery::ENABLE_OPTION_NAME, $this->original_product_media_gallery_option );
+		} else {
+			delete_option( ProductMediaGallery::ENABLE_OPTION_NAME );
+		}
+		$this->assertSame(
+			$this->product_media_gallery_option_was_autoloaded,
+			array_key_exists( ProductMediaGallery::ENABLE_OPTION_NAME, wp_load_alloptions() ),
+			'The product media gallery feature option should retain its original autoload behavior.'
+		);
+
+		if ( $this->had_post_global ) {
+			$GLOBALS['_POST'] = $this->original_post_global;
+		} else {
+			unset( $GLOBALS['_POST'] );
+		}
+
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Saves ordered gallery images through the classic product images meta box lifecycle.
+	 */
+	public function test_save_persists_ordered_gallery_lifecycle(): void {
+		$product         = $this->create_simple_product();
+		$first_image_id  = $this->create_image_attachment();
+		$second_image_id = $this->create_image_attachment();
+		$third_image_id  = $this->create_image_attachment();
+
+		$this->save_gallery( $product->get_id(), array( $first_image_id, $second_image_id, $third_image_id ) );
+		$this->assertSame(
+			array( $first_image_id, $second_image_id, $third_image_id ),
+			$this->get_fresh_product( $product->get_id() )->get_gallery_image_ids(),
+			'The classic meta box should persist gallery image IDs in their posted order.'
+		);
+
+		$this->save_gallery( $product->get_id(), array( $second_image_id, $third_image_id ) );
+		$this->assertSame(
+			array( $second_image_id, $third_image_id ),
+			$this->get_fresh_product( $product->get_id() )->get_gallery_image_ids(),
+			'The classic meta box should persist removed gallery images and their remaining order.'
+		);
+
+		$this->save_gallery( $product->get_id(), array() );
+		$this->assertSame(
+			array(),
+			$this->get_fresh_product( $product->get_id() )->get_gallery_image_ids(),
+			'The classic meta box should clear the gallery when its posted field is empty.'
+		);
+	}
+
+	/**
+	 * @testdox Renders saved featured and gallery images in order before rendering the placeholder.
+	 */
+	public function test_saved_gallery_renders_featured_and_gallery_images_then_placeholder(): void {
+		$product         = $this->create_simple_product();
+		$first_image_id  = $this->create_image_attachment();
+		$second_image_id = $this->create_image_attachment();
+		$third_image_id  = $this->create_image_attachment();
+		$product_id      = $product->get_id();
+
+		$product->set_image_id( $first_image_id );
+		$product->save();
+		$this->save_gallery( $product_id, array( $second_image_id, $third_image_id ) );
+
+		$rendered_attachment_ids = array();
+		$record_attachment_id    = static function ( $html, $attachment_id ) use ( &$rendered_attachment_ids ) {
+			$rendered_attachment_ids[] = (int) $attachment_id;
+
+			return $html;
+		};
+		add_filter( 'woocommerce_single_product_image_thumbnail_html', $record_attachment_id, 10, 2 );
+
+		try {
+			$gallery_html = wc_get_product_gallery_html( $this->get_fresh_product( $product_id ) );
+		} finally {
+			remove_filter( 'woocommerce_single_product_image_thumbnail_html', $record_attachment_id );
+		}
+
+		$this->assertSame(
+			array( $first_image_id, $second_image_id, $third_image_id ),
+			$rendered_attachment_ids,
+			'The classic gallery template should render the featured image before saved gallery images.'
+		);
+		$this->assertStringContainsString( 'woocommerce-product-gallery__image', $gallery_html, 'The classic gallery template should render product image markup.' );
+		$this->assertStringContainsString( 'wp-post-image', $gallery_html, 'The classic gallery template should render the featured attachment image.' );
+
+		$product = $this->get_fresh_product( $product_id );
+		$product->set_image_id( $second_image_id );
+		$product->save();
+		$this->save_gallery( $product_id, array( $third_image_id ) );
+
+		$rendered_attachment_ids = array();
+		add_filter( 'woocommerce_single_product_image_thumbnail_html', $record_attachment_id, 10, 2 );
+		try {
+			$gallery_html = wc_get_product_gallery_html( $this->get_fresh_product( $product_id ) );
+		} finally {
+			remove_filter( 'woocommerce_single_product_image_thumbnail_html', $record_attachment_id );
+		}
+
+		$this->assertSame(
+			array( $second_image_id, $third_image_id ),
+			$rendered_attachment_ids,
+			'The classic gallery template should render the updated featured and gallery image order.'
+		);
+		$this->assertStringContainsString( 'wp-post-image', $gallery_html, 'The updated featured attachment should be rendered.' );
+
+		$this->save_gallery( $product_id, array() );
+		$product = $this->get_fresh_product( $product_id );
+		$product->set_image_id( 0 );
+		$product->save();
+
+		$rendered_attachment_ids = array();
+		add_filter( 'woocommerce_single_product_image_thumbnail_html', $record_attachment_id, 10, 2 );
+		try {
+			$gallery_html = wc_get_product_gallery_html( $this->get_fresh_product( $product_id ) );
+		} finally {
+			remove_filter( 'woocommerce_single_product_image_thumbnail_html', $record_attachment_id );
+		}
+
+		$this->assertStringContainsString( 'Awaiting product image', $gallery_html, 'The classic gallery template should render the product image placeholder after both image sources are cleared.' );
+		$this->assertSame(
+			array(),
+			array_values(
+				array_filter(
+					$rendered_attachment_ids,
+					static function ( int $attachment_id ): bool {
+						return $attachment_id > 0;
+					}
+				)
+			),
+			'The placeholder should not render a positive attachment ID.'
+		);
+	}
+
+	/**
+	 * Create and track a simple product.
+	 *
+	 * @return WC_Product
+	 */
+	private function create_simple_product(): WC_Product {
+		$product             = WC_Helper_Product::create_simple_product();
+		$this->product_ids[] = $product->get_id();
+
+		return $product;
+	}
+
+	/**
+	 * Create and track a real image attachment.
+	 *
+	 * @return int
+	 */
+	private function create_image_attachment(): int {
+		$attachment_id          = $this->factory->attachment->create_upload_object( DIR_TESTDATA . '/images/canola.jpg' );
+		$this->attachment_ids[] = $attachment_id;
+
+		return $attachment_id;
+	}
+
+	/**
+	 * Persist gallery image IDs through the real classic meta-box handler.
+	 *
+	 * @param int   $product_id Product ID.
+	 * @param int[] $image_ids  Gallery image IDs.
+	 */
+	private function save_gallery( int $product_id, array $image_ids ): void {
+		$_POST = array(
+			'product-type'          => 'simple',
+			'product_image_gallery' => implode( ',', $image_ids ),
+		);
+
+		$post = get_post( $product_id );
+
+		if ( ! $post instanceof WP_Post ) {
+			throw new RuntimeException( 'Expected the product post to exist before saving its gallery.' );
+		}
+
+		WC_Meta_Box_Product_Images::save( $product_id, $post );
+	}
+
+	/**
+	 * Load a product from persistent storage.
+	 *
+	 * @param int $product_id Product ID.
+	 * @return WC_Product
+	 */
+	private function get_fresh_product( int $product_id ): WC_Product {
+		$product = wc_get_product( $product_id );
+
+		if ( ! $product instanceof WC_Product ) {
+			throw new RuntimeException( 'Expected the saved product to reload from persistent storage.' );
+		}
+
+		return $product;
+	}
+}