Commit 8fa19f40f42 for woocommerce

commit 8fa19f40f4200786b51fdd176e040a4c4c65fa4b
Author: Chris Lilitsas <1105590+xristos3490@users.noreply.github.com>
Date:   Tue Sep 1 17:50:43 2026 +0300

    BIS: add e2e coverage for variable products and variations (#68093)

    * test: cover BIS variable products and variations in e2e

    * test: assert BIS variation pre-selection and parent signup opt-out

    * test: store the Any variation with an empty attribute value

    * test: wait on the resolved variation id when picking a BIS variation

    * test: assert BIS opt-out removes the form with a positive control

    * test: tighten BIS variation helpers and reuse the mail-log opener

    * docs: drop the Any-variation prose from the BIS variation coverage

    * fix: keep the BIS changelog Comment header on the header block

diff --git a/plugins/woocommerce/changelog/68064-add-bis-e2e-variation-coverage b/plugins/woocommerce/changelog/68064-add-bis-e2e-variation-coverage
new file mode 100644
index 00000000000..6e4ef254abb
--- /dev/null
+++ b/plugins/woocommerce/changelog/68064-add-bis-e2e-variation-coverage
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Add Playwright E2E coverage for Back in Stock Notifications on variable products and variations (form show/hide per variation, variation signup, variation attributes in the emails, and the back-in-stock CTA linking back to a fixed-value variation).
diff --git a/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/README.md b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/README.md
index 0815a600e68..d1548d21e09 100644
--- a/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/README.md
+++ b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/README.md
@@ -1,11 +1,35 @@
 # Back in Stock Notifications — Playwright tests

-Covers the four scenarios from the original plugin test plan that have a target in core:
+Covers the scenarios from the original plugin test plan that have a target in core:

 - `signing-up.spec.ts` — PDP form rendering + signup flow (logged-in, guest single-opt-in, guest double-opt-in, requires-account).
 - `receiving-confirmations.spec.ts` — verify email + verified email + unsubscribe flow (double opt-in).
 - `receiving-notifications.spec.ts` — back-in-stock email dispatch on restock + unsubscribe flow.
 - `managing-notifications.spec.ts` — admin list rendering + Resend on PENDING + Resend guard on ACTIVE + admin Cancel.
+- `variations.spec.ts` — variable products: the form following the selected
+  variation, signup against a variation, the variation's attributes in the
+  emails, the back-in-stock email linking back to a fixed-value variation
+  pre-selected, and the parent-level signup opt-out removing the form from the
+  whole variable product page.
+
+## Variation notes
+
+- The form's show/hide is asserted on the `hidden` class rather than on
+  visibility. That class is the contract `back-in-stock-form.js` drives; whether
+  it actually hides the form depends on the theme, since
+  `.woocommerce .wc_bis_form.hidden { display: none }` ships in `woocommerce.css`
+  (the `woocommerce-general` handle), which bundled themes such as Twenty
+  Twenty-Three replace with their own stylesheet.
+- The parent-level opt-out is asserted on the product page only. On render,
+  `maybe_render_form()` passes the parent from `global $product` to
+  `product_allows_signups()`, so the variation branch of that method — the one
+  that walks up to the parent — is only reached on the signup POST, which the
+  form's absence makes unreachable from the UI.
+- The plugin's "already signed up for this variation" scenario has no core
+  target: that message is only rendered when the
+  `woocommerce_customer_stock_notifications_personalization_enabled` filter is
+  enabled (it defaults to `false`) and it resolves against the parent product on
+  a variable PDP, not the selected variation.

 ## Skipped scenarios

diff --git a/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/variations.spec.ts b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/variations.spec.ts
new file mode 100644
index 00000000000..7fd00509c33
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/variations.spec.ts
@@ -0,0 +1,321 @@
+/**
+ * Internal dependencies
+ */
+import { expect, request, tags } from '../../fixtures/fixtures';
+import { ADMIN_STATE_PATH } from '../../playwright.config';
+import {
+	BIS_EMAIL_ELEMENTS,
+	BIS_EMAIL_LINKS,
+	bisEmailBody,
+	bisEmailSubject,
+	bisFormLocator,
+	bisTargetProductInput,
+	escapeRegExp,
+	getEmailLinkById,
+	openEmailInMailLog,
+	resetBISOptions,
+	restockVariation,
+	selectVariation,
+	setBISOptions,
+	setProductSignupsAllowed,
+	signUpAsGuest,
+	signUpOnProductPage,
+	test,
+	triggerStockNotificationsBatch,
+	uniqueGuestEmail,
+} from '../../utils/back-in-stock-notifications';
+
+test.describe(
+	'Back in Stock Notifications — variable products and variations',
+	{ tag: [ tags.SERVICES ] },
+	() => {
+		test.use( { storageState: ADMIN_STATE_PATH } );
+
+		test.afterAll( async ( { baseURL } ) => {
+			await resetBISOptions( request, baseURL! );
+		} );
+
+		test.describe( 'Single opt-in', () => {
+			test.beforeAll( async ( { baseURL } ) => {
+				await setBISOptions( request, baseURL!, {
+					allowSignups: true,
+					doubleOptIn: false,
+					requireAccount: false,
+				} );
+			} );
+
+			test( 'the form follows the selected variation and targets it', async ( {
+				page,
+				variableProduct,
+			} ) => {
+				await page.goto( variableProduct.permalink );
+
+				const form = bisFormLocator( page );
+				const targetProduct = bisTargetProductInput( page );
+
+				// Before a variation is picked the form is rendered but
+				// hidden, and still points at the parent product.
+				//
+				// Asserted on the `hidden` class rather than on visibility:
+				// that class is the contract `back-in-stock-form.js` drives,
+				// while whether it actually hides the form depends on the
+				// theme's stylesheet.
+				await expect( form ).toContainClass( 'hidden' );
+				await expect( targetProduct ).toHaveValue(
+					String( variableProduct.id )
+				);
+
+				await selectVariation(
+					page,
+					variableProduct,
+					variableProduct.outOfStockVariation
+				);
+
+				await expect( form ).not.toContainClass( 'hidden' );
+				await expect(
+					form.getByRole( 'button', { name: /Notify me/i } )
+				).toBeVisible();
+				await expect( targetProduct ).toHaveValue(
+					String( variableProduct.outOfStockVariation.id )
+				);
+
+				// Switching to an in-stock variation hides it again. Asserted
+				// as a transition from the shown state above, so a form that
+				// was never shown at all cannot pass this.
+				await selectVariation(
+					page,
+					variableProduct,
+					variableProduct.inStockVariation!
+				);
+
+				await expect( form ).toContainClass( 'hidden' );
+			} );
+
+			test( 'signing up for an out-of-stock variation confirms the variation by name', async ( {
+				browser,
+				variableProduct,
+			} ) => {
+				const email = uniqueGuestEmail( 'bis-variation-signup' );
+				const { outOfStockVariation } = variableProduct;
+
+				// Driven in a guest context so the PDP renders the email field,
+				// and so the success notice can be read before it is torn down.
+				const guestContext = await browser.newContext( {
+					storageState: { cookies: [], origins: [] },
+				} );
+				const guestPage = await guestContext.newPage();
+
+				await guestPage.goto( variableProduct.permalink );
+				await selectVariation(
+					guestPage,
+					variableProduct,
+					outOfStockVariation
+				);
+				await signUpOnProductPage( guestPage, { email } );
+
+				// The notice names the variation rather than the parent
+				// product, which is what shows the signup was recorded against
+				// the variation the shopper picked.
+				// `wptexturize` turns the hyphen WooCommerce generated the
+				// variation title with into an en dash by the time the notice
+				// is printed, so match either.
+				await expect(
+					guestPage.getByText(
+						new RegExp(
+							`You have successfully signed up! You will be notified when "${ escapeRegExp(
+								variableProduct.name
+							) } [-–] ${ escapeRegExp(
+								outOfStockVariation.option
+							) }" is back in stock\\.`
+						)
+					)
+				).toBeVisible();
+
+				await guestContext.close();
+			} );
+
+			test( 'restocking a variation notifies its subscribers and links back to it', async ( {
+				page,
+				browser,
+				restApi,
+				variableProduct,
+			} ) => {
+				const email = uniqueGuestEmail( 'bis-variation-restock' );
+				const { outOfStockVariation } = variableProduct;
+
+				await signUpAsGuest(
+					browser,
+					variableProduct.permalink,
+					email,
+					{
+						selectVariation: {
+							product: variableProduct,
+							variation: outOfStockVariation,
+						},
+					}
+				);
+
+				await restockVariation(
+					restApi,
+					variableProduct.id,
+					outOfStockVariation.id
+				);
+
+				// StockSyncController schedules an AS job; drain it synchronously.
+				await triggerStockNotificationsBatch( page );
+
+				const subject = bisEmailSubject.backInStock(
+					outOfStockVariation.notificationName
+				);
+
+				const productLink = await getEmailLinkById(
+					page,
+					email,
+					subject,
+					BIS_EMAIL_LINKS.actionButton
+				);
+
+				// The CTA has to land on the parent product page with the
+				// variation pre-selected, so the shopper can buy the thing they
+				// signed up for instead of picking it out again.
+				const linkUrl = new URL( productLink );
+
+				expect(
+					linkUrl.searchParams.get( variableProduct.attributeSelect )
+				).toBe( outOfStockVariation.option );
+
+				linkUrl.searchParams.delete( 'utm_source' );
+				linkUrl.searchParams.delete( 'utm_medium' );
+				linkUrl.searchParams.delete( variableProduct.attributeSelect );
+				expect( linkUrl.toString() ).toBe(
+					new URL( variableProduct.permalink ).toString()
+				);
+
+				// Following it has to leave the variation picked, which is what
+				// makes the attribute in the URL worth carrying.
+				await page.goto( productLink );
+				await expect(
+					page.locator(
+						`.variations select[name="${ variableProduct.attributeSelect }"]`
+					)
+				).toHaveValue( outOfStockVariation.option );
+			} );
+
+			test( 'a parent opted out of signups renders no form on its product page', async ( {
+				page,
+				restApi,
+				variableProduct,
+			} ) => {
+				// Positive control: the form is there before the opt-out, so a
+				// product page that renders no form for an unrelated reason
+				// cannot pass the assertion below.
+				await page.goto( variableProduct.permalink );
+				await expect( bisFormLocator( page ) ).toHaveCount( 1 );
+
+				await setProductSignupsAllowed(
+					restApi,
+					variableProduct.id,
+					false
+				);
+
+				// `maybe_render_form()` reads the parent from `global $product`,
+				// so the parent's opt-out meta removes the form from the whole
+				// variable product page — there is nothing left to show for
+				// any variation.
+				await page.goto( variableProduct.permalink );
+				await expect( bisFormLocator( page ) ).toHaveCount( 0 );
+			} );
+		} );
+
+		test.describe( 'Double opt-in', () => {
+			test.beforeAll( async ( { baseURL } ) => {
+				await setBISOptions( request, baseURL!, {
+					allowSignups: true,
+					doubleOptIn: true,
+					requireAccount: false,
+				} );
+			} );
+
+			test( 'the verification email names the variation and lists its attributes', async ( {
+				page,
+				browser,
+				variableProduct,
+			} ) => {
+				const email = uniqueGuestEmail( 'bis-variation-verify' );
+				const { outOfStockVariation } = variableProduct;
+
+				await signUpAsGuest(
+					browser,
+					variableProduct.permalink,
+					email,
+					{
+						selectVariation: {
+							product: variableProduct,
+							variation: outOfStockVariation,
+						},
+					}
+				);
+
+				const subject = bisEmailSubject.verify(
+					outOfStockVariation.notificationName
+				);
+
+				await openEmailInMailLog( page, email, subject );
+
+				const emailBody = bisEmailBody( page );
+
+				await expect(
+					emailBody.locator( BIS_EMAIL_ELEMENTS.productTitle )
+				).toHaveText( outOfStockVariation.notificationName );
+
+				// The body carries the variation's own attributes, which a
+				// simple product's email has no equivalent of.
+				await expect(
+					emailBody.locator( BIS_EMAIL_ELEMENTS.productAttributes )
+				).toContainText( variableProduct.attributeLabel );
+				await expect(
+					emailBody.locator( BIS_EMAIL_ELEMENTS.productAttributes )
+				).toContainText( outOfStockVariation.option );
+			} );
+
+			test( 'an "Any" variation records the attribute value the shopper chose', async ( {
+				page,
+				browser,
+				anyAttributeVariableProduct,
+			} ) => {
+				const email = uniqueGuestEmail( 'bis-any-variation' );
+				const { outOfStockVariation } = anyAttributeVariableProduct;
+
+				await signUpAsGuest(
+					browser,
+					anyAttributeVariableProduct.permalink,
+					email,
+					{
+						selectVariation: {
+							product: anyAttributeVariableProduct,
+							variation: outOfStockVariation,
+						},
+					}
+				);
+
+				const subject = bisEmailSubject.verify(
+					outOfStockVariation.notificationName
+				);
+
+				await openEmailInMailLog( page, email, subject );
+
+				const emailBody = bisEmailBody( page );
+
+				await expect(
+					emailBody.locator( BIS_EMAIL_ELEMENTS.productTitle )
+				).toHaveText( anyAttributeVariableProduct.name );
+				await expect(
+					emailBody.locator( BIS_EMAIL_ELEMENTS.productAttributes )
+				).toContainText( anyAttributeVariableProduct.attributeLabel );
+				await expect(
+					emailBody.locator( BIS_EMAIL_ELEMENTS.productAttributes )
+				).toContainText( outOfStockVariation.option );
+			} );
+		} );
+	}
+);
diff --git a/plugins/woocommerce/tests/e2e/utils/back-in-stock-notifications.ts b/plugins/woocommerce/tests/e2e/utils/back-in-stock-notifications.ts
index df065be2cf3..bafb4e4f6d7 100644
--- a/plugins/woocommerce/tests/e2e/utils/back-in-stock-notifications.ts
+++ b/plugins/woocommerce/tests/e2e/utils/back-in-stock-notifications.ts
@@ -180,6 +180,225 @@ export async function createOutOfStockProduct(
 	};
 }

+/**
+ * A variation of an out-of-stock variable product created for a spec.
+ */
+export type BISVariation = {
+	id: number;
+	option: string;
+	/**
+	 * The name core interpolates into notices and email subjects for this
+	 * variation — `Notification::get_product_name()` returns the variation's
+	 * post title, which `WC_Product_Variation_Data_Store_CPT::generate_product_title()`
+	 * builds as the parent title plus an attribute suffix, or the bare parent
+	 * title when the variation has no attribute values of its own.
+	 *
+	 * The REST API's own `name` field is the formatted attribute list ("White"),
+	 * not this, so it cannot stand in for it.
+	 */
+	notificationName: string;
+};
+
+/**
+ * An out-of-stock variable product created for a spec.
+ */
+export type BISVariableProduct = BISProduct & {
+	/** Label of the (product-level) variation attribute, e.g. `Color`. */
+	attributeLabel: string;
+	/** Name of the front-end variation `select`, e.g. `attribute_color`. */
+	attributeSelect: string;
+	/** Only set for the two-variation fixture. */
+	inStockVariation?: BISVariation;
+	outOfStockVariation: BISVariation;
+};
+
+/**
+ * Label of the variation attribute used by the variable-product fixtures.
+ *
+ * A product-level (not global) attribute, so parallel workers can reuse the
+ * same label without colliding on a shared taxonomy term.
+ *
+ * Keep it single-word ASCII: `attributeSelect` lowercases it to build the
+ * select name, which only matches core's `sanitize_title()` for such labels.
+ */
+const BIS_VARIATION_ATTRIBUTE = 'Color';
+
+/**
+ * Return a handle to a variable product with one in-stock and one out-of-stock variation.
+ *
+ * Deletion is not the caller's job: the fixtures below queue the parent id for
+ * the worker-scoped batch in `reapProducts()`, and deleting the parent takes
+ * its variations with it.
+ *
+ * @param {ApiClient} restApi             WP REST client.
+ * @param {Object}    [opts]              Creation options.
+ * @param {boolean}   [opts.anyAttribute] Create a single attribute-less variation.
+ */
+export async function createOutOfStockVariableProduct(
+	restApi: ApiClient,
+	opts: { anyAttribute?: boolean } = {}
+): Promise< BISVariableProduct > {
+	const name = `BIS Test Variable Product ${ Date.now() }-${ Math.floor(
+		Math.random() * 1e6
+	) }`;
+
+	const { data: product } = await restApi.post< {
+		id: number;
+		name: string;
+		permalink: string;
+	} >( `${ WC_API_PATH }/products`, {
+		name,
+		type: 'variable',
+		attributes: [
+			{
+				name: BIS_VARIATION_ATTRIBUTE,
+				visible: true,
+				variation: true,
+				options: [ 'Blue', 'White' ],
+			},
+		],
+	} );
+
+	// One request for both variations: the fixture runs before every test in
+	// these specs, so a second round trip here is per-test overhead.
+	const create = opts.anyAttribute
+		? [
+				{
+					regular_price: '9.99',
+					manage_stock: false,
+					stock_status: 'outofstock',
+					// The REST controller stores an empty `option` as an empty
+					// `attribute_color` meta value, the same shape the admin
+					// writes. Omitting the attribute entirely would store no
+					// meta row at all, which `find_matching_product_variation()`
+					// cannot match.
+					attributes: [
+						{ name: BIS_VARIATION_ATTRIBUTE, option: '' },
+					],
+				},
+		  ]
+		: [
+				{
+					regular_price: '9.99',
+					manage_stock: false,
+					stock_status: 'instock',
+					attributes: [
+						{ name: BIS_VARIATION_ATTRIBUTE, option: 'Blue' },
+					],
+				},
+				{
+					regular_price: '9.99',
+					manage_stock: false,
+					stock_status: 'outofstock',
+					attributes: [
+						{ name: BIS_VARIATION_ATTRIBUTE, option: 'White' },
+					],
+				},
+		  ];
+
+	const { data: batch } = await restApi.post< {
+		create: Array< { id: number; stock_status: string } >;
+	} >( `${ WC_API_PATH }/products/${ product.id }/variations/batch`, {
+		create,
+	} );
+
+	const created = batch.create;
+
+	// A short batch would otherwise surface as an opaque "cannot read
+	// properties of undefined" from the variation lookups below.
+	expect( created ).toHaveLength( create.length );
+
+	// A variation that came back with the stock status we didn't ask for would
+	// otherwise surface much later, as a form that never appears or an email
+	// that never arrives.
+	created.forEach( ( variation: { stock_status: string }, index: number ) => {
+		expect( variation.stock_status ).toBe( create[ index ].stock_status );
+	} );
+
+	const notificationName = ( option: string ): string =>
+		opts.anyAttribute ? product.name : `${ product.name } - ${ option }`;
+
+	const outOfStock = {
+		id: created[ opts.anyAttribute ? 0 : 1 ].id,
+		option: 'White',
+		notificationName: notificationName( 'White' ),
+	};
+	const inStock = opts.anyAttribute
+		? undefined
+		: {
+				id: created[ 0 ].id,
+				option: 'Blue',
+				notificationName: notificationName( 'Blue' ),
+		  };
+
+	return {
+		id: product.id,
+		name: product.name,
+		permalink: product.permalink,
+		attributeLabel: BIS_VARIATION_ATTRIBUTE,
+		attributeSelect: `attribute_${ BIS_VARIATION_ATTRIBUTE.toLowerCase() }`,
+		inStockVariation: inStock,
+		outOfStockVariation: outOfStock,
+	};
+}
+
+/**
+ * Per-product meta that opts a product out of stock notification signups.
+ *
+ * Set on the parent product: `EligibilityService::product_allows_signups()`
+ * resolves a variation by recursing up to its parent, so a variation has no
+ * opt-out of its own.
+ *
+ * @see Config::get_product_signups_meta_key()
+ */
+export const BIS_PRODUCT_SIGNUPS_META =
+	'customer_stock_notifications_enable_signups';
+
+/**
+ * Opt a product out of (or back into) stock notification signups.
+ *
+ * @param {ApiClient} restApi   WP REST client.
+ * @param {number}    productId Product id.
+ * @param {boolean}   allowed   Whether signups are allowed for the product.
+ */
+export async function setProductSignupsAllowed(
+	restApi: ApiClient,
+	productId: number,
+	allowed: boolean
+): Promise< void > {
+	await restApi.put( `${ WC_API_PATH }/products/${ productId }`, {
+		meta_data: [
+			{
+				key: BIS_PRODUCT_SIGNUPS_META,
+				value: allowed ? 'yes' : 'no',
+			},
+		],
+	} );
+}
+
+/**
+ * Restock a single variation via REST, leaving the rest of the product untouched.
+ *
+ * @param {ApiClient} restApi     WP REST client.
+ * @param {number}    productId   Parent product id.
+ * @param {number}    variationId Variation id.
+ */
+export async function restockVariation(
+	restApi: ApiClient,
+	productId: number,
+	variationId: number
+): Promise< void > {
+	const response = await restApi.put< { stock_status: string } >(
+		`${ WC_API_PATH }/products/${ productId }/variations/${ variationId }`,
+		{
+			stock_status: 'instock',
+			manage_stock: false,
+		}
+	);
+
+	expect( response.data.stock_status ).toBe( 'instock' );
+}
+
 /**
  * Restock a product via REST (used by receiving-notifications.spec.ts to trigger the stock-sync action).
  *
@@ -203,6 +422,62 @@ export async function restockProduct(
 	expect( response.data.stock_status ).toBe( 'instock' );
 }

+/**
+ * Locator for the PDP sign-up form wrapper.
+ *
+ * The wrapper is rendered whenever the product allows signups, and core's
+ * `back-in-stock-form.js` toggles its `hidden` class from the `show_variation`
+ * event — the class the variation specs assert on. A product whose parent opts
+ * out of signups renders no wrapper at all, so that case is asserted on
+ * presence instead.
+ *
+ * @param {Page} page Playwright page on the product detail.
+ */
+export function bisFormLocator( page: Page ) {
+	return page.locator( '.wc_bis_form' );
+}
+
+/**
+ * Locator for the hidden input carrying the product the sign-up targets.
+ *
+ * Starts out holding the parent id and is swapped to the variation id by
+ * `found_variation`, so it is the assertion that the form targets the variation
+ * the shopper picked rather than the product they landed on.
+ *
+ * @param {Page} page Playwright page on the product detail.
+ */
+export function bisTargetProductInput( page: Page ) {
+	return page.locator( 'input[name="wc_bis_product_id"]' );
+}
+
+/**
+ * Pick a variation on a variable product page and wait for core's variation AJAX to settle.
+ *
+ * The BIS form only reacts once WooCommerce has fetched the variation and fired
+ * `found_variation`, so the wait is on core's own hidden `variation_id` input.
+ * `.single_variation_wrap` cannot stand in for it — `VariationForm` shows that
+ * wrapper at init, before any variation is picked — and waiting on core's state
+ * rather than on `.wc_bis_form` keeps the helper usable in the tests that
+ * assert the form's own visibility or absence.
+ *
+ * @param {Page}   page      Playwright page on the product detail.
+ * @param {Object} product   The variable product handle.
+ * @param {Object} variation The variation to select.
+ */
+export async function selectVariation(
+	page: Page,
+	product: BISVariableProduct,
+	variation: BISVariation
+): Promise< void > {
+	await page
+		.locator( `.variations select[name="${ product.attributeSelect }"]` )
+		.selectOption( variation.option );
+
+	await expect( page.locator( 'input[name="variation_id"]' ) ).toHaveValue(
+		String( variation.id )
+	);
+}
+
 /**
  * Submit the PDP sign-up form. Caller must already have the product page loaded.
  *
@@ -230,20 +505,39 @@ export async function signUpOnProductPage(
 /**
  * Submit the PDP signup form as a logged-out guest, regardless of the test's storageState.
  *
- * @param {Browser} browser   The test's browser fixture.
- * @param {string}  permalink The product permalink.
- * @param {string}  email     The guest's email address.
+ * @param {Browser} browser                          The test's browser fixture.
+ * @param {string}  permalink                        The product permalink.
+ * @param {string}  email                            The guest's email address.
+ * @param {Object}  [opts]                           Signup options.
+ * @param {Object}  [opts.selectVariation]           Variation to pick before submitting, for variable products.
+ * @param {Object}  [opts.selectVariation.product]   The variable product handle.
+ * @param {Object}  [opts.selectVariation.variation] The variation to select.
  */
 export async function signUpAsGuest(
 	browser: Browser,
 	permalink: string,
-	email: string
+	email: string,
+	opts: {
+		selectVariation?: {
+			product: BISVariableProduct;
+			variation: BISVariation;
+		};
+	} = {}
 ): Promise< void > {
 	const guestContext = await browser.newContext( {
 		storageState: { cookies: [], origins: [] },
 	} );
 	const guestPage = await guestContext.newPage();
 	await guestPage.goto( permalink );
+
+	if ( opts.selectVariation ) {
+		await selectVariation(
+			guestPage,
+			opts.selectVariation.product,
+			opts.selectVariation.variation
+		);
+	}
+
 	await signUpOnProductPage( guestPage, { email } );

 	// The form posts and reloads the PDP with a notice. Wait for that notice
@@ -304,7 +598,11 @@ async function reapProducts(): Promise< void > {
  * Shared fixtures for the Back in Stock Notifications specs.
  */
 export const test = baseTest.extend<
-	{ product: BISProduct },
+	{
+		product: BISProduct;
+		variableProduct: BISVariableProduct;
+		anyAttributeVariableProduct: BISVariableProduct;
+	},
 	{ bisEnvReady: void }
 >( {
 	/**
@@ -331,6 +629,32 @@ export const test = baseTest.extend<
 		await use( product );
 		productsToReap.push( product.id );
 	},
+
+	/**
+	 * A variable product with one in-stock and one out-of-stock variation.
+	 *
+	 * Fixtures are lazy, so a spec that never references this pays nothing for it.
+	 */
+	variableProduct: async ( { restApi }, use ) => {
+		const product = await createOutOfStockVariableProduct( restApi );
+		// eslint-disable-next-line react-hooks/rules-of-hooks -- Playwright's fixture `use`, not a React hook.
+		await use( product );
+		// Deleting the parent takes its variations with it, so the worker-scoped
+		// batch still needs only the one id.
+		productsToReap.push( product.id );
+	},
+
+	/**
+	 * A variable product with a single attribute-less out-of-stock variation.
+	 */
+	anyAttributeVariableProduct: async ( { restApi }, use ) => {
+		const product = await createOutOfStockVariableProduct( restApi, {
+			anyAttribute: true,
+		} );
+		// eslint-disable-next-line react-hooks/rules-of-hooks -- Playwright's fixture `use`, not a React hook.
+		await use( product );
+		productsToReap.push( product.id );
+	},
 } );

 /**
@@ -350,12 +674,35 @@ export const BIS_EMAIL_LINKS = {
 	unsubscribe: '#notification__unsubscribe_link',
 } as const;

+/**
+ * Element ids rendered inside the Back in Stock Notifications email templates.
+ *
+ * @see EmailTemplatesController::register_template_hooks()
+ */
+export const BIS_EMAIL_ELEMENTS = {
+	productTitle: '#notification__product__title',
+	// Only rendered when the notification has a variation attribute list, so
+	// absent from a simple product's email.
+	productAttributes: '#notification__product__attributes',
+} as const;
+
+/**
+ * Frame locator for the email body inside an open WP Mail Logging modal.
+ *
+ * @param {Page} page Playwright page with the mail-log modal open.
+ */
+export function bisEmailBody( page: Page ) {
+	return page.frameLocator(
+		'#wp-mail-logging-modal-content-body-content iframe'
+	);
+}
+
 /**
  * Escape a string for literal use inside a regular expression.
  *
  * @param {string} value The string to escape.
  */
-function escapeRegExp( value: string ): string {
+export function escapeRegExp( value: string ): string {
 	return value.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
 }

@@ -408,7 +755,7 @@ export const bisEmailSubject = {
  * @param {RegExp} subject              The email subject (regular expression).
  * @param {number} [expectedCount]      Expected number of matching rows. Defaults to 1.
  */
-async function openEmailInMailLog(
+export async function openEmailInMailLog(
 	page: Page,
 	receiverEmailAddress: string,
 	subject: RegExp,
@@ -476,10 +823,7 @@ export async function getEmailLinkById(
 		expectedCount
 	);

-	const iframe = page.frameLocator(
-		'#wp-mail-logging-modal-content-body-content iframe'
-	);
-	const anchor = iframe.locator( `a${ anchorId }` ).first();
+	const anchor = bisEmailBody( page ).locator( `a${ anchorId }` ).first();
 	await anchor.waitFor( { state: 'attached' } );

 	const href = await anchor.getAttribute( 'href' );