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' );