Skip to content
Merged
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
72 changes: 72 additions & 0 deletions inc/class-base-css.php
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,14 @@ public function get_animation_css( $blocks ) {
return $style;
}

if ( ! self::has_own_css_parser() ) {
// A foreign php-css-parser release is loaded; parsing now fatals
// uncatchably at class-link time (#2942). The frontend loader serves
// the stock stylesheet instead.
error_log( '[Otter Blocks] A conflicting Sabberworm php-css-parser release is loaded; skipping animation CSS optimization and serving the stock stylesheet instead.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
return $style;
}
Comment on lines +387 to +393

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's also add here a log similar to #2956 (comment) to mark that something is preventing it from working correctly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 55690d3 — the collision fallback now logs [Otter Blocks] A conflicting Sabberworm php-css-parser release is loaded; skipping animation CSS optimization and serving the stock stylesheet instead. before returning, mirroring the autoload-skip notice from #2956, so a foreign parser release no longer looks like the feature silently stopped. Verified in the standalone sandbox (both commentable and outputformat scenarios) and via the Test_Animation_CSS PHPUnit suite (3 tests / 10 assertions green) — the positive parse path is unaffected.


$prepared_classes = array( ':root' );

foreach ( $classes as $class ) {
Expand Down Expand Up @@ -445,6 +453,70 @@ public function get_animation_css( $blocks ) {
return $style;
}

/**
* Check that every loaded Sabberworm\CSS symbol resolves to this plugin's copy.
*
* Another plugin can ship a different php-css-parser release under the same
* namespace. Once any of its classes loads, loading the bundled counterparts
* fatals at class-link time, 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 any foreign copy already in memory before the sentinel checks
// autoload a bundled class: the parser uses more classes than the
// sentinels, and one preloaded foreign symbol poisons the process.
$declared = array_merge( get_declared_classes(), get_declared_interfaces(), get_declared_traits() );

foreach ( $declared as $declared_name ) {
// PHP class names are case-insensitive; match a foreign copy in any casing.
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',
);
Comment on lines +487 to +491

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 237f9a9has_own_css_parser() now scans every already-declared class, interface, and trait under Sabberworm\CSS and rejects any that does not resolve to the bundled vendor directory, before the sentinel checks can autoload anything (which also addresses the ordering concern about Parser loading ahead of the interface check). The sentinels remain only as forced entry-point resolution for the nothing-loaded-yet case. Regression coverage added: the sandbox's new outputformat scenario preloads a foreign non-sentinel class — it fatals against the sentinel-only guard and passes now.


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
*
Expand Down
8 changes: 7 additions & 1 deletion inc/class-blocks-animation.php
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,13 @@ 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 ) ) ) {
// Foreign-parser pages (#2942) cache post-CSS with no animation rules,
// so deliver the stock stylesheet here rather than via the parser path.
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' );
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php
/**
* Typed `Commentable` interface as shipped by php-css-parser 9.x.
*
* Lives in a subdirectory so WordPress does not auto-load it as an mu-plugin;
* otter-e2e-bootstrap.php requires it only while the foreign-Sabberworm
* scenario (issue #2942) is armed. Once defined, loading Otter's bundled
* untyped CSSList fatals at class-link time — exactly like a second plugin
* shipping a newer parser release.
*
* @package otter-blocks
*/

// phpcs:ignoreFile -- deliberately mirrors the upstream 9.x signatures.

namespace Sabberworm\CSS\Comment;

interface Commentable {
public function addComments( array $comments ): void;
public function getComments(): array;
public function setComments( array $comments ): void;
}
52 changes: 52 additions & 0 deletions packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,14 @@
*/
const BROKEN_AUTOLOADER_OPTION = 'otter_e2e_broken_autoloader';

/**
* When truthy, a typed php-css-parser 9.x `Commentable` interface is defined before
* any plugin loads, reproducing a second plugin shipping a different Sabberworm
* release (issue #2942). Otter must skip its animation-CSS optimization instead
* of fataling while loading its bundled untyped classes.
*/
const FOREIGN_SABBERWORM_OPTION = 'otter_e2e_foreign_sabberworm';

/**
* Form record post type, mirrored from \ThemeIsle\GutenbergBlocks\Plugins\Form_Submissions.
*/
Expand Down Expand Up @@ -835,6 +843,18 @@ function break_otter_autoloader( $classnames ) {

add_filter( 'otter_blocks_autoloader', __NAMESPACE__ . '\\break_otter_autoloader' );

// Foreign-Sabberworm scenario (issue #2942): define the typed 9.x interface before
// any plugin code runs, like a competing plugin's autoloader would. The scenario
// endpoints stay exempt so a spec can always disarm the flag, even against code
// where the armed frontend fatals.
if ( get_option( FOREIGN_SABBERWORM_OPTION, false ) ) {
$otter_e2e_request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';

if ( false === strpos( $otter_e2e_request_uri, REST_NAMESPACE ) ) {
require __DIR__ . '/includes/foreign-sabberworm-interface.php';
}
}

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 Expand Up @@ -1446,6 +1466,37 @@ function () {
)
);

register_rest_route(
REST_NAMESPACE,
'/sabberworm',
array(
'methods' => \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',
Expand Down Expand Up @@ -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 ) );
},
Expand Down
131 changes: 131 additions & 0 deletions src/blocks/test/e2e/blocks/sabberworm-collision.spec.js
Original file line number Diff line number Diff line change
@@ -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 = `<!-- wp:paragraph {"className":"animated fadeIn"} -->
<p class="animated fadeIn">Animated collision probe</p>
<!-- /wp:paragraph -->

<!-- wp:themeisle-blocks/progress-bar {"id":"wp-block-themeisle-blocks-progress-bar-e2e2942","title":"Collision probe","percentage":75,"titleColor":"#123abc","height":36} -->
<div id="wp-block-themeisle-blocks-progress-bar-e2e2942" class="wp-block-themeisle-blocks-progress-bar"><div class="wp-block-themeisle-blocks-progress-bar__title">Collision probe</div><div class="wp-block-themeisle-blocks-progress-bar__area"><div class="wp-block-themeisle-blocks-progress-bar__area__bar"></div></div></div>
<!-- /wp:themeisle-blocks/progress-bar -->`;

const OWN_POST_CONTENT = `<!-- wp:paragraph {"className":"animated fadeIn"} -->
<p class="animated fadeIn">Animated collision probe</p>
<!-- /wp:paragraph -->`;

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' );
});
});
8 changes: 8 additions & 0 deletions src/blocks/test/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ export type OtterUtils = {
/** Remove the seeded widget, its CSS file/options, and the filesystem block. */
cleanupOtterWidget: () => Promise<unknown>;

/**
* '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<unknown>;

/** All stored Submission Records with their Delivery Status meta. */
getFormRecords: () => Promise<FormRecord[]>;

Expand Down Expand Up @@ -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<FormRecord[]>,
cleanupFormRecords: () => call( 'form/records/cleanup' )
});
Expand Down
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 @@ -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({
Expand Down
Loading
Loading