Commit f70cb1bd1d2 for woocommerce

commit f70cb1bd1d2b656e48038fd6d4fa27aa666b3d45
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Tue Oct 20 16:47:03 2026 +0300

    [tests] Make the product export and email browser titles assert what their names claim (#68889)

    * test(e2e): Isolate the order key check and assert download links are entitlements

    The known-shopper title made every bad-key visit while logged out, so the
    owner check hid the page before the key check could be observed. It now
    visits a wrong-key URL as the order's owner, and again as a guest with the
    owner requirement lifted by the filter plugin, so the key check stands on
    its own.

    The downloadable title matched each link href against /.+/, which fails
    only on an empty string. It now requires the download file, order and key
    parameters in the href and that the href is not the product page.

    Refs TESTOPS-234

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(e2e): Assert the shipping rate a shopper picks reaches the cart

    With a single flat rate configured, the default selection and a chosen one
    look identical. The topology helper now offers a second flat rate at a
    different cost, and the test picks the non-default one and asserts the
    radio, the order summary shipping total and the Store API cart all report
    it. Switching to Pickup selects the pickup location, and switching back
    returns the package's first rate, which is what the block does; the title
    says that rather than promising the earlier choice is preserved.

    Refs TESTOPS-234

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(e2e): Assert the error notice banner under the block notices filter

    Only the success template was proven, twice. The body now produces a
    non-coupon error by putting the cart's product out of stock, and asserts
    the error banner with its icon on a stable page load. The title names the
    two templates the body covers.

    Refs TESTOPS-234

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(e2e): Assert saved Related by criteria decide the frontend related list

    Album has only a category, so toggling Tags changed nothing and the
    frontend was read only with both criteria checked. Album now also carries
    the Recommended tag, giving each criterion its own set of relatives, and
    the test saves with Tags unchecked and asserts the frontend list drops the
    tag-only relatives. The docblock names the query-context behaviour this
    title is the only browser owner of.

    Refs TESTOPS-234

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(e2e): Assert the frontend inherit list and the on-sale custom criteria

    The inherit half was read only in the editor, and the custom half was
    asserted as 'a different list'. The test now saves in the inherit state and
    asserts the shop page renders the catalog query it captured, and requires
    every product in the custom list to be one the seed puts on sale. The
    docblock names the post-context Query type control this title is the only
    browser owner of.

    Refs TESTOPS-234

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(e2e): Assert Product Rating and Stock Indicator render their product's data

    The Product Elements title inserted a rating and a stock indicator but
    asserted neither, and checked the post-reload editor by product count. It
    now asserts the four inserted elements by block name after the reload, the
    Hoodie card's rating, and an out-of-stock card's stock indicator.

    The empty-collection title checked the frontend for 'No products to
    display', an editor-only string that never appears there. That is replaced
    by a count of the collections that reach the page.

    The specific-request title asserts the shape of the editor's outgoing REST
    request and nothing about what comes back, so its name now says so. The
    Product Elements docblock names the per-product rendering it owns.

    Refs TESTOPS-234

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(e2e): Document the template-slug to collection-location mapping this title owns

    The preview-label title is the only browser coverage for a template slug
    resolving to the product or cart location a registered collection requires.
    The docblock says so, so a later move of this coverage has to account for
    it.

    Refs TESTOPS-234

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(e2e): Name the Product Filters title after the inner filters it asserts

    The default template has six inner filters; the body asserts the Active
    and Color ones in the canvas and their List View entries. The title now
    says that instead of promising the whole structure.

    Refs TESTOPS-234

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(e2e): Assert the inserted Related Products variation carries the related collection

    The title proved only that a Product Collection block appears and the
    legacy block does not, which any collection would satisfy. It now reads the
    inserted block's collection attribute and requires the related variation.

    Refs TESTOPS-234

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(e2e): Wait for the inserter list before asserting Product Gallery is absent

    The negative half ran straight after the inserter toggle, so toBeHidden
    passed against a list that had yet to render. The test now waits for the
    WooCommerce list to hold an option before requiring the Product Gallery
    option to be absent.

    Refs TESTOPS-234

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(e2e): Assert the Cart block shipping row is absent when store shipping is off

    On the Cart block the row is labelled 'Shipping', so a check for hidden
    'Delivery' text matched nothing either way. The assertion now targets the
    Cart block's own shipping totals row.

    Refs TESTOPS-234

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(e2e): Assert the checkout link builds exactly the cart it describes

    The logged-in title read only the two product names out of the order
    summary, so quantities, line totals, the discount, the total and the cart
    emptying that precedes the link were all unobserved. The fixture now
    creates a third, unrelated product that goes into the cart first, and the
    link carries an explicit quantity for one product and the default for the
    other. The summary is asserted item by item, together with the coupon
    discount line and the order total, and the pre-seeded product has to be
    gone. The products are virtual so the total is the prices and the coupon.

    Refs TESTOPS-288

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(e2e): Assert the generated CSV holds only the selected products

    No export was ever run: the title stopped at the export screen. It now
    clicks Generate CSV, reads the downloaded file, and requires its non-
    variation rows to be exactly the two selected products. The title says what
    the body covers, including the clearing step it ends on.

    Refs TESTOPS-288

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(e2e): Assert Apply honours a keep-yours choice alongside a use-core one

    With one conflict switched to use-core and nothing left on keep-yours, an
    Apply that took core wholesale passed. The fixture now carries merchant
    edits on two blocks, the second conflict stays on keep-yours, and the
    persisted content is checked for both: core for the switched conflict and
    the merchant text for the kept one.

    The third block is core-only, so the drawer lists it as auto-resolved and
    says the update will apply, while the applier defaults it to keep_yours.
    That contradiction is named in a comment and left unasserted.

    Refs TESTOPS-288

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(e2e): Assert the preview email reaches the mailer and the header image URL

    The send-preview title's only post-click assertion was the generic failure
    message, which a broken send path also produces. It now also requires a
    logged message addressed to the entered recipient with the previewed
    email's subject, so the preview has to reach the mailer. The recipient is
    unique per run because the core store keeps its mail log.

    The image title checked only that the preview img appears and the upload
    icon goes away. It now requires the img src and the
    woocommerce_email_header_image input to hold the picked media URL, and the
    input to be empty after Remove.

    The live-preview title also covers the initial render and the preview-type
    switch, so its name now says so.

    Refs TESTOPS-288

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(e2e): Guard the WP-CLI product IDs and parse the download link URL

    Three specs took a product ID from WP-CLI output with `/\d+/g` and
    `.pop()`. npm writes its own banner to the same stdout, so any digits
    in it could pick the wrong product, and an empty result passed
    `undefined` into the next command. Take the line that holds nothing but
    digits and throw with the raw output when there is none, as the
    featured-product spec does.

    The order confirmation spec matched the download link against a regex
    that fixed the order of `download_file`, `order` and `key`. Parse the
    href and assert each parameter, so a reordered URL still passes and the
    failure names the missing parameter.

    The product card lookup used `new RegExp` on the product title, which
    reads title characters as pattern syntax. Match the title text exactly.
    Say in a comment which two formats the rating name expression accepts.

    The guard proved itself locally: against a database without the Blocks
    seed it stopped the test with "Failed to find Belt". The download link
    mutation (the block printing the product URL) still turns the test red,
    now with "The download link should carry download_file".

    Refs #68889

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(e2e): Assert a classic theme renders the classic notice templates

    The only browser title that shows a cart notice under a classic theme
    activates the child theme that opts in to block notices, so it renders
    the block banners. Since the notices coverage moved to PHPUnit, no
    browser test loads a page where the classic success and error templates
    print, and switching the opt-in filter's default to true left every
    browser title green.

    Add a title that stays on Storefront without the opt-in: applying a
    coupon prints `.woocommerce-message` inside the notices wrapper, an
    out-of-stock cart item prints `.woocommerce-error` there, and no block
    notice banner is on the page. The out-of-stock setup moves into a helper
    both titles use.

    The title fails with the filter default set to true (the success
    notice is a block banner) and with the error template's class changed
    (the error notice is not found); the opt-in title passes under both.

    Refs TESTOPS-234

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(e2e): Assert the edit order screen shows its heading and order number

    The removed "can view single order" title was the only browser check
    that the order edit screen prints its "Edit order" heading. Since then
    the heading is asserted only by the optional k6 performance job, which
    runs when tests/performance changes. Printing the "Add new order" label
    on the edit screen left all thirteen titles of the spec green.

    Add a title that opens the order created for the spec and asserts the
    page heading and the "Order #<id>" heading of the order data box. With
    the edit screen printing the add-new label, this title alone fails.

    Refs TESTOPS-288

    Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

    * test(e2e): Describe the three remaining specs in the changelog entry

    The entry still described the PR before its two trims: sixteen titles,
    two new titles for the classic notice templates and the edit order
    screen heading, and five documented sole-owner titles. The 2026-09-28
    trim dropped twelve specs and both new titles, and merging trunk on
    2026-10-07 dropped the Checkout Link spec after trunk's #69096 deleted
    the title this PR hardened.

    Name the three specs that remain: product export, the email settings
    preview and the email editor review drawer.

    Refs TESTOPS-234

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * chore(changelog): Make the retained browser titles entry comment-only

    The entry carried a body, so it would have shipped to the public
    changelog as a "Dev -" line. The change only tightens test assertions
    and has no merchant-facing effect.

    Switch it to a Comment so reviewers still see it but the release notes
    stay clean, as requested in review.

    Refs TESTOPS-234

    Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/testops-234-harden-retained-browser-titles b/plugins/woocommerce/changelog/testops-234-harden-retained-browser-titles
new file mode 100644
index 00000000000..f18e916ebcf
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-234-harden-retained-browser-titles
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Tighten product export and email browser title tests; no production change.
diff --git a/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/core-flows.spec.ts b/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/core-flows.spec.ts
index 9726cd75336..64e268b89ac 100644
--- a/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/core-flows.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/email-editor/update-propagation/core-flows.spec.ts
@@ -81,10 +81,11 @@ test.describe(
 				'<!-- wp:paragraph --><p>OLD BLOCK B</p><!-- /wp:paragraph -->' +
 				'<!-- wp:paragraph --><p>OLD BLOCK C</p><!-- /wp:paragraph -->';

-			const customized = oldHtml.replace(
-				'OLD BLOCK A',
-				'MERCHANT EDITED A'
-			);
+			// Two merchant edits, so the drawer offers a choice per conflict, and one
+			// block the merchant leaves alone.
+			const customized = oldHtml
+				.replace( 'OLD BLOCK A', 'MERCHANT EDITED A' )
+				.replace( 'OLD BLOCK B', 'MERCHANT EDITED B' );

 			const newCanonical =
 				'<!-- wp:paragraph --><p>NEW CORE A</p><!-- /wp:paragraph -->' +
@@ -139,11 +140,13 @@ test.describe(
 				drawer.getByRole( 'heading', { name: /needs your attention/i } )
 			).toBeVisible( { timeout: 15000 } );

-			const firstRadioGroup = drawer
-				.getByRole( 'radiogroup', {
-					name: /choose which version to apply/i,
-				} )
-				.first();
+			const radioGroups = drawer.getByRole( 'radiogroup', {
+				name: /choose which version to apply/i,
+			} );
+			await expect( radioGroups ).toHaveCount( 2 );
+
+			const firstRadioGroup = radioGroups.first();
+			const secondRadioGroup = radioGroups.nth( 1 );
 			await expect(
 				firstRadioGroup.getByRole( 'radio', { name: /keep yours/i } )
 			).toHaveAttribute( 'aria-checked', 'true' );
@@ -154,13 +157,18 @@ test.describe(
 				firstRadioGroup.getByRole( 'radio', { name: /use core/i } )
 			).toHaveAttribute( 'aria-checked', 'true' );

-			// B and C were changed by core only. Assert the drawer's promise about
-			// them before applying, so the claim and the result stay tied together.
+			// The second conflict stays on keep-yours, so Apply has to honour both
+			// choices rather than one of them.
+			await expect(
+				secondRadioGroup.getByRole( 'radio', { name: /keep yours/i } )
+			).toHaveAttribute( 'aria-checked', 'true' );
+
+			// C was changed by core only, so the drawer promises to apply its update.
 			await expect(
 				drawer.getByText(
 					/your version was unchanged, so the update will apply/i
 				)
-			).toHaveCount( 2 );
+			).toHaveCount( 1 );

 			await drawer.getByRole( 'button', { name: /^apply/i } ).click();
 			await expect( drawer ).toBeHidden( { timeout: 15000 } );
@@ -169,7 +177,8 @@ test.describe(
 			expect( content ).toContain( 'NEW CORE A' );
 			expect( content ).not.toContain( 'MERCHANT EDITED A' );

-			expect( content ).toContain( 'NEW CORE B' );
+			expect( content ).toContain( 'MERCHANT EDITED B' );
+			expect( content ).not.toContain( 'NEW CORE B' );
 			expect( content ).toContain( 'NEW CORE C' );
 			expect( content ).not.toContain( 'OLD BLOCK B' );
 			expect( content ).not.toContain( 'OLD BLOCK C' );
diff --git a/plugins/woocommerce/tests/e2e/tests/email/settings-email.spec.ts b/plugins/woocommerce/tests/e2e/tests/email/settings-email.spec.ts
index 78a11d2f306..5481351b799 100644
--- a/plugins/woocommerce/tests/e2e/tests/email/settings-email.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/email/settings-email.spec.ts
@@ -10,6 +10,7 @@ import { setFeatureEmailImprovementsFlag } from './helpers/set-email-improvement
 import { disableEmailEditor } from '../email-editor/helpers/enable-email-editor-feature';
 import { tags, locks } from '../../fixtures/fixtures';
 import { ADMIN_STATE_PATH } from '../../playwright.config';
+import { expectEmail } from '../../utils/email';

 const pickImageFromLibrary = async ( page: Page, imageName: string ) => {
 	await page.getByRole( 'tab', { name: 'Media Library' } ).click();
@@ -35,7 +36,7 @@ test.describe(
 		} );

 		test(
-			'Live preview when changing email settings',
+			'Email preview renders, switches type, and updates live when settings change',
 			{ tag: [ tags.SKIP_ON_EXTERNAL_ENV ] },
 			async ( { page, baseURL } ) => {
 				await setFeatureEmailImprovementsFlag( baseURL, 'no' );
@@ -146,8 +147,10 @@ test.describe(
 			} );
 			await expect( sendButton ).toBeDisabled();

-			// Fill in the email address field
-			const email = 'test@example.com';
+			// Fill in the email address field. The recipient is unique per run so the
+			// mail log assertion below finds this run's message on a store that keeps
+			// the ones before it.
+			const email = `preview-${ Date.now() }@example.com`;
 			const emailInput = modal.getByLabel( 'Send to' );
 			await emailInput.fill( email );

@@ -155,12 +158,20 @@ test.describe(
 			await expect( sendButton ).toBeEnabled();
 			await sendButton.click();

-			// Sending fails in the test env (no mail server); the backend returns
+			// Delivery fails in the test env (no mail server); the backend returns
 			// `woocommerce_rest_email_preview_not_sent`, which hits the generic fallback in friendlyEmailSendError.
 			const message = modal.locator(
 				"text=Couldn't send the test email. Check your email settings and try again."
 			);
 			await expect( message ).toBeVisible();
+
+			// The preview email still reaches the mailer, addressed to the entered
+			// recipient and carrying the previewed email's own subject.
+			await expectEmail(
+				page,
+				email,
+				new RegExp( `Your ${ storeName } order has been received!` )
+			);
 		} );

 		test( 'Choose image in email image url field', async ( { page } ) => {
@@ -175,6 +186,18 @@ test.describe(
 			await expect( page.locator( logoImageElement ) ).toBeVisible();
 			await expect( page.locator( uploadIconElement ) ).toBeHidden();

+			// The preview and the setting both hold the URL of the picked image.
+			const headerImageInput = page.locator(
+				'#woocommerce_email_header_image'
+			);
+			const pickedImageUrl = await page
+				.locator( logoImageElement )
+				.getAttribute( 'src' );
+			expect( pickedImageUrl ).toMatch( /image-03/ );
+			await expect( headerImageInput ).toHaveValue(
+				pickedImageUrl as string
+			);
+
 			// Remove an image
 			await page
 				.locator( '#wc_settings_email_image_url_slotfill' )
@@ -182,6 +205,7 @@ test.describe(
 				.click();
 			await expect( page.locator( logoImageElement ) ).toBeHidden();
 			await expect( page.locator( uploadIconElement ) ).toBeVisible();
+			await expect( headerImageInput ).toHaveValue( '' );
 		} );
 	}
 );
diff --git a/plugins/woocommerce/tests/e2e/tests/product/product-export.spec.ts b/plugins/woocommerce/tests/e2e/tests/product/product-export.spec.ts
index 26f71a16138..9af44821202 100644
--- a/plugins/woocommerce/tests/e2e/tests/product/product-export.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/product/product-export.spec.ts
@@ -1,6 +1,7 @@
 /**
  * External dependencies
  */
+import { readFile } from 'node:fs/promises';
 import { WC_API_PATH } from '@woocommerce/e2e-utils-playwright';

 /**
@@ -68,7 +69,7 @@ const test = baseTest.extend( {
 } );

 test.describe( 'Product > Export Selected Products', () => {
-	test( 'preserves multiple selection through export and clear', async ( {
+	test( 'carries a multi-product selection into the export and the generated CSV, then clears it', async ( {
 		page,
 		productsFixture,
 	} ) => {
@@ -136,6 +137,41 @@ test.describe( 'Product > Export Selected Products', () => {
 			).toBeVisible();
 		} );

+		await test.step( 'Generate the CSV and verify it holds only the selected products', async () => {
+			const downloadPromise = page.waitForEvent( 'download' );
+			await page.getByRole( 'button', { name: 'Generate CSV' } ).click();
+			const download = await downloadPromise;
+			const csv = await readFile( await download.path(), 'utf8' );
+
+			// Keep only rows that start with an id, so a quoted field holding a
+			// newline cannot be read as a row of its own.
+			const dataRows = csv
+				.trim()
+				.split( '\n' )
+				.slice( 1 )
+				.filter( ( row ) => /^\d+,/.test( row ) );
+			const parentIds = dataRows
+				.map( ( row ) => row.split( ',' ) )
+				.filter(
+					( columns ) =>
+						// The Type column is a quoted list for a variation that
+						// is also virtual or downloadable.
+						! columns[ 1 ]
+							.replace( /^"/, '' )
+							.startsWith( 'variation' )
+				)
+				.map( ( columns ) => columns[ 0 ] );
+
+			expect( parentIds.toSorted() ).toEqual(
+				[
+					String( simpleProduct.id ),
+					String( variableProduct.id ),
+				].toSorted()
+			);
+			expect( csv ).toContain( simpleProduct.name );
+			expect( csv ).toContain( variableProduct.name );
+		} );
+
 		await test.step( 'Clear the selected products', async () => {
 			await page
 				.getByRole( 'link', { name: 'clear your selection' } )