From 405dee168112340e8921eb968f61e45266f72386 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:01:52 +0300 Subject: [PATCH 1/9] chore(deps): bump codeinwp/themeisle-sdk from 3.3.58 to 3.3.61 (#1128) Bumps [codeinwp/themeisle-sdk](https://github.com/Codeinwp/themeisle-sdk) from 3.3.58 to 3.3.61. - [Release notes](https://github.com/Codeinwp/themeisle-sdk/releases) - [Changelog](https://github.com/Codeinwp/themeisle-sdk/blob/v3.3.61/CHANGELOG.md) - [Commits](https://github.com/Codeinwp/themeisle-sdk/compare/v3.3.58...v3.3.61) --- updated-dependencies: - dependency-name: codeinwp/themeisle-sdk dependency-version: 3.3.61 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.lock b/composer.lock index 6cac7270..2cc90509 100644 --- a/composer.lock +++ b/composer.lock @@ -64,16 +64,16 @@ }, { "name": "codeinwp/themeisle-sdk", - "version": "3.3.58", + "version": "3.3.61", "source": { "type": "git", "url": "https://github.com/Codeinwp/themeisle-sdk.git", - "reference": "d6807c0b7308e323bd77cced667dee3f2d5e6a82" + "reference": "9fe698b52dec768a0dd8b500fb51efe40962ee99" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/d6807c0b7308e323bd77cced667dee3f2d5e6a82", - "reference": "d6807c0b7308e323bd77cced667dee3f2d5e6a82", + "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/9fe698b52dec768a0dd8b500fb51efe40962ee99", + "reference": "9fe698b52dec768a0dd8b500fb51efe40962ee99", "shasum": "" }, "require-dev": { @@ -99,9 +99,9 @@ ], "support": { "issues": "https://github.com/Codeinwp/themeisle-sdk/issues", - "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.58" + "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.61" }, - "time": "2026-07-29T08:38:52+00:00" + "time": "2026-08-24T15:59:27+00:00" }, { "name": "enshrined/svg-sanitize", From 8436d77d342df7aae185f835eb214bef48df17e1 Mon Sep 17 00:00:00 2001 From: Marius Cristea Date: Wed, 2 Sep 2026 13:01:24 +0300 Subject: [PATCH 2/9] Fix Jetpack conflict notice eligibility (#1131) * fix: gate Jetpack conflict notice on Photon * refactor: centralize Jetpack Photon status * refactor: register Photon conflict as compatibility --- .../jetpack_photon_compatibility.php | 57 ++++++++++ inc/conflicts/conflicting_plugins.php | 3 +- inc/conflicts/jetpack_photon.php | 10 +- inc/manager.php | 1 + tests/test-jetpack-conflicts.php | 101 ++++++++++++++++++ 5 files changed, 163 insertions(+), 9 deletions(-) create mode 100644 inc/compatibilities/jetpack_photon_compatibility.php create mode 100644 tests/test-jetpack-conflicts.php diff --git a/inc/compatibilities/jetpack_photon_compatibility.php b/inc/compatibilities/jetpack_photon_compatibility.php new file mode 100644 index 00000000..1dfefff3 --- /dev/null +++ b/inc/compatibilities/jetpack_photon_compatibility.php @@ -0,0 +1,57 @@ + $plugins Conflicting plugin definitions. + * @return array + */ + public function add_conflicting_plugin( $plugins ) { + $plugins[ self::CONFLICT_KEY ] = self::PLUGIN_FILE; + + return $plugins; + } + + /** + * Register before the generic conflict notice is evaluated. + * + * @return bool + */ + public function should_load_early() { + return true; + } +} diff --git a/inc/conflicts/conflicting_plugins.php b/inc/conflicts/conflicting_plugins.php index 721a3a13..4b13a755 100644 --- a/inc/conflicts/conflicting_plugins.php +++ b/inc/conflicts/conflicting_plugins.php @@ -1,4 +1,5 @@ 'litespeed-cache/litespeed-cache.php', 'autoptimize' => 'autoptimize/autoptimize.php', 'perfmatters' => 'perfmatters/perfmatters.php', - 'jetpack_Photon' => 'jetpack/jetpack.php', // 'plugin-slug' => 'plugin-folder/plugin-file.php' ]; @@ -73,6 +73,7 @@ private function get_active_plugins() { $conflicting_plugins = $this->defined_plugins(); $conflicting_plugins = array_filter( $conflicting_plugins, 'is_plugin_active' ); + return apply_filters( 'optml_conflicting_active_plugins', $conflicting_plugins ); } diff --git a/inc/conflicts/jetpack_photon.php b/inc/conflicts/jetpack_photon.php index 533b81e2..b545c986 100644 --- a/inc/conflicts/jetpack_photon.php +++ b/inc/conflicts/jetpack_photon.php @@ -34,14 +34,8 @@ public function define_message() { * @access public */ public function is_conflict_valid() { + $compatibility = new Optml_jetpack_photon_compatibility(); - if ( ! is_plugin_active( 'jetpack/jetpack.php' ) ) { - return false; - } - if ( ! class_exists( 'Jetpack', false ) ) { - return false; - } - - return Jetpack::is_module_active( 'photon' ); + return $compatibility->should_load(); } } diff --git a/inc/manager.php b/inc/manager.php index ffb713c7..07020fde 100644 --- a/inc/manager.php +++ b/inc/manager.php @@ -104,6 +104,7 @@ final class Optml_Manager { 'wpsp', 'jetengine', 'jetpack', + 'jetpack_photon_compatibility', 'wp_rocket', 'wp_super_cache', 'breeze', diff --git a/tests/test-jetpack-conflicts.php b/tests/test-jetpack-conflicts.php new file mode 100644 index 00000000..fb0bbecf --- /dev/null +++ b/tests/test-jetpack-conflicts.php @@ -0,0 +1,101 @@ +active_plugins = get_option( 'active_plugins', [] ); + $this->compatibility = new Optml_jetpack_photon_compatibility(); + update_option( + 'active_plugins', + array_merge( $this->active_plugins, [ 'jetpack/jetpack.php' ] ) + ); + } + + /** + * Restore active plugins after each test. + */ + public function tear_down() { + remove_filter( 'optml_conflicting_defined_plugins', [ $this->compatibility, 'add_conflicting_plugin' ] ); + Jetpack::$photon_active = false; + update_option( 'active_plugins', $this->active_plugins ); + + parent::tear_down(); + } + + /** + * Jetpack without Photon should not be a generic conflict. + */ + public function test_jetpack_without_photon_is_not_a_generic_conflict() { + $conflicts = new Optml_Conflicting_Plugins(); + $conflict = new Optml_Jetpack_Photon(); + + $this->assertFalse( $this->compatibility->should_load() ); + $this->assertFalse( $conflict->is_conflict_valid() ); + $this->assertNotContains( 'jetpack/jetpack.php', $conflicts->get_conflicting_plugins( true ) ); + } + + /** + * Jetpack with Photon should remain a generic conflict. + */ + public function test_jetpack_with_photon_is_a_generic_conflict() { + Jetpack::$photon_active = true; + $this->compatibility->register(); + $conflicts = new Optml_Conflicting_Plugins(); + $conflict = new Optml_Jetpack_Photon(); + + $this->assertTrue( $this->compatibility->should_load() ); + $this->assertTrue( $conflict->is_conflict_valid() ); + $this->assertContains( 'jetpack/jetpack.php', $conflicts->get_conflicting_plugins( true ) ); + } +} + +if ( ! class_exists( 'Jetpack', false ) ) { + /** + * Minimal Jetpack test double. + */ + class Jetpack { + /** + * Whether Photon is active. + * + * @var bool + */ + public static $photon_active = false; + + /** + * Check whether a module is active. + * + * @param string $module Module slug. + * @return bool + */ + public static function is_module_active( $module ) { + return 'photon' === $module && self::$photon_active; + } + } +} From 0f77733baf75ba17f492d87325aebdb38cc06d4b Mon Sep 17 00:00:00 2001 From: Marius Cristea Date: Wed, 2 Sep 2026 13:18:28 +0300 Subject: [PATCH 3/9] ci: skip PR-comment job on Dependabot PRs (no secrets, always fails) Co-Authored-By: Claude Fable 5 --- .github/workflows/build-dev-artifacts.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-dev-artifacts.yml b/.github/workflows/build-dev-artifacts.yml index 6869a5e5..e6741244 100644 --- a/.github/workflows/build-dev-artifacts.yml +++ b/.github/workflows/build-dev-artifacts.yml @@ -68,7 +68,7 @@ jobs: comment-on-pr: name: Comment on PR with links to plugin ZIPs - if: ${{ github.head_ref && github.head_ref != null }} + if: ${{ github.head_ref && github.head_ref != null && github.actor != 'dependabot[bot]' }} runs-on: ubuntu-latest needs: dev-zip env: From 5ec68ded816f00e9a3f9f820b832e763d819ce2b Mon Sep 17 00:00:00 2001 From: Marius Cristea Date: Wed, 2 Sep 2026 13:24:33 +0300 Subject: [PATCH 4/9] ci: revert Dependabot gate on PR-comment job (org Dependabot secret covers this repo) [skip ci] Co-Authored-By: Claude Fable 5 --- .github/workflows/build-dev-artifacts.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-dev-artifacts.yml b/.github/workflows/build-dev-artifacts.yml index e6741244..6869a5e5 100644 --- a/.github/workflows/build-dev-artifacts.yml +++ b/.github/workflows/build-dev-artifacts.yml @@ -68,7 +68,7 @@ jobs: comment-on-pr: name: Comment on PR with links to plugin ZIPs - if: ${{ github.head_ref && github.head_ref != null && github.actor != 'dependabot[bot]' }} + if: ${{ github.head_ref && github.head_ref != null }} runs-on: ubuntu-latest needs: dev-zip env: From dcc61ab24f523bb96a79376e9287343bdf87fbe4 Mon Sep 17 00:00:00 2001 From: Marius Cristea Date: Wed, 2 Sep 2026 17:02:21 +0300 Subject: [PATCH 5/9] fix: skip temporary Cache-Control header on non-cacheable pages (#1135) While page profiling is pending, replace_content() sent 'Cache-Control: max-age=300' for every not-logged-in request with headers still unsent. Because PHP's header() replaces same-name headers, it overwrote the no-cache header WooCommerce sets on cart, checkout and account pages, letting proxies cache user-specific pages for five minutes. The header is now sent only when DONOTCACHEPAGE is not set and no Cache-Control header exists yet, so a no-cache or longer-lived policy set by WordPress, WooCommerce or a cache plugin is never overridden. The new optml_send_temporary_cache_header filter lets developers override the decision in both directions. Fixes #1082 Co-authored-by: Claude Fable 5 --- inc/manager.php | 44 ++++++++++++++++- tests/test-cache-header.php | 98 +++++++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 tests/test-cache-header.php diff --git a/inc/manager.php b/inc/manager.php index 07020fde..06944ef8 100644 --- a/inc/manager.php +++ b/inc/manager.php @@ -431,6 +431,48 @@ public function register_after_setup() { public static function should_load_profiler( $default_value = false ) { return ! $default_value && apply_filters( 'optml_page_profiler_disable', false ) === false; } + + /** + * Decide if the temporary Cache-Control header can be sent while page profiling is pending. + * + * The header must never be sent on non-cacheable pages: it would replace a + * Cache-Control header already set by WordPress or another plugin (PHP's + * header() replaces same-name headers by default), e.g. WooCommerce's + * no-cache header on cart and checkout, letting proxies cache user-specific + * pages. We back off when DONOTCACHEPAGE is set or when any Cache-Control + * header exists already, and let developers override the decision. + * + * @param array|null $sent_headers Headers already set for the response; defaults to headers_list(). + * @param bool|null $do_not_cache Whether the page is flagged as non-cacheable; defaults to the DONOTCACHEPAGE constant. + * + * @return bool Whether the header can be sent. + */ + public function should_send_temporary_cache_header( $sent_headers = null, $do_not_cache = null ) { + if ( null === $do_not_cache ) { + $do_not_cache = defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE; + } + $send = ! $do_not_cache; + + if ( $send ) { + if ( null === $sent_headers ) { + $sent_headers = headers_list(); + } + foreach ( $sent_headers as $header ) { + if ( stripos( $header, 'cache-control:' ) === 0 ) { + $send = false; + break; + } + } + } + + /** + * Filters whether the temporary `Cache-Control: max-age=300` header is sent + * while page profiling is pending for the current page. + * + * @param bool $send Computed decision: false when DONOTCACHEPAGE is set or a Cache-Control header exists already. + */ + return apply_filters( 'optml_send_temporary_cache_header', $send ) === true; + } /** * Filter raw HTML content for urls. * @@ -462,7 +504,7 @@ public function replace_content( $html, $partial = false ) { $js_optimizer ); $html = str_replace( Optml_Admin::get_optimizer_script( true ), $js_optimizer, $html ); - if ( ! headers_sent() ) { + if ( ! headers_sent() && $this->should_send_temporary_cache_header() ) { header( 'Cache-Control: max-age=300' ); // Attempt to cache the page just for 5 mins until the optimizer is done. Once the optimizer is done, the page will load optimized. } } else { diff --git a/tests/test-cache-header.php b/tests/test-cache-header.php new file mode 100644 index 00000000..d64e071a --- /dev/null +++ b/tests/test-cache-header.php @@ -0,0 +1,98 @@ +assertTrue( $manager->should_send_temporary_cache_header( [], false ) ); + } + + /** + * Unrelated headers do not block it. + */ + public function test_unrelated_headers_do_not_block() { + $manager = Optml_Manager::instance(); + $headers = [ + 'Content-Type: text/html; charset=UTF-8', + 'X-Pingback: http://example.org/xmlrpc.php', + 'Pragma: no-cache', + ]; + $this->assertTrue( $manager->should_send_temporary_cache_header( $headers, false ) ); + } + + /** + * An existing Cache-Control header is never overridden, e.g. WooCommerce's + * nocache_headers() output on cart and checkout pages. + */ + public function test_existing_cache_control_blocks() { + $manager = Optml_Manager::instance(); + $headers = [ + 'Expires: Wed, 11 Jan 1984 05:00:00 GMT', + 'Cache-Control: no-cache, must-revalidate, max-age=0', + ]; + $this->assertFalse( $manager->should_send_temporary_cache_header( $headers, false ) ); + } + + /** + * The Cache-Control match is case-insensitive and value-agnostic — a longer + * max-age set by a cache plugin must not be downgraded either. + */ + public function test_existing_cache_control_case_and_value_agnostic() { + $manager = Optml_Manager::instance(); + $this->assertFalse( $manager->should_send_temporary_cache_header( [ 'cache-control: public, max-age=31536000' ], false ) ); + } + + /** + * DONOTCACHEPAGE blocks the header. + */ + public function test_donotcachepage_blocks() { + $manager = Optml_Manager::instance(); + $this->assertFalse( $manager->should_send_temporary_cache_header( [], true ) ); + } + + /** + * The filter can force the header off. + */ + public function test_filter_can_disable() { + add_filter( 'optml_send_temporary_cache_header', '__return_false' ); + $manager = Optml_Manager::instance(); + $this->assertFalse( $manager->should_send_temporary_cache_header( [], false ) ); + } + + /** + * The filter can force the header on despite blocking signals. + */ + public function test_filter_can_force_enable() { + add_filter( 'optml_send_temporary_cache_header', '__return_true' ); + $manager = Optml_Manager::instance(); + $this->assertTrue( $manager->should_send_temporary_cache_header( [ 'Cache-Control: no-cache' ], true ) ); + } + + /** + * A non-boolean filter return does not accidentally enable the header. + */ + public function test_non_boolean_filter_return_is_not_true() { + add_filter( + 'optml_send_temporary_cache_header', + function () { + return 'yes'; + } + ); + $manager = Optml_Manager::instance(); + $this->assertFalse( $manager->should_send_temporary_cache_header( [], false ) ); + } +} From 8340bb270a5c060dacf61290b6c0966b23dbb0ba Mon Sep 17 00:00:00 2001 From: Marius Cristea Date: Wed, 2 Sep 2026 17:02:26 +0300 Subject: [PATCH 6/9] fix: coerce object-shaped page profiles before viewport lookup (#1133) * fix: coerce object-shaped page profiles before viewport lookup Object-cache backends that JSON-decode without associative arrays store profiler payloads as stdClass, which fatals on ['af'] access during frontend lazyload. Normalize storage reads and guard device lookups. Co-authored-by: Cursor * fix: drop stale PHPStan baseline entries for typed storage get() Co-authored-by: Cursor * refactor: keep object-shaped profile fix at the storage boundary Drop per-lookup Profile/Lazyload guards now that storage get() normalizes stdClass. Keep the LCP imageId null coalesce. Seed tests through transients instead of reflection. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- inc/v2/PageProfiler/Profile.php | 2 +- inc/v2/PageProfiler/Storage/Base.php | 34 +- inc/v2/PageProfiler/Storage/ObjectCache.php | 4 +- inc/v2/PageProfiler/Storage/Transients.php | 4 +- phpstan-baseline.neon | 12 - tests/test-page-profiler-shape.php | 437 ++++++++++++++++++++ 6 files changed, 475 insertions(+), 18 deletions(-) create mode 100644 tests/test-page-profiler-shape.php diff --git a/inc/v2/PageProfiler/Profile.php b/inc/v2/PageProfiler/Profile.php index 31fa3634..5cd8fa3e 100644 --- a/inc/v2/PageProfiler/Profile.php +++ b/inc/v2/PageProfiler/Profile.php @@ -384,7 +384,7 @@ public function is_in_all_viewports( int $image_id ): bool { */ public function is_lcp_image_in_all_viewports( int $image_id ): bool { foreach ( self::get_active_devices() as $device ) { - if ( ( ( self::$current_profile_data[ $device ]['lcp']['type'] ?? '' ) === 'img' ) && ( self::$current_profile_data[ $device ]['lcp']['imageId'] === $image_id ) ) { + if ( ( ( self::$current_profile_data[ $device ]['lcp']['type'] ?? '' ) === 'img' ) && ( ( self::$current_profile_data[ $device ]['lcp']['imageId'] ?? null ) === $image_id ) ) { return true; } } diff --git a/inc/v2/PageProfiler/Storage/Base.php b/inc/v2/PageProfiler/Storage/Base.php index 750684ab..82011a12 100644 --- a/inc/v2/PageProfiler/Storage/Base.php +++ b/inc/v2/PageProfiler/Storage/Base.php @@ -22,10 +22,42 @@ abstract public function store( string $key, array $data ); * Retrieve data by key. * * @param string $key The unique identifier for the data to retrieve. - * @return array|false The stored data or null if not found. + * @return array|false The stored data or false if not found. */ abstract public function get( string $key ); + /** + * Coerce a stored profiler payload to an array. + * + * Object-cache backends that JSON-decode without associative arrays return stdClass. + * Nested objects (e.g. `af`, `bg`, `lcp`) are converted recursively. + * + * @param mixed $value Raw storage value. + * @return array|false + */ + public static function normalize_value( $value ) { + if ( false === $value || null === $value ) { + return false; + } + + if ( is_object( $value ) ) { + $value = get_object_vars( $value ); + } + + if ( ! is_array( $value ) ) { + return false; + } + + foreach ( $value as $key => $item ) { + if ( is_object( $item ) || is_array( $item ) ) { + $normalized_item = self::normalize_value( $item ); + $value[ $key ] = ( false !== $normalized_item ) ? $normalized_item : []; + } + } + + return $value; + } + /** * Delete data by key. * diff --git a/inc/v2/PageProfiler/Storage/ObjectCache.php b/inc/v2/PageProfiler/Storage/ObjectCache.php index 23075c4c..9170a1bc 100644 --- a/inc/v2/PageProfiler/Storage/ObjectCache.php +++ b/inc/v2/PageProfiler/Storage/ObjectCache.php @@ -55,10 +55,10 @@ public function store( string $key, array $data ) { * Retrieve data from the object cache. * * @param string $key The unique identifier for the data to retrieve. - * @return array|false The stored data or false if not found. + * @return array|false The stored data or false if not found. */ public function get( string $key ) { - return wp_cache_get( $key, self::GROUP ); + return self::normalize_value( wp_cache_get( $key, self::GROUP ) ); } /** diff --git a/inc/v2/PageProfiler/Storage/Transients.php b/inc/v2/PageProfiler/Storage/Transients.php index f1d139eb..13c56a27 100644 --- a/inc/v2/PageProfiler/Storage/Transients.php +++ b/inc/v2/PageProfiler/Storage/Transients.php @@ -65,10 +65,10 @@ public function store( string $key, array $data ) { * Retrieves data from a transient. * * @param string $key The key to retrieve data for. - * @return mixed The stored data or false if the transient doesn't exist or has expired. + * @return array|false The stored data or false if the transient doesn't exist or has expired. */ public function get( string $key ) { - return get_transient( $this->get_key( $key ) ); + return self::normalize_value( get_transient( $this->get_key( $key ) ) ); } /** diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 3141d694..ba4bee0c 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -3072,12 +3072,6 @@ parameters: count: 1 path: inc/v2/PageProfiler/Storage/Base.php - - - message: '#^Method OptimoleWP\\PageProfiler\\Storage\\Base\:\:get\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: inc/v2/PageProfiler/Storage/Base.php - - message: '#^Method OptimoleWP\\PageProfiler\\Storage\\Base\:\:store\(\) has no return type specified\.$#' identifier: missingType.return @@ -3090,12 +3084,6 @@ parameters: count: 1 path: inc/v2/PageProfiler/Storage/Base.php - - - message: '#^Method OptimoleWP\\PageProfiler\\Storage\\ObjectCache\:\:get\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: inc/v2/PageProfiler/Storage/ObjectCache.php - - message: '#^Method OptimoleWP\\PageProfiler\\Storage\\ObjectCache\:\:store\(\) has parameter \$data with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue diff --git a/tests/test-page-profiler-shape.php b/tests/test-page-profiler-shape.php new file mode 100644 index 00000000..15dcf133 --- /dev/null +++ b/tests/test-page-profiler-shape.php @@ -0,0 +1,437 @@ +update( + 'service_data', + [ + 'cdn_key' => 'test123', + 'cdn_secret' => '12345', + 'whitelist' => [ 'example.com' ], + ] + ); + $settings->update( 'lazyload', 'enabled' ); + $settings->update( 'lazyload_type', 'viewport' ); + Optml_Url_Replacer::instance()->init(); + Optml_Tag_Replacer::instance()->init(); + Optml_Lazyload_Replacer::instance()->init(); + Optml_Manager::instance()->init(); + Profile::reset_current_profile(); + } + + /** + * Clean up after each test. + */ + public function tearDown(): void { + parent::tearDown(); + Profile::reset_current_profile(); + wp_cache_flush(); + } + + /** + * Build a valid device profile payload as an array. + * + * @param array $overrides Optional overrides. + * @return array + */ + private function arrayProfile( $overrides = [] ) { + return array_merge( + [ + 'af' => [ self::ABOVE_FOLD_IMAGE_ID => true ], + 'bg' => [ + '[style*="background-image:url("]' => [ + '.hero' => [ 'https://example.com/bg.jpg' ], + ], + ], + 'lcp' => [ + 'type' => 'img', + 'imageId' => self::ABOVE_FOLD_IMAGE_ID, + ], + ], + $overrides + ); + } + + /** + * JSON-decode a payload without associative arrays (Redis/JSON object-cache shape). + * + * @param array $payload Array payload. + * @return object + */ + private function objectProfile( $payload = null ) { + if ( null === $payload ) { + $payload = $this->arrayProfile(); + } + return json_decode( wp_json_encode( $payload ) ); + } + + /** + * Seed transients with object-shaped payloads and load them through storage. + * + * @param mixed $mobile Mobile payload, or false to skip. + * @param mixed $desktop Desktop payload, or false to skip. + * @param mixed $global Global payload, or false to skip. + * @return string Profile ID. + */ + private function loadProfileFromStorage( $mobile, $desktop, $global = false ) { + $profile_id = 'shape_' . wp_generate_uuid4(); + if ( false !== $mobile ) { + set_transient( Transients::PREFIX . $profile_id . '_' . Profile::DEVICE_TYPE_MOBILE, $mobile, HOUR_IN_SECONDS ); + } + if ( false !== $desktop ) { + set_transient( Transients::PREFIX . $profile_id . '_' . Profile::DEVICE_TYPE_DESKTOP, $desktop, HOUR_IN_SECONDS ); + } + if ( false !== $global ) { + set_transient( Transients::PREFIX . $profile_id, $global, HOUR_IN_SECONDS ); + } + Profile::reset_current_profile(); + Profile::set_current_profile_id( $profile_id ); + Optml_Manager::instance()->page_profiler->set_current_profile_data(); + return $profile_id; + } + + /** + * @dataProvider normalizeValueProvider + * @param mixed $input Raw value. + * @param mixed $expected Expected normalize result. + */ + public function test_normalize_value( $input, $expected ) { + $this->assertSame( $expected, ProfilerStorage::normalize_value( $input ) ); + } + + /** + * Data provider for normalize_value. + * + * @return array + */ + public function normalizeValueProvider() { + return [ + 'false' => [ false, false ], + 'null' => [ null, false ], + 'string' => [ 'corrupt', false ], + 'integer' => [ 0, false ], + 'empty_array' => [ [], [] ], + 'flat_array' => [ + [ 'af' => [ 1 => true ] ], + [ 'af' => [ 1 => true ] ], + ], + ]; + } + + /** + * Nested stdClass trees become associative arrays, including numeric keys. + */ + public function test_normalize_value_converts_nested_stdclass() { + $object = $this->objectProfile(); + $normalized = ProfilerStorage::normalize_value( $object ); + + $this->assertIsArray( $normalized ); + $this->assertIsArray( $normalized['af'] ); + $this->assertTrue( $normalized['af'][ self::ABOVE_FOLD_IMAGE_ID ] ); + $this->assertIsArray( $normalized['bg'] ); + $this->assertIsArray( $normalized['lcp'] ); + $this->assertSame( 'img', $normalized['lcp']['type'] ); + $this->assertSame( self::ABOVE_FOLD_IMAGE_ID, $normalized['lcp']['imageId'] ); + } + + /** + * Top-level array with object-shaped members is still coerced. + */ + public function test_normalize_value_converts_object_members_inside_array() { + $payload = [ + 'af' => (object) [ (string) self::ABOVE_FOLD_IMAGE_ID => true ], + 'bg' => (object) [], + 'lcp' => (object) [ 'type' => 'img', 'imageId' => self::ABOVE_FOLD_IMAGE_ID ], + ]; + $normalized = ProfilerStorage::normalize_value( $payload ); + + $this->assertIsArray( $normalized['af'] ); + $this->assertTrue( ! empty( $normalized['af'][ self::ABOVE_FOLD_IMAGE_ID ] ) ); + $this->assertIsArray( $normalized['lcp'] ); + $this->assertSame( 'img', $normalized['lcp']['type'] ); + } + + /** + * Object cache get() returns arrays when the backend stored stdClass. + */ + public function test_object_cache_get_normalizes_stdclass() { + $storage = new ObjectCache(); + $key = 'shape_oc_' . wp_generate_uuid4(); + wp_cache_set( $key, $this->objectProfile(), ObjectCache::GROUP ); + + $retrieved = $storage->get( $key ); + $this->assertIsArray( $retrieved ); + $this->assertTrue( $retrieved['af'][ self::ABOVE_FOLD_IMAGE_ID ] ); + } + + /** + * Object cache miss stays false. + */ + public function test_object_cache_get_miss_returns_false() { + $storage = new ObjectCache(); + $this->assertFalse( $storage->get( 'missing_profiler_key_' . wp_generate_uuid4() ) ); + } + + /** + * Object cache still returns stored arrays unchanged. + */ + public function test_object_cache_get_preserves_arrays() { + $storage = new ObjectCache(); + $key = 'shape_oc_array_' . wp_generate_uuid4(); + $payload = $this->arrayProfile(); + $storage->store( $key, $payload ); + + $this->assertSame( $payload, $storage->get( $key ) ); + } + + /** + * Corrupt object-cache values are treated as a miss. + */ + public function test_object_cache_get_rejects_scalars() { + $storage = new ObjectCache(); + $key = 'shape_oc_bad_' . wp_generate_uuid4(); + wp_cache_set( $key, 'not-a-profile', ObjectCache::GROUP ); + + $this->assertFalse( $storage->get( $key ) ); + } + + /** + * Transient get() returns arrays when the stored value is stdClass. + */ + public function test_transients_get_normalizes_stdclass() { + $storage = new Transients(); + $key = 'shape_tr_' . wp_generate_uuid4(); + set_transient( Transients::PREFIX . $key, $this->objectProfile(), HOUR_IN_SECONDS ); + + $retrieved = $storage->get( $key ); + $this->assertIsArray( $retrieved ); + $this->assertTrue( $retrieved['af'][ self::ABOVE_FOLD_IMAGE_ID ] ); + } + + /** + * Transient miss stays false. + */ + public function test_transients_get_miss_returns_false() { + $storage = new Transients(); + $this->assertFalse( $storage->get( 'missing_transient_' . wp_generate_uuid4() ) ); + } + + /** + * The reported crash: indexing object-shaped device data as an array. + */ + public function test_is_in_all_viewports_does_not_fatal_on_stdclass() { + $this->loadProfileFromStorage( $this->objectProfile(), $this->objectProfile() ); + $profiler = Optml_Manager::instance()->page_profiler; + + $this->assertTrue( $profiler->is_in_all_viewports( self::ABOVE_FOLD_IMAGE_ID ) ); + $this->assertFalse( $profiler->is_in_all_viewports( self::OTHER_IMAGE_ID ) ); + } + + /** + * Object-shaped above-fold map only (array wrapper, object `af`). + */ + public function test_is_in_all_viewports_with_object_shaped_af_member() { + $mobile = $this->arrayProfile(); + $desktop = $this->arrayProfile(); + $mobile['af'] = (object) [ (string) self::ABOVE_FOLD_IMAGE_ID => true ]; + $desktop['af'] = (object) [ (string) self::ABOVE_FOLD_IMAGE_ID => true ]; + $this->loadProfileFromStorage( $mobile, $desktop ); + $profiler = Optml_Manager::instance()->page_profiler; + + $this->assertTrue( $profiler->is_in_all_viewports( self::ABOVE_FOLD_IMAGE_ID ) ); + } + + /** + * Missing or empty object-shaped device data is treated as unavailable. + */ + public function test_is_in_all_viewports_empty_object_is_unavailable() { + $this->loadProfileFromStorage( new stdClass(), $this->objectProfile() ); + $profiler = Optml_Manager::instance()->page_profiler; + + $this->assertFalse( $profiler->is_in_all_viewports( self::ABOVE_FOLD_IMAGE_ID ) ); + $this->assertFalse( $profiler->is_data_available() ); + } + + /** + * is_in_any_viewport must not fatal on object-shaped data. + */ + public function test_is_in_any_viewport_does_not_fatal_on_stdclass() { + $this->loadProfileFromStorage( $this->objectProfile(), $this->objectProfile() ); + $profiler = Optml_Manager::instance()->page_profiler; + + $this->assertSame( Profile::DEVICE_TYPE_MOBILE, $profiler->is_in_any_viewport( self::ABOVE_FOLD_IMAGE_ID ) ); + $this->assertFalse( $profiler->is_in_any_viewport( self::OTHER_IMAGE_ID ) ); + } + + /** + * LCP lookup must not fatal on object-shaped `lcp`. + */ + public function test_is_lcp_image_in_all_viewports_does_not_fatal_on_stdclass() { + $this->loadProfileFromStorage( $this->objectProfile(), $this->objectProfile() ); + $profiler = Optml_Manager::instance()->page_profiler; + + $this->assertTrue( $profiler->is_lcp_image_in_all_viewports( self::ABOVE_FOLD_IMAGE_ID ) ); + $this->assertFalse( $profiler->is_lcp_image_in_all_viewports( self::OTHER_IMAGE_ID ) ); + } + + /** + * Missing LCP imageId must not warn or fatal. + */ + public function test_is_lcp_image_handles_missing_image_id() { + $payload = $this->arrayProfile(); + $payload['lcp'] = [ 'type' => 'img' ]; + $this->loadProfileFromStorage( $payload, $payload ); + $profiler = Optml_Manager::instance()->page_profiler; + + $this->assertFalse( $profiler->is_lcp_image_in_all_viewports( self::ABOVE_FOLD_IMAGE_ID ) ); + } + + /** + * Global missing-dimension lookups must not fatal on object-shaped global data. + */ + public function test_global_lookups_do_not_fatal_on_stdclass() { + $global = (object) [ + 'm' => (object) [ + (string) self::ABOVE_FOLD_IMAGE_ID => (object) [ 'w' => 100, 'h' => 80 ], + ], + 's' => (object) [ + (string) self::ABOVE_FOLD_IMAGE_ID => (object) [ + '200' => (object) [ + 'w' => 200, + 'h' => 160, + 'd' => 1, + 's' => 'https://example.com/i.jpg', + 'b' => 1, + ], + ], + ], + 'c' => (object) [ (string) self::ABOVE_FOLD_IMAGE_ID => true ], + ]; + $this->loadProfileFromStorage( $this->objectProfile(), $this->objectProfile(), $global ); + $profiler = Optml_Manager::instance()->page_profiler; + + $this->assertSame( [ 'w' => 100, 'h' => 80 ], $profiler->get_missing_dimensions( self::ABOVE_FOLD_IMAGE_ID ) ); + $this->assertSame( [], $profiler->get_missing_dimensions( self::OTHER_IMAGE_ID ) ); + $this->assertNotEmpty( $profiler->get_missing_srcsets( self::ABOVE_FOLD_IMAGE_ID ) ); + $this->assertTrue( $profiler->get_crop_status( self::ABOVE_FOLD_IMAGE_ID ) ); + $this->assertFalse( $profiler->get_crop_status( self::OTHER_IMAGE_ID ) ); + } + + /** + * Loading current profile data from object-shaped transients yields arrays. + */ + public function test_set_current_profile_data_normalizes_transients() { + $this->loadProfileFromStorage( $this->objectProfile(), $this->objectProfile() ); + $data = Profile::get_current_profile_data(); + $profiler = Optml_Manager::instance()->page_profiler; + + $this->assertIsArray( $data[ Profile::DEVICE_TYPE_MOBILE ] ); + $this->assertIsArray( $data[ Profile::DEVICE_TYPE_DESKTOP ] ); + $this->assertTrue( $data[ Profile::DEVICE_TYPE_MOBILE ]['af'][ self::ABOVE_FOLD_IMAGE_ID ] ); + $this->assertTrue( $profiler->is_in_all_viewports( self::ABOVE_FOLD_IMAGE_ID ) ); + } + + /** + * get_profile_data() also normalizes object-shaped storage. + */ + public function test_get_profile_data_normalizes_stdclass() { + $profile_id = 'shape_get_' . wp_generate_uuid4(); + set_transient( + Transients::PREFIX . $profile_id . '_' . Profile::DEVICE_TYPE_DESKTOP, + $this->objectProfile(), + HOUR_IN_SECONDS + ); + + $data = Optml_Manager::instance()->page_profiler->get_profile_data( $profile_id ); + $this->assertIsArray( $data[ Profile::DEVICE_TYPE_DESKTOP ] ); + $this->assertTrue( $data[ Profile::DEVICE_TYPE_DESKTOP ]['af'][ self::ABOVE_FOLD_IMAGE_ID ] ); + } + + /** + * exists() is true after object-shaped data is normalized, false for scalars. + */ + public function test_exists_with_object_shaped_and_corrupt_storage() { + $profiler = Optml_Manager::instance()->page_profiler; + $good_id = 'shape_exists_good_' . wp_generate_uuid4(); + $bad_id = 'shape_exists_bad_' . wp_generate_uuid4(); + + set_transient( + Transients::PREFIX . $good_id . '_' . Profile::DEVICE_TYPE_DESKTOP, + $this->objectProfile(), + HOUR_IN_SECONDS + ); + set_transient( + Transients::PREFIX . $bad_id . '_' . Profile::DEVICE_TYPE_DESKTOP, + 'nope', + HOUR_IN_SECONDS + ); + + $this->assertTrue( $profiler->exists( $good_id, Profile::DEVICE_TYPE_DESKTOP ) ); + $this->assertFalse( $profiler->exists( $bad_id, Profile::DEVICE_TYPE_DESKTOP ) ); + $this->assertFalse( $profiler->exists_all( $good_id ) ); + } + + /** + * Personalized background CSS must not fatal when current profile came from stdClass storage. + */ + public function test_personalized_css_does_not_fatal_on_stdclass() { + $this->loadProfileFromStorage( $this->objectProfile(), $this->objectProfile() ); + + $css = Lazyload::get_current_personalized_css(); + $this->assertIsString( $css ); + } + + /** + * Frontend HTML replacement must not fatal when transients hold stdClass profiles. + */ + public function test_replace_content_does_not_fatal_on_object_shaped_profile() { + $profile_id = $this->loadProfileFromStorage( $this->objectProfile(), $this->objectProfile() ); + add_filter( + 'optml_page_profile_id', + function () use ( $profile_id ) { + return $profile_id; + } + ); + + $html = Optml_Manager::instance()->replace_content( Test_Lazyload_Viewport::get_sample_html() ); + $this->assertNotEmpty( $html ); + $this->assertStringContainsString( 'loadProfileFromStorage( $this->objectProfile(), $this->objectProfile() ); + + $replacer = Optml_Lazyload_Replacer::instance(); + $url = 'https://example.com/test-image.jpg'; + $tag = 'test'; + + $this->assertIsBool( $replacer->can_lazyload_for( $url, $tag ) ); + } +} From 0126cec830693f5439e71d1b5a33d0de84f77c3b Mon Sep 17 00:00:00 2001 From: Marius Cristea Date: Wed, 2 Sep 2026 17:03:11 +0300 Subject: [PATCH 7/9] fix: do not index WP_Error when polling optimized images (#1134) get_optimized_images() can return WP_Error or false on transport and API failures. Guard poll_optimized_images() so dashboard polling degrades to an empty list instead of fatalling. Co-authored-by: Cursor --- inc/rest.php | 2 +- tests/test-poll-optimized-images.php | 219 +++++++++++++++++++++++++++ 2 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 tests/test-poll-optimized-images.php diff --git a/inc/rest.php b/inc/rest.php index 6e70298c..59d8be1c 100644 --- a/inc/rest.php +++ b/inc/rest.php @@ -709,7 +709,7 @@ public function poll_optimized_images( WP_REST_Request $request ) { $api_key = $request->get_param( 'api_key' ); $request = new Optml_Api(); $images = $request->get_optimized_images( $api_key ); - if ( ! isset( $images['list'] ) || empty( $images['list'] ) ) { + if ( is_wp_error( $images ) || ! is_array( $images ) || empty( $images['list'] ) ) { return $this->response( [] ); } diff --git a/tests/test-poll-optimized-images.php b/tests/test-poll-optimized-images.php new file mode 100644 index 00000000..929d7e56 --- /dev/null +++ b/tests/test-poll-optimized-images.php @@ -0,0 +1,219 @@ +update( + 'service_data', + [ + 'cdn_key' => 'test123', + 'cdn_secret' => '12345', + 'whitelist' => [ 'example.com' ], + ] + ); + add_filter( 'pre_http_request', [ $this, 'filter_pre_http_request' ], 10, 3 ); + } + + /** + * Clean up after each test. + */ + public function tearDown(): void { + parent::tearDown(); + remove_filter( 'pre_http_request', [ $this, 'filter_pre_http_request' ], 10 ); + $this->http_mock = null; + } + + /** + * Route HTTP mocks for dashboard stats/images requests. + * + * @param false|array|WP_Error $preempt Whether to preempt. + * @param array $args Request args. + * @param string $url Request URL. + * @return false|array|WP_Error + */ + public function filter_pre_http_request( $preempt, $args, $url ) { + if ( strpos( $url, 'stats/images' ) === false ) { + return $preempt; + } + if ( is_callable( $this->http_mock ) ) { + return call_user_func( $this->http_mock, $preempt, $args, $url ); + } + return $preempt; + } + + /** + * Call poll_optimized_images and return decoded REST payload. + * + * @return array{data: mixed, code: string|int} + */ + private function poll() { + $rest = new Optml_Rest(); + $request = new WP_REST_Request( 'GET' ); + $request->set_param( 'api_key', 'test-key' ); + $response = $rest->poll_optimized_images( $request ); + $this->assertInstanceOf( WP_REST_Response::class, $response ); + return $response->get_data(); + } + + /** + * Transport WP_Error must not fatal; polling returns an empty list. + */ + public function test_poll_returns_empty_list_on_transport_wp_error() { + $this->http_mock = function () { + return new WP_Error( 'http_request_failed', 'Could not connect' ); + }; + + $payload = $this->poll(); + $this->assertSame( 'success', $payload['code'] ); + $this->assertSame( [], $payload['data'] ); + } + + /** + * Non-200 API payload with an error field becomes WP_Error in Optml_Api::request(). + */ + public function test_poll_returns_empty_list_on_api_error_payload() { + $this->http_mock = function () { + return [ + 'headers' => [], + 'body' => wp_json_encode( + [ + 'code' => 500, + 'error' => 'upstream failed', + ] + ), + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + ]; + }; + + $payload = $this->poll(); + $this->assertSame( 'success', $payload['code'] ); + $this->assertSame( [], $payload['data'] ); + } + + /** + * Empty API body is documented as false and must not fatal. + */ + public function test_poll_returns_empty_list_on_false_api_result() { + $this->http_mock = function () { + return [ + 'headers' => [], + 'body' => '', + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + ]; + }; + + $payload = $this->poll(); + $this->assertSame( 'success', $payload['code'] ); + $this->assertSame( [], $payload['data'] ); + } + + /** + * Successful payload without a list is treated as empty. + */ + public function test_poll_returns_empty_list_when_list_missing() { + $this->http_mock = function () { + return [ + 'headers' => [], + 'body' => wp_json_encode( + [ + 'code' => 200, + 'data' => [ 'count' => 0 ], + ] + ), + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + ]; + }; + + $payload = $this->poll(); + $this->assertSame( 'success', $payload['code'] ); + $this->assertSame( [], $payload['data'] ); + } + + /** + * Successful payload with an empty list is treated as empty. + */ + public function test_poll_returns_empty_list_when_list_empty() { + $this->http_mock = function () { + return [ + 'headers' => [], + 'body' => wp_json_encode( + [ + 'code' => 200, + 'data' => [ 'list' => [] ], + ] + ), + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + ]; + }; + + $payload = $this->poll(); + $this->assertSame( 'success', $payload['code'] ); + $this->assertSame( [], $payload['data'] ); + } + + /** + * A valid list is returned (URLs rewritten through Optimole). + */ + public function test_poll_returns_images_from_list() { + $this->http_mock = function () { + return [ + 'headers' => [], + 'body' => wp_json_encode( + [ + 'code' => 200, + 'data' => [ + 'list' => [ + [ + 'url' => 'https://example.com/photo.jpg', + 'key' => 'AbC', + ], + ], + ], + ] + ), + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + ]; + }; + + $payload = $this->poll(); + $this->assertSame( 'success', $payload['code'] ); + $this->assertCount( 1, $payload['data'] ); + $this->assertArrayHasKey( 'url', $payload['data'][0] ); + $this->assertArrayNotHasKey( 'key', $payload['data'][0] ); + $this->assertStringContainsString( 'example.com/photo.jpg', $payload['data'][0]['url'] ); + } +} From 141bd2e8528b06e09a7a5db078f0660632eac1f6 Mon Sep 17 00:00:00 2001 From: Girish Panchal <79647963+girishpanchal30@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:31:16 +0530 Subject: [PATCH 8/9] Normalized file permissions during replacement (#1110) * fix: normalize file permissions during replacement * fix: improve file permission handling * fix: phpunit * fix: improve error message * fix: reuse existing generic error for permission failure The permission failure isn't actionable by the user, so reuse the already-translated "Error replacing file" string instead of adding a new untranslated one. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: selul Co-authored-by: Claude Opus 5 --- inc/media_rename/attachment_replace.php | 67 +++++++- .../media_rename/test-attachment-replace.php | 159 ++++++++++++++++++ 2 files changed, 225 insertions(+), 1 deletion(-) diff --git a/inc/media_rename/attachment_replace.php b/inc/media_rename/attachment_replace.php index c5b4c9fa..d4b19733 100644 --- a/inc/media_rename/attachment_replace.php +++ b/inc/media_rename/attachment_replace.php @@ -83,7 +83,7 @@ public function replace() { return new WP_Error( 'file_error', __( 'Could not move file.', 'optimole-wp' ) ); } - $wp_filesystem->chmod( $original_file, FS_CHMOD_FILE ); + $permissions_normalized = $this->normalize_file_permissions( $original_file ); $this->remove_all_image_sizes(); @@ -102,9 +102,74 @@ public function replace() { do_action( 'optml_attachment_replaced', $this->attachment_id ); + if ( ! $permissions_normalized ) { + return new WP_Error( 'file_permissions_error', __( 'Error replacing file', 'optimole-wp' ) ); + } + return true; } + /** + * Normalize the permissions of the replaced file. + * + * @param string $file File path. + * + * @return bool Whether the file ended up with the expected permissions. + */ + private function normalize_file_permissions( $file ) { + global $wp_filesystem; + + $mode = defined( 'FS_CHMOD_FILE' ) ? FS_CHMOD_FILE : 0644; + + $applied = $wp_filesystem->chmod( $file, $mode ); + + $reason = ''; + + if ( ! $applied || ! $this->has_permissions( $file, $mode ) ) { + // Fallback for transports where the filesystem abstraction can't chmod. + set_error_handler( + function ( $errno, $errstr ) use ( &$reason ) { + $reason = $errstr; + + return true; + } + ); + + $applied = chmod( $file, $mode ); + + restore_error_handler(); + } + + if ( $applied && $this->has_permissions( $file, $mode ) ) { + return true; + } + + if ( OPTML_DEBUG ) { + do_action( + 'optml_log', + sprintf( 'Could not normalize permissions to %o for replaced file %s. %s', $mode, $file, $reason ) + ); + } + + return false; + } + + /** + * Check the current permissions of a file against an expected mode. + * + * @param string $file File path. + * @param int $mode Expected mode. + * + * @return bool + */ + private function has_permissions( $file, $mode ) { + clearstatcache( true, $file ); + + $perms = fileperms( $file ); + + return false !== $perms && ( $perms & 0777 ) === ( $mode & 0777 ); + } + /** * Remove all image sizes files. * diff --git a/tests/media_rename/test-attachment-replace.php b/tests/media_rename/test-attachment-replace.php index fa170670..e643e97f 100644 --- a/tests/media_rename/test-attachment-replace.php +++ b/tests/media_rename/test-attachment-replace.php @@ -109,6 +109,165 @@ private function test_replace_unscaled_to_unscaled() { $this->do_replace_test( self::$unscaled_unscaled_id, $replace_file, false, false ); } + /** + * A 0600 upload tmp file must not leave the replaced attachment unreadable to the web server. + */ + public function test_replace_normalizes_permissions_of_restricted_tmp_file() { + global $wp_filesystem; + + $id = self::factory()->attachment->create_upload_object( OPTML_PATH . 'tests/assets/sample-test.jpg' ); + + $tmp_file = self::FILESTASH . 'replace-restricted.jpg'; + $wp_filesystem->copy( OPTML_PATH . 'tests/assets/small-1.jpg', $tmp_file, true ); + chmod( $tmp_file, 0600 ); + + $model = new Optml_Attachment_Model( $id ); + $file_path = $model->get_source_file_path(); + + $replacer = new Optml_Attachment_Replace( + $id, + [ + 'name' => 'replace-restricted.jpg', + 'type' => 'image/jpeg', + 'tmp_name' => $tmp_file, + ] + ); + + $result = $replacer->replace(); + + clearstatcache( true, $file_path ); + + $this->assertTrue( $result, 'Replacement operation failed.' ); + $this->assertSame( FS_CHMOD_FILE & 0777, fileperms( $file_path ) & 0777, 'Replaced file kept the restrictive tmp file permissions.' ); + + wp_delete_post( $id, true ); + } + + /** + * When the filesystem abstraction can't chmod, the native fallback must still fix the file. + */ + public function test_replace_falls_back_to_native_chmod() { + global $wp_filesystem; + + $real_filesystem = $wp_filesystem; + + $id = self::factory()->attachment->create_upload_object( OPTML_PATH . 'tests/assets/sample-test.jpg' ); + $model = new Optml_Attachment_Model( $id ); + $file_path = $model->get_source_file_path(); + + $tmp_file = self::FILESTASH . 'replace-fallback.jpg'; + $wp_filesystem->copy( OPTML_PATH . 'tests/assets/small-1.jpg', $tmp_file, true ); + chmod( $tmp_file, 0600 ); + + $replacer = new Optml_Attachment_Replace( + $id, + [ + 'name' => 'replace-fallback.jpg', + 'type' => 'image/jpeg', + 'tmp_name' => $tmp_file, + ] + ); + + // After the constructor: it calls WP_Filesystem(), which reassigns the global. + $wp_filesystem = self::failing_chmod_filesystem(); + + try { + $result = $replacer->replace(); + } finally { + $wp_filesystem = $real_filesystem; + } + + clearstatcache( true, $file_path ); + + $this->assertTrue( $result, 'Replacement operation failed.' ); + $this->assertSame( FS_CHMOD_FILE & 0777, fileperms( $file_path ) & 0777, 'The native chmod fallback did not normalize the permissions.' ); + + wp_delete_post( $id, true ); + } + + /** + * With both chmod attempts failing, the replacement must not be reported as a plain success. + */ + public function test_replace_reports_error_when_permissions_cannot_be_normalized() { + global $wp_filesystem; + + $real_filesystem = $wp_filesystem; + + $id = self::factory()->attachment->create_upload_object( OPTML_PATH . 'tests/assets/sample-test.jpg' ); + $model = new Optml_Attachment_Model( $id ); + $file_path = $model->get_source_file_path(); + + $tmp_file = self::FILESTASH . 'replace-unfixable.jpg'; + $wp_filesystem->copy( OPTML_PATH . 'tests/assets/small-1.jpg', $tmp_file, true ); + chmod( $tmp_file, 0600 ); + + $replacer = new Optml_Attachment_Replace( + $id, + [ + 'name' => 'replace-unfixable.jpg', + 'type' => 'image/jpeg', + 'tmp_name' => $tmp_file, + ] + ); + + // After the constructor: it calls WP_Filesystem(), which reassigns the global. + $wp_filesystem = self::unfixable_filesystem(); + + // The metadata step warns about the intentionally missing file; PHPUnit turns that into an error. + set_error_handler( '__return_true' ); + + try { + $result = $replacer->replace(); + } finally { + restore_error_handler(); + $wp_filesystem = $real_filesystem; + } + + clearstatcache( true, $file_path ); + + $this->assertWPError( $result, 'Replacement was reported as a success.' ); + $this->assertSame( 'file_permissions_error', $result->get_error_code() ); + $this->assertFileDoesNotExist( $file_path, 'The test did not exercise an unfixable file.' ); + + wp_delete_post( $id, true ); + } + + /** + * A direct filesystem whose chmod always fails, as an FTP/SSH transport can. + * + * @return WP_Filesystem_Direct + */ + private static function failing_chmod_filesystem() { + return new class( null ) extends WP_Filesystem_Direct { + public function chmod( $file, $mode = false, $recursive = false ) { + return false; + } + }; + } + + /** + * A filesystem that reports a successful move but leaves nothing at the destination. + * + * Both chmod attempts then fail with ENOENT, which is the only way to reach the failure + * branch without root: a file the test user owns can always be chmod'ed natively. + * + * @return WP_Filesystem_Direct + */ + private static function unfixable_filesystem() { + return new class( null ) extends WP_Filesystem_Direct { + public function move( $source, $destination, $overwrite = false ) { + @unlink( $source ); + @unlink( $destination ); + + return true; + } + + public function chmod( $file, $mode = false, $recursive = false ) { + return false; + } + }; + } + private function do_replace_test( $id_to_replace, $replace_file, $source_scaled, $result_scaled ) { // Removed var_dump From d6cc7b54b8e84e061ea8499166203117640a47af Mon Sep 17 00:00:00 2001 From: Marius Cristea Date: Fri, 4 Sep 2026 15:03:18 +0300 Subject: [PATCH 9/9] fix: process page HTML outside PHP's output-buffer display handler (#1132) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: process page HTML outside PHP's output-buffer display handler Running replace_content() as the ob_start() display handler meant any output-buffering call from third-party code hooked into our filters was a fatal error, and any real fatal during processing (e.g. memory exhaustion) was masked as "Cannot use output buffering in output buffering display handlers" with a misleading crash location. The buffer is now a plain capture: close_buffer() flushes third-party buffers stacked above ours, captures our own by its recorded nesting level (never popping someone else's buffer), processes the HTML in normal execution context and re-arms the capture so late shutdown output is still handled. The attached handler remains only as a fallback that keeps the previous behavior when third-party code flushes our buffer mid-request. Also replaces the per-URL full-page preg_replace() loop with chunked single-pass replacement to reduce peak memory on large pages, the likely trigger of the masked production fatals. Fixes #1126 Co-Authored-By: Claude Fable 5 * fix: bound URL replacement chunks by pattern size, not only count A chunk of 200 very long URLs (e.g. signed CDN URLs with kilobyte-sized query strings) could exceed PCRE's ~64KB compiled-pattern limit, failing the whole chunk and leaving those URLs unreplaced. Chunks now flush when the accumulated quoted pattern reaches 24KB, so compilation always succeeds regardless of URL length, and a failed chunk is logged via optml_log instead of being silently skipped. Co-Authored-By: Claude Fable 5 * perf: apply URL replacement chunks as they fill Building every chunk's bookkeeping up front held all origin/replacement maps in memory at once, which cost about 1MB extra on pages with thousands of URLs. Each chunk is now applied as soon as it fills, so only one chunk's bookkeeping exists at a time; peak memory is now at or below the old per-URL loop at every scale. Co-Authored-By: Claude Fable 5 * fix: address review — no in-handler processing, real buffer ownership P1: the fallback handler no longer runs replace_content() when a third party flushes our buffer early. The ob-in-handler fatal is an engine E_ERROR that catch (Throwable) cannot intercept, so processing there reintroduced the crash this rework removes; early-flushed content is now passed through unprocessed and logged. Only the explicit legacy mode (optml_capture_at_shutdown false) keeps in-handler processing. P2: buffer ownership is now verified by handler identity, not nesting level alone. The capture buffer uses a named method handler so ob_get_status()['name'] reports Optml_Manager::handle_buffer_fallback, and capture_and_process_buffer() refuses any buffer that does not carry it — a foreign buffer at our recorded level is never consumed. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- inc/manager.php | 224 +++++++++++++++++++++++++++--- tests/test-zz-buffer.php | 293 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 500 insertions(+), 17 deletions(-) create mode 100644 tests/test-zz-buffer.php diff --git a/inc/manager.php b/inc/manager.php index 06944ef8..cb3cea3f 100644 --- a/inc/manager.php +++ b/inc/manager.php @@ -123,6 +123,23 @@ final class Optml_Manager { * @var boolean Buffer state. */ private static $ob_started = false; + /** + * The output-buffer nesting level of our capture buffer. + * + * Used to make sure we only ever capture or close our own buffer and not + * one started by a third party. + * + * @var int Buffer nesting level, 0 when no capture buffer is armed. + */ + private static $ob_level = 0; + /** + * Whether the captured buffer was already processed at shutdown. + * + * When true, the fallback output handler passes content through untouched. + * + * @var boolean Processed state. + */ + private static $ob_processed = false; /** * Class instance method. @@ -409,6 +426,7 @@ public function register_hooks() { add_action( 'template_redirect', [ $this, 'register_after_setup' ] ); add_action( 'rest_api_init', [ $this, 'process_template_redirect_content' ], PHP_INT_MIN ); add_action( 'shutdown', [ $this, 'close_buffer' ], PHP_INT_MIN ); + add_action( 'shutdown', [ $this, 'close_final_buffer' ], PHP_INT_MAX ); foreach ( self::$loaded_compatibilities as $registered_compatibility ) { $registered_compatibility->register(); } @@ -823,13 +841,61 @@ function ( $url ) use ( $upload_resource ) { $urls ); + /* + * Replace all URLs in a single pass per chunk instead of one full-page + * preg_replace() per URL, which scanned and rebuilt the whole page for + * every replaced URL. Chunks are bounded by pattern size, not only + * count, so the compiled regex stays within PCRE's ~64KB limit even + * for very long URLs (e.g. signed CDN URLs with kilobyte-sized query + * strings). Each chunk is applied as soon as it fills, so only one + * chunk's bookkeeping is in memory at a time. + */ + $chunk = []; + $quoted = []; + $quoted_size = 0; foreach ( $urls as $origin => $replace ) { - $html = preg_replace( '/(?= 200 || $quoted_size + strlen( $quoted_origin ) > 24000 ) ) { + $html = $this->replace_urls_chunk( $html, $chunk, $quoted ); + $chunk = []; + $quoted = []; + $quoted_size = 0; + } + $chunk[ $origin ] = $replace; + $quoted[] = $quoted_origin; + $quoted_size += strlen( $quoted_origin ) + 1; + } + if ( ! empty( $chunk ) ) { + $html = $this->replace_urls_chunk( $html, $chunk, $quoted ); } return $html; } + /** + * Replace one chunk of URLs in the content with a single combined pattern. + * + * @param string $html Content to process. + * @param array $chunk Map of origin => replacement URLs. + * @param string[] $quoted The preg_quote()d origins, in the same order. + * + * @return string Processed content, unchanged when the pattern fails. + */ + private function replace_urls_chunk( $html, $chunk, $quoted ) { + $result = preg_replace_callback( + '/(?start_capture_buffer(); + } + + /** + * Start an output buffer that captures 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". + * + * 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']. + * + * @return void + */ + private function start_capture_buffer() { + self::$ob_processed = false; + ob_start( [ $this, 'handle_buffer_fallback' ] ); + self::$ob_level = ob_get_level(); + } + + /** + * The handler name PHP reports for our capture buffer in ob_get_status(). + */ + const OB_HANDLER_NAME = 'Optml_Manager::handle_buffer_fallback'; + + /** + * Output-buffer handler attached to our capture buffer. + * + * 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. + * + * @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). + * + * @return string The content to output. + */ + public function handle_buffer_fallback( $content, $phase = 0 ) { + if ( self::$ob_processed || $content === '' ) { + return $content; + } + if ( apply_filters( 'optml_capture_at_shutdown', true ) === false ) { + try { return $this->replace_content( $content, self::is_ajax_request() ); + } catch ( Throwable $t ) { + // Never break the page from inside a display handler. + do_action( 'optml_log', 'replace_content failed inside the output handler: ' . $t->getMessage() ); + return $content; } - ); + } + do_action( 'optml_log', 'Optimole buffer was flushed outside close_buffer(); content passed through unprocessed.' ); + + return $content; } /** * Close the buffer and flush the content. */ public function close_buffer() { - if ( self::$ob_started && ob_get_length() ) { - ob_end_flush(); + if ( ! self::$ob_started ) { + 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 ( ob_get_length() ) { + ob_end_flush(); + } + return; + } + + /* + * Flush the buffers other plugins stacked on top of ours so their + * handlers still transform the page before we process it, preserving + * the same order as a full top-down flush at request shutdown. + */ + while ( ob_get_level() > self::$ob_level ) { + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a non-flushable buffer must not raise a notice; we stop on failure. + if ( ! @ob_end_flush() ) { + break; + } + } + + if ( ! $this->capture_and_process_buffer() ) { + do_action( 'optml_log', 'Optimole buffer was closed earlier by third-party code.' ); + return; + } + + /* + * Re-arm the capture so output echoed by later shutdown callbacks is + * still processed and unguarded third-party flush calls find a buffer + * to close instead of raising a notice. + */ + $this->start_capture_buffer(); + } + + /** + * Close the re-armed buffer at the very end of shutdown. + * + * @return void + */ + public function close_final_buffer() { + if ( ! self::$ob_started ) { + return; + } + $this->capture_and_process_buffer(); + } + + /** + * Capture our buffer, process it outside the display-handler context and echo the result. + * + * Ownership is verified by both nesting level and handler identity, so a + * buffer another plugin opened at the same level after ours was closed is + * never captured or closed by us. + * + * @return bool Whether our buffer was found and consumed. + */ + private function capture_and_process_buffer() { + if ( self::$ob_level === 0 || ob_get_level() !== self::$ob_level ) { + return false; + } + $status = ob_get_status(); + if ( ( $status['name'] ?? '' ) !== self::OB_HANDLER_NAME ) { + return false; + } + $html = ob_get_contents(); + // Set before ob_end_clean() so our handler no-ops during buffer cleanup. + self::$ob_processed = true; + ob_end_clean(); + if ( $html !== false && $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; } /** * Throw error on object clone diff --git a/tests/test-zz-buffer.php b/tests/test-zz-buffer.php new file mode 100644 index 00000000..15425d5c --- /dev/null +++ b/tests/test-zz-buffer.php @@ -0,0 +1,293 @@ +Test '; + + /** + * The output-buffer nesting level before each test. + * + * @var int + */ + private $base_level = 0; + + public function setUp(): void { + parent::setUp(); + $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(); + + $this->reset_buffer_state(); + $this->base_level = ob_get_level(); + } + + public function tearDown(): void { + // Make any leftover capture handler a pass-through before cleaning up. + $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(); + 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 ); + } + } + + /** + * Callbacks on our filters may use output buffering without fataling. + * + * Before processing moved outside the display handler, the nested + * ob_start() below crashed with "Cannot use output buffering in output + * buffering display handlers". + */ + public function test_filter_callbacks_can_use_output_buffering() { + $manager = Optml_Manager::instance(); + $probed = 0; + add_filter( + 'optml_url_pre_process', + function ( $html ) use ( &$probed ) { + ob_start(); + echo 'probe'; + ob_get_clean(); + $probed ++; + return $html; + } + ); + ob_start(); + $manager->process_template_redirect_content(); + echo self::IMG_TAGS; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $manager->close_buffer(); + $manager->close_final_buffer(); + $out = ob_get_clean(); + + $this->assertSame( 1, $probed ); + $this->assertStringContainsString( 'i.optimole.com', $out ); + $this->assertSame( $this->base_level, ob_get_level() ); + } + + /** + * A buffer another plugin stacks on top of ours is flushed through its own + * handler first, and we process its transformed output — never swallow it. + */ + public function test_foreign_buffer_above_is_flushed_first() { + $manager = Optml_Manager::instance(); + ob_start(); + $manager->process_template_redirect_content(); + // Third-party handler started after ours, e.g. a minifier. + ob_start( + function ( $content ) { + return $content . ''; + } + ); + echo self::IMG_TAGS; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $manager->close_buffer(); + $manager->close_final_buffer(); + $out = ob_get_clean(); + + // Both the page image and the one appended by the foreign handler are optimized. + $this->assertSame( 2, substr_count( $out, 'i.optimole.com' ) ); + $this->assertStringNotContainsString( '"http://example.org/wp-content/uploads/foreign.jpg', $out ); + $this->assertSame( $this->base_level, ob_get_level() ); + } + + /** + * When third-party code force-flushes our buffer before shutdown, the + * content is passed through UNPROCESSED: running the filter graph inside a + * display handler would make any third-party ob_*() call an uncatchable + * fatal. close_buffer() detects the loss without side effects. + */ + public function test_third_party_flush_passes_content_through() { + $manager = Optml_Manager::instance(); + ob_start(); + $manager->process_template_redirect_content(); + echo self::IMG_TAGS; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + ob_end_flush(); // Third-party force flush of our buffer. + $this->assertSame( $this->base_level + 1, ob_get_level() ); + $manager->close_buffer(); + $manager->close_final_buffer(); + $out = ob_get_clean(); + + $this->assertStringNotContainsString( 'i.optimole.com', $out ); + $this->assertStringContainsString( 'themes/twentyseventeen/assets/images/header.jpg', $out ); + $this->assertSame( $this->base_level, ob_get_level() ); + } + + /** + * A third-party flush combined with an output-buffering filter callback + * must not fatal. Processing inside the handler would terminate PHP with + * "Cannot use output buffering in output buffering display handlers", + * which catch ( Throwable ) cannot intercept. + */ + public function test_third_party_flush_with_ob_filter_does_not_fatal() { + $manager = Optml_Manager::instance(); + add_filter( + 'optml_url_pre_process', + function ( $html ) { + ob_start(); + echo 'probe'; + ob_get_clean(); + return $html; + } + ); + ob_start(); + $manager->process_template_redirect_content(); + echo self::IMG_TAGS; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + ob_end_flush(); // Would exit(255) if the handler ran the filter graph. + $manager->close_buffer(); + $manager->close_final_buffer(); + $out = ob_get_clean(); + + $this->assertStringContainsString( 'themes/twentyseventeen/assets/images/header.jpg', $out ); + $this->assertSame( $this->base_level, ob_get_level() ); + } + + /** + * A foreign buffer that ends up at our recorded nesting level is never + * captured or closed: ownership requires our handler identity, not just + * the level. + */ + public function test_foreign_buffer_at_same_level_is_not_consumed() { + $manager = Optml_Manager::instance(); + $manager->process_template_redirect_content(); + ob_end_clean(); // Third party discards our buffer... + ob_start(); // ...and opens its own at the same level. + echo 'FOREIGN'; + $manager->close_buffer(); + $manager->close_final_buffer(); + + $this->assertSame( $this->base_level + 1, ob_get_level() ); + $this->assertSame( 'default output handler', ob_get_status()['name'] ); + $this->assertStringContainsString( 'FOREIGN', ob_get_contents() ); + ob_end_clean(); + } + + /** + * Calling process_template_redirect_content() twice must not stack a + * second buffer, and the page is processed exactly once. + */ + public function test_buffer_started_once() { + $manager = Optml_Manager::instance(); + ob_start(); + $manager->process_template_redirect_content(); + $level = ob_get_level(); + $manager->process_template_redirect_content(); + $this->assertSame( $level, ob_get_level() ); + echo self::IMG_TAGS; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $manager->close_buffer(); + $manager->close_final_buffer(); + $out = ob_get_clean(); + + $this->assertSame( 1, substr_count( $out, 'i.optimole.com' ) ); + } + + /** + * An empty buffer closes without output or errors. + */ + public function test_empty_buffer_no_output() { + $manager = Optml_Manager::instance(); + ob_start(); + $manager->process_template_redirect_content(); + $manager->close_buffer(); + $manager->close_final_buffer(); + + $this->assertSame( '', ob_get_clean() ); + $this->assertSame( $this->base_level, ob_get_level() ); + } + + /** + * Output echoed by shutdown callbacks running after close_buffer() is + * captured by the re-armed buffer and still processed. + */ + public function test_late_shutdown_output_is_processed() { + $manager = Optml_Manager::instance(); + ob_start(); + $manager->process_template_redirect_content(); + echo self::IMG_TAGS; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $manager->close_buffer(); + echo self::IMG_TAGS; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $manager->close_final_buffer(); + $out = ob_get_clean(); + + $this->assertSame( 2, substr_count( $out, 'i.optimole.com' ) ); + $this->assertSame( $this->base_level, ob_get_level() ); + } + + /** + * Very long URLs (e.g. signed CDN URLs) must not push a replacement + * chunk's compiled pattern over PCRE's size limit. + */ + public function test_long_url_replacement_stays_within_pcre_limits() { + $manager = Optml_Manager::instance(); + add_filter( + 'optml_content_url', + function ( $url ) { + return 'https://replaced.test/marker'; + } + ); + $urls = []; + $html = ''; + for ( $i = 0; $i < 250; $i ++ ) { + $url = 'https://example.org/image-' . $i . '.jpg?X-Signature=' . str_repeat( 'a1b2c3d4', 180 ) . '&i=' . $i; + $urls[] = $url; + $html .= ''; + } + $out = $manager->do_url_replacement( $html, $urls ); + + $this->assertSame( 250, substr_count( $out, 'https://replaced.test/marker' ) ); + $this->assertStringNotContainsString( 'X-Signature', $out ); + } + + /** + * The optml_capture_at_shutdown filter restores the legacy in-handler flow. + */ + public function test_legacy_in_handler_mode() { + add_filter( 'optml_capture_at_shutdown', '__return_false' ); + $manager = Optml_Manager::instance(); + ob_start(); + $manager->process_template_redirect_content(); + echo self::IMG_TAGS; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $manager->close_buffer(); + $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() ); + } +}