Commit 57c1e18b420 for woocommerce

commit 57c1e18b420a3c74274300dcc42ad22c29e77840
Author: Thomas Roberts <5656702+opr@users.noreply.github.com>
Date:   Thu Aug 27 11:14:34 2026 +0100

    Add Playwright e2e coverage for BIS alpha (#64482)

    * Move BIS activation from wp-config flag to a Features setting

    The Back in Stock Notifications alpha was gated behind a
    WOOCOMMERCE_BIS_ALPHA_ENABLED wp-config constant, which means store
    owners can only opt in by editing wp-config.php — a non-starter for
    the wider alpha audience we want to reach. Move the gate to a normal
    WooCommerce feature toggle so it shows up in the standard place
    (WooCommerce → Settings → Advanced → Features) and merchants can flip
    it on/off from wp-admin.

    - Register a `customer_stock_notifications` feature in
      FeaturesController, marked experimental + alpha in the description so
      the feature card sits under the Experimental heading and the wording
      is unambiguous about API/data stability.
    - Replace the constant check in `WooCommerce::init_classes` with
      `FeaturesUtil::feature_is_enabled( 'customer_stock_notifications' )`,
      and drop the now-unused `Constants` import from class-woocommerce.php.
    - Drop the schema gate in `StockNotificationsDataStore::get_database_schema`
      so the BIS tables are always created at install/upgrade time.
      Otherwise enabling the feature mid-life would leave a no-op feature
      with no tables to write to until the next plugin upgrade. Empty tables
      are cheap; the feature flag now governs UI/behavior only.
    - Update the legacy test bootstrap to set
      `woocommerce_feature_customer_stock_notifications_enabled = yes`
      instead of defining the now-removed constant.

    Stores currently relying on the constant will need to re-enable the
    feature once via the Features screen after upgrading. Acceptable
    because the audience is alpha-flagged users only and the upgrade
    notice can mention this.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    * Drop alpha wording from BIS feature card

    The "(alpha)" suffix and "Alpha — interfaces, data, and behavior may
    change" caveat are coming off — present in the original draft but not
    needed on the merchant-facing card.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    * Add RSM-437 Playwright e2e coverage design doc

    * Add Playwright e2e coverage for Back in Stock Notifications alpha

    Covers four of the seven scenarios from the original plugin test plan that
    have a target in the core alpha: signup form rendering + signup flow,
    double opt-in verify/confirmation email flow, back-in-stock email dispatch
    on restock, and admin list + resend/cancel management.

    Skipped scenarios — viewing-signups-count, viewing-account-activity,
    following-catalog-sign-up-prompts — target features that didn't survive
    PRD cuts into core. A README in the test directory points at the feature
    tickets so they're trivially picked up once those features ship.

    The approach inverts the closed PRs #53641 / #55836: we treat the seven
    old specs as a coverage checklist, not a code source. No BDD DSL port;
    specs call a small focused `utils/back-in-stock-notifications.ts` helper
    layer + the existing `expectEmail` / `setOption` / `process-waiting-actions`
    infrastructure that already ships in `tests/e2e-pw`.

    * Make BIS Playwright specs pass against wp-env

    Fixup commit after running the new specs locally. Four concrete issues
    the green specs needed:

    - setOption/deleteOption expect the module-level @playwright/test request
      object (has .newContext()); switching to the per-test request fixture
      (APIRequestContext) doesn't have that method. Import `request` from
      fixtures/fixtures and use it for options helpers.
    - Guest-browser contexts spawned via `browser.newContext()` inherit
      browser-level auth state, so signup-as-guest flows ended up authenticated
      as admin and the email field was hidden. Explicitly passing
      `storageState: { cookies: [], origins: [] }` forces a truly empty context.
    - Mail-log assertions need admin credentials; specs that combine a guest
      signup with a mail-log read now run under ADMIN_STATE_PATH and spawn a
      guest context just for the signup submission.
    - The back-in-stock batch job has a 1-minute delay via
      `woocommerce_customer_stock_notifications_first_batch_delay`; a new
      mu-plugin (`bis-test-helpers.php`) zeroes it out under tests so
      `?process-waiting-actions` drains the job immediately.

    Also adjusts the "admin Cancel" test to create an ACTIVE notification
    (Cancel is not in the actions dropdown for PENDING rows in the core
    admin template), and hardens `getLinkFromEmailBody` to wait for the
    WP Mail Logging modal iframe before scanning anchors.

    Result: 15/15 BIS specs passing locally (`WP_ENV_TESTS_PORT=8899
    npx playwright test … tests/back-in-stock-notifications/`).

    * Fix JS lint errors in BIS Playwright helpers and specs

    Prettier requires the @woocommerce/e2e-utils-playwright import on one line; JSDoc requires documenting destructured option members explicitly and using 'Object' (not 'object') in @param types; nested ternary in the toYesNo helper is replaced with an early-return. Guest signup helpers now take the test's browser fixture directly instead of reaching through page.context().browser()! with a non-null assertion.

    * Collapse signing-up.spec import to single line for prettier

    Branch-level lint (`pnpm lint:changes:branch`) treats this destructured
    import as a prettier error while per-file lint did not surface it.

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

    * Add strict_types declaration to bis-test-helpers plugin

    Caught by branch-level phpcs-changed (Generic.PHP.RequireStrictTypes).

    * Move strict_types declaration after the plugin docblock

    PSR12.Files.FileHeader.IncorrectOrder requires the file-level docblock
    to directly follow the opening PHP tag; declare statements come after.

    * Set up Back in Stock Notifications e2e env

    Activate the bis-test-helpers utility plugin and enable the
    `customer_stock_notifications` feature option so the BIS specs run
    against a fully configured environment.

    Without the helpers plugin, the receiving-notifications specs fail in CI
    — the filter that zeroes the first-batch delay never registers, so the
    back-in-stock email never dispatches before the test times out. Local
    runs pass because the plugin tends to already be active there.

    The feature option replaces the old `WOOCOMMERCE_BIS_ALPHA_ENABLED`
    constant gate (now removed in favour of the WooCommerce → Settings →
    Advanced → Features → Experimental toggle).

    * Tighten admin resend verification test

    The test's only post-click assertion was the "Verification email sent"
    notice — which is the exact deferred bug where the success notice shows
    without a real dispatch. Adds a mail-log count assertion so the test
    fails if dispatch regresses, and pins the submit button to the exact
    "Update" label (the loose /Update|Save|Apply/i regex would silently
    drift if multiple controls matched).

    * Wait for first signup to persist before repeating in dedupe test

    signUpOnProductPage ends on .click(); if the form posts via AJAX the
    follow-up page.goto() can race the server before the first submission
    persisted, flaking the "already joined" assertion.

    * Clear notices in NotificationManagementServiceTests setUp

    The strengthened invalid-nonce test asserts wc_get_notices() is empty
    after the SUT call, but prior tests in the suite can leak notices into
    the static cache. Clear on every setUp so the assertion measures only
    what this test's code path actually queues.

    * Scroll Update button into view before clicking in admin spec

    On narrow CI viewports the product-thumbnail overlay can intercept the
    click. The assertion passes today, but CodeRabbit flagged this as a
    latent intercept risk on the admin edit page — scrolling the submit
    into view before the click removes the race.

    * Re-trigger CI after rsm-438 alignment fix

    * Address CodeRabbit nits on BIS e2e helpers

    1. back-in-stock-notifications.ts createOutOfStockProduct(): append a
       random suffix to the product name so parallel Playwright workers
       don't collide on the same `${namePrefix} ${Date.now()}` string
       (two workers ticking in the same millisecond would both create a
       'BIS Test Product 1234567890' and break row-scoped selectors).

    2. back-in-stock-notifications.ts getLinkFromEmailBody(): when
       reconstructing the hrefPattern RegExp inside the iframe evaluator,
       pass both .source and .flags through so flags like /i are preserved
       on the recreated regex instead of being silently dropped.

    3. managing-notifications.spec.ts: the three admin-list-table tests
       used .first() on the row locator, which would silently mask duplicate
       rows for the same email/product from a parallel-worker bleed. Switch
       to await expect(row).toHaveCount(1) so any cross-test bleed surfaces
       explicitly instead of getting hidden.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    * Fix lint + markdown regressions on rsm-437

    My previous nit-followup commit (35b8c4cee7) left tab/newline indent
    mismatches that prettier's branch lint caught — 13 errors across the
    managing-notifications spec and the utils file — which broke the Lint
    job and then all Core e2e shards downstream.

    Also: the sprint scratch docs (CODERABBIT-TRIAGE*, SPRINT.md,
    RSM-437-DESIGN.md, RSM-438-DESIGN.md, RSM-438-TESTING.md) were still
    present in plugins/woocommerce/src/Internal/StockNotifications on this
    branch, which tripped Validate markdown and was already flagged by
    CodeRabbit on sibling PRs. Delete them and drop the stale
    .markdownlintignore entry that was pointing at the now-gone
    CODERABBIT-TRIAGE-RAW.md.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    * Fix SyntaxError: shadowed 'row' const in managing-notifications spec

    My earlier toHaveCount uniqueness fix introduced `const row` at line
    212 inside the admin Cancel test block, but that same block already
    had a `const row` re-query at line 235 (for the post-cancel
    assertion). Two `const`s with the same name in the same scope is a
    SyntaxError, which tripped Playwright's JS loader and failed every
    Core e2e shard.

    Rename the second query to `refreshedRow` so the two row handles
    coexist.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    * Scope Edit locator to disambiguate title vs row-action link

    The WP list table title column renders as a link with aria-label="Edit
    <email>", which /Edit/i matched alongside the row-actions Edit link.
    Pin to { name: 'Edit', exact: true } so only the row-action link matches.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    * Scope Edit click to span.edit anchor instead of accessible name

    getByRole('link', {name: 'Edit', exact: true}) timed out on WP 6.8.5 —
    WordPress's WP_List_Table may decorate the row-actions link with a
    screen-reader suffix, making the accessible name drift from 'Edit'.
    WP's row_actions() markup, however, is stable: each action is wrapped
    in span.<action-slug> containing the anchor, so span.edit > a reliably
    targets the Edit row action without risk of matching the title column
    link (which sits outside .row-actions).

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    * Click title-column link instead of hidden row-actions Edit anchor

    WP admin's row-actions div renders with visibility:hidden until hover.
    Playwright's click waits for visibility, so span.edit > a timed out on
    every attempt. The .row-title anchor in the same row is always visible
    and points to the same edit URL, so use that instead.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    * Factor Update-button helper and poll mail-log assertion

    - Extract the shared 'scroll + click Update button' pattern into
      submitNotificationEditForm(page) so the Resend and Cancel tests stay
      in sync if CI viewport quirks change again.
    - Wrap the mail-log count assertion in expect(...).toPass() so the
      check reloads the page until the second verify email lands (the
      dispatch is async via Action Scheduler and the previous single-shot
      assertion raced the reload).
    - Drop the redundant exact:true on the regex-based name matcher —
      Playwright's getByRole only applies exact to string names.

    Addresses CodeRabbit feedback on PR #64352.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    * Fix prettier: hoist Page type import instead of inline import()

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    * Consolidate rsm-437 changelogs into a single feature entry

    The CR-nit-followups and prettier-scratch-cleanup entries are internal
    follow-ups that fold naturally into the main feature changelog. Keep
    only `rsm-437-bis-playwright-coverage` and delete the other two so the
    feature ships as one line in the changelog.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    * Force-click the BIS Update button in admin tests for WP 7.0-RC2 compat

    * Wait for success notice before navigating away from cancel form

    `submitNotificationEditForm` only awaits the click event, not the
    resulting POST round-trip, so on slower CI runners the next `page.goto()`
    was racing the submit and the row was still ACTIVE by the time the list
    view loaded.

    Wait on the "Notification updated." admin notice — the definitive signal
    that the POST landed and was processed — instead of looking for
    "Cancelled" text on the edit page, which is a less reliable proxy.

    * Constrain product thumbnail size on BIS notification edit page

    The image inside `.notification-data__product-data` rendered at its
    intrinsic size (~768px for the placeholder) and overflowed the column,
    which on narrow viewports overlapped the Update button and caused
    Playwright's actionability check to time out.

    * Drop force-click on BIS Update button

    The actionability check now passes naturally — the giant unconstrained
    product thumbnail that was overlapping the button is fixed in the
    preceding commit, so the workaround is no longer needed.

    * test: run back-in-stock-notifications e2e specs serially

    * test: make BIS e2e email assertions able to actually fail

    * test: dedupe BIS e2e helpers, harden mail lookup and env guards

    * test: fix BIS e2e env guards and expectEmail return type

    * test: tighten BIS e2e guest signup, env guards, and subject matchers

    * test: cut BIS e2e option churn and batch product cleanup

    * docs: tighten BIS e2e changelog entry

    * test: group BIS admin specs by opt-in mode to stop global state leaking

    * test: share expectEmail polling with BIS mail-log helpers

    * test: fold BIS e2e helper plugin into the shared test helper

    * test: drop dead View log click and fix serial-run comment

    * fix: size stock notification edit screen product image to its column

    * test: stop BIS specs passing on absent markup or dead env

    * test: make expectEmail poll retry and wait for admin action select

    * test: assert BIS email link params by value instead of substring

    * test: drop no-op exact flag from expectEmail subject matcher

    ---------

    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    Co-authored-by: Chris Lilitsas <1105590+xristos3490@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/rsm-437-bis-notification-image-overflow b/plugins/woocommerce/changelog/rsm-437-bis-notification-image-overflow
new file mode 100644
index 00000000000..8f9ecc67bbb
--- /dev/null
+++ b/plugins/woocommerce/changelog/rsm-437-bis-notification-image-overflow
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Constrain the product image on the stock notification edit screen so it no longer overflows its column and overlaps the Update button.
diff --git a/plugins/woocommerce/changelog/rsm-437-bis-playwright-coverage b/plugins/woocommerce/changelog/rsm-437-bis-playwright-coverage
new file mode 100644
index 00000000000..d311afbcafb
--- /dev/null
+++ b/plugins/woocommerce/changelog/rsm-437-bis-playwright-coverage
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Add Playwright e2e coverage for the Back in Stock Notifications signup, email, and admin management flows.
diff --git a/plugins/woocommerce/client/legacy/css/admin.scss b/plugins/woocommerce/client/legacy/css/admin.scss
index 393c08311b8..b43282228b4 100644
--- a/plugins/woocommerce/client/legacy/css/admin.scss
+++ b/plugins/woocommerce/client/legacy/css/admin.scss
@@ -9936,6 +9936,13 @@ body.woocommerce_page_wc-settings {
 		margin-bottom: 15px;
 	}

+	&__product-data {
+		img {
+			max-width: 100%;
+			height: auto;
+		}
+	}
+
 	&__form-field {
 		clear: both;

diff --git a/plugins/woocommerce/tests/e2e/README.md b/plugins/woocommerce/tests/e2e/README.md
index f8d8de83eba..ba2605c6058 100644
--- a/plugins/woocommerce/tests/e2e/README.md
+++ b/plugins/woocommerce/tests/e2e/README.md
@@ -180,10 +180,11 @@ Keep `Requires PHP` at the **lowest PHP version any E2E environment runs** (curr
 How a helper is wired up depends on when it needs to be active:

 - **Always-on helpers** are listed in `.wp-env.e2e.json`'s `plugins` array, which mounts the folder **and auto-activates** it. Do not add a manual `wp plugin activate …` line for these. Current always-on helpers:
-    - `woocommerce-e2e-test-helper` — the general-purpose helper bundle, covering three concerns in one plugin:
+    - `woocommerce-e2e-test-helper` — the general-purpose helper bundle, covering four concerns in one plugin:
         - **Filter setter** — registers WordPress filters from an `e2e-filters` cookie so tests can override filtered values on the fly.
         - **Process waiting actions** — runs the Action Scheduler queue synchronously when a request carries the `?process-waiting-actions` query param (used by the analytics suite so order data lands in reports immediately).
         - **Test helper REST API** — endpoints (`e2e-feature-flags`, `e2e-options`, `e2e-environment`, `e2e-theme`) for toggling feature flags, setting/deleting options, reading environment info and switching themes during a test.
+        - **Timing overrides** — fixed filters removing production delays and throttles that only slow tests down or make them flaky: WordPress' comment flood protection, and the 1-minute wait before the first Back in Stock Notifications batch. Unconditional rather than cookie-driven, because they must also apply to REST requests made outside the browser.
     - `wc-email-template-sync-test-helper` — see below (email template sync fixtures for RSM-146).
 - **Per-test block plugins** live in `tests/e2e/test-plugins/blocks/`, mounted (not auto-activated) via the `woocommerce-blocks-test-plugins` mapping. Each is activated and deactivated by the spec that needs it (e.g. `wp plugin activate woocommerce-blocks-test-plugins/<file>.php`), because they change store behavior globally and must not be on for every test.

diff --git a/plugins/woocommerce/tests/e2e/bin/test-env-setup.sh b/plugins/woocommerce/tests/e2e/bin/test-env-setup.sh
index dcf111ac575..18928818260 100755
--- a/plugins/woocommerce/tests/e2e/bin/test-env-setup.sh
+++ b/plugins/woocommerce/tests/e2e/bin/test-env-setup.sh
@@ -50,6 +50,9 @@ if ! $WP_CLI_PREFIX wp user get customer --field=ID >/dev/null 2>&1; then
 		--user_registered='2022-01-01 12:23:45'
 fi

+echo -e 'Enable Back in Stock Notifications feature \n'
+$WP_CLI_PREFIX wp option update woocommerce_feature_customer_stock_notifications_enabled 'yes'
+
 echo -e 'Update Blog Name \n'
 $WP_CLI_PREFIX wp option update blogname 'WooCommerce Core E2E Test Suite'

diff --git a/plugins/woocommerce/tests/e2e/playwright.config.ts b/plugins/woocommerce/tests/e2e/playwright.config.ts
index e01eed00df5..8a6c966bd74 100644
--- a/plugins/woocommerce/tests/e2e/playwright.config.ts
+++ b/plugins/woocommerce/tests/e2e/playwright.config.ts
@@ -131,6 +131,13 @@ const serialRunSpecs = [
 	// the order-import mode, and this serial job never runs concurrently with the
 	// parallel one.)
 	'**/tests/analytics/analytics-settings.spec.ts',
+	// Every spec sets the global `woocommerce_customer_stock_notifications_*`
+	// options in beforeAll (allow_signups / double_opt_in / require_account) and
+	// deletes them in afterAll. Run in parallel the files demand conflicting global
+	// config and race on those options: concurrent identical writes make
+	// `update_option` return false (`e2e-options/update` 400 "Update option FAILED"),
+	// and one file's afterAll strips the signup form mid-test for the others.
+	'**/tests/back-in-stock-notifications/**/*.spec.ts',
 	// Flips the global `woocommerce_default_customer_address` (geolocation) and
 	// `woocommerce_enable_ajax_add_to_cart` settings, which change add-to-cart
 	// behavior for every other worker. (`cart.spec.ts` runs in core-parallel — it
diff --git a/plugins/woocommerce/tests/e2e/test-plugins/woocommerce-e2e-test-helper/woocommerce-e2e-test-helper.php b/plugins/woocommerce/tests/e2e/test-plugins/woocommerce-e2e-test-helper/woocommerce-e2e-test-helper.php
index 875e1e04a48..085fe79d6f4 100644
--- a/plugins/woocommerce/tests/e2e/test-plugins/woocommerce-e2e-test-helper/woocommerce-e2e-test-helper.php
+++ b/plugins/woocommerce/tests/e2e/test-plugins/woocommerce-e2e-test-helper/woocommerce-e2e-test-helper.php
@@ -1,15 +1,16 @@
 <?php
 /**
  * Plugin Name: WooCommerce E2E Test Helper
- * Description: Always-on utilities for the WooCommerce E2E suite: cookie-driven filter overrides, synchronous Action Scheduler processing, and a REST API for feature flags, options, environment info and theme switching.
+ * Description: Always-on utilities for the WooCommerce E2E suite: cookie-driven filter overrides, synchronous Action Scheduler processing, a REST API for feature flags, options, environment info and theme switching, and fixed overrides that remove production timing delays.
  * Version: 1.0.0
  * Requires PHP: 7.4
  * Author: WooCommerce
  *
  * This bundles three previously separate helpers (filter-setter, process-waiting-actions and
- * test-helper-apis). They share the same lifecycle — mounted and auto-activated for every E2E run
- * via the .wp-env.e2e.json "plugins" array — so they live together here. Each concern is kept in its
- * own section below and none of them touch the others.
+ * test-helper-apis), plus the fixed overrides that remove production timing delays. They share the
+ * same lifecycle — mounted and auto-activated for every E2E run via the .wp-env.e2e.json "plugins"
+ * array — so they live together here. Each concern is kept in its own section below and none of them
+ * touch the others.
  *
  * It hopefully goes without saying, none of this should ever run in a production environment.
  *
@@ -230,18 +231,6 @@ function enable_experimental_features( $features ) {

 add_filter( 'woocommerce_admin_get_feature_config', 'enable_experimental_features' );

-/**
- * Disable WordPress comment flood protection during E2E runs.
- *
- * Parallel specs post comments and reviews as the shared customer account.
- * WordPress' 15-second flood throttle ("You are posting comments too quickly")
- * then rejects whichever request lands second, causing cross-spec flakes that
- * have nothing to do with the behaviour under test. Override core's
- * `wp_throttle_comment_flood` (priority 10) with a later filter that always
- * allows the comment.
- */
-add_filter( 'comment_flood_filter', '__return_false', 99 );
-
 /**
  * Update a WordPress option.
  *
@@ -333,3 +322,40 @@ function activate_theme( WP_REST_Request $request ) {
 		return new WP_REST_Response( array( 'message' => "Theme '$theme_name' does not exist." ), 400 );
 	}
 }
+
+/*
+ * -----------------------------------------------------------------------------
+ * Timing overrides
+ * -----------------------------------------------------------------------------
+ *
+ * Fixed filters that strip out delays and throttles which exist for production traffic but only
+ * make E2E runs slow or flaky. Unconditional by design: unlike the cookie-driven filter setter
+ * above, these must apply to requests a spec does not drive through the browser, such as REST calls
+ * made by the API client.
+ */
+
+/**
+ * Disable WordPress comment flood protection during E2E runs.
+ *
+ * Parallel specs post comments and reviews as the shared customer account.
+ * WordPress' 15-second flood throttle ("You are posting comments too quickly")
+ * then rejects whichever request lands second, causing cross-spec flakes that
+ * have nothing to do with the behaviour under test. Override core's
+ * `wp_throttle_comment_flood` (priority 10) with a later filter that always
+ * allows the comment.
+ */
+add_filter( 'comment_flood_filter', '__return_false', 99 );
+
+/**
+ * Dispatch Back in Stock Notifications batches immediately after a restock.
+ *
+ * Core waits a minute between a product coming back in stock and the first
+ * notifications batch. The BIS specs restock over REST and then drain the queue
+ * with `?process-waiting-actions`, so without this the batch is not due yet and
+ * the back-in-stock email never arrives.
+ */
+add_filter(
+	'woocommerce_customer_stock_notifications_first_batch_delay',
+	'__return_zero',
+	PHP_INT_MAX
+);
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
new file mode 100644
index 00000000000..0815a600e68
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/README.md
@@ -0,0 +1,39 @@
+# Back in Stock Notifications — Playwright tests
+
+Covers the four 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.
+
+## Skipped scenarios
+
+Three scenarios from the original plugin test plan target features that didn't
+survive PRD cuts into the core alpha. They'll be picked up alongside the
+respective feature tickets:
+
+- **Viewing signups count** — no per-product signup counter exists on the PDP in
+  core. Expected to land alongside [RSM-439](https://linear.app/a8c/issue/RSM-439)
+  (Data tracking / analytics).
+- **Viewing account activity** — the `stock-notifications` my-account endpoint
+  was scope-cut (WOOPLUG-4997). Will be added once a my-account follow-up
+  ticket ships.
+- **Following catalog sign-up prompts** — core BIS does not hook into the shop
+  loop. Will be added if/when a catalog-prompts ticket ships.
+
+## Prerequisites
+
+- BIS is gated by the `customer_stock_notifications` feature toggle (WooCommerce
+  → Settings → Advanced → Features → Experimental), enabled for the tests env
+  via `plugins/woocommerce/tests/e2e/bin/test-env-setup.sh`. If you bring
+  the env up manually, set `woocommerce_feature_customer_stock_notifications_enabled`
+  to `'yes'`.
+- The tests assume the WP Mail Logging plugin is installed and active (it is,
+  via the `.wp-env.e2e.json` plugins list).
+- `woocommerce-e2e-test-helper` zeroes
+  `woocommerce_customer_stock_notifications_first_batch_delay`, so a restock
+  dispatches its batch immediately instead of a minute later. Without it the
+  back-in-stock specs time out with no email.
+- Run these under `core-serial` (`--project=core-serial`). They set global
+  options, so `playwright.config.ts` excludes them from `core-parallel`.
diff --git a/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/managing-notifications.spec.ts b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/managing-notifications.spec.ts
new file mode 100644
index 00000000000..6202e9b1c11
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/managing-notifications.spec.ts
@@ -0,0 +1,226 @@
+/**
+ * External dependencies
+ */
+import type { Page } from '@playwright/test';
+
+/**
+ * Internal dependencies
+ */
+import { expect, request, tags } from '../../fixtures/fixtures';
+import { ADMIN_STATE_PATH } from '../../playwright.config';
+import {
+	bisAdminListUrl,
+	bisEmailSubject,
+	resetBISOptions,
+	setBISOptions,
+	signUpAsGuest,
+	test,
+	uniqueGuestEmail,
+} from '../../utils/back-in-stock-notifications';
+import { expectEmail } from '../../utils/email';
+
+/**
+ * Click the notification edit-form "Update" button.
+ *
+ * Scrolling into view first guards against narrow CI viewports where the
+ * button can sit just below the fold.
+ */
+async function submitNotificationEditForm( page: Page ): Promise< void > {
+	const updateButton = page.getByRole( 'button', {
+		name: 'Update',
+		exact: true,
+	} );
+	await updateButton.scrollIntoViewIfNeeded();
+	await updateButton.click();
+}
+
+test.describe(
+	'Back in Stock Notifications — admin management',
+	{ tag: [ tags.SERVICES ] },
+	() => {
+		test.use( { storageState: ADMIN_STATE_PATH } );
+
+		test.afterAll( async ( { baseURL } ) => {
+			await resetBISOptions( request, baseURL! );
+		} );
+
+		// Grouped by opt-in mode rather than flipping the option inside a test:
+		// these options are global, so a mid-test write leaks into every test
+		// declared after it.
+		test.describe( 'Double opt-in — signups land PENDING', () => {
+			test.beforeAll( async ( { baseURL } ) => {
+				await setBISOptions( request, baseURL!, {
+					allowSignups: true,
+					doubleOptIn: true,
+					requireAccount: false,
+				} );
+			} );
+
+			test( 'notifications list renders a signup row filtered by product', async ( {
+				page,
+				product,
+				browser,
+			} ) => {
+				const email = uniqueGuestEmail( 'bis-admin-list' );
+
+				await signUpAsGuest( browser, product.permalink, email );
+
+				await page.goto( bisAdminListUrl( product.id ) );
+				await expect(
+					page.getByRole( 'cell', { name: email, exact: true } )
+				).toBeVisible();
+			} );
+
+			test( 'Resend verification email on a pending notification dispatches a new verify email', async ( {
+				page,
+				product,
+				browser,
+			} ) => {
+				const email = uniqueGuestEmail( 'bis-admin-resend-pending' );
+
+				await signUpAsGuest( browser, product.permalink, email );
+
+				// Wait for the initial verify email so we can count a new one later.
+				await expectEmail(
+					page,
+					email,
+					bisEmailSubject.verify( product.name )
+				);
+
+				await page.goto( bisAdminListUrl( product.id ) );
+				// Surface cross-test bleed (duplicate rows for the same email/product)
+				// as an explicit failure instead of silently picking one via .first().
+				const row = page.getByRole( 'row' ).filter( {
+					has: page.getByText( email, { exact: true } ),
+				} );
+				await expect( row ).toHaveCount( 1 );
+				// Click the title-column link instead of the row-actions "Edit"
+				// anchor — the row-actions div is visibility:hidden until hover,
+				// and the title link leads to the same edit page.
+				await row.locator( 'a.row-title' ).click();
+
+				await page
+					.locator(
+						'select[name="wc_customer_stock_notification_action"]'
+					)
+					.selectOption( 'send_verification_email' );
+				await submitNotificationEditForm( page );
+
+				await expect(
+					page.getByText( `Verification email sent to "${ email }"` )
+				).toBeVisible();
+
+				// Assert a second verify email actually landed in the log — the
+				// admin success notice alone would pass even if dispatch regressed.
+				await expectEmail(
+					page,
+					email,
+					bisEmailSubject.verify( product.name ),
+					2
+				);
+			} );
+		} );
+
+		test.describe( 'Single opt-in — signups land ACTIVE', () => {
+			test.beforeAll( async ( { baseURL } ) => {
+				await setBISOptions( request, baseURL!, {
+					allowSignups: true,
+					doubleOptIn: false,
+					requireAccount: false,
+				} );
+			} );
+
+			test( 'Resend verification is not offered for notifications that are already active', async ( {
+				page,
+				product,
+				browser,
+			} ) => {
+				const email = uniqueGuestEmail( 'bis-admin-active' );
+
+				await signUpAsGuest( browser, product.permalink, email );
+
+				await page.goto( bisAdminListUrl( product.id ) );
+				const row = page.getByRole( 'row' ).filter( {
+					has: page.getByText( email, { exact: true } ),
+				} );
+				await expect( row ).toHaveCount( 1 );
+				// Click the title-column link instead of the row-actions "Edit"
+				// anchor — the row-actions div is visibility:hidden until hover,
+				// and the title link leads to the same edit page.
+				await row.locator( 'a.row-title' ).click();
+
+				const actionSelect = page.locator(
+					'select[name="wc_customer_stock_notification_action"]'
+				);
+
+				// `allTextContents()` does not auto-wait, so wait for the select
+				// itself first. Otherwise a slow edit page reads zero options and
+				// fails as a missing action rather than a timeout.
+				await expect( actionSelect ).toBeVisible();
+
+				const options = await actionSelect
+					.locator( 'option' )
+					.allTextContents();
+
+				// The select renders for every status, so an empty option list
+				// would make the check below pass without proving anything.
+				// Anchor on an ACTIVE-only action first.
+				expect(
+					options.some( ( text ) => /^Cancel$/i.test( text.trim() ) )
+				).toBe( true );
+
+				// UI-layer guard: the Resend action is not offered for non-pending rows.
+				expect(
+					options.some( ( text ) =>
+						/Resend verification email/i.test( text )
+					)
+				).toBe( false );
+			} );
+
+			// The Cancel action is only available on ACTIVE / SENT rows.
+			test( 'admin Cancel action marks an active notification as Cancelled', async ( {
+				page,
+				product,
+				browser,
+			} ) => {
+				const email = uniqueGuestEmail( 'bis-admin-cancel' );
+
+				await signUpAsGuest( browser, product.permalink, email );
+
+				await page.goto( bisAdminListUrl( product.id ) );
+				const row = page.getByRole( 'row' ).filter( {
+					has: page.getByText( email, { exact: true } ),
+				} );
+				await expect( row ).toHaveCount( 1 );
+				// Click the title-column link instead of the row-actions "Edit"
+				// anchor — the row-actions div is visibility:hidden until hover,
+				// and the title link leads to the same edit page.
+				await row.locator( 'a.row-title' ).click();
+
+				await page
+					.locator(
+						'select[name="wc_customer_stock_notification_action"]'
+					)
+					.selectOption( 'cancel_notification' );
+				await submitNotificationEditForm( page );
+
+				// Wait for the success notice before navigating away —
+				// `submitNotificationEditForm` only awaits the click event, not
+				// the resulting POST round-trip, so on slower CI runners the
+				// next `page.goto()` was racing the submit and the row was
+				// still ACTIVE by the time the list view loaded.
+				await expect(
+					page.getByText( 'Notification updated.' )
+				).toBeVisible();
+
+				await page.goto( bisAdminListUrl( product.id ) );
+				const refreshedRow = page.getByRole( 'row' ).filter( {
+					has: page.getByText( email, { exact: true } ),
+				} );
+				await expect(
+					refreshedRow.getByText( /Cancelled/i )
+				).toBeVisible();
+			} );
+		} );
+	}
+);
diff --git a/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/receiving-confirmations.spec.ts b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/receiving-confirmations.spec.ts
new file mode 100644
index 00000000000..6603bc64322
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/receiving-confirmations.spec.ts
@@ -0,0 +1,169 @@
+/**
+ * Internal dependencies
+ */
+import { expect, request, tags } from '../../fixtures/fixtures';
+import { ADMIN_STATE_PATH } from '../../playwright.config';
+import {
+	BIS_EMAIL_LINKS,
+	bisAdminListUrl,
+	bisEmailSubject,
+	getEmailLinkById,
+	resetBISOptions,
+	setBISOptions,
+	signUpAsGuest,
+	test,
+	uniqueGuestEmail,
+} from '../../utils/back-in-stock-notifications';
+import { expectEmail, expectEmailContent } from '../../utils/email';
+
+test.describe(
+	'Back in Stock Notifications — receiving confirmations',
+	{ tag: [ tags.SERVICES ] },
+	() => {
+		test.use( { storageState: ADMIN_STATE_PATH } );
+
+		test.beforeAll( async ( { baseURL } ) => {
+			await setBISOptions( request, baseURL!, {
+				allowSignups: true,
+				doubleOptIn: true,
+				requireAccount: false,
+			} );
+		} );
+
+		test.afterAll( async ( { baseURL } ) => {
+			await resetBISOptions( request, baseURL! );
+		} );
+
+		test( 'double-opt-in signup dispatches verify email with UTM params', async ( {
+			page,
+			product,
+			browser,
+		} ) => {
+			const email = uniqueGuestEmail( 'bis-confirm-verify' );
+
+			await signUpAsGuest( browser, product.permalink, email );
+
+			const emailRow = await expectEmail(
+				page,
+				email,
+				bisEmailSubject.verify( product.name )
+			);
+			await emailRow.getByRole( 'button', { name: 'View log' } ).click();
+
+			await expectEmailContent(
+				page,
+				email,
+				bisEmailSubject.verify( product.name ),
+				// Confirm button text is in the rendered email body.
+				/Confirm/
+			);
+
+			// Link is selected by its role in the template, then checked for the params it must carry.
+			const verifyLink = await getEmailLinkById(
+				page,
+				email,
+				bisEmailSubject.verify( product.name ),
+				BIS_EMAIL_LINKS.actionButton
+			);
+
+			// Read the params rather than substring-matching the URL: a
+			// present-but-empty key still satisfies `email_link_action_key=`.
+			const verifyUrl = new URL( verifyLink );
+
+			expect( verifyUrl.searchParams.get( 'email_link_action' ) ).toBe(
+				'verify'
+			);
+			expect(
+				verifyUrl.searchParams.get( 'email_link_action_key' )
+			).toBeTruthy();
+			expect( verifyUrl.searchParams.get( 'utm_source' ) ).toBe(
+				'back-in-stock-notifications'
+			);
+			expect( verifyUrl.searchParams.get( 'utm_medium' ) ).toBe(
+				'email'
+			);
+		} );
+
+		test( 'clicking the verify link dispatches the confirmation email with UTM on unsubscribe link', async ( {
+			page,
+			product,
+			browser,
+		} ) => {
+			const email = uniqueGuestEmail( 'bis-confirm-verified' );
+
+			await signUpAsGuest( browser, product.permalink, email );
+
+			const verifyLink = await getEmailLinkById(
+				page,
+				email,
+				bisEmailSubject.verify( product.name ),
+				BIS_EMAIL_LINKS.actionButton
+			);
+
+			await page.goto( verifyLink );
+
+			// Confirmation email should be dispatched after successful verification (RSM-438).
+			await expectEmail(
+				page,
+				email,
+				bisEmailSubject.verified( product.name )
+			);
+
+			const unsubscribeLink = await getEmailLinkById(
+				page,
+				email,
+				bisEmailSubject.verified( product.name ),
+				BIS_EMAIL_LINKS.unsubscribe
+			);
+
+			const unsubscribeUrl = new URL( unsubscribeLink );
+
+			expect(
+				unsubscribeUrl.searchParams.get( 'email_link_action' )
+			).toBe( 'unsubscribe' );
+			expect(
+				unsubscribeUrl.searchParams.get( 'email_link_action_key' )
+			).toBeTruthy();
+			expect( unsubscribeUrl.searchParams.get( 'utm_source' ) ).toBe(
+				'back-in-stock-notifications'
+			);
+			expect( unsubscribeUrl.searchParams.get( 'utm_medium' ) ).toBe(
+				'email'
+			);
+		} );
+
+		test( 'following the unsubscribe link cancels the notification', async ( {
+			page,
+			product,
+			browser,
+		} ) => {
+			const email = uniqueGuestEmail( 'bis-confirm-unsub' );
+
+			await signUpAsGuest( browser, product.permalink, email );
+
+			const verifyLink = await getEmailLinkById(
+				page,
+				email,
+				bisEmailSubject.verify( product.name ),
+				BIS_EMAIL_LINKS.actionButton
+			);
+			await page.goto( verifyLink );
+
+			const unsubscribeLink = await getEmailLinkById(
+				page,
+				email,
+				bisEmailSubject.verified( product.name ),
+				BIS_EMAIL_LINKS.unsubscribe
+			);
+			await page.goto( unsubscribeLink );
+
+			// Verify via the admin notifications list: the row should now show Cancelled.
+			await page.goto( bisAdminListUrl( product.id ) );
+			const row = page
+				.getByRole( 'row' )
+				.filter( { has: page.getByText( email, { exact: true } ) } );
+
+			await expect( row.getByText( /Cancelled/i ) ).toBeVisible();
+		} );
+	}
+);
diff --git a/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/receiving-notifications.spec.ts b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/receiving-notifications.spec.ts
new file mode 100644
index 00000000000..f382444971c
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/receiving-notifications.spec.ts
@@ -0,0 +1,121 @@
+/**
+ * Internal dependencies
+ */
+import { expect, request, tags } from '../../fixtures/fixtures';
+import { ADMIN_STATE_PATH } from '../../playwright.config';
+import {
+	BIS_EMAIL_LINKS,
+	bisAdminListUrl,
+	bisEmailSubject,
+	getEmailLinkById,
+	resetBISOptions,
+	restockProduct,
+	setBISOptions,
+	signUpAsGuest,
+	test,
+	triggerStockNotificationsBatch,
+	uniqueGuestEmail,
+} from '../../utils/back-in-stock-notifications';
+import { expectEmail } from '../../utils/email';
+
+test.describe(
+	'Back in Stock Notifications — receiving back-in-stock emails',
+	{ tag: [ tags.SERVICES ] },
+	() => {
+		test.use( { storageState: ADMIN_STATE_PATH } );
+
+		test.beforeAll( async ( { baseURL } ) => {
+			// Single opt-in so the notification becomes ACTIVE immediately
+			// (no verify step), which is what the back-in-stock dispatch needs.
+			await setBISOptions( request, baseURL!, {
+				allowSignups: true,
+				doubleOptIn: false,
+				requireAccount: false,
+			} );
+		} );
+
+		test.afterAll( async ( { baseURL } ) => {
+			await resetBISOptions( request, baseURL! );
+		} );
+
+		test( 'restocking a product dispatches the back-in-stock email with UTM params', async ( {
+			page,
+			product,
+			restApi,
+			browser,
+		} ) => {
+			const email = uniqueGuestEmail( 'bis-restock' );
+
+			await signUpAsGuest( browser, product.permalink, email );
+
+			await restockProduct( restApi, product.id );
+
+			// StockSyncController schedules an AS job; drain it synchronously.
+			await triggerStockNotificationsBatch( page );
+
+			await expectEmail(
+				page,
+				email,
+				bisEmailSubject.backInStock( product.name )
+			);
+
+			const productLink = await getEmailLinkById(
+				page,
+				email,
+				bisEmailSubject.backInStock( product.name ),
+				BIS_EMAIL_LINKS.actionButton
+			);
+
+			// The CTA is the product permalink with exactly utm_source and
+			// utm_medium appended (UtmHelper::add_email_utm_params), so the
+			// tracking params must be right and stripping them must leave the
+			// product page — the link has to actually go somewhere useful.
+			const linkUrl = new URL( productLink );
+
+			expect( linkUrl.searchParams.get( 'utm_source' ) ).toBe(
+				'back-in-stock-notifications'
+			);
+			expect( linkUrl.searchParams.get( 'utm_medium' ) ).toBe( 'email' );
+
+			linkUrl.searchParams.delete( 'utm_source' );
+			linkUrl.searchParams.delete( 'utm_medium' );
+			expect( linkUrl.toString() ).toBe(
+				new URL( product.permalink ).toString()
+			);
+		} );
+
+		test( 'unsubscribe link in the back-in-stock email cancels the notification', async ( {
+			page,
+			product,
+			restApi,
+			browser,
+		} ) => {
+			const email = uniqueGuestEmail( 'bis-restock-unsub' );
+
+			await signUpAsGuest( browser, product.permalink, email );
+
+			await restockProduct( restApi, product.id );
+			await triggerStockNotificationsBatch( page );
+
+			await expectEmail(
+				page,
+				email,
+				bisEmailSubject.backInStock( product.name )
+			);
+
+			const unsubscribeLink = await getEmailLinkById(
+				page,
+				email,
+				bisEmailSubject.backInStock( product.name ),
+				BIS_EMAIL_LINKS.unsubscribe
+			);
+			await page.goto( unsubscribeLink );
+
+			await page.goto( bisAdminListUrl( product.id ) );
+			const row = page
+				.getByRole( 'row' )
+				.filter( { has: page.getByText( email, { exact: true } ) } );
+			await expect( row.getByText( /Cancelled/i ) ).toBeVisible();
+		} );
+	}
+);
diff --git a/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/signing-up.spec.ts b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/signing-up.spec.ts
new file mode 100644
index 00000000000..6520bf4d0e3
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/tests/back-in-stock-notifications/signing-up.spec.ts
@@ -0,0 +1,189 @@
+/**
+ * Internal dependencies
+ */
+import { expect, request, tags } from '../../fixtures/fixtures';
+import { ADMIN_STATE_PATH, CUSTOMER_STATE_PATH } from '../../playwright.config';
+import {
+	bisEmailSubject,
+	resetBISOptions,
+	setBISOptions,
+	signUpOnProductPage,
+	test,
+	uniqueGuestEmail,
+} from '../../utils/back-in-stock-notifications';
+import { expectEmail } from '../../utils/email';
+
+test.describe(
+	'Back in Stock Notifications — signing up',
+	{ tag: [ tags.SERVICES ] },
+	() => {
+		test.afterAll( async ( { baseURL } ) => {
+			await resetBISOptions( request, baseURL! );
+		} );
+
+		test.describe( 'Logged-in customer, single opt-in', () => {
+			test.use( { storageState: CUSTOMER_STATE_PATH } );
+
+			test.beforeAll( async ( { baseURL } ) => {
+				await setBISOptions( request, baseURL!, {
+					allowSignups: true,
+					doubleOptIn: false,
+					requireAccount: false,
+				} );
+			} );
+
+			test( 'the signup form renders on an out-of-stock simple product page', async ( {
+				page,
+				product,
+			} ) => {
+				await page.goto( product.permalink );
+
+				await expect(
+					page.getByRole( 'heading', {
+						name: /Want to be notified when this product is back in stock\?/i,
+					} )
+				).toBeVisible();
+
+				// A logged-in customer does not see the email field — email is derived server-side.
+				await expect(
+					page.getByRole( 'textbox', {
+						name: /Email address to be notified/i,
+					} )
+				).toHaveCount( 0 );
+				await expect(
+					page.getByRole( 'button', { name: /Notify me/i } )
+				).toBeVisible();
+			} );
+
+			test( 'submitting the form surfaces a success notice', async ( {
+				page,
+				product,
+			} ) => {
+				await page.goto( product.permalink );
+				await signUpOnProductPage( page );
+
+				await expect(
+					page.getByText( /You have successfully signed up/i )
+				).toBeVisible();
+			} );
+
+			test( 'a repeated signup surfaces the "already joined" notice', async ( {
+				page,
+				product,
+			} ) => {
+				await page.goto( product.permalink );
+				await signUpOnProductPage( page );
+
+				// Wait for the first submission to finish so the re-navigation
+				// below can't race it. This is the first signup for a freshly
+				// created product, so it always succeeds.
+				await expect(
+					page.getByText( /You have successfully signed up/i )
+				).toBeVisible();
+
+				// Submitting the form a second time (the "already joined"
+				// notice only renders as a form-submit response; the cached
+				// state shown on page reload is behind the off-by-default
+				// `woocommerce_customer_stock_notifications_personalization_enabled`
+				// filter).
+				await page.goto( product.permalink );
+				await signUpOnProductPage( page );
+				await expect(
+					page.getByText( /You have already joined this waitlist/i )
+				).toBeVisible();
+			} );
+		} );
+
+		test.describe( 'Guest — single opt-in', () => {
+			test.beforeAll( async ( { baseURL } ) => {
+				await setBISOptions( request, baseURL!, {
+					allowSignups: true,
+					doubleOptIn: false,
+					requireAccount: false,
+				} );
+			} );
+
+			test( 'the signup form shows an email field for guests', async ( {
+				page,
+				product,
+			} ) => {
+				await page.goto( product.permalink );
+
+				await expect(
+					page.getByRole( 'textbox', {
+						name: /Email address to be notified/i,
+					} )
+				).toBeVisible();
+				await expect(
+					page.getByRole( 'button', { name: /Notify me/i } )
+				).toBeVisible();
+			} );
+		} );
+
+		test.describe( 'Guest — double opt-in', () => {
+			test.beforeAll( async ( { baseURL } ) => {
+				await setBISOptions( request, baseURL!, {
+					allowSignups: true,
+					doubleOptIn: true,
+					requireAccount: false,
+				} );
+			} );
+
+			// eslint-disable-next-line playwright/expect-expect -- `expectEmail()` asserts on the mail log.
+			test( 'submitting signup dispatches a verification email', async ( {
+				page,
+				product,
+				browser,
+			} ) => {
+				const email = uniqueGuestEmail( 'bis-guest-double' );
+
+				await page.goto( product.permalink );
+				await signUpOnProductPage( page, { email } );
+
+				// Switch to an admin context to inspect the mail log —
+				// WP Mail Logging is an admin-only screen.
+				const adminContext = await browser.newContext( {
+					storageState: ADMIN_STATE_PATH,
+				} );
+				const adminPage = await adminContext.newPage();
+				await expectEmail(
+					adminPage,
+					email,
+					bisEmailSubject.verify( product.name )
+				);
+				await adminContext.close();
+			} );
+		} );
+
+		test.describe( 'Guest — requires account', () => {
+			test.beforeAll( async ( { baseURL } ) => {
+				await setBISOptions( request, baseURL!, {
+					allowSignups: true,
+					doubleOptIn: false,
+					requireAccount: true,
+				} );
+			} );
+
+			test( 'the signup form hides the email field when an account is required', async ( {
+				page,
+				product,
+			} ) => {
+				await page.goto( product.permalink );
+
+				await expect(
+					page.getByRole( 'textbox', {
+						name: /Email address to be notified/i,
+					} )
+				).toHaveCount( 0 );
+
+				// Pair the absence with the prompt core renders in its place,
+				// so a 404 or a failed render can't pass as account gating.
+				await expect(
+					page.getByText(
+						/Please log in to sign up for stock notifications/i
+					)
+				).toBeVisible();
+			} );
+		} );
+	}
+);
diff --git a/plugins/woocommerce/tests/e2e/utils/back-in-stock-notifications.ts b/plugins/woocommerce/tests/e2e/utils/back-in-stock-notifications.ts
new file mode 100644
index 00000000000..df065be2cf3
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e/utils/back-in-stock-notifications.ts
@@ -0,0 +1,518 @@
+/**
+ * External dependencies
+ */
+import type { APIRequest, Browser, Page } from '@playwright/test';
+import {
+	createClient,
+	WC_API_PATH,
+	type ApiClient,
+} from '@woocommerce/e2e-utils-playwright';
+
+/**
+ * Internal dependencies
+ */
+import { deleteOption, setOption } from './options';
+import { expectEmail } from './email';
+import { wpCLI } from './cli';
+import { expect, test as baseTest } from '../fixtures/fixtures';
+import { admin } from '../test-data/data';
+
+/**
+ * Names of the Back in Stock Notifications options in core.
+ *
+ * The external plugin used `wc_bis_*` slugs; core uses these.
+ */
+export const BIS_OPTIONS = {
+	allowSignups: 'woocommerce_customer_stock_notifications_allow_signups',
+	doubleOptIn:
+		'woocommerce_customer_stock_notifications_require_double_opt_in',
+	requireAccount: 'woocommerce_customer_stock_notifications_require_account',
+} as const;
+
+/**
+ * Option that gates the whole Back in Stock Notifications feature.
+ *
+ * @see src/Internal/Features/FeaturesController.php
+ */
+export const BIS_FEATURE_OPTION =
+	'woocommerce_feature_customer_stock_notifications_enabled';
+
+/**
+ * Fail early, with the fix, when the env can't run these specs.
+ *
+ * Both are provisioned by `bin/test-env-setup.sh`, which only runs on env
+ * create or `--update`. On a stale env the feature UI simply never renders and
+ * notification batches keep their one-minute delay, so every spec fails as an
+ * unexplained timeout.
+ */
+export async function assertBISEnvReady(): Promise< void > {
+	// wp-env prefixes its own lines onto stdout, so match rather than compare.
+	const checks = [
+		{
+			command: `wp option get ${ BIS_FEATURE_OPTION }`,
+			expected: /^yes$/m,
+			problem: `the "${ BIS_FEATURE_OPTION }" feature flag is not enabled, so none of the Back in Stock Notifications UI renders`,
+		},
+		{
+			command: 'wp plugin list --status=active --field=name',
+			expected: /^woocommerce-e2e-test-helper$/m,
+			problem:
+				'the "woocommerce-e2e-test-helper" plugin is not active, so the notifications batch delay is not zeroed',
+		},
+	];
+
+	for ( const { command, expected, problem } of checks ) {
+		// `wpCLI` rejects on a non-zero exit, and WP-CLI exits 1 when an option
+		// is missing entirely — the stale-env case this guard exists to explain.
+		// Treat a failed command as "not provisioned" so the message below wins.
+		let stdout = '';
+		try {
+			( { stdout } = await wpCLI( command ) );
+		} catch {
+			stdout = '';
+		}
+
+		if ( ! expected.test( stdout ) ) {
+			throw new Error(
+				`Cannot run the Back in Stock Notifications specs: ${ problem }. Run \`pnpm env:e2e:start\` to re-provision the tests env.`
+			);
+		}
+	}
+}
+
+/**
+ * Configure the BIS feature options for a test. Omitted keys are left untouched.
+ *
+ * @param {APIRequest} request                  Playwright request fixture.
+ * @param {string}     baseURL                  Test site base URL.
+ * @param {Object}     options                  BIS option toggles.
+ * @param {boolean}    [options.allowSignups]   Whether the signup form is rendered on product pages.
+ * @param {boolean}    [options.doubleOptIn]    Whether signups require email verification before activating.
+ * @param {boolean}    [options.requireAccount] Whether signups are limited to logged-in users.
+ */
+export async function setBISOptions(
+	request: APIRequest,
+	baseURL: string,
+	options: {
+		allowSignups?: boolean;
+		doubleOptIn?: boolean;
+		requireAccount?: boolean;
+	}
+): Promise< void > {
+	const toYesNo = ( v: boolean | undefined ): string | undefined => {
+		if ( v === undefined ) {
+			return undefined;
+		}
+		return v ? 'yes' : 'no';
+	};
+
+	const entries: Array< [ string, string | undefined ] > = [
+		[ BIS_OPTIONS.allowSignups, toYesNo( options.allowSignups ) ],
+		[ BIS_OPTIONS.doubleOptIn, toYesNo( options.doubleOptIn ) ],
+		[ BIS_OPTIONS.requireAccount, toYesNo( options.requireAccount ) ],
+	];
+
+	for ( const [ name, value ] of entries ) {
+		if ( value !== undefined ) {
+			await setOption( request, baseURL, name, value );
+		}
+	}
+}
+
+/**
+ * Delete all BIS feature options, restoring core defaults. Mirrors `setBISOptions()`.
+ *
+ * @param {APIRequest} request Playwright request fixture.
+ * @param {string}     baseURL Test site base URL.
+ */
+export async function resetBISOptions(
+	request: APIRequest,
+	baseURL: string
+): Promise< void > {
+	for ( const option of Object.values( BIS_OPTIONS ) ) {
+		await deleteOption( request, baseURL, option );
+	}
+}
+
+/**
+ * An out-of-stock simple product created for a spec.
+ */
+export type BISProduct = {
+	id: number;
+	name: string;
+	permalink: string;
+};
+
+/**
+ * Return a handle to an out-of-stock simple product.
+ *
+ * Deletion is not the caller's job: the `product` fixture queues the id for the
+ * worker-scoped batch in `reapProducts()`.
+ *
+ * @param {ApiClient} restApi WP REST client.
+ */
+export async function createOutOfStockProduct(
+	restApi: ApiClient
+): Promise< BISProduct > {
+	// Append a random suffix so parallel workers don't collide on the product name,
+	// which would break the row-scoped selectors in the admin list-table specs.
+	const name = `BIS Test Product ${ Date.now() }-${ Math.floor(
+		Math.random() * 1e6
+	) }`;
+
+	const response = await restApi.post< {
+		id: number;
+		name: string;
+		permalink: string;
+	} >( `${ WC_API_PATH }/products`, {
+		name,
+		type: 'simple',
+		regular_price: '9.99',
+		manage_stock: false,
+		stock_status: 'outofstock',
+	} );
+	const product = response.data;
+
+	return {
+		id: product.id,
+		name: product.name,
+		permalink: product.permalink,
+	};
+}
+
+/**
+ * Restock a product via REST (used by receiving-notifications.spec.ts to trigger the stock-sync action).
+ *
+ * @param {ApiClient} restApi   WP REST client.
+ * @param {number}    productId Product id.
+ */
+export async function restockProduct(
+	restApi: ApiClient,
+	productId: number
+): Promise< void > {
+	const response = await restApi.put< { stock_status: string } >(
+		`${ WC_API_PATH }/products/${ productId }`,
+		{
+			stock_status: 'instock',
+			manage_stock: false,
+		}
+	);
+
+	// A 200 whose body doesn't reflect the requested stock change would
+	// otherwise only surface ~20s later as an email timeout.
+	expect( response.data.stock_status ).toBe( 'instock' );
+}
+
+/**
+ * Submit the PDP sign-up form. Caller must already have the product page loaded.
+ *
+ * @param {Page}   page         Playwright page on the product detail.
+ * @param {Object} [opts]       Fill options.
+ * @param {string} [opts.email] Email address to enter (guest flow only; logged-in PDP hides the field).
+ */
+export async function signUpOnProductPage(
+	page: Page,
+	opts: {
+		email?: string;
+	} = {}
+): Promise< void > {
+	if ( opts.email !== undefined ) {
+		await page
+			.getByRole( 'textbox', {
+				name: /Email address to be notified/i,
+			} )
+			.fill( opts.email );
+	}
+
+	await page.getByRole( 'button', { name: /Notify me/i } ).click();
+}
+
+/**
+ * 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.
+ */
+export async function signUpAsGuest(
+	browser: Browser,
+	permalink: string,
+	email: string
+): Promise< void > {
+	const guestContext = await browser.newContext( {
+		storageState: { cookies: [], origins: [] },
+	} );
+	const guestPage = await guestContext.newPage();
+	await guestPage.goto( permalink );
+	await signUpOnProductPage( guestPage, { email } );
+
+	// The form posts and reloads the PDP with a notice. Wait for that notice
+	// before closing the context, or the submission can be aborted mid-flight
+	// and the spec fails later, looking like a missing email.
+	await expect(
+		guestPage.getByText(
+			/You have successfully signed up|Thanks for signing up/i
+		)
+	).toBeVisible();
+
+	await guestContext.close();
+}
+
+/**
+ * Build the admin notifications-list URL, optionally filtered to one product.
+ *
+ * Relative (no leading slash) so it resolves under any `baseURL` subdirectory,
+ * matching the rest of the suite's navigation convention.
+ *
+ * @param {number} productId Product id to filter the list by.
+ */
+export function bisAdminListUrl( productId: number ): string {
+	return `wp-admin/admin.php?page=wc-customer-stock-notifications&customer_stock_notifications_product_filter=${ productId }`;
+}
+
+/**
+ * Ids of products created by the `product` fixture, deleted in one batch when
+ * the worker finishes. A per-test DELETE costs ~0.45s, which is pure overhead
+ * on a suite where every test needs its own product.
+ */
+const productsToReap: number[] = [];
+
+/**
+ * Delete every product the `product` fixture created, in a single request.
+ */
+async function reapProducts(): Promise< void > {
+	if ( productsToReap.length === 0 ) {
+		return;
+	}
+
+	const restApi = createClient( process.env.BASE_URL as string, {
+		type: 'basic',
+		username: admin.username,
+		password: admin.password,
+	} );
+
+	await restApi
+		.post( `${ WC_API_PATH }/products/batch`, {
+			delete: productsToReap.splice( 0 ),
+		} )
+		.catch( () => {
+			/* best-effort cleanup */
+		} );
+}
+
+/**
+ * Shared fixtures for the Back in Stock Notifications specs.
+ */
+export const test = baseTest.extend<
+	{ product: BISProduct },
+	{ bisEnvReady: void }
+>( {
+	/**
+	 * Verify the env can run these specs at all, once per worker rather than
+	 * once per file — each check shells out through `wp-env run cli`. Doubles as
+	 * the worker-scoped teardown that reaps the products the specs created.
+	 */
+	bisEnvReady: [
+		async ( {}, use ) => {
+			await assertBISEnvReady();
+			await use();
+			await reapProducts();
+		},
+		{ scope: 'worker', auto: true },
+	],
+
+	/**
+	 * An out-of-stock simple product, created before the test. Its deletion is
+	 * deferred to the worker-scoped batch above rather than awaited here.
+	 */
+	product: async ( { restApi }, use ) => {
+		const product = await createOutOfStockProduct( restApi );
+		// eslint-disable-next-line react-hooks/rules-of-hooks -- Playwright's fixture `use`, not a React hook.
+		await use( product );
+		productsToReap.push( product.id );
+	},
+} );
+
+/**
+ * Anchor ids rendered by the Back in Stock Notifications email templates.
+ *
+ * Targeting these is more precise than scanning every href in the body: the
+ * back-in-stock email carries both a product CTA and an unsubscribe link, and
+ * a pattern loose enough to match one can match the other.
+ *
+ * @see templates/emails/customer-stock-notification.php
+ * @see templates/emails/customer-stock-notification-verify.php
+ * @see templates/emails/customer-stock-notification-verified.php
+ */
+export const BIS_EMAIL_LINKS = {
+	// Product CTA in the back-in-stock email; verification link in the verify email.
+	actionButton: '#notification__action_button',
+	unsubscribe: '#notification__unsubscribe_link',
+} as const;
+
+/**
+ * Escape a string for literal use inside a regular expression.
+ *
+ * @param {string} value The string to escape.
+ */
+function escapeRegExp( value: string ): string {
+	return value.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
+}
+
+/**
+ * Subject matchers for the three BIS emails, bound to a specific product.
+ *
+ * The product name is interpolated rather than wildcarded so a notification for
+ * the wrong product fails the test instead of passing it.
+ *
+ * @see src/Internal/StockNotifications/Emails/
+ */
+const subjectMatcher = ( subject: string ): RegExp =>
+	new RegExp( escapeRegExp( subject ) );
+
+export const bisEmailSubject = {
+	/**
+	 * Verify email.
+	 *
+	 * @param {string} productName The product name.
+	 */
+	verify: ( productName: string ): RegExp =>
+		subjectMatcher( `Join the "${ productName }" waitlist.` ),
+
+	/**
+	 * Verified email.
+	 *
+	 * @param {string} productName The product name.
+	 */
+	verified: ( productName: string ): RegExp =>
+		subjectMatcher( `You have joined the "${ productName }" waitlist.` ),
+
+	/**
+	 * Back-in-stock email.
+	 *
+	 * @param {string} productName The product name.
+	 */
+	backInStock: ( productName: string ): RegExp =>
+		subjectMatcher( `"${ productName }" is back in stock!` ),
+} as const;
+
+/**
+ * Open the WP Mail Logging entry for a given recipient and subject, leaving its modal open.
+ *
+ * Finds the row through `expectEmail()` so this shares its re-navigating poll
+ * rather than reading the log once — an email dispatched after the first
+ * navigation is still found.
+ *
+ * @param {Page}   page                 Playwright page.
+ * @param {string} receiverEmailAddress The recipient email address.
+ * @param {RegExp} subject              The email subject (regular expression).
+ * @param {number} [expectedCount]      Expected number of matching rows. Defaults to 1.
+ */
+async function openEmailInMailLog(
+	page: Page,
+	receiverEmailAddress: string,
+	subject: RegExp,
+	expectedCount = 1
+): Promise< void > {
+	const rows = await expectEmail(
+		page,
+		receiverEmailAddress,
+		subject,
+		expectedCount
+	);
+
+	// The log is sorted newest first, so the first row is the latest email.
+	await rows.first().getByRole( 'button', { name: 'View log' } ).click();
+
+	await expect(
+		page.locator( '#wp-mail-logging-modal-content-body-content' )
+	).toBeVisible();
+}
+
+/**
+ * Close the WP Mail Logging modal, if this version of the plugin renders a close control.
+ *
+ * @param {Page} page Playwright page.
+ */
+async function closeMailLogModal( page: Page ): Promise< void > {
+	const closeButton = page
+		.locator(
+			'#wp-mail-logging-modal-content-header-close, .wp-mail-logging-modal-close'
+		)
+		.first();
+
+	// Some wp-mail-logging versions don't surface an explicit close button.
+	// Check rather than swallowing a click failure, which would otherwise burn
+	// the full action timeout on every call.
+	if ( ( await closeButton.count() ) > 0 ) {
+		await closeButton.click( { timeout: 2000 } ).catch( () => {} );
+	}
+}
+
+/**
+ * Open an email in WP Mail Logging and return the href of a specific anchor, selected by its id.
+ *
+ * Selecting the anchor by its stable template id names the link you mean
+ * instead of inferring it from the URL, so the assertion can then check the
+ * URL without circularity.
+ *
+ * @param {Page}   page                 Playwright page.
+ * @param {string} receiverEmailAddress The recipient email address.
+ * @param {RegExp} subject              The email subject (regular expression).
+ * @param {string} anchorId             CSS id selector, e.g. `BIS_EMAIL_LINKS.actionButton`.
+ * @param {number} [expectedCount]      Expected number of matching rows. Defaults to 1.
+ */
+export async function getEmailLinkById(
+	page: Page,
+	receiverEmailAddress: string,
+	subject: RegExp,
+	anchorId: string,
+	expectedCount = 1
+): Promise< string > {
+	await openEmailInMailLog(
+		page,
+		receiverEmailAddress,
+		subject,
+		expectedCount
+	);
+
+	const iframe = page.frameLocator(
+		'#wp-mail-logging-modal-content-body-content iframe'
+	);
+	const anchor = iframe.locator( `a${ anchorId }` ).first();
+	await anchor.waitFor( { state: 'attached' } );
+
+	const href = await anchor.getAttribute( 'href' );
+
+	if ( ! href ) {
+		throw new Error(
+			`No anchor ${ anchorId } with an href found in email to ${ receiverEmailAddress } with subject ${ subject }`
+		);
+	}
+
+	await closeMailLogModal( page );
+
+	return href;
+}
+
+/**
+ * Run any pending Action Scheduler jobs via the process-waiting-actions mu-plugin.
+ *
+ * @param {Page} page Playwright page (can be on any URL).
+ */
+export async function triggerStockNotificationsBatch(
+	page: Page
+): Promise< void > {
+	await page.goto( '?process-waiting-actions' );
+}
+
+/**
+ * Generate a unique guest email address for a test so mail-log assertions don't collide.
+ *
+ * @param {string} prefix Short descriptor of the test.
+ */
+export function uniqueGuestEmail( prefix = 'bis' ): string {
+	return `${ prefix }-${ Date.now() }-${ Math.floor(
+		Math.random() * 1000
+	) }@example.com`;
+}
diff --git a/plugins/woocommerce/tests/e2e/utils/email.ts b/plugins/woocommerce/tests/e2e/utils/email.ts
index 5727dcc27ce..1b6b076d147 100644
--- a/plugins/woocommerce/tests/e2e/utils/email.ts
+++ b/plugins/woocommerce/tests/e2e/utils/email.ts
@@ -8,25 +8,50 @@ import type { Page } from '@playwright/test';
  */
 import { expect } from '../fixtures/fixtures';

+/**
+ * How long to keep re-navigating to the mail log while waiting for an email.
+ *
+ * Comfortably covers an Action Scheduler-backed dispatch, while keeping a
+ * genuine miss from eating a quarter of the 120s per-test budget. Every caller
+ * of `expectEmail()` pays this on the failure path, not just the ones that wait
+ * on an async email.
+ */
+const MAIL_LOG_POLL_TIMEOUT = 30 * 1000;
+
+/**
+ * How long a single mail log lookup may take before the poll re-navigates.
+ *
+ * Set explicitly: without it the inner assertion inherits the project's
+ * `expect` timeout (20s on CI), which would leave the budget above room for
+ * about two attempts instead of a steady poll.
+ */
+const MAIL_LOG_ATTEMPT_TIMEOUT = 1000;
+
 /**
  * Check that an email exists in the WP Mail Logging plugin Email Log page. WP Mail Logging plugin must be installed.
  *
+ * Polls by re-navigating to the log on every attempt, not just re-querying the
+ * already-loaded DOM, so an email that lands after the first navigation is
+ * still found instead of timing out.
+ *
  * @param {import('@playwright/test').Page } page                 The Playwright page.
  * @param {string}                           receiverEmailAddress The email address of the email receiver.
  * @param {RegExp}                           subject              The subject of the email, in regular expression format.
- * @return {Promise<*>} Returns the row element of the email in the Email Log page.
+ * @param {number}                           [expectedCount]      Expected number of matching rows. Defaults to 1.
+ * @return {Promise<*>} Returns the row locator for the matching email(s) in the Email Log page. Resolves to `expectedCount` rows, so callers that want a single row must narrow it themselves.
  */
 export async function expectEmail(
 	page: Page,
 	receiverEmailAddress: string,
-	subject: RegExp
+	subject: RegExp,
+	expectedCount = 1
 ) {
-	await page.goto(
-		`wp-admin/tools.php?page=wpml_plugin_log&search[place]=receiver&search[term]=${ encodeURIComponent(
-			receiverEmailAddress
-		) }&orderby=timestamp&order=desc`
-	);
+	const mailLogUrl = `wp-admin/tools.php?page=wpml_plugin_log&search[place]=receiver&search[term]=${ encodeURIComponent(
+		receiverEmailAddress
+	) }&orderby=timestamp&order=desc`;

+	// Locators are lazy, so building this once outside the poll still re-queries
+	// the freshly loaded page on every attempt.
 	const row = page
 		.getByRole( 'row' )
 		.filter( {
@@ -36,13 +61,18 @@ export async function expectEmail(
 			} ),
 		} )
 		.filter( {
-			has: page.getByRole( 'cell', {
-				name: subject,
-				exact: true,
-			} ),
+			// No `exact` here: Playwright ignores it when `name` is a RegExp,
+			// so passing it would only imply a guarantee the matcher doesn't give.
+			has: page.getByRole( 'cell', { name: subject } ),
 		} );

-	await expect( row ).toBeVisible();
+	await expect( async () => {
+		await page.goto( mailLogUrl );
+
+		await expect( row ).toHaveCount( expectedCount, {
+			timeout: MAIL_LOG_ATTEMPT_TIMEOUT,
+		} );
+	} ).toPass( { timeout: MAIL_LOG_POLL_TIMEOUT } );

 	return row;
 }