Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions inc/class-autoloader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php
/**
* Fallback autoloader for the plugin's own classes.
*
* @package ThemeIsle
*/

namespace ThemeIsle\GutenbergBlocks;

/**
* Class Autoloader
*
* Composer resolves `ThemeIsle\GutenbergBlocks\*` through the classmap it generates
* into `vendor/`. When that map does not match the files on disk — an interrupted
* plugin update, an OPcache entry compiled from the previous version — a class that
* is present becomes unloadable. This loader resolves such a class from its file
* name instead. It is registered after Composer, so it only runs when Composer has
* no answer for the class.
*/
class Autoloader {

/**
* Namespace this loader answers for.
*/
const PREFIX = 'ThemeIsle\\GutenbergBlocks\\';

/**
* Append the loader to the SPL autoload stack.
*
* @return void
*/
public static function register() {
spl_autoload_register( array( __CLASS__, 'load' ) );
}

/**
* Load a class from the file its name maps to.
*
* @param string $class_name Fully-qualified class name.
* @return void
*/
public static function load( $class_name ) {
$file = self::path_for( $class_name );

if ( false !== $file ) {
require_once $file;
}
}

/**
* Existing file for a class name, following the WordPress file naming convention.
*
* `ThemeIsle\GutenbergBlocks\Plugins\Atomic_Wind_Blocks` maps to
* `inc/plugins/class-atomic-wind-blocks.php`.
*
* @param string $class_name Fully-qualified class name.
* @return string|false Readable file path, or false when the class is not ours or has no file.
*/
public static function path_for( $class_name ) {
if ( 0 !== strpos( $class_name, self::PREFIX ) ) {
return false;
}

$relative = strtolower(
str_replace(
array( '\\', '_' ),
array( '/', '-' ),
substr( $class_name, strlen( self::PREFIX ) )
)
);

$separator = strrpos( $relative, '/' );
$relative = false === $separator
? 'class-' . $relative
: substr( $relative, 0, $separator + 1 ) . 'class-' . substr( $relative, $separator + 1 );

foreach ( self::candidates( $relative ) as $file ) {
if ( is_readable( $file ) ) {
return $file;
}
}

return false;
}

/**
* Files a relative class path can live in.
*
* @param string $relative Class path relative to `inc/`, without extension.
* @return array<int, string>
*/
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;
}
}
5 changes: 5 additions & 0 deletions inc/class-main.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ) ) {
Expand Down
5 changes: 5 additions & 0 deletions otter-blocks.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
33 changes: 33 additions & 0 deletions packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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<int, string> $classnames Classes Otter initializes on `init`.
* @return array<int, string>
*/
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 );

Expand Down
43 changes: 43 additions & 0 deletions src/blocks/test/e2e/blocks/autoloader-resilience.spec.js
Original file line number Diff line number Diff line change
@@ -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: '<!-- wp:themeisle-blocks/posts-grid /-->',
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();
});
});
5 changes: 4 additions & 1 deletion src/blocks/test/e2e/playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
155 changes: 155 additions & 0 deletions tests/test-main-autoload.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<?php
/**
* Tests for Main::autoload_classes().
*
* @package gutenberg-blocks
*/

use ThemeIsle\GutenbergBlocks\Autoloader;
use ThemeIsle\GutenbergBlocks\Main;

/**
* Probe class instantiated through the `otter_blocks_autoloader` filter.
*/
class Otter_Autoload_Probe {
/**
* Set when the autoloader instantiates this class.
*
* @var bool
*/
public static $instantiated = false;

/**
* Constructor.
*/
public function __construct() {
self::$instantiated = true;
}
}

/**
* Main autoloader test case.
*/
class TestMainAutoload extends WP_UnitTestCase {

/**
* Tear down each test.
*/
public function tear_down() {
remove_all_filters( 'otter_blocks_autoloader' );
Otter_Autoload_Probe::$instantiated = false;
parent::tear_down();
}

/**
* A listed class that cannot be loaded must not fatal the request, and must not stop the rest of the list.
*/
public function test_autoload_classes_skips_unavailable_class() {
add_filter(
'otter_blocks_autoloader',
function () {
return array(
'\ThemeIsle\GutenbergBlocks\Plugins\Definitely_Missing_Class',
'Otter_Autoload_Probe',
);
}
);

( new Main() )->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<int, string>
*/
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;
}
}
Loading