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( + '
%s
', + __( '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" . + '
E2E Progress
' . "\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: ` +

+

Post 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

+ + + +
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