diff --git a/inc/class-base-css.php b/inc/class-base-css.php index 8d804cc3b..146269f9b 100644 --- a/inc/class-base-css.php +++ b/inc/class-base-css.php @@ -384,6 +384,15 @@ public function get_animation_css( $blocks ) { return $style; } + if ( ! self::has_own_css_parser() ) { + // Another plugin loaded a different php-css-parser release; mixing its + // classes with the bundled ones fatals at class-link time (issue #2942). + // Skip the optimization — Blocks_Animation::frontend_load() serves the + // full stock stylesheet whenever this guard fails, so the animation + // rules never depend on the optimized fragment generated here. + return $style; + } + $prepared_classes = array( ':root' ); foreach ( $classes as $class ) { @@ -445,6 +454,74 @@ public function get_animation_css( $blocks ) { return $style; } + /** + * Check that every Sabberworm class the animation parser touches resolves to + * the copy bundled with this plugin. + * + * Another active plugin can ship a different php-css-parser release under the + * same global namespace. Once any of its classes or interfaces is loaded, + * loading the bundled counterparts fatals at class-link time with a + * declaration-compatibility error, and that error is not catchable. + * + * @return bool + */ + public static function has_own_css_parser() { + $own_vendor = wp_normalize_path( OTTER_BLOCKS_PATH . '/vendor/' ); + $prefix = 'Sabberworm\\CSS\\'; + + // Reject anything already in memory from a foreign copy first, before the + // sentinel checks below can autoload any bundled class: the parser touches + // more classes than the sentinels (OutputFormat, KeyFrame, the cached + // object graph...), and any preloaded foreign one poisons the process. + $declared = array_merge( get_declared_classes(), get_declared_interfaces(), get_declared_traits() ); + + foreach ( $declared as $declared_name ) { + // Case-insensitive: PHP class and namespace names are case-insensitive, + // so a foreign copy declared with different casing is the same class. + if ( 0 !== stripos( $declared_name, $prefix ) ) { + continue; + } + + if ( ! self::is_bundled_class( $declared_name, $own_vendor ) ) { + return false; + } + } + + // Entry points nothing may have loaded yet: whichever autoloader resolves + // them must serve the bundled copy. + $sentinels = array( + '\Sabberworm\CSS\Parser', + '\Sabberworm\CSS\Comment\Commentable', + '\Sabberworm\CSS\Renderable', + ); + + foreach ( $sentinels as $sentinel ) { + if ( ! class_exists( $sentinel ) && ! interface_exists( $sentinel ) ) { + return false; + } + + if ( ! self::is_bundled_class( $sentinel, $own_vendor ) ) { + return false; + } + } + + return true; + } + + /** + * Check that a class, interface, or trait was loaded from this plugin's vendor directory. + * + * @param string $name Fully qualified name. + * @param string $own_vendor Normalized path of this plugin's vendor directory. + * @return bool + */ + private static function is_bundled_class( $name, $own_vendor ) { + $reflection = new \ReflectionClass( $name ); + $file = $reflection->getFileName(); + + return false !== $file && 0 === strpos( wp_normalize_path( $file ), $own_vendor ); + } + /** * Get Animation Classes * diff --git a/inc/class-blocks-animation.php b/inc/class-blocks-animation.php index 78181b08c..badbffcdb 100644 --- a/inc/class-blocks-animation.php +++ b/inc/class-blocks-animation.php @@ -172,7 +172,15 @@ public function frontend_load( $block_content, $block ) { } if ( ! self::$scripts_loaded['animation'] && strpos( $block_content, 'animated' ) ) { - if ( ! defined( 'OTTER_BLOCKS_VERSION' ) || ( defined( 'OTTER_BLOCKS_VERSION' ) && ! get_option( 'themeisle_blocks_settings_optimize_animations_css', true ) ) ) { + // The stock stylesheet also serves pages whose generated CSS was built + // while a foreign php-css-parser copy blocked the optimization (issue + // #2942) — the cached CSS carries no animation rules, so this delivery + // must not depend on the optimized path having run. + if ( + ! defined( 'OTTER_BLOCKS_VERSION' ) || + ! get_option( 'themeisle_blocks_settings_optimize_animations_css', true ) || + ! Base_CSS::has_own_css_parser() + ) { wp_enqueue_style( 'otter-animation' ); } diff --git a/packages/e2e-tests/mu-plugins/includes/foreign-sabberworm-interface.php b/packages/e2e-tests/mu-plugins/includes/foreign-sabberworm-interface.php new file mode 100644 index 000000000..308bace7c --- /dev/null +++ b/packages/e2e-tests/mu-plugins/includes/foreign-sabberworm-interface.php @@ -0,0 +1,22 @@ + \WP_REST_Server::CREATABLE, + 'permission_callback' => __NAMESPACE__ . '\\require_admin', + 'callback' => function ( \WP_REST_Request $request ) { + $mode = $request->get_param( 'mode' ); + + if ( ! in_array( $mode, array( 'foreign', 'own' ), true ) ) { + return new \WP_Error( + 'otter_e2e_invalid_sabberworm_mode', + 'Mode must be "foreign" or "own".', + array( 'status' => 400 ) + ); + } + + if ( 'foreign' === $mode ) { + update_option( FOREIGN_SABBERWORM_OPTION, true, false ); + } else { + delete_option( FOREIGN_SABBERWORM_OPTION ); + } + + // Force the next frontend request through the parse path. + delete_transient( 'otter_animations_parsed' ); + + return rest_ensure_response( array( 'ok' => true ) ); + }, + ) + ); + register_rest_route( REST_NAMESPACE, '/widgets/seed', @@ -1497,6 +1548,7 @@ function () { delete_option( CAPTCHA_MODE_OPTION ); delete_option( OPENAI_STUB_OPTION ); delete_option( FS_BLOCKED_OPTION ); + delete_option( FOREIGN_SABBERWORM_OPTION ); cleanup_form_records(); return rest_ensure_response( array( 'ok' => true ) ); }, diff --git a/src/blocks/test/e2e/blocks/sabberworm-collision.spec.js b/src/blocks/test/e2e/blocks/sabberworm-collision.spec.js new file mode 100644 index 000000000..99f470a4f --- /dev/null +++ b/src/blocks/test/e2e/blocks/sabberworm-collision.spec.js @@ -0,0 +1,131 @@ +/** + * Internal dependencies + */ +import { test, expect } from '../fixtures'; + +/** + * Frontend animation-CSS coverage for https://github.com/Codeinwp/otter-blocks/issues/2942. + * + * When another plugin loads a different php-css-parser release, mixing its + * classes with Otter's bundled copy fatals at class-link time while + * Base_CSS::get_animation_css() parses the animation stylesheet. The scenario + * mu-plugin predefines the typed 9.x `Commentable` interface before plugins + * load; the page must still render, with the full stock animation stylesheet + * enqueued instead of the optimized inline subset — including on later + * requests served from the generated post-CSS cache, which carries no + * animation rules while the guard fails. + * + * Each test creates its own fresh post AFTER switching modes: the first + * singular view generates and caches the post CSS, and cached requests never + * reach the parser again. + * + * Serial project: flips a site-wide scenario flag that affects every request. + */ + +// The Progress Bar makes the generated post CSS non-empty, so the second +// request is served from the cached stylesheet file. +const FOREIGN_POST_CONTENT = ` +

Animated collision probe

+ + + +
Collision probe
+`; + +const OWN_POST_CONTENT = ` +

Animated collision probe

+`; + +test.describe( 'Sabberworm collision fallback', () => { + const createdPosts = []; + + const createProbePost = async( requestUtils, title, content ) => { + const post = await requestUtils.rest({ + method: 'POST', + path: '/wp/v2/posts', + data: { + status: 'publish', + title, + content + } + }); + + createdPosts.push( post.id ); + + // Plain query form: independent of the permalink structure. + return `/?p=${ post.id }`; + }; + + test.afterAll( async({ requestUtils }) => { + await requestUtils.rest({ + method: 'POST', + path: '/otter-e2e/v1/sabberworm', + data: { mode: 'own' } + }); + + // Only this spec's own posts — other specs run against the same site. + // Best-effort per post: one failed request must not orphan the rest. + while ( createdPosts.length ) { + const postId = createdPosts.pop(); + try { + await requestUtils.rest({ + method: 'DELETE', + path: `/wp/v2/posts/${ postId }`, + params: { force: true } + }); + } catch ( error ) { + console.warn( `Could not delete post ${ postId }:`, error.message ); + } + } + }); + + test( 'serves the full stylesheet when a foreign parser is loaded, also from the CSS cache', async({ page, otterUtils, requestUtils }) => { + await otterUtils.setSabberwormMode( 'foreign' ); + + try { + const postUrl = await createProbePost( requestUtils, 'Foreign parser probe', FOREIGN_POST_CONTENT ); + + const response = await page.goto( postUrl ); + + expect( response.status() ).toBe( 200 ); + + await expect( page.getByText( 'Animated collision probe' ) ).toBeVisible(); + + // Assert on the server response: the animation frontend script rewrites + // the block's classes in the live DOM once the animation plays. + const html = await response.text(); + expect( html ).toContain( 'animated fadeIn' ); + expect( html ).not.toContain( 'Fatal error' ); + expect( html ).not.toContain( 'must be compatible' ); + + // The optimization is skipped, so the stock stylesheet carries the animations. + expect( html ).toContain( 'otter-animation-css' ); + expect( html ).toMatch( /animation\/index\.css/ ); + + // The first request generated and cached the post CSS without animation + // rules; the fallback must survive requests served from that cache. + const cachedResponse = await page.goto( postUrl ); + const cachedHtml = await cachedResponse.text(); + expect( cachedHtml ).toContain( 'otter-animation-css' ); + expect( cachedHtml ).not.toContain( 'Fatal error' ); + } finally { + await otterUtils.setSabberwormMode( 'own' ); + } + }); + + test( 'inlines the optimized animation CSS with the bundled parser', async({ page, otterUtils, requestUtils }) => { + await otterUtils.setSabberwormMode( 'own' ); + + const postUrl = await createProbePost( requestUtils, 'Bundled parser probe', OWN_POST_CONTENT ); + + const response = await page.goto( postUrl ); + + await expect( page.getByText( 'Animated collision probe' ) ).toBeVisible(); + + // The optimized subset is served: the fadeIn keyframe is present without + // the full stock stylesheet. + const html = await response.text(); + expect( html ).toContain( '@keyframes fadeIn' ); + expect( html ).not.toContain( 'otter-animation-css' ); + }); +}); diff --git a/src/blocks/test/e2e/fixtures.ts b/src/blocks/test/e2e/fixtures.ts index dd13ff773..374f22a75 100644 --- a/src/blocks/test/e2e/fixtures.ts +++ b/src/blocks/test/e2e/fixtures.ts @@ -70,6 +70,13 @@ export type OtterUtils = { /** Remove the seeded widget, its CSS file/options, and the filesystem block. */ cleanupOtterWidget: () => Promise; + /** + * 'foreign' predefines a typed php-css-parser 9.x Commentable interface before + * plugins load (issue #2942 scenario); 'own' restores the bundled parser. + * Both modes clear the parsed-animations transient. + */ + setSabberwormMode: ( mode: 'foreign' | 'own' ) => Promise; + /** All stored Submission Records with their Delivery Status meta. */ getFormRecords: () => Promise; @@ -105,6 +112,7 @@ export const test = base.extend<{ otterUtils: OtterUtils }>({ setFilesystemMode: ( mode ) => call( 'filesystem', { mode }), seedOtterWidget: ( sidebar ) => call( 'widgets/seed', sidebar ? { sidebar } : undefined ), cleanupOtterWidget: () => call( 'widgets/cleanup' ), + setSabberwormMode: ( mode ) => call( 'sabberworm', { mode }), getFormRecords: () => call( 'form/records' ) as Promise, cleanupFormRecords: () => call( 'form/records/cleanup' ) }); diff --git a/src/blocks/test/e2e/playwright.config.js b/src/blocks/test/e2e/playwright.config.js index 79ec41825..7e8340d59 100644 --- a/src/blocks/test/e2e/playwright.config.js +++ b/src/blocks/test/e2e/playwright.config.js @@ -63,7 +63,10 @@ const SERIAL_SPECS = [ '**/blocks/widgets-css-frontend.spec.js', // Flips a site-wide flag that breaks Otter's autoloader for every request. - '**/blocks/autoloader-resilience.spec.js' + '**/blocks/autoloader-resilience.spec.js', + + // Flips a site-wide flag that injects a foreign Sabberworm interface for every request. + '**/blocks/sabberworm-collision.spec.js' ]; const config = defineConfig({ diff --git a/tests/php/foreign-sabberworm-sandbox.php b/tests/php/foreign-sabberworm-sandbox.php new file mode 100644 index 000000000..21c32becd --- /dev/null +++ b/tests/php/foreign-sabberworm-sandbox.php @@ -0,0 +1,92 @@ + 'core/paragraph', + 'attrs' => array( 'className' => 'animated fadeIn' ), + ), + ); + + $css = $base->get_animation_css( $blocks ); + + echo 'CSS_LENGTH:' . strlen( (string) $css ) . "\n"; + echo "REQUEST COMPLETED WITHOUT FATAL\n"; +} diff --git a/tests/test-animation-css.php b/tests/test-animation-css.php new file mode 100644 index 000000000..7bc8e236a --- /dev/null +++ b/tests/test-animation-css.php @@ -0,0 +1,93 @@ +> + */ + private function animated_blocks() { + return array( + array( + 'blockName' => 'core/paragraph', + 'attrs' => array( 'className' => 'animated fadeIn' ), + ), + ); + } + + /** + * With a foreign typed `Commentable` interface already loaded, parsing must be + * skipped in favor of the stock stylesheet — not fatal at class-link time. + * + * The collision poisons every later Sabberworm use in the process, so the + * scenario runs in a separate PHP process against a predefined 9.x interface. + */ + public function test_get_animation_css_falls_back_when_foreign_parser_is_loaded() { + $output = $this->run_sandbox( 'commentable' ); + + $this->assertStringContainsString( 'CSS_LENGTH:0', $output, 'The optimization should be skipped when a foreign parser is loaded: ' . $output ); + $this->assertStringNotContainsString( 'must be compatible', $output ); + } + + /** + * The guard must reject any preloaded foreign `Sabberworm\CSS` symbol, not + * only its sentinel entry points — here a foreign `OutputFormat` class. + */ + public function test_get_animation_css_falls_back_when_foreign_non_sentinel_class_is_loaded() { + $output = $this->run_sandbox( 'outputformat' ); + + $this->assertStringContainsString( 'CSS_LENGTH:0', $output, 'The optimization should be skipped when any foreign Sabberworm class is loaded: ' . $output ); + } + + /** + * Run the collision sandbox in a separate PHP process and assert it completes. + * + * @param string $scenario Sandbox scenario name. + * @return string Combined process output. + */ + private function run_sandbox( $scenario ) { + $sandbox = __DIR__ . '/php/foreign-sabberworm-sandbox.php'; + + $command = escapeshellarg( PHP_BINARY ) . ' -d display_errors=1 ' . escapeshellarg( $sandbox ) . ' ' . escapeshellarg( $scenario ) . ' 2>&1'; + + exec( $command, $output, $exit_code ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec + + $output = implode( "\n", $output ); + + $this->assertSame( 0, $exit_code, 'The sandbox request fataled instead of degrading gracefully: ' . $output ); + $this->assertStringContainsString( 'REQUEST COMPLETED WITHOUT FATAL', $output ); + + return $output; + } + + /** + * Sanity check: with only the bundled parser present, the guard passes and the + * optimized subset is produced. + */ + public function test_get_animation_css_parses_with_bundled_parser() { + $this->assertTrue( Base_CSS::has_own_css_parser() ); + + delete_transient( 'otter_animations_parsed' ); + + $css = ( new Base_CSS() )->get_animation_css( $this->animated_blocks() ); + + $this->assertStringContainsString( 'fadeIn', $css ); + $this->assertStringContainsString( '@keyframes', $css ); + } +}