From 45285345dca012f5cf9865908318f34bef23ea75 Mon Sep 17 00:00:00 2001
From: Jeffrey Carandang
Date: Mon, 2 Nov 2026 21:28:32 +0800
Subject: [PATCH 1/8] feat(newsletter-publishing): convert Group blocks to
beehiiv sections
Feature: newsletter-publishing/block-conversion/block-conversion-framework
Implements:
- US-006: A Group block becomes a beehiiv section holding its converted inner blocks
Posts first synced with this change keep sections on later syncs. Posts synced before it keep omitting Group blocks.
---
includes/Editor/Meta.php | 9 +
includes/Newsletter/BlockConverter.php | 61 +++++-
includes/Newsletter/PostSettingsBuilder.php | 26 ++-
includes/Newsletter/Sender.php | 2 +
includes/Newsletter/SupportedBlocks.php | 7 +-
tests/phpunit/BlockConverterGroupTest.php | 228 ++++++++++++++++++++
6 files changed, 325 insertions(+), 8 deletions(-)
create mode 100644 tests/phpunit/BlockConverterGroupTest.php
diff --git a/includes/Editor/Meta.php b/includes/Editor/Meta.php
index 8dfc86a..ca9deb2 100644
--- a/includes/Editor/Meta.php
+++ b/includes/Editor/Meta.php
@@ -72,6 +72,15 @@ final class Meta {
*/
public const BEEHIIV_POST_ID = '_beehiiv_post_id';
+ /**
+ * Set when the post was first synced with Group blocks converted to beehiiv sections.
+ *
+ * Posts synced before section support lack it and keep omitting Group blocks on re-sync.
+ *
+ * @since x.x.x
+ */
+ public const BEEHIIV_GROUP_SECTIONS = '_beehiiv_group_sections';
+
/**
* UTC ISO 8601 datetime last synced to beehiiv `scheduled_at` for a linked post.
*
diff --git a/includes/Newsletter/BlockConverter.php b/includes/Newsletter/BlockConverter.php
index 32a1f94..86d68d2 100644
--- a/includes/Newsletter/BlockConverter.php
+++ b/includes/Newsletter/BlockConverter.php
@@ -38,7 +38,11 @@
* - `core/more` — snippet newsletters only; beehiiv Read More `button` via `convert_more_block()`
* - `beehiiv/advertisement` — beehiiv `advertisement` block via `convert_advertisement_block()`
*
- * Layout blocks (`core/group`, `core/columns`, etc.) are skipped at the top level.
+ * - `core/group` — `convert_group_block()` (beehiiv `section` block holding the converted inner
+ * blocks; only for posts whose first sync used sections, see `PostSettingsBuilder`)
+ *
+ * Other layout blocks (`core/columns`, etc.) are skipped at the top level, and so is
+ * `core/group` for posts first synced before section support.
* `convert_columns_block()` exists for internal reuse (e.g. `core/media-text`) and is
* not wired into the main block walk yet. Snippet mode stops at `core/more`.
*
@@ -49,11 +53,13 @@ final class BlockConverter {
/**
* Convert a WordPress post's blocks to beehiiv blocks.
*
- * @param WP_Post $post_object Post object.
+ * @param WP_Post $post_object Post object.
+ * @param bool $convert_groups Whether `core/group` blocks become beehiiv sections. When false,
+ * Group blocks are omitted.
* @return array>
* @since 1.0.0
*/
- public static function convert_supported_blocks( WP_Post $post_object ): array {
+ public static function convert_supported_blocks( WP_Post $post_object, bool $convert_groups = false ): array {
$beehiiv_blocks = [];
$wp_blocks = parse_blocks( $post_object->post_content );
@@ -73,7 +79,8 @@ public static function convert_supported_blocks( WP_Post $post_object ): array {
$converted_blocks = self::convert_blocks(
$wp_blocks,
$post_object,
- $is_send_newsletter_snippet
+ $is_send_newsletter_snippet,
+ $convert_groups
);
return array_values(
@@ -89,13 +96,15 @@ public static function convert_supported_blocks( WP_Post $post_object ): array {
* @param array> $wp_blocks Parsed blocks.
* @param WP_Post $post_object Post object.
* @param bool $is_send_newsletter_snippet Whether snippet mode is enabled.
+ * @param bool $convert_groups Whether Group blocks become sections.
* @return array>
* @since 1.0.0
*/
private static function convert_blocks(
array $wp_blocks,
WP_Post $post_object,
- bool $is_send_newsletter_snippet
+ bool $is_send_newsletter_snippet,
+ bool $convert_groups = false
): array {
$beehiiv_blocks = [];
@@ -111,6 +120,16 @@ private static function convert_blocks(
break;
}
+ if ( 'core/group' === $block_name ) {
+ $section_block = $convert_groups ? self::convert_group_block( $wp_block, $post_object ) : [];
+
+ if ( ! empty( $section_block ) ) {
+ $beehiiv_blocks[] = $section_block;
+ }
+
+ continue;
+ }
+
if ( ! SupportedBlocks::is_supported( $block_name, $is_send_newsletter_snippet ) ) {
continue;
}
@@ -137,6 +156,38 @@ private static function convert_blocks(
return $beehiiv_blocks;
}
+ /**
+ * Convert a core/group block to a beehiiv section block.
+ *
+ * The Group's inner blocks are converted with the same rules as top-level blocks and
+ * placed in the section in their original order. A nested Group becomes a nested
+ * section. Section settings are left at beehiiv defaults. Returns an empty array when
+ * no inner block converts, so no empty section is sent. Snippet-only blocks are
+ * omitted inside a section.
+ *
+ * @param array $wp_block Parsed core/group block.
+ * @param WP_Post $post_object Post object.
+ * @return array
+ * @since x.x.x
+ */
+ private static function convert_group_block( array $wp_block, WP_Post $post_object ): array {
+ $inner_blocks = self::convert_blocks(
+ $wp_block['innerBlocks'] ?? [],
+ $post_object,
+ false,
+ true
+ );
+
+ if ( empty( $inner_blocks ) ) {
+ return [];
+ }
+
+ return [
+ 'type' => 'section',
+ 'blocks' => $inner_blocks,
+ ];
+ }
+
/**
* Convert a single supported WordPress block.
*
diff --git a/includes/Newsletter/PostSettingsBuilder.php b/includes/Newsletter/PostSettingsBuilder.php
index df74b14..de73b5f 100644
--- a/includes/Newsletter/PostSettingsBuilder.php
+++ b/includes/Newsletter/PostSettingsBuilder.php
@@ -94,7 +94,10 @@ public static function get_post_settings( int $post_id, bool $for_update = false
);
}
- $beehiiv_blocks = BlockConverter::convert_supported_blocks( $post_object );
+ $beehiiv_blocks = BlockConverter::convert_supported_blocks(
+ $post_object,
+ self::should_convert_groups( $post_id )
+ );
if ( empty( $beehiiv_blocks ) ) {
return new WP_Error(
@@ -264,6 +267,27 @@ public static function get_update_payload( int $post_id ) {
return $update['payload'];
}
+ /**
+ * Whether Group blocks become beehiiv sections for this post.
+ *
+ * A post that has no beehiiv post yet is on its first sync, so Groups convert. After
+ * that, only a post whose first sync converted them keeps converting them; a post
+ * synced before section support keeps omitting Groups so its beehiiv layout does not change.
+ *
+ * @param int $post_id Post ID.
+ * @return bool
+ * @since x.x.x
+ */
+ private static function should_convert_groups( int $post_id ): bool {
+ $beehiiv_post_id = get_post_meta( $post_id, Meta::BEEHIIV_POST_ID, true );
+
+ if ( ! is_string( $beehiiv_post_id ) || '' === trim( $beehiiv_post_id ) ) {
+ return true;
+ }
+
+ return (bool) get_post_meta( $post_id, Meta::BEEHIIV_GROUP_SECTIONS, true );
+ }
+
/**
* Whether a non-empty subtitle was last synced to the linked beehiiv post.
*
diff --git a/includes/Newsletter/Sender.php b/includes/Newsletter/Sender.php
index accbe8a..3a45795 100644
--- a/includes/Newsletter/Sender.php
+++ b/includes/Newsletter/Sender.php
@@ -536,6 +536,8 @@ public static function send( int $post_id ): void {
(string) ( $beehiiv_post_data['post_template_id'] ?? '' )
);
update_post_meta( $post_id, Meta::BEEHIIV_POST_ID, $result['post_id'] );
+ // This first sync converted Group blocks to sections; later syncs of this post must too.
+ update_post_meta( $post_id, Meta::BEEHIIV_GROUP_SECTIONS, true );
update_post_meta( $post_id, Meta::SEND_TO_NEWSLETTER, false );
delete_post_meta( $post_id, Meta::BEEHIIV_NEWSLETTER_SENT );
self::persist_synced_subtitle( $post_id, $beehiiv_post_data );
diff --git a/includes/Newsletter/SupportedBlocks.php b/includes/Newsletter/SupportedBlocks.php
index ba93a96..80ba90a 100644
--- a/includes/Newsletter/SupportedBlocks.php
+++ b/includes/Newsletter/SupportedBlocks.php
@@ -12,8 +12,10 @@
/**
* Single source of truth for which WordPress blocks are included in newsletters.
*
- * Layout blocks such as `core/group` and `core/columns` are not supported. They
- * and all of their inner blocks are omitted from the newsletter payload.
+ * `core/group` is supported as a beehiiv section for posts first synced with section
+ * support; see `BlockConverter::convert_group_block()`. Other layout blocks such as
+ * `core/columns` are not supported. They and all of their inner blocks are omitted
+ * from the newsletter payload.
*
* @since 1.0.0
*/
@@ -34,6 +36,7 @@ final class SupportedBlocks {
'core/pullquote',
'core/embed',
'core/media-text',
+ 'core/group',
'core/buttons',
'core/separator',
'beehiiv/advertisement',
diff --git a/tests/phpunit/BlockConverterGroupTest.php b/tests/phpunit/BlockConverterGroupTest.php
new file mode 100644
index 0000000..78b3a78
--- /dev/null
+++ b/tests/phpunit/BlockConverterGroupTest.php
@@ -0,0 +1,228 @@
+ 'pub_test',
+ 'post_template_id' => 'tmpl_test',
+ )
+ );
+ }
+
+ /**
+ * Build a paragraph block's markup.
+ *
+ * @param string $text Paragraph text.
+ * @return string
+ */
+ private function paragraph( string $text ): string {
+ return "\n{$text}
\n";
+ }
+
+ /**
+ * Wrap block markup in a Group block.
+ *
+ * @param string $inner Inner block markup.
+ * @return string
+ */
+ private function group( string $inner ): string {
+ return "\n{$inner}
\n";
+ }
+
+ /**
+ * Create a published post with the given content.
+ *
+ * @param string $content Block markup.
+ * @return WP_Post
+ */
+ private function create_post( string $content ): WP_Post {
+ $post_id = self::factory()->post->create(
+ array(
+ 'post_title' => 'Group post',
+ 'post_status' => 'publish',
+ 'post_content' => $content,
+ )
+ );
+
+ return get_post( $post_id );
+ }
+
+ /**
+ * AC-009 and AC-013: a Group with a heading and two paragraphs is one section with three children.
+ */
+ public function test_group_becomes_one_section_with_children_in_order(): void {
+ $heading = "\nTitle \n";
+ $post = $this->create_post(
+ $this->group( $heading . $this->paragraph( 'First' ) . $this->paragraph( 'Second' ) )
+ );
+
+ $blocks = BlockConverter::convert_supported_blocks( $post, true );
+
+ $this->assertCount( 1, $blocks );
+ $this->assertSame( 'section', $blocks[0]['type'] );
+ $this->assertCount( 3, $blocks[0]['blocks'] );
+ $this->assertSame(
+ array( 'heading', 'paragraph', 'paragraph' ),
+ array_column( $blocks[0]['blocks'], 'type' )
+ );
+ }
+
+ /**
+ * AC-009: blocks around a Group stay in place and the section keeps their order.
+ */
+ public function test_section_sits_between_sibling_blocks(): void {
+ $post = $this->create_post(
+ $this->paragraph( 'Before' ) . $this->group( $this->paragraph( 'Inside' ) ) . $this->paragraph( 'After' )
+ );
+
+ $blocks = BlockConverter::convert_supported_blocks( $post, true );
+
+ $this->assertSame(
+ array( 'paragraph', 'section', 'paragraph' ),
+ array_column( $blocks, 'type' )
+ );
+ }
+
+ /**
+ * AC-010: a Group inside a Group becomes a nested section.
+ */
+ public function test_nested_group_becomes_nested_section(): void {
+ $inner = $this->group( $this->paragraph( 'Deep' ) );
+ $post = $this->create_post( $this->group( $this->paragraph( 'Top' ) . $inner ) );
+
+ $blocks = BlockConverter::convert_supported_blocks( $post, true );
+
+ $this->assertCount( 1, $blocks );
+ $this->assertSame( 'section', $blocks[0]['type'] );
+ $this->assertSame(
+ array( 'paragraph', 'section' ),
+ array_column( $blocks[0]['blocks'], 'type' )
+ );
+ $this->assertCount( 1, $blocks[0]['blocks'][1]['blocks'] );
+ }
+
+ /**
+ * AC-011: a section carries only its type and blocks, never Group styling.
+ */
+ public function test_section_uses_default_settings(): void {
+ $attrs = '{"backgroundColor":"vivid-red","style":{"spacing":{"padding":{"top":"40px"}}}}';
+ $content = "\n"
+ . ''
+ . $this->paragraph( 'Styled' )
+ . "
\n";
+
+ $blocks = BlockConverter::convert_supported_blocks( $this->create_post( $content ), true );
+
+ $this->assertSame( array( 'type', 'blocks' ), array_keys( $blocks[0] ) );
+ }
+
+ /**
+ * AC-012: a Group with no blocks, or no convertible blocks, produces no section.
+ */
+ public function test_group_without_convertible_blocks_produces_no_section(): void {
+ $unsupported = "\nx \n";
+
+ $empty = BlockConverter::convert_supported_blocks(
+ $this->create_post( $this->paragraph( 'Kept' ) . $this->group( '' ) ),
+ true
+ );
+ $only_unsupported = BlockConverter::convert_supported_blocks(
+ $this->create_post( $this->paragraph( 'Kept' ) . $this->group( $unsupported ) ),
+ true
+ );
+
+ $this->assertSame( array( 'paragraph' ), array_column( $empty, 'type' ) );
+ $this->assertSame( array( 'paragraph' ), array_column( $only_unsupported, 'type' ) );
+ }
+
+ /**
+ * AC-012: a Group that mixes convertible and unconvertible blocks keeps only the convertible ones.
+ */
+ public function test_mixed_group_keeps_only_convertible_blocks(): void {
+ $unsupported = "\nx \n";
+ $post = $this->create_post(
+ $this->group( $this->paragraph( 'One' ) . $unsupported . $this->paragraph( 'Two' ) )
+ );
+
+ $blocks = BlockConverter::convert_supported_blocks( $post, true );
+
+ $this->assertSame( 'section', $blocks[0]['type'] );
+ $this->assertCount( 2, $blocks[0]['blocks'] );
+ }
+
+ /**
+ * Without the flag a Group and its inner blocks are omitted, as before section support.
+ */
+ public function test_group_is_omitted_when_conversion_is_off(): void {
+ $post = $this->create_post( $this->paragraph( 'Kept' ) . $this->group( $this->paragraph( 'Lost' ) ) );
+
+ $blocks = BlockConverter::convert_supported_blocks( $post );
+
+ $this->assertSame( array( 'paragraph' ), array_column( $blocks, 'type' ) );
+ }
+
+ /**
+ * AC-014: a post with no beehiiv post yet converts Groups.
+ */
+ public function test_first_sync_converts_groups(): void {
+ $post = $this->create_post( $this->paragraph( 'Kept' ) . $this->group( $this->paragraph( 'Inside' ) ) );
+ $settings = PostSettingsBuilder::get_post_settings( $post->ID );
+
+ $this->assertIsArray( $settings );
+ $this->assertSame( array( 'paragraph', 'section' ), array_column( $settings['blocks'], 'type' ) );
+ }
+
+ /**
+ * AC-014: a post first synced before section support keeps omitting Groups on re-sync.
+ */
+ public function test_post_synced_before_section_support_keeps_omitting_groups(): void {
+ $post = $this->create_post( $this->paragraph( 'Kept' ) . $this->group( $this->paragraph( 'Inside' ) ) );
+ update_post_meta( $post->ID, Meta::BEEHIIV_POST_ID, 'post_legacy' );
+
+ $settings = PostSettingsBuilder::get_post_settings( $post->ID, true );
+
+ $this->assertIsArray( $settings );
+ $this->assertSame( array( 'paragraph' ), array_column( $settings['blocks'], 'type' ) );
+ }
+
+ /**
+ * AC-014: a post first synced with sections keeps its sections when it is synced again.
+ */
+ public function test_post_first_synced_with_sections_keeps_them_on_resync(): void {
+ $post = $this->create_post( $this->paragraph( 'Kept' ) . $this->group( $this->paragraph( 'Inside' ) ) );
+ update_post_meta( $post->ID, Meta::BEEHIIV_POST_ID, 'post_new' );
+ update_post_meta( $post->ID, Meta::BEEHIIV_GROUP_SECTIONS, true );
+
+ $settings = PostSettingsBuilder::get_post_settings( $post->ID, true );
+
+ $this->assertIsArray( $settings );
+ $this->assertSame( array( 'paragraph', 'section' ), array_column( $settings['blocks'], 'type' ) );
+ }
+}
From f65f06fd82f824932974ece51bdabbf18f9b79ce Mon Sep 17 00:00:00 2001
From: Jeffrey Carandang
Date: Mon, 2 Nov 2026 21:43:28 +0800
Subject: [PATCH 2/8] test(e2e): cover block conversion framework acceptance
criteria
---
.../specs/block-conversion-framework.spec.js | 643 ++++++++++++++++++
1 file changed, 643 insertions(+)
create mode 100644 tests/e2e/specs/block-conversion-framework.spec.js
diff --git a/tests/e2e/specs/block-conversion-framework.spec.js b/tests/e2e/specs/block-conversion-framework.spec.js
new file mode 100644
index 0000000..948136e
--- /dev/null
+++ b/tests/e2e/specs/block-conversion-framework.spec.js
@@ -0,0 +1,643 @@
+const { test, expect } = require( '@playwright/test' );
+const { loginAsAdmin } = require( '../utils/auth' );
+const {
+ wpCli,
+ wpCliSafe,
+ ensurePluginActive,
+ ensurePrettyPermalinks,
+} = require( '../utils/wp-cli' );
+
+/**
+ * PRD: requirements/07-newsletter-publishing/2-block-conversion/block-conversion-framework.prd.md (v1.0)
+ *
+ * Independent behavioral coverage (qa-e2e-author), written from the PRD's
+ * Acceptance Criteria plus the real code at:
+ * - includes/Newsletter/BlockConverter.php -- block routing, Group to section.
+ * - includes/Newsletter/SupportedBlocks.php -- supported block registry.
+ * - includes/Newsletter/PostSettingsBuilder.php -- Group gate (first sync marker).
+ * - includes/Newsletter/Sender.php -- sets the first-sync marker on create.
+ * - includes/Editor/Meta.php -- post meta keys.
+ *
+ * Every save goes through the REST endpoint the block editor uses
+ * (POST /wp/v2/posts/{id}, cookie + nonce), which fires the newsletter sync.
+ * Every outbound beehiiv request is answered by the test-only
+ * `pre_http_request` mock in tests/e2e/plugins/beehiiv-options.php and
+ * recorded in its request log, so specs assert on the exact `blocks` payload
+ * the plugin sent to beehiiv. Nothing reaches the live API.
+ */
+
+const META = {
+ send: '_beehiiv_send_to_newsletter',
+ snippet: '_beehiiv_send_to_newsletter_snippet',
+ postId: '_beehiiv_post_id',
+ groupSections: '_beehiiv_group_sections',
+};
+
+const PUBLICATION_ID = 'pub_qa_e2e_blocks';
+const TEMPLATE_ID = 'tpl_qa_e2e_blocks';
+const MOCK_CREATED_ID = 'post_qa_e2e_blocks_created';
+const LEGACY_ID = 'post_qa_e2e_blocks_legacy';
+const ACTIVE_AD = 'ad_qa_active';
+const EXPIRED_AD = 'ad_qa_expired';
+const PNG_B64 =
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
+
+const createdPosts = [];
+const createdAttachments = [];
+
+const b64 = ( value ) =>
+ Buffer.from( JSON.stringify( value ) ).toString( 'base64' );
+
+/**
+ * Runs PHP inside the tests env; the payload travels base64-encoded so no quoting issues.
+ *
+ * @param {Object} payload Values exposed to the PHP snippet as `$a`.
+ * @param {string} php PHP code to evaluate.
+ * @return {string} wp-cli output.
+ */
+function wpEvalWith( payload, php ) {
+ return wpCli(
+ `eval '$a = json_decode( base64_decode( "${ b64(
+ payload
+ ) }" ), true ); ${ php }'`
+ );
+}
+
+/**
+ * Resets beehiiv seams and seeds a "ready to send" site with an active ad list.
+ */
+function seed() {
+ const mocks = [
+ [
+ '/advertisement_opportunities',
+ {
+ body: {
+ data: [
+ {
+ id: ACTIVE_AD,
+ advertiser_name: 'QA Advertiser',
+ payout_rate: '1.00',
+ advertisement_kind: 'cpc',
+ },
+ ],
+ },
+ },
+ ],
+ [ '/posts', { body: { data: { id: MOCK_CREATED_ID } } } ],
+ ];
+ wpEvalWith(
+ { mocks },
+ `beehiiv_e2e_reset_all();
+ beehiiv_e2e_seed_connection();
+ beehiiv_e2e_mock_permissions( [ "posts" => [ "read", "write" ] ] );
+ update_option( "beehiiv_settings", [ "publication_id" => "${ PUBLICATION_ID }", "post_template_id" => "${ TEMPLATE_ID }" ] );
+ beehiiv_e2e_seed_publications( [ [ "id" => "${ PUBLICATION_ID }", "name" => "QA E2E Publication" ] ] );
+ beehiiv_e2e_seed_post_templates( "${ PUBLICATION_ID }", [ [ "id" => "${ TEMPLATE_ID }", "name" => "QA E2E Template" ] ] );
+ foreach ( $a["mocks"] as $m ) { beehiiv_e2e_mock_http( $m[0], $m[1] ); }
+ beehiiv_e2e_clear_http_log();`
+ );
+}
+
+/**
+ * Creates a draft post fixture in the DB and empties the request log.
+ *
+ * @param {Object} options Post fields.
+ * @param {string} options.title Post title.
+ * @param {string} options.content Post content (block markup).
+ * @param {Object} [options.meta] Post meta to set.
+ * @param {number} [options.thumb] Featured image attachment ID.
+ * @param {string} [options.status] Post status.
+ * @return {string} Post ID.
+ */
+function createPost( {
+ title,
+ content,
+ meta = {},
+ thumb = 0,
+ status = 'draft',
+} ) {
+ const post = {
+ post_type: 'post',
+ post_title: title,
+ post_content: content,
+ post_status: status,
+ post_author: 1,
+ };
+ const id = wpEvalWith(
+ { post, meta, thumb },
+ `$id = wp_insert_post( $a["post"] );
+ foreach ( $a["meta"] as $k => $v ) { update_post_meta( $id, $k, $v ); }
+ if ( $a["thumb"] ) { set_post_thumbnail( $id, $a["thumb"] ); }
+ beehiiv_e2e_clear_http_log();
+ echo $id . "\\n";`
+ );
+ createdPosts.push( id );
+ return id;
+}
+
+/**
+ * Creates an image attachment in the tests env's media library.
+ *
+ * @return {string} Attachment ID.
+ */
+function createAttachment() {
+ const id = wpEvalWith(
+ { png: PNG_B64 },
+ `$up = wp_upload_bits( "qa-e2e-featured.png", null, base64_decode( $a["png"] ) );
+ $att = wp_insert_attachment( [ "post_mime_type" => "image/png", "post_title" => "QA featured", "post_status" => "inherit" ], $up["file"] );
+ require_once ABSPATH . "wp-admin/includes/image.php";
+ wp_update_attachment_metadata( $att, wp_generate_attachment_metadata( $att, $up["file"] ) );
+ echo $att . "\\n";`
+ );
+ createdAttachments.push( id );
+ return id;
+}
+
+/**
+ * All meta for a post, single values.
+ *
+ * @param {string} id Post ID.
+ * @return {Object} Meta keyed by meta key.
+ */
+function getMeta( id ) {
+ const raw = wpCli(
+ `eval '$m = get_post_meta( ${ id } ); $o = []; foreach ( $m as $k => $v ) { $o[ $k ] = $v[0]; } echo wp_json_encode( (object) $o ) . "\\n";'`
+ );
+ return JSON.parse( raw );
+}
+
+/**
+ * Returns the recorded outbound beehiiv requests.
+ *
+ * @return {Array} Request log entries.
+ */
+function getLog() {
+ return JSON.parse(
+ wpCli( `eval 'beehiiv_e2e_print_http_log();'` ) || '[]'
+ );
+}
+
+/**
+ * Clears the recorded request log.
+ */
+function clearLog() {
+ wpCli( `eval 'beehiiv_e2e_clear_http_log();'` );
+}
+
+const creates = ( log ) =>
+ log.filter(
+ ( r ) =>
+ r.method === 'POST' && /\/publications\/[^/]+\/posts$/.test( r.url )
+ );
+const updates = ( log ) => log.filter( ( r ) => r.method === 'PATCH' );
+
+/**
+ * Logs in as admin and returns a REST caller bound to that session (cookie + nonce).
+ *
+ * @param {import('@playwright/test').Page} page Playwright page.
+ * @return {Promise} `( method, path, data ) => { status, body }`.
+ */
+async function restAs( page ) {
+ await loginAsAdmin( page );
+ const nonceRes = await page.request.get(
+ '/wp-admin/admin-ajax.php?action=rest-nonce'
+ );
+ const nonce = ( await nonceRes.text() ).trim();
+ return async ( method, path, data ) => {
+ const res = await page.request.fetch( `/wp-json${ path }`, {
+ method,
+ headers: { 'X-WP-Nonce': nonce },
+ data,
+ } );
+ let body = null;
+ try {
+ body = await res.json();
+ } catch {
+ // Non-JSON response; leave body null.
+ }
+ return { status: res.status(), body };
+ };
+}
+
+/**
+ * Publishes a new post with "Send to Newsletter" on and returns the `blocks`
+ * beehiiv received in the create request.
+ *
+ * @param {import('@playwright/test').Page} page Playwright page.
+ * @param {string} content Post content (block markup).
+ * @param {Object} [options] Extra fixture options.
+ * @return {Promise<{id: string, blocks: Array, rest: Function}>} Result.
+ */
+async function sendNew( page, content, options = {} ) {
+ seed();
+ const id = createPost( {
+ title: 'QA E2E block conversion',
+ content,
+ meta: { [ META.send ]: '1', ...( options.meta || {} ) },
+ thumb: options.thumb || 0,
+ } );
+ const rest = await restAs( page );
+ const res = await rest( 'POST', `/wp/v2/posts/${ id }`, {
+ status: 'publish',
+ } );
+ expect( res.status ).toBe( 200 );
+ const created = creates( getLog() );
+ expect( created ).toHaveLength( 1 );
+ return { id, blocks: created[ 0 ].body.blocks, rest };
+}
+
+const p = ( text ) =>
+ `${ text }
`;
+const h = ( text ) =>
+ `${ text } `;
+const group = ( inner ) =>
+ `${ inner }
`;
+const types = ( blocks ) => blocks.map( ( b ) => b.type );
+const text = ( blocks ) => JSON.stringify( blocks );
+
+test.describe.configure( { timeout: 150 * 1000 } );
+
+test.beforeAll( async () => {
+ ensurePluginActive();
+ ensurePrettyPermalinks();
+} );
+
+test.afterAll( async () => {
+ wpCliSafe(
+ `eval 'foreach ( array( ${ createdPosts.join(
+ ', '
+ ) } ) as $id ) { delete_post_meta( $id, "${
+ META.postId
+ }" ); wp_delete_post( $id, true ); }
+ foreach ( array( ${ createdAttachments.join(
+ ', '
+ ) } ) as $id ) { wp_delete_attachment( $id, true ); }
+ delete_option( "beehiiv_settings" ); beehiiv_e2e_reset_all();'`
+ );
+} );
+
+// ---------------------------------------------------------------------------
+test.describe( 'US-001: convert post blocks', () => {
+ test( 'AC-001: heading, paragraph and image blocks convert without content loss', async ( {
+ page,
+ } ) => {
+ const image =
+ ' ';
+ const { blocks } = await sendNew(
+ page,
+ h( 'QA heading text' ) + p( 'QA paragraph text.' ) + image
+ );
+ expect( types( blocks ) ).toEqual( [
+ 'heading',
+ 'paragraph',
+ 'image',
+ ] );
+ expect( blocks[ 0 ].text ).toBe( 'QA heading text' );
+ expect( blocks[ 1 ].plaintext ).toBe( 'QA paragraph text.' );
+ expect( blocks[ 2 ].imageUrl ).toBe(
+ 'https://example.com/qa-photo.jpg'
+ );
+ expect( blocks[ 2 ].alt_text ).toBe( 'QA alt' );
+ } );
+
+ test( 'AC-002: separator blocks convert to a content break', async ( {
+ page,
+ } ) => {
+ const { blocks } = await sendNew(
+ page,
+ p( 'Before' ) +
+ ' ' +
+ p( 'After' )
+ );
+ expect( types( blocks ) ).toEqual( [
+ 'paragraph',
+ 'content_break',
+ 'paragraph',
+ ] );
+ } );
+
+ test( 'AC-003: embed blocks convert to a link to the embedded content', async ( {
+ page,
+ } ) => {
+ const { blocks } = await sendNew(
+ page,
+ 'https://www.youtube.com/watch?v=qa123
' +
+ p( 'Anchor paragraph' )
+ );
+ const embed = blocks.find( ( b ) => b.type === 'embed_link' );
+ expect( embed ).toBeTruthy();
+ expect( embed.url ).toBe( 'https://www.youtube.com/watch?v=qa123' );
+ } );
+} );
+
+// ---------------------------------------------------------------------------
+test.describe( 'US-002: skip unsupported blocks', () => {
+ test( 'AC-004: blocks without beehiiv equivalents are silently omitted', async ( {
+ page,
+ } ) => {
+ const { id, blocks } = await sendNew(
+ page,
+ p( 'Kept paragraph' ) +
+ 'QA_UNSUPPORTED_CODE ' +
+ 'QA_UNSUPPORTED_VERSE ' +
+ '
'
+ );
+ expect( types( blocks ) ).toEqual( [ 'paragraph' ] );
+ expect( text( blocks ) ).not.toContain( 'QA_UNSUPPORTED' );
+ // The send still succeeds without an error recorded.
+ expect( getMeta( id )[ META.postId ] ).toBe( MOCK_CREATED_ID );
+ expect( getMeta( id )._beehiiv_newsletter_error ).toBeUndefined();
+ } );
+
+ test( 'AC-005: empty and whitespace-only blocks are omitted', async ( {
+ page,
+ } ) => {
+ const { blocks } = await sendNew(
+ page,
+ p( 'Real content' ) +
+ '
' +
+ '
' +
+ ' '
+ );
+ expect( types( blocks ) ).toEqual( [ 'paragraph' ] );
+ expect( blocks[ 0 ].plaintext ).toBe( 'Real content' );
+ } );
+
+ // Known gap (QA finding): a block holding only a non-breaking space
+ // ( , which the editor produces for "blank" lines) is converted to a
+ // paragraph/heading with a nbsp instead of being omitted. test.fail()
+ // records the gap without breaking CI and starts failing once it is fixed.
+ test( 'AC-005: blocks holding only a non-breaking space are omitted', async ( {
+ page,
+ } ) => {
+ test.fail(
+ true,
+ 'Known gap: -only paragraph and heading blocks are still sent.'
+ );
+ const { blocks } = await sendNew(
+ page,
+ p( 'Real content' ) +
+ '
' +
+ ' '
+ );
+ expect( types( blocks ) ).toEqual( [ 'paragraph' ] );
+ } );
+} );
+
+// ---------------------------------------------------------------------------
+test.describe( 'US-003: snippet mode with Read More button', () => {
+ test( 'AC-006: snippet mode stops at core/more and adds a Read More button to the permalink in a new tab', async ( {
+ page,
+ } ) => {
+ const content =
+ p( 'Teaser paragraph' ) +
+ '' +
+ p( 'HIDDEN_AFTER_MORE' );
+ const { id, blocks } = await sendNew( page, content, {
+ meta: { [ META.snippet ]: '1' },
+ } );
+ expect( types( blocks ) ).toEqual( [ 'paragraph', 'button' ] );
+ expect( text( blocks ) ).not.toContain( 'HIDDEN_AFTER_MORE' );
+ const button = blocks[ 1 ];
+ expect( button.text ).toBe( 'Read More' );
+ expect( button.target ).toBe( '_blank' );
+ const permalink = wpCli( `eval 'echo get_permalink( ${ id } );'` );
+ expect( button.href ).toBe( permalink );
+ expect( button.href ).toContain( 'qa-e2e-block-conversion' );
+
+ // Edge case: snippet mode with no core/more converts every block.
+ const noMore = await sendNew( page, p( 'One' ) + p( 'Two' ), {
+ meta: { [ META.snippet ]: '1' },
+ } );
+ expect( types( noMore.blocks ) ).toEqual( [
+ 'paragraph',
+ 'paragraph',
+ ] );
+ } );
+} );
+
+// ---------------------------------------------------------------------------
+test.describe( 'US-004: featured image', () => {
+ test( 'AC-007: the featured image leads the newsletter and is configured for email only', async ( {
+ page,
+ } ) => {
+ const thumb = createAttachment();
+ const { blocks } = await sendNew( page, p( 'Body text' ), { thumb } );
+ expect( types( blocks ) ).toEqual( [ 'image', 'paragraph' ] );
+ expect( blocks[ 0 ].imageUrl ).toContain( 'qa-e2e-featured' );
+ expect( blocks[ 0 ].visibility_settings ).toEqual( {
+ show_on_email: true,
+ show_on_web: false,
+ } );
+
+ // Edge case: no featured image is omitted without error.
+ const none = await sendNew( page, p( 'No image' ) );
+ expect( types( none.blocks ) ).toEqual( [ 'paragraph' ] );
+ } );
+} );
+
+// ---------------------------------------------------------------------------
+test.describe( 'US-005: advertisement validation', () => {
+ test( 'AC-008: advertisement blocks are included only for a valid, active ad', async ( {
+ page,
+ } ) => {
+ const ad = ( attrs ) =>
+ ``;
+
+ // Active ad: included with its opportunity id.
+ const active = await sendNew(
+ page,
+ p( 'Intro' ) + ad( { adId: ACTIVE_AD } )
+ );
+ const ads = active.blocks.filter( ( b ) => b.type === 'advertisement' );
+ expect( ads ).toHaveLength( 1 );
+ expect( ads[ 0 ].opportunity_id ).toBe( ACTIVE_AD );
+
+ // Expired/unknown ad: never reaches beehiiv. (The sender rejects the
+ // whole send with an error instead of silently dropping the block, so
+ // no create request is made at all.)
+ seed();
+ const id = createPost( {
+ title: 'QA E2E block conversion expired ad',
+ content: p( 'Intro' ) + ad( { adId: EXPIRED_AD } ),
+ meta: { [ META.send ]: '1' },
+ } );
+ const rest = await restAs( page );
+ await rest( 'POST', `/wp/v2/posts/${ id }`, { status: 'publish' } );
+ const log = getLog();
+ expect( JSON.stringify( log ) ).not.toContain(
+ `"opportunity_id":"${ EXPIRED_AD }"`
+ );
+ expect( creates( log ) ).toHaveLength( 0 );
+ expect( getMeta( id )._beehiiv_newsletter_error ).toContain(
+ 'no longer available'
+ );
+ } );
+} );
+
+// ---------------------------------------------------------------------------
+test.describe( 'US-006: Group blocks become sections', () => {
+ test( 'AC-009: a Group becomes one section with its inner blocks in original order', async ( {
+ page,
+ } ) => {
+ const { blocks } = await sendNew(
+ page,
+ p( 'Outside before' ) +
+ group(
+ h( 'Inside heading' ) +
+ p( 'Inside one' ) +
+ p( 'Inside two' )
+ ) +
+ p( 'Outside after' )
+ );
+ expect( types( blocks ) ).toEqual( [
+ 'paragraph',
+ 'section',
+ 'paragraph',
+ ] );
+ const section = blocks[ 1 ];
+ expect( types( section.blocks ) ).toEqual( [
+ 'heading',
+ 'paragraph',
+ 'paragraph',
+ ] );
+ expect( section.blocks[ 0 ].text ).toBe( 'Inside heading' );
+ expect( section.blocks[ 1 ].plaintext ).toBe( 'Inside one' );
+ expect( section.blocks[ 2 ].plaintext ).toBe( 'Inside two' );
+ } );
+
+ test( 'AC-010: a Group nested in a Group becomes a nested section', async ( {
+ page,
+ } ) => {
+ const { blocks } = await sendNew(
+ page,
+ group( p( 'Outer text' ) + group( p( 'Nested text' ) ) )
+ );
+ expect( types( blocks ) ).toEqual( [ 'section' ] );
+ expect( types( blocks[ 0 ].blocks ) ).toEqual( [
+ 'paragraph',
+ 'section',
+ ] );
+ const nested = blocks[ 0 ].blocks[ 1 ];
+ expect( types( nested.blocks ) ).toEqual( [ 'paragraph' ] );
+ expect( nested.blocks[ 0 ].plaintext ).toBe( 'Nested text' );
+ } );
+
+ test( 'AC-011: sections use beehiiv defaults; Group background and padding are not carried over', async ( {
+ page,
+ } ) => {
+ const styled =
+ '' +
+ p( 'Styled group text' ) +
+ '
';
+ const { blocks } = await sendNew( page, styled );
+ expect( blocks ).toHaveLength( 1 );
+ expect( Object.keys( blocks[ 0 ] ).sort() ).toEqual( [
+ 'blocks',
+ 'type',
+ ] );
+ expect( blocks[ 0 ].type ).toBe( 'section' );
+ expect( text( blocks ) ).not.toContain( '#ff0000' );
+ expect( text( blocks ) ).not.toContain( '40px' );
+ } );
+
+ test( 'AC-012: an empty or unconvertible Group produces no section; a mixed Group keeps only convertible blocks', async ( {
+ page,
+ } ) => {
+ const empty = await sendNew(
+ page,
+ p( 'Kept outside' ) +
+ group( '' ) +
+ group(
+ 'QA_CODE_ONLY '
+ )
+ );
+ expect( types( empty.blocks ) ).toEqual( [ 'paragraph' ] );
+
+ const mixed = await sendNew(
+ page,
+ group(
+ p( 'Mixed one' ) +
+ 'QA_CODE_DROPPED ' +
+ p( 'Mixed two' )
+ )
+ );
+ expect( types( mixed.blocks ) ).toEqual( [ 'section' ] );
+ expect( types( mixed.blocks[ 0 ].blocks ) ).toEqual( [
+ 'paragraph',
+ 'paragraph',
+ ] );
+ expect( text( mixed.blocks ) ).not.toContain( 'QA_CODE_DROPPED' );
+ } );
+
+ // The "shows as one grouped section in the beehiiv editor" half of this AC
+ // depends on beehiiv's own editor and cannot be driven here; the payload
+ // half (one section, three children) is what the plugin controls.
+ test( 'AC-013: a Group with a heading and two paragraphs produces one section with those three children', async ( {
+ page,
+ } ) => {
+ const { blocks } = await sendNew(
+ page,
+ group(
+ h( 'Group heading' ) + p( 'Group para A' ) + p( 'Group para B' )
+ )
+ );
+ const sections = blocks.filter( ( b ) => b.type === 'section' );
+ expect( blocks ).toHaveLength( 1 );
+ expect( sections ).toHaveLength( 1 );
+ expect( types( sections[ 0 ].blocks ) ).toEqual( [
+ 'heading',
+ 'paragraph',
+ 'paragraph',
+ ] );
+ } );
+
+ test( 'AC-014: Group conversion applies to posts first synced after the change, including re-syncs; older posts keep omitting Group content', async ( {
+ page,
+ } ) => {
+ const content =
+ p( 'Top level text' ) + group( p( 'GROUPED_CONTENT_TEXT' ) );
+
+ // Post first synced now: sections on create, marker stored, kept on re-sync.
+ const fresh = await sendNew( page, content );
+ expect( types( fresh.blocks ) ).toEqual( [ 'paragraph', 'section' ] );
+ expect( getMeta( fresh.id )[ META.groupSections ] ).toBeTruthy();
+ clearLog();
+ await fresh.rest( 'POST', `/wp/v2/posts/${ fresh.id }`, {
+ content: content + p( 'Edited later' ),
+ } );
+ const resynced = updates( getLog() );
+ expect( resynced ).toHaveLength( 1 );
+ expect( types( resynced[ 0 ].body.blocks ) ).toEqual( [
+ 'paragraph',
+ 'section',
+ 'paragraph',
+ ] );
+ expect( text( resynced[ 0 ].body.blocks ) ).toContain(
+ 'GROUPED_CONTENT_TEXT'
+ );
+
+ // Post first synced before the change (linked, no marker): Group stays omitted.
+ seed();
+ const legacy = createPost( {
+ title: 'QA E2E block conversion legacy',
+ content,
+ status: 'publish',
+ meta: { [ META.postId ]: LEGACY_ID },
+ } );
+ const rest = await restAs( page );
+ await rest( 'POST', `/wp/v2/posts/${ legacy }`, {
+ content: content + p( 'Edited legacy' ),
+ } );
+ const legacyUpdates = updates( getLog() );
+ expect( legacyUpdates ).toHaveLength( 1 );
+ expect( types( legacyUpdates[ 0 ].body.blocks ) ).toEqual( [
+ 'paragraph',
+ 'paragraph',
+ ] );
+ expect( text( legacyUpdates[ 0 ].body.blocks ) ).not.toContain(
+ 'GROUPED_CONTENT_TEXT'
+ );
+ expect( getMeta( legacy )[ META.groupSections ] ).toBeUndefined();
+ } );
+} );
From 8ccbab905688a5a0d61f78d72cc6a3885d22a60d Mon Sep 17 00:00:00 2001
From: Jeffrey Carandang
Date: Wed, 4 Nov 2026 18:50:03 +0800
Subject: [PATCH 3/8] fix(newsletter-publishing): omit Groups nested in a Group
from the beehiiv payload
beehiiv rejects a section inside a section, so the post failed to sync.
A Group inside a Group is now omitted with its inner blocks, and the editor
counts it in the omitted-block notice and marks it with the hidden icon.
---
includes/Newsletter/BlockConverter.php | 14 +++++++-----
.../post-settings/hooks/use-omitted-blocks.js | 21 +++++++++++++++++-
.../specs/block-conversion-framework.spec.js | 14 +++++++-----
tests/phpunit/BlockConverterGroupTest.php | 22 ++++++++++++++-----
4 files changed, 53 insertions(+), 18 deletions(-)
diff --git a/includes/Newsletter/BlockConverter.php b/includes/Newsletter/BlockConverter.php
index 86d68d2..6be6a24 100644
--- a/includes/Newsletter/BlockConverter.php
+++ b/includes/Newsletter/BlockConverter.php
@@ -39,7 +39,8 @@
* - `beehiiv/advertisement` — beehiiv `advertisement` block via `convert_advertisement_block()`
*
* - `core/group` — `convert_group_block()` (beehiiv `section` block holding the converted inner
- * blocks; only for posts whose first sync used sections, see `PostSettingsBuilder`)
+ * blocks; only for posts whose first sync used sections, see `PostSettingsBuilder`; a Group
+ * inside a Group is omitted because beehiiv sections cannot hold sections)
*
* Other layout blocks (`core/columns`, etc.) are skipped at the top level, and so is
* `core/group` for posts first synced before section support.
@@ -160,10 +161,11 @@ private static function convert_blocks(
* Convert a core/group block to a beehiiv section block.
*
* The Group's inner blocks are converted with the same rules as top-level blocks and
- * placed in the section in their original order. A nested Group becomes a nested
- * section. Section settings are left at beehiiv defaults. Returns an empty array when
- * no inner block converts, so no empty section is sent. Snippet-only blocks are
- * omitted inside a section.
+ * placed in the section in their original order. A Group nested inside a Group is
+ * omitted with its inner blocks, because a beehiiv section cannot hold another section
+ * and sending one makes beehiiv reject the whole post. Section settings are left at
+ * beehiiv defaults. Returns an empty array when no inner block converts, so no empty
+ * section is sent. Snippet-only blocks are omitted inside a section.
*
* @param array $wp_block Parsed core/group block.
* @param WP_Post $post_object Post object.
@@ -175,7 +177,7 @@ private static function convert_group_block( array $wp_block, WP_Post $post_obje
$wp_block['innerBlocks'] ?? [],
$post_object,
false,
- true
+ false
);
if ( empty( $inner_blocks ) ) {
diff --git a/src/js/editor/post-settings/hooks/use-omitted-blocks.js b/src/js/editor/post-settings/hooks/use-omitted-blocks.js
index 5edd154..4251333 100644
--- a/src/js/editor/post-settings/hooks/use-omitted-blocks.js
+++ b/src/js/editor/post-settings/hooks/use-omitted-blocks.js
@@ -33,6 +33,22 @@ function resolveOmittedBlockTarget( ancestors, blockClientId ) {
return blockClientId;
}
+/**
+ * Whether a Group block sits inside another Group block.
+ *
+ * beehiiv sections cannot hold sections, so the inner Group is omitted.
+ *
+ * @param {WPBlock} block Block being visited.
+ * @param {BlockAncestor[]} ancestors Root-to-parent chain for the block.
+ * @return {boolean} True when the block is a Group nested in a Group.
+ */
+function isGroupInsideGroup( block, ancestors ) {
+ return (
+ 'core/group' === block.name &&
+ ancestors.some( ( ancestor ) => 'core/group' === ancestor.name )
+ );
+}
+
/**
* Collect client IDs for blocks omitted from the newsletter.
*
@@ -49,7 +65,10 @@ export function collectOmittedBlockClientIds( blocks ) {
*/
function visit( blockList, ancestors = [] ) {
for ( const block of blockList ) {
- if ( isOmittedFromNewsletter( block ) ) {
+ if (
+ isOmittedFromNewsletter( block ) ||
+ isGroupInsideGroup( block, ancestors )
+ ) {
targets.add(
resolveOmittedBlockTarget( ancestors, block.clientId )
);
diff --git a/tests/e2e/specs/block-conversion-framework.spec.js b/tests/e2e/specs/block-conversion-framework.spec.js
index 948136e..f0cf344 100644
--- a/tests/e2e/specs/block-conversion-framework.spec.js
+++ b/tests/e2e/specs/block-conversion-framework.spec.js
@@ -506,21 +506,23 @@ test.describe( 'US-006: Group blocks become sections', () => {
expect( section.blocks[ 2 ].plaintext ).toBe( 'Inside two' );
} );
- test( 'AC-010: a Group nested in a Group becomes a nested section', async ( {
+ test( 'AC-010: a Group nested in a Group is omitted, so no section holds a section', async ( {
page,
} ) => {
const { blocks } = await sendNew(
page,
- group( p( 'Outer text' ) + group( p( 'Nested text' ) ) )
+ group(
+ p( 'Outer text' ) +
+ group( p( 'Nested text' ) ) +
+ p( 'Last text' )
+ )
);
expect( types( blocks ) ).toEqual( [ 'section' ] );
expect( types( blocks[ 0 ].blocks ) ).toEqual( [
'paragraph',
- 'section',
+ 'paragraph',
] );
- const nested = blocks[ 0 ].blocks[ 1 ];
- expect( types( nested.blocks ) ).toEqual( [ 'paragraph' ] );
- expect( nested.blocks[ 0 ].plaintext ).toBe( 'Nested text' );
+ expect( JSON.stringify( blocks ) ).not.toContain( 'Nested text' );
} );
test( 'AC-011: sections use beehiiv defaults; Group background and padding are not carried over', async ( {
diff --git a/tests/phpunit/BlockConverterGroupTest.php b/tests/phpunit/BlockConverterGroupTest.php
index 78b3a78..e3b8b96 100644
--- a/tests/phpunit/BlockConverterGroupTest.php
+++ b/tests/phpunit/BlockConverterGroupTest.php
@@ -111,21 +111,33 @@ public function test_section_sits_between_sibling_blocks(): void {
}
/**
- * AC-010: a Group inside a Group becomes a nested section.
+ * AC-010: a Group inside a Group is omitted, because a beehiiv section cannot hold a section.
*/
- public function test_nested_group_becomes_nested_section(): void {
+ public function test_nested_group_is_omitted_with_its_content(): void {
$inner = $this->group( $this->paragraph( 'Deep' ) );
- $post = $this->create_post( $this->group( $this->paragraph( 'Top' ) . $inner ) );
+ $post = $this->create_post( $this->group( $this->paragraph( 'Top' ) . $inner . $this->paragraph( 'Last' ) ) );
$blocks = BlockConverter::convert_supported_blocks( $post, true );
$this->assertCount( 1, $blocks );
$this->assertSame( 'section', $blocks[0]['type'] );
$this->assertSame(
- array( 'paragraph', 'section' ),
+ array( 'paragraph', 'paragraph' ),
array_column( $blocks[0]['blocks'], 'type' )
);
- $this->assertCount( 1, $blocks[0]['blocks'][1]['blocks'] );
+ $this->assertStringNotContainsString( 'Deep', wp_json_encode( $blocks ) );
+ }
+
+ /**
+ * AC-010: no section is nested at any depth, and a Group holding only a nested Group sends nothing.
+ */
+ public function test_no_section_is_nested_at_any_depth(): void {
+ $deep = $this->group( $this->group( $this->group( $this->paragraph( 'Deep' ) ) ) );
+ $post = $this->create_post( $this->paragraph( 'Kept' ) . $deep );
+
+ $blocks = BlockConverter::convert_supported_blocks( $post, true );
+
+ $this->assertSame( array( 'paragraph' ), array_column( $blocks, 'type' ) );
}
/**
From 54d4cb89d89e3cd9bc9609c006777be1bbe16070 Mon Sep 17 00:00:00 2001
From: Jeffrey Carandang
Date: Wed, 4 Nov 2026 18:57:58 +0800
Subject: [PATCH 4/8] test(e2e): cover the editor notice for a Group nested in
a Group
---
.../specs/block-conversion-framework.spec.js | 44 +++++++++++++++++++
1 file changed, 44 insertions(+)
diff --git a/tests/e2e/specs/block-conversion-framework.spec.js b/tests/e2e/specs/block-conversion-framework.spec.js
index f0cf344..e0dc8ad 100644
--- a/tests/e2e/specs/block-conversion-framework.spec.js
+++ b/tests/e2e/specs/block-conversion-framework.spec.js
@@ -1,5 +1,6 @@
const { test, expect } = require( '@playwright/test' );
const { loginAsAdmin } = require( '../utils/auth' );
+const { openPostEditor, openBeehiivSidebar } = require( '../utils/editor' );
const {
wpCli,
wpCliSafe,
@@ -525,6 +526,49 @@ test.describe( 'US-006: Group blocks become sections', () => {
expect( JSON.stringify( blocks ) ).not.toContain( 'Nested text' );
} );
+ test( 'AC-010: the editor notice counts a nested Group as omitted and marks it', async ( {
+ page,
+ } ) => {
+ seed();
+ const id = createPost( {
+ title: 'QA E2E nested group notice',
+ content: group( p( 'Outer text' ) + group( p( 'Nested text' ) ) ),
+ meta: { [ META.send ]: '1' },
+ } );
+ await loginAsAdmin( page );
+ await openPostEditor( page, id );
+ await openBeehiivSidebar( page );
+
+ await expect(
+ page.locator( '.beehiiv-post-settings-notice__omitted' )
+ ).toContainText( '1 block will be omitted from this newsletter.' );
+ const canvas = page.frameLocator( 'iframe[name="editor-canvas"]' );
+ await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount(
+ 1
+ );
+ } );
+
+ test( 'AC-010: the editor shows no omitted notice for a single, non-nested Group', async ( {
+ page,
+ } ) => {
+ seed();
+ const id = createPost( {
+ title: 'QA E2E single group notice',
+ content: group( p( 'Only text' ) ),
+ meta: { [ META.send ]: '1' },
+ } );
+ await loginAsAdmin( page );
+ await openPostEditor( page, id );
+ await openBeehiivSidebar( page );
+
+ await expect(
+ page.locator( '.beehiiv-post-settings-notice__text' )
+ ).toBeVisible();
+ await expect(
+ page.locator( '.beehiiv-post-settings-notice__omitted' )
+ ).toHaveCount( 0 );
+ } );
+
test( 'AC-011: sections use beehiiv defaults; Group background and padding are not carried over', async ( {
page,
} ) => {
From 2f27426f4cbbe710ff086d714381178f06cae268 Mon Sep 17 00:00:00 2001
From: Jeffrey Carandang
Date: Wed, 4 Nov 2026 19:00:56 +0800
Subject: [PATCH 5/8] fix(editor): show the omitted-block notice on scheduled
posts linked to beehiiv
A linked post turns Send to newsletter off, which hid the notice and the
hidden-block icon while its newsletter was still scheduled and syncing edits.
---
.../components/omitted-blocks-notice.js | 23 ++++++++
.../filters/with-omitted-block-indicator.js | 14 +++--
src/js/editor/post-settings/index.js | 6 ++-
.../specs/block-conversion-framework.spec.js | 53 +++++++++++++++++++
4 files changed, 90 insertions(+), 6 deletions(-)
diff --git a/src/js/editor/post-settings/components/omitted-blocks-notice.js b/src/js/editor/post-settings/components/omitted-blocks-notice.js
index 0eaf0bd..bbf2948 100644
--- a/src/js/editor/post-settings/components/omitted-blocks-notice.js
+++ b/src/js/editor/post-settings/components/omitted-blocks-notice.js
@@ -3,6 +3,7 @@
*/
import { _n, sprintf } from '@wordpress/i18n';
+import PostSettingsNotice from './post-settings-notice';
import { useOmittedBlockCount } from '../hooks/use-omitted-blocks';
/**
@@ -35,3 +36,25 @@ export function OmittedBlocksNoticeMessage() {
);
}
+
+/**
+ * Standalone warning for a linked post whose newsletter is still scheduled.
+ *
+ * A linked post has no send warning to hold the message, so it gets its own
+ * notice, shown only when a block will be omitted.
+ *
+ * @return {import('react').JSX.Element|null} Warning notice or null.
+ */
+export function OmittedBlocksNotice() {
+ const omittedBlockCount = useOmittedBlockCount();
+
+ if ( omittedBlockCount < 1 ) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/src/js/editor/post-settings/filters/with-omitted-block-indicator.js b/src/js/editor/post-settings/filters/with-omitted-block-indicator.js
index e81ef86..61f3a85 100644
--- a/src/js/editor/post-settings/filters/with-omitted-block-indicator.js
+++ b/src/js/editor/post-settings/filters/with-omitted-block-indicator.js
@@ -13,11 +13,15 @@ function useShowOmittedBlockIndicators( clientId ) {
const beehiivMeta = useBeehiivPostMeta();
const isOmittedBlock = useIsOmittedBlock( clientId );
- return (
- !! beehiivMeta?.sendToNewsletter &&
- ! beehiivMeta.newsletterAlreadySent &&
- isOmittedBlock
- );
+ // A linked newsletter that is still scheduled keeps syncing edits, so its
+ // omitted blocks are marked too. A sent newsletter no longer changes.
+ const isQueued =
+ !! beehiivMeta?.sendToNewsletter && ! beehiivMeta.newsletterAlreadySent;
+ const isScheduled =
+ !! beehiivMeta?.newsletterAlreadySent &&
+ ! beehiivMeta.newsletterPublished;
+
+ return ( isQueued || isScheduled ) && isOmittedBlock;
}
// Higher order component to add a visual indicator to blocks that are omitted from the newsletter.
diff --git a/src/js/editor/post-settings/index.js b/src/js/editor/post-settings/index.js
index 636895f..44c1337 100644
--- a/src/js/editor/post-settings/index.js
+++ b/src/js/editor/post-settings/index.js
@@ -26,7 +26,10 @@ import PostSettingsNotice from './components/post-settings-notice';
import SendNewsletterToggle from './components/send-newsletter-toggle';
import TestEmailSend from './components/test-email-send';
import IncompleteAdvertisementNotice from './components/incomplete-advertisement-notice';
-import { OmittedBlocksNoticeMessage } from './components/omitted-blocks-notice';
+import {
+ OmittedBlocksNotice,
+ OmittedBlocksNoticeMessage,
+} from './components/omitted-blocks-notice';
import { useBeehiivEditorConfig } from './hooks/use-beehiiv-editor-config';
import { useBeehiivPostMeta } from './hooks/use-beehiiv-post-meta';
import { usePostPublication } from './hooks/use-post-publication';
@@ -214,6 +217,7 @@ function BeehiivPostSettingsPanel() {
{ newsletterAlreadySent && isNewsletterReady && (
<>
+ { ! newsletterPublished && }
{
);
} );
+ test( 'AC-010: a scheduled, linked post shows the omitted notice for a nested Group, and a sent one does not', async ( {
+ page,
+ } ) => {
+ seed();
+ const linked = ( scheduledAt ) => ( {
+ [ META.postId ]: 'post_qa_e2e_scheduled',
+ _beehiiv_publication_id: PUBLICATION_ID,
+ _beehiiv_post_template_id: TEMPLATE_ID,
+ _beehiiv_linked_publication_id: PUBLICATION_ID,
+ _beehiiv_linked_post_template_id: TEMPLATE_ID,
+ [ META.send ]: '',
+ _beehiiv_scheduled_at: scheduledAt,
+ } );
+ const content = group(
+ p( 'Outer text' ) + group( p( 'Nested text' ) )
+ );
+ const scheduledId = createPost( {
+ title: 'QA E2E scheduled nested group',
+ content,
+ status: 'publish',
+ meta: linked( '2031-01-01T10:00:00Z' ),
+ } );
+ const sentId = createPost( {
+ title: 'QA E2E sent nested group',
+ content,
+ status: 'publish',
+ meta: linked( '2020-01-01T10:00:00Z' ),
+ } );
+ await loginAsAdmin( page );
+
+ await openPostEditor( page, scheduledId );
+ await openBeehiivSidebar( page );
+ await expect(
+ page.locator( '.beehiiv-post-settings-notice__omitted' )
+ ).toContainText( '1 block will be omitted from this newsletter.' );
+ const canvas = page.frameLocator( 'iframe[name="editor-canvas"]' );
+ await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount(
+ 1
+ );
+
+ await openPostEditor( page, sentId );
+ await openBeehiivSidebar( page );
+ await expect(
+ page.locator( '.beehiiv-post-settings-content' )
+ ).toBeVisible();
+ await expect(
+ page.locator( '.beehiiv-post-settings-notice__omitted' )
+ ).toHaveCount( 0 );
+ await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount(
+ 0
+ );
+ } );
+
test( 'AC-010: the editor shows no omitted notice for a single, non-nested Group', async ( {
page,
} ) => {
From 9351fd66ec50640fd74b4fdb9a5e61241996042f Mon Sep 17 00:00:00 2001
From: Jeffrey Carandang
Date: Wed, 4 Nov 2026 19:02:56 +0800
Subject: [PATCH 6/8] style(editor): remove the top margin on the omitted-block
notice
---
src/js/editor/post-settings/css/_sidebar.scss | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/js/editor/post-settings/css/_sidebar.scss b/src/js/editor/post-settings/css/_sidebar.scss
index 7a46f98..ed10cb8 100644
--- a/src/js/editor/post-settings/css/_sidebar.scss
+++ b/src/js/editor/post-settings/css/_sidebar.scss
@@ -34,7 +34,6 @@
align-items: flex-start;
display: flex;
gap: 6px;
- margin-top: 10px !important;
}
.beehiiv-post-settings-notice__omitted-icon {
From 718b6dac485b24af6ca5ff6b5342c62cf7a68641 Mon Sep 17 00:00:00 2001
From: Jeffrey Carandang
Date: Wed, 4 Nov 2026 19:12:27 +0800
Subject: [PATCH 7/8] fix(editor): mark omitted blocks on the block element and
only while the beehiiv sidebar is open
The marker no longer wraps the block in an extra element. The hidden-eye icon is
drawn on the block itself and stays visible without hovering.
---
src/js/editor/post-settings/constants.js | 11 ++++
.../post-settings/css/_omitted-blocks.scss | 44 ++++---------
.../filters/with-omitted-block-indicator.js | 66 +++++++++++--------
.../hooks/use-is-sidebar-open.js | 18 +++++
src/js/editor/post-settings/index.js | 3 +-
.../specs/block-conversion-framework.spec.js | 63 ++++++++++++++++--
6 files changed, 137 insertions(+), 68 deletions(-)
create mode 100644 src/js/editor/post-settings/constants.js
create mode 100644 src/js/editor/post-settings/hooks/use-is-sidebar-open.js
diff --git a/src/js/editor/post-settings/constants.js b/src/js/editor/post-settings/constants.js
new file mode 100644
index 0000000..77ca223
--- /dev/null
+++ b/src/js/editor/post-settings/constants.js
@@ -0,0 +1,11 @@
+/**
+ * Names of the beehiiv editor plugin and its sidebar.
+ */
+
+export const PLUGIN_NAME = 'beehiiv-post-settings';
+export const SIDEBAR_NAME = 'beehiiv-post-settings';
+
+/**
+ * Complementary area id WordPress reports while the beehiiv sidebar is open.
+ */
+export const SIDEBAR_AREA = `${ PLUGIN_NAME }/${ SIDEBAR_NAME }`;
diff --git a/src/js/editor/post-settings/css/_omitted-blocks.scss b/src/js/editor/post-settings/css/_omitted-blocks.scss
index 9062b92..9ed1697 100644
--- a/src/js/editor/post-settings/css/_omitted-blocks.scss
+++ b/src/js/editor/post-settings/css/_omitted-blocks.scss
@@ -12,41 +12,21 @@
box-sizing: border-box;
min-height: 24px;
position: relative;
- width: 100%;
- // Invisible hit area so the icon above the block stays hoverable.
- &::before {
+ // Hidden-eye icon above the block's top right corner, drawn on the block itself.
+ &::after {
bottom: 100%;
- content: "";
- height: 24px;
+ color: beehiiv.$beehiiv-status-disconnected;
+ content: "\f530";
+ font-family: dashicons, sans-serif;
+ font-size: 16px;
+ height: 20px;
+ line-height: 18px;
+ pointer-events: none;
position: absolute;
- right: 0;
- width: 28px;
+ right: 2px;
+ width: 20px;
+ z-index: 30;
}
}
-
-.editor-styles-wrapper .beehiiv-omitted-block__icon {
- bottom: 100%;
- color: beehiiv.$beehiiv-status-disconnected;
- cursor: default;
- display: block;
- font-family: dashicons, sans-serif;
- font-size: 16px;
- height: 20px;
- line-height: 18px;
- opacity: 0;
- pointer-events: auto;
- position: absolute;
- right: 2px;
- transition: opacity 0.15s ease;
- width: 20px;
- z-index: 30;
-}
-
-.editor-styles-wrapper .beehiiv-omitted-block__icon:hover,
-.editor-styles-wrapper
- .beehiiv-omitted-block:has(:hover)
- .beehiiv-omitted-block__icon {
- opacity: 1;
-}
/* stylelint-enable selector-class-pattern */
diff --git a/src/js/editor/post-settings/filters/with-omitted-block-indicator.js b/src/js/editor/post-settings/filters/with-omitted-block-indicator.js
index 61f3a85..10b8275 100644
--- a/src/js/editor/post-settings/filters/with-omitted-block-indicator.js
+++ b/src/js/editor/post-settings/filters/with-omitted-block-indicator.js
@@ -6,12 +6,14 @@ import { createHigherOrderComponent } from '@wordpress/compose';
import { addFilter } from '@wordpress/hooks';
import { useBeehiivPostMeta } from '../hooks/use-beehiiv-post-meta';
+import { useIsBeehiivSidebarOpen } from '../hooks/use-is-sidebar-open';
import { useIsOmittedBlock } from '../hooks/use-omitted-blocks';
// Custom hook to determine if a block should show an omitted block indicator.
function useShowOmittedBlockIndicators( clientId ) {
const beehiivMeta = useBeehiivPostMeta();
const isOmittedBlock = useIsOmittedBlock( clientId );
+ const isSidebarOpen = useIsBeehiivSidebarOpen();
// A linked newsletter that is still scheduled keeps syncing edits, so its
// omitted blocks are marked too. A sent newsletter no longer changes.
@@ -21,40 +23,46 @@ function useShowOmittedBlockIndicators( clientId ) {
!! beehiivMeta?.newsletterAlreadySent &&
! beehiivMeta.newsletterPublished;
- return ( isQueued || isScheduled ) && isOmittedBlock;
+ // The marks only show while the beehiiv sidebar is open.
+ return isSidebarOpen && ( isQueued || isScheduled ) && isOmittedBlock;
}
-// Higher order component to add a visual indicator to blocks that are omitted from the newsletter.
-const withOmittedBlockIndicator = createHigherOrderComponent( ( BlockEdit ) => {
- return function BeehiivOmittedBlockEdit( props ) {
- const showIndicator = useShowOmittedBlockIndicators( props.clientId );
-
- if ( ! showIndicator ) {
- return ;
- }
-
- return (
-
-
-
- {
+ return function BeehiivOmittedBlockListBlock( props ) {
+ const showIndicator = useShowOmittedBlockIndicators(
+ props.clientId
+ );
+
+ if ( ! showIndicator ) {
+ return ;
+ }
+
+ return (
+
-
- );
- };
-}, 'withOmittedBlockIndicator' );
+ );
+ };
+ },
+ 'withOmittedBlockIndicator'
+);
addFilter(
- 'editor.BlockEdit',
+ 'editor.BlockListBlock',
'beehiiv/omitted-block-indicator',
withOmittedBlockIndicator
);
diff --git a/src/js/editor/post-settings/hooks/use-is-sidebar-open.js b/src/js/editor/post-settings/hooks/use-is-sidebar-open.js
new file mode 100644
index 0000000..54ccf13
--- /dev/null
+++ b/src/js/editor/post-settings/hooks/use-is-sidebar-open.js
@@ -0,0 +1,18 @@
+/**
+ * Whether the beehiiv sidebar panel is the open editor sidebar.
+ */
+import { useSelect } from '@wordpress/data';
+
+import { SIDEBAR_AREA } from '../constants';
+
+/**
+ * @return {boolean} True while the beehiiv sidebar is open.
+ */
+export function useIsBeehiivSidebarOpen() {
+ return useSelect(
+ ( select ) =>
+ SIDEBAR_AREA ===
+ select( 'core/interface' )?.getActiveComplementaryArea( 'core' ),
+ []
+ );
+}
diff --git a/src/js/editor/post-settings/index.js b/src/js/editor/post-settings/index.js
index 44c1337..7d1659d 100644
--- a/src/js/editor/post-settings/index.js
+++ b/src/js/editor/post-settings/index.js
@@ -36,11 +36,10 @@ import { usePostPublication } from './hooks/use-post-publication';
import './filters/with-omitted-block-indicator';
import './editor.scss';
+import { PLUGIN_NAME, SIDEBAR_NAME } from './constants';
const sidebarIcon = ;
-const PLUGIN_NAME = 'beehiiv-post-settings';
-const SIDEBAR_NAME = 'beehiiv-post-settings';
const PRE_PUBLISH_PANEL_NAME = 'beehiiv-send-newsletter';
function usePostPublishDate() {
diff --git a/tests/e2e/specs/block-conversion-framework.spec.js b/tests/e2e/specs/block-conversion-framework.spec.js
index fb82429..e849e57 100644
--- a/tests/e2e/specs/block-conversion-framework.spec.js
+++ b/tests/e2e/specs/block-conversion-framework.spec.js
@@ -1,6 +1,6 @@
const { test, expect } = require( '@playwright/test' );
const { loginAsAdmin } = require( '../utils/auth' );
-const { openPostEditor, openBeehiivSidebar } = require( '../utils/editor' );
+const { openPostEditor } = require( '../utils/editor' );
const {
wpCli,
wpCliSafe,
@@ -247,6 +247,31 @@ async function sendNew( page, content, options = {} ) {
return { id, blocks: created[ 0 ].body.blocks, rest };
}
+/**
+ * Opens or closes the beehiiv sidebar, whatever state the editor remembered.
+ *
+ * @param {import('@playwright/test').Page} page Editor page.
+ * @param {boolean} open Whether the sidebar should be open.
+ */
+async function setBeehiivSidebar( page, open ) {
+ const content = page.locator( '.beehiiv-post-settings-content' );
+ const remembered = await content
+ .waitFor( { state: 'visible', timeout: 3000 } )
+ .then( () => true )
+ .catch( () => false );
+ if ( remembered !== open ) {
+ await page
+ .getByRole( 'button', { name: 'beehiiv', exact: true } )
+ .first()
+ .click();
+ }
+ if ( open ) {
+ await expect( content ).toBeVisible();
+ } else {
+ await expect( content ).toBeHidden();
+ }
+}
+
const p = ( text ) =>
`${ text }
`;
const h = ( text ) =>
@@ -537,7 +562,7 @@ test.describe( 'US-006: Group blocks become sections', () => {
} );
await loginAsAdmin( page );
await openPostEditor( page, id );
- await openBeehiivSidebar( page );
+ await setBeehiivSidebar( page, true );
await expect(
page.locator( '.beehiiv-post-settings-notice__omitted' )
@@ -546,6 +571,34 @@ test.describe( 'US-006: Group blocks become sections', () => {
await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount(
1
);
+ // The class sits on the Group block itself; no wrapper element is added.
+ await expect(
+ canvas.locator(
+ '.wp-block-group .wp-block-group.beehiiv-omitted-block'
+ )
+ ).toHaveCount( 1 );
+ await expect(
+ canvas.locator( '.beehiiv-omitted-block:not(.wp-block)' )
+ ).toHaveCount( 0 );
+ // The hidden-eye icon is always drawn, without hovering the block.
+ const icon = await canvas
+ .locator( '.beehiiv-omitted-block' )
+ .evaluate( ( el ) => {
+ const style = window.getComputedStyle( el, '::after' );
+ return { content: style.content, opacity: style.opacity };
+ } );
+ expect( icon.content ).not.toBe( 'none' );
+ expect( icon.opacity ).toBe( '1' );
+
+ // Closing the beehiiv sidebar removes the marks from the canvas.
+ await setBeehiivSidebar( page, false );
+ await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount(
+ 0
+ );
+ await setBeehiivSidebar( page, true );
+ await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount(
+ 1
+ );
} );
test( 'AC-010: a scheduled, linked post shows the omitted notice for a nested Group, and a sent one does not', async ( {
@@ -579,7 +632,7 @@ test.describe( 'US-006: Group blocks become sections', () => {
await loginAsAdmin( page );
await openPostEditor( page, scheduledId );
- await openBeehiivSidebar( page );
+ await setBeehiivSidebar( page, true );
await expect(
page.locator( '.beehiiv-post-settings-notice__omitted' )
).toContainText( '1 block will be omitted from this newsletter.' );
@@ -589,7 +642,7 @@ test.describe( 'US-006: Group blocks become sections', () => {
);
await openPostEditor( page, sentId );
- await openBeehiivSidebar( page );
+ await setBeehiivSidebar( page, true );
await expect(
page.locator( '.beehiiv-post-settings-content' )
).toBeVisible();
@@ -612,7 +665,7 @@ test.describe( 'US-006: Group blocks become sections', () => {
} );
await loginAsAdmin( page );
await openPostEditor( page, id );
- await openBeehiivSidebar( page );
+ await setBeehiivSidebar( page, true );
await expect(
page.locator( '.beehiiv-post-settings-notice__text' )
From f69d8b8a0c71847e7452a5ffc30bbf2dc847dc9a Mon Sep 17 00:00:00 2001
From: Jeffrey Carandang
Date: Wed, 4 Nov 2026 20:17:02 +0800
Subject: [PATCH 8/8] style(editor): add a gap above the omitted-blocks line
only when it follows the send warning text
---
src/js/editor/post-settings/css/_sidebar.scss | 5 +++++
tests/e2e/specs/block-conversion-framework.spec.js | 8 ++++++++
2 files changed, 13 insertions(+)
diff --git a/src/js/editor/post-settings/css/_sidebar.scss b/src/js/editor/post-settings/css/_sidebar.scss
index ed10cb8..0e707c2 100644
--- a/src/js/editor/post-settings/css/_sidebar.scss
+++ b/src/js/editor/post-settings/css/_sidebar.scss
@@ -36,6 +36,11 @@
gap: 6px;
}
+ // Gap only when the omitted-blocks line follows the send warning text.
+ .beehiiv-post-settings-notice__text + .beehiiv-post-settings-notice__omitted {
+ margin-top: 10px;
+ }
+
.beehiiv-post-settings-notice__omitted-icon {
flex-shrink: 0;
font-size: 18px;
diff --git a/tests/e2e/specs/block-conversion-framework.spec.js b/tests/e2e/specs/block-conversion-framework.spec.js
index e849e57..e215f96 100644
--- a/tests/e2e/specs/block-conversion-framework.spec.js
+++ b/tests/e2e/specs/block-conversion-framework.spec.js
@@ -567,6 +567,10 @@ test.describe( 'US-006: Group blocks become sections', () => {
await expect(
page.locator( '.beehiiv-post-settings-notice__omitted' )
).toContainText( '1 block will be omitted from this newsletter.' );
+ // It follows the send warning text, so there is a gap above it.
+ await expect(
+ page.locator( '.beehiiv-post-settings-notice__omitted' )
+ ).toHaveCSS( 'margin-top', '10px' );
const canvas = page.frameLocator( 'iframe[name="editor-canvas"]' );
await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount(
1
@@ -636,6 +640,10 @@ test.describe( 'US-006: Group blocks become sections', () => {
await expect(
page.locator( '.beehiiv-post-settings-notice__omitted' )
).toContainText( '1 block will be omitted from this newsletter.' );
+ // Alone in its notice, so no gap above it.
+ await expect(
+ page.locator( '.beehiiv-post-settings-notice__omitted' )
+ ).toHaveCSS( 'margin-top', '0px' );
const canvas = page.frameLocator( 'iframe[name="editor-canvas"]' );
await expect( canvas.locator( '.beehiiv-omitted-block' ) ).toHaveCount(
1