diff --git a/inc/class-autoloader.php b/inc/class-autoloader.php new file mode 100644 index 000000000..7522a47d8 --- /dev/null +++ b/inc/class-autoloader.php @@ -0,0 +1,102 @@ + + */ + private static function candidates( $relative ) { + $files = array( OTTER_BLOCKS_PATH . '/inc/' . $relative . '.php' ); + + // The Integration namespace lives in inc/integrations/. + if ( 0 === strpos( $relative, 'integration/' ) ) { + $files[] = OTTER_BLOCKS_PATH . '/inc/integrations/' . substr( $relative, strlen( 'integration/' ) ) . '.php'; + } + + return $files; + } +} diff --git a/inc/class-main.php b/inc/class-main.php index f4288c356..6a34e4f47 100644 --- a/inc/class-main.php +++ b/inc/class-main.php @@ -94,6 +94,11 @@ public function autoload_classes() { $classnames = apply_filters( 'otter_blocks_autoloader', $classnames ); foreach ( $classnames as $classname ) { + // A stale Composer classmap or a third-party filter can list a class that is not loadable; skip it instead of fataling the request. + if ( ! is_string( $classname ) || ! class_exists( $classname ) ) { + continue; + } + $classname = new $classname(); if ( method_exists( $classname, 'instance' ) ) { diff --git a/otter-blocks.php b/otter-blocks.php index 02a1722c6..e89ef308a 100644 --- a/otter-blocks.php +++ b/otter-blocks.php @@ -38,6 +38,11 @@ require_once $vendor_file; } +// Resolves the plugin's own classes from their file names when Composer's generated +// classmap does not match the files on disk. Registered last, so Composer stays first. +require_once OTTER_BLOCKS_PATH . '/inc/class-autoloader.php'; +\ThemeIsle\GutenbergBlocks\Autoloader::register(); + if ( class_exists( '\ThemeIsle\GutenbergBlocks\Main' ) ) { \ThemeIsle\GutenbergBlocks\Main::instance(); } diff --git a/packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php b/packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php index 649de761d..963058ec2 100644 --- a/packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php +++ b/packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php @@ -51,6 +51,8 @@ // Form webhooks registry; retention specs seed a dead-URL webhook to force // a delivery failure with the 'webhook' action. 'themeisle_webhooks_options', + // Scenario flag for the autoloader-resilience spec; see break_otter_autoloader(). + 'otter_e2e_broken_autoloader', 'otter_blocks_logger_flag', 'otter_blocks_logger_data', 'otter_activation_first_save', @@ -107,6 +109,12 @@ */ const WIDGET_SEED_INDEX = 999; +/** + * When truthy, an unloadable class is put at the head of the Otter autoloader list, + * reproducing a stale Composer classmap on a released package (issue #2954). + */ +const BROKEN_AUTOLOADER_OPTION = 'otter_e2e_broken_autoloader'; + /** * Form record post type, mirrored from \ThemeIsle\GutenbergBlocks\Plugins\Form_Submissions. */ @@ -802,6 +810,31 @@ function ( $block_content ) use ( &$saved ) { add_action( 'wp', __NAMESPACE__ . '\\corrupt_pages_around_dynamic_tags' ); +/** + * Put an unloadable class first in the Otter autoloader list when the scenario option is on. + * + * @param array $classnames Classes Otter initializes on `init`. + * @return array + */ +function break_otter_autoloader( $classnames ) { + if ( ! get_option( BROKEN_AUTOLOADER_OPTION, false ) ) { + return $classnames; + } + + // Never break the scenario endpoints themselves, or a spec running against unfixed + // code could not disarm the flag and would poison the rest of the run. + $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; + if ( false !== strpos( $uri, REST_NAMESPACE ) ) { + return $classnames; + } + + array_unshift( $classnames, '\ThemeIsle\GutenbergBlocks\Plugins\Missing_From_Classmap' ); + + return $classnames; +} + +add_filter( 'otter_blocks_autoloader', __NAMESPACE__ . '\\break_otter_autoloader' ); + add_filter( 'pre_wp_mail', __NAMESPACE__ . '\\stub_wp_mail_for_e2e', 10, 2 ); add_filter( 'pre_http_request', __NAMESPACE__ . '\\stub_openai_http_for_e2e', 10, 3 ); diff --git a/src/blocks/test/e2e/blocks/autoloader-resilience.spec.js b/src/blocks/test/e2e/blocks/autoloader-resilience.spec.js new file mode 100644 index 000000000..8fc05d8d2 --- /dev/null +++ b/src/blocks/test/e2e/blocks/autoloader-resilience.spec.js @@ -0,0 +1,43 @@ +/** + * Internal dependencies + */ +import { test, expect } from '../fixtures'; + +/** + * Regression for #2954: a class listed for autoloading that the released package cannot load + * (stale Composer classmap) crashed every request in `Main::autoload_classes()`. + */ +test.describe( 'Autoloader resilience', () => { + test.beforeEach( async({ otterUtils }) => { + await otterUtils.setOptions({ otter_e2e_broken_autoloader: true }); + }); + + test.afterEach( async({ otterUtils }) => { + await otterUtils.setOptions({ otter_e2e_broken_autoloader: false }); + }); + + test( 'frontend survives an unloadable class in the autoload list', async({ page, requestUtils }) => { + const post = await requestUtils.createPost({ + title: 'Autoloader resilience', + content: '', + status: 'publish' + }); + + const response = await page.goto( post.link ); + + expect( response.status() ).toBe( 200 ); + await expect( page.locator( 'text=There has been a critical error' ) ).toBeHidden(); + + // The blocks listed after the unloadable one must still be initialized: + // posts-grid only renders when Registration ran. The grid lists earlier + // posts, which can embed their own grid, so match the first one. + await expect( page.locator( '.wp-block-themeisle-blocks-posts-grid' ).first() ).toBeVisible(); + }); + + test( 'admin survives an unloadable class in the autoload list', async({ page, admin }) => { + await admin.visitAdminPage( 'admin.php?page=otter' ); + + await expect( page.locator( 'text=There has been a critical error' ) ).toBeHidden(); + await expect( page.locator( '#otter' ) ).toBeVisible(); + }); +}); diff --git a/src/blocks/test/e2e/playwright.config.js b/src/blocks/test/e2e/playwright.config.js index 78823c080..79ec41825 100644 --- a/src/blocks/test/e2e/playwright.config.js +++ b/src/blocks/test/e2e/playwright.config.js @@ -60,7 +60,10 @@ const SERIAL_SPECS = [ '**/blocks/atomic-wind-list-view.spec.js', // Switches the active theme and mutates site-wide widget + filesystem state. - '**/blocks/widgets-css-frontend.spec.js' + '**/blocks/widgets-css-frontend.spec.js', + + // Flips a site-wide flag that breaks Otter's autoloader for every request. + '**/blocks/autoloader-resilience.spec.js' ]; const config = defineConfig({ diff --git a/tests/test-main-autoload.php b/tests/test-main-autoload.php new file mode 100644 index 000000000..0248cf9ff --- /dev/null +++ b/tests/test-main-autoload.php @@ -0,0 +1,155 @@ +autoload_classes(); + + $this->assertTrue( Otter_Autoload_Probe::$instantiated, 'Classes listed after an unavailable one should still be instantiated.' ); + } + + /** + * Non-string entries injected by a third-party filter must not fatal either. + */ + public function test_autoload_classes_skips_non_string_entries() { + add_filter( + 'otter_blocks_autoloader', + function () { + return array( null, array( 'nope' ), 'Otter_Autoload_Probe' ); + } + ); + + ( new Main() )->autoload_classes(); + + $this->assertTrue( Otter_Autoload_Probe::$instantiated ); + } + + /** + * Every class the plugin ships in the autoload list must be loadable, so a stale classmap is caught here instead of on a live site. + */ + public function test_bundled_classnames_are_loadable() { + $listed = $this->get_listed_classnames(); + + $this->assertNotEmpty( $listed ); + + foreach ( $listed as $classname ) { + $this->assertTrue( class_exists( $classname ), $classname . ' is listed for autoloading but cannot be loaded.' ); + } + } + + /** + * The fallback loader must cover the whole autoload list, so a class stays reachable when Composer's generated classmap does not match the files on disk. + */ + public function test_fallback_autoloader_resolves_every_listed_classname() { + foreach ( $this->get_listed_classnames() as $classname ) { + $this->assertNotFalse( + Autoloader::path_for( ltrim( $classname, '\\' ) ), + $classname . ' cannot be resolved from its file name; the fallback autoloader no longer covers the autoload list.' + ); + } + } + + /** + * File name mapping, including the Integration namespace that lives in inc/integrations/. + */ + public function test_path_for_maps_class_names_to_files() { + $this->assertSame( + OTTER_BLOCKS_PATH . '/inc/plugins/class-atomic-wind-blocks.php', + Autoloader::path_for( 'ThemeIsle\GutenbergBlocks\Plugins\Atomic_Wind_Blocks' ) + ); + + $this->assertSame( + OTTER_BLOCKS_PATH . '/inc/integrations/class-form-providers.php', + Autoloader::path_for( 'ThemeIsle\GutenbergBlocks\Integration\Form_Providers' ) + ); + + $this->assertSame( + OTTER_BLOCKS_PATH . '/inc/class-main.php', + Autoloader::path_for( 'ThemeIsle\GutenbergBlocks\Main' ) + ); + } + + /** + * Classes outside the plugin namespace, and names with no file, are left to the other loaders. + */ + public function test_path_for_ignores_foreign_and_missing_classes() { + $this->assertFalse( Autoloader::path_for( 'WP_Query' ) ); + $this->assertFalse( Autoloader::path_for( 'ThemeIsle\OtterPro\Plugins\License' ) ); + $this->assertFalse( Autoloader::path_for( 'ThemeIsle\GutenbergBlocks\Plugins\Definitely_Missing_Class' ) ); + } + + /** + * The class list the plugin passes through the `otter_blocks_autoloader` filter. + * + * @return array + */ + private function get_listed_classnames() { + $listed = array(); + + add_filter( + 'otter_blocks_autoloader', + function ( $classnames ) use ( &$listed ) { + $listed = $classnames; + + return array(); // Nothing to instantiate; the list itself is what is under test. + }, + 0 + ); + + ( new Main() )->autoload_classes(); + + return $listed; + } +}