Commit 2f043940bc0 for woocommerce

commit 2f043940bc043e0c1589881f511579a721179994
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Tue Sep 15 16:06:18 2026 +0300

    [tests] Demote 2 email editor E2E tests to Jest and PHPUnit (#68627)

    * test(email-editor): Move editor enablement and preview send below E2E

    The email editor spec runs six browser titles. Two of them prove things
    a browser is not needed for: that ticking a box under Advanced >
    Features turns the feature on, and that the editor's test-email modal
    renders and reports a failed send.

    Move both down. A PHPUnit method takes the feature's settings row and
    its enable round-trip. A new Jest suite drives the store generator that
    posts the preview request, step by step, including its error state. The
    preview modal's suite swaps mocked hooks for a real @wordpress/data
    store, so the reducer and selectors run, and gains a case that types an
    address and asserts what is dispatched. A package integration test takes
    the REST controller's failure response.

    Keep the four that need a real wp-admin: the editor opening from the
    Emails list, Preview in new tab rendering the generated document, an
    edit surviving a save, and the personalization tags on the Button
    block. The suite now enables the feature itself in beforeAll rather
    than depending on the removed enable title, and deletes every email
    post it opened in afterAll.

    Consolidates the mega-branch slice:
    - Slice 061: test(email-editor): Move deterministic checks below E2E

    with three in-campaign corrections it carries: isolating the preview
    state, refreshing the editor's rewrite rules on enable, and covering the
    in-flight preview disable state.

    Refs TESTOPS-288
    Refs #68046

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

    * test(php): Clear the feature option before asserting the enable step

    `change_feature_enable` returns `update_option`'s result, so it reports
    false when the option already reads `yes` even though the feature ends
    up enabled. The test asserted that return value against an option it had
    not normalised, so a committed `yes` from earlier in the run would fail
    it for the wrong reason.

    Delete the option after capturing its previous value. The existing
    `finally` block already restores or removes it.

    Refs TESTOPS-288

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

    * test(features): Stop the Block Email Editor test restoring base-class state

    The finally block put the feature option back, re-attached this class's
    dummy-feature callback, and walked a seventeen-entry list removing every
    hook the temporary FeaturesController had registered. The rollback
    covers the option row and _restore_hooks() rebuilds $wp_filter from the
    suite baseline, which is exactly what that list was doing by hand -- and
    doing it from a list that had to be kept in step with the controller
    constructor.

    The detach of register_dummy_features stays, because it has to hold
    while the real controller reads the definitions. It now uses the
    literal priority 11 the class registers it at, matching the existing
    tearDown(), rather than looking it up.

    Refs TESTOPS-288

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

    * test(email-editor): Assert the save snackbar instead of the a11y region

    The comment above this assertion had the mechanism backwards. It claimed
    the editor writes every announcement into one region, so the test should
    match inside it rather than require a single message. In fact
    @wordpress/a11y's speak() calls clear(), blanking every .a11y-speak-region
    before writing, so announcements replace and never accumulate: the region
    holds exactly one message at a time.

    That makes the assertion a race rather than a lenient check. The editor
    rewrites a second notice during the same save, and if that announcement
    lands between two polls it wipes "Email saved." and the test times out on
    a save that worked.

    The snackbar carries the same rewritten notice text, stays on screen, and
    .components-snackbar is already the locator this suite uses for it.

    Refs TESTOPS-288

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

    * test(e2e): Reinstate the test email title against the route response

    The JS store's send-preview path is asserted against a mocked apiFetch
    and the PHP route by its own permission test, but nothing proved the
    two strings are the same route. A drifted path answers 404, and the
    modal renders the same error notice as a real failed send, so a
    notice-only title would pass either way.

    Open the modal from the View menu, click send inside a Promise.all with
    a waitForResponse on the route, and assert 400: the test environment
    has no mailer, so the real send fails. Renaming the PHP route to
    /send_preview_email_mutant fails the title with 404.

    Refs TESTOPS-288

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

    * chore(changelog): Correct email editor spec title count

    Reinstating `Can send test email` during review left the browser spec
    at five titles, but the three changelog entries still said four.

    Refs TESTOPS-288

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

    ---------

    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

diff --git a/packages/js/email-editor/changelog/testops-288-email-editor-loads b/packages/js/email-editor/changelog/testops-288-email-editor-loads
new file mode 100644
index 00000000000..cc09da4dfb0
--- /dev/null
+++ b/packages/js/email-editor/changelog/testops-288-email-editor-loads
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Move the email editor feature flag, preview send and REST failure coverage below E2E; the browser spec goes from six titles to five.
+
diff --git a/packages/js/email-editor/src/components/preview/test/send-preview-email.spec.tsx b/packages/js/email-editor/src/components/preview/test/send-preview-email.spec.tsx
index 9ae7fe64004..0c290e98502 100644
--- a/packages/js/email-editor/src/components/preview/test/send-preview-email.spec.tsx
+++ b/packages/js/email-editor/src/components/preview/test/send-preview-email.spec.tsx
@@ -1,23 +1,58 @@
-import '../../test/__mocks__/setup-shared-mocks';
-
 /**
  * External dependencies
  */
-import { render, screen } from '@testing-library/react';
+import { act, render, screen } from '@testing-library/react';
 import '@testing-library/jest-dom';
-import * as dataModule from '@wordpress/data';
+import {
+	createRegistry,
+	createReduxStore,
+	RegistryProvider,
+} from '@wordpress/data';
+import { controls } from '@wordpress/data-controls';
 import { forwardRef } from '@wordpress/element';
+import userEvent from '@testing-library/user-event';

 /**
  * Internal dependencies
  */
 import { SendPreviewEmail } from '../send-preview-email';
-import { SendingPreviewStatus } from '../../../store';
+import { SendingPreviewStatus, storeName } from '../../../store';
+import * as actions from '../../../store/actions';
+import { getInitialState } from '../../../store/initial-state';
+import { reducer } from '../../../store/reducer';
+import * as selectors from '../../../store/selectors';
+import { State } from '../../../store/types';

 jest.mock( '@wordpress/compose', () => ( {
 	useViewportMatch: jest.fn(),
 } ) );

+jest.mock( '@wordpress/core-data', () => ( {
+	store: { name: 'core' },
+} ) );
+
+jest.mock( '@wordpress/editor', () => ( {
+	store: { name: 'core/editor' },
+} ) );
+
+jest.mock( '@wordpress/preferences', () => ( {
+	store: { name: 'core/preferences' },
+} ) );
+
+jest.mock( '@wordpress/blocks', () => ( {
+	parse: jest.fn(),
+	serialize: jest.fn(),
+} ) );
+
+jest.mock( '@wordpress/hooks', () => ( {
+	applyFilters: jest.fn( ( _hook: string, value: unknown ) => value ),
+} ) );
+
+jest.mock( '@wordpress/i18n', () => ( {
+	__: ( value: string ) => value,
+	sprintf: ( format: string, value: string ) => format.replace( '%s', value ),
+} ) );
+
 jest.mock( '@wordpress/components', () => {
 	const TextControl = forwardRef<
 		HTMLInputElement,
@@ -73,64 +108,113 @@ jest.mock( '../../../events', () => ( {
 	recordEventOnce: jest.fn(),
 } ) );

-const useDispatchMock = dataModule.useDispatch as jest.Mock;
-const useSelectMock = dataModule.useSelect as jest.Mock;
-
-interface PreviewState {
-	toEmail: string;
-	isSendingPreviewEmail: boolean;
-	sendingPreviewStatus: string;
-	isModalOpened: boolean;
-	errorMessage: string;
-}
-
-const setupUseSelectMock = ( overrides: Partial< PreviewState > = {} ) => {
-	useSelectMock.mockImplementation(
-		(
-			selector: (
-				select: ( storeName: string ) => {
-					getPreviewState: () => PreviewState;
-					getEmailPostType: () => string;
-				}
-			) => unknown
-		) =>
-			selector( () => ( {
-				getPreviewState: () => ( {
-					toEmail: 'test@example.com',
-					isSendingPreviewEmail: false,
-					sendingPreviewStatus: '',
-					isModalOpened: true,
-					errorMessage: '',
-					...overrides,
-				} ),
-				getEmailPostType: () => 'post',
-			} ) )
-	);
+const renderWithPreviewState = (
+	overrides: Partial< State[ 'preview' ] > = {}
+) => {
+	const requestSendingNewsletterPreview = jest.fn( () => ( {
+		type: 'REQUEST_SENDING_NEWSLETTER_PREVIEW',
+	} ) );
+	const initialState = getInitialState();
+	const store = createReduxStore( storeName, {
+		actions: {
+			...actions,
+			requestSendingNewsletterPreview,
+		},
+		controls,
+		selectors,
+		reducer,
+		initialState: {
+			...initialState,
+			preview: {
+				...initialState.preview,
+				isModalOpened: true,
+				...overrides,
+			},
+		},
+	} );
+	const registry = createRegistry();
+	registry.register( store );
+
+	return {
+		registry,
+		requestSendingNewsletterPreview,
+		...render(
+			<RegistryProvider value={ registry }>
+				<SendPreviewEmail />
+			</RegistryProvider>
+		),
+	};
 };

 describe( 'SendPreviewEmail', () => {
-	beforeEach( () => {
-		jest.clearAllMocks();
-		useDispatchMock.mockReturnValue( {
-			requestSendingNewsletterPreview: jest.fn(),
-			togglePreviewModal: jest.fn(),
-			updateSendPreviewEmail: jest.fn(),
-		} );
-	} );
-
 	it( 'should render the modal with input and buttons', () => {
-		setupUseSelectMock();
-		render( <SendPreviewEmail /> );
+		renderWithPreviewState();
 		expect( screen.getByTestId( 'modal' ) ).toBeInTheDocument();
 		expect( screen.getByTestId( 'text-control' ) ).toBeInTheDocument();
 	} );

+	it( "carries the editor config's user email into the preview recipient", () => {
+		const { registry } = renderWithPreviewState( { toEmail: '' } );
+
+		// Inside act(): the rendered modal is subscribed to this store, so the
+		// dispatch re-renders it.
+		act( () => {
+			registry.dispatch( storeName ).setEditorConfig( {
+				editorSettings: {} as never,
+				theme: {} as never,
+				urls: {} as never,
+				userEmail: 'shopkeeper@example.com',
+			} );
+		} );
+
+		// This is the address PHP localises into the editor config, and it is what
+		// the modal is expected to open with.
+		expect( registry.select( storeName ).getPreviewState() ).toMatchObject(
+			{
+				toEmail: 'shopkeeper@example.com',
+			}
+		);
+	} );
+
+	it( 'opens with that recipient already filled in and a usable send button', () => {
+		renderWithPreviewState( { toEmail: 'shopkeeper@example.com' } );
+
+		expect( screen.getByTestId( 'text-control' ) ).toHaveValue(
+			'shopkeeper@example.com'
+		);
+		expect(
+			screen.getByRole( 'button', { name: 'Send test email' } )
+		).toBeEnabled();
+	} );
+
+	it( 'requests a preview email sent to the address entered by the user', async () => {
+		const { registry, requestSendingNewsletterPreview } =
+			renderWithPreviewState();
+
+		await userEvent.type(
+			screen.getByTestId( 'text-control' ),
+			'test@example.com'
+		);
+		expect( registry.select( storeName ).getPreviewState() ).toMatchObject(
+			{
+				toEmail: 'test@example.com',
+			}
+		);
+		await userEvent.click(
+			screen.getByRole( 'button', { name: 'Send test email' } )
+		);
+
+		expect( requestSendingNewsletterPreview ).toHaveBeenCalledTimes( 1 );
+		expect( requestSendingNewsletterPreview ).toHaveBeenCalledWith(
+			'test@example.com'
+		);
+	} );
+
 	it( 'should show error message when status is ERROR', () => {
-		setupUseSelectMock( {
+		renderWithPreviewState( {
 			sendingPreviewStatus: SendingPreviewStatus.ERROR,
 			errorMessage: 'Server failure',
 		} );
-		render( <SendPreviewEmail /> );
 		expect(
 			screen.getByText( /Sorry, we were unable to send this email/ )
 		).toBeInTheDocument();
@@ -140,10 +224,9 @@ describe( 'SendPreviewEmail', () => {
 	} );

 	it( 'should show success message when status is SUCCESS', () => {
-		setupUseSelectMock( {
+		renderWithPreviewState( {
 			sendingPreviewStatus: SendingPreviewStatus.SUCCESS,
 		} );
-		render( <SendPreviewEmail /> );
 		expect(
 			screen.getByText( 'Test email sent successfully!' )
 		).toBeInTheDocument();
@@ -151,18 +234,17 @@ describe( 'SendPreviewEmail', () => {
 	} );

 	it( 'should render nothing when modal is closed', () => {
-		setupUseSelectMock( {
+		const { container } = renderWithPreviewState( {
 			isModalOpened: false,
 		} );
-		const { container } = render( <SendPreviewEmail /> );
 		expect( container.firstChild ).toBeNull();
 	} );

 	it( 'should disable send button and show "Sending…" text when sending', () => {
-		setupUseSelectMock( {
+		renderWithPreviewState( {
 			isSendingPreviewEmail: true,
+			toEmail: 'test@example.com',
 		} );
-		render( <SendPreviewEmail /> );
 		const sendButton = screen.getByRole( 'button', {
 			name: /sending…/i,
 		} );
diff --git a/packages/js/email-editor/src/store/test/actions.spec.ts b/packages/js/email-editor/src/store/test/actions.spec.ts
new file mode 100644
index 00000000000..9e71e46e053
--- /dev/null
+++ b/packages/js/email-editor/src/store/test/actions.spec.ts
@@ -0,0 +1,130 @@
+/**
+ * External dependencies
+ */
+import { select } from '@wordpress/data';
+import { apiFetch } from '@wordpress/data-controls';
+
+/**
+ * Internal dependencies
+ */
+import { requestSendingNewsletterPreview } from '../actions';
+import { storeName } from '../constants';
+import { SendingPreviewStatus } from '../types';
+
+jest.mock( '@wordpress/data', () => ( {
+	select: jest.fn(),
+} ) );
+
+jest.mock( '@wordpress/core-data', () => ( {
+	store: { name: 'core' },
+} ) );
+
+jest.mock( '@wordpress/data-controls', () => ( {
+	apiFetch: jest.fn(),
+} ) );
+
+jest.mock( '../../events', () => ( {
+	recordEvent: jest.fn(),
+} ) );
+
+const selectMock = select as jest.Mock;
+const apiFetchMock = apiFetch as jest.Mock;
+
+const initialSendingState = {
+	type: 'CHANGE_PREVIEW_STATE',
+	state: {
+		sendingPreviewStatus: null,
+		isSendingPreviewEmail: true,
+	},
+};
+
+describe( 'requestSendingNewsletterPreview', () => {
+	beforeEach( () => {
+		jest.clearAllMocks();
+		selectMock
+			.mockReturnValueOnce( {
+				getPreviewState: () => ( {
+					isSendingPreviewEmail: false,
+				} ),
+			} )
+			.mockReturnValueOnce( {
+				getEmailPostId: () => 123,
+			} );
+	} );
+
+	it( 'transitions to sending, posts the preview request, then marks it successful', () => {
+		const request = { type: 'API_FETCH' };
+		apiFetchMock.mockReturnValue( request );
+
+		const action = requestSendingNewsletterPreview( 'test@example.com' );
+
+		expect( action.next() ).toStrictEqual( {
+			value: initialSendingState,
+			done: false,
+		} );
+		expect( action.next() ).toStrictEqual( {
+			value: request,
+			done: false,
+		} );
+		expect( selectMock ).toHaveBeenNthCalledWith( 1, storeName );
+		expect( selectMock ).toHaveBeenNthCalledWith( 2, storeName );
+		expect( apiFetchMock ).toHaveBeenCalledWith( {
+			path: '/woocommerce-email-editor/v1/send_preview_email',
+			method: 'POST',
+			data: {
+				email: 'test@example.com',
+				postId: 123,
+			},
+		} );
+		expect( action.next() ).toStrictEqual( {
+			value: {
+				type: 'CHANGE_PREVIEW_STATE',
+				state: {
+					sendingPreviewStatus: SendingPreviewStatus.SUCCESS,
+					isSendingPreviewEmail: false,
+				},
+			},
+			done: false,
+		} );
+	} );
+
+	it( 'yields nothing when a preview send is already in flight', () => {
+		// Overwrite the queue beforeEach set up: this case needs the guard to see a
+		// send already running. Queue it the same way, so nothing outlives the case.
+		selectMock.mockReset();
+		selectMock.mockReturnValueOnce( {
+			getPreviewState: () => ( {
+				isSendingPreviewEmail: true,
+			} ),
+		} );
+
+		const action = requestSendingNewsletterPreview( 'test@example.com' );
+
+		expect( action.next() ).toStrictEqual( {
+			value: undefined,
+			done: true,
+		} );
+		expect( apiFetchMock ).not.toHaveBeenCalled();
+	} );
+
+	it( 'transitions to the exact error state when the preview request is rejected', () => {
+		const request = { type: 'API_FETCH' };
+		apiFetchMock.mockReturnValue( request );
+		const action = requestSendingNewsletterPreview( 'test@example.com' );
+
+		action.next();
+		action.next();
+
+		expect( action.throw( { error: 'Request failed' } ) ).toStrictEqual( {
+			value: {
+				type: 'CHANGE_PREVIEW_STATE',
+				state: {
+					sendingPreviewStatus: SendingPreviewStatus.ERROR,
+					isSendingPreviewEmail: false,
+					errorMessage: '"Request failed"',
+				},
+			},
+			done: false,
+		} );
+	} );
+} );
diff --git a/packages/php/email-editor/changelog/testops-288-email-editor-loads b/packages/php/email-editor/changelog/testops-288-email-editor-loads
new file mode 100644
index 00000000000..cc09da4dfb0
--- /dev/null
+++ b/packages/php/email-editor/changelog/testops-288-email-editor-loads
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Move the email editor feature flag, preview send and REST failure coverage below E2E; the browser spec goes from six titles to five.
+
diff --git a/packages/php/email-editor/tests/integration/Engine/Email_Api_Controller_Test.php b/packages/php/email-editor/tests/integration/Engine/Email_Api_Controller_Test.php
index 8f3d52d945d..23b2d9f8968 100644
--- a/packages/php/email-editor/tests/integration/Engine/Email_Api_Controller_Test.php
+++ b/packages/php/email-editor/tests/integration/Engine/Email_Api_Controller_Test.php
@@ -228,4 +228,39 @@ class Email_Api_Controller_Test extends Email_Editor_Integration_Test_Case {

 		$this->assertTrue( $found, 'Test tag should be in the response' );
 	}
+
+	/**
+	 * Test that a failed preview email send returns a bad request response.
+	 */
+	public function testSendPreviewEmailDataReturnsBadRequestWhenSendingFails(): void {
+		$filter = static function () {
+			return false;
+		};
+		add_filter( 'woocommerce_email_editor_send_preview_email', $filter, PHP_INT_MAX, 1 );
+
+		try {
+			/**
+			 * The send-preview request.
+			 *
+			 * @var WP_REST_Request<array{_locale: string, email: string, postId: int}> $request
+			 */
+			$request = new WP_REST_Request( 'POST', '/woocommerce-email-editor/v1/send_preview_email' );
+			$request->set_param( 'email', 'test@example.com' );
+			$request->set_param( 'postId', 123 );
+
+			$response = $this->controller->send_preview_email_data( $request );
+
+			$this->assertSame( 400, $response->get_status(), 'A failed preview email send should return a bad request response.' );
+			$this->assertSame(
+				array(
+					'success' => false,
+					'result'  => false,
+				),
+				$response->get_data(),
+				'A failed preview email send should return the complete failure response data.'
+			);
+		} finally {
+			remove_filter( 'woocommerce_email_editor_send_preview_email', $filter, PHP_INT_MAX );
+		}
+	}
 }
diff --git a/plugins/woocommerce/changelog/testops-288-email-editor-loads b/plugins/woocommerce/changelog/testops-288-email-editor-loads
new file mode 100644
index 00000000000..cc09da4dfb0
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-288-email-editor-loads
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Move the email editor feature flag, preview send and REST failure coverage below E2E; the browser spec goes from six titles to five.
+
diff --git a/plugins/woocommerce/tests/e2e/tests/email-editor/email-editor-loads.spec.ts b/plugins/woocommerce/tests/e2e/tests/email-editor/email-editor-loads.spec.ts
index 3a7920ee7cf..b77e1b73227 100644
--- a/plugins/woocommerce/tests/e2e/tests/email-editor/email-editor-loads.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/email-editor/email-editor-loads.spec.ts
@@ -1,43 +1,89 @@
+/**
+ * External dependencies
+ */
+import type { Page } from '@playwright/test';
+
 /**
  * Internal dependencies
  */
-import { expect, test } from '../../fixtures/fixtures';
+import { expect, request, test } from '../../fixtures/fixtures';
 import { ADMIN_STATE_PATH } from '../../playwright.config';
 import {
+	deleteEmailPost,
 	disableEmailEditor,
 	enableEmailEditor,
 } from './helpers/enable-email-editor-feature';
 import { accessTheEmailEditor } from '../../utils/email';
+import { setOption } from '../../utils/options';

 test.describe( 'WooCommerce Email Editor Core', () => {
 	test.use( { storageState: ADMIN_STATE_PATH } );

-	test.afterAll( async ( { baseURL } ) => {
-		await disableEmailEditor( baseURL );
+	const emailPostIds = new Set< string >();
+
+	const captureEmailPostId = ( page: Page ) => {
+		const postId = new URL( page.url() ).searchParams.get( 'post' );
+		if ( postId && /^[1-9]\d*$/.test( postId ) ) {
+			emailPostIds.add( postId );
+		}
+		return postId;
+	};
+
+	const accessAndTrackEmailPost = async ( page: Page ) => {
+		let postId: string | null = null;
+		try {
+			await accessTheEmailEditor( page, 'New order' );
+		} finally {
+			postId = captureEmailPostId( page );
+		}
+		// The editor has to have opened a post, and its id is what afterAll deletes.
+		expect( postId ).toMatch( /^[1-9]\d*$/ );
+	};
+
+	test.beforeAll( async ( { baseURL } ) => {
+		await enableEmailEditor( baseURL );
 	} );

-	test( 'Can enable the email editor', async ( { page } ) => {
-		// Navigate to the settings page.
-		await page.goto( '/wp-admin/admin.php?page=wc-settings' );
+	test.afterAll( async ( { baseURL } ) => {
+		const cleanupErrors: unknown[] = [];

-		// Enable the email editor using the UI.
-		await page.getByRole( 'link', { name: 'Advanced' } ).click();
-		await page.getByRole( 'link', { name: 'Features' } ).click();
-		await page
-			.getByRole( 'checkbox', { name: 'Enable the block-based email' } )
-			.check();
-		await page.getByRole( 'button', { name: 'Save changes' } ).click();
-		await page.getByRole( 'link', { name: 'Emails' } ).click();
-		await expect(
-			page.locator( '#email_notification_settings-description' )
-		).toContainText(
-			'Manage email notifications sent from WooCommerce below'
-		);
+		for ( const postId of emailPostIds ) {
+			try {
+				await deleteEmailPost( baseURL, postId );
+			} catch ( error ) {
+				cleanupErrors.push( error );
+			}
+		}
+
+		try {
+			await disableEmailEditor( baseURL );
+			const verification = await setOption(
+				request,
+				baseURL,
+				'woocommerce_feature_block_email_editor_enabled',
+				'no'
+			);
+			// The e2e test-helper plugin answers a no-op option write with this
+			// wording, so the match proves disableEmailEditor already wrote `no`.
+			// A failure here is cleanup failing, not the title that ran last.
+			expect( verification ).toContain( 'already set to: no' );
+		} catch ( error ) {
+			cleanupErrors.push( error );
+		}
+
+		if ( cleanupErrors.length > 0 ) {
+			throw new AggregateError(
+				cleanupErrors,
+				`Email editor cleanup failed: ${ cleanupErrors
+					.map( ( error ) => String( error ) )
+					.join( '; ' ) }`
+			);
+		}
 	} );

 	test( 'Can access the email editor', async ( { page } ) => {
 		// Try with the new order email.
-		await accessTheEmailEditor( page, 'New order' );
+		await accessAndTrackEmailPost( page );
 		// TODO: WP 7.0 compat - WP 7.0 changed the editor sidebar tab role from
 		// tab to button. Simplify when WP 7.0 is the minimum supported version.
 		const emailTab = page
@@ -56,7 +102,7 @@ test.describe( 'WooCommerce Email Editor Core', () => {
 	} );

 	test( 'Can preview in new tab', async ( { page } ) => {
-		await accessTheEmailEditor( page, 'New order' );
+		await accessAndTrackEmailPost( page );
 		await page.getByRole( 'button', { name: 'View', exact: true } ).click();

 		// WP 7.1 adds a "Responsive styles" toggle to this menu; the email
@@ -74,41 +120,49 @@ test.describe( 'WooCommerce Email Editor Core', () => {
 				.getByRole( 'menuitem', { name: 'Preview in new tab' } )
 				.click(),
 		] );
-		await newPage.bringToFront();
-		await newPage.waitForLoadState( 'domcontentloaded' );
-		// eslint-disable-next-line playwright/no-wait-for-selector -- wait for the tab to be loaded.
-		await newPage.waitForSelector( '.wp-block-heading' );
-		await page.close(); // close the original tab.
-		expect( newPage.url() ).toContain( 'preview=true' );
-		await expect( newPage.locator( 'body' ) ).toContainText(
-			'New order: #12345'
-		);
+		try {
+			await newPage.bringToFront();
+			await newPage.waitForLoadState( 'domcontentloaded' );
+			// eslint-disable-next-line playwright/no-wait-for-selector -- wait for the tab to be loaded.
+			await newPage.waitForSelector( '.wp-block-heading' );
+			await page.close(); // close the original tab.
+			await expect( newPage.locator( 'body' ) ).toContainText(
+				'New order: #12345'
+			);
+		} finally {
+			await newPage.close();
+		}
 	} );

 	test( 'Can send test email', async ( { page } ) => {
-		await accessTheEmailEditor( page, 'New order' );
+		await accessAndTrackEmailPost( page );
 		await page.getByRole( 'button', { name: 'View', exact: true } ).click();
 		await page
 			.getByRole( 'menuitem', { name: 'Send a test email' } )
 			.click();
-		await expect(
-			page.locator( '.components-modal__header' )
-		).toContainText( 'Send a test email' );
-		await expect(
-			page.getByRole( 'button', { name: 'Send test email' } )
-		).toBeEnabled();
-		await expect(
-			page.getByRole( 'button', { name: 'Cancel' } )
-		).toBeEnabled();
-		await page.getByRole( 'button', { name: 'Send test email' } ).click();
-		await expect(
-			page.locator( '.woocommerce-send-preview-modal-notice-error' )
-		).toContainText( 'Sorry, we were unable to send this email.' );
-		await page.getByRole( 'button', { name: 'Close' } ).click();
+		const sendButton = page.getByRole( 'button', {
+			name: 'Send test email',
+		} );
+		await expect( sendButton ).toBeEnabled();
+
+		// Assert the response, not the error notice: a route path that drifted on
+		// either side answers 404 and still renders the same notice. The test
+		// environment has no mailer, so the real send fails and the route answers 400.
+		const [ response ] = await Promise.all( [
+			page.waitForResponse(
+				( candidate ) =>
+					candidate.request().method() === 'POST' &&
+					decodeURIComponent( candidate.url() ).includes(
+						'/woocommerce-email-editor/v1/send_preview_email'
+					)
+			),
+			sendButton.click(),
+		] );
+		expect( response.status() ).toBe( 400 );
 	} );

 	test( 'Can edit and save content', async ( { page } ) => {
-		await accessTheEmailEditor( page, 'New order' );
+		await accessAndTrackEmailPost( page );
 		await expect(
 			page
 				.locator( 'iframe[name="editor-canvas"]' )
@@ -121,15 +175,33 @@ test.describe( 'WooCommerce Email Editor Core', () => {
 		// dirtying change, so the Save button stays disabled. A single-line
 		// edit commits normally and still exercises the edit → save → preview
 		// flow this test covers.
-		await page
+		const editableParagraph = page
 			.locator( 'iframe[name="editor-canvas"]' )
 			.contentFrame()
-			.getByText( 'You’ve received a new' )
-			.fill( 'Hello world from Woo plugin' );
+			.getByText( 'You’ve received a new' );
+		await editableParagraph.click();
+		await expect( editableParagraph ).toBeEditable();
+		await editableParagraph.fill( 'Hello world from Woo plugin' );
+		await expect(
+			page
+				.locator( 'iframe[name="editor-canvas"]' )
+				.contentFrame()
+				.getByText( 'Hello world from Woo plugin' )
+		).toBeVisible();
 		await expect(
 			page.getByRole( 'button', { name: 'Save', exact: true } )
 		).toBeVisible();
 		await page.getByRole( 'button', { name: 'Save', exact: true } ).click();
+		// Assert the snackbar, not the a11y live region. @wordpress/a11y's speak()
+		// clears every .a11y-speak-region before writing, so announcements replace
+		// rather than accumulate: the region holds exactly one message, and any
+		// later announcement during the save wipes this one. The snackbar carries
+		// the same rewritten notice text and stays on screen.
+		await expect(
+			page
+				.locator( '.components-snackbar' )
+				.filter( { hasText: 'Email saved.' } )
+		).toBeVisible();
 		await expect(
 			page
 				.locator( 'iframe[name="editor-canvas"]' )
@@ -145,20 +217,31 @@ test.describe( 'WooCommerce Email Editor Core', () => {
 			.getByRole( 'menuitem', { name: 'Preview in new tab' } )
 			.click();
 		const page1 = await page1Promise;
-		await expect( page1.locator( 'body' ) ).toContainText(
-			'Hello world from Woo plugin'
-		);
+		try {
+			await page1.bringToFront();
+			await page1.waitForLoadState( 'domcontentloaded' );
+			// Wait for the generated preview to replace the loading screen.
+			await expect(
+				page1.locator( '.wp-block-heading' ).first()
+			).toBeVisible();
+			await expect( page1.locator( 'body' ) ).toContainText(
+				'Hello world from Woo plugin'
+			);
+		} finally {
+			await page1.close();
+		}
 	} );

 	test( 'Can use personalization tags in the Button block', async ( {
 		page,
 		baseURL,
 	} ) => {
-		// Enable via API so the test does not depend on the enable test having
-		// run in the same worker (a worker restart runs afterAll, which
-		// disables the feature).
+		// Redundant with beforeAll, which owns enablement for this suite and runs
+		// again in a restarted worker. Kept so that merging this title forward
+		// stays a merge: it is harmless, and removing it would be a behavior
+		// change in a title this batch does not otherwise touch.
 		await enableEmailEditor( baseURL );
-		await accessTheEmailEditor( page, 'New order' );
+		await accessAndTrackEmailPost( page );
 		const canvas = page
 			.locator( 'iframe[name="editor-canvas"]' )
 			.contentFrame();
diff --git a/plugins/woocommerce/tests/e2e/tests/email-editor/helpers/enable-email-editor-feature.ts b/plugins/woocommerce/tests/e2e/tests/email-editor/helpers/enable-email-editor-feature.ts
index 1a97c09754c..4e2619dff6d 100644
--- a/plugins/woocommerce/tests/e2e/tests/email-editor/helpers/enable-email-editor-feature.ts
+++ b/plugins/woocommerce/tests/e2e/tests/email-editor/helpers/enable-email-editor-feature.ts
@@ -35,8 +35,14 @@ export const setEmailEditorFeatureFlag = async (
  * @param {string} baseURL The base URL.
  * @return {Promise<void>}
  */
-export const enableEmailEditor = async ( baseURL: string ) =>
-	setEmailEditorFeatureFlag( baseURL, 'yes' );
+export const enableEmailEditor = async ( baseURL: string ) => {
+	await deleteOption(
+		request,
+		baseURL,
+		'woocommerce_email_editor_rewrites_flushed'
+	);
+	await setEmailEditorFeatureFlag( baseURL, 'yes' );
+};

 /**
  * Disable the email editor feature.
diff --git a/plugins/woocommerce/tests/php/src/Internal/Features/FeaturesControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/Features/FeaturesControllerTest.php
index dae69d05401..36c9faba4fe 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Features/FeaturesControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Features/FeaturesControllerTest.php
@@ -363,6 +363,53 @@ class FeaturesControllerTest extends \WC_Unit_Test_Case {
 		$this->assertEquals( $expected_new_enabled, $result );
 	}

+	/**
+	 * @testdox The Block Email Editor setting is visible and persists enablement.
+	 */
+	public function test_block_email_editor_setting_is_visible_and_persists_enablement(): void {
+		$feature_option_name = 'woocommerce_feature_block_email_editor_enabled';
+
+		// setUp() registers this class's own dummy features on this hook. Detach them so the
+		// real controller below sees only the built-in definitions. _restore_hooks() puts the
+		// callback back after the test, the same way the rollback puts the option row back.
+		remove_action( 'woocommerce_register_feature_definitions', array( $this, 'register_dummy_features' ), 11 );
+
+		// `change_feature_enable` reports whether `update_option` wrote anything, so it
+		// returns false when the option already reads `yes`. Start from no option at
+		// all, so the assertion below measures the transition rather than whatever an
+		// earlier test may have committed.
+		delete_option( $feature_option_name );
+
+		$real_sut = new FeaturesController();
+		$real_sut->init( wc_get_container()->get( LegacyProxy::class ), $this->fake_plugin_util );
+
+		$all_settings = $real_sut->add_feature_settings( array(), 'features' );
+		$settings     = array_values(
+			array_filter(
+				$all_settings,
+				function ( $candidate ) use ( $feature_option_name ) {
+					return ( $candidate['id'] ?? null ) === $feature_option_name;
+				}
+			)
+		);
+		$this->assertCount( 1, $settings, 'The Block Email Editor feature should have exactly one settings row.' );
+		$setting = $settings[0];
+
+		$this->assertSame( $feature_option_name, $setting['id'], 'The setting should use the feature enable option.' );
+		$this->assertSame( 'Block Email Editor (alpha)', $setting['title'], 'The setting should use the built-in feature title.' );
+		$this->assertSame( 'checkbox', $setting['type'], 'The setting should render as a checkbox.' );
+		$this->assertSame( 'no', $setting['default'], 'The Block Email Editor feature should be disabled by default.' );
+		$this->assertStringContainsString(
+			'Enable the block-based email editor',
+			$setting['desc'],
+			'The setting should carry the feature description a merchant reads next to the checkbox.'
+		);
+
+		$this->assertTrue( $real_sut->change_feature_enable( 'block_email_editor', true ), 'Enabling the built-in Block Email Editor feature should update its option.' );
+		$this->assertSame( 'yes', get_option( $feature_option_name ), 'Enabling the feature should persist the expected option value.' );
+		$this->assertTrue( $real_sut->feature_is_enabled( 'block_email_editor' ), 'The real feature controller should report the enabled feature as enabled.' );
+	}
+
 	/**
 	 * @testdox 'declare_compatibility' fails when invoked from outside the 'before_woocommerce_init' action.
 	 */