diff --git a/.wp-env.json b/.wp-env.json
index ecc59bbe2..e9f3eebbc 100644
--- a/.wp-env.json
+++ b/.wp-env.json
@@ -6,7 +6,10 @@
".",
"https://downloads.wordpress.org/plugin/ai-provider-for-openai.zip"
],
- "themes": [ "./test/emptytheme" ],
+ "themes": [
+ "./test/emptytheme",
+ "https://downloads.wordpress.org/theme/twentytwentyone.zip"
+ ],
"config": {
"WP_DEBUG": true,
"WP_DEBUG_LOG": true,
@@ -17,7 +20,8 @@
},
"mappings": {
"wp-content/mu-plugins": "./packages/e2e-tests/mu-plugins",
- "wp-content/themes/raft": "https://downloads.wordpress.org/theme/raft.zip"
+ "wp-content/themes/raft": "https://downloads.wordpress.org/theme/raft.zip",
+ "wp-content/plugins/woocommerce": "./vendor/wp-content/plugins/woocommerce"
},
"lifecycleScripts": {
"afterStart": "bash bin/e2e-tests.sh"
diff --git a/composer.lock b/composer.lock
index c3be5643a..f81925300 100644
--- a/composer.lock
+++ b/composer.lock
@@ -3113,15 +3113,15 @@
},
{
"name": "wpackagist-plugin/woocommerce",
- "version": "10.1.2",
+ "version": "10.9.4",
"source": {
"type": "svn",
"url": "https://plugins.svn.wordpress.org/woocommerce/",
- "reference": "tags/10.1.2"
+ "reference": "tags/10.9.4"
},
"dist": {
"type": "zip",
- "url": "https://downloads.wordpress.org/plugin/woocommerce.10.1.2.zip"
+ "url": "https://downloads.wordpress.org/plugin/woocommerce.10.9.4.zip"
},
"require": {
"composer/installers": "^1.0 || ^2.0"
diff --git a/inc/class-base-css.php b/inc/class-base-css.php
index 8d804cc3b..0739628ba 100644
--- a/inc/class-base-css.php
+++ b/inc/class-base-css.php
@@ -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;
+ }
+
$prepared_classes = array( ':root' );
foreach ( $classes as $class ) {
@@ -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',
+ );
+
+ 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..3bc12d174 100644
--- a/inc/class-blocks-animation.php
+++ b/inc/class-blocks-animation.php
@@ -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' );
}
diff --git a/inc/class-main.php b/inc/class-main.php
index f4288c356..39d1aed7d 100644
--- a/inc/class-main.php
+++ b/inc/class-main.php
@@ -94,6 +94,12 @@ 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 ) ) {
+ error_log( '[Otter Blocks] Skipped an autoload entry that could not be loaded: ' . ( is_string( $classname ) ? $classname : gettype( $classname ) ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+ continue;
+ }
+
$classname = new $classname();
if ( method_exists( $classname, 'instance' ) ) {
diff --git a/inc/css/class-css-handler.php b/inc/css/class-css-handler.php
index db96b5832..41afb4ecd 100644
--- a/inc/css/class-css-handler.php
+++ b/inc/css/class-css-handler.php
@@ -496,18 +496,17 @@ public static function save_widgets_styles() {
public static function is_writable() {
global $wp_filesystem;
include_once ABSPATH . 'wp-admin/includes/file.php';
- WP_Filesystem();
-
- $wp_upload_dir = wp_upload_dir( null, false );
- $upload_dir = $wp_upload_dir['basedir'];
if ( ! function_exists( 'WP_Filesystem' ) ) {
return false;
}
+ $wp_upload_dir = wp_upload_dir( null, false );
+ $upload_dir = $wp_upload_dir['basedir'];
+
$writable = WP_Filesystem( false, $upload_dir );
- return $writable && 'direct' === $wp_filesystem->method;
+ return $writable && $wp_filesystem instanceof \WP_Filesystem_Base && 'direct' === $wp_filesystem->method;
}
/**
diff --git a/inc/plugins/class-dynamic-content.php b/inc/plugins/class-dynamic-content.php
index 1924d6474..b782417fd 100644
--- a/inc/plugins/class-dynamic-content.php
+++ b/inc/plugins/class-dynamic-content.php
@@ -545,7 +545,7 @@ public function get_content( $data ) {
return '';
}
- $content = get_the_content( $data['context'] );
+ $content = get_the_content( null, false, $data['context'] );
$content = apply_filters( 'the_content', str_replace( ']]>', ']]>', $content ) );
return wp_kses_post( $content );
}
@@ -902,7 +902,7 @@ class_exists( '\Neve_Pro\Modules\Custom_Layouts\Module' )
if ( ! $post instanceof \WP_Post ) {
return $data;
}
- $content = get_the_content( $data['context'] );
+ $content = get_the_content( null, false, $data['context'] );
if ( strpos( $content, 'data-type="postContent"' ) ) {
$key = $this->get_exception_key( $data, $post->ID );
if ( $key ) {
diff --git a/inc/render/class-stripe-checkout-block.php b/inc/render/class-stripe-checkout-block.php
index 64efbf819..b456ccb72 100644
--- a/inc/render/class-stripe-checkout-block.php
+++ b/inc/render/class-stripe-checkout-block.php
@@ -15,6 +15,13 @@
* Class Stripe_Checkout_Block
*/
class Stripe_Checkout_Block {
+ /**
+ * Transient prefix for the cached checkout mode of a price.
+ *
+ * @var string
+ */
+ const PRICE_MODE_CACHE_PREFIX = 'otter_stripe_price_mode_';
+
/**
* Stripe API instance.
*
@@ -56,8 +63,8 @@ public function watch_checkout() {
$product_id = isset( $_GET['product_id'] ) ? sanitize_text_field( wp_unslash( $_GET['product_id'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$price_id = isset( $_GET['price_id'] ) ? sanitize_text_field( wp_unslash( $_GET['price_id'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
- $url = isset( $_GET['url'] ) ? sanitize_text_field( wp_unslash( $_GET['url'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
- $mode = isset( $_GET['mode'] ) ? sanitize_text_field( wp_unslash( $_GET['mode'] ) ) : 'payment'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $url = isset( $_GET['url'] ) ? sanitize_url( wp_unslash( $_GET['url'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( empty( $product_id ) || empty( $price_id ) || empty( $url ) ) {
return sprintf(
@@ -66,12 +73,19 @@ public function watch_checkout() {
);
}
+ if ( ! hash_equals( self::get_checkout_token( $product_id, $price_id ), $token ) ) {
+ return sprintf(
+ '
',
+ __( 'An error occurred! Could not retrieve the product information!', 'otter-blocks' )
+ );
+ }
+
$permalink = add_query_arg(
array(
'stripe_session_id' => '{CHECKOUT_SESSION_ID}',
'product_id' => $product_id,
),
- $url
+ $this->get_return_url( $url )
);
$session = $this->stripe_api->create_request(
@@ -85,7 +99,7 @@ public function watch_checkout() {
'quantity' => 1,
),
),
- 'mode' => $mode,
+ 'mode' => $this->get_mode_for_price( $price_id ),
)
);
@@ -165,17 +179,22 @@ public function render( $attributes ) {
$details_markup .= '' . $currency . $amount . '
';
$details_markup .= '';
- $mode = 'recurring' === $price['type'] ? 'subscription' : 'payment';
+ // A widget area or an FSE template has no permalink of its own, so fall back to the current URL.
+ $return_url = get_permalink();
+
+ if ( ! is_string( $return_url ) || '' === $return_url ) {
+ $return_url = home_url( add_query_arg( array() ) );
+ }
$session_url = add_query_arg(
array(
'action' => 'buy_stripe',
'product_id' => $attributes['product'],
'price_id' => $attributes['price'],
- 'url' => get_permalink(),
- 'mode' => $mode,
+ 'url' => $return_url,
+ 'token' => self::get_checkout_token( $attributes['product'], $attributes['price'] ),
),
- get_permalink()
+ $return_url
);
$button_markup = '' . __( 'Checkout', 'otter-blocks' ) . '';
@@ -188,6 +207,61 @@ public function render( $attributes ) {
);
}
+ /**
+ * Sign a product/price pair so the checkout can verify it was offered by a block.
+ *
+ * @param string $product_id Stripe product ID.
+ * @param string $price_id Stripe price ID.
+ * @return string
+ */
+ public static function get_checkout_token( $product_id, $price_id ) {
+ return hash_hmac( 'sha256', $product_id . '|' . $price_id, wp_salt( 'otter_stripe' ) );
+ }
+
+ /**
+ * Get the URL Stripe returns the buyer to, restricted to this site.
+ *
+ * @param string $url Requested return URL.
+ * @return string
+ */
+ private function get_return_url( $url ) {
+ $host = wp_parse_url( $url, PHP_URL_HOST );
+ $home = wp_parse_url( home_url(), PHP_URL_HOST );
+
+ if ( $home !== $host ) {
+ return home_url( '/' );
+ }
+
+ return $url;
+ }
+
+ /**
+ * Get the checkout session mode for a price.
+ *
+ * @param string $price_id Stripe price ID.
+ * @return string
+ */
+ private function get_mode_for_price( $price_id ) {
+ $cache_key = self::PRICE_MODE_CACHE_PREFIX . md5( $price_id );
+ $cached = get_transient( $cache_key );
+
+ if ( 'payment' === $cached || 'subscription' === $cached ) {
+ return $cached;
+ }
+
+ $price = $this->stripe_api->create_request( 'price', $price_id );
+
+ if ( is_wp_error( $price ) || ! isset( $price['type'] ) ) {
+ return 'payment';
+ }
+
+ $mode = 'recurring' === $price['type'] ? 'subscription' : 'payment';
+
+ set_transient( $cache_key, $mode, WEEK_IN_SECONDS );
+
+ return $mode;
+ }
+
/**
* Format the error message.
*
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 @@
+.
+ */
+const FS_BLOCKED_OPTION = 'otter_e2e_fs_blocked';
+
+/**
+ * Numeric index used for the seeded block widget instance (widget id `block-999`).
+ */
+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';
+
+/**
+ * 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.
*/
@@ -741,6 +770,91 @@ function stub_openai_http_for_e2e( $preempt, $parsed_args, $url ) {
return stub_openai_http_response( $content );
}
+/**
+ * Issue #2929 rig: with ?otter_e2e_corrupt_pages=1, mimic a theme/plugin that
+ * clobbers the $pages loop global (main templates are included at global scope,
+ * so any template-level $pages variable overwrites it) while Otter evaluates a
+ * dynamic tag, and surface PHP notices in the output so the spec can assert
+ * none are emitted.
+ *
+ * The corruption is scoped to blocks carrying an tag and restored
+ * straight after Otter's filter (priority 10): leaving it in place for every
+ * block would make core's own the_content() warn too, which is core behavior
+ * rather than the bug under test.
+ */
+function corrupt_pages_around_dynamic_tags() {
+ if ( is_admin() || ! isset( $_GET['otter_e2e_corrupt_pages'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ return;
+ }
+
+ ini_set( 'display_errors', '1' ); // phpcs:ignore WordPress.PHP.IniSet.display_errors_Disallowed
+
+ $saved = null;
+
+ add_filter(
+ 'render_block',
+ function ( $block_content ) use ( &$saved ) {
+ if ( false !== strpos( $block_content, ' $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' );
+
+// 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 );
@@ -941,6 +1055,104 @@ function stub_captcha_http_verification_for_e2e( $preempt, $request, $url ) {
add_filter( 'pre_http_request', __NAMESPACE__ . '\\stub_captcha_http_verification_for_e2e', 10, 3 );
+/**
+ * Report an unknown filesystem method while the fs-blocked scenario is active.
+ *
+ * WP_Filesystem() then fails to locate the abstraction class file and bails out
+ * without initializing `$wp_filesystem`, which is the closest reproducible
+ * stand-in for the production condition behind issue #2937.
+ *
+ * @param string $method Detected filesystem method.
+ * @return string
+ */
+function block_filesystem_method_for_e2e( $method ) {
+ return get_option( FS_BLOCKED_OPTION ) ? 'otter_e2e_blocked' : $method;
+}
+
+add_filter( 'filesystem_method', __NAMESPACE__ . '\\block_filesystem_method_for_e2e', PHP_INT_MAX );
+
+/**
+ * Seed a classic-widgets sidebar with an Otter block so the frontend
+ * widgets-CSS path (Block_Frontend::enqueue_widgets_css) is exercised.
+ *
+ * Clears the generated-stylesheet options so the next frontend request takes
+ * the "no CSS file yet" branch that calls CSS_Handler::is_writable().
+ *
+ * @param string $sidebar_id Sidebar to place the widget in.
+ * @return void
+ */
+function seed_otter_widget( $sidebar_id ) {
+ $markup = '' . "\n" .
+ '' . "\n" .
+ '';
+
+ $widget_blocks = get_option( 'widget_block', array() );
+
+ if ( ! is_array( $widget_blocks ) ) {
+ $widget_blocks = array();
+ }
+
+ $widget_blocks[ WIDGET_SEED_INDEX ] = array( 'content' => $markup );
+ $widget_blocks['_multiwidget'] = 1;
+ update_option( 'widget_block', $widget_blocks );
+
+ $sidebars = get_option( 'sidebars_widgets', array() );
+
+ if ( ! is_array( $sidebars ) ) {
+ $sidebars = array();
+ }
+
+ $existing = isset( $sidebars[ $sidebar_id ] ) && is_array( $sidebars[ $sidebar_id ] ) ? $sidebars[ $sidebar_id ] : array();
+
+ $sidebars[ $sidebar_id ] = array_values( array_unique( array_merge( array( 'block-' . WIDGET_SEED_INDEX ), $existing ) ) );
+ update_option( 'sidebars_widgets', $sidebars );
+
+ delete_option( 'themeisle_blocks_widgets_css_file' );
+ delete_option( 'themeisle_blocks_widgets_css' );
+ delete_option( 'themeisle_blocks_widgets_fonts' );
+}
+
+/**
+ * Remove the seeded widget and every widgets-CSS artifact it produced.
+ *
+ * @return void
+ */
+function cleanup_otter_widget() {
+ $widget_blocks = get_option( 'widget_block', array() );
+
+ if ( is_array( $widget_blocks ) && isset( $widget_blocks[ WIDGET_SEED_INDEX ] ) ) {
+ unset( $widget_blocks[ WIDGET_SEED_INDEX ] );
+ update_option( 'widget_block', $widget_blocks );
+ }
+
+ $sidebars = get_option( 'sidebars_widgets', array() );
+
+ if ( is_array( $sidebars ) ) {
+ foreach ( $sidebars as $sidebar_id => $widgets ) {
+ if ( is_array( $widgets ) ) {
+ $sidebars[ $sidebar_id ] = array_values( array_diff( $widgets, array( 'block-' . WIDGET_SEED_INDEX ) ) );
+ }
+ }
+ update_option( 'sidebars_widgets', $sidebars );
+ }
+
+ $file_name = get_option( 'themeisle_blocks_widgets_css_file' );
+
+ if ( $file_name ) {
+ $wp_upload_dir = wp_upload_dir( null, false );
+ $file_path = $wp_upload_dir['basedir'] . '/themeisle-gutenberg/' . $file_name . '.css';
+
+ if ( is_file( $file_path ) ) {
+ wp_delete_file( $file_path );
+ }
+ }
+
+ delete_option( 'themeisle_blocks_widgets_css_file' );
+ delete_option( 'themeisle_blocks_widgets_css' );
+ delete_option( 'themeisle_blocks_widgets_fonts' );
+ delete_option( FS_BLOCKED_OPTION );
+}
+
add_action(
'rest_api_init',
function () {
@@ -1226,6 +1438,197 @@ function () {
)
);
+ register_rest_route(
+ REST_NAMESPACE,
+ '/woo/product',
+ array(
+ 'methods' => \WP_REST_Server::CREATABLE,
+ 'permission_callback' => __NAMESPACE__ . '\\require_admin',
+ 'callback' => function ( \WP_REST_Request $request ) {
+ if ( ! class_exists( 'WC_Product_Simple' ) ) {
+ return new \WP_Error(
+ 'otter_e2e_no_woocommerce',
+ 'WooCommerce is not active in the environment.',
+ array( 'status' => 500 )
+ );
+ }
+
+ // The spec activates WooCommerce right before this call; drop
+ // the redirect it schedules so it cannot hijack admin visits.
+ delete_transient( '_wc_activation_redirect' );
+
+ $product = new \WC_Product_Simple();
+ $product->set_name( $request->get_param( 'title' ) ? sanitize_text_field( $request->get_param( 'title' ) ) : 'E2E Product' );
+ $product->set_regular_price( '49.99' );
+ $product->set_status( 'publish' );
+ $id = $product->save();
+
+ if ( rest_sanitize_boolean( $request->get_param( 'builder' ) ) ) {
+ update_post_meta( $id, '_themeisle_gutenberg_woo_builder', true );
+ }
+
+ return rest_ensure_response( array( 'id' => $id ) );
+ },
+ )
+ );
+
+ register_rest_route(
+ REST_NAMESPACE,
+ '/woo/product/delete',
+ array(
+ 'methods' => \WP_REST_Server::CREATABLE,
+ 'permission_callback' => __NAMESPACE__ . '\\require_admin',
+ 'callback' => function ( \WP_REST_Request $request ) {
+ $deleted = array();
+
+ foreach ( (array) $request->get_param( 'ids' ) as $id ) {
+ $id = absint( $id );
+
+ // Only ever remove products, so a stale id from a spec
+ // cannot delete unrelated content in a reused env.
+ if ( 0 === $id || 'product' !== get_post_type( $id ) ) {
+ continue;
+ }
+
+ wp_delete_post( $id, true );
+ $deleted[] = $id;
+ }
+
+ return rest_ensure_response( array( 'deleted' => $deleted ) );
+ },
+ )
+ );
+
+ register_rest_route(
+ REST_NAMESPACE,
+ '/user/meta-box-order',
+ array(
+ 'methods' => \WP_REST_Server::CREATABLE,
+ 'permission_callback' => __NAMESPACE__ . '\\require_admin',
+ 'callback' => function ( \WP_REST_Request $request ) {
+ $order = $request->get_param( 'order' );
+
+ if ( ! is_array( $order ) || empty( $order ) ) {
+ delete_user_meta( get_current_user_id(), 'meta-box-order_product' );
+ } else {
+ update_user_meta( get_current_user_id(), 'meta-box-order_product', array_map( 'sanitize_text_field', $order ) );
+ }
+
+ return rest_ensure_response( array( 'ok' => true ) );
+ },
+ )
+ );
+
+ register_rest_route(
+ REST_NAMESPACE,
+ '/user/meta-boxes-pane/reset',
+ array(
+ 'methods' => \WP_REST_Server::CREATABLE,
+ 'permission_callback' => __NAMESPACE__ . '\\require_admin',
+ 'callback' => function () {
+ $user_id = get_current_user_id();
+ $meta_key = $GLOBALS['wpdb']->get_blog_prefix() . 'persisted_preferences';
+ $preferences = get_user_meta( $user_id, $meta_key, true );
+
+ if ( is_array( $preferences ) && isset( $preferences['core/edit-post'] ) ) {
+ unset(
+ $preferences['core/edit-post']['metaBoxesMainIsOpen'],
+ $preferences['core/edit-post']['metaBoxesMainOpenHeight']
+ );
+ update_user_meta( $user_id, $meta_key, $preferences );
+ }
+
+ return rest_ensure_response( array( 'ok' => true ) );
+ },
+ )
+ );
+
+ register_rest_route(
+ REST_NAMESPACE,
+ '/filesystem',
+ 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( 'blocked', 'ok' ), true ) ) {
+ return new \WP_Error(
+ 'otter_e2e_invalid_fs_mode',
+ 'Mode must be "blocked" or "ok".',
+ array( 'status' => 400 )
+ );
+ }
+
+ if ( 'blocked' === $mode ) {
+ update_option( FS_BLOCKED_OPTION, true, false );
+ } else {
+ delete_option( FS_BLOCKED_OPTION );
+ }
+
+ return rest_ensure_response( array( 'ok' => true ) );
+ },
+ )
+ );
+
+ 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',
+ array(
+ 'methods' => \WP_REST_Server::CREATABLE,
+ 'permission_callback' => __NAMESPACE__ . '\\require_admin',
+ 'callback' => function ( \WP_REST_Request $request ) {
+ $sidebar_id = $request->get_param( 'sidebar' );
+ seed_otter_widget( is_string( $sidebar_id ) && '' !== $sidebar_id ? $sidebar_id : 'sidebar-1' );
+ return rest_ensure_response( array( 'ok' => true ) );
+ },
+ )
+ );
+
+ register_rest_route(
+ REST_NAMESPACE,
+ '/widgets/cleanup',
+ array(
+ 'methods' => \WP_REST_Server::CREATABLE,
+ 'permission_callback' => __NAMESPACE__ . '\\require_admin',
+ 'callback' => function () {
+ cleanup_otter_widget();
+ return rest_ensure_response( array( 'ok' => true ) );
+ },
+ )
+ );
+
register_rest_route(
REST_NAMESPACE,
'/reset',
@@ -1249,6 +1652,8 @@ function () {
delete_option( MAIL_LOG_OPTION );
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/plugins/otter-pro/inc/plugins/class-woocommerce-builder.php b/plugins/otter-pro/inc/plugins/class-woocommerce-builder.php
index cdfdf59e0..20b379015 100644
--- a/plugins/otter-pro/inc/plugins/class-woocommerce-builder.php
+++ b/plugins/otter-pro/inc/plugins/class-woocommerce-builder.php
@@ -30,6 +30,65 @@ public function init() {
add_filter( 'wc_get_template_part', array( $this, 'wc_get_template_part' ), 1000, 3 );
add_action( 'otter_blocks_woocommerce_content', 'the_content' );
add_filter( 'body_class', array( $this, 'add_body_class' ), 1000, 1 );
+ add_action( 'enqueue_block_editor_assets', array( $this, 'show_meta_boxes_pane' ) );
+ add_filter( 'get_user_option_meta-box-order_product', array( $this, 'restore_product_data_location' ) );
+ }
+
+ /**
+ * Keep the Product data metabox out of the narrow side column.
+ *
+ * The metabox "Move up/down" arrows persist the order instantly and, at the
+ * edge of an area, relocate a box into the adjacent area. From the block
+ * editor (used by WooCommerce Builder products) one accidental click can
+ * move WooCommerce's Product data box into "side", where it renders inside
+ * the ~280px sidebar and its layout breaks. Correct it at read time; the
+ * stored user option is left untouched.
+ *
+ * @param mixed $order Saved metabox order for the product screen.
+ *
+ * @access public
+ * @return mixed
+ */
+ public function restore_product_data_location( $order ) {
+ if ( ! boolval( get_post_meta( get_the_ID(), '_themeisle_gutenberg_woo_builder', true ) ) ) {
+ return $order;
+ }
+
+ if ( ! is_array( $order ) || ! isset( $order['side'] ) || false === strpos( $order['side'], 'woocommerce-product-data' ) ) {
+ return $order;
+ }
+
+ $side = array_diff( explode( ',', $order['side'] ), array( 'woocommerce-product-data' ) );
+ $normal = empty( $order['normal'] ) ? array() : explode( ',', $order['normal'] );
+ array_unshift( $normal, 'woocommerce-product-data' );
+
+ $order['side'] = implode( ',', $side );
+ $order['normal'] = implode( ',', array_unique( $normal ) );
+
+ return $order;
+ }
+
+ /**
+ * Keep the Meta Boxes pane open by default in the block editor.
+ *
+ * Since WP 6.7 the iframed post editor renders meta boxes inside a bottom
+ * drawer that is collapsed unless the user opened it before. On builder
+ * products that hides the WooCommerce Product data panel (price, inventory
+ * etc.), so default the drawer to open. An explicit user preference is not
+ * overridden, as setDefaults only applies to unset preferences.
+ *
+ * @access public
+ * @return void
+ */
+ public function show_meta_boxes_pane() {
+ if ( 'product' !== get_post_type() || ! boolval( get_post_meta( get_the_ID(), '_themeisle_gutenberg_woo_builder', true ) ) ) {
+ return;
+ }
+
+ wp_add_inline_script(
+ 'wp-edit-post',
+ 'window.wp && wp.data && wp.data.dispatch( "core/preferences" ).setDefaults( "core/edit-post", { metaBoxesMainIsOpen: true } );'
+ );
}
/**
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/blocks/dynamic-content-frontend.spec.js b/src/blocks/test/e2e/blocks/dynamic-content-frontend.spec.js
new file mode 100644
index 000000000..039a42c91
--- /dev/null
+++ b/src/blocks/test/e2e/blocks/dynamic-content-frontend.spec.js
@@ -0,0 +1,93 @@
+/**
+ * WordPress dependencies
+ */
+import { test, expect } from '@wordpress/e2e-test-utils-playwright';
+
+/**
+ * Frontend rendering of the postContent dynamic tag (issue #2929).
+ *
+ * The tag's context post ID used to be passed to get_the_content() as
+ * $more_link_text, so core fell back to the loop globals: a clobbered $pages
+ * global surfaced "Undefined array key -1" from post-template.php and the tag
+ * rendered empty.
+ */
+test.describe( 'Dynamic Content postContent tag', () => {
+
+ // wp-env is persistent, so the fixtures are namespaced per run and torn down
+ // in afterAll: a fixed token would let leftovers from an earlier run (or a
+ // retry) win the Query Loop and make the assertions state-dependent.
+ let token;
+ let targetContent;
+ let pageId;
+
+ // Every record created by this spec, so a retried beforeAll cleans up both
+ // attempts instead of leaking the first one.
+ const created = [];
+
+ test.beforeAll( async({ requestUtils }) => {
+ token = `frontier2929${ Date.now() }`;
+ targetContent = `Otter dynamic target content ${ token }`;
+
+ // The Query Loop block has no include/post__in arg, so the loop is
+ // scoped to the target post via the run's search token in its title.
+ const target = await requestUtils.createPost({
+ title: `Dynamic content target ${ token }`,
+ content: `${ targetContent }
`,
+ status: 'publish'
+ });
+
+ created.push({ type: 'posts', id: target.id });
+
+ const holder = await requestUtils.createPage({
+ title: `Dynamic content holder ${ token }`,
+ // The tag is wrapped in a group: postContent runs the_content, which
+ // wraps its output in , and a nested
would be auto-closed by
+ // the browser parser and land outside the marker element.
+ content: `
+
+`,
+ status: 'publish'
+ });
+
+ created.push({ type: 'pages', id: holder.id });
+
+ pageId = holder.id;
+ });
+
+ test.afterAll( async({ requestUtils }) => {
+ // Only this spec's own records - other specs run against the same site.
+ // Best-effort per record: one failed request must not orphan the rest.
+ while ( created.length ) {
+ const record = created.pop();
+ try {
+ await requestUtils.rest({
+ method: 'DELETE',
+ path: `/wp/v2/${ record.type }/${ record.id }`,
+ params: { force: true }
+ });
+ } catch ( error ) {
+ console.warn( `Could not delete ${ record.type }/${ record.id }:`, error.message );
+ }
+ }
+ });
+
+ test( 'renders the target post content on the frontend', async({ page }) => {
+ await page.goto( `/?page_id=${ pageId }` );
+
+ await expect( page.locator( '.o-dyn-2929' ) ).toContainText( targetContent );
+ });
+
+ test( 'survives a corrupted $pages loop global without PHP warnings', async({ page }) => {
+ await page.goto( `/?page_id=${ pageId }&otter_e2e_corrupt_pages=1` );
+
+ // Regression #2929: "Warning: Undefined array key -1 in .../post-template.php"
+ // (PHP 7.4 words it "Undefined offset: -1") plus a preg_match() deprecation.
+ await expect( page.locator( 'body' ) ).not.toContainText( /Undefined (array key|offset)/ );
+ await expect( page.locator( 'body' ) ).not.toContainText( 'preg_match' );
+
+ // The tag must still resolve the context post's content.
+ await expect( page.locator( '.o-dyn-2929' ) ).toContainText( targetContent );
+ });
+});
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
+
+
+
+
+`;
+
+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/blocks/tabs.spec.js b/src/blocks/test/e2e/blocks/tabs.spec.js
index ff339c946..207b10eaa 100644
--- a/src/blocks/test/e2e/blocks/tabs.spec.js
+++ b/src/blocks/test/e2e/blocks/tabs.spec.js
@@ -13,13 +13,13 @@ test.describe( 'Tabs Block', () => {
await admin.createNewPost();
});
- test( 'can be created by typing "/tabs"', async({ editor, page }) => {
+ test( 'can be created by typing "/themeisle-tabs"', async({ editor, page }) => {
// Create a Progress Block with the slash block shortcut.
await insertBlockBySlash({
editor,
page,
- shortcut: '/tabs',
+ shortcut: '/themeisle-tabs',
blockName: 'themeisle-blocks/tabs'
});
});
diff --git a/src/blocks/test/e2e/blocks/widgets-css-frontend.spec.js b/src/blocks/test/e2e/blocks/widgets-css-frontend.spec.js
new file mode 100644
index 000000000..196ccff5f
--- /dev/null
+++ b/src/blocks/test/e2e/blocks/widgets-css-frontend.spec.js
@@ -0,0 +1,94 @@
+/**
+ * Internal dependencies
+ */
+import { test, expect } from '../fixtures';
+
+/**
+ * Frontend widgets-CSS coverage for https://github.com/Codeinwp/otter-blocks/issues/2937.
+ *
+ * A frontend request with an active sidebar and no generated widget stylesheet
+ * reaches CSS_Handler::is_writable() from Block_Frontend::enqueue_widgets_css()
+ * at wp_footer. In production that request fataled when WP_Filesystem() was
+ * unavailable. The exact missing-function condition can only be recreated in
+ * the isolated PHPUnit sandbox (tests/test-css-handler.php); here the
+ * filesystem is blocked at the get_filesystem_method() level, which drives the
+ * same is_writable() → false branch and asserts the user-visible contract: the
+ * page must finish rendering and serve the widget CSS inline.
+ *
+ * Serial project: switches the active theme and mutates site-wide widget,
+ * option, and filesystem state.
+ */
+
+const WIDGET_SELECTOR = '.wp-block-themeisle-blocks-progress-bar';
+const WIDGET_CSS_ID = '#wp-block-themeisle-blocks-progress-bar-e2e2937';
+
+test.describe( 'Widgets CSS frontend', () => {
+ test.beforeAll( async({ requestUtils }) => {
+ // Classic theme with a registered sidebar; block themes register none,
+ // so the widgets-CSS path is unreachable on the default theme.
+ await requestUtils.activateTheme( 'twentytwentyone' );
+
+ await requestUtils.rest({
+ method: 'POST',
+ path: '/otter-e2e/v1/widgets/seed'
+ });
+ });
+
+ test.afterAll( async({ requestUtils }) => {
+ await requestUtils.rest({
+ method: 'POST',
+ path: '/otter-e2e/v1/widgets/cleanup'
+ });
+
+ await requestUtils.activateTheme( 'twentytwentythree' );
+ });
+
+ test( 'completes the page with inline widget CSS when the filesystem is unavailable', async({ page, otterUtils }) => {
+ await otterUtils.setFilesystemMode( 'blocked' );
+
+ try {
+ // Take the no-stylesheet branch on a fresh request.
+ await otterUtils.seedOtterWidget();
+
+ const response = await page.goto( '/' );
+
+ expect( response.status() ).toBe( 200 );
+
+ // The widget itself rendered inside the sidebar.
+ await expect( page.locator( WIDGET_SELECTOR ) ).toBeVisible();
+
+ const content = await page.content();
+
+ // The inline