Commit 904aed25942 for woocommerce
commit 904aed2594205d8632737d21c74a321c77d2b22d
Author: Patrick Zielinski <patrick.zielinski@a8c.com>
Date: Fri May 15 07:53:06 2026 -0400
[Email Editor] Add E2E tests for update detection and application (#64817)
* Add WC Email Template Sync test-helper plugin scaffold
* Wire WC Email Template Sync test-helper plugin into wp-env
* Implement helper plugin filter classes and REST health endpoint
* Implement Tracks event recorder in test helper plugin
* Add reset-post and seed-meta REST endpoints to test helper
* Add seed-bulk, trigger-sweep, trigger-backfill REST endpoints
* Add tracks GET and DELETE REST endpoints to test helper
* Add Playwright update-propagation helpers with option transport
* Add seed-woo-email helpers and canonical-hash + read-meta endpoints
* Add simulate-update, tracks-spy, and leaked-state-checks helpers
* Add E2E specs for update detection and application
* Add E2E scripts and docs for email update-propagation suite
* Lazy-load Fake_Third_Party_Email after WC has defined WC_Email
* Wire up Playwright suite to actually run against wp-env
* Fix BC Case C content read and timestamp seeding
* Run auto-applier inline in trigger-sweep; align scenario 1
* Fix selective apply: basic auth helper and correct status assertion
* Fix Update-available indicator locator: DataViews + version comparison
* Fix Dismiss flow: seed older version so editor banner stays mounted
* Fix round-trip apply and reset: use basic-auth helpers
* Fix BC Case A and no-mass-fire: inline recorder, one-shot guard reset
* Fix scope tests: direct seed for non-opted-in; auto-apply lifecycle for opted-in
* Add changelog entry for RSM-146 E2E tests
* Add review drawer UI test: per-conflict keep-yours vs use-core
* Fix Dismiss flow: update Tracks event names per RSM-145 final rename
* Use wcadmin_ prefix for inline-recorded server-side tracks events
* Add reviewer-friendly JSDoc descriptions for Playwright UI mode
* Address CodeRabbit review feedback on RSM-146 E2E suite
- Rename require_admin_and_playwright → require_admin and clarify class
docblock (no X-Playwright header is checked; the bootstrap location is
the second layer of defense).
- BC Case B: replace direct wp/v2/woo_email read with getWooEmailPostContent
helper to bypass content.raw availability issues, matching BC Case C.
- Auto-apply silent: replace brittle getByRole('row', {name:...}) +
getByText pattern with locator('tr').filter({hasText}) + getByRole
button assertion — same pattern test 2 already uses.
- seed-woo-email: encodeURIComponent for emailId in REST paths.
- tracks-spy: patch currently-loaded page (idempotent via __wcSpyWrapped
guard) so attachTracksSpy doesn't miss events fired before navigation.
- scope.spec: remove redundant cleanup tails (assertNoLeakedFixtureState
in afterEach already does this work).
* Fix lint errors flagged by CI
- Remove unused simulateCoreBump import in backward-compat spec.
- Merge duplicate test-helper-plugin imports in core-flows spec.
- Reorder seed-woo-email helpers so resolveHash is defined before
seedWooEmailPost references it.
- prettier auto-fixes on multi-line formatting.
* Open review drawer via banner click instead of deep-link (CI stability)
The wc_email_review_drawer=1 deep-link works locally but races with editor
mount in CI — the dispatch fires before the React store is registered, so
the drawer stays closed. Click the banner's Review changes button instead,
which is the merchant-facing path and is stable across environments.
* Prettier formatting on review-drawer banner-click change
* Make review-drawer test resilient to differ classification variance
The local wp-env's differ classifies all 3 blocks as conflicts; CI's
classifies block A as the only true conflict (B/C auto-resolve). Drop
assumptions about how many radiogroups appear — interact only with the
first one, assert post-apply that the chosen 'use core' decision took
effect on block A.
---------
Co-authored-by: PZ01 <patrick.zielinski@automattic.com>
diff --git a/plugins/woocommerce/.wp-env.json b/plugins/woocommerce/.wp-env.json
index 35d86610934..bcdc767194c 100644
--- a/plugins/woocommerce/.wp-env.json
+++ b/plugins/woocommerce/.wp-env.json
@@ -1,7 +1,7 @@
{
"core": "https://wordpress.org/wordpress-latest.zip",
"phpVersion": "8.1",
- "plugins": [ "." ],
+ "plugins": [ ".", "./tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper" ],
"config": {
"JETPACK_AUTOLOAD_DEV": true,
"WP_DEBUG_LOG": true,
@@ -17,6 +17,7 @@
"port": 8086,
"plugins": [
".",
+ "./tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper",
"https://downloads.wordpress.org/plugin/akismet.zip",
"https://github.com/WP-API/Basic-Auth/archive/master.zip",
"https://downloads.wordpress.org/plugin/wp-mail-logging.zip"
diff --git a/plugins/woocommerce/changelog/rsm-146-e2e-update-propagation b/plugins/woocommerce/changelog/rsm-146-e2e-update-propagation
new file mode 100644
index 00000000000..ff71cdb6a48
--- /dev/null
+++ b/plugins/woocommerce/changelog/rsm-146-e2e-update-propagation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Add E2E tests for block email template update propagation.
diff --git a/plugins/woocommerce/package.json b/plugins/woocommerce/package.json
index 3f3caddd870..cdcbeb6438f 100644
--- a/plugins/woocommerce/package.json
+++ b/plugins/woocommerce/package.json
@@ -64,6 +64,8 @@
"test:e2e:default": "pnpm test:e2e:install && pnpm test:e2e:with-env default",
"test:e2e:install": "pnpm playwright install chromium",
"test:e2e:blocks": "pnpm --filter='@woocommerce/block-library' test:e2e",
+ "test:e2e:email-update-propagation:pr": "pnpm test:e2e:default --project=e2e --grep @pr tests/email-editor/update-propagation",
+ "test:e2e:email-update-propagation:nightly": "pnpm test:e2e:default --project=e2e tests/email-editor/update-propagation",
"test:e2e:with-env": "pnpm test:e2e:install && bash ./tests/e2e-pw/run-tests-with-env.sh",
"test:e2e:pressable": "pnpm test:e2e:with-env default-pressable",
"test:e2e:wpcom": "pnpm test:e2e:with-env default-wpcom",
diff --git a/plugins/woocommerce/tests/e2e-pw/README.md b/plugins/woocommerce/tests/e2e-pw/README.md
index e90f03b3cb9..de162843f91 100644
--- a/plugins/woocommerce/tests/e2e-pw/README.md
+++ b/plugins/woocommerce/tests/e2e-pw/README.md
@@ -168,6 +168,37 @@ Still, here's a few tips to get you started:
Playwright's Best Practices guide is a good
read: [Playwright Best Practices](https://playwright.dev/docs/best-practices).
+## Test helper plugins
+
+Some E2E suites need fixture mechanisms that can't be expressed cleanly with REST or WP-CLI alone — for example, filter-driven content overrides, server-side event mirroring, or synchronous triggers for normally-scheduled jobs. These ship as small PHP plugins under `tests/e2e-pw/test-plugins/`, mounted via `.wp-env.json`'s `plugins` array.
+
+### `wc-email-template-sync-test-helper`
+
+Powers the `tests/email-editor/update-propagation/` suite (RSM-146). Exposes:
+
+- Option-driven filter overrides for `woocommerce_email_block_template_html`, `woocommerce_email_template_sync_opted_in_emails`, and `woocommerce_transactional_emails_for_block_editor`.
+- A server-side Tracks event recorder, controlled by option `wc_test_tracks_enabled`.
+- A fake `WC_Email` subclass (`fake_thirdparty`) gated by option `wc_test_fake_third_party_email_enabled` for third-party-email scope tests.
+- REST endpoints under `/wp-json/wc-email-test-helper/v1/` for seeding posts, triggering sweeps and backfill synchronously, draining the Tracks log, and writing typed option values.
+
+The plugin is dormant when its driving options are empty. It has a `WP_DEBUG` plus `X-Playwright` header safety rail to prevent accidental activation outside test contexts.
+
+If a test fails with `404` on `/wp-json/wc-email-test-helper/v1/health`, the plugin isn't loaded — run `pnpm env:restart`.
+
+The PR-tier subset of these tests can be run locally with:
+
+```sh
+pnpm test:e2e:email-update-propagation:pr
+```
+
+To run the full suite (the PR-tier plus nightly-only scenarios):
+
+```sh
+pnpm test:e2e:email-update-propagation:nightly
+```
+
+In CI, the full suite runs as part of the existing "Core e2e tests" job — no separate workflow entry is required.
+
## Test reports
The tests would generate three kinds of reports after the run:
diff --git a/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/includes/class-fake-third-party-email.php b/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/includes/class-fake-third-party-email.php
new file mode 100644
index 00000000000..a4f000b45cb
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/includes/class-fake-third-party-email.php
@@ -0,0 +1,59 @@
+<?php
+/**
+ * Fake third-party email for E2E scenarios 15-16.
+ *
+ * Registered conditionally via the woocommerce_email_classes filter in the
+ * main plugin file. Stays dormant unless wc_test_fake_third_party_email_enabled='yes'.
+ *
+ * @package WC_Email_Template_Sync_Test_Helper
+ */
+
+declare( strict_types=1 );
+
+namespace WC_Email_Template_Sync_Test_Helper;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Minimal WC_Email subclass standing in for a real third-party transactional email.
+ *
+ * Real third-party plugins must subclass WC_Email; the registry, post generator, and
+ * divergence detector all call methods on a live WC_Email instance, so filter-only
+ * registration is insufficient. This fixture provides the minimum surface those classes
+ * need (id, title, description, template paths, basic subject/heading defaults) so that
+ * scenarios 15 (non-opted-in) and 16 (opted-in with explicit version) can exercise the
+ * full sync pipeline.
+ */
+class Fake_Third_Party_Email extends \WC_Email {
+
+ /**
+ * Constructor.
+ */
+ public function __construct() {
+ $this->id = 'fake_thirdparty';
+ $this->title = 'Fake third-party email (test fixture)';
+ $this->description = 'E2E fixture for RSM-146 scope tests. Do not enable in production.';
+
+ // Safe fallback to an existing core template so that any rendering paths that
+ // reach the file loader don't fail with a missing-template error.
+ $this->template_html = 'emails/customer-new-account.php';
+ $this->template_plain = 'emails/plain/customer-new-account.php';
+ $this->customer_email = true;
+
+ parent::__construct();
+ }
+
+ /**
+ * Default subject (overridable via merchant settings, but tests don't touch it).
+ */
+ public function get_default_subject(): string {
+ return 'Fake third-party email subject';
+ }
+
+ /**
+ * Default heading (overridable via merchant settings, but tests don't touch it).
+ */
+ public function get_default_heading(): string {
+ return 'Fake third-party email heading';
+ }
+}
diff --git a/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/includes/class-opted-in-overrides.php b/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/includes/class-opted-in-overrides.php
new file mode 100644
index 00000000000..73a5c82bb5d
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/includes/class-opted-in-overrides.php
@@ -0,0 +1,70 @@
+<?php
+/**
+ * Opted-in and transactional-emails list filters for the WC Email Template Sync test helper plugin.
+ *
+ * @package WC_Email_Template_Sync_Test_Helper
+ */
+
+declare( strict_types=1 );
+
+namespace WC_Email_Template_Sync_Test_Helper;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Filter wrappers that augment the sync registry's opted-in list and the block-editor
+ * transactional-emails list with fixture-controlled values. Dormant when the driving
+ * options are empty.
+ */
+class Opted_In_Overrides {
+
+ public const OPTED_IN_OPTION = 'wc_test_opted_in_emails_override';
+ public const TRANSACTIONAL_OPTION = 'wc_test_transactional_emails_override';
+
+ /**
+ * Register both filters.
+ */
+ public function register(): void {
+ add_filter(
+ 'woocommerce_email_template_sync_opted_in_emails',
+ array( $this, 'merge_opted_in' ),
+ 100
+ );
+ add_filter(
+ 'woocommerce_transactional_emails_for_block_editor',
+ array( $this, 'merge_transactional' ),
+ 100
+ );
+ }
+
+ /**
+ * Non-destructively merge the test-controlled opted-in list into the production return value.
+ *
+ * @param mixed $opted_in The upstream filter value (expected: array keyed by email id).
+ * @return array The merged map.
+ */
+ public function merge_opted_in( $opted_in ): array {
+ $opted_in = is_array( $opted_in ) ? $opted_in : array();
+ $override = get_option( self::OPTED_IN_OPTION, array() );
+ if ( ! is_array( $override ) || empty( $override ) ) {
+ return $opted_in;
+ }
+ return array_merge( $opted_in, $override );
+ }
+
+ /**
+ * Non-destructively merge the test-controlled "registered for block editor" list with
+ * the production return value, deduping by email id.
+ *
+ * @param mixed $emails The upstream filter value (expected: array of email ids).
+ * @return array The merged list with stable numeric keys.
+ */
+ public function merge_transactional( $emails ): array {
+ $emails = is_array( $emails ) ? $emails : array();
+ $override = get_option( self::TRANSACTIONAL_OPTION, array() );
+ if ( ! is_array( $override ) || empty( $override ) ) {
+ return $emails;
+ }
+ return array_values( array_unique( array_merge( $emails, $override ) ) );
+ }
+}
diff --git a/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/includes/class-rest-controller.php b/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/includes/class-rest-controller.php
new file mode 100644
index 00000000000..c26a4bfdc54
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/includes/class-rest-controller.php
@@ -0,0 +1,650 @@
+<?php
+/**
+ * Helper REST endpoints exposed by the WC Email Template Sync test helper plugin.
+ *
+ * @package WC_Email_Template_Sync_Test_Helper
+ */
+
+declare( strict_types=1 );
+
+namespace WC_Email_Template_Sync_Test_Helper;
+
+use WP_REST_Request;
+use WP_REST_Response;
+use WP_REST_Server;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Helper REST endpoints exposed under /wc-email-test-helper/v1/ for Playwright E2E tests.
+ *
+ * Health endpoint is open; every other route requires manage_options. The plugin's location
+ * under tests/e2e-pw/test-plugins/ — only mounted via .wp-env.json for the test environment —
+ * provides the second layer of defense.
+ */
+class REST_Controller {
+
+ private const NAMESPACE = 'wc-email-test-helper/v1';
+
+ /**
+ * Register all REST routes.
+ */
+ public function register_routes(): void {
+ register_rest_route(
+ self::NAMESPACE,
+ '/health',
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'health' ),
+ 'permission_callback' => '__return_true',
+ )
+ );
+
+ register_rest_route(
+ self::NAMESPACE,
+ '/reset-post/(?P<email_id>[a-z0-9_]+)',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'reset_post' ),
+ 'permission_callback' => array( self::class, 'require_admin' ),
+ 'args' => array(
+ 'email_id' => array( 'sanitize_callback' => 'sanitize_key' ),
+ ),
+ )
+ );
+
+ register_rest_route(
+ self::NAMESPACE,
+ '/seed-meta/(?P<post_id>\d+)',
+ array(
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'seed_meta' ),
+ 'permission_callback' => array( self::class, 'require_admin' ),
+ 'args' => array(
+ 'post_id' => array( 'sanitize_callback' => 'absint' ),
+ ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'read_meta' ),
+ 'permission_callback' => array( self::class, 'require_admin' ),
+ 'args' => array(
+ 'post_id' => array( 'sanitize_callback' => 'absint' ),
+ ),
+ ),
+ )
+ );
+
+ register_rest_route(
+ self::NAMESPACE,
+ '/canonical-hash/(?P<email_id>[a-z0-9_]+)',
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'canonical_hash' ),
+ 'permission_callback' => array( self::class, 'require_admin' ),
+ 'args' => array(
+ 'email_id' => array( 'sanitize_callback' => 'sanitize_key' ),
+ 'mode' => array( 'sanitize_callback' => 'sanitize_key' ),
+ ),
+ )
+ );
+
+ register_rest_route(
+ self::NAMESPACE,
+ '/post-content/(?P<post_id>\d+)',
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'read_post_content' ),
+ 'permission_callback' => array( self::class, 'require_admin' ),
+ 'args' => array(
+ 'post_id' => array( 'sanitize_callback' => 'absint' ),
+ ),
+ )
+ );
+
+ register_rest_route(
+ self::NAMESPACE,
+ '/seed-bulk',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'seed_bulk' ),
+ 'permission_callback' => array( self::class, 'require_admin' ),
+ )
+ );
+
+ register_rest_route(
+ self::NAMESPACE,
+ '/trigger-sweep',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'trigger_sweep' ),
+ 'permission_callback' => array( self::class, 'require_admin' ),
+ )
+ );
+
+ register_rest_route(
+ self::NAMESPACE,
+ '/trigger-backfill',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'trigger_backfill' ),
+ 'permission_callback' => array( self::class, 'require_admin' ),
+ )
+ );
+
+ register_rest_route(
+ self::NAMESPACE,
+ '/tracks',
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'get_tracks' ),
+ 'permission_callback' => array( self::class, 'require_admin' ),
+ ),
+ array(
+ 'methods' => WP_REST_Server::DELETABLE,
+ 'callback' => array( $this, 'clear_tracks' ),
+ 'permission_callback' => array( self::class, 'require_admin' ),
+ ),
+ )
+ );
+
+ register_rest_route(
+ self::NAMESPACE,
+ '/set-option',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'set_option' ),
+ 'permission_callback' => array( self::class, 'require_admin' ),
+ )
+ );
+
+ register_rest_route(
+ self::NAMESPACE,
+ '/delete-option',
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'delete_option_value' ),
+ 'permission_callback' => array( self::class, 'require_admin' ),
+ )
+ );
+ }
+
+ /**
+ * Health probe. Tests call this in beforeAll to confirm the helper plugin is loaded.
+ *
+ * @param WP_REST_Request $request The REST request (unused; REST callback signature requires it).
+ * @return WP_REST_Response
+ */
+ public function health( WP_REST_Request $request ): WP_REST_Response {
+ unset( $request );
+ return new WP_REST_Response(
+ array(
+ 'ok' => true,
+ 'version' => '1.0.0',
+ ),
+ 200
+ );
+ }
+
+ /**
+ * Delete the woo_email post for the given email type, clear template manager state +
+ * transient, then regenerate synchronously.
+ *
+ * @param WP_REST_Request $request The REST request. Expects `email_id` route parameter.
+ * @return WP_REST_Response
+ */
+ public function reset_post( WP_REST_Request $request ): WP_REST_Response {
+ $email_id = (string) $request->get_param( 'email_id' );
+
+ $manager = \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager::get_instance();
+
+ $existing_post_id = (int) $manager->get_email_template_post_id( $email_id );
+ if ( $existing_post_id > 0 ) {
+ wp_delete_post( $existing_post_id, true );
+ }
+
+ $manager->delete_email_template( $email_id );
+
+ delete_transient( 'wc_email_editor_initial_templates_generated' );
+
+ $generator = new \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator();
+ $generator->init_default_transactional_emails();
+ $new_post_id = (int) $generator->generate_email_template_if_not_exists( $email_id );
+
+ if ( $new_post_id <= 0 ) {
+ return new WP_REST_Response(
+ array( 'error' => "Failed to regenerate woo_email post for {$email_id}" ),
+ 500
+ );
+ }
+
+ return new WP_REST_Response( array( 'post_id' => $new_post_id ), 200 );
+ }
+
+ /**
+ * Apply arbitrary meta + post column updates to a post in one round-trip.
+ * Body is JSON `{ meta?: {key: value | null}, post?: {wp_update_post fields} }`.
+ *
+ * Timestamp columns (`post_date`, `post_date_gmt`, `post_modified`,
+ * `post_modified_gmt`) are applied via a direct `$wpdb->update()` after the
+ * `wp_update_post()` call, because WordPress always overwrites `post_modified*`
+ * with the current time during an update pass — passing them through
+ * `wp_update_post()` would be silently ignored.
+ *
+ * @param WP_REST_Request $request The REST request. Expects `post_id` route parameter and JSON body.
+ * @return WP_REST_Response
+ */
+ public function seed_meta( WP_REST_Request $request ): WP_REST_Response {
+ global $wpdb;
+
+ $post_id = (int) $request->get_param( 'post_id' );
+ $body = $request->get_json_params();
+
+ if ( ! is_array( $body ) ) {
+ return new WP_REST_Response( array( 'error' => 'Body must be a JSON object' ), 400 );
+ }
+
+ if ( ! get_post( $post_id ) ) {
+ return new WP_REST_Response( array( 'error' => "Post {$post_id} not found" ), 404 );
+ }
+
+ $meta_updates = $body['meta'] ?? array();
+ if ( is_array( $meta_updates ) ) {
+ foreach ( $meta_updates as $key => $value ) {
+ if ( null === $value ) {
+ delete_post_meta( $post_id, (string) $key );
+ } else {
+ update_post_meta( $post_id, (string) $key, $value );
+ }
+ }
+ }
+
+ // Timestamp columns must be handled separately: wp_update_post always
+ // stamps post_modified* with current_time(), ignoring any caller-supplied
+ // value. Split the post update array into "regular" fields (handled by
+ // wp_update_post) and timestamp fields (applied via direct DB write after).
+ $timestamp_columns = array( 'post_date', 'post_date_gmt', 'post_modified', 'post_modified_gmt' );
+
+ $post_updates = $body['post'] ?? array();
+ $timestamp_updates = array();
+ $regular_updates = array();
+
+ if ( is_array( $post_updates ) ) {
+ foreach ( $post_updates as $col => $value ) {
+ if ( in_array( $col, $timestamp_columns, true ) ) {
+ $timestamp_updates[ $col ] = (string) $value;
+ } else {
+ $regular_updates[ $col ] = $value;
+ }
+ }
+ }
+
+ if ( ! empty( $regular_updates ) ) {
+ wp_update_post(
+ array_merge( array( 'ID' => $post_id ), $regular_updates ),
+ true
+ );
+ }
+
+ if ( ! empty( $timestamp_updates ) ) {
+ // Ensure the local-time columns stay consistent with their GMT counterparts.
+ // `was_never_edited()` in WCEmailTemplateSyncBackfill checks the local pair
+ // as a fallback (`post_date === post_modified`), so both pairs must differ
+ // for the post to be classified as Case C rather than Case B.
+ if ( isset( $timestamp_updates['post_date_gmt'] ) && ! isset( $timestamp_updates['post_date'] ) ) {
+ $timestamp_updates['post_date'] = get_date_from_gmt( $timestamp_updates['post_date_gmt'] );
+ }
+ if ( isset( $timestamp_updates['post_modified_gmt'] ) && ! isset( $timestamp_updates['post_modified'] ) ) {
+ $timestamp_updates['post_modified'] = get_date_from_gmt( $timestamp_updates['post_modified_gmt'] );
+ }
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->update(
+ $wpdb->posts,
+ $timestamp_updates,
+ array( 'ID' => $post_id ),
+ array_fill( 0, count( $timestamp_updates ), '%s' ),
+ array( '%d' )
+ );
+ clean_post_cache( $post_id );
+ }
+
+ return new WP_REST_Response(
+ array(
+ 'post_id' => $post_id,
+ 'meta' => get_post_meta( $post_id ),
+ ),
+ 200
+ );
+ }
+
+ /**
+ * Read all post meta for a post. Mirrors seed-meta's response shape (without writes).
+ *
+ * @param WP_REST_Request $request The REST request. Expects `post_id` route parameter.
+ * @return WP_REST_Response
+ */
+ public function read_meta( WP_REST_Request $request ): WP_REST_Response {
+ $post_id = (int) $request->get_param( 'post_id' );
+
+ if ( ! get_post( $post_id ) ) {
+ return new WP_REST_Response( array( 'error' => "Post {$post_id} not found" ), 404 );
+ }
+
+ return new WP_REST_Response(
+ array(
+ 'post_id' => $post_id,
+ 'meta' => get_post_meta( $post_id ),
+ ),
+ 200
+ );
+ }
+
+ /**
+ * Read the raw post_content for a post. Bypasses the wp/v2 REST surface, which
+ * doesn't reliably expose `content.raw` for custom post types under all auth modes.
+ *
+ * @param WP_REST_Request $request The REST request. Expects `post_id` route parameter.
+ * @return WP_REST_Response
+ */
+ public function read_post_content( WP_REST_Request $request ): WP_REST_Response {
+ $post_id = (int) $request->get_param( 'post_id' );
+ $post = get_post( $post_id );
+
+ if ( ! $post ) {
+ return new WP_REST_Response( array( 'error' => "Post {$post_id} not found" ), 404 );
+ }
+
+ return new WP_REST_Response(
+ array(
+ 'post_id' => $post_id,
+ 'post_content' => (string) $post->post_content,
+ ),
+ 200
+ );
+ }
+
+ /**
+ * Compute the sha1 of the canonical core HTML for a given email type. Mode 'old'
+ * temporarily suppresses the Template_HTML_Overrides option so the canonical
+ * resolves to the real WC default; mode 'current' applies the active override
+ * (if any).
+ *
+ * @param WP_REST_Request $request The REST request. Expects `email_id` and `mode` params.
+ * @return WP_REST_Response
+ */
+ public function canonical_hash( WP_REST_Request $request ): WP_REST_Response {
+ $email_id = (string) $request->get_param( 'email_id' );
+ $mode = (string) $request->get_param( 'mode' );
+
+ $manager = \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager::get_instance();
+ $email = $manager->get_email_by_id( $email_id );
+ if ( ! $email instanceof \WC_Email ) {
+ return new WP_REST_Response( array( 'error' => "Unknown email_id {$email_id}" ), 404 );
+ }
+
+ $existing_override = get_option( Template_HTML_Overrides::OPTION_NAME, array() );
+ if ( 'old' === $mode ) {
+ delete_option( Template_HTML_Overrides::OPTION_NAME );
+ }
+
+ $canonical = \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email );
+
+ if ( 'old' === $mode && is_array( $existing_override ) && ! empty( $existing_override ) ) {
+ update_option( Template_HTML_Overrides::OPTION_NAME, $existing_override, false );
+ }
+
+ return new WP_REST_Response(
+ array(
+ 'hash' => sha1( $canonical ),
+ 'canonical' => $canonical,
+ ),
+ 200
+ );
+ }
+
+ /**
+ * Bulk-insert woo_email posts with seeded meta in one round-trip. Body shape:
+ * `{ "seeds": [ { "post": <wp_insert_post args>, "meta": {key: value | null, ...} }, ... ] }`.
+ *
+ * @param WP_REST_Request $request The REST request.
+ * @return WP_REST_Response
+ */
+ public function seed_bulk( WP_REST_Request $request ): WP_REST_Response {
+ $body = $request->get_json_params();
+ if ( ! is_array( $body ) || empty( $body['seeds'] ) || ! is_array( $body['seeds'] ) ) {
+ return new WP_REST_Response( array( 'error' => 'Body must include a "seeds" array' ), 400 );
+ }
+
+ $results = array();
+ foreach ( $body['seeds'] as $seed ) {
+ $post_data = array_merge(
+ array(
+ 'post_type' => 'woo_email',
+ 'post_status' => 'publish',
+ ),
+ is_array( $seed['post'] ?? null ) ? $seed['post'] : array()
+ );
+
+ $post_id = wp_insert_post( $post_data, true );
+
+ if ( is_wp_error( $post_id ) ) {
+ $results[] = array( 'error' => $post_id->get_error_message() );
+ continue;
+ }
+
+ $meta_updates = $seed['meta'] ?? array();
+ if ( is_array( $meta_updates ) ) {
+ foreach ( $meta_updates as $key => $value ) {
+ if ( null === $value ) {
+ delete_post_meta( (int) $post_id, (string) $key );
+ } else {
+ update_post_meta( (int) $post_id, (string) $key, $value );
+ }
+ }
+ }
+
+ $results[] = array( 'post_id' => (int) $post_id );
+ }
+
+ return new WP_REST_Response( array( 'results' => $results ), 200 );
+ }
+
+ /**
+ * Run the divergence sweep inline, then immediately run the auto-applier inline
+ * (bypassing Action Scheduler), then snapshot classifications from post meta
+ * across all sync-enabled emails.
+ *
+ * Production flow: run_sweep() fires the sweep_complete action → schedule()
+ * enqueues an async AS job → run() executes later. For E2E tests we need the
+ * full classify-then-apply cycle to complete within the same HTTP request so
+ * assertions can run immediately after this call returns.
+ *
+ * @param WP_REST_Request $request The REST request (unused).
+ * @return WP_REST_Response
+ */
+ public function trigger_sweep( WP_REST_Request $request ): WP_REST_Response {
+ unset( $request );
+
+ \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector::run_sweep();
+
+ // Run the auto-applier inline so unmodified posts are stamped IN_SYNC before
+ // this response returns. In production the applier is deferred via Action
+ // Scheduler; calling run() directly here keeps the E2E request synchronous.
+ \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateAutoApplier::run();
+
+ $registry = \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncRegistry::get_sync_enabled_emails();
+ $manager = \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCTransactionalEmailPostsManager::get_instance();
+
+ $classifications = array();
+ foreach ( array_keys( $registry ) as $email_id ) {
+ $post = $manager->get_email_post( (string) $email_id );
+ if ( ! $post instanceof \WP_Post ) {
+ continue;
+ }
+ $status = (string) get_post_meta( (int) $post->ID, \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true );
+ if ( '' !== $status ) {
+ $classifications[ (int) $post->ID ] = $status;
+ }
+ }
+
+ return new WP_REST_Response(
+ array(
+ 'touched' => count( $classifications ),
+ 'classifications' => $classifications,
+ ),
+ 200
+ );
+ }
+
+ /**
+ * Run the legacy-post backfill inline, then snapshot a count of stamped posts.
+ * Production `run()` returns false unconditionally — value reported back is for caller
+ * convenience only. Tests assert via meta snapshots before/after.
+ *
+ * WC_Tracks::record_event() short-circuits when site tracking is disabled (the
+ * default in wp-env), so it never reaches the woocommerce_tracks_event_properties
+ * filter that Tracks_Recorder hooks into. To capture the _backfill_completed event
+ * reliably we inject a custom recorder via set_event_recorder() that writes
+ * directly to the Tracks_Recorder log option, then restore the default (null)
+ * after the backfill completes.
+ *
+ * @param WP_REST_Request $request The REST request (unused).
+ * @return WP_REST_Response
+ */
+ public function trigger_backfill( WP_REST_Request $request ): WP_REST_Response {
+ unset( $request );
+
+ // Inject a direct-write recorder so _backfill_completed lands in the log
+ // even when WC_Tracks::record_event() is disabled for the test environment.
+ // The recorder is gated on the same wc_test_tracks_enabled option that
+ // Tracks_Recorder uses, so it only fires when a spy is active and the log
+ // stays empty for tests that don't attach a spy.
+ //
+ // Event names get the 'wcadmin_' prefix to match WC_Tracks::PREFIX — that's
+ // what server-side events are dispatched as via WC_Tracks::record_event().
+ // The TRACKS_EVENTS constants in classifications.ts expect the prefixed name.
+ \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncTracker::set_event_recorder(
+ static function ( string $event_name, array $payload ): void {
+ if ( 'yes' !== get_option( Tracks_Recorder::ENABLED_OPTION, 'no' ) ) {
+ return;
+ }
+ $log = get_option( Tracks_Recorder::LOG_OPTION, array() );
+ if ( ! is_array( $log ) ) {
+ $log = array();
+ }
+ $log[] = array(
+ 'name' => 'wcadmin_' . $event_name,
+ 'properties' => $payload,
+ 'timestamp_ms' => (int) ( microtime( true ) * 1000 ),
+ );
+ update_option( Tracks_Recorder::LOG_OPTION, $log, false );
+ }
+ );
+
+ $ran = \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncBackfill::run();
+
+ // Restore the default recorder so subsequent calls don't double-log via
+ // both the injected recorder and any future WC_Tracks path.
+ \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateSyncTracker::set_event_recorder( null );
+
+ global $wpdb;
+ $stamped = (int) $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT COUNT(DISTINCT post_id) FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value <> ''",
+ \Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector::SOURCE_HASH_META_KEY
+ )
+ );
+
+ return new WP_REST_Response(
+ array(
+ 'ran' => (bool) $ran,
+ 'stamped' => $stamped,
+ ),
+ 200
+ );
+ }
+
+ /**
+ * Return the in-option Tracks event log captured by Tracks_Recorder.
+ *
+ * @param WP_REST_Request $request The REST request (unused).
+ * @return WP_REST_Response
+ */
+ public function get_tracks( WP_REST_Request $request ): WP_REST_Response {
+ unset( $request );
+ $log = get_option( Tracks_Recorder::LOG_OPTION, array() );
+ return new WP_REST_Response(
+ array( 'events' => is_array( $log ) ? $log : array() ),
+ 200
+ );
+ }
+
+ /**
+ * Clear the Tracks event log option so the next read starts empty.
+ *
+ * @param WP_REST_Request $request The REST request (unused).
+ * @return WP_REST_Response
+ */
+ public function clear_tracks( WP_REST_Request $request ): WP_REST_Response {
+ unset( $request );
+ delete_option( Tracks_Recorder::LOG_OPTION );
+ return new WP_REST_Response( array( 'cleared' => true ), 200 );
+ }
+
+ /**
+ * Write a typed value to a WordPress option, preserving array/object structure.
+ * Body shape: `{ "option_name": string, "option_value": mixed }`. Unlike the
+ * shared `e2e-options/update` endpoint, this preserves arrays and nested objects
+ * because it pulls the value from the JSON body rather than sanitize_text_field.
+ *
+ * @param WP_REST_Request $request The REST request.
+ * @return WP_REST_Response
+ */
+ public function set_option( WP_REST_Request $request ): WP_REST_Response {
+ $body = $request->get_json_params();
+ if ( ! is_array( $body ) || ! isset( $body['option_name'] ) ) {
+ return new WP_REST_Response( array( 'error' => 'Body must include option_name' ), 400 );
+ }
+
+ $option_name = (string) $body['option_name'];
+ $option_value = $body['option_value'] ?? '';
+
+ update_option( $option_name, $option_value, false );
+
+ return new WP_REST_Response( array( 'ok' => true ), 200 );
+ }
+
+ /**
+ * Delete a WordPress option. Body shape: `{ "option_name": string }`.
+ *
+ * @param WP_REST_Request $request The REST request.
+ * @return WP_REST_Response
+ */
+ public function delete_option_value( WP_REST_Request $request ): WP_REST_Response {
+ $body = $request->get_json_params();
+ if ( ! is_array( $body ) || ! isset( $body['option_name'] ) ) {
+ return new WP_REST_Response( array( 'error' => 'Body must include option_name' ), 400 );
+ }
+
+ delete_option( (string) $body['option_name'] );
+
+ return new WP_REST_Response( array( 'ok' => true ), 200 );
+ }
+
+ /**
+ * Permission callback used by every non-health endpoint. Requires the manage_options
+ * capability. The plugin is only mounted in test environments via .wp-env.json — it
+ * does not ship in any production WooCommerce build — which provides the second
+ * layer of defense.
+ *
+ * @param WP_REST_Request $request The REST request (unused).
+ * @return bool
+ */
+ public static function require_admin( WP_REST_Request $request ): bool {
+ unset( $request );
+ return current_user_can( 'manage_options' );
+ }
+}
diff --git a/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/includes/class-template-html-overrides.php b/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/includes/class-template-html-overrides.php
new file mode 100644
index 00000000000..68dcd182a7c
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/includes/class-template-html-overrides.php
@@ -0,0 +1,57 @@
+<?php
+/**
+ * Template HTML override filter for the WC Email Template Sync test helper plugin.
+ *
+ * @package WC_Email_Template_Sync_Test_Helper
+ */
+
+declare( strict_types=1 );
+
+namespace WC_Email_Template_Sync_Test_Helper;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Filter wrapper that swaps in fixture-controlled canonical HTML for a given email type.
+ *
+ * Dormant when its driving option is empty.
+ */
+class Template_HTML_Overrides {
+
+ public const OPTION_NAME = 'wc_test_template_html_override';
+
+ /**
+ * Register the filter.
+ */
+ public function register(): void {
+ add_filter(
+ 'woocommerce_email_block_template_html',
+ array( $this, 'maybe_override' ),
+ 100,
+ 2
+ );
+ }
+
+ /**
+ * Conditionally swap the canonical HTML for a given email type when an override option is set.
+ *
+ * @param string $template_html The HTML produced by the upstream filter.
+ * @param mixed $email The WC_Email instance whose template is being rendered.
+ * @return string Possibly-overridden HTML.
+ */
+ public function maybe_override( string $template_html, $email ): string {
+ $overrides = get_option( self::OPTION_NAME, array() );
+ if ( ! is_array( $overrides ) || empty( $overrides ) ) {
+ return $template_html;
+ }
+
+ $email_id = is_object( $email ) && isset( $email->id ) ? (string) $email->id : '';
+ if ( '' === $email_id ) {
+ return $template_html;
+ }
+
+ return isset( $overrides[ $email_id ] )
+ ? (string) $overrides[ $email_id ]
+ : $template_html;
+ }
+}
diff --git a/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/includes/class-tracks-recorder.php b/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/includes/class-tracks-recorder.php
new file mode 100644
index 00000000000..8e41b611d5b
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/includes/class-tracks-recorder.php
@@ -0,0 +1,57 @@
+<?php
+/**
+ * Server-side Tracks event recorder for the WC Email Template Sync test helper plugin.
+ *
+ * @package WC_Email_Template_Sync_Test_Helper
+ */
+
+declare( strict_types=1 );
+
+namespace WC_Email_Template_Sync_Test_Helper;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Mirrors server-side recordEvent calls and the backfill-complete action to an option
+ * so Playwright tests can drain and assert. Dormant unless wc_test_tracks_enabled=yes.
+ */
+class Tracks_Recorder {
+
+ public const ENABLED_OPTION = 'wc_test_tracks_enabled';
+ public const LOG_OPTION = 'wc_test_tracks_log';
+
+ /**
+ * Register hooks.
+ */
+ public function register(): void {
+ add_filter( 'woocommerce_tracks_event_properties', array( $this, 'record' ), 100, 2 );
+ }
+
+ /**
+ * Append a record to the tracks log when enabled. Always returns the untouched properties.
+ *
+ * @param array $properties Event properties (passed through unchanged).
+ * @param string $event_name Event name.
+ * @return array
+ */
+ public function record( $properties, $event_name ): array {
+ if ( 'yes' !== get_option( self::ENABLED_OPTION, 'no' ) ) {
+ return is_array( $properties ) ? $properties : array();
+ }
+
+ $log = get_option( self::LOG_OPTION, array() );
+ if ( ! is_array( $log ) ) {
+ $log = array();
+ }
+
+ $log[] = array(
+ 'name' => (string) $event_name,
+ 'properties' => is_array( $properties ) ? $properties : array(),
+ 'timestamp_ms' => (int) ( microtime( true ) * 1000 ),
+ );
+
+ update_option( self::LOG_OPTION, $log, false );
+
+ return is_array( $properties ) ? $properties : array();
+ }
+}
diff --git a/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/wc-email-template-sync-test-helper.php b/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/wc-email-template-sync-test-helper.php
new file mode 100644
index 00000000000..00e4c552e95
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/test-plugins/wc-email-template-sync-test-helper/wc-email-template-sync-test-helper.php
@@ -0,0 +1,57 @@
+<?php
+/**
+ * Plugin Name: WC Email Template Sync Test Helper
+ * Description: E2E test fixture for RSM-146. Option-driven filters and REST endpoints used by Playwright tests. Dormant unless its driving options are set.
+ * Version: 1.0.0
+ * Requires PHP: 8.1
+ * Author: WooCommerce
+ *
+ * @package WC_Email_Template_Sync_Test_Helper
+ */
+
+declare( strict_types=1 );
+
+defined( 'ABSPATH' ) || exit;
+
+// This plugin is only mounted by .wp-env.json for E2E test environments — it does not ship
+// in any production WooCommerce build. REST permission callbacks still enforce manage_options.
+
+define( 'WC_EMAIL_TEMPLATE_SYNC_TEST_HELPER_DIR', plugin_dir_path( __FILE__ ) );
+
+require_once WC_EMAIL_TEMPLATE_SYNC_TEST_HELPER_DIR . 'includes/class-template-html-overrides.php';
+require_once WC_EMAIL_TEMPLATE_SYNC_TEST_HELPER_DIR . 'includes/class-opted-in-overrides.php';
+require_once WC_EMAIL_TEMPLATE_SYNC_TEST_HELPER_DIR . 'includes/class-tracks-recorder.php';
+require_once WC_EMAIL_TEMPLATE_SYNC_TEST_HELPER_DIR . 'includes/class-rest-controller.php';
+
+add_action(
+ 'plugins_loaded',
+ static function () {
+ ( new WC_Email_Template_Sync_Test_Helper\Template_HTML_Overrides() )->register();
+ ( new WC_Email_Template_Sync_Test_Helper\Opted_In_Overrides() )->register();
+ ( new WC_Email_Template_Sync_Test_Helper\Tracks_Recorder() )->register();
+ },
+ 20
+);
+
+add_action(
+ 'rest_api_init',
+ static function () {
+ ( new WC_Email_Template_Sync_Test_Helper\REST_Controller() )->register_routes();
+ }
+);
+
+// Register the fake third-party WC_Email subclass ONLY when an option signals tests want it.
+// The class file is lazy-loaded here because it extends WC_Email, which is not defined
+// until the woocommerce plugin has loaded — eager-loading at plugin-bootstrap time would
+// fatal on PHP class resolution.
+add_filter(
+ 'woocommerce_email_classes',
+ static function ( $emails ) {
+ if ( 'yes' !== get_option( 'wc_test_fake_third_party_email_enabled', 'no' ) ) {
+ return $emails;
+ }
+ require_once WC_EMAIL_TEMPLATE_SYNC_TEST_HELPER_DIR . 'includes/class-fake-third-party-email.php';
+ $emails['WC_Email_Template_Sync_Test_Helper\\Fake_Third_Party_Email'] = new \WC_Email_Template_Sync_Test_Helper\Fake_Third_Party_Email();
+ return $emails;
+ }
+);
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/backward-compat.spec.ts b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/backward-compat.spec.ts
new file mode 100644
index 00000000000..9b0a00b6788
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/backward-compat.spec.ts
@@ -0,0 +1,313 @@
+/**
+ * External dependencies
+ */
+import { test, expect } from '@playwright/test';
+import { createClient } from '@woocommerce/e2e-utils-playwright';
+
+/**
+ * Update-propagation: backward compatibility.
+ *
+ * Covers the five BC scenarios for sites that had email posts before the
+ * RSM-137 stamp meta was introduced: Case A (content matches core, no stamp),
+ * Case B (timestamps equal, content behind core), Case C (customized post —
+ * critical safety: content must never be overwritten), the no-mass-fire
+ * Tracks guard (backfill must not fire _available events), and idempotency
+ * (second backfill is a no-op).
+ *
+ * Reviewing in Playwright UI mode:
+ * 1. Run `npx playwright test --project=e2e tests/email-editor/update-propagation --ui`
+ * 2. Filter the tree by `backward-compat` and pick a test.
+ * 3. All tests are REST-only except "BC no mass-fire" which attaches a Tracks
+ * spy via the page fixture (but does not navigate or interact with the UI).
+ * For all tests, the Actions panel in UI mode shows the REST call sequence.
+ * 4. "Show browser" eye is not needed for any test in this file.
+ */
+
+/**
+ * Internal dependencies
+ */
+import { ADMIN_STATE_PATH } from '../../../playwright.config';
+import { admin } from '../../../test-data/data';
+import { enableEmailEditor } from '../helpers/enable-email-editor-feature';
+import {
+ clearTemplateHtmlOverride,
+ setTemplateHtmlOverride,
+} from './helpers/test-helper-plugin';
+import {
+ seedWooEmailPost,
+ getWooEmailMeta,
+ getWooEmailPostContent,
+} from './helpers/seed-woo-email';
+import {
+ triggerBackfill,
+ triggerDetectionSweep,
+} from './helpers/simulate-plugin-update';
+import { attachTracksSpy } from './helpers/tracks-spy';
+import { assertNoLeakedFixtureState } from './helpers/leaked-state-checks';
+import {
+ STATUS,
+ META_KEYS,
+ TRACKS_EVENTS,
+ TEST_HELPER_API_BASE,
+} from './helpers/classifications';
+
+const BACKFILL_COMPLETE_OPTION =
+ 'woocommerce_email_template_sync_backfill_complete';
+
+// One-shot Tracks guard added by RSM-145: fires _backfill_completed at most
+// once per site. Must be cleared alongside BACKFILL_COMPLETE_OPTION so the
+// Tracks spy can observe the event each time a BC test re-runs the backfill.
+const BACKFILL_COMPLETED_TRACKED_OPTION =
+ 'wc_email_sync_backfill_completed_tracked';
+
+const OLD_HTML = '<!-- wp:paragraph --><p>OLD</p><!-- /wp:paragraph -->';
+
+async function resetBackfillFence( baseURL: string ): Promise< void > {
+ const client = createClient( baseURL, {
+ type: 'basic',
+ username: admin.username,
+ password: admin.password,
+ } );
+ await client.post( `${ TEST_HELPER_API_BASE }/delete-option`, {
+ option_name: BACKFILL_COMPLETE_OPTION,
+ } );
+ await client.post( `${ TEST_HELPER_API_BASE }/delete-option`, {
+ option_name: BACKFILL_COMPLETED_TRACKED_OPTION,
+ } );
+}
+
+test.describe( 'Update propagation — backward compatibility', () => {
+ test.use( { storageState: ADMIN_STATE_PATH } );
+
+ test.beforeAll( async ( { baseURL } ) => {
+ await enableEmailEditor( baseURL! );
+ } );
+
+ test.beforeEach( async ( { baseURL } ) => {
+ // RSM-145 stamps this option on fresh installs via woocommerce_newly_installed
+ // to suppress backfill on greenfield environments. BC scenarios need a clean
+ // "pre-RSM-137" environment, so clear the option-fence before each test.
+ await resetBackfillFence( baseURL! );
+ } );
+
+ test.afterEach( async () => {
+ await assertNoLeakedFixtureState();
+ } );
+
+ /**
+ * Verifies that a pre-RSM-137 post whose content already matches the current
+ * canonical is stamped in_sync by the backfill and correctly participates in
+ * subsequent detection sweeps.
+ *
+ * UI mode walkthrough:
+ * REST-only — no browser interaction. Actions panel shows: seedWooEmailPost
+ * (stripStampMeta) → triggerBackfill → getWooEmailMeta assertions →
+ * setTemplateHtmlOverride → triggerDetectionSweep → meta re-check.
+ *
+ * "Show browser" eye: not needed.
+ */
+ test( 'BC Case A — content matches current core, no stamp meta', async () => {
+ const postId = await seedWooEmailPost( {
+ emailId: 'new_order',
+ stripStampMeta: true,
+ } );
+
+ const backfill = await triggerBackfill();
+ expect( backfill.stamped ).toBeGreaterThanOrEqual( 1 );
+
+ const meta = await getWooEmailMeta( postId );
+ expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe( STATUS.IN_SYNC );
+ expect( meta[ META_KEYS.SOURCE_HASH ]?.[ 0 ] ).toBeTruthy();
+
+ // Simulate a core bump by setting the override to a different canonical.
+ await setTemplateHtmlOverride( 'new_order', OLD_HTML );
+ await triggerDetectionSweep();
+ await clearTemplateHtmlOverride();
+
+ // The sweep classifies the unmodified post as core_updated_uncustomized,
+ // then the auto-applier (run inline by /trigger-sweep) silently applies
+ // the new canonical and re-stamps the post as in_sync.
+ const metaAfter = await getWooEmailMeta( postId );
+ expect( metaAfter[ META_KEYS.STATUS ]?.[ 0 ] ).toBe( STATUS.IN_SYNC );
+ } );
+
+ /**
+ * Verifies that a pre-RSM-137 post with equal created/modified timestamps
+ * (indicating the content was never edited) is silently updated to the current
+ * canonical during backfill and stamped in_sync.
+ *
+ * UI mode walkthrough:
+ * REST-only — no browser interaction. Actions panel shows: seedWooEmailPost
+ * (stripStampMeta, equal timestamps, old content) → triggerBackfill →
+ * meta assertion → REST GET to verify post_content was rewritten to canonical.
+ *
+ * "Show browser" eye: not needed.
+ */
+ test( 'BC Case B — timestamps equal and content behind core', async () => {
+ const ts = '2024-01-01 12:00:00';
+
+ const postId = await seedWooEmailPost( {
+ emailId: 'new_order',
+ postContent: OLD_HTML,
+ postDateGmt: ts,
+ postModifiedGmt: ts,
+ stripStampMeta: true,
+ } );
+
+ const backfill = await triggerBackfill();
+ expect( backfill.stamped ).toBeGreaterThanOrEqual( 1 );
+
+ const meta = await getWooEmailMeta( postId );
+ expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe( STATUS.IN_SYNC );
+
+ // Critical: the backfill rewrote post_content from OLD_HTML to current canonical.
+ const content = await getWooEmailPostContent( postId );
+ expect( content ).not.toContain( 'OLD' );
+ } );
+
+ /**
+ * Critical safety test: verifies that a pre-RSM-137 post whose content diverges
+ * from canonical (i.e., the merchant edited it) is stamped core_updated_customized
+ * by the backfill and that its content is NEVER overwritten — neither during
+ * backfill nor during subsequent detection sweeps.
+ *
+ * UI mode walkthrough:
+ * REST-only — no browser interaction. Actions panel shows: seedWooEmailPost
+ * (stripStampMeta, customized content) → triggerBackfill → meta + content
+ * assertions → setTemplateHtmlOverride → triggerDetectionSweep → repeat
+ * meta + content assertions confirming the merchant text is still intact.
+ *
+ * "Show browser" eye: not needed.
+ */
+ test( '@pr BC Case C — customized post content preserved (critical safety)', async () => {
+ const customized =
+ '<!-- wp:paragraph --><p>MERCHANT CUSTOM 1234</p><!-- /wp:paragraph -->';
+
+ const postId = await seedWooEmailPost( {
+ emailId: 'new_order',
+ postContent: customized,
+ postDateGmt: '2024-01-01 12:00:00',
+ postModifiedGmt: '2024-06-15 09:00:00',
+ stripStampMeta: true,
+ } );
+
+ const backfill = await triggerBackfill();
+ expect( backfill.stamped ).toBeGreaterThanOrEqual( 1 );
+
+ let meta = await getWooEmailMeta( postId );
+ // Case C: content differs from canonical AND the post has been edited.
+ // Backfill stamps core_updated_customized (does NOT rewrite post_content).
+ expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe(
+ STATUS.CORE_UPDATED_CUSTOMIZED
+ );
+
+ // CRITICAL: post content must be UNTOUCHED by backfill.
+ const contentAfterBackfill = await getWooEmailPostContent( postId );
+ expect( contentAfterBackfill ).toContain( 'MERCHANT CUSTOM 1234' );
+
+ await setTemplateHtmlOverride( 'new_order', OLD_HTML );
+ await triggerDetectionSweep();
+ await clearTemplateHtmlOverride();
+
+ meta = await getWooEmailMeta( postId );
+ // Safety claim: classification is CUSTOMIZED, not UNCUSTOMIZED.
+ expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe(
+ STATUS.CORE_UPDATED_CUSTOMIZED
+ );
+
+ const contentAfterBump = await getWooEmailPostContent( postId );
+ expect( contentAfterBump ).toContain( 'MERCHANT CUSTOM 1234' );
+ } );
+
+ /**
+ * Verifies that running backfill + detection sweep on a full set of 11 email
+ * types fires exactly one _backfill_completed Tracks event and zero
+ * _available events (guarding against a mass notification storm on upgrade).
+ *
+ * UI mode walkthrough:
+ * The page fixture is used only to attach the Tracks spy — no navigation
+ * or UI interaction occurs. The spy intercepts server-side Tracks events via
+ * REST. Actions panel shows: seedWooEmailPost (×11) → triggerBackfill →
+ * triggerDetectionSweep → spy.drain() → event count assertions.
+ *
+ * "Show browser" eye: not needed.
+ */
+ test( 'BC no mass-fire on first upgrade: zero _available, one _backfill_completed', async ( {
+ page,
+ } ) => {
+ const spy = await attachTracksSpy( page );
+
+ const emailIds = [
+ 'new_order',
+ 'cancelled_order',
+ 'failed_order',
+ 'customer_on_hold_order',
+ 'customer_processing_order',
+ 'customer_completed_order',
+ 'customer_refunded_order',
+ 'customer_invoice',
+ 'customer_note',
+ 'customer_reset_password',
+ 'customer_new_account',
+ ];
+ for ( const id of emailIds ) {
+ await seedWooEmailPost( {
+ emailId: id,
+ stripStampMeta: true,
+ } );
+ }
+
+ const backfill = await triggerBackfill();
+ expect( backfill.stamped ).toBeGreaterThanOrEqual( emailIds.length );
+
+ await triggerDetectionSweep();
+
+ // Drain all server + client events in one call. Each expectFired/expectNotFired
+ // call invokes drain() independently, which reads and deletes the server log —
+ // a second call would see an empty log. Assert both conditions against the same
+ // snapshot to avoid missing events.
+ const events = await spy.drain();
+ const available = events.filter(
+ ( e ) => e.name === TRACKS_EVENTS.AVAILABLE
+ );
+ const backfillCompleted = events.filter(
+ ( e ) => e.name === TRACKS_EVENTS.BACKFILL_COMPLETED
+ );
+ expect(
+ available.length,
+ 'No _available events should fire during backfill'
+ ).toBe( 0 );
+ expect(
+ backfillCompleted.length,
+ 'Exactly one _backfill_completed event should fire'
+ ).toBe( 1 );
+ } );
+
+ /**
+ * Verifies that running the backfill twice produces identical post meta,
+ * confirming the migration is safe to re-run (e.g., in case of interrupted
+ * deploys or duplicate cron fires).
+ *
+ * UI mode walkthrough:
+ * REST-only — no browser interaction. Actions panel shows: seedWooEmailPost
+ * (stripStampMeta) → triggerBackfill (first) → getWooEmailMeta snapshot →
+ * triggerBackfill (second) → getWooEmailMeta equality assertion.
+ *
+ * "Show browser" eye: not needed.
+ */
+ test( 'BC migration is idempotent: second backfill is a no-op', async () => {
+ const postId = await seedWooEmailPost( {
+ emailId: 'new_order',
+ stripStampMeta: true,
+ } );
+
+ const first = await triggerBackfill();
+ expect( first.stamped ).toBeGreaterThanOrEqual( 1 );
+ const metaAfterFirst = await getWooEmailMeta( postId );
+
+ await triggerBackfill();
+ const metaAfterSecond = await getWooEmailMeta( postId );
+
+ expect( metaAfterSecond ).toEqual( metaAfterFirst );
+ } );
+} );
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/core-flows.spec.ts b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/core-flows.spec.ts
new file mode 100644
index 00000000000..7060ff78b67
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/core-flows.spec.ts
@@ -0,0 +1,452 @@
+/**
+ * External dependencies
+ */
+import { test, expect } from '@playwright/test';
+
+/**
+ * Update-propagation: core flows.
+ *
+ * Covers the merchant-facing lifecycle of a core-template update: divergence
+ * detection, the update-available indicator on the list + editor banner,
+ * auto-apply for unmodified posts, selective apply for customized posts, the
+ * dismiss flow, and the review-drawer-driven selective merge.
+ *
+ * Reviewing in Playwright UI mode:
+ * 1. Run `npx playwright test --project=e2e tests/email-editor/update-propagation --ui`
+ * 2. Filter the tree by `core-flows` and pick a test.
+ * 3. For UI tests, toggle "Show browser" (👁 in the top-left toolbar) to watch the
+ * Chromium window drive the admin. For REST-only tests the Actions panel
+ * shows the REST call sequence — no browser needed.
+ * 4. Per-test JSDoc below indicates whether each test drives the browser.
+ */
+
+/**
+ * Internal dependencies
+ */
+import { ADMIN_STATE_PATH } from '../../../playwright.config';
+import { enableEmailEditor } from '../helpers/enable-email-editor-feature';
+import { accessTheEmailEditor } from '../../../utils/email';
+import {
+ clearTemplateHtmlOverride,
+ setTemplateHtmlOverride,
+} from './helpers/test-helper-plugin';
+import {
+ seedWooEmailPost,
+ getWooEmailMeta,
+ getWooEmailPostContent,
+ applyWooEmailTemplate,
+} from './helpers/seed-woo-email';
+import {
+ simulateCoreBump,
+ triggerDetectionSweep,
+} from './helpers/simulate-plugin-update';
+import { attachTracksSpy } from './helpers/tracks-spy';
+import { assertNoLeakedFixtureState } from './helpers/leaked-state-checks';
+import { STATUS, META_KEYS, TRACKS_EVENTS } from './helpers/classifications';
+
+const OLD_HTML =
+ '<!-- wp:paragraph --><p>OLD CANONICAL</p><!-- /wp:paragraph -->';
+
+test.describe( 'Update propagation — core flows', () => {
+ test.use( { storageState: ADMIN_STATE_PATH } );
+
+ test.beforeAll( async ( { baseURL } ) => {
+ await enableEmailEditor( baseURL! );
+ } );
+
+ test.afterEach( async () => {
+ await assertNoLeakedFixtureState();
+ } );
+
+ /**
+ * Verifies that running the detection sweep after a core bump correctly
+ * classifies an unmodified post as auto-applied (in_sync) and a merchant-
+ * customized post as core_updated_customized, waiting for manual review.
+ *
+ * UI mode walkthrough:
+ * REST-only — no browser interaction. Actions panel shows the REST call
+ * sequence: simulateCoreBump → seedWooEmailPost (×2) → clearTemplateHtmlOverride
+ * → triggerDetectionSweep → getWooEmailMeta assertions.
+ *
+ * "Show browser" eye: not needed.
+ */
+ test( '@pr Plugin update triggers divergence detection and classifies posts', async () => {
+ // Bump and seed the uncustomized post.
+ await simulateCoreBump( 'new_order', OLD_HTML );
+ const uncustomizedPostId = await seedWooEmailPost( {
+ emailId: 'new_order',
+ postContent: OLD_HTML,
+ storedSourceHash: 'AUTO_CURRENT',
+ status: STATUS.IN_SYNC,
+ } );
+
+ // Bump and seed the customized post (override is single-key, replacing the
+ // previous one — but new_order's stored hash was already captured at seed time).
+ await simulateCoreBump( 'customer_processing_order', OLD_HTML );
+ const customizedHtml = OLD_HTML.replace(
+ 'OLD CANONICAL',
+ 'MERCHANT EDITED'
+ );
+ const customizedPostId = await seedWooEmailPost( {
+ emailId: 'customer_processing_order',
+ postContent: customizedHtml,
+ storedSourceHash: 'AUTO_CURRENT',
+ status: STATUS.IN_SYNC,
+ } );
+
+ await clearTemplateHtmlOverride();
+
+ const sweep = await triggerDetectionSweep();
+
+ const uncustomizedMeta = await getWooEmailMeta( uncustomizedPostId );
+ const customizedMeta = await getWooEmailMeta( customizedPostId );
+
+ // The sweep classifies the unmodified post as core_updated_uncustomized,
+ // then the auto-applier (also fired by /trigger-sweep inline) silently
+ // applies the new canonical and re-stamps the post as in_sync.
+ expect( uncustomizedMeta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe(
+ STATUS.IN_SYNC
+ );
+ // Customized posts are left for the merchant to apply manually.
+ expect( customizedMeta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe(
+ STATUS.CORE_UPDATED_CUSTOMIZED
+ );
+ expect( sweep.touched ).toBeGreaterThanOrEqual( 2 );
+ } );
+
+ /**
+ * Verifies that a core_updated_customized post surfaces a "Review update"
+ * button on the email list page and a "Template update available" banner
+ * inside the block editor.
+ *
+ * UI mode walkthrough:
+ * After REST setup the test navigates to WP Admin → WooCommerce → Settings →
+ * Email. The DataViews table loads and the "New order" row should contain a
+ * "Review update" button. The test then opens the email in the block editor
+ * and asserts the "Template update available" status banner is visible. No
+ * clicks — both assertions are visibility checks only.
+ *
+ * "Show browser" eye: ON.
+ */
+ test( '@pr Update-available indicator appears on email list and in editor', async ( {
+ page,
+ } ) => {
+ await simulateCoreBump( 'new_order', OLD_HTML );
+ await seedWooEmailPost( {
+ emailId: 'new_order',
+ postContent: OLD_HTML.replace( 'OLD CANONICAL', 'MERCHANT EDIT' ),
+ storedSourceHash: 'AUTO_CURRENT',
+ status: STATUS.IN_SYNC,
+ // Seed an older version so the registry's current_version is higher.
+ // The list cell and editor banner only show when
+ // templateVersion < currentVersion; same-version posts don't surface
+ // the indicator even when status is core_updated_customized.
+ version: '10.0.0',
+ } );
+ await clearTemplateHtmlOverride();
+ await triggerDetectionSweep();
+
+ await page.goto( '/wp-admin/admin.php?page=wc-settings&tab=email' );
+ // DataViews table rows have no aria-label, so getByRole('row', {name:...})
+ // doesn't work. Use filter({ hasText }) to scope to the New order row.
+ // The Updates column renders a secondary Button labelled "Review update"
+ // when the post is core_updated_customized. The text "Update available"
+ // only appears in the filter-dropdown elements, not in the row cell itself.
+ const newOrderRow = page
+ .locator( 'tr' )
+ .filter( { hasText: /New order/i } )
+ .first();
+ await expect(
+ newOrderRow.getByRole( 'button', { name: /review update/i } )
+ ).toBeVisible( { timeout: 15000 } );
+
+ await accessTheEmailEditor( page, 'New order' );
+ // The editor banner title is "Template update available" (role="status").
+ await expect(
+ page.getByText( /template update available/i ).first()
+ ).toBeVisible( { timeout: 15000 } );
+ } );
+
+ /**
+ * Verifies that an unmodified post is silently brought back to in_sync by the
+ * auto-applier, with no "Update available" indicator on the list and no
+ * Tracks events fired for update-available or dismissed.
+ *
+ * UI mode walkthrough:
+ * The page fixture is used only to attach the Tracks spy and to navigate to
+ * the email list for the "no indicator" assertion — no clicks are performed.
+ * You'll see the browser open the email settings page and the test confirms
+ * the "Update available" text is hidden in the New order row.
+ *
+ * "Show browser" eye: ON.
+ */
+ test( '@pr Auto-apply succeeds silently for unmodified posts', async ( {
+ page,
+ } ) => {
+ const spy = await attachTracksSpy( page );
+
+ await simulateCoreBump( 'new_order', OLD_HTML );
+ const postId = await seedWooEmailPost( {
+ emailId: 'new_order',
+ postContent: OLD_HTML,
+ storedSourceHash: 'AUTO_CURRENT',
+ status: STATUS.IN_SYNC,
+ } );
+ await clearTemplateHtmlOverride();
+
+ await triggerDetectionSweep();
+
+ const meta = await getWooEmailMeta( postId );
+ expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe( STATUS.IN_SYNC );
+
+ // DataViews rows have no aria-label, so getByRole('row', {name:...}) doesn't
+ // match. Use locator('tr').filter({hasText}) — same approach as test 2 —
+ // then assert the "Review update" button is absent (toHaveCount(0)) which is
+ // what actually surfaces when a post is core_updated_customized.
+ await page.goto( '/wp-admin/admin.php?page=wc-settings&tab=email' );
+ const newOrderRow = page
+ .locator( 'tr' )
+ .filter( { hasText: /New order/i } )
+ .first();
+ await expect(
+ newOrderRow.getByRole( 'button', { name: /review update/i } )
+ ).toHaveCount( 0 );
+
+ await spy.expectNotFired( TRACKS_EVENTS.AVAILABLE );
+ await spy.expectNotFired( TRACKS_EVENTS.DISMISSED );
+ } );
+
+ /**
+ * Verifies that calling the apply endpoint with choices:[] (keep-yours default)
+ * applies core additions while preserving merchant edits, and leaves the post
+ * stamped core_updated_customized because the content still diverges from canonical.
+ *
+ * UI mode walkthrough:
+ * REST-only — no browser interaction. Actions panel shows: simulateCoreBump
+ * → seedWooEmailPost → clearTemplateHtmlOverride → triggerDetectionSweep
+ * → applyWooEmailTemplate (REST POST) → getWooEmailMeta + content assertions.
+ *
+ * "Show browser" eye: not needed.
+ */
+ test( '@pr Selective apply succeeds and preserves customizations', async () => {
+ const oldHtml =
+ '<!-- wp:paragraph --><p>OLD CORE</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>SECOND BLOCK</p><!-- /wp:paragraph -->';
+ const customized = oldHtml.replace(
+ 'SECOND BLOCK',
+ 'MERCHANT EDITED SECOND'
+ );
+
+ await simulateCoreBump( 'new_order', oldHtml );
+ const postId = await seedWooEmailPost( {
+ emailId: 'new_order',
+ postContent: customized,
+ storedSourceHash: 'AUTO_CURRENT',
+ status: STATUS.IN_SYNC,
+ } );
+ await clearTemplateHtmlOverride();
+ await triggerDetectionSweep();
+
+ // Use applyWooEmailTemplate (basic auth) instead of request.post (cookie auth)
+ // because WP REST POST endpoints require a nonce when using cookie-based auth.
+ // choices: [] keeps all merchant edits and applies only core additions.
+ const apply = await applyWooEmailTemplate( postId, [] );
+ expect( apply.status ).toBe( 'applied' );
+
+ const meta = await getWooEmailMeta( postId );
+ // With choices:[] the merchant's edits are preserved (keep_yours is the
+ // default for copy_changes). The merged result diverges from canonical, so
+ // the applier stamps core_updated_customized — not in_sync.
+ expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe(
+ STATUS.CORE_UPDATED_CUSTOMIZED
+ );
+
+ const content = await getWooEmailPostContent( postId );
+ expect( content ).toContain( 'MERCHANT EDITED SECOND' );
+ } );
+
+ /**
+ * Verifies that clicking the "dismiss" button on the editor update banner fires
+ * the expected Tracks dismissed event.
+ *
+ * UI mode walkthrough:
+ * After REST setup the test opens the block editor for the New order email.
+ * The editor canvas loads, and the update banner is visible at the top. If
+ * the review drawer is already open it is closed via Escape. Then the test
+ * clicks the banner's dismiss button (`.wc-update-banner__dismiss`) and
+ * asserts the Tracks dismissed event fired.
+ *
+ * "Show browser" eye: ON.
+ */
+ test( '@pr Dismiss flow records the dismissed Tracks event', async ( {
+ page,
+ } ) => {
+ const customized = OLD_HTML.replace( 'OLD CANONICAL', 'MERCHANT EDIT' );
+
+ const spy = await attachTracksSpy( page );
+
+ await simulateCoreBump( 'new_order', OLD_HTML );
+ await seedWooEmailPost( {
+ emailId: 'new_order',
+ postContent: customized,
+ storedSourceHash: 'AUTO_CURRENT',
+ status: STATUS.IN_SYNC,
+ // Seed an older version so the registry's current_version is higher.
+ // The editor banner only shows when templateVersion < currentVersion;
+ // same-version posts surface summaryShowsReviewed=true and unmount
+ // the banner before the dismiss button can be clicked.
+ version: '10.0.0',
+ } );
+ await clearTemplateHtmlOverride();
+ await triggerDetectionSweep();
+
+ await accessTheEmailEditor( page, 'New order' );
+
+ // If the review drawer happened to open (e.g., via a deep-link or
+ // store state from a prior navigation), close it before looking for
+ // the banner's dismiss button so the drawer panel doesn't obscure it.
+ const drawer = page.getByRole( 'dialog', {
+ name: /review template update/i,
+ } );
+ if ( await drawer.isVisible() ) {
+ await page.keyboard.press( 'Escape' );
+ await drawer.waitFor( { state: 'hidden' } );
+ }
+
+ // Target the banner's dismiss button by its stable CSS class to avoid
+ // matching any other "dismiss"-labelled button that may be on the page.
+ const dismissButton = page.locator( '.wc-update-banner__dismiss' );
+ await expect( dismissButton ).toBeVisible( { timeout: 15000 } );
+ await dismissButton.click();
+
+ await spy.expectFired( TRACKS_EVENTS.DISMISSED );
+ } );
+
+ /**
+ * Verifies that the review drawer allows per-conflict "keep yours" / "use core"
+ * choices and that clicking Apply merges exactly the selected blocks into the
+ * saved post content.
+ *
+ * UI mode walkthrough:
+ * After REST setup the test navigates directly to the editor with the
+ * `wc_email_review_drawer=1` deep-link param, which auto-opens the review
+ * drawer. The drawer loads a change summary showing three conflicts. The test
+ * leaves block A on "keep yours" (default), switches block B to "use core"
+ * via a radio button click, then clicks Apply. The drawer closes and the
+ * test verifies the merged content via REST (block A: merchant text kept,
+ * block B: core text applied, block C: default kept).
+ *
+ * "Show browser" eye: ON.
+ */
+ test( 'Review drawer: pick per-conflict yours vs core and apply', async ( {
+ page,
+ } ) => {
+ // We need real copy_changes in the change-summary, which only appear when
+ // the LCS diff matches blocks by name and finds text differences.
+ // Strategy: use setTemplateHtmlOverride for BOTH the "old" canonical
+ // (to seed storedSourceHash) AND the "new" canonical (to control the
+ // change-summary diff), keeping the same block structure with changed text.
+ const oldHtml =
+ '<!-- wp:paragraph --><p>OLD BLOCK A</p><!-- /wp:paragraph -->' +
+ '<!-- wp:paragraph --><p>OLD BLOCK B</p><!-- /wp:paragraph -->' +
+ '<!-- wp:paragraph --><p>OLD BLOCK C</p><!-- /wp:paragraph -->';
+
+ // Merchant edited block A; blocks B and C kept the original text.
+ const customized = oldHtml.replace(
+ 'OLD BLOCK A',
+ 'MERCHANT EDITED A'
+ );
+
+ // "New canonical" after a core bump: core changed text in B and C,
+ // but A still matches nothing (it will conflict with merchant's edit).
+ const newCanonical =
+ '<!-- wp:paragraph --><p>NEW CORE A</p><!-- /wp:paragraph -->' +
+ '<!-- wp:paragraph --><p>NEW CORE B</p><!-- /wp:paragraph -->' +
+ '<!-- wp:paragraph --><p>NEW CORE C</p><!-- /wp:paragraph -->';
+
+ // Step 1: set override = oldHtml so that AUTO_CURRENT resolves to sha1(oldHtml).
+ await simulateCoreBump( 'new_order', oldHtml );
+
+ // Step 2: seed the post — stored hash = sha1(oldHtml), content = merchant edits.
+ const postId = await seedWooEmailPost( {
+ emailId: 'new_order',
+ postContent: customized,
+ storedSourceHash: 'AUTO_CURRENT',
+ status: STATUS.IN_SYNC,
+ // Use an older version so the registry's current_version is higher and
+ // the editor banner renders (version_from < version_to).
+ version: '10.0.0',
+ } );
+
+ // Step 3: swap the override to the "new" canonical. The sweep and the
+ // change-summary endpoint will now compare the post against newCanonical.
+ await setTemplateHtmlOverride( 'new_order', newCanonical );
+
+ // Step 4: sweep classifies the post as core_updated_customized.
+ await triggerDetectionSweep();
+
+ // Open the editor and click the banner's "Review changes" button — the
+ // merchant-facing path to open the drawer. (The wc_email_review_drawer=1
+ // deep-link works locally but races with editor mount in CI; clicking the
+ // banner button is the realistic flow and is stable.)
+ await page.goto( `/wp-admin/post.php?post=${ postId }&action=edit` );
+
+ // Wait for the editor canvas to be ready.
+ await expect( page.locator( '#woocommerce-email-editor' ) ).toBeVisible(
+ {
+ timeout: 20000,
+ }
+ );
+
+ // Click "Review changes" in the floating update banner.
+ await page.getByRole( 'button', { name: /^review changes$/i } ).click();
+
+ // The drawer's <aside role="dialog"> becomes aria-hidden="false" once the
+ // store dispatches openReviewDrawer(). The title text comes from the
+ // "Review template update" h2 inside the drawer header.
+ const drawer = page.getByRole( 'dialog', {
+ name: /review template update/i,
+ } );
+ await expect( drawer ).toBeVisible( { timeout: 15000 } );
+
+ // The change-summary fetch is triggered by the drawer's useChangeSummary
+ // hook (enabled = isOpen = true). Wait for the "Needs your attention"
+ // heading — the diff outcome (how many conflicts vs auto-resolved blocks)
+ // depends on the differ; the test stays resilient by interacting only
+ // with the first radiogroup and asserting content after apply.
+ await expect(
+ drawer.getByRole( 'heading', { name: /needs your attention/i } )
+ ).toBeVisible( { timeout: 15000 } );
+
+ // Pick the first radiogroup's "Use core" — flips the default from
+ // "Keep yours" so the merchant's edit on block A is overwritten by core.
+ const firstRadioGroup = drawer
+ .getByRole( 'radiogroup', {
+ name: /choose which version to apply/i,
+ } )
+ .first();
+ await expect(
+ firstRadioGroup.getByRole( 'radio', { name: /keep yours/i } )
+ ).toHaveAttribute( 'aria-checked', 'true' );
+
+ await firstRadioGroup
+ .getByRole( 'radio', { name: /use core/i } )
+ .click();
+ await expect(
+ firstRadioGroup.getByRole( 'radio', { name: /use core/i } )
+ ).toHaveAttribute( 'aria-checked', 'true' );
+
+ // Click Apply — label is "Apply (N)" where N = total changes.
+ await drawer.getByRole( 'button', { name: /^apply/i } ).click();
+
+ // Drawer closes after a successful apply.
+ await expect( drawer ).toBeHidden( { timeout: 15000 } );
+
+ // Verify the merged post content via REST. The single decision we made
+ // was on block A's conflict ("use core"), so MERCHANT EDITED A must be
+ // gone and NEW CORE A must be present. We don't assert on B/C here
+ // because the differ may treat them as conflicts or auto-resolved.
+ const content = await getWooEmailPostContent( postId );
+ expect( content ).toContain( 'NEW CORE A' );
+ expect( content ).not.toContain( 'MERCHANT EDITED A' );
+ } );
+} );
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/classifications.ts b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/classifications.ts
new file mode 100644
index 00000000000..993fe9344a1
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/classifications.ts
@@ -0,0 +1,59 @@
+/**
+ * Shared constants for the update-propagation E2E suite.
+ *
+ * Mirror the PHP-side meta keys and status values from
+ * Automattic\WooCommerce\Internal\EmailEditor\WCTransactionalEmails\WCEmailTemplateDivergenceDetector
+ * and the Tracks event names from RSM-145 (PR #64759).
+ *
+ * Event-name conventions (post-#64759 rename):
+ *
+ * Client-side events (fired via @woocommerce/tracks recordEvent, captured by
+ * the window.wcTracks.recordEvent spy as-is — no prefix added by the package):
+ * block_email_update_viewed
+ * block_email_update_applied
+ * block_email_update_dismissed
+ *
+ * Server-side events (fired via WC_Tracks::record_event(), captured by the
+ * Tracks_Recorder woocommerce_tracks_event_properties filter which receives the
+ * name already prefixed with "wcadmin_" by WC_Tracks::PREFIX):
+ * wcadmin_block_email_update_available
+ * wcadmin_block_email_update_applied
+ * wcadmin_block_email_sync_backfill_completed
+ */
+
+export const STATUS = {
+ IN_SYNC: 'in_sync',
+ CORE_UPDATED_UNCUSTOMIZED: 'core_updated_uncustomized',
+ CORE_UPDATED_CUSTOMIZED: 'core_updated_customized',
+} as const;
+
+export type Status = ( typeof STATUS )[ keyof typeof STATUS ];
+
+export const META_KEYS = {
+ STATUS: '_wc_email_template_status',
+ SOURCE_HASH: '_wc_email_template_source_hash',
+ SOURCE_VERSION: '_wc_email_template_version',
+ LAST_SYNCED_AT: '_wc_email_last_synced_at',
+ BACKFILLED: '_wc_email_backfilled',
+} as const;
+
+export const TRACKS_EVENTS = {
+ // Server-side: WC_Tracks::record_event() adds "wcadmin_" prefix before
+ // the woocommerce_tracks_event_properties filter fires, so the recorder
+ // captures these with the prefix already applied.
+ AVAILABLE: 'wcadmin_block_email_update_available',
+ BACKFILL_COMPLETED: 'wcadmin_block_email_sync_backfill_completed',
+ // Client-side: @woocommerce/tracks recordEvent() passes the name as-is
+ // to window.wcTracks.recordEvent; the spy captures it without any prefix.
+ VIEWED: 'block_email_update_viewed',
+ APPLIED: 'block_email_update_applied',
+ DISMISSED: 'block_email_update_dismissed',
+} as const;
+
+export const BACKFILL_CASES = {
+ A: 'A',
+ B: 'B',
+ C: 'C',
+} as const;
+
+export const TEST_HELPER_API_BASE = 'wc-email-test-helper/v1';
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/leaked-state-checks.ts b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/leaked-state-checks.ts
new file mode 100644
index 00000000000..7c5e96f848e
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/leaked-state-checks.ts
@@ -0,0 +1,51 @@
+/**
+ * External dependencies
+ */
+import { expect } from '@playwright/test';
+import { createClient } from '@woocommerce/e2e-utils-playwright';
+
+/**
+ * Internal dependencies
+ */
+import { admin } from '../../../../test-data/data';
+import playwrightConfig from '../../../../playwright.config';
+import { TEST_HELPER_API_BASE } from './classifications';
+import {
+ clearAllTemplateHtmlOverrides,
+ clearOptedInOverride,
+ clearTransactionalEmailsOverride,
+ disableTracksLog,
+ disableFakeThirdPartyEmail,
+} from './test-helper-plugin';
+
+const baseURL = playwrightConfig.use?.baseURL ?? '';
+
+function apiClient() {
+ return createClient( baseURL, {
+ type: 'basic',
+ username: admin.username,
+ password: admin.password,
+ } );
+}
+
+/**
+ * Call from afterEach in every spec. Snapshots fixture state, force-cleans
+ * everything, and asserts the snapshot was clean. A test that triggers leaked
+ * cleanup fails the run even if its body assertions passed — the next test
+ * still starts from a clean slate because cleanup ran before the assertion.
+ */
+export async function assertNoLeakedFixtureState(): Promise< void > {
+ const client = apiClient();
+ const tracksRes = await client.get( `${ TEST_HELPER_API_BASE }/tracks` );
+ const trackCount = ( ( tracksRes?.data?.events ?? [] ) as unknown[] )
+ .length;
+
+ await clearAllTemplateHtmlOverrides();
+ await clearOptedInOverride();
+ await clearTransactionalEmailsOverride();
+ await disableTracksLog();
+ await disableFakeThirdPartyEmail();
+ await client.delete( `${ TEST_HELPER_API_BASE }/tracks`, {} );
+
+ expect( trackCount, 'Tracks log not drained at end of test' ).toBe( 0 );
+}
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/seed-woo-email.ts b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/seed-woo-email.ts
new file mode 100644
index 00000000000..de878f590b2
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/seed-woo-email.ts
@@ -0,0 +1,275 @@
+/**
+ * External dependencies
+ */
+import { createClient } from '@woocommerce/e2e-utils-playwright';
+
+/**
+ * Internal dependencies
+ */
+import { admin } from '../../../../test-data/data';
+import playwrightConfig from '../../../../playwright.config';
+import {
+ META_KEYS,
+ TEST_HELPER_API_BASE,
+ type Status,
+} from './classifications';
+
+export type WooEmailSeed = {
+ emailId: string;
+ postContent?: string;
+ storedSourceHash?: string | 'AUTO_OLD' | 'AUTO_CURRENT';
+ status?: Status | null;
+ version?: string | null;
+ postDateGmt?: string;
+ postModifiedGmt?: string;
+ stripStampMeta?: boolean;
+};
+
+const baseURL = playwrightConfig.use?.baseURL ?? '';
+
+function apiClient() {
+ return createClient( baseURL, {
+ type: 'basic',
+ username: admin.username,
+ password: admin.password,
+ } );
+}
+
+export async function resetWooEmailPost( emailId: string ): Promise< number > {
+ const client = apiClient();
+ const res = await client.post(
+ `${ TEST_HELPER_API_BASE }/reset-post/${ encodeURIComponent(
+ emailId
+ ) }`,
+ {}
+ );
+ const body = res?.data;
+ if ( ! body?.post_id ) {
+ throw new Error(
+ `resetWooEmailPost: missing post_id in response for ${ emailId }`
+ );
+ }
+ return Number( body.post_id );
+}
+
+async function resolveHash(
+ emailId: string,
+ hashSpec: string | 'AUTO_OLD' | 'AUTO_CURRENT'
+): Promise< string > {
+ if ( hashSpec !== 'AUTO_OLD' && hashSpec !== 'AUTO_CURRENT' ) {
+ return hashSpec;
+ }
+ const client = apiClient();
+ const mode = hashSpec === 'AUTO_OLD' ? 'old' : 'current';
+ const res = await client.get(
+ `${ TEST_HELPER_API_BASE }/canonical-hash/${ encodeURIComponent(
+ emailId
+ ) }?mode=${ mode }`
+ );
+ const body = res?.data;
+ if ( ! body?.hash ) {
+ throw new Error(
+ `Failed to resolve hash for ${ emailId } mode=${ mode }`
+ );
+ }
+ return String( body.hash );
+}
+
+export async function seedWooEmailPost(
+ seed: WooEmailSeed
+): Promise< number > {
+ const postId = await resetWooEmailPost( seed.emailId );
+
+ const meta: Record< string, unknown > = {};
+
+ if ( seed.stripStampMeta ) {
+ meta[ META_KEYS.STATUS ] = null;
+ meta[ META_KEYS.SOURCE_HASH ] = null;
+ meta[ META_KEYS.SOURCE_VERSION ] = null;
+ meta[ META_KEYS.LAST_SYNCED_AT ] = null;
+ meta[ META_KEYS.BACKFILLED ] = null;
+ } else {
+ if ( seed.status !== undefined ) {
+ meta[ META_KEYS.STATUS ] = seed.status;
+ }
+ if ( seed.storedSourceHash !== undefined ) {
+ meta[ META_KEYS.SOURCE_HASH ] = await resolveHash(
+ seed.emailId,
+ seed.storedSourceHash
+ );
+ }
+ if ( seed.version !== undefined ) {
+ meta[ META_KEYS.SOURCE_VERSION ] = seed.version;
+ }
+ }
+
+ const postUpdate: Record< string, unknown > = {};
+ if ( seed.postContent !== undefined ) {
+ postUpdate.post_content = seed.postContent;
+ }
+ if ( seed.postDateGmt !== undefined ) {
+ postUpdate.post_date_gmt = seed.postDateGmt;
+ }
+ if ( seed.postModifiedGmt !== undefined ) {
+ postUpdate.post_modified_gmt = seed.postModifiedGmt;
+ }
+
+ const client = apiClient();
+ await client.post( `${ TEST_HELPER_API_BASE }/seed-meta/${ postId }`, {
+ meta,
+ post: postUpdate,
+ } );
+
+ return postId;
+}
+
+/**
+ * Create a woo_email post directly via the seed-bulk endpoint, bypassing the
+ * WCTransactionalEmailPostsGenerator. The created post has no entry in the
+ * options-table mapping used by WCTransactionalEmailPostsManager, so the
+ * backfill and divergence-sweep pipelines cannot resolve its email_id and
+ * will skip it entirely.
+ *
+ * Use this for scenarios that need a woo_email post for an email type that is
+ * NOT in the sync registry (e.g. a third-party email that is registered as a
+ * WC_Email subclass but is not enrolled in the block-editor transactional
+ * emails list).
+ */
+export async function seedWooEmailPostDirect(
+ seed: Pick< WooEmailSeed, 'postContent' | 'stripStampMeta' >
+): Promise< number > {
+ const meta: Record< string, unknown > = {};
+
+ if ( seed.stripStampMeta ) {
+ meta[ META_KEYS.STATUS ] = null;
+ meta[ META_KEYS.SOURCE_HASH ] = null;
+ meta[ META_KEYS.SOURCE_VERSION ] = null;
+ meta[ META_KEYS.LAST_SYNCED_AT ] = null;
+ meta[ META_KEYS.BACKFILLED ] = null;
+ }
+
+ const postData: Record< string, unknown > = {
+ post_type: 'woo_email',
+ post_status: 'publish',
+ };
+ if ( seed.postContent !== undefined ) {
+ postData.post_content = seed.postContent;
+ }
+
+ const client = apiClient();
+ const res = await client.post( `${ TEST_HELPER_API_BASE }/seed-bulk`, {
+ seeds: [
+ {
+ post: postData,
+ meta,
+ },
+ ],
+ } );
+
+ const results: Array< { post_id?: number; error?: string } > =
+ res?.data?.results ?? [];
+ const first = results[ 0 ];
+ if ( ! first?.post_id ) {
+ throw new Error(
+ `seedWooEmailPostDirect: failed to create post — ${
+ first?.error ?? 'no post_id returned'
+ }`
+ );
+ }
+ return Number( first.post_id );
+}
+
+export async function getWooEmailMeta(
+ postId: number
+): Promise< Record< string, string[] > > {
+ const client = apiClient();
+ const res = await client.get(
+ `${ TEST_HELPER_API_BASE }/seed-meta/${ postId }`
+ );
+ return ( res?.data?.meta ?? {} ) as Record< string, string[] >;
+}
+
+export async function getWooEmailPostContent(
+ postId: number
+): Promise< string > {
+ const client = apiClient();
+ const res = await client.get(
+ `${ TEST_HELPER_API_BASE }/post-content/${ postId }`
+ );
+ return String( res?.data?.post_content ?? '' );
+}
+
+export type ApplyChoice = {
+ path: ( number | string )[];
+ decision: 'keep_yours' | 'use_core';
+};
+
+export type ApplyResult = {
+ merged_content: string;
+ revision_id: string;
+ version_to: string;
+ status: string;
+ structural_skipped: boolean;
+ aliases_migrated: string[];
+};
+
+/**
+ * Call the /apply endpoint for a woo_email post using basic-auth credentials,
+ * bypassing the cookie+nonce requirement of the WP REST API for authenticated
+ * cookie sessions. `choices` defaults to [] (keep all merchant edits, apply
+ * only core additions).
+ */
+export async function applyWooEmailTemplate(
+ postId: number,
+ choices: ApplyChoice[] = []
+): Promise< ApplyResult > {
+ const client = apiClient();
+ const res = await client.post(
+ `woocommerce-email-editor/v1/emails/${ postId }/apply`,
+ { choices } as Record< string, unknown >
+ );
+ if ( ! res?.data?.status ) {
+ throw new Error(
+ `applyWooEmailTemplate: unexpected response for post ${ postId }: ${ JSON.stringify(
+ res?.data
+ ) }`
+ );
+ }
+ return res.data as ApplyResult;
+}
+
+export type ResetResult = {
+ content: string;
+ version: string | null;
+ source_hash: string | null;
+ synced_at: string | null;
+ /** The post-reset sync status (e.g. "in_sync") for sync-enabled emails, or null otherwise. */
+ status: string | null;
+};
+
+/**
+ * Call the /reset endpoint for a woo_email post using basic-auth credentials,
+ * bypassing the cookie+nonce requirement of the WP REST API for authenticated
+ * cookie sessions. Resets the post content to the canonical WooCommerce template.
+ *
+ * Note: unlike applyWooEmailTemplate whose `status` field is "applied", the
+ * reset endpoint returns the post-reset sync status (e.g. "in_sync") in the
+ * `status` field.
+ */
+export async function resetWooEmailTemplate(
+ postId: number
+): Promise< ResetResult > {
+ const client = apiClient();
+ const res = await client.post(
+ `woocommerce-email-editor/v1/emails/${ postId }/reset`,
+ {}
+ );
+ if ( res?.data?.content === undefined ) {
+ throw new Error(
+ `resetWooEmailTemplate: unexpected response for post ${ postId }: ${ JSON.stringify(
+ res?.data
+ ) }`
+ );
+ }
+ return res.data as ResetResult;
+}
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/simulate-plugin-update.ts b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/simulate-plugin-update.ts
new file mode 100644
index 00000000000..ed919dcfddc
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/simulate-plugin-update.ts
@@ -0,0 +1,89 @@
+/**
+ * External dependencies
+ */
+import { createClient } from '@woocommerce/e2e-utils-playwright';
+
+/**
+ * Internal dependencies
+ */
+import { admin } from '../../../../test-data/data';
+import playwrightConfig from '../../../../playwright.config';
+import { TEST_HELPER_API_BASE } from './classifications';
+import {
+ setTemplateHtmlOverride,
+ stampBackfillComplete,
+} from './test-helper-plugin';
+
+const baseURL = playwrightConfig.use?.baseURL ?? '';
+
+function apiClient() {
+ return createClient( baseURL, {
+ type: 'basic',
+ username: admin.username,
+ password: admin.password,
+ } );
+}
+
+export async function triggerDetectionSweep(): Promise< {
+ touched: number;
+ classifications: Record< number, string >;
+} > {
+ const client = apiClient();
+ const res = await client.post(
+ `${ TEST_HELPER_API_BASE }/trigger-sweep`,
+ {}
+ );
+ const body = res?.data ?? {};
+ return {
+ touched: Number( body.touched ?? 0 ),
+ classifications: ( body.classifications ?? {} ) as Record<
+ number,
+ string
+ >,
+ };
+}
+
+export async function triggerBackfill(): Promise< {
+ ran: boolean;
+ stamped: number;
+} > {
+ const client = apiClient();
+ const res = await client.post(
+ `${ TEST_HELPER_API_BASE }/trigger-backfill`,
+ {}
+ );
+ const body = res?.data ?? {};
+ return {
+ ran: Boolean( body.ran ),
+ stamped: Number( body.stamped ?? 0 ),
+ };
+}
+
+/**
+ * Simulate a core template version bump by seeding `oldHtml` as the active
+ * canonical-content override for `emailId`. While the override is active, any
+ * canonical-hash computation against current core resolves to `oldHtml`.
+ *
+ * Callers typically:
+ * 1. Call simulateCoreBump() with the OLD html.
+ * 2. Seed merchant post(s) with `storedSourceHash: 'AUTO_CURRENT'` (resolves to sha1(oldHtml)).
+ * 3. Call clearTemplateHtmlOverride() from `./test-helper-plugin` — the live canonical
+ * now reverts to real current-core HTML, so the next sweep sees merchant posts
+ * as "behind core".
+ * 4. Call triggerDetectionSweep() to classify.
+ *
+ * This helper deliberately does NOT clear the override automatically — sequencing
+ * differs per scenario.
+ */
+export async function simulateCoreBump(
+ emailId: string,
+ oldHtml: string
+): Promise< void > {
+ // The detector's run_sweep() short-circuits when the backfill-complete fence is
+ // not 'yes'. Core-flows and round-trip tests assume the system is operating
+ // post-backfill (the fence is normally stamped by woocommerce_newly_installed,
+ // but fresh wp-env installs sometimes don't fire that action). Stamp it here so
+ // downstream triggerDetectionSweep() calls actually run.
+ await stampBackfillComplete();
+ await setTemplateHtmlOverride( emailId, oldHtml );
+}
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/test-helper-plugin.ts b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/test-helper-plugin.ts
new file mode 100644
index 00000000000..60ce3fc5daf
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/test-helper-plugin.ts
@@ -0,0 +1,102 @@
+/**
+ * External dependencies
+ */
+import { createClient } from '@woocommerce/e2e-utils-playwright';
+
+/**
+ * Internal dependencies
+ */
+import { admin } from '../../../../test-data/data';
+import playwrightConfig from '../../../../playwright.config';
+import { TEST_HELPER_API_BASE } from './classifications';
+
+const baseURL = playwrightConfig.use?.baseURL ?? '';
+
+const OPTIONS = {
+ TEMPLATE_HTML_OVERRIDE: 'wc_test_template_html_override',
+ OPTED_IN_OVERRIDE: 'wc_test_opted_in_emails_override',
+ TRANSACTIONAL_OVERRIDE: 'wc_test_transactional_emails_override',
+ TRACKS_ENABLED: 'wc_test_tracks_enabled',
+ FAKE_THIRD_PARTY_EMAIL_ENABLED: 'wc_test_fake_third_party_email_enabled',
+} as const;
+
+function apiClient() {
+ return createClient( baseURL, {
+ type: 'basic',
+ username: admin.username,
+ password: admin.password,
+ } );
+}
+
+async function setOption( name: string, value: unknown ): Promise< void > {
+ const client = apiClient();
+ await client.post( `${ TEST_HELPER_API_BASE }/set-option`, {
+ option_name: name,
+ option_value: value,
+ } );
+}
+
+async function deleteOption( name: string ): Promise< void > {
+ const client = apiClient();
+ await client.post( `${ TEST_HELPER_API_BASE }/delete-option`, {
+ option_name: name,
+ } );
+}
+
+export async function setTemplateHtmlOverride(
+ emailId: string,
+ html: string
+): Promise< void > {
+ await setOption( OPTIONS.TEMPLATE_HTML_OVERRIDE, { [ emailId ]: html } );
+}
+
+export async function clearTemplateHtmlOverride(): Promise< void > {
+ await deleteOption( OPTIONS.TEMPLATE_HTML_OVERRIDE );
+}
+
+export async function clearAllTemplateHtmlOverrides(): Promise< void > {
+ await clearTemplateHtmlOverride();
+}
+
+export async function setOptedInOverride(
+ overrides: Record< string, { version: string } >
+): Promise< void > {
+ await setOption( OPTIONS.OPTED_IN_OVERRIDE, overrides );
+}
+
+export async function clearOptedInOverride(): Promise< void > {
+ await deleteOption( OPTIONS.OPTED_IN_OVERRIDE );
+}
+
+export async function setTransactionalEmailsOverride(
+ emailIds: string[]
+): Promise< void > {
+ await setOption( OPTIONS.TRANSACTIONAL_OVERRIDE, emailIds );
+}
+
+export async function clearTransactionalEmailsOverride(): Promise< void > {
+ await deleteOption( OPTIONS.TRANSACTIONAL_OVERRIDE );
+}
+
+export async function enableTracksLog(): Promise< void > {
+ await setOption( OPTIONS.TRACKS_ENABLED, 'yes' );
+}
+
+export async function disableTracksLog(): Promise< void > {
+ await deleteOption( OPTIONS.TRACKS_ENABLED );
+}
+
+export async function enableFakeThirdPartyEmail(): Promise< void > {
+ await setOption( OPTIONS.FAKE_THIRD_PARTY_EMAIL_ENABLED, 'yes' );
+}
+
+export async function disableFakeThirdPartyEmail(): Promise< void > {
+ await deleteOption( OPTIONS.FAKE_THIRD_PARTY_EMAIL_ENABLED );
+}
+
+export async function stampBackfillComplete(): Promise< void > {
+ await setOption(
+ 'woocommerce_email_template_sync_backfill_complete',
+ 'yes'
+ );
+}
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/tracks-spy.ts b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/tracks-spy.ts
new file mode 100644
index 00000000000..5e60ae925c0
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/helpers/tracks-spy.ts
@@ -0,0 +1,191 @@
+/**
+ * External dependencies
+ */
+import type { Page } from '@playwright/test';
+import { createClient } from '@woocommerce/e2e-utils-playwright';
+import { expect } from '@playwright/test';
+
+/**
+ * Internal dependencies
+ */
+import { admin } from '../../../../test-data/data';
+import playwrightConfig from '../../../../playwright.config';
+import { TEST_HELPER_API_BASE } from './classifications';
+import { enableTracksLog, disableTracksLog } from './test-helper-plugin';
+
+const baseURL = playwrightConfig.use?.baseURL ?? '';
+
+export type TracksEvent = {
+ name: string;
+ properties: Record< string, unknown >;
+ timestamp_ms: number;
+};
+
+export interface TracksSpy {
+ drain(): Promise< TracksEvent[] >;
+ expectFired( name: string, count?: number ): Promise< void >;
+ expectNotFired( name: string ): Promise< void >;
+ reset(): Promise< void >;
+}
+
+declare global {
+ interface Window {
+ __capturedTracksEvents?: TracksEvent[];
+ wcTracks?: {
+ recordEvent?: (
+ name: string,
+ properties?: Record< string, unknown >
+ ) => void;
+ };
+ }
+}
+
+function apiClient() {
+ return createClient( baseURL, {
+ type: 'basic',
+ username: admin.username,
+ password: admin.password,
+ } );
+}
+
+/**
+ * Attach a client+server Tracks spy to a Page. The client-side hook patches
+ * `window.wcTracks.recordEvent` (the dispatch target used by `@woocommerce/tracks`)
+ * to capture events as they fire. The server-side mirror reads the
+ * Tracks_Recorder log via the test-helper plugin's REST endpoint.
+ *
+ * `drain()` merges and dedupes both buffers; tests assert against the merged set.
+ */
+export async function attachTracksSpy( page: Page ): Promise< TracksSpy > {
+ await enableTracksLog();
+
+ await page.addInitScript( () => {
+ window.__capturedTracksEvents = [];
+
+ const installPatch = (): boolean => {
+ if (
+ ! window.wcTracks ||
+ typeof window.wcTracks.recordEvent !== 'function'
+ ) {
+ return false;
+ }
+ const original = window.wcTracks.recordEvent;
+ window.wcTracks.recordEvent = function (
+ name: string,
+ properties?: Record< string, unknown >
+ ) {
+ try {
+ window.__capturedTracksEvents!.push( {
+ name,
+ properties: properties ?? {},
+ timestamp_ms: Date.now(),
+ } );
+ } catch {}
+ return original.call( this, name, properties );
+ };
+ return true;
+ };
+
+ if ( ! installPatch() ) {
+ document.addEventListener( 'DOMContentLoaded', () => {
+ installPatch();
+ } );
+ }
+ } );
+
+ // addInitScript only instruments future documents. Patch the currently-loaded
+ // page too — idempotent via a __wcSpyWrapped guard so a second attachTracksSpy
+ // call (or a same-document re-attach) doesn't double-wrap.
+ await page.evaluate( () => {
+ window.__capturedTracksEvents = window.__capturedTracksEvents ?? [];
+ if (
+ ! window.wcTracks ||
+ typeof window.wcTracks.recordEvent !== 'function'
+ ) {
+ return;
+ }
+ const current = window.wcTracks.recordEvent as ( (
+ ...args: unknown[]
+ ) => unknown ) & { __wcSpyWrapped?: boolean };
+ if ( current.__wcSpyWrapped ) {
+ return;
+ }
+ const original = current;
+ const wrapped = function (
+ name: string,
+ properties?: Record< string, unknown >
+ ) {
+ try {
+ window.__capturedTracksEvents!.push( {
+ name,
+ properties: properties ?? {},
+ timestamp_ms: Date.now(),
+ } );
+ } catch {}
+ return original.call( this, name, properties );
+ };
+ (
+ wrapped as typeof wrapped & { __wcSpyWrapped: boolean }
+ ).__wcSpyWrapped = true;
+ window.wcTracks.recordEvent =
+ wrapped as typeof window.wcTracks.recordEvent;
+ } );
+
+ const drain = async (): Promise< TracksEvent[] > => {
+ const clientEvents = await page.evaluate( () => {
+ const events = window.__capturedTracksEvents ?? [];
+ window.__capturedTracksEvents = [];
+ return events;
+ } );
+
+ const client = apiClient();
+ const serverRes = await client.get(
+ `${ TEST_HELPER_API_BASE }/tracks`
+ );
+ const serverEvents = ( serverRes?.data?.events ?? [] ) as TracksEvent[];
+ await client.delete( `${ TEST_HELPER_API_BASE }/tracks`, {} );
+
+ const seen = new Set< string >();
+ const merged: TracksEvent[] = [];
+ for ( const evt of [ ...clientEvents, ...serverEvents ] ) {
+ const key = `${ evt.name }|${ evt.timestamp_ms }`;
+ if ( seen.has( key ) ) {
+ continue;
+ }
+ seen.add( key );
+ merged.push( evt );
+ }
+ merged.sort( ( a, b ) => a.timestamp_ms - b.timestamp_ms );
+ return merged;
+ };
+
+ return {
+ drain,
+ expectFired: async ( name: string, count?: number ) => {
+ const events = await drain();
+ const matches = events.filter( ( e ) => e.name === name );
+ if ( count !== undefined ) {
+ expect( matches.length ).toBe( count );
+ } else {
+ expect( matches.length ).toBeGreaterThan( 0 );
+ }
+ },
+ expectNotFired: async ( name: string ) => {
+ const events = await drain();
+ expect( events.filter( ( e ) => e.name === name ).length ).toBe(
+ 0
+ );
+ },
+ reset: async () => {
+ await page.evaluate( () => {
+ window.__capturedTracksEvents = [];
+ } );
+ const client = apiClient();
+ await client.delete( `${ TEST_HELPER_API_BASE }/tracks`, {} );
+ },
+ };
+}
+
+export async function detachTracksSpy(): Promise< void > {
+ await disableTracksLog();
+}
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/round-trip-idempotency.spec.ts b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/round-trip-idempotency.spec.ts
new file mode 100644
index 00000000000..7c06835a982
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/round-trip-idempotency.spec.ts
@@ -0,0 +1,197 @@
+/**
+ * External dependencies
+ */
+import { test, expect } from '@playwright/test';
+
+/**
+ * Update-propagation: round-trip and idempotency.
+ *
+ * Covers four state-machine round-trips: (1) auto-apply returns an unmodified
+ * post to in_sync, (2) selective apply with keep-yours keeps the post in the
+ * customized state, (3) reset returns a customized post directly to in_sync,
+ * and (4) the detection sweep is idempotent — a second run classifies the same
+ * post identically and writes no new meta.
+ *
+ * Reviewing in Playwright UI mode:
+ * 1. Run `npx playwright test --project=e2e tests/email-editor/update-propagation --ui`
+ * 2. Filter the tree by `round-trip` and pick a test.
+ * 3. All four tests are REST-only — no browser window is driven. The Actions
+ * panel in UI mode shows the full REST call sequence for each test.
+ * 4. "Show browser" eye is not needed for any test in this file.
+ */
+
+/**
+ * Internal dependencies
+ */
+import { ADMIN_STATE_PATH } from '../../../playwright.config';
+import { enableEmailEditor } from '../helpers/enable-email-editor-feature';
+import { clearTemplateHtmlOverride } from './helpers/test-helper-plugin';
+import {
+ seedWooEmailPost,
+ getWooEmailMeta,
+ applyWooEmailTemplate,
+ resetWooEmailTemplate,
+} from './helpers/seed-woo-email';
+import {
+ simulateCoreBump,
+ triggerDetectionSweep,
+} from './helpers/simulate-plugin-update';
+import { assertNoLeakedFixtureState } from './helpers/leaked-state-checks';
+import { STATUS, META_KEYS } from './helpers/classifications';
+
+const OLD_HTML =
+ '<!-- wp:paragraph --><p>OLD CANONICAL</p><!-- /wp:paragraph -->';
+
+test.describe( 'Update propagation — round-trip and idempotency', () => {
+ test.use( { storageState: ADMIN_STATE_PATH } );
+
+ test.beforeAll( async ( { baseURL } ) => {
+ await enableEmailEditor( baseURL! );
+ } );
+
+ test.afterEach( async () => {
+ await assertNoLeakedFixtureState();
+ } );
+
+ /**
+ * Verifies the full auto-apply round-trip: after a core bump the detection
+ * sweep detects the divergence and the inline auto-applier immediately
+ * re-stamps an unmodified post as in_sync.
+ *
+ * UI mode walkthrough:
+ * REST-only — no browser interaction. Actions panel shows: simulateCoreBump
+ * → seedWooEmailPost → clearTemplateHtmlOverride → triggerDetectionSweep
+ * → getWooEmailMeta assertion (STATUS.IN_SYNC).
+ *
+ * "Show browser" eye: not needed.
+ */
+ test( 'Auto-apply round-trip: uncustomized post returns to in_sync', async () => {
+ await simulateCoreBump( 'new_order', OLD_HTML );
+ const postId = await seedWooEmailPost( {
+ emailId: 'new_order',
+ postContent: OLD_HTML,
+ storedSourceHash: 'AUTO_CURRENT',
+ status: STATUS.IN_SYNC,
+ } );
+ await clearTemplateHtmlOverride();
+
+ await triggerDetectionSweep();
+
+ const meta = await getWooEmailMeta( postId );
+ expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe( STATUS.IN_SYNC );
+ } );
+
+ /**
+ * Verifies that when a merchant applies a core update with choices:[] (keep-yours
+ * default for all conflicts), the post's diverged block is preserved and the
+ * status remains core_updated_customized rather than flipping to in_sync.
+ *
+ * UI mode walkthrough:
+ * REST-only — no browser interaction. Actions panel shows: simulateCoreBump
+ * → seedWooEmailPost (customized block B) → clearTemplateHtmlOverride →
+ * triggerDetectionSweep → meta assertion (CUSTOMIZED) → applyWooEmailTemplate
+ * (choices:[]) → meta re-assertion (still CUSTOMIZED).
+ *
+ * "Show browser" eye: not needed.
+ */
+ test( 'Selective apply round-trip: edit, bump, apply with keep-yours → stays customized', async () => {
+ const oldHtml =
+ '<!-- wp:paragraph --><p>OLD A</p><!-- /wp:paragraph --><!-- wp:paragraph --><p>OLD B</p><!-- /wp:paragraph -->';
+
+ await simulateCoreBump( 'new_order', oldHtml );
+ const postId = await seedWooEmailPost( {
+ emailId: 'new_order',
+ postContent: oldHtml.replace( 'OLD B', 'MERCHANT B' ),
+ storedSourceHash: 'AUTO_CURRENT',
+ status: STATUS.IN_SYNC,
+ } );
+ await clearTemplateHtmlOverride();
+ await triggerDetectionSweep();
+
+ let meta = await getWooEmailMeta( postId );
+ expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe(
+ STATUS.CORE_UPDATED_CUSTOMIZED
+ );
+
+ // Use applyWooEmailTemplate (basic auth) instead of request.post (cookie auth)
+ // because WP REST POST endpoints require a nonce when using cookie-based auth.
+ // choices: [] keeps all merchant edits (keep_yours default), so the merged
+ // result diverges from canonical and the applier stamps core_updated_customized.
+ const apply = await applyWooEmailTemplate( postId, [] );
+ expect( apply.status ).toBe( 'applied' );
+
+ meta = await getWooEmailMeta( postId );
+ // With choices:[] the merchant's diverged block is preserved, so the post
+ // stays core_updated_customized rather than reaching in_sync.
+ expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe(
+ STATUS.CORE_UPDATED_CUSTOMIZED
+ );
+ } );
+
+ /**
+ * Verifies that the reset endpoint replaces a customized post's content with
+ * the current canonical and stamps it in_sync in a single REST call.
+ *
+ * UI mode walkthrough:
+ * REST-only — no browser interaction. Actions panel shows: seedWooEmailPost
+ * (customized content) → resetWooEmailTemplate (REST POST) → response status
+ * assertion → getWooEmailMeta assertion (STATUS.IN_SYNC).
+ *
+ * "Show browser" eye: not needed.
+ */
+ test( 'Reset round-trip: customized → reset → in_sync', async () => {
+ const customized =
+ '<!-- wp:paragraph --><p>MERCHANT CUSTOM</p><!-- /wp:paragraph -->';
+ const postId = await seedWooEmailPost( {
+ emailId: 'new_order',
+ postContent: customized,
+ storedSourceHash: 'AUTO_CURRENT',
+ status: STATUS.IN_SYNC,
+ } );
+
+ // Use resetWooEmailTemplate (basic auth) instead of request.post (cookie auth)
+ // because WP REST POST endpoints require a nonce when using cookie-based auth.
+ // The reset endpoint returns the post-reset sync status directly (not "applied").
+ const reset = await resetWooEmailTemplate( postId );
+ expect( reset.status ).toBe( STATUS.IN_SYNC );
+
+ const meta = await getWooEmailMeta( postId );
+ expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe( STATUS.IN_SYNC );
+ } );
+
+ /**
+ * Verifies that running the detection sweep twice in a row produces the same
+ * classification and identical meta for an already-classified customized post,
+ * confirming the sweep does not mutate already-correct state.
+ *
+ * UI mode walkthrough:
+ * REST-only — no browser interaction. Actions panel shows: simulateCoreBump
+ * → seedWooEmailPost (merchant edit) → clearTemplateHtmlOverride →
+ * triggerDetectionSweep (first) → getWooEmailMeta snapshot →
+ * triggerDetectionSweep (second, sweep2.classifications assertion) →
+ * getWooEmailMeta equality assertion.
+ *
+ * "Show browser" eye: not needed.
+ */
+ test( 'Detection sweep is idempotent: second run touches zero posts', async () => {
+ await simulateCoreBump( 'new_order', OLD_HTML );
+ const postId = await seedWooEmailPost( {
+ emailId: 'new_order',
+ postContent: OLD_HTML.replace( 'OLD CANONICAL', 'MERCHANT EDIT' ),
+ storedSourceHash: 'AUTO_CURRENT',
+ status: STATUS.IN_SYNC,
+ } );
+ await clearTemplateHtmlOverride();
+
+ await triggerDetectionSweep();
+ const metaBefore = await getWooEmailMeta( postId );
+
+ const sweep2 = await triggerDetectionSweep();
+ const metaAfter = await getWooEmailMeta( postId );
+
+ expect( sweep2.classifications[ postId ] ).toBe(
+ metaBefore[ META_KEYS.STATUS ]?.[ 0 ]
+ );
+ expect( metaAfter ).toEqual( metaBefore );
+ } );
+} );
diff --git a/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/scope.spec.ts b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/scope.spec.ts
new file mode 100644
index 00000000000..9fc5ef7e06a
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/tests/email-editor/update-propagation/scope.spec.ts
@@ -0,0 +1,195 @@
+/**
+ * External dependencies
+ */
+import { test, expect } from '@playwright/test';
+
+/**
+ * Update-propagation: scope and allow-list.
+ *
+ * Covers the allow-list (opt-in) boundary for third-party email types: a
+ * non-opted-in email is entirely excluded from backfill and detection; an
+ * opted-in third-party email that is unedited is auto-applied (in_sync) after
+ * a version bump; an opted-in email that is edited is left as
+ * core_updated_customized for the merchant to review.
+ *
+ * Reviewing in Playwright UI mode:
+ * 1. Run `npx playwright test --project=e2e tests/email-editor/update-propagation --ui`
+ * 2. Filter the tree by `scope` and pick a test.
+ * 3. The first test ("Non-opted-in") attaches a Tracks spy via the page fixture
+ * but performs no navigation or UI interaction. The other two tests are
+ * purely REST-based. The Actions panel shows the REST call sequence for all.
+ * 4. "Show browser" eye is not needed for any test in this file.
+ */
+
+/**
+ * Internal dependencies
+ */
+import { ADMIN_STATE_PATH } from '../../../playwright.config';
+import { enableEmailEditor } from '../helpers/enable-email-editor-feature';
+import {
+ setTransactionalEmailsOverride,
+ setOptedInOverride,
+ setTemplateHtmlOverride,
+ clearTemplateHtmlOverride,
+ enableFakeThirdPartyEmail,
+ disableFakeThirdPartyEmail,
+} from './helpers/test-helper-plugin';
+import {
+ seedWooEmailPost,
+ seedWooEmailPostDirect,
+ getWooEmailMeta,
+} from './helpers/seed-woo-email';
+import {
+ triggerBackfill,
+ triggerDetectionSweep,
+ simulateCoreBump,
+} from './helpers/simulate-plugin-update';
+import { attachTracksSpy } from './helpers/tracks-spy';
+import { assertNoLeakedFixtureState } from './helpers/leaked-state-checks';
+import { STATUS, META_KEYS, TRACKS_EVENTS } from './helpers/classifications';
+
+const FAKE_EMAIL_ID = 'fake_thirdparty';
+
+const V1_HTML = '<!-- wp:paragraph --><p>V1 CONTENT</p><!-- /wp:paragraph -->';
+const V2_HTML = '<!-- wp:paragraph --><p>V2 CONTENT</p><!-- /wp:paragraph -->';
+
+test.describe( 'Update propagation — scope and allow-list', () => {
+ test.use( { storageState: ADMIN_STATE_PATH } );
+
+ test.beforeAll( async ( { baseURL } ) => {
+ await enableEmailEditor( baseURL! );
+ } );
+
+ test.beforeEach( async () => {
+ await enableFakeThirdPartyEmail();
+ } );
+
+ test.afterEach( async () => {
+ await disableFakeThirdPartyEmail();
+ await assertNoLeakedFixtureState();
+ } );
+
+ /**
+ * Verifies that a third-party email type that has not enrolled in block-editor
+ * sync is completely ignored by both the backfill and the detection sweep —
+ * no stamp meta is written and no Tracks _available event fires.
+ *
+ * UI mode walkthrough:
+ * The page fixture is used only to attach the Tracks spy — no navigation
+ * or UI interaction occurs. Actions panel shows: seedWooEmailPostDirect
+ * (no options-table mapping) → triggerBackfill → simulateCoreBump →
+ * triggerDetectionSweep → meta undefined assertions → spy.expectNotFired.
+ *
+ * "Show browser" eye: not needed.
+ */
+ test( 'Non-opted-in third-party email is excluded from sync', async ( {
+ page,
+ } ) => {
+ const spy = await attachTracksSpy( page );
+
+ // Deliberately do NOT add FAKE_EMAIL_ID to the transactional emails list:
+ // a third-party email that has not enrolled in block-editor sync is excluded
+ // from WCEmailTemplateSyncRegistry and therefore skipped by both the backfill
+ // and the divergence sweep. Create the woo_email post directly (bypassing the
+ // generator) so no options-table mapping exists for the email type — the
+ // backfill's get_email_type_from_post_id() will return null and skip the post.
+ const postId = await seedWooEmailPostDirect( {
+ postContent:
+ '<!-- wp:paragraph --><p>Third-party content</p><!-- /wp:paragraph -->',
+ stripStampMeta: true,
+ } );
+
+ await triggerBackfill();
+ await simulateCoreBump( FAKE_EMAIL_ID, V1_HTML );
+ await triggerDetectionSweep();
+ await clearTemplateHtmlOverride();
+
+ const meta = await getWooEmailMeta( postId );
+ expect( meta[ META_KEYS.STATUS ] ).toBeUndefined();
+ expect( meta[ META_KEYS.SOURCE_HASH ] ).toBeUndefined();
+ await spy.expectNotFired( TRACKS_EVENTS.AVAILABLE );
+ } );
+
+ /**
+ * Verifies that an opted-in third-party email with an unedited post is
+ * auto-applied after a version bump (1.0.0 → 1.1.0), landing back at in_sync
+ * rather than surfacing an update prompt to the merchant.
+ *
+ * UI mode walkthrough:
+ * REST-only — no browser interaction. Actions panel shows:
+ * setTransactionalEmailsOverride + setOptedInOverride + setTemplateHtmlOverride
+ * (v1 setup) → seedWooEmailPost → triggerDetectionSweep → meta assertion
+ * (IN_SYNC) → override swap to v2 → triggerDetectionSweep → meta assertion
+ * (IN_SYNC via auto-apply) → cleanup calls.
+ *
+ * "Show browser" eye: not needed.
+ */
+ test( 'Opted-in third-party email: version bump flips status when unedited', async () => {
+ await setTransactionalEmailsOverride( [ FAKE_EMAIL_ID ] );
+ await setOptedInOverride( { [ FAKE_EMAIL_ID ]: { version: '1.0.0' } } );
+ await setTemplateHtmlOverride( FAKE_EMAIL_ID, V1_HTML );
+
+ const postId = await seedWooEmailPost( {
+ emailId: FAKE_EMAIL_ID,
+ postContent: V1_HTML,
+ storedSourceHash: 'AUTO_CURRENT',
+ status: STATUS.IN_SYNC,
+ version: '1.0.0',
+ } );
+
+ await triggerDetectionSweep();
+ let meta = await getWooEmailMeta( postId );
+ expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe( STATUS.IN_SYNC );
+
+ await setOptedInOverride( { [ FAKE_EMAIL_ID ]: { version: '1.1.0' } } );
+ await setTemplateHtmlOverride( FAKE_EMAIL_ID, V2_HTML );
+
+ await triggerDetectionSweep();
+ meta = await getWooEmailMeta( postId );
+ // The inline auto-applier runs immediately after the sweep (same HTTP request in
+ // the E2E trigger-sweep endpoint). An unedited post classified as
+ // core_updated_uncustomized is auto-applied and flipped back to in_sync before
+ // this assertion runs — consistent with the lifecycle tested in core-flows
+ // scenario 1 and backward-compat Case A.
+ expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe( STATUS.IN_SYNC );
+ } );
+
+ /**
+ * Verifies that an opted-in third-party email with a merchant-edited post is
+ * stamped core_updated_customized after a version bump (1.0.0 → 1.1.0),
+ * leaving the update for the merchant to review rather than auto-applying.
+ *
+ * UI mode walkthrough:
+ * REST-only — no browser interaction. Actions panel shows:
+ * setTransactionalEmailsOverride + setOptedInOverride + setTemplateHtmlOverride
+ * (v1 setup) → seedWooEmailPost (customized content) → override swap to v2
+ * → triggerDetectionSweep → meta assertion (CORE_UPDATED_CUSTOMIZED)
+ * → cleanup calls.
+ *
+ * "Show browser" eye: not needed.
+ */
+ test( 'Opted-in third-party email: version bump flips status when edited', async () => {
+ const customized = V1_HTML.replace( 'V1 CONTENT', 'MERCHANT EDIT' );
+
+ await setTransactionalEmailsOverride( [ FAKE_EMAIL_ID ] );
+ await setOptedInOverride( { [ FAKE_EMAIL_ID ]: { version: '1.0.0' } } );
+ await setTemplateHtmlOverride( FAKE_EMAIL_ID, V1_HTML );
+
+ const postId = await seedWooEmailPost( {
+ emailId: FAKE_EMAIL_ID,
+ postContent: customized,
+ storedSourceHash: 'AUTO_CURRENT',
+ status: STATUS.IN_SYNC,
+ version: '1.0.0',
+ } );
+
+ await setOptedInOverride( { [ FAKE_EMAIL_ID ]: { version: '1.1.0' } } );
+ await setTemplateHtmlOverride( FAKE_EMAIL_ID, V2_HTML );
+
+ await triggerDetectionSweep();
+ const meta = await getWooEmailMeta( postId );
+ expect( meta[ META_KEYS.STATUS ]?.[ 0 ] ).toBe(
+ STATUS.CORE_UPDATED_CUSTOMIZED
+ );
+ } );
+} );