diff --git a/composer.json b/composer.json index 5e727a2a..98bf344b 100644 --- a/composer.json +++ b/composer.json @@ -59,6 +59,6 @@ "php": ">=7.4", "codeinwp/themeisle-sdk": "^3.3", "codeinwp/optimole-sdk": "^1.2", - "enshrined/svg-sanitize": "^0.22.0" + "enshrined/svg-sanitize": "^1.0.0" } } diff --git a/composer.lock b/composer.lock index 2cc90509..8f192862 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "982a4078faab475dd9f9f90a3f065675", + "content-hash": "9236121e91b49149c045b0f11d2dbc13", "packages": [ { "name": "codeinwp/optimole-sdk", @@ -64,16 +64,16 @@ }, { "name": "codeinwp/themeisle-sdk", - "version": "3.3.61", + "version": "3.3.62", "source": { "type": "git", "url": "https://github.com/Codeinwp/themeisle-sdk.git", - "reference": "9fe698b52dec768a0dd8b500fb51efe40962ee99" + "reference": "8363c9cab1a233095a76cd48e96fb64ce1b29ef8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/9fe698b52dec768a0dd8b500fb51efe40962ee99", - "reference": "9fe698b52dec768a0dd8b500fb51efe40962ee99", + "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/8363c9cab1a233095a76cd48e96fb64ce1b29ef8", + "reference": "8363c9cab1a233095a76cd48e96fb64ce1b29ef8", "shasum": "" }, "require-dev": { @@ -99,22 +99,22 @@ ], "support": { "issues": "https://github.com/Codeinwp/themeisle-sdk/issues", - "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.61" + "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.62" }, - "time": "2026-08-24T15:59:27+00:00" + "time": "2026-09-17T17:08:21+00:00" }, { "name": "enshrined/svg-sanitize", - "version": "0.22.0", + "version": "1.0.0", "source": { "type": "git", "url": "https://github.com/darylldoyle/svg-sanitizer.git", - "reference": "0afa95ea74be155a7bcd6c6fb60c276c39984500" + "reference": "f3300fcd1bbf67d205b52217c75d0f7d6a8c47ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/darylldoyle/svg-sanitizer/zipball/0afa95ea74be155a7bcd6c6fb60c276c39984500", - "reference": "0afa95ea74be155a7bcd6c6fb60c276c39984500", + "url": "https://api.github.com/repos/darylldoyle/svg-sanitizer/zipball/f3300fcd1bbf67d205b52217c75d0f7d6a8c47ff", + "reference": "f3300fcd1bbf67d205b52217c75d0f7d6a8c47ff", "shasum": "" }, "require": { @@ -144,9 +144,9 @@ "description": "An SVG sanitizer for PHP", "support": { "issues": "https://github.com/darylldoyle/svg-sanitizer/issues", - "source": "https://github.com/darylldoyle/svg-sanitizer/tree/0.22.0" + "source": "https://github.com/darylldoyle/svg-sanitizer/tree/1.0.0" }, - "time": "2025-08-12T10:13:48+00:00" + "time": "2026-09-01T09:35:47+00:00" }, { "name": "symfony/polyfill-php80", diff --git a/inc/compatibilities/groovy_menu.php b/inc/compatibilities/groovy_menu.php new file mode 100644 index 00000000..34fe0c83 --- /dev/null +++ b/inc/compatibilities/groovy_menu.php @@ -0,0 +1,68 @@ +. + * + * This only matters with the `optml_capture_at_shutdown` opt-in, where we capture + * and process our buffer at `shutdown` (PHP_INT_MIN) and re-arm an empty one, so + * Groovy Menu would receive an empty string and drop the menu. In that mode we + * apply Groovy Menu's final-output filter to the page we capture, before image + * replacement, and unhook its own shutdown step at that moment. In the default + * in-handler mode our capture never runs and Groovy Menu keeps its own step. + */ +class Optml_groovy_menu extends Optml_compatibility { + /** + * Groovy Menu's shutdown callback that grabs the top output buffer. + */ + const GROOVY_SHUTDOWN_CALLBACK = 'groovy_menu_pre_shutdown'; + + /** + * Groovy Menu's filter that inserts the menu markup into the page HTML. + */ + const GROOVY_OUTPUT_FILTER = 'groovy_menu_final_output'; + + /** + * Should we load the integration logic. + * + * @return bool Should we load. + */ + public function should_load() { + return function_exists( self::GROOVY_SHUTDOWN_CALLBACK ) + && has_action( 'shutdown', self::GROOVY_SHUTDOWN_CALLBACK ) !== false; + } + + /** + * Register integration details. + * + * @return void + */ + public function register() { + add_filter( 'optml_captured_page_html', [ $this, 'insert_menu' ] ); + } + + /** + * Insert the Groovy Menu markup into the captured page and take over its shutdown step. + * + * @param string $html The captured page HTML, before image replacement. + * + * @return string + */ + public function insert_menu( $html ) { + $priority = has_action( 'shutdown', self::GROOVY_SHUTDOWN_CALLBACK ); + if ( $priority === false ) { + return $html; + } + // Our capture ran, so Groovy Menu must not ob_get_clean() the re-armed empty buffer afterwards. + remove_action( 'shutdown', self::GROOVY_SHUTDOWN_CALLBACK, $priority ); + + // Same guard as Groovy Menu's own shutdown callback. + if ( ! defined( 'GROOVY_MENU_SCRIPTS_INIT' ) ) { + return $html; + } + + return apply_filters( self::GROOVY_OUTPUT_FILTER, $html ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Groovy Menu's own filter. + } +} diff --git a/inc/manager.php b/inc/manager.php index cb3cea3f..587fe756 100644 --- a/inc/manager.php +++ b/inc/manager.php @@ -116,6 +116,7 @@ final class Optml_Manager { 'hummingbird', 'aruba_hsc', 'spc', + 'groovy_menu', ]; /** * The current state of the buffer. @@ -393,7 +394,7 @@ public static function is_ajax_request() { if ( ! wp_doing_ajax() ) { return false; } - if ( isset( $_REQUEST['action'] ) && strpos( $_REQUEST['action'], 'wpmdb' ) !== false ) { + if ( isset( $_REQUEST['action'] ) && is_string( $_REQUEST['action'] ) && strpos( $_REQUEST['action'], 'wpmdb' ) !== false ) { return false; } @@ -912,19 +913,22 @@ public function process_template_redirect_content() { } /** - * Start an output buffer that captures the page HTML. + * Start the output buffer that holds the page HTML. * - * On normal requests the buffer is captured and processed by close_buffer() - * at shutdown, outside of PHP's display-handler context, so callbacks hooked - * into our filters are free to use output buffering themselves and fatal - * errors raised during processing keep their real message instead of being - * masked by "Cannot use output buffering in output buffering display handlers". + * By default the page is processed by the attached handler when the buffer + * is flushed, the way it worked up to 4.2.11. We never flush other buffers + * and open no buffer at shutdown, so code that opens a buffer early and reads + * it back with ob_get_clean() at shutdown (FacetWP, Groovy Menu) keeps working. * - * The attached handler is only a fallback for buffers flushed outside of - * close_buffer() — third-party force-flush loops, ob_flush() streaming, or - * core's wp_ob_end_flush_all() reaching the re-armed buffer. A named method - * is used instead of a closure so the buffer can be identified as ours via - * ob_get_status()['name']. + * When the optml_capture_at_shutdown filter returns true, close_buffer() + * captures and processes the buffer at shutdown instead, outside of PHP's + * display-handler context. Callbacks hooked into our filters can then use + * output buffering themselves, and fatal errors raised during processing + * keep their real message instead of being masked by "Cannot use output + * buffering in output buffering display handlers". + * + * A named method is used instead of a closure so the buffer can be + * identified as ours via ob_get_status()['name']. * * @return void */ @@ -940,15 +944,35 @@ private function start_capture_buffer() { const OB_HANDLER_NAME = 'Optml_Manager::handle_buffer_fallback'; /** - * Output-buffer handler attached to our capture buffer. + * Whether the page is captured and processed at shutdown, outside of PHP's display-handler context. + * + * @return bool + */ + private function captures_at_shutdown() { + /** + * Filters whether the page is captured and processed at shutdown, outside + * of PHP's display-handler context, instead of inside the output-buffer + * handler. + * + * Off by default: the shutdown capture flushes the buffers + * stacked above ours and opens a new buffer afterwards, which breaks code + * that reads its own buffer back at shutdown. Return true to opt in. + * + * @param bool $capture_at_shutdown Whether to process the buffer at shutdown. + */ + return apply_filters( 'optml_capture_at_shutdown', false ) === true; + } + + /** + * Output-buffer handler attached to our buffer. + * + * By default this is where the page is processed. An exception thrown by the + * replacement never breaks the page: the content is returned untouched. * - * Runs only when the buffer is flushed outside of close_buffer(). Content is - * passed through UNPROCESSED here: running the replacement filter graph - * inside a PHP display handler would turn any third-party ob_*() call into - * an uncatchable fatal ("Cannot use output buffering in output buffering - * display handlers") — the very crash this rework removes. The only - * exception is the legacy mode selected via the optml_capture_at_shutdown - * filter, which explicitly restores the previous in-handler processing. + * With the optml_capture_at_shutdown opt-in the handler runs only when the + * buffer is flushed outside of close_buffer(), and the content is passed + * through unprocessed, because running the replacement filter graph inside a + * display handler is what that mode exists to avoid. * * @param string $content The buffered content. * @param int $phase PHP's output-handler phase bitmask (unused; keeps replace_content()'s $partial parameter shielded from it). @@ -959,7 +983,7 @@ public function handle_buffer_fallback( $content, $phase = 0 ) { if ( self::$ob_processed || $content === '' ) { return $content; } - if ( apply_filters( 'optml_capture_at_shutdown', true ) === false ) { + if ( ! $this->captures_at_shutdown() ) { try { return $this->replace_content( $content, self::is_ajax_request() ); } catch ( Throwable $t ) { @@ -981,14 +1005,7 @@ public function close_buffer() { return; } - /** - * Filters whether the captured page is processed at shutdown, outside of - * PHP's display-handler context. Return false to restore the legacy - * behavior of processing inside the output-buffer handler. - * - * @param bool $capture_at_shutdown Whether to process the buffer at shutdown. - */ - if ( apply_filters( 'optml_capture_at_shutdown', true ) === false ) { + if ( ! $this->captures_at_shutdown() ) { if ( ob_get_length() ) { ob_end_flush(); } @@ -1026,10 +1043,10 @@ public function close_buffer() { * @return void */ public function close_final_buffer() { - if ( ! self::$ob_started ) { + if ( ! self::$ob_started || ! $this->captures_at_shutdown() ) { return; } - $this->capture_and_process_buffer(); + $this->capture_and_process_buffer( false ); } /** @@ -1039,9 +1056,11 @@ public function close_final_buffer() { * buffer another plugin opened at the same level after ours was closed is * never captured or closed by us. * + * @param bool $is_page Whether this is the page capture (true) or the late shutdown output (false). + * * @return bool Whether our buffer was found and consumed. */ - private function capture_and_process_buffer() { + private function capture_and_process_buffer( $is_page = true ) { if ( self::$ob_level === 0 || ob_get_level() !== self::$ob_level ) { return false; } @@ -1054,6 +1073,18 @@ private function capture_and_process_buffer() { self::$ob_processed = true; ob_end_clean(); if ( $html !== false && $html !== '' ) { + if ( $is_page ) { + /** + * Filters the captured page HTML before Optimole processes it. + * + * Runs once per request, on the buffer captured at shutdown, outside of + * PHP's display-handler context. Late output echoed by other shutdown + * callbacks is not passed through this filter. + * + * @param string $html The full page HTML. + */ + $html = apply_filters( 'optml_captured_page_html', $html ); + } echo $this->replace_content( $html, self::is_ajax_request() ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- full page HTML, escaping would break the page. } return true; diff --git a/tests/test-ajax-request-detection.php b/tests/test-ajax-request-detection.php new file mode 100644 index 00000000..914af0af --- /dev/null +++ b/tests/test-ajax-request-detection.php @@ -0,0 +1,104 @@ +had_action = isset( $_REQUEST['action'] ); + + if ( $this->had_action ) { + $this->original_action = $_REQUEST['action']; + } + + // DOING_AJAX cannot be defined per test, the filter is the supported way in. + add_filter( 'wp_doing_ajax', '__return_true' ); + + wp_set_current_user( 0 ); + } + + public function tearDown(): void { + remove_filter( 'wp_doing_ajax', '__return_true' ); + + if ( $this->had_action ) { + $_REQUEST['action'] = $this->original_action; + } else { + unset( $_REQUEST['action'] ); + } + + $this->had_action = false; + $this->original_action = null; + + parent::tearDown(); + } + + /** + * An array-valued action, i.e. `action[]=wpmdb`, must not fatal. + */ + public function test_array_action_does_not_fatal() { + $_REQUEST['action'] = [ 'wpmdb' ]; + + $this->assertTrue( Optml_Manager::is_ajax_request() ); + } + + /** + * A nested array action is handled the same way. + */ + public function test_nested_array_action_does_not_fatal() { + $_REQUEST['action'] = [ 'a' => [ 'wpmdb' ] ]; + + $this->assertTrue( Optml_Manager::is_ajax_request() ); + } + + /** + * WP Migrate DB requests stay excluded, the guarantee from 02df0774. + */ + public function test_wpmdb_action_still_excluded() { + $_REQUEST['action'] = 'wpmdb_verify_connection_to_remote_site'; + + $this->assertFalse( Optml_Manager::is_ajax_request() ); + } + + /** + * An unrelated AJAX action is still treated as a replaceable request. + */ + public function test_unrelated_action_is_ajax_request() { + $_REQUEST['action'] = 'woocommerce_get_refreshed_fragments'; + + $this->assertTrue( Optml_Manager::is_ajax_request() ); + } + + /** + * A request with no action at all is still treated as a replaceable request. + */ + public function test_missing_action_is_ajax_request() { + unset( $_REQUEST['action'] ); + + $this->assertTrue( Optml_Manager::is_ajax_request() ); + } +} diff --git a/tests/test-zz-buffer.php b/tests/test-zz-buffer.php index 15425d5c..a7f8e2fa 100644 --- a/tests/test-zz-buffer.php +++ b/tests/test-zz-buffer.php @@ -39,6 +39,9 @@ public function setUp(): void { Optml_Tag_Replacer::instance()->init(); Optml_Manager::instance()->init(); + // The shutdown capture is opt-in; most tests below exercise it. + add_filter( 'optml_capture_at_shutdown', '__return_true' ); + $this->reset_buffer_state(); $this->base_level = ob_get_level(); } @@ -53,6 +56,8 @@ public function tearDown(): void { } } $this->reset_buffer_state(); + remove_filter( 'optml_capture_at_shutdown', '__return_true' ); + remove_filter( 'optml_capture_at_shutdown', '__return_false' ); parent::tearDown(); } @@ -290,4 +295,69 @@ public function test_legacy_in_handler_mode() { $this->assertSame( 1, substr_count( $out, 'i.optimole.com' ) ); $this->assertSame( $this->base_level, ob_get_level() ); } + + /** + * Without the opt-in the page is processed inside the output handler, as up to 4.2.11. + */ + public function test_default_processes_in_handler() { + remove_filter( 'optml_capture_at_shutdown', '__return_true' ); + $manager = Optml_Manager::instance(); + ob_start(); + $manager->process_template_redirect_content(); + echo self::IMG_TAGS; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $manager->close_buffer(); + + $this->assertSame( $this->base_level + 1, ob_get_level(), 'No buffer is opened at shutdown.' ); + $manager->close_final_buffer(); + $out = ob_get_clean(); + + $this->assertSame( 1, substr_count( $out, 'i.optimole.com' ) ); + $this->assertSame( $this->base_level, ob_get_level() ); + } + + /** + * By default, code that opened a buffer before ours and reads it back on + * shutdown priority 0 (FacetWP refresh, Groovy Menu) finds its own buffer on + * top, holding the processed page. + */ + public function test_default_foreign_buffer_below_can_be_read_back() { + remove_filter( 'optml_capture_at_shutdown', '__return_true' ); + $manager = Optml_Manager::instance(); + ob_start(); + ob_start(); // Third-party buffer opened on init, before ours. + $foreign_level = ob_get_level(); + $manager->process_template_redirect_content(); + echo self::IMG_TAGS; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $manager->close_buffer(); + + $this->assertSame( $foreign_level, ob_get_level(), 'The foreign buffer is on top again.' ); + + // The third party reads its buffer back on shutdown priority 0. + $page = ob_get_clean(); + $manager->close_final_buffer(); + + $this->assertSame( 1, substr_count( $page, 'i.optimole.com' ) ); + $this->assertSame( '', ob_get_clean() ); + $this->assertSame( $this->base_level, ob_get_level() ); + } + + /** + * By default an exception thrown during the replacement never breaks the page. + */ + public function test_default_exception_passes_content_through() { + remove_filter( 'optml_capture_at_shutdown', '__return_true' ); + $thrower = function () { + throw new RuntimeException( 'broken third-party callback' ); + }; + add_filter( 'optml_url_pre_process', $thrower ); + $manager = Optml_Manager::instance(); + ob_start(); + $manager->process_template_redirect_content(); + echo self::IMG_TAGS; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $manager->close_buffer(); + $out = ob_get_clean(); + remove_filter( 'optml_url_pre_process', $thrower ); + + $this->assertSame( self::IMG_TAGS, $out ); + } } diff --git a/tests/test-zz-groovy-menu.php b/tests/test-zz-groovy-menu.php new file mode 100644 index 00000000..0ea900dc --- /dev/null +++ b/tests/test-zz-groovy-menu.php @@ -0,0 +1,219 @@ +. + * + * @param string $output Page HTML. + * + * @return string + */ + function groovy_menu_add_after_body( $output ) { + return preg_replace( '#(\
';
+
+ /**
+ * The output-buffer nesting level before each test.
+ *
+ * @var int
+ */
+ private $base_level = 0;
+
+ /**
+ * The compatibility under test.
+ *
+ * @var Optml_groovy_menu
+ */
+ private $compatibility;
+
+ public function setUp(): void {
+ parent::setUp();
+ if ( ! defined( 'GROOVY_MENU_SCRIPTS_INIT' ) ) {
+ define( 'GROOVY_MENU_SCRIPTS_INIT', true );
+ }
+ $settings = new Optml_Settings();
+ $settings->update( 'service_data', [
+ 'cdn_key' => 'test123',
+ 'cdn_secret' => '12345',
+ 'whitelist' => [ 'example.com', 'example.org' ],
+ ] );
+ $settings->update( 'lazyload', 'disabled' );
+ $settings->update( 'cdn', 'enabled' );
+ Optml_Url_Replacer::instance()->init();
+ Optml_Tag_Replacer::instance()->init();
+ Optml_Manager::instance()->init();
+
+ $GLOBALS['gm_test_shutdown_calls'] = 0;
+ add_action( 'shutdown', 'groovy_menu_pre_shutdown', 0 );
+ add_filter( 'groovy_menu_final_output', 'groovy_menu_add_after_body' );
+ $this->compatibility = new Optml_groovy_menu();
+
+ // The compatibility acts only with the shutdown capture opt-in.
+ add_filter( 'optml_capture_at_shutdown', '__return_true' );
+
+ $this->reset_buffer_state();
+ $this->base_level = ob_get_level();
+ }
+
+ public function tearDown(): void {
+ $this->reset_buffer_state( true );
+ while ( ob_get_level() > $this->base_level ) {
+ // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
+ if ( ! @ob_end_clean() ) {
+ break;
+ }
+ }
+ $this->reset_buffer_state();
+ remove_action( 'shutdown', 'groovy_menu_pre_shutdown', 0 );
+ remove_filter( 'groovy_menu_final_output', 'groovy_menu_add_after_body' );
+ remove_filter( 'optml_captured_page_html', [ $this->compatibility, 'insert_menu' ] );
+ remove_filter( 'optml_capture_at_shutdown', '__return_false' );
+ remove_filter( 'optml_capture_at_shutdown', '__return_true' );
+ parent::tearDown();
+ }
+
+ /**
+ * Reset Optml_Manager buffer statics between tests.
+ *
+ * @param bool $processed Value for the processed flag.
+ */
+ private function reset_buffer_state( $processed = false ) {
+ $reflection = new ReflectionClass( Optml_Manager::class );
+ foreach ( [ 'ob_started' => false, 'ob_level' => 0, 'ob_processed' => $processed ] as $property => $value ) {
+ $prop = $reflection->getProperty( $property );
+ $prop->setAccessible( true );
+ $prop->setValue( null, $value );
+ }
+ }
+
+ /**
+ * The compatibility loads only when Groovy Menu's auto-integration hooked its shutdown step.
+ */
+ public function test_loads_only_with_auto_integration() {
+ $this->assertTrue( $this->compatibility->should_load() );
+ remove_action( 'shutdown', 'groovy_menu_pre_shutdown', 0 );
+ $this->assertFalse( $this->compatibility->should_load() );
+ }
+
+ /**
+ * Groovy Menu buffer opened on init sits below ours: the menu is inserted and optimized.
+ */
+ public function test_menu_inserted_when_groovy_buffer_is_below_ours() {
+ $this->compatibility->register();
+ $manager = Optml_Manager::instance();
+ ob_start();
+ ob_start(); // Groovy Menu's own buffer, opened on init.
+ $manager->process_template_redirect_content();
+ echo self::PAGE; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ $manager->close_buffer();
+
+ $this->assertFalse( has_action( 'shutdown', 'groovy_menu_pre_shutdown' ), 'Groovy Menu shutdown step is taken over.' );
+
+ $manager->close_final_buffer();
+ ob_end_flush(); // Core flushes Groovy Menu's buffer at shutdown.
+ $out = ob_get_clean();
+
+ $this->assertSame( 1, substr_count( $out, 'gm-navbar' ) );
+ $this->assertSame( 2, substr_count( $out, 'i.optimole.com' ) );
+ $this->assertStringNotContainsString( '"http://example.org/wp-content/uploads/gm-logo.jpg', $out );
+ $this->assertSame( 0, $GLOBALS['gm_test_shutdown_calls'] );
+ $this->assertSame( $this->base_level, ob_get_level() );
+ }
+
+ /**
+ * Groovy Menu buffer stacked above ours is flushed through, and the menu is still inserted.
+ */
+ public function test_menu_inserted_when_groovy_buffer_is_above_ours() {
+ $this->compatibility->register();
+ $manager = Optml_Manager::instance();
+ ob_start();
+ $manager->process_template_redirect_content();
+ ob_start(); // Groovy Menu's buffer opened after ours.
+ echo self::PAGE; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ $manager->close_buffer();
+ $manager->close_final_buffer();
+ $out = ob_get_clean();
+
+ $this->assertFalse( has_action( 'shutdown', 'groovy_menu_pre_shutdown' ) );
+ $this->assertSame( 1, substr_count( $out, 'gm-navbar' ) );
+ $this->assertSame( 2, substr_count( $out, 'i.optimole.com' ) );
+ $this->assertSame( $this->base_level, ob_get_level() );
+ }
+
+ /**
+ * In the default in-handler mode Groovy Menu keeps its own shutdown step and still works.
+ */
+ public function test_default_mode_keeps_groovy_shutdown_step() {
+ remove_filter( 'optml_capture_at_shutdown', '__return_true' );
+ $this->compatibility->register();
+ $manager = Optml_Manager::instance();
+ ob_start();
+ ob_start(); // Groovy Menu's buffer.
+ $manager->process_template_redirect_content();
+ echo self::PAGE; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ $manager->close_buffer();
+
+ $this->assertSame( 0, has_action( 'shutdown', 'groovy_menu_pre_shutdown' ), 'Groovy Menu shutdown step is left in place.' );
+
+ groovy_menu_pre_shutdown(); // Groovy Menu at shutdown priority 0.
+ $out = ob_get_clean();
+
+ $this->assertSame( 1, $GLOBALS['gm_test_shutdown_calls'] );
+ $this->assertSame( 1, substr_count( $out, 'gm-navbar' ) );
+ $this->assertSame( 1, substr_count( $out, 'i.optimole.com' ) );
+ $this->assertSame( $this->base_level, ob_get_level() );
+ }
+
+ /**
+ * When third-party code flushes our buffer before shutdown, Groovy Menu keeps its own shutdown step.
+ */
+ public function test_early_flush_keeps_groovy_shutdown_step() {
+ $this->compatibility->register();
+ $manager = Optml_Manager::instance();
+ ob_start();
+ ob_start(); // Groovy Menu's buffer.
+ $manager->process_template_redirect_content();
+ echo self::PAGE; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ ob_end_flush(); // Third-party force flush of our buffer.
+ $manager->close_buffer();
+ $manager->close_final_buffer();
+
+ $this->assertSame( 0, has_action( 'shutdown', 'groovy_menu_pre_shutdown' ) );
+
+ groovy_menu_pre_shutdown();
+ $out = ob_get_clean();
+
+ $this->assertSame( 1, substr_count( $out, 'gm-navbar' ) );
+ $this->assertSame( $this->base_level, ob_get_level() );
+ }
+}