diff --git a/projects/plugins/boost/app/admin/class-config.php b/projects/plugins/boost/app/admin/class-config.php index 36d5d207c455..e580310276c3 100644 --- a/projects/plugins/boost/app/admin/class-config.php +++ b/projects/plugins/boost/app/admin/class-config.php @@ -97,6 +97,10 @@ private static function get_custom_post_types() { * Retrieves the hosting provider. * We're only interested in 'atomic' or 'woa' for now. * + * A new value here also changes CSS and JS delivery: + * jetpack_boost_minify_host_handles_wp_content_404s() reads anything but 'other' as a host that + * answers wp-content 404s itself, and opts it out of static cache URLs for good. + * * @since 3.10.0 * * @return string The hosting provider. diff --git a/projects/plugins/boost/app/class-jetpack-boost.php b/projects/plugins/boost/app/class-jetpack-boost.php index 8c31747f728a..3ceb5d6804bf 100644 --- a/projects/plugins/boost/app/class-jetpack-boost.php +++ b/projects/plugins/boost/app/class-jetpack-boost.php @@ -175,8 +175,12 @@ public function schedule_version_change() { } public function handle_version_change() { - // Remove this option to prevent the notice from showing up. - delete_site_option( 'jetpack_boost_static_minification' ); + // Remove this option to prevent the notice from showing up. This runs before + // jetpack_boost_minify_activation() below, so route the delete through the helper: otherwise + // the activation path finds the row gone and nothing announces the change in emitted URLs. + // It is also the only cleanup a migrated site reaches with both minify modules off, which is + // why the marker drop lives in the helper rather than in the tester paths alone. + jetpack_boost_minify_forget_static_cache_verdict(); // Add upgrade check for Cornerstone Pages. $pages = jetpack_boost_ds_get( 'cornerstone_pages_list' ); diff --git a/projects/plugins/boost/app/lib/minify/class-concatenate-css.php b/projects/plugins/boost/app/lib/minify/class-concatenate-css.php index 22b17bee3229..cf59d2594054 100644 --- a/projects/plugins/boost/app/lib/minify/class-concatenate-css.php +++ b/projects/plugins/boost/app/lib/minify/class-concatenate-css.php @@ -202,7 +202,7 @@ public function do_items( $handles = false, $group = false ) { foreach ( $css_groups as $css_group ) { $file_name = jetpack_boost_page_optimize_generate_concat_path( $css_group, $this->dependency_path_mapping ); - if ( get_site_option( 'jetpack_boost_static_minification' ) ) { + if ( jetpack_boost_minify_use_static_cache_urls() ) { $href = jetpack_boost_get_minify_url( $file_name . '.min.css' ); } else { $href = $siteurl . jetpack_boost_get_static_prefix() . '??' . $file_name; diff --git a/projects/plugins/boost/app/lib/minify/class-concatenate-js.php b/projects/plugins/boost/app/lib/minify/class-concatenate-js.php index e43c496ec0e3..48c2bdf23491 100644 --- a/projects/plugins/boost/app/lib/minify/class-concatenate-js.php +++ b/projects/plugins/boost/app/lib/minify/class-concatenate-js.php @@ -278,7 +278,7 @@ public function do_items( $handles = false, $group = false ) { if ( isset( $js_array['paths'] ) && count( $js_array['paths'] ) > 1 ) { $file_name = jetpack_boost_page_optimize_generate_concat_path( $js_array['paths'], $this->dependency_path_mapping ); - if ( get_site_option( 'jetpack_boost_static_minification' ) ) { + if ( jetpack_boost_minify_use_static_cache_urls() ) { $href = jetpack_boost_get_minify_url( $file_name . '.min.js' ); } else { $href = $siteurl . jetpack_boost_get_static_prefix() . '??' . $file_name; diff --git a/projects/plugins/boost/app/lib/minify/functions-helpers.php b/projects/plugins/boost/app/lib/minify/functions-helpers.php index 4b7ce146fb0b..8b13d3a0575e 100644 --- a/projects/plugins/boost/app/lib/minify/functions-helpers.php +++ b/projects/plugins/boost/app/lib/minify/functions-helpers.php @@ -1,5 +1,6 @@ payload the concatenators emit, or a site with the prefix set to `assets` + * loses each page whose path ends in /assets/ to a 400 that WordPress never sees. + * + * @since $$next-version$$ + * + * @param string $request_uri Request URI, query string included. + * @return bool True if the minify service should handle this request. + */ +function jetpack_boost_minify_request_is_for_static_prefix( $request_uri ) { + $prefix = jetpack_boost_get_static_prefix(); + $parts = explode( '?', $request_uri, 2 ); + + if ( $prefix !== substr( $parts[0], -strlen( $prefix ) ) ) { + return false; + } + + // The length test rejects a bare `??`, which names no bundle. + return isset( $parts[1] ) && strlen( $parts[1] ) > 1 && str_starts_with( $parts[1], '?' ); } /** * Detects requests within the `/_jb_static/` directory, and serves minified content. * + * Nothing in Boost calls this since the router moved into jetpack-boost.php, which runs while + * WordPress loads plugins. It is a jetpack_-prefixed global that shipped in released tags, so it + * stays for the deprecation window in docs/coding-guidelines.md. It now uses the corrected + * predicate, and like the original it does not return once it decides to serve. + * + * @deprecated $$next-version$$ Boost dispatches this from jetpack-boost.php, before the query loads. + * * @return void */ function jetpack_boost_minify_serve_concatenated() { - // Potential improvement: Make concat URL dir configurable - if ( isset( $_SERVER['REQUEST_URI'] ) ) { - // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - $request_path = explode( '?', wp_unslash( $_SERVER['REQUEST_URI'] ) )[0]; - $prefix = jetpack_boost_get_static_prefix(); - if ( $prefix === substr( $request_path, -strlen( $prefix ), strlen( $prefix ) ) ) { - require_once __DIR__ . '/functions-service-fallback.php'; - jetpack_boost_page_optimize_service_request(); - exit( 0 ); // @phan-suppress-current-line PhanPluginUnreachableCode -- Safer to include it even though jetpack_boost_page_optimize_service_request() itself never returns. - } + _deprecated_function( __FUNCTION__, 'jetpack-boost-$$next-version$$' ); + + if ( ! isset( $_SERVER['REQUEST_URI'] ) ) { + return; + } + + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + if ( ! jetpack_boost_minify_request_is_for_static_prefix( wp_unslash( $_SERVER['REQUEST_URI'] ) ) ) { + return; + } + + require_once __DIR__ . '/functions-service-fallback.php'; + jetpack_boost_page_optimize_service_request(); + exit( 0 ); // @phan-suppress-current-line PhanPluginUnreachableCode -- Safer to include it even though jetpack_boost_page_optimize_service_request() itself never returns. +} + +/** + * Whether the host routes requests for missing wp-content files through WordPress. + * + * Static cache URLs depend on that: the first request 404s, and Boost builds the file while it + * answers the 404. Whether a missing .css or .js reaches WordPress is a per-site platform setting on + * Atomic and WP Cloud, and Boost cannot read it, so it does not use static cache URLs there. + * + * The name describes the condition, not the feature, to keep it apart from + * Minify\Config::can_use_static_cache(), which asks whether the cache directory is writable. + * + * Admin\Config::get_hosting_provider() returns 'other' for any host it does not name, so a host it + * does not recognize keeps the static cache. A new named value there opts that provider out for + * good, because the tester paths then skip the probe. + * + * @since $$next-version$$ + * + * @return bool True if WordPress sees wp-content 404s, false if the web server answers them. + */ +function jetpack_boost_minify_host_handles_wp_content_404s() { + return 'other' === Boost_Admin_Config::get_hosting_provider(); +} + +/** + * Whether concatenated files should be linked from the static cache directory. + * + * The 404 tester's verdict describes the host, but jetpack_boost_static_minification travels with + * the database: a migrated site arrives with its previous host's `1`. Ask the host first. + * + * @since $$next-version$$ + * + * @return bool True if static cache URLs should be used, false to fall back to /_jb_static/. + */ +function jetpack_boost_minify_use_static_cache_urls() { + if ( ! jetpack_boost_minify_host_handles_wp_content_404s() ) { + return false; } + + return (bool) get_site_option( 'jetpack_boost_static_minification' ); +} + +function jetpack_boost_get_minify_url( $file_name = '' ) { + return content_url( '/boost-cache/static/' . $file_name ); +} + +function jetpack_boost_get_minify_file_path( $file_name = '' ) { + return WP_CONTENT_DIR . '/boost-cache/static/' . $file_name; } /** diff --git a/projects/plugins/boost/app/lib/minify/functions-service-fallback.php b/projects/plugins/boost/app/lib/minify/functions-service-fallback.php index 25ae9afda4eb..f83922a2f7ac 100644 --- a/projects/plugins/boost/app/lib/minify/functions-service-fallback.php +++ b/projects/plugins/boost/app/lib/minify/functions-service-fallback.php @@ -301,7 +301,9 @@ function jetpack_boost_page_optimize_get_file_paths( $args ) { // It's a base64 encoded list of file path. // e.g.: /_jb_static/??-eJzTT8vP109KLNJLLi7W0QdyDEE8IK4CiVjn2hpZGluYmKcDABRMDPM= - if ( '-' === $args[0] ) { + // The empty test is this function's own: the static cache 404 handler also reaches here, and a + // request for `/.js` gives an empty file name. + if ( '' !== $args && '-' === $args[0] ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode $args = @gzuncompress( base64_decode( substr( $args, 1 ) ) ); diff --git a/projects/plugins/boost/app/lib/minify/functions-service.php b/projects/plugins/boost/app/lib/minify/functions-service.php index f56f8914c6b1..4c1073918971 100644 --- a/projects/plugins/boost/app/lib/minify/functions-service.php +++ b/projects/plugins/boost/app/lib/minify/functions-service.php @@ -1,6 +1,5 @@ 0 flip changes the emitted URLs but announces nothing. Cached HTML keeps working through + // loader.php here, and a failed loopback looks the same as a host that changed behaviour. update_site_option( 'jetpack_boost_static_minification', $minification_enabled ); return $minification_enabled; @@ -112,23 +174,24 @@ function jetpack_boost_404_tester() { add_action( 'jetpack_boost_404_tester_cron', 'jetpack_boost_404_tester_cron' ); /** - * Setup the 404 tester. + * Setup the 404 tester, and drop a verdict inherited from a different host. * * Schedule the 404 tester if the concatenation modules * haven't been toggled since this feature was released. - * Only run this in wp-admin to avoid excessive updates to the option. */ function jetpack_boost_404_setup() { - // If we're on Atomic or Woa, don't setup the 404 tester. - if ( in_array( Boost_Admin_Config::get_hosting_provider(), array( 'atomic', 'woa' ), true ) ) { - return; - } + // Schedule the daily tester on every host. jetpack_boost_404_tester() short-circuits where the + // static cache cannot work, and keeping the event on the books lets a site that later migrates + // onto a supported host re-test. Jetpack_Boost::handle_version_change() clears and recreates the + // event on upgrade, but only while a minify module is on. + jetpack_boost_page_optimize_schedule_404_tester(); - if ( is_admin() && get_site_option( 'jetpack_boost_static_minification', 'na' ) === 'na' ) { - update_site_option( 'jetpack_boost_static_minification', 0 ); // Add a default value if not set to avoid an extra SQL query. + // No verdict is worth storing on Atomic or WP Cloud, so drop one left by a previous host. Nothing + // follows this branch: setup used to write a placeholder 0 here, which show_legacy_notice() reads + // as a measurement and reports as slow delivery before anything measures it. + if ( ! jetpack_boost_minify_host_handles_wp_content_404s() ) { + jetpack_boost_minify_forget_static_cache_verdict(); } - - jetpack_boost_page_optimize_schedule_404_tester(); } /** diff --git a/projects/plugins/boost/app/modules/optimizations/minify/class-minify-common.php b/projects/plugins/boost/app/modules/optimizations/minify/class-minify-common.php index 9ff4d084061b..bc757404d82a 100644 --- a/projects/plugins/boost/app/modules/optimizations/minify/class-minify-common.php +++ b/projects/plugins/boost/app/modules/optimizations/minify/class-minify-common.php @@ -48,6 +48,11 @@ public static function show_legacy_notice() { return false; } + // No notice where the host cannot serve the static cache at all. + if ( ! jetpack_boost_minify_host_handles_wp_content_404s() ) { + return false; + } + // If the static minfification has not ran yet, don't show the legacy notice. $static_minification_enabled = get_site_option( 'jetpack_boost_static_minification', 'na' ); if ( $static_minification_enabled === 'na' ) { diff --git a/projects/plugins/boost/changelog/fix-boost-608-legacy-notice-placeholder b/projects/plugins/boost/changelog/fix-boost-608-legacy-notice-placeholder new file mode 100644 index 000000000000..be254c95ab1b --- /dev/null +++ b/projects/plugins/boost/changelog/fix-boost-608-legacy-notice-placeholder @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Concatenate JS/CSS: Don't offer the legacy delivery method notice until the site's delivery method has actually been tested. diff --git a/projects/plugins/boost/changelog/fix-boost-608-remove-dead-router b/projects/plugins/boost/changelog/fix-boost-608-remove-dead-router new file mode 100644 index 000000000000..d4169ee6cb53 --- /dev/null +++ b/projects/plugins/boost/changelog/fix-boost-608-remove-dead-router @@ -0,0 +1,4 @@ +Significance: minor +Type: deprecated + +Concatenate JS/CSS: Deprecate jetpack_boost_minify_serve_concatenated(), an unused duplicate of the minify request router. diff --git a/projects/plugins/boost/changelog/fix-boost-608-root-static-prefix b/projects/plugins/boost/changelog/fix-boost-608-root-static-prefix new file mode 100644 index 000000000000..144193e0eab7 --- /dev/null +++ b/projects/plugins/boost/changelog/fix-boost-608-root-static-prefix @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Concatenate JS/CSS: Ignore a JETPACK_BOOST_STATIC_PREFIX that resolves to the site root, and only claim requests that carry a concatenation payload, so ordinary pages are never served by the minify service. diff --git a/projects/plugins/boost/changelog/fix-boost-608-static-minification-after-migration b/projects/plugins/boost/changelog/fix-boost-608-static-minification-after-migration new file mode 100644 index 000000000000..a23f539e5305 --- /dev/null +++ b/projects/plugins/boost/changelog/fix-boost-608-static-minification-after-migration @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Concatenate JS/CSS: Fix broken CSS and JS delivery on pages rendered after a site is migrated onto WP Cloud, and re-test for the faster delivery method automatically after a site migrates back off it. Pages already served from a platform edge cache keep the old URLs until that cache is purged, which Boost cannot do for you. diff --git a/projects/plugins/boost/changelog/fix-boost-608-static-prefix-router b/projects/plugins/boost/changelog/fix-boost-608-static-prefix-router new file mode 100644 index 000000000000..d781f879bc6b --- /dev/null +++ b/projects/plugins/boost/changelog/fix-boost-608-static-prefix-router @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Concatenate JS/CSS: Serve concatenated files when JETPACK_BOOST_STATIC_PREFIX is defined without slashes. diff --git a/projects/plugins/boost/jetpack-boost.php b/projects/plugins/boost/jetpack-boost.php index d12bc44d71c3..28509fe22d08 100644 --- a/projects/plugins/boost/jetpack-boost.php +++ b/projects/plugins/boost/jetpack-boost.php @@ -142,12 +142,11 @@ function jetpack_boost_admin_missing_files() { // Potential improvement: Make concat URL dir configurable if ( isset( $_SERVER['REQUEST_URI'] ) ) { + // Canonicalize the prefix as the concatenators do, and require the ?? payload they emit. + // The constant may be defined without a leading or trailing slash, so a raw comparison misses + // requests for URLs Boost emitted itself. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - $request_path = explode( '?', wp_unslash( $_SERVER['REQUEST_URI'] ) )[0]; - - // Handling JETPACK_BOOST_STATIC_PREFIX constant inline to avoid loading the minify module until we know we want it. - $static_prefix = defined( 'JETPACK_BOOST_STATIC_PREFIX' ) ? JETPACK_BOOST_STATIC_PREFIX : '/_jb_static/'; - if ( $static_prefix === substr( $request_path, -strlen( $static_prefix ) ) ) { + if ( jetpack_boost_minify_request_is_for_static_prefix( wp_unslash( $_SERVER['REQUEST_URI'] ) ) ) { define( 'JETPACK_BOOST_CONCAT_USE_WP', true ); require_once JETPACK_BOOST_DIR_PATH . '/serve-minified-content.php'; diff --git a/projects/plugins/boost/phpunit.11.xml.dist b/projects/plugins/boost/phpunit.11.xml.dist index e1a1b8774fc3..4a356a190574 100644 --- a/projects/plugins/boost/phpunit.11.xml.dist +++ b/projects/plugins/boost/phpunit.11.xml.dist @@ -25,12 +25,14 @@ tests/php/lib/critical-css/Critical_CSS_Storage_Test.php tests/php/modules/optimizations/critical-css/CSS_Proxy_Test.php tests/php/Jetpack_Boost_Test.php + tests/php/lib/minify/Concatenate_Static_Cache_Urls_Test.php tests/php/modules/optimizations/lcp/LCP_Optimize_Bg_Image_Test.php tests/php/modules/optimizations/lcp/LCP_Optimize_Img_Tag_Test.php tests/php/abilities/Boost_Abilities_Test.php tests/php/Jetpack_Boost_Test.php + tests/php/lib/minify/Concatenate_Static_Cache_Urls_Test.php tests/php/lib/critical-css/Display_Critical_CSS_Test.php tests/php/lib/critical-css/Critical_CSS_Storage_Test.php tests/php/modules/optimizations/critical-css/CSS_Proxy_Test.php diff --git a/projects/plugins/boost/phpunit.9.xml.dist b/projects/plugins/boost/phpunit.9.xml.dist index 30140ce16855..1b0ccf1d1c67 100644 --- a/projects/plugins/boost/phpunit.9.xml.dist +++ b/projects/plugins/boost/phpunit.9.xml.dist @@ -16,12 +16,14 @@ tests/php/lib/critical-css/Critical_CSS_Storage_Test.php tests/php/modules/optimizations/critical-css/CSS_Proxy_Test.php tests/php/Jetpack_Boost_Test.php + tests/php/lib/minify/Concatenate_Static_Cache_Urls_Test.php tests/php/modules/optimizations/lcp/LCP_Optimize_Bg_Image_Test.php tests/php/modules/optimizations/lcp/LCP_Optimize_Img_Tag_Test.php tests/php/abilities/Boost_Abilities_Test.php tests/php/Jetpack_Boost_Test.php + tests/php/lib/minify/Concatenate_Static_Cache_Urls_Test.php tests/php/lib/critical-css/Display_Critical_CSS_Test.php tests/php/lib/critical-css/Critical_CSS_Storage_Test.php tests/php/modules/optimizations/critical-css/CSS_Proxy_Test.php diff --git a/projects/plugins/boost/tests/bootstrap.php b/projects/plugins/boost/tests/bootstrap.php index 351e35f60d45..a58a2d740e09 100644 --- a/projects/plugins/boost/tests/bootstrap.php +++ b/projects/plugins/boost/tests/bootstrap.php @@ -35,6 +35,22 @@ function str_contains( $haystack, $needle ) { } } +// Same for str_starts_with(), which the prefix helper and the router predicate call. CI runs this +// suite on PHP 7.2, 7.3 and 7.4. +if ( ! function_exists( 'str_starts_with' ) ) { + /** + * Polyfill for PHP 8.0's str_starts_with(). + * + * @param string $haystack String to search in. + * @param string $needle Substring to search for. + * @return bool Whether $haystack begins with $needle. + * @suppress PhanRedefineFunctionInternal -- Guarded polyfill for PHP < 8.0. + */ + function str_starts_with( $haystack, $needle ) { + return 0 === strncmp( $haystack, $needle, strlen( $needle ) ); + } +} + // Additional functions that brain/monkey doesn't currently define. if ( ! function_exists( 'wp_unslash' ) ) { /** diff --git a/projects/plugins/boost/tests/php/Jetpack_Boost_Test.php b/projects/plugins/boost/tests/php/Jetpack_Boost_Test.php index 7b3072bcfa60..7b0b4d0c57b6 100644 --- a/projects/plugins/boost/tests/php/Jetpack_Boost_Test.php +++ b/projects/plugins/boost/tests/php/Jetpack_Boost_Test.php @@ -3,8 +3,11 @@ namespace Automattic\Jetpack_Boost\Tests; use Automattic\Jetpack\Boost_Speed_Score\Speed_Score_History; +use Automattic\Jetpack\Constants; +use Automattic\Jetpack\Status\Cache as Status_Cache; use Automattic\Jetpack_Boost\Jetpack_Boost; use Automattic\Jetpack_Boost\Lib\Critical_CSS\Critical_CSS_State; +use Automattic\Jetpack_Boost\Lib\Minify\Config; use Automattic\Jetpack_Boost\Modules\Modules_Setup; use Automattic\Jetpack_Boost\Modules\Optimizations\Lcp\LCP_State; use WorDBless\BaseTestCase; @@ -19,6 +22,24 @@ */ class Jetpack_Boost_Test extends BaseTestCase { + /** + * A leaked hosting constant or a memoised host verdict decides which branch these tests take, so + * start and finish from a known host. + */ + public function set_up() { + parent::set_up(); + + Constants::clear_constants(); + Status_Cache::clear(); + } + + public function tear_down() { + Constants::clear_constants(); + Status_Cache::clear(); + + parent::tear_down(); + } + /** * Test that handle_version_change removes empty string from cornerstone_pages_list */ @@ -77,6 +98,180 @@ public function test_handle_version_change_does_nothing_when_no_empty_string() { $this->assertEquals( array_map( 'home_url', $initial_pages ), $updated_pages, 'The array should remain unchanged when no empty string is present' ); } + /** + * Count how many times handle_version_change() announces a change in emitted page output. + * + * Minify is disabled in this fixture, so the call at the top of handle_version_change() is the only + * thing that can announce. That makes each of these an anchor for that one line. + * + * @param mixed $stored The verdict the site carries into the upgrade. + * @return int Times jetpack_boost_page_output_changed fired. + */ + private function count_announcements_on_version_change( $stored ) { + $jetpack_boost = new Jetpack_Boost(); + do_action( 'init' ); + + update_site_option( 'jetpack_boost_static_minification', $stored ); + + $fired = 0; + add_action( + 'jetpack_boost_page_output_changed', + function () use ( &$fired ) { + ++$fired; + } + ); + + $jetpack_boost->handle_version_change(); + + $this->assertFalse( get_site_option( 'jetpack_boost_static_minification' ) ); + + return $fired; + } + + /** + * BOOST-608: a plugin upgrade is the path a migrated site takes, and the caller that drops the + * stale verdict. Nothing puts it back on WP Cloud, so every URL the next render emits changes and + * whatever holds the old ones has to be told. + * + * Reinstate the raw delete_site_option() and the option still goes, but the announcement is lost. + */ + public function test_handle_version_change_announces_the_dropped_static_minification_verdict() { + Constants::set_constant( 'ATOMIC_SITE_ID', 1 ); + Constants::set_constant( 'ATOMIC_CLIENT_ID', 1 ); + Status_Cache::clear(); + + $this->assertSame( 1, $this->count_announcements_on_version_change( 1 ) ); + } + + /** + * The same upgrade runs on every site on every release. A verdict already pointing renders at + * /_jb_static/ changes nothing when it goes, so it must not cost a cache purge. + */ + public function test_handle_version_change_stays_quiet_when_nothing_changed() { + Constants::set_constant( 'ATOMIC_SITE_ID', 1 ); + Constants::set_constant( 'ATOMIC_CLIENT_ID', 1 ); + Status_Cache::clear(); + + $this->assertSame( 0, $this->count_announcements_on_version_change( 0 ) ); + } + + /** + * Run $body with a 404 probe marker on disk, and clear up whatever it leaves behind. + * + * WorDBless resolves WP_CONTENT_DIR to a real directory in the checkout, so this writes the marker + * where production writes it. + * + * @param callable $body Receives the marker path. + */ + private function with_probe_marker( $body ) { + $marker = Config::get_static_cache_dir_path() . '/404'; + if ( ! is_dir( dirname( $marker ) ) ) { + mkdir( dirname( $marker ), 0755, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir + } + file_put_contents( $marker, '1' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + + try { + $body( $marker ); + } finally { + if ( file_exists( $marker ) ) { + wp_delete_file( $marker ); + } + @rmdir( dirname( $marker ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged + @rmdir( dirname( $marker, 2 ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged + } + } + + /** + * BOOST-608: a migration carries the probe marker as well as the verdict, and this upgrade is the + * only cleanup a site with both minify modules off reaches. Left on disk, the marker makes the + * next probe record 1 for a delivery method WP Cloud cannot serve. + * + * Drop the marker deletion from the forget helper and this test notices. + */ + public function test_handle_version_change_reclaims_a_migrated_probe_marker_on_wp_cloud() { + Constants::set_constant( 'ATOMIC_SITE_ID', 1 ); + Constants::set_constant( 'ATOMIC_CLIENT_ID', 1 ); + Status_Cache::clear(); + + $this->with_probe_marker( + function ( $marker ) { + $this->count_announcements_on_version_change( 1 ); + + $this->assertFalse( file_exists( $marker ), 'The migrated 404 probe marker should have been dropped.' ); + } + ); + } + + /** + * The marker drop is not the announcement's business: a verdict that changed nothing still arrived + * with a filesystem that can carry a marker. Gate the drop on the verdict as well as the host, the + * shape this helper had earlier, and this test notices. + */ + public function test_handle_version_change_reclaims_the_probe_marker_even_when_nothing_was_announced() { + Constants::set_constant( 'ATOMIC_SITE_ID', 1 ); + Constants::set_constant( 'ATOMIC_CLIENT_ID', 1 ); + Status_Cache::clear(); + + $this->with_probe_marker( + function ( $marker ) { + $this->assertSame( 0, $this->count_announcements_on_version_change( 0 ) ); + + $this->assertFalse( file_exists( $marker ), 'The migrated 404 probe marker should have been dropped.' ); + } + ); + } + + /** + * The mirror on an ordinary host: a marker there is the probe's own measurement, so the same + * upgrade leaves it alone. + */ + public function test_handle_version_change_leaves_the_probe_marker_alone_on_an_ordinary_host() { + $this->with_probe_marker( + function ( $marker ) { + $this->count_announcements_on_version_change( 1 ); + + $this->assertTrue( file_exists( $marker ), 'A measured 404 probe marker should survive an upgrade.' ); + } + ); + } + + /** + * The forget helper runs above jetpack_boost_minify_activation(). The comment there covers one + * direction: activation would otherwise find the row gone and lose the announcement. This pins the + * other. Activation re-runs the inline probe, which measures the host the site is on now, so a + * forget call moved below it deletes that fresh measurement and leaves a supported host reading + * "never probed" until the daily cron. Both suites stay green under that move without this test. + */ + public function test_handle_version_change_keeps_the_verdict_its_own_activation_measures() { + update_option( 'jetpack_boost_status_minify-css', true ); + + // The probe is a loopback request and no server answers it here. Short-circuiting leaves no + // marker, so the verdict reads 0. Only that one was recorded matters. + add_filter( 'pre_http_request', '__return_true' ); + + $jetpack_boost = new Jetpack_Boost(); + do_action( 'init' ); + + update_site_option( 'jetpack_boost_static_minification', 1 ); + + $jetpack_boost->handle_version_change(); + + $this->assertNotSame( + 'never probed', + get_site_option( 'jetpack_boost_static_minification', 'never probed' ), + 'The upgrade should keep the verdict the activation it runs re-measured.' + ); + } + + /** + * On a host that routes wp-content 404s through WordPress, the URLs cached HTML holds keep working + * after the drop; see the forget helper for why. Announcing there would purge the page cache on + * every release for pages that were never broken. + */ + public function test_handle_version_change_stays_quiet_on_a_host_that_still_serves_the_old_urls() { + $this->assertSame( 0, $this->count_announcements_on_version_change( 1 ) ); + } + /** * Test that the lazy sync callables are invocable and read current state at invocation time. */ diff --git a/projects/plugins/boost/tests/php/lib/minify/Concatenate_Static_Cache_Urls_Test.php b/projects/plugins/boost/tests/php/lib/minify/Concatenate_Static_Cache_Urls_Test.php new file mode 100644 index 000000000000..766d92b86f23 --- /dev/null +++ b/projects/plugins/boost/tests/php/lib/minify/Concatenate_Static_Cache_Urls_Test.php @@ -0,0 +1,373 @@ +asset_dir = WP_CONTENT_DIR . '/boost-concat-test'; + if ( ! is_dir( $this->asset_dir ) ) { + mkdir( $this->asset_dir, 0755, true ); + } + + $this->asset_files = array(); + foreach ( array( + 'a.css' => '.a{color:red}', + 'b.css' => '.b{color:blue}', + 'a.js' => 'var a = 1;', + 'b.js' => 'var b = 2;', + ) as $name => $contents ) { + $path = $this->asset_dir . '/' . $name; + file_put_contents( $path, $contents ); + $this->asset_files[] = $path; + } + + // Both classes derive the site URL from the base_url WordPress hands them, which points at + // wp-includes here. Pin it, or nothing resolves as internal and every asset is skipped. + $this->site_url_filter = function () { + return site_url(); + }; + add_filter( 'page_optimize_site_url', $this->site_url_filter ); + } + + public function tear_down() { + if ( $this->site_url_filter ) { + remove_filter( 'page_optimize_site_url', $this->site_url_filter ); + } + + // Remove exactly the files set_up() wrote, then the directory if that emptied it. Tracking + // paths rather than ownership means a part-way run cannot leave the fixture behind. + foreach ( $this->asset_files as $file ) { + if ( file_exists( $file ) ) { + unlink( $file ); + } + } + if ( is_dir( $this->asset_dir ) ) { + @rmdir( $this->asset_dir ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best effort; a non-empty directory holds something this test did not create. + } + + // WorDBless does not reset the current screen between tests, so the set_current_screen() call + // below would leave is_admin() true for every later file in the suite. + unset( $GLOBALS['current_screen'] ); + + Constants::clear_constants(); + Status_Cache::clear(); + delete_site_option( 'jetpack_boost_static_minification' ); + wp_clear_scheduled_hook( 'jetpack_boost_404_tester_cron' ); + + parent::tear_down(); + } + + /** + * Put the site on WP Cloud, where the web server answers wp-content 404s itself. + */ + private function pretend_to_be_on_wp_cloud() { + Constants::set_constant( 'ATOMIC_SITE_ID', 1 ); + Constants::set_constant( 'ATOMIC_CLIENT_ID', 1 ); + Status_Cache::clear(); + } + + private function render_styles() { + $styles = new Concatenate_CSS( new WP_Styles() ); + $styles->add( 'boost-test-a', '/wp-content/boost-concat-test/a.css', array(), null ); + $styles->add( 'boost-test-b', '/wp-content/boost-concat-test/b.css', array(), null ); + $styles->enqueue( array( 'boost-test-a', 'boost-test-b' ) ); + + // finally, so a throw inside do_items() surfaces as that failure rather than as a leaked + // buffer tripping beStrictAboutOutputDuringTests. + ob_start(); + try { + $styles->do_items(); + } finally { + $output = ob_get_clean(); + } + + return $output; + } + + private function render_scripts() { + $scripts = new Concatenate_JS( new WP_Scripts() ); + $scripts->add( 'boost-test-a', '/wp-content/boost-concat-test/a.js', array(), null ); + $scripts->add( 'boost-test-b', '/wp-content/boost-concat-test/b.js', array(), null ); + $scripts->enqueue( array( 'boost-test-a', 'boost-test-b' ) ); + + ob_start(); + try { + $scripts->do_items(); + } finally { + $output = ob_get_clean(); + } + + return $output; + } + + /** + * The migrated database says the 404 tester passed, but it passed on the previous host. + */ + public function test_css_falls_back_when_a_stale_verdict_arrives_on_wp_cloud() { + update_site_option( 'jetpack_boost_static_minification', 1 ); + $this->pretend_to_be_on_wp_cloud(); + + $output = $this->render_styles(); + + $this->assertStringContainsString( '/_jb_static/??', $output ); + $this->assertStringNotContainsString( '/boost-cache/static/', $output ); + } + + public function test_js_falls_back_when_a_stale_verdict_arrives_on_wp_cloud() { + update_site_option( 'jetpack_boost_static_minification', 1 ); + $this->pretend_to_be_on_wp_cloud(); + + $output = $this->render_scripts(); + + $this->assertStringContainsString( '/_jb_static/??', $output ); + $this->assertStringNotContainsString( '/boost-cache/static/', $output ); + } + + /** + * Hosts that can serve the static cache keep doing so. The guard only moves a site onto the + * fallback, never the other way. + */ + public function test_css_uses_static_cache_urls_on_a_supported_host() { + update_site_option( 'jetpack_boost_static_minification', 1 ); + + $output = $this->render_styles(); + + $this->assertStringContainsString( '/boost-cache/static/', $output ); + $this->assertStringContainsString( '.min.css', $output ); + $this->assertStringNotContainsString( '/_jb_static/??', $output ); + } + + public function test_js_uses_static_cache_urls_on_a_supported_host() { + update_site_option( 'jetpack_boost_static_minification', 1 ); + + $output = $this->render_scripts(); + + $this->assertStringContainsString( '/boost-cache/static/', $output ); + $this->assertStringContainsString( '.min.js', $output ); + $this->assertStringNotContainsString( '/_jb_static/??', $output ); + } + + public function test_css_falls_back_on_a_supported_host_when_the_tester_failed() { + update_site_option( 'jetpack_boost_static_minification', 0 ); + + $output = $this->render_styles(); + + $this->assertStringContainsString( '/_jb_static/??', $output ); + $this->assertStringNotContainsString( '/boost-cache/static/', $output ); + } + + public function test_js_falls_back_on_a_supported_host_when_the_tester_failed() { + update_site_option( 'jetpack_boost_static_minification', 0 ); + + $output = $this->render_scripts(); + + $this->assertStringContainsString( '/_jb_static/??', $output ); + $this->assertStringNotContainsString( '/boost-cache/static/', $output ); + } + + /** + * The reverse migration end to end: a stale verdict arrives on WP Cloud, activation drops it, and + * the site moves to an ordinary host with the same database. Revert the guard in + * jetpack_boost_404_setup() and the 1 survives, so the site emits static cache URLs on a host that + * never probed for them. + * + * The event is scheduled up front so the tester does not run inline. The tester carries its own + * copy of the guard, and which guard is under test would otherwise depend on cron state. + */ + public function test_a_verdict_dropped_on_wp_cloud_does_not_come_back_after_migrating_off() { + update_site_option( 'jetpack_boost_static_minification', 1 ); + $this->pretend_to_be_on_wp_cloud(); + wp_schedule_event( time() + DAY_IN_SECONDS, 'daily', 'jetpack_boost_404_tester_cron' ); + + jetpack_boost_404_setup(); + + $this->assertFalse( get_site_option( 'jetpack_boost_static_minification' ) ); + + // Migrate off: the constants are gone, the database is the same one. + Constants::clear_constants(); + Status_Cache::clear(); + + $output = $this->render_styles(); + + $this->assertStringContainsString( '/_jb_static/??', $output ); + $this->assertStringNotContainsString( '/boost-cache/static/', $output ); + } + + /** + * Pull the static cache URL out of a rendered tag and return the path the web server would ask + * WordPress about. + * + * @param string $output Rendered markup containing exactly one static cache URL. + * @return string Request URI, e.g. /wp-content/boost-cache/static/777873a36e.min.css + */ + private function static_cache_request_uri( $output ) { + $this->assertSame( 1, preg_match( '#["\']([^"\']*/boost-cache/static/[^"\']+)["\']#', $output, $matches ) ); + + // Flags spelled out: PHP 8.1 changed the default and Boost still supports 7.2. + $uri = wp_parse_url( html_entity_decode( $matches[1], ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, 'UTF-8' ), PHP_URL_PATH ); + + // loader.php answers a request by matching a hardcoded copy of this literal, and a divergence + // means no bundle is ever rebuilt. This pins the emit side against a third copy written here. + // Nothing executes the template_redirect closure, so editing loader.php's copy stays green. + $this->assertStringContainsString( '/wp-content/boost-cache/static/', strtolower( $uri ) ); + + return $uri; + } + + /** + * The other end of the emit decision: a static cache URL works only because the request for it + * 404s and Boost builds the bundle while it answers that 404. The delivery method rests on this, + * and so does the forget helper's argument for not purging caches on ordinary hosts. + * + * The URL comes from the real emitter, so a divergence between what Concatenate_CSS writes and + * what the service parses back out fails here. + */ + public function test_an_emitted_static_cache_url_rebuilds_its_bundle() { + update_site_option( 'jetpack_boost_static_minification', 1 ); + + $request_uri = $this->static_cache_request_uri( $this->render_styles() ); + + // Nothing has written the file yet, which is the state every first request arrives in. Built + // through the production path helper, since pasting $request_uri onto WP_CONTENT_DIR names a + // doubled /wp-content path. + $this->assertFileDoesNotExist( jetpack_boost_get_minify_file_path( basename( $request_uri ) ) ); + + $output = jetpack_boost_build_minify_output( $request_uri ); + + $this->assertStringContainsString( '.a{color:red}', $output['content'] ); + $this->assertStringContainsString( '.b{color:blue}', $output['content'] ); + $this->assertContains( 'Content-Type: text/css', $output['headers'] ); + } + + /** + * The same for scripts, because the two concatenators build their URLs independently. + */ + public function test_an_emitted_static_cache_script_url_rebuilds_its_bundle() { + update_site_option( 'jetpack_boost_static_minification', 1 ); + + $request_uri = $this->static_cache_request_uri( $this->render_scripts() ); + $output = jetpack_boost_build_minify_output( $request_uri ); + + // Minified on the way through, which is the other half of what the service is for. + $this->assertStringContainsString( 'var a=1;', $output['content'] ); + $this->assertStringContainsString( 'var b=2;', $output['content'] ); + $this->assertContains( 'Content-Type: application/javascript', $output['headers'] ); + } + + /** + * What bounds the claim above: the hash is only a key, and the paths it stands for live in a + * File_Paths transient that expires. This pins which handles the emitter recorded, and that the + * entry goes with the transient. A rebuild without one ends in exit(), which no in-process test + * can catch. + */ + public function test_the_rebuild_is_only_possible_while_the_stored_paths_survive() { + update_site_option( 'jetpack_boost_static_minification', 1 ); + + $request_uri = $this->static_cache_request_uri( $this->render_styles() ); + $hash = jetpack_boost_minify_get_file_parts( $request_uri )['file_name']; + + $this->assertNotEmpty( File_Paths::get( $hash ) ); + $this->assertSame( + array( + 'boost-test-a' => '/wp-content/boost-concat-test/a.css', + 'boost-test-b' => '/wp-content/boost-concat-test/b.css', + ), + File_Paths::get( $hash )->get_paths() + ); + + File_Paths::delete_by_cache_id( $hash ); + + $this->assertEmpty( File_Paths::get( $hash ) ); + } + + /** + * The bundle name reaching the fallback parser is not always one the router vetted. A request for + * `/.js` 404s under the prefix the loader watches, and pathinfo() splits it into an + * empty file name, so build_output() passes that empty string here. An unauthenticated request + * must not put an "Uninitialized string offset" warning in the error log. + */ + public function test_an_empty_bundle_name_is_not_read_past_the_end_of() { + $this->assertSame( '', jetpack_boost_minify_get_file_parts( '/wp-content/boost-cache/static/.js' )['file_name'] ); + $this->assertSame( array( '' ), jetpack_boost_page_optimize_get_file_paths( '' ) ); + } + + /** + * Setup used to write a placeholder 0 to save an option lookup. Absent means "never probed" and 0 + * means "probed, unsupported", which is why the forget helper deletes rather than zeroes and + * show_legacy_notice() fires only for the second. + * + * An event already on the books is the state a site migrating off WP Cloud arrives in, and the + * branch that skips the inline probe, so a placeholder would have stood there as if measured. + */ + public function test_setup_records_no_verdict_when_it_skips_the_probe() { + // The write this pins was gated on is_admin(), and module activation runs in wp-admin. + // is_admin() is false under WorDBless until a screen is set, so without this line the gated + // write comes back green and only an ungated one fails. + set_current_screen( 'dashboard' ); + + wp_schedule_event( time() + DAY_IN_SECONDS, 'daily', 'jetpack_boost_404_tester_cron' ); + + jetpack_boost_404_setup(); + + $this->assertFalse( get_site_option( 'jetpack_boost_static_minification' ) ); + $this->assertSame( 'na', get_site_option( 'jetpack_boost_static_minification', 'na' ) ); + } +} diff --git a/projects/plugins/boost/tests/php/lib/minify/Functions_Service_Test.php b/projects/plugins/boost/tests/php/lib/minify/Functions_Service_Test.php index d76408a14ce3..53c5cd8d9c00 100644 --- a/projects/plugins/boost/tests/php/lib/minify/Functions_Service_Test.php +++ b/projects/plugins/boost/tests/php/lib/minify/Functions_Service_Test.php @@ -17,6 +17,9 @@ protected function set_up() { // Mock add_action Functions\expect( 'add_action' )->andReturn( true ); + // The 404 tester asks Host which hosting provider this is, and Host caches per blog. + Functions\when( 'get_current_blog_id' )->justReturn( 1 ); + // Clean up any test files before each test if ( file_exists( Config::get_static_cache_dir_path() . '/404' ) ) { unlink( Config::get_static_cache_dir_path() . '/404' ); diff --git a/projects/plugins/boost/tests/php/lib/minify/Static_Cache_Urls_Test.php b/projects/plugins/boost/tests/php/lib/minify/Static_Cache_Urls_Test.php new file mode 100644 index 000000000000..273ea4031c74 --- /dev/null +++ b/projects/plugins/boost/tests/php/lib/minify/Static_Cache_Urls_Test.php @@ -0,0 +1,577 @@ +justReturn( true ); + + require_once JETPACK_BOOST_DIR_PATH . '/app/lib/minify/loader.php'; + } + + protected function tearDown(): void { + Mockery::close(); + \Brain\Monkey\tearDown(); + parent::tearDown(); + } + + /** + * @param bool $is_woa Whether Host::is_woa_site() should report true. + * @param bool $is_atomic Whether Host::is_atomic_platform() should report true. + */ + private function mock_host( $is_woa, $is_atomic ) { + $host = Mockery::mock( 'overload:' . Host::class ); + $host->shouldReceive( 'is_woa_site' )->andReturn( $is_woa ); + $host->shouldReceive( 'is_atomic_platform' )->andReturn( $is_atomic ); + } + + public function test_host_support_is_false_on_woa() { + $this->mock_host( true, true ); + + $this->assertFalse( jetpack_boost_minify_host_handles_wp_content_404s() ); + } + + public function test_host_support_is_false_on_wp_cloud() { + $this->mock_host( false, true ); + + $this->assertFalse( jetpack_boost_minify_host_handles_wp_content_404s() ); + } + + public function test_host_support_is_true_elsewhere() { + $this->mock_host( false, false ); + + $this->assertTrue( jetpack_boost_minify_host_handles_wp_content_404s() ); + } + + /** + * A database migrated onto WP Cloud carries the previous host's verdict, and acting on it points + * every bundle at a URL the new host can only 404. + */ + public function test_stale_option_is_ignored_on_wp_cloud() { + $this->mock_host( false, true ); + + Functions\expect( 'get_site_option' )->never(); + + $this->assertFalse( jetpack_boost_minify_use_static_cache_urls() ); + } + + public function test_option_is_honoured_on_supported_hosts() { + $this->mock_host( false, false ); + + Functions\expect( 'get_site_option' ) + ->once() + ->with( 'jetpack_boost_static_minification' ) + ->andReturn( 1 ); + + $this->assertTrue( jetpack_boost_minify_use_static_cache_urls() ); + } + + public function test_static_cache_urls_are_off_when_the_tester_says_so() { + $this->mock_host( false, false ); + + Functions\expect( 'get_site_option' ) + ->once() + ->with( 'jetpack_boost_static_minification' ) + ->andReturn( 0 ); + + $this->assertFalse( jetpack_boost_minify_use_static_cache_urls() ); + } + + /** + * Setup runs on module activation and plugin upgrade, the first chance a migrated site gets to + * drop the old verdict. That changes every URL the next render emits, so cached HTML has to go. + */ + public function test_setup_clears_the_stale_option_on_wp_cloud() { + $this->mock_host( false, true ); + + Functions\when( 'get_site_option' )->justReturn( 1 ); + + $deleted = array(); + Functions\when( 'delete_site_option' )->alias( + function ( $name ) use ( &$deleted ) { + $deleted[] = $name; + return true; + } + ); + + Functions\when( 'wp_next_scheduled' )->justReturn( 1234567890 ); + Functions\expect( 'update_site_option' )->never(); + Actions\expectDone( 'jetpack_boost_page_output_changed' )->once(); + + jetpack_boost_404_setup(); + + $this->assertSame( array( 'jetpack_boost_static_minification' ), $deleted ); + } + + /** + * Setup runs on every activation and the tester on every daily cron, long after the migration. + * Only a truthy verdict pointed renders at the static cache, so only dropping one of those changes + * the output. Anything else would purge a cache for nothing. + * + * @param mixed $stored The verdict the row holds before setup runs. + * + * @dataProvider provide_verdicts_that_change_nothing + */ + #[DataProvider( 'provide_verdicts_that_change_nothing' )] + public function test_no_invalidation_when_the_verdict_changed_nothing( $stored ) { + $this->mock_host( false, true ); + + Functions\when( 'get_site_option' )->justReturn( $stored ); + + $attempted = array(); + Functions\when( 'delete_site_option' )->alias( + function ( $name ) use ( &$attempted ) { + $attempted[] = $name; + return true; + } + ); + + Functions\when( 'wp_next_scheduled' )->justReturn( 1234567890 ); + Actions\expectDone( 'jetpack_boost_page_output_changed' )->never(); + + jetpack_boost_404_setup(); + + // The delete still runs; it is the invalidation that has to stay quiet. + $this->assertSame( array( 'jetpack_boost_static_minification' ), $attempted ); + } + + /** + * The helper's own truth table. Two callers only reach it from inside the unsupported-host branch, + * so the ordinary-host cell, where an announcement would purge a cache for nothing, arrives only + * through Jetpack_Boost::handle_version_change(). + * + * @param bool $is_atomic Whether the site is on Atomic / WP Cloud. + * @param mixed $stored The verdict the row holds when the helper runs. + * @param int $announced How many times dropping it should be announced. + * + * @dataProvider provide_forget_verdict_cases + */ + #[DataProvider( 'provide_forget_verdict_cases' )] + public function test_forget_verdict_announces_only_a_durable_change( $is_atomic, $stored, $announced ) { + $this->mock_host( false, $is_atomic ); + + Functions\when( 'get_site_option' )->justReturn( $stored ); + + $deleted = array(); + Functions\when( 'delete_site_option' )->alias( + function ( $name ) use ( &$deleted ) { + $deleted[] = $name; + return true; + } + ); + + Actions\expectDone( 'jetpack_boost_page_output_changed' )->times( $announced ); + + jetpack_boost_minify_forget_static_cache_verdict(); + + // The delete is unconditional in all four cells. Only the announcement is gated. + $this->assertSame( array( 'jetpack_boost_static_minification' ), $deleted ); + } + + public static function provide_forget_verdict_cases() { + return array( + // The migration this exists for. Nothing re-probes on these hosts, and cached HTML holds + // URLs nothing produces now. + 'wp cloud, stale verdict' => array( true, 1, 1 ), + 'wp cloud, nothing stored' => array( true, false, 0 ), + // Elsewhere the output is unchanged: loader.php still answers the cached URLs. + 'ordinary host, verdict set' => array( false, 1, 0 ), + 'ordinary host, nothing' => array( false, false, 0 ), + ); + } + + public static function provide_verdicts_that_change_nothing() { + return array( + // get_site_option()'s default for a row that is not there. + 'never stored' => array( false ), + // Stored, but already pointing renders at /_jb_static/. + 'tested, cannot' => array( 0 ), + 'tested, cannot(s)' => array( '0' ), + ); + } + + /** + * Jetpack_Boost::handle_version_change() tears down every minify schedule before it re-runs + * activation. If setup skipped scheduling here, a site that later migrates onto a supported host + * would never re-test. + */ + public function test_setup_still_schedules_the_daily_tester_on_wp_cloud() { + $this->mock_host( false, true ); + + Functions\when( 'get_site_option' )->justReturn( 1 ); + Functions\when( 'delete_site_option' )->justReturn( true ); + Functions\when( 'wp_next_scheduled' )->justReturn( false ); + + $scheduled = array(); + Functions\when( 'wp_schedule_event' )->alias( + function ( $timestamp, $recurrence, $hook ) use ( &$scheduled ) { + $scheduled[] = array( $recurrence, $hook ); + return true; + } + ); + + // Scheduling the event runs the tester inline, with no loopback request and no writes. The + // daily cron pays one throttle write per network per day; this path does not. + Functions\expect( 'wp_remote_get' )->never(); + Functions\expect( 'update_site_option' )->never(); + + jetpack_boost_404_setup(); + + $this->assertSame( array( array( 'daily', 'jetpack_boost_404_tester_cron' ) ), $scheduled ); + } + + public function test_setup_leaves_an_existing_schedule_alone_on_supported_hosts() { + $this->mock_host( false, false ); + + $hooks_checked = array(); + Functions\when( 'wp_next_scheduled' )->alias( + function ( $hook ) use ( &$hooks_checked ) { + $hooks_checked[] = $hook; + return 1234567890; + } + ); + + Functions\expect( 'delete_site_option' )->never(); + Functions\expect( 'wp_schedule_event' )->never(); + + jetpack_boost_404_setup(); + + $this->assertSame( array( 'jetpack_boost_404_tester_cron' ), $hooks_checked ); + } + + /** + * With no event on the books, setup schedules the daily run and probes at once, so the settings + * screen has a verdict without a day's wait. + */ + public function test_setup_schedules_and_probes_when_no_event_exists() { + $this->mock_host( false, false ); + + Functions\when( 'wp_next_scheduled' )->justReturn( false ); + Functions\when( 'home_url' )->alias( + function ( $path ) { + return 'http://example.com' . $path; + } + ); + + $scheduled = array(); + Functions\when( 'wp_schedule_event' )->alias( + function ( $timestamp, $recurrence, $hook ) use ( &$scheduled ) { + $scheduled[] = array( $recurrence, $hook ); + return true; + } + ); + + $probed = array(); + Functions\when( 'wp_remote_get' )->alias( + function ( $url ) use ( &$probed ) { + $probed[] = $url; + return array(); + } + ); + + $written = array(); + Functions\when( 'update_site_option' )->alias( + function ( $name, $value ) use ( &$written ) { + $written[ $name ] = $value; + return true; + } + ); + + jetpack_boost_404_setup(); + + $this->assertSame( array( array( 'daily', 'jetpack_boost_404_tester_cron' ) ), $scheduled ); + $this->assertSame( array( 'http://example.com' . JETPACK_BOOST_STATIC_CACHE_404_TESTER_PATH ), $probed ); + $this->assertSame( array( 'jetpack_boost_static_minification' => 0 ), $written ); + } + + /** + * The cron schedule travels with the database too, so the tester can run on a migrated site + * without setup being reached. It must not put back the verdict setup deleted. + */ + public function test_tester_skips_the_probe_and_clears_the_option_on_wp_cloud() { + $this->mock_host( false, true ); + + Functions\when( 'get_site_option' )->justReturn( 1 ); + + $deleted = array(); + Functions\when( 'delete_site_option' )->alias( + function ( $name ) use ( &$deleted ) { + $deleted[] = $name; + return true; + } + ); + + Functions\expect( 'wp_remote_get' )->never(); + Functions\expect( 'update_site_option' )->never(); + Actions\expectDone( 'jetpack_boost_page_output_changed' )->once(); + + $this->assertNull( jetpack_boost_404_tester() ); + $this->assertSame( array( 'jetpack_boost_static_minification' ), $deleted ); + } + + /** + * Plant a 404 marker and let wp_delete_file() really delete, so the reclaim is observable. + * + * @return string The marker path. + */ + private function plant_404_marker() { + $marker = Config::get_static_cache_dir_path() . '/404'; + + if ( ! is_dir( dirname( $marker ) ) ) { + mkdir( dirname( $marker ), 0755, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir + } + file_put_contents( $marker, '1' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + + Functions\when( 'wp_delete_file' )->alias( + function ( $path ) { + unlink( $path ); + } + ); + + return $marker; + } + + /** + * Remove every directory this test owns, deepest first, stopping at the per-process root. rmdir() + * only succeeds on an empty directory, so a stray file stops the walk. + */ + private function remove_owned_content_dirs() { + $root = dirname( WP_CONTENT_DIR ); + $path = Config::get_static_cache_dir_path(); + + while ( is_dir( $path ) && strpos( $path, $root ) === 0 ) { + if ( ! @rmdir( $path ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged + return; + } + $path = dirname( $path ); + } + + @rmdir( $root ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged + } + + /** + * The branch that skips the probe reclaims the marker as well. Left on disk, that marker is what a + * Boost release with no host check reads after a downgrade, and it stores a verdict of 1: the + * outage this branch prevents. + */ + public function test_tester_reclaims_a_stale_marker_on_wp_cloud() { + $this->mock_host( false, true ); + + Functions\when( 'get_site_option' )->justReturn( 1 ); + Functions\when( 'delete_site_option' )->justReturn( true ); + Functions\expect( 'wp_remote_get' )->never(); + + $marker = $this->plant_404_marker(); + + try { + jetpack_boost_404_tester(); + + $this->assertFalse( file_exists( $marker ), 'The stale 404 marker should have been reclaimed.' ); + } finally { + $this->remove_owned_content_dirs(); + } + } + + /** + * JETPACK_BOOST_DISABLE_404_TESTER turns off a loopback request this branch never sends, so it + * must not also turn off the verdict drop and the marker reclaim. A site that sets the constant + * and then migrates onto WP Cloud would keep emitting URLs the host can only 404. + */ + public function test_tester_still_cleans_up_on_wp_cloud_when_disabled_by_constant() { + define( 'JETPACK_BOOST_DISABLE_404_TESTER', true ); + + $this->mock_host( false, true ); + + $deleted = array(); + Functions\when( 'get_site_option' )->justReturn( 1 ); + Functions\when( 'delete_site_option' )->alias( + function ( $option ) use ( &$deleted ) { + $deleted[] = $option; + return true; + } + ); + Functions\expect( 'wp_remote_get' )->never(); + Functions\expect( 'update_site_option' )->never(); + Actions\expectDone( 'jetpack_boost_page_output_changed' )->once(); + + $marker = $this->plant_404_marker(); + + try { + $this->assertNull( jetpack_boost_404_tester() ); + + $this->assertSame( array( 'jetpack_boost_static_minification' ), $deleted ); + $this->assertFalse( file_exists( $marker ), 'The stale 404 marker should have been reclaimed.' ); + } finally { + $this->remove_owned_content_dirs(); + } + } + + /** + * The early router matches request paths against this prefix, and the concatenators build their + * URLs from it. A site can define JETPACK_BOOST_STATIC_PREFIX with or without either slash, so + * both sides must canonicalize the same way. + * + * @param string $defined The value a site defined the constant as. + * @param string $expected The canonical prefix both sides must agree on. + * + * @dataProvider provide_static_prefix_spellings + */ + #[DataProvider( 'provide_static_prefix_spellings' )] + public function test_static_prefix_is_canonicalized( $defined, $expected ) { + define( 'JETPACK_BOOST_STATIC_PREFIX', $defined ); + + $this->assertSame( $expected, jetpack_boost_get_static_prefix() ); + } + + public static function provide_static_prefix_spellings() { + return array( + 'no slashes' => array( 'assets', '/assets/' ), + 'leading only' => array( '/assets', '/assets/' ), + 'trailing only' => array( 'assets/', '/assets/' ), + 'both slashes' => array( '/assets/', '/assets/' ), + 'nested path' => array( 'static/jb', '/static/jb/' ), + // Every spelling of the site root canonicalizes to '/', which as a suffix match claims + // every URL ending in a slash. These must read as if the constant were never defined. + 'empty' => array( '', '/_jb_static/' ), + 'root' => array( '/', '/_jb_static/' ), + 'double slash' => array( '//', '/_jb_static/' ), + ); + } + + /** + * The decision the bootstrap router makes on every request, before the query loads. A false + * positive swallows the page and answers 400, so the near misses matter as much as the hits. + * + * @param string $defined The value a site defined the constant as. + * @param string $uri The request URI, query string included. + * @param bool $expected Whether the minify service should claim it. + * + * @dataProvider provide_router_paths + */ + #[DataProvider( 'provide_router_paths' )] + public function test_router_claims_only_static_prefix_requests( $defined, $uri, $expected ) { + define( 'JETPACK_BOOST_STATIC_PREFIX', $defined ); + + $this->assertSame( $expected, jetpack_boost_minify_request_is_for_static_prefix( $uri ) ); + } + + public static function provide_router_paths() { + return array( + // The URLs Boost emits, for each spelling of the constant that produces them. + 'slashless constant, own URL' => array( 'assets', '/assets/??/a.css,/b.css', true ), + 'leading constant, own URL' => array( '/assets', '/assets/??/a.css,/b.css', true ), + 'trailing constant, own URL' => array( 'assets/', '/assets/??/a.css,/b.css', true ), + 'subdirectory install' => array( 'assets', '/blog/assets/??/a.css', true ), + 'nested prefix' => array( 'static/jb', '/static/jb/??/a.css', true ), + 'base64 payload' => array( 'assets', '/assets/??-eJzTT8vP109KLNJLLi7W0Q==', true ), + + // Near misses. Claiming any of these serves a 400 in place of a page. + 'prefix without the slash' => array( 'assets', '/assets??/a.css', false ), + 'longer last segment' => array( 'assets', '/myassets/??/a.css', false ), + 'ordinary page' => array( 'assets', '/about/', false ), + + // A permalink whose last segment is the prefix. The payload test closes this collision. + 'page at the prefix' => array( 'assets', '/assets/', false ), + 'page at the prefix, subdir' => array( 'assets', '/blog/assets/', false ), + 'page at the prefix, 1 query' => array( 'assets', '/assets/?utm_source=x', false ), + + // Shapes the service accepts but no concatenator emits. The length term stops a bare `??`, + // which names no bundle; the leading-'?' term already refused the second. + 'empty payload' => array( 'assets', '/assets/??', false ), + 'payload not at the front' => array( 'assets', '/assets/?m=1?/a.css', false ), + + // The site root, spelled three ways. On trunk '' and '//' left the router inert, and '/' + // claimed every trailing-slash URL on the site. + 'empty constant, home' => array( '', '/', false ), + 'empty constant, wp-admin' => array( '', '/wp-admin/', false ), + 'root constant, page' => array( '/', '/about/', false ), + 'root constant, concat URL' => array( '/', '/??/a.css', false ), + 'double slash, wp-admin' => array( '//', '/wp-admin/', false ), + 'empty constant, default URL' => array( '', '/_jb_static/??/a.css', true ), + ); + } + + /** + * The overwhelmingly common case: no constant at all. + */ + public function test_router_uses_the_default_prefix_when_the_constant_is_unset() { + $this->assertTrue( jetpack_boost_minify_request_is_for_static_prefix( '/_jb_static/??/a.css' ) ); + $this->assertFalse( jetpack_boost_minify_request_is_for_static_prefix( '/_jb_static/' ) ); + $this->assertFalse( jetpack_boost_minify_request_is_for_static_prefix( '/about/' ) ); + } + + /** + * The legacy notice tells an admin a faster delivery method exists. Where the host can never serve + * it, the notice must stay quiet whatever the migrated option says. + */ + public function test_legacy_notice_is_suppressed_on_wp_cloud() { + $this->mock_host( false, true ); + + Functions\when( 'is_multisite' )->justReturn( false ); + Functions\expect( 'get_site_option' )->never(); + + $this->assertFalse( Minify_Common::show_legacy_notice() ); + } + + /** + * @param mixed $option_value The stored 404 tester verdict. + * @param bool $expected Whether the notice should show. + * + * @dataProvider provide_legacy_notice_option_values + */ + #[DataProvider( 'provide_legacy_notice_option_values' )] + public function test_legacy_notice_still_follows_the_option_on_supported_hosts( $option_value, $expected ) { + $this->mock_host( false, false ); + + Functions\when( 'is_multisite' )->justReturn( false ); + Functions\expect( 'get_site_option' ) + ->once() + ->with( 'jetpack_boost_static_minification', 'na' ) + ->andReturn( $option_value ); + + $this->assertSame( $expected, Minify_Common::show_legacy_notice() ); + } + + public static function provide_legacy_notice_option_values() { + return array( + 'never tested' => array( 'na', false ), + 'tested, cannot' => array( 0, true ), + 'tested, can use' => array( 1, false ), + ); + } +}