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(); + } }