diff --git a/composer.lock b/composer.lock
index 0d8f0c84d..c3be5643a 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,20 +4,20 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "b41cfd1bb81a9d4f8ed32b2381a22d65",
+ "content-hash": "556f279ce85f25f708f60329f1f7067c",
"packages": [
{
"name": "codeinwp/themeisle-sdk",
- "version": "3.3.54",
+ "version": "3.3.58",
"source": {
"type": "git",
"url": "https://github.com/Codeinwp/themeisle-sdk.git",
- "reference": "095c2d0f1388af0b0196c492a7f79e2fd092dab1"
+ "reference": "d6807c0b7308e323bd77cced667dee3f2d5e6a82"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/095c2d0f1388af0b0196c492a7f79e2fd092dab1",
- "reference": "095c2d0f1388af0b0196c492a7f79e2fd092dab1",
+ "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/d6807c0b7308e323bd77cced667dee3f2d5e6a82",
+ "reference": "d6807c0b7308e323bd77cced667dee3f2d5e6a82",
"shasum": ""
},
"require-dev": {
@@ -36,16 +36,16 @@
"homepage": "https://themeisle.com"
}
],
- "description": "Themeisle SDK.",
+ "description": "Themeisle SDK library.",
"homepage": "https://github.com/Codeinwp/themeisle-sdk",
"keywords": [
"wordpress"
],
"support": {
"issues": "https://github.com/Codeinwp/themeisle-sdk/issues",
- "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.54"
+ "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.58"
},
- "time": "2026-06-23T13:43:47+00:00"
+ "time": "2026-07-29T08:38:52+00:00"
},
{
"name": "enshrined/svg-sanitize",
diff --git a/inc/class-registration.php b/inc/class-registration.php
index 759c15824..fff5da32a 100644
--- a/inc/class-registration.php
+++ b/inc/class-registration.php
@@ -968,7 +968,7 @@ public function register_blocks() {
);
}
- if ( isset( $dynamic_blocks[ $block ] ) ) {
+ if ( isset( $dynamic_blocks[ $block ] ) && class_exists( $dynamic_blocks[ $block ] ) ) {
$classname = $dynamic_blocks[ $block ];
$renderer = new $classname();
diff --git a/inc/plugins/class-dashboard.php b/inc/plugins/class-dashboard.php
index a3f4a8085..038be1fba 100644
--- a/inc/plugins/class-dashboard.php
+++ b/inc/plugins/class-dashboard.php
@@ -635,6 +635,54 @@ private function the_otter_banner() {
max-height: 35px;
}
+ .o-export-split {
+ position: relative;
+ display: inline-flex;
+ }
+
+ .o-export-split #export-submissions {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ }
+
+ .o-export-split__toggle {
+ border-top-left-radius: 0 !important;
+ border-bottom-left-radius: 0 !important;
+ border-left: none !important;
+ padding: 0 6px !important;
+ }
+
+ .o-export-split__menu {
+ position: absolute;
+ top: 100%;
+ right: 0;
+ z-index: 10;
+ margin: 4px 0 0;
+ padding: 4px 0;
+ list-style: none;
+ background: #fff;
+ border: 1px solid #c3c4c7;
+ border-radius: 4px;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
+ min-width: 220px;
+ }
+
+ .o-export-split__item {
+ display: block;
+ width: 100%;
+ padding: 8px 12px;
+ background: none;
+ border: none;
+ text-align: left;
+ cursor: pointer;
+ font-size: 13px;
+ }
+
+ .o-export-split__item:hover,
+ .o-export-split__item:focus {
+ background: #f0f0f1;
+ }
+
.wp-core-ui .button.o-locked-action,
.wp-core-ui .button.o-locked-action:focus {
display: inline-flex;
@@ -683,9 +731,25 @@ private function the_otter_banner() {
-
+
+
+
+
+
@@ -706,7 +770,20 @@ class="button o-locked-action"
export_csv( $output );
+ } else {
+ $this->export_xml( $output );
+ }
+
+ wp_die();
+ }
+
+ /**
+ * Build the WordPress WXR (XML) export of all submissions.
+ *
+ * @param \SplFileObject $output Output stream.
+ * @return void
+ */
+ private function export_xml( $output ) {
require_once ABSPATH . 'wp-admin/includes/export.php';
+
ob_start();
export_wp( array( 'content' => Form_Submissions::FORM_RECORD_TYPE ) );
$export = ob_get_clean();
- echo ent2ncr( $export ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
- wp_die();
+ $output->fwrite( ent2ncr( $export ) );
+ }
+
+ /**
+ * Write a CSV export of all submissions to the output.
+ *
+ * @param \SplFileObject $output Output stream.
+ * @return void
+ */
+ private function export_csv( $output ) {
+ $max_id = (int) $this->get_max_submission_id();
+
+ $columns = array_merge( $this->get_fixed_columns(), $this->collect_input_columns( $max_id ) );
+
+ // Prefix a UTF-8 BOM so Excel detects the encoding instead of mangling accented characters.
+ $output->fwrite( "\xEF\xBB\xBF" );
+
+ $output->fputcsv( array_map( array( $this, 'sanitize_cell' ), array_values( $columns ) ) );
+
+ $column_keys = array_keys( $columns );
+
+ $this->walk_submissions(
+ $max_id,
+ function ( $meta, $record ) use ( $output, $column_keys ) {
+ $row = $this->get_record_row( $record, $meta );
+ $line = array();
+
+ foreach ( $column_keys as $key ) {
+ $line[] = $this->sanitize_cell( isset( $row[ $key ] ) ? $row[ $key ] : '' );
+ }
+
+ $output->fputcsv( $line );
+ }
+ );
+ }
+
+ /**
+ * Columns present for every submission, in export order.
+ *
+ * @return array Column key => header label.
+ */
+ private function get_fixed_columns() {
+ return array(
+ 'id' => __( 'ID', 'otter-blocks' ),
+ 'status' => __( 'Status', 'otter-blocks' ),
+ 'date' => __( 'Submission Date', 'otter-blocks' ),
+ 'form' => __( 'Form', 'otter-blocks' ),
+ 'post' => __( 'Post URL', 'otter-blocks' ),
+ );
+ }
+
+ /**
+ * Collect the dynamically generated input columns found across every submission.
+ *
+ * @param int $max_id Highest submission ID to export.
+ * @return array Column key => header label, in order of first appearance.
+ */
+ private function collect_input_columns( $max_id ) {
+ $input_columns = array();
+
+ $this->walk_submissions(
+ $max_id,
+ function ( $meta ) use ( &$input_columns ) {
+ foreach ( $this->get_record_inputs( $meta ) as $column_key => $input ) {
+ if ( isset( $input_columns[ $column_key ] ) ) {
+ continue;
+ }
+
+ $input_columns[ $column_key ] = $input['label'];
+ }
+ }
+ );
+
+ return $input_columns;
+ }
+
+ /**
+ * Run a callback over every submission, loading them in bounded batches.
+ *
+ * @param int $max_id Highest submission ID to export.
+ * @param callable $callback Receives the submission record meta and the submission post.
+ * @return void
+ */
+ private function walk_submissions( $max_id, $callback ) {
+ $last_id = 0;
+
+ do {
+ $records = $this->get_submissions_batch( $last_id, $max_id );
+
+ // The batch is only limited, never offset, so an empty one means the range is done.
+ if ( empty( $records ) ) {
+ return;
+ }
+
+ $ids = wp_list_pluck( $records, 'ID' );
+ $last_id = (int) end( $ids );
+
+ update_meta_cache( 'post', $ids );
+
+ foreach ( $records as $record ) {
+ $meta = get_post_meta( $record->ID, Form_Submissions::FORM_RECORD_META_KEY, true );
+
+ if ( ! is_array( $meta ) ) {
+ continue;
+ }
+
+ $callback( $meta, $record );
+ }
+
+ // Drop the batch from the object cache, otherwise memory grows with every batch.
+ foreach ( $ids as $id ) {
+ wp_cache_delete( $id, 'post_meta' );
+ }
+
+ unset( $records, $ids );
+
+ $has_more_submissions = $last_id < $max_id;
+ } while ( $has_more_submissions );
+ }
+
+ /**
+ * The highest submission ID eligible for export.
+ *
+ * @return int
+ */
+ private function get_max_submission_id() {
+ $ids = get_posts(
+ array(
+ 'post_type' => Form_Submissions::FORM_RECORD_TYPE,
+ 'post_status' => self::EXPORT_POST_STATUSES,
+ 'posts_per_page' => 1,
+ 'orderby' => 'ID',
+ 'order' => 'DESC',
+ 'fields' => 'ids',
+ 'cache_results' => false,
+ 'update_post_meta_cache' => false,
+ 'update_post_term_cache' => false,
+ )
+ );
+
+ return empty( $ids ) ? 0 : reset( $ids );
+ }
+
+ /**
+ * Fetch the submissions following the last one handled, oldest first.
+ *
+ * @param int $last_id Highest submission ID handled so far.
+ * @param int $max_id Highest submission ID to export.
+ * @return \WP_Post[]
+ */
+ private function get_submissions_batch( $last_id, $max_id ) {
+ $keyset_clause = static function ( $where ) use ( $last_id, $max_id ) {
+ global $wpdb;
+
+ return $where . $wpdb->prepare( " AND {$wpdb->posts}.ID > %d AND {$wpdb->posts}.ID <= %d", $last_id, $max_id );
+ };
+
+ /*
+ * A split query fetches the IDs and then primes the post cache with a second query, which
+ * is wasted work here: the batch is read once and its caches are dropped straight after.
+ * That priming is not covered by `cache_results`, so it has to be turned off separately.
+ */
+ $single_query = static function () {
+ return false;
+ };
+
+ add_filter( 'posts_where', $keyset_clause );
+ add_filter( 'split_the_query', $single_query );
+
+ $query = new \WP_Query(
+ array(
+ 'post_type' => Form_Submissions::FORM_RECORD_TYPE,
+ 'post_status' => self::EXPORT_POST_STATUSES,
+ 'posts_per_page' => self::EXPORT_BATCH_SIZE,
+ 'orderby' => 'ID',
+ 'order' => 'ASC',
+ 'no_found_rows' => true,
+ 'cache_results' => false,
+ 'update_post_meta_cache' => false,
+ 'update_post_term_cache' => false,
+ 'suppress_filters' => false,
+ )
+ );
+
+ remove_filter( 'split_the_query', $single_query );
+ remove_filter( 'posts_where', $keyset_clause );
+
+ return $query->posts;
+ }
+
+ /**
+ * Build the CSV row of a single submission, keyed by column.
+ *
+ * @param \WP_Post $record Submission post.
+ * @param array $meta Submission record meta.
+ * @return array
+ */
+ private function get_record_row( $record, $meta ) {
+ $row = array(
+ 'id' => substr( strval( $record->ID ), -8 ),
+ 'status' => get_post_status( $record ),
+ 'date' => get_the_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $record ),
+ 'form' => isset( $meta['form']['value'] ) ? $meta['form']['value'] : '',
+ 'post' => isset( $meta['post_url']['value'] ) ? $meta['post_url']['value'] : '',
+ );
+
+ foreach ( $this->get_record_inputs( $meta ) as $column_key => $input ) {
+ $row[ $column_key ] = $input['value'];
+ }
+
+ return $row;
+ }
+
+ /**
+ * Extract the exportable inputs of a submission, keyed by column.
+ *
+ * @param array $meta Submission record meta.
+ * @return array> Column key => array with the `label` and `value` of the input.
+ */
+ private function get_record_inputs( $meta ) {
+ $inputs = isset( $meta['inputs'] ) && is_array( $meta['inputs'] ) ? $meta['inputs'] : array();
+ $parsed = array();
+
+ foreach ( $inputs as $input ) {
+ if ( empty( $input ) || ! isset( $input['type'], $input['label'] ) || 'stripe-field' === $input['type'] ) {
+ continue;
+ }
+
+ $value = isset( $input['value'] ) ? $input['value'] : '';
+
+ if ( 'file' === $input['type'] && isset( $input['metadata']['name'] ) ) {
+ $value = $input['metadata']['name'];
+ }
+
+ $parsed[ 'input:' . $input['label'] ] = array(
+ 'label' => $input['label'],
+ 'value' => $value,
+ );
+ }
+
+ return $parsed;
+ }
+
+ /**
+ * Sanitize a cell value for CSV export, prefixing with a single quote if it looks like a formula.
+ *
+ * @param mixed $value Cell value.
+ * @return string
+ */
+ private function sanitize_cell( $value ) {
+ $value = strval( $value );
+
+ return preg_match( '/^[\x00-\x20]*[=+\-@]/', $value ) ? "'" . $value : $value;
}
}
diff --git a/plugins/blocks-animation/composer.lock b/plugins/blocks-animation/composer.lock
index b11e5749e..d03d95990 100644
--- a/plugins/blocks-animation/composer.lock
+++ b/plugins/blocks-animation/composer.lock
@@ -8,16 +8,16 @@
"packages": [
{
"name": "codeinwp/themeisle-sdk",
- "version": "3.3.54",
+ "version": "3.3.58",
"source": {
"type": "git",
"url": "https://github.com/Codeinwp/themeisle-sdk.git",
- "reference": "095c2d0f1388af0b0196c492a7f79e2fd092dab1"
+ "reference": "d6807c0b7308e323bd77cced667dee3f2d5e6a82"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/095c2d0f1388af0b0196c492a7f79e2fd092dab1",
- "reference": "095c2d0f1388af0b0196c492a7f79e2fd092dab1",
+ "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/d6807c0b7308e323bd77cced667dee3f2d5e6a82",
+ "reference": "d6807c0b7308e323bd77cced667dee3f2d5e6a82",
"shasum": ""
},
"require-dev": {
@@ -36,16 +36,16 @@
"homepage": "https://themeisle.com"
}
],
- "description": "Themeisle SDK.",
+ "description": "Themeisle SDK library.",
"homepage": "https://github.com/Codeinwp/themeisle-sdk",
"keywords": [
"wordpress"
],
"support": {
"issues": "https://github.com/Codeinwp/themeisle-sdk/issues",
- "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.54"
+ "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.58"
},
- "time": "2026-06-23T13:43:47+00:00"
+ "time": "2026-07-29T08:38:52+00:00"
}
],
"packages-dev": [],
diff --git a/plugins/blocks-css/composer.lock b/plugins/blocks-css/composer.lock
index 215845bb9..c2a3c2df6 100644
--- a/plugins/blocks-css/composer.lock
+++ b/plugins/blocks-css/composer.lock
@@ -8,16 +8,16 @@
"packages": [
{
"name": "codeinwp/themeisle-sdk",
- "version": "3.3.54",
+ "version": "3.3.58",
"source": {
"type": "git",
"url": "https://github.com/Codeinwp/themeisle-sdk.git",
- "reference": "095c2d0f1388af0b0196c492a7f79e2fd092dab1"
+ "reference": "d6807c0b7308e323bd77cced667dee3f2d5e6a82"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/095c2d0f1388af0b0196c492a7f79e2fd092dab1",
- "reference": "095c2d0f1388af0b0196c492a7f79e2fd092dab1",
+ "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/d6807c0b7308e323bd77cced667dee3f2d5e6a82",
+ "reference": "d6807c0b7308e323bd77cced667dee3f2d5e6a82",
"shasum": ""
},
"require-dev": {
@@ -36,16 +36,16 @@
"homepage": "https://themeisle.com"
}
],
- "description": "Themeisle SDK.",
+ "description": "Themeisle SDK library.",
"homepage": "https://github.com/Codeinwp/themeisle-sdk",
"keywords": [
"wordpress"
],
"support": {
"issues": "https://github.com/Codeinwp/themeisle-sdk/issues",
- "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.54"
+ "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.58"
},
- "time": "2026-06-23T13:43:47+00:00"
+ "time": "2026-07-29T08:38:52+00:00"
}
],
"packages-dev": [],
diff --git a/src/blocks/test/e2e/blocks/form.spec.js b/src/blocks/test/e2e/blocks/form.spec.js
index aec3dee3b..82815e4cf 100644
--- a/src/blocks/test/e2e/blocks/form.spec.js
+++ b/src/blocks/test/e2e/blocks/form.spec.js
@@ -412,10 +412,10 @@ test.describe( 'Form Block', () => {
await expect( page.locator( '.otter-form-input[type="hidden"]' ) ).toHaveValue( '123' );
});
- test( 'can export form data', async({ page }) => {
+ test( 'can export form data as WordPress XML (WXR)', async({ page }) => {
await page.goto( '/wp-admin/edit.php?post_type=otter_form_record' );
- const exportBtn = page.getByRole( 'button', { name: 'Export' });
+ const exportBtn = page.getByRole( 'button', { name: 'Export', exact: true });
await expect( exportBtn ).toBeVisible();
@@ -425,5 +425,20 @@ test.describe( 'Form Block', () => {
await download.path(); // Wait for download to complete.
expect( download.suggestedFilename().startsWith( 'otter_form_submissions' ) ).toBeTruthy();
+ expect( download.suggestedFilename().endsWith( '.xml' ) ).toBeTruthy();
+ });
+
+ test( 'can export form data as CSV', async({ page }) => {
+ await page.goto( '/wp-admin/edit.php?post_type=otter_form_record' );
+
+ await page.locator( '#export-submissions-toggle' ).click();
+
+ const downloadPromise = page.waitForEvent( 'download' );
+ await page.getByRole( 'button', { name: 'Export as CSV' }).click();
+ const download = await downloadPromise;
+
+ await download.path(); // Wait for download to complete.
+ expect( download.suggestedFilename().startsWith( 'otter_form_submissions' ) ).toBeTruthy();
+ expect( download.suggestedFilename().endsWith( '.csv' ) ).toBeTruthy();
});
});
diff --git a/tests/test-form-submissions.php b/tests/test-form-submissions.php
index 68a0aea73..ede6a7c3a 100644
--- a/tests/test-form-submissions.php
+++ b/tests/test-form-submissions.php
@@ -58,7 +58,7 @@ public function set_up() {
*/
public function tear_down() {
unset( $_REQUEST[ Form_Submissions::FORM_RECORD_TYPE ], $_REQUEST['_wpnonce'], $_REQUEST['post'] );
- unset( $_POST['action'], $_POST['_wpnonce'], $_POST['_nonce'] );
+ unset( $_POST['action'], $_POST['_wpnonce'], $_POST['_nonce'], $_POST['format'] );
unset( $_GET['post_type'], $_GET['filter_action'], $_GET['filters_nonce'], $_GET['otter_form_filter'], $_REQUEST['otter_form_filter'] );
unset( $_GET['otter_post_filter'], $_REQUEST['otter_post_filter'] );
unset( $GLOBALS['otter_test_stripe_record_id'] );
@@ -809,6 +809,150 @@ public function test_export_requires_manage_options() {
( new Form_Records_Export() )->export_submissions();
}
+ /**
+ * Ensure the export defaults to the WordPress XML (WXR) format when no format is
+ * posted, preserving the pre-existing endpoint contract.
+ */
+ public function test_export_defaults_to_xml_format() {
+ if ( ! \ThemeIsle\GutenbergBlocks\Pro::is_pro_installed() ) {
+ $this->markTestSkipped( 'Otter Pro is not installed in this test environment.' );
+ }
+
+ add_filter( 'product_otter_license_status', array( $this, 'valid_license' ) );
+
+ $this->create_record();
+
+ wp_set_current_user( $this->admin_id );
+ $_POST['_nonce'] = wp_create_nonce( 'otter_form_export_submissions' );
+
+ ob_start();
+
+ try {
+ ( new Form_Records_Export() )->export_submissions();
+ } catch ( WPDieException $exception ) {
+ // export_submissions() always ends in wp_die(); assertions run on the buffered output below.
+ }
+
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString( 'markTestSkipped( 'Otter Pro is not installed in this test environment.' );
+ }
+
+ add_filter( 'product_otter_license_status', array( $this, 'valid_license' ) );
+
+ $this->create_record(
+ 'unread',
+ array(
+ 'inputs' => array(
+ 'input01' => array(
+ 'label' => 'Name',
+ 'value' => 'Ada Lovelace',
+ 'type' => 'text',
+ ),
+ ),
+ )
+ );
+
+ $this->create_record(
+ 'read',
+ array(
+ 'inputs' => array(
+ 'input02' => array(
+ 'label' => 'Company',
+ 'value' => 'Analytical Engines Ltd',
+ 'type' => 'text',
+ ),
+ ),
+ )
+ );
+
+ wp_set_current_user( $this->admin_id );
+ $_POST['_nonce'] = wp_create_nonce( 'otter_form_export_submissions' );
+ $_POST['format'] = 'csv';
+
+ ob_start();
+
+ try {
+ ( new Form_Records_Export() )->export_submissions();
+ } catch ( WPDieException $exception ) {
+ // export_submissions() always ends in wp_die(); assertions run on the buffered output below.
+ }
+
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString( 'Name', $output );
+ $this->assertStringContainsString( 'Company', $output );
+ $this->assertStringContainsString( 'Ada Lovelace', $output );
+ $this->assertStringContainsString( 'Analytical Engines Ltd', $output );
+ }
+
+ /**
+ * Ensure the CSV export walks past the first batch: every submission gets a row, and a field
+ * that only shows up in a later batch still makes it into the header row.
+ */
+ public function test_export_csv_paginates_beyond_a_single_batch() {
+ if ( ! \ThemeIsle\GutenbergBlocks\Pro::is_pro_installed() ) {
+ $this->markTestSkipped( 'Otter Pro is not installed in this test environment.' );
+ }
+
+ add_filter( 'product_otter_license_status', array( $this, 'valid_license' ) );
+
+ $total = Form_Records_Export::EXPORT_BATCH_SIZE + 5;
+
+ for ( $index = 0; $index < $total; $index++ ) {
+ // Only the very last submission carries the late field, so it lands in a second batch.
+ $label = $index === $total - 1 ? 'Late Field' : 'Name';
+
+ $this->create_record(
+ 'unread',
+ array(
+ 'inputs' => array(
+ 'input01' => array(
+ 'label' => $label,
+ 'value' => 'Submission ' . $index,
+ 'type' => 'text',
+ ),
+ ),
+ )
+ );
+ }
+
+ wp_set_current_user( $this->admin_id );
+ $_POST['_nonce'] = wp_create_nonce( 'otter_form_export_submissions' );
+ $_POST['format'] = 'csv';
+
+ ob_start();
+
+ try {
+ ( new Form_Records_Export() )->export_submissions();
+ } catch ( WPDieException $exception ) {
+ // export_submissions() always ends in wp_die(); assertions run on the buffered output below.
+ }
+
+ $output = ob_get_clean();
+
+ $lines = array_filter( explode( "\n", trim( $output ) ) );
+
+ // One header row plus one row per submission.
+ $this->assertCount( $total + 1, $lines );
+
+ // The header is built from a full pass, so a label from a later batch is not missed.
+ $this->assertStringContainsString( 'Late Field', reset( $lines ) );
+
+ $this->assertStringContainsString( 'Submission 0', $output );
+ $this->assertStringContainsString( 'Submission ' . ( Form_Records_Export::EXPORT_BATCH_SIZE - 1 ), $output );
+ $this->assertStringContainsString( 'Submission ' . ( $total - 1 ), $output );
+ }
+
/**
* Ensure the list-table bulk actions match the current status view.
*/
diff --git a/tests/test-registration.php b/tests/test-registration.php
index 0f0087b05..d12b2983c 100644
--- a/tests/test-registration.php
+++ b/tests/test-registration.php
@@ -13,12 +13,30 @@
*/
class Test_Registration extends WP_UnitTestCase {
+ /**
+ * Temp renderer files to remove after the test, even if it failed part way.
+ *
+ * @var array
+ */
+ private $temp_renderer_files = array();
+
/**
* Tear down test environment.
*/
public function tear_down() {
delete_option( 'themeisle_blocks_settings_global_defaults' );
+ // Restore permissions before unlinking: a test that fails mid-way would
+ // otherwise leave an unreadable file behind and poison later runs.
+ foreach ( $this->temp_renderer_files as $file ) {
+ if ( file_exists( $file ) ) {
+ chmod( $file, 0644 );
+ unlink( $file );
+ }
+ }
+
+ $this->temp_renderer_files = array();
+
parent::tear_down();
}
@@ -61,4 +79,169 @@ public function test_editor_global_defaults_is_object_when_option_is_missing() {
$this->assertEquals( new stdClass(), Registration::get_editor_global_defaults() );
}
+
+ /**
+ * Run register_blocks() with `form-captcha` mapped to $classname.
+ *
+ * register_blocks() already ran once via the `init` hook fired during
+ * bootstrap, so the plugin's blocks are unregistered first to keep this a
+ * clean registration instead of "already registered" notices.
+ *
+ * @param string $classname Renderer class to map form-captcha to.
+ * @param string $expected_include_failure Path whose failed include is expected, or empty if no include failure is expected.
+ * @return void
+ */
+ private function register_blocks_with_captcha_renderer( $classname, $expected_include_failure = '' ) {
+ $filter = function ( $dynamic_blocks ) use ( $classname ) {
+ $dynamic_blocks['form-captcha'] = $classname;
+
+ return $dynamic_blocks;
+ };
+
+ add_filter( 'otter_blocks_register_dynamic_blocks', $filter );
+
+ $registry = \WP_Block_Type_Registry::get_instance();
+
+ foreach ( array_keys( $registry->get_all_registered() ) as $name ) {
+ if ( 0 === strpos( $name, 'themeisle-blocks/' ) ) {
+ $registry->unregister( $name );
+ }
+ }
+
+ $previous = set_error_handler(
+ function ( $level, $message, $file = '', $line = 0 ) use ( &$previous, $expected_include_failure ) {
+ if (
+ '' !== $expected_include_failure &&
+ E_WARNING === $level &&
+ false !== strpos( $message, $expected_include_failure )
+ ) {
+ return true;
+ }
+
+ if ( null === $previous ) {
+ return false;
+ }
+
+ return call_user_func( $previous, $level, $message, $file, $line );
+ }
+ );
+
+ try {
+ ( new Registration() )->register_blocks();
+ } finally {
+ restore_error_handler();
+ remove_filter( 'otter_blocks_register_dynamic_blocks', $filter );
+ }
+ }
+
+ /**
+ * Register a throwaway autoloader that resolves $class from $file the same
+ * way Composer's classmap loader does: an unsuppressed `include` of the
+ * mapped path, with no prior existence check.
+ *
+ * @param string $class Class to map.
+ * @param string $file Path to include for it.
+ * @return callable The loader, for spl_autoload_unregister.
+ */
+ private function register_composer_like_loader( $class, $file ) {
+ $loader = function ( $requested ) use ( $class, $file ) {
+ if ( ltrim( $class, '\\' ) === $requested ) {
+ include $file;
+ }
+ };
+
+ spl_autoload_register( $loader );
+
+ return $loader;
+ }
+
+ /**
+ * Positive control: a loadable renderer must register the block *with* a
+ * render_callback. Without this, the null-callback assertions below would
+ * pass even if registration silently stopped wiring renderers altogether.
+ */
+ public function test_register_blocks_uses_the_renderer_when_the_class_is_loadable() {
+ $this->register_blocks_with_captcha_renderer( '\ThemeIsle\GutenbergBlocks\Render\Form_Captcha_Block' );
+
+ $block_type = \WP_Block_Type_Registry::get_instance()->get_registered( 'themeisle-blocks/form-captcha' );
+
+ $this->assertNotNull( $block_type, 'The block must be registered.' );
+ $this->assertIsCallable( $block_type->render_callback, 'A loadable renderer must be wired as the render callback.' );
+ }
+
+ /**
+ * A dynamic block whose mapped renderer class has no autoload entry at all
+ * must not fatal registration for every other block; it should fall back to
+ * plain metadata registration.
+ */
+ public function test_register_blocks_survives_dynamic_renderer_class_with_no_autoload_entry() {
+ $this->register_blocks_with_captcha_renderer( '\ThemeIsle\GutenbergBlocks\Render\Nonexistent_Form_Captcha_Block' );
+
+ $this->assertCaptchaRegisteredWithoutRenderer();
+ }
+
+ /**
+ * Assert the captcha block came through the fallback branch: still registered,
+ * but with no render_callback. `is_registered()` alone would also pass on the
+ * dynamic path, so the null callback is what pins the fallback down.
+ *
+ * @return void
+ */
+ private function assertCaptchaRegisteredWithoutRenderer() {
+ $block_type = \WP_Block_Type_Registry::get_instance()->get_registered( 'themeisle-blocks/form-captcha' );
+
+ $this->assertNotNull( $block_type, 'The block must still be registered.' );
+ $this->assertNull( $block_type->render_callback, 'The block must fall back to registration without a renderer.' );
+ }
+
+ /**
+ * A renderer class that is mapped by the autoloader but whose file is absent
+ * from the deployed artifact must degrade the same way. Composer's classmap
+ * returns the path without checking it exists, so the include only warns and
+ * the class stays undefined.
+ */
+ public function test_register_blocks_survives_dynamic_renderer_class_whose_file_is_missing() {
+ $class = 'ThemeIsle\GutenbergBlocks\Render\Missing_File_Captcha_Block';
+ $file = get_temp_dir() . 'otter-absent-renderer.php';
+ $loader = $this->register_composer_like_loader( $class, $file );
+
+ try {
+ $this->register_blocks_with_captcha_renderer( $class, $file );
+ } finally {
+ spl_autoload_unregister( $loader );
+ }
+
+ $this->assertFalse( class_exists( $class, false ), 'The renderer class must not have been defined.' );
+ $this->assertCaptchaRegisteredWithoutRenderer();
+ }
+
+ /**
+ * Same degradation when the renderer file is present but not readable — a
+ * permissions mishap during deploy. `include` needs read permission, so the
+ * class again stays undefined.
+ */
+ public function test_register_blocks_survives_dynamic_renderer_class_whose_file_is_unreadable() {
+ $class = 'ThemeIsle\GutenbergBlocks\Render\Unreadable_File_Captcha_Block';
+ $file = get_temp_dir() . 'otter-unreadable-renderer.php';
+
+ $this->temp_renderer_files[] = $file;
+
+ file_put_contents( $file, 'markTestSkipped( 'Cannot make a file unreadable as this user (likely running as root).' );
+ }
+
+ $loader = $this->register_composer_like_loader( $class, $file );
+
+ try {
+ $this->register_blocks_with_captcha_renderer( $class, $file );
+ } finally {
+ spl_autoload_unregister( $loader );
+ }
+
+ $this->assertFalse( class_exists( $class, false ), 'The renderer class must not have been defined.' );
+ $this->assertCaptchaRegisteredWithoutRenderer();
+ }
}