diff --git a/.wp-env.json b/.wp-env.json index ad5592d9eb..9af20e97ab 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -1,9 +1,12 @@ { - "core": null, - "plugins": [ "." ], - "env": { - "tests": { - "phpVersion": "7.4" - } - } + "core": null, + "plugins": [ "." ], + "env": { + "tests": { + "config": { + "FS_METHOD": "direct" + }, + "phpVersion": "7.4" + } + } } diff --git a/load.php b/load.php index 0848e22689..db4269479f 100644 --- a/load.php +++ b/load.php @@ -21,6 +21,16 @@ define( 'PERFLAB_MODULES_SETTING', 'perflab_modules_settings' ); define( 'PERFLAB_MODULES_SCREEN', 'perflab-modules' ); +// If the constant isn't defined yet, it means the Performance Lab object cache file is not loaded. +if ( ! defined( 'PERFLAB_OBJECT_CACHE_DROPIN_VERSION' ) ) { + define( 'PERFLAB_OBJECT_CACHE_DROPIN_VERSION', false ); +} + +require_once PERFLAB_PLUGIN_DIR_PATH . 'server-timing/class-perflab-server-timing-metric.php'; +require_once PERFLAB_PLUGIN_DIR_PATH . 'server-timing/class-perflab-server-timing.php'; +require_once PERFLAB_PLUGIN_DIR_PATH . 'server-timing/load.php'; +require_once PERFLAB_PLUGIN_DIR_PATH . 'server-timing/defaults.php'; + /** * Registers the performance modules setting. * @@ -253,9 +263,101 @@ function perflab_load_active_and_valid_modules() { require_once PERFLAB_PLUGIN_DIR_PATH . 'modules/' . $module . '/load.php'; } } - perflab_load_active_and_valid_modules(); +/** + * Places the Performance Lab's object cache drop-in in the drop-ins folder. + * + * This only runs in WP Admin to not have any potential performance impact on + * the frontend. + * + * This function will short-circuit if the constant + * 'PERFLAB_DISABLE_OBJECT_CACHE_DROPIN' is set as true. + * + * @since n.e.x.t + * + * @global WP_Filesystem_Base $wp_filesystem WordPress filesystem subclass. + */ +function perflab_maybe_set_object_cache_dropin() { + global $wp_filesystem; + + // Bail if disabled via constant. + if ( defined( 'PERFLAB_DISABLE_OBJECT_CACHE_DROPIN' ) && PERFLAB_DISABLE_OBJECT_CACHE_DROPIN ) { + return; + } + + // Bail if already placed. + if ( PERFLAB_OBJECT_CACHE_DROPIN_VERSION ) { + return; + } + + // Bail if already attempted before timeout has been completed. + // This is present in case placing the file fails for some reason, to avoid + // excessively retrying to place it on every request. + $timeout = get_transient( 'perflab_set_object_cache_dropin' ); + if ( false !== $timeout ) { + return; + } + + if ( $wp_filesystem || WP_Filesystem() ) { + // If there is an actual object-cache.php file, rename it. + // The Performance Lab object-cache.php will still load it, so the + // behavior does not change. + if ( $wp_filesystem->exists( WP_CONTENT_DIR . '/object-cache.php' ) ) { + $wp_filesystem->move( WP_CONTENT_DIR . '/object-cache.php', WP_CONTENT_DIR . '/object-cache-plst-orig.php' ); + } + + $wp_filesystem->copy( PERFLAB_PLUGIN_DIR_PATH . 'server-timing/object-cache.copy.php', WP_CONTENT_DIR . '/object-cache.php' ); + } + + // Set timeout of 1 hour before retrying again (only in case of failure). + set_transient( 'perflab_set_object_cache_dropin', true, HOUR_IN_SECONDS ); +} +add_action( 'admin_init', 'perflab_maybe_set_object_cache_dropin' ); + +/** + * Removes the Performance Lab's object cache drop-in from the drop-ins folder. + * + * This function should be run on plugin deactivation. If there was another original + * object-cache.php drop-in file (renamed in `perflab_maybe_set_object_cache_dropin()` + * to object-cache-plst-orig.php), it will be restored. + * + * This function will short-circuit if the constant + * 'PERFLAB_DISABLE_OBJECT_CACHE_DROPIN' is set as true. + * + * @since n.e.x.t + * + * @global WP_Filesystem_Base $wp_filesystem WordPress filesystem subclass. + */ +function perflab_maybe_remove_object_cache_dropin() { + global $wp_filesystem; + + // Bail if disabled via constant. + if ( defined( 'PERFLAB_DISABLE_OBJECT_CACHE_DROPIN' ) && PERFLAB_DISABLE_OBJECT_CACHE_DROPIN ) { + return; + } + + // Bail if custom drop-in not present anyway. + if ( ! PERFLAB_OBJECT_CACHE_DROPIN_VERSION ) { + return; + } + + if ( $wp_filesystem || WP_Filesystem() ) { + // If there is an actual object-cache.php file, restore it + // and override the Performance Lab file. + // Otherwise just delete the Performance Lab file. + if ( $wp_filesystem->exists( WP_CONTENT_DIR . '/object-cache-plst-orig.php' ) ) { + $wp_filesystem->move( WP_CONTENT_DIR . '/object-cache-plst-orig.php', WP_CONTENT_DIR . '/object-cache.php', true ); + } else { + $wp_filesystem->delete( WP_CONTENT_DIR . '/object-cache.php' ); + } + } + + // Delete transient for drop-in check in case the plugin is reactivated shortly after. + delete_transient( 'perflab_set_object_cache_dropin' ); +} +register_deactivation_hook( __FILE__, 'perflab_maybe_remove_object_cache_dropin' ); + // Only load admin integration when in admin. if ( is_admin() ) { require_once PERFLAB_PLUGIN_DIR_PATH . 'admin/load.php'; diff --git a/server-timing/class-perflab-server-timing-metric.php b/server-timing/class-perflab-server-timing-metric.php new file mode 100644 index 0000000000..cf139d1b25 --- /dev/null +++ b/server-timing/class-perflab-server-timing-metric.php @@ -0,0 +1,145 @@ +slug = $slug; + } + + /** + * Gets the metric slug. + * + * @since n.e.x.t + * + * @return string The metric slug. + */ + public function get_slug() { + return $this->slug; + } + + /** + * Sets the metric value. + * + * Alternatively to setting the metric value directly, the {@see Perflab_Server_Timing_Metric::measure_before()} + * and {@see Perflab_Server_Timing_Metric::measure_after()} methods can be used to further simplify measuring. + * + * @since n.e.x.t + * + * @param int|float $value The metric value to set, in milliseconds. + */ + public function set_value( $value ) { + if ( ! is_numeric( $value ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: PHP parameter name */ + sprintf( __( 'The %s parameter must be an integer, float, or numeric string.', 'performance-lab' ), '$value' ), + '' + ); + return; + } + + if ( did_action( 'perflab_server_timing_send_header' ) && ! doing_action( 'perflab_server_timing_send_header' ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: WordPress action name */ + sprintf( __( 'The method must be called before or during the %s action.', 'performance-lab' ), 'perflab_server_timing_send_header' ), + '' + ); + return; + } + + // In case e.g. a numeric string is passed, cast it. + if ( ! is_int( $value ) && ! is_float( $value ) ) { + $value = (float) $value; + } + + $this->value = $value; + } + + /** + * Gets the metric value. + * + * @since n.e.x.t + * + * @return int|float|null The metric value, or null if none set. + */ + public function get_value() { + return $this->value; + } + + /** + * Captures the current time, as a reference point to calculate the duration of a task afterwards. + * + * This should be used in combination with {@see Perflab_Server_Timing_Metric::measure_after()}. Alternatively, + * {@see Perflab_Server_Timing_Metric::set_value()} can be used to set a calculated value manually. + * + * @since n.e.x.t + */ + public function measure_before() { + $this->before_value = microtime( true ); + } + + /** + * Captures the current time and compares it to the reference point to calculate a task's duration. + * + * This should be used in combination with {@see Perflab_Server_Timing_Metric::measure_before()}. Alternatively, + * {@see Perflab_Server_Timing_Metric::set_value()} can be used to set a calculated value manually. + * + * @since n.e.x.t + */ + public function measure_after() { + if ( ! $this->before_value ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: PHP method name */ + sprintf( __( 'The %s method must be called before.', 'performance-lab' ), __CLASS__ . '::measure_before()' ), + '' + ); + return; + } + + $this->set_value( ( microtime( true ) - $this->before_value ) * 1000.0 ); + } +} diff --git a/server-timing/class-perflab-server-timing.php b/server-timing/class-perflab-server-timing.php new file mode 100644 index 0000000000..f45749a5f0 --- /dev/null +++ b/server-timing/class-perflab-server-timing.php @@ -0,0 +1,265 @@ +registered_metrics[ $metric_slug ] ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: metric slug */ + sprintf( __( 'A metric with the slug %s is already registered.', 'performance-lab' ), $metric_slug ), + '' + ); + return; + } + + if ( did_action( 'perflab_server_timing_send_header' ) && ! doing_action( 'perflab_server_timing_send_header' ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: WordPress action name */ + sprintf( __( 'The method must be called before or during the %s action.', 'performance-lab' ), 'perflab_server_timing_send_header' ), + '' + ); + return; + } + + $args = wp_parse_args( + $args, + array( + 'measure_callback' => null, + 'access_cap' => null, + ) + ); + if ( ! $args['measure_callback'] || ! is_callable( $args['measure_callback'] ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: PHP parameter name */ + sprintf( __( 'The %s argument is required and must be a callable.', 'performance-lab' ), '$args["measure_callback"]' ), + '' + ); + return; + } + if ( ! $args['access_cap'] || ! is_string( $args['access_cap'] ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: PHP parameter name */ + sprintf( __( 'The %s argument is required and must be a string.', 'performance-lab' ), '$args["access_cap"]' ), + '' + ); + return; + } + + $this->registered_metrics[ $metric_slug ] = new Perflab_Server_Timing_Metric( $metric_slug ); + $this->registered_metrics_data[ $metric_slug ] = $args; + + // If the current user has already been determined and they lack the necessary access, + // do not even attempt to calculate the metric. + if ( did_action( 'set_current_user' ) && ! current_user_can( $args['access_cap'] ) ) { + return; + } + + // Otherwise, call the measuring callback and pass the metric instance to it. + call_user_func( $args['measure_callback'], $this->registered_metrics[ $metric_slug ] ); + } + + /** + * Checks whether the given metric has been registered. + * + * @since n.e.x.t + * + * @param string $metric_slug The metric slug. + * @return bool True if registered, false otherwise. + */ + public function has_registered_metric( $metric_slug ) { + return isset( $this->registered_metrics[ $metric_slug ] ) && isset( $this->registered_metrics_data[ $metric_slug ] ); + } + + /** + * Outputs the Server-Timing header. + * + * This method must be called before rendering the page. + * + * @since n.e.x.t + */ + public function send_header() { + if ( headers_sent() ) { + _doing_it_wrong( + __METHOD__, + __( 'The method must be called before headers have been sent.', 'performance-lab' ), + '' + ); + return; + } + + /** + * Fires right before the Server-Timing header is sent. + * + * This action is the last possible point to register a Server-Timing metric. + * + * @since n.e.x.t + */ + do_action( 'perflab_server_timing_send_header' ); + + $header_value = $this->get_header(); + if ( ! $header_value ) { + return; + } + + header( sprintf( 'Server-Timing: %s', $header_value ), false ); + } + + /** + * Gets the value for the Server-Timing header. + * + * @since n.e.x.t + * + * @return string The Server-Timing header value. + */ + public function get_header() { + // Get all metric header values, as long as the current user has access to the metric. + $metric_header_values = array_filter( + array_map( + function( Perflab_Server_Timing_Metric $metric ) { + // Check the registered capability here to ensure no metric without access is exposed. + if ( ! current_user_can( $this->registered_metrics_data[ $metric->get_slug() ]['access_cap'] ) ) { + return null; + } + + return $this->format_metric_header_value( $metric ); + }, + $this->registered_metrics + ), + function( $value ) { + return null !== $value; + } + ); + + return implode( ', ', $metric_header_values ); + } + + /** + * Returns whether an output buffer should be used to gather Server-Timing metrics during template rendering. + * + * Without an output buffer, it is only possible to cover metrics from before serving the template, i.e. before + * the HTML output starts. Therefore sites that would like to gather metrics while serving the template should + * enable this via the {@see 'perflab_server_timing_use_output_buffer'} filter. + * + * @since n.e.x.t + * + * @return bool True if an output buffer should be used, false otherwise. + */ + public function use_output_buffer() { + /** + * Filters whether an output buffer should be used to be able to gather additional Server-Timing metrics. + * + * Without an output buffer, it is only possible to cover metrics from before serving the template, i.e. before + * the HTML output starts. Therefore sites that would like to gather metrics while serving the template should + * enable this. + * + * @since n.e.x.t + * + * @param bool $use_output_buffer Whether to use an output buffer. + */ + return apply_filters( 'perflab_server_timing_use_output_buffer', false ); + } + + /** + * Hook callback for the 'template_include' filter. + * + * This effectively initializes the class to send the Server-Timing header at the right point. + * + * This method is solely intended for internal use within WordPress. + * + * @since n.e.x.t + * + * @param mixed $passthrough Optional. Filter value. Default null. + * @return mixed Unmodified value of $passthrough. + */ + public function on_template_include( $passthrough = null ) { + if ( ! $this->use_output_buffer() ) { + $this->send_header(); + return $passthrough; + } + + ob_start(); + add_action( + 'shutdown', + function() { + $output = ob_get_clean(); + $this->send_header(); + echo $output; + }, + // phpcs:ignore PHPCompatibility.Constants.NewConstants + defined( 'PHP_INT_MIN' ) ? PHP_INT_MIN : -1000 + ); + return $passthrough; + } + + /** + * Formats the header segment for a single metric. + * + * @since n.e.x.t + * + * @param Perflab_Server_Timing_Metric $metric The metric to format. + * @return string|null Segment for the Server-Timing header, or null if no value set. + */ + private function format_metric_header_value( Perflab_Server_Timing_Metric $metric ) { + $value = $metric->get_value(); + + // If no value is set, make sure it's just passed through. + if ( null === $value ) { + return null; + } + + if ( is_float( $value ) ) { + $value = round( $value, 2 ); + } + return sprintf( 'wp-%1$s;dur=%2$s', $metric->get_slug(), $value ); + } +} diff --git a/server-timing/defaults.php b/server-timing/defaults.php new file mode 100644 index 0000000000..81fc095543 --- /dev/null +++ b/server-timing/defaults.php @@ -0,0 +1,192 @@ + function( $metric ) { + // The 'timestart' global is set right at the beginning of WordPress execution. + $metric->set_value( ( microtime( true ) - $GLOBALS['timestart'] ) * 1000.0 ); + }, + 'access_cap' => 'exist', + ) + ); + + // SQL query time is only measured if the SAVEQUERIES constant is set to true. + if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) { + // WordPress database query time before template. + perflab_server_timing_register_metric( + 'before-template-db-queries', + array( + 'measure_callback' => function( $metric ) { + // This should never happen, but some odd database implementations may be doing it wrong. + if ( ! isset( $GLOBALS['wpdb']->queries ) || ! is_array( $GLOBALS['wpdb']->queries ) ) { + return; + } + + // Store this value in a global to later subtract it from total query time after template. + $GLOBALS['perflab_query_time_before_template'] = array_reduce( + $GLOBALS['wpdb']->queries, + function( $acc, $query ) { + return $acc + $query[1]; + }, + 0.0 + ); + $metric->set_value( $GLOBALS['perflab_query_time_before_template'] * 1000.0 ); + }, + 'access_cap' => 'exist', + ) + ); + } + }; + + // If output buffering is used, explicitly measure only the time before serving the template. + // Otherwise, the Server-Timing header will be sent before serving the template anyway. + // We need to check for output buffer usage in the callback so that e.g. plugins and theme can + // modify the value prior to the check. + add_filter( + 'template_include', + function( $passthrough ) use ( $calculate_before_template_metrics ) { + if ( perflab_server_timing_use_output_buffer() ) { + $calculate_before_template_metrics(); + } + return $passthrough; + }, + PHP_INT_MAX + ); + add_action( + 'perflab_server_timing_send_header', + function() use ( $calculate_before_template_metrics ) { + if ( ! perflab_server_timing_use_output_buffer() ) { + $calculate_before_template_metrics(); + } + }, + PHP_INT_MAX + ); + + // Measure duration of autoloaded options query. + // Requires the Performance Lab object-cache.php drop-in to be present in order to work, + // which is why the constant is checked below. + if ( PERFLAB_OBJECT_CACHE_DROPIN_VERSION ) { + add_filter( + 'query', + function( $query ) { + global $wpdb; + if ( "SELECT option_name, option_value FROM $wpdb->options WHERE autoload = 'yes'" !== $query ) { + return $query; + } + // In case the autoloaded options query is run again, prevent re-registering it and do not measure again. + if ( perflab_server_timing()->has_registered_metric( 'load-alloptions-query' ) ) { + return $query; + } + perflab_server_timing_register_metric( + 'load-alloptions-query', + array( + 'measure_callback' => function( $metric ) { + $metric->measure_before(); + add_filter( + 'pre_cache_alloptions', + function( $passthrough ) use ( $metric ) { + $metric->measure_after(); + return $passthrough; + } + ); + }, + 'access_cap' => 'exist', + ) + ); + return $query; + } + ); + } +} +perflab_register_default_server_timing_before_template_metrics(); + +/** + * Registers the default Server-Timing metrics while rendering the template. + * + * These metrics should be registered at a later point, e.g. the 'wp_loaded' action. + * They will only be registered if the Server-Timing API is configured to use an + * output buffer for the site's template. + * + * @since n.e.x.t + */ +function perflab_register_default_server_timing_template_metrics() { + // Template-related metrics can only be recorded if output buffering is used. + if ( ! perflab_server_timing_use_output_buffer() ) { + return; + } + + add_filter( + 'template_include', + function( $passthrough = null ) { + // WordPress execution while serving the template. + perflab_server_timing_register_metric( + 'template', + array( + 'measure_callback' => function( $metric ) { + $metric->measure_before(); + add_action( 'perflab_server_timing_send_header', array( $metric, 'measure_after' ), PHP_INT_MAX ); + }, + 'access_cap' => 'exist', + ) + ); + + return $passthrough; + }, + PHP_INT_MAX + ); + + // SQL query time is only measured if the SAVEQUERIES constant is set to true. + if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) { + add_action( + 'perflab_server_timing_send_header', + function() { + // WordPress database query time within template. + perflab_server_timing_register_metric( + 'template-db-queries', + array( + 'measure_callback' => function( $metric ) { + // This global should typically be set when this is called, but check just in case. + if ( ! isset( $GLOBALS['perflab_query_time_before_template'] ) ) { + return; + } + + // This should never happen, but some odd database implementations may be doing it wrong. + if ( ! isset( $GLOBALS['wpdb']->queries ) || ! is_array( $GLOBALS['wpdb']->queries ) ) { + return; + } + + $total_query_time = array_reduce( + $GLOBALS['wpdb']->queries, + function( $acc, $query ) { + return $acc + $query[1]; + }, + 0.0 + ); + $metric->set_value( ( $total_query_time - $GLOBALS['perflab_query_time_before_template'] ) * 1000.0 ); + }, + 'access_cap' => 'exist', + ) + ); + }, + PHP_INT_MAX + ); + } +} +add_action( 'wp_loaded', 'perflab_register_default_server_timing_template_metrics' ); diff --git a/server-timing/load.php b/server-timing/load.php new file mode 100644 index 0000000000..4c1bf1b62f --- /dev/null +++ b/server-timing/load.php @@ -0,0 +1,111 @@ +register_metric( $metric_slug, $args ); +} + +/** + * Returns whether an output buffer should be used to gather Server-Timing metrics during template rendering. + * + * @since n.e.x.t + * + * @return bool True if an output buffer should be used, false otherwise. + */ +function perflab_server_timing_use_output_buffer() { + return perflab_server_timing()->use_output_buffer(); +} + +/** + * Wraps a callback (e.g. for an action or filter) to be measured and included in the Server-Timing header. + * + * @since n.e.x.t + * + * @param callable $callback The callback to wrap. + * @param string $metric_slug The metric slug to use within the Server-Timing header. + * @param string $access_cap Capability required to view the metric. If this is a public metric, this needs to be + * set to "exist". + * @return callable Callback function that will run $callback and measure its execution time once called. + */ +function perflab_wrap_server_timing( $callback, $metric_slug, $access_cap ) { + return function( ...$callback_args ) use ( $callback, $metric_slug, $access_cap ) { + // Gain access to Perflab_Server_Timing_Metric instance. + $server_timing_metric = null; + + // Only register the metric the first time the function is called. + // For now, this also means only the first function call is measured. + if ( ! perflab_server_timing()->has_registered_metric( $metric_slug ) ) { + perflab_server_timing_register_metric( + $metric_slug, + array( + 'measure_callback' => function( $metric ) use ( &$server_timing_metric ) { + $server_timing_metric = $metric; + }, + 'access_cap' => $access_cap, + ) + ); + } + + // If metric instance was not set, this metric should not be calculated. + if ( null === $server_timing_metric ) { + return call_user_func_array( $callback, $callback_args ); + } + + // Measure time before the callback. + $server_timing_metric->measure_before(); + + // Execute the callback. + $result = call_user_func_array( $callback, $callback_args ); + + // Measure time after the callback and calculate total. + $server_timing_metric->measure_after(); + + // Return result (e.g. in case this is a filter callback). + return $result; + }; +} diff --git a/server-timing/object-cache.copy.php b/server-timing/object-cache.copy.php new file mode 100644 index 0000000000..59af853349 --- /dev/null +++ b/server-timing/object-cache.copy.php @@ -0,0 +1,57 @@ +markTestSkipped( 'Filesystem cannot be initialized.' ); + } + + if ( ! $GLOBALS['wp_filesystem']->is_writable( WP_CONTENT_DIR ) ) { + $this->markTestSkipped( 'This system does not allow file modifications within WP_CONTENT_DIR.' ); + } + + $this->assertFalse( $GLOBALS['wp_filesystem']->exists( WP_CONTENT_DIR . '/object-cache.php' ) ); + $this->assertFalse( PERFLAB_OBJECT_CACHE_DROPIN_VERSION ); + + perflab_maybe_set_object_cache_dropin(); + $this->assertTrue( $GLOBALS['wp_filesystem']->exists( WP_CONTENT_DIR . '/object-cache.php' ) ); + + // Clean up. This is okay to be run after the assertion since otherwise + // the file does not exist anyway. + $GLOBALS['wp_filesystem']->delete( WP_CONTENT_DIR . '/object-cache.php' ); + } } diff --git a/tests/server-timing/load-tests.php b/tests/server-timing/load-tests.php new file mode 100644 index 0000000000..3dfec9b29e --- /dev/null +++ b/tests/server-timing/load-tests.php @@ -0,0 +1,61 @@ +assertInstanceOf( Perflab_Server_Timing::class, $server_timing ); + $this->assertSame( PHP_INT_MAX, has_filter( 'template_include', array( $server_timing, 'on_template_include' ) ), 'template_include filter not added' ); + + $server_timing2 = perflab_server_timing(); + $this->assertSame( $server_timing, $server_timing2, 'Different instance returned' ); + } + + public function test_perflab_server_timing_register_metric() { + $this->assertFalse( perflab_server_timing()->has_registered_metric( 'test-metric' ) ); + + perflab_server_timing_register_metric( + 'test-metric', + array( + 'measure_callback' => function( $metric ) { + $metric->set_value( 100 ); + }, + 'access_cap' => 'exist', + ) + ); + $this->assertTrue( perflab_server_timing()->has_registered_metric( 'test-metric' ) ); + } + + public function test_perflab_server_timing_use_output_buffer() { + $this->assertFalse( perflab_server_timing_use_output_buffer() ); + + add_filter( 'perflab_server_timing_use_output_buffer', '__return_true' ); + $this->assertTrue( perflab_server_timing_use_output_buffer() ); + } + + public function test_perflab_wrap_server_timing() { + $cb = function() { + return 123; + }; + + $wrapped = perflab_wrap_server_timing( $cb, 'wrapped-cb-without-capability', 'manage_options' ); + $this->assertSame( 123, $wrapped(), 'Wrapped callback without capability did not return expected value' ); + $this->assertTrue( perflab_server_timing()->has_registered_metric( 'wrapped-cb-without-capability' ), 'Wrapped callback metric should be registered despite lack of capability' ); + $this->assertStringNotContainsString( 'wrapped-cb-without-capability', perflab_server_timing()->get_header(), 'Wrapped callback was measured despite lack of capability' ); + + $wrapped = perflab_wrap_server_timing( $cb, 'wrapped-cb-with-capability', 'exist' ); + $this->assertSame( 123, $wrapped(), 'Wrapped callback with capability did not return expected value' ); + $this->assertTrue( perflab_server_timing()->has_registered_metric( 'wrapped-cb-with-capability' ), 'Wrapped callback metric should be registered' ); + $this->assertStringContainsString( 'wrapped-cb-with-capability', perflab_server_timing()->get_header(), 'Wrapped callback was not measured despite having necessary capability' ); + + $this->assertSame( 123, $wrapped(), 'Calling wrapped callback multiple times should not result in warning' ); + } +} diff --git a/tests/server-timing/perflab-server-timing-metric-tests.php b/tests/server-timing/perflab-server-timing-metric-tests.php new file mode 100644 index 0000000000..61fa929351 --- /dev/null +++ b/tests/server-timing/perflab-server-timing-metric-tests.php @@ -0,0 +1,77 @@ +metric = new Perflab_Server_Timing_Metric( 'test-metric' ); + } + + public function test_get_slug() { + $this->assertSame( 'test-metric', $this->metric->get_slug() ); + } + + public function test_set_value_with_integer() { + $this->metric->set_value( 123 ); + $this->assertSame( 123, $this->metric->get_value() ); + } + + public function test_set_value_with_float() { + $this->metric->set_value( 123.4567 ); + $this->assertSame( 123.4567, $this->metric->get_value() ); + } + + public function test_set_value_with_numeric_string() { + $this->metric->set_value( '123.4567' ); + $this->assertSame( 123.4567, $this->metric->get_value() ); + } + + public function test_set_value_requires_integer_or_float_or_numeric_string() { + $this->setExpectedIncorrectUsage( Perflab_Server_Timing_Metric::class . '::set_value' ); + + $this->metric->set_value( 'not-a-number' ); + $this->assertNull( $this->metric->get_value() ); + } + + public function test_set_value_prevents_late_measurement() { + $this->setExpectedIncorrectUsage( Perflab_Server_Timing_Metric::class . '::set_value' ); + + $this->metric->set_value( 2 ); + do_action( 'perflab_server_timing_send_header' ); + $this->metric->set_value( 3 ); + + $this->assertSame( 2, $this->metric->get_value() ); + } + + public function test_get_value() { + $this->metric->set_value( 86.42 ); + $this->assertSame( 86.42, $this->metric->get_value() ); + } + + public function test_measure_before_and_after_correctly() { + $this->metric->measure_before(); + sleep( 1 ); + $this->metric->measure_after(); + + // Loose float comparison with 100ms delta, since measurement won't be exactly 1000ms. + $this->assertEqualsWithDelta( 1000.0, $this->metric->get_value(), 100.0 ); + } + + public function test_measure_after_without_before() { + $this->setExpectedIncorrectUsage( Perflab_Server_Timing_Metric::class . '::measure_after' ); + + $this->metric->measure_after(); + + $this->assertNull( $this->metric->get_value() ); + } +} diff --git a/tests/server-timing/perflab-server-timing-tests.php b/tests/server-timing/perflab-server-timing-tests.php new file mode 100644 index 0000000000..3617ed23e7 --- /dev/null +++ b/tests/server-timing/perflab-server-timing-tests.php @@ -0,0 +1,196 @@ + function() {}, + 'access_cap' => 'exist', + ); + + self::$admin_id = $factory->user->create( array( 'role' => 'administrator' ) ); + } + + public static function wpTearDownAfterClass() { + self::delete_user( self::$admin_id ); + } + + public function set_up() { + parent::set_up(); + $this->server_timing = new Perflab_Server_Timing(); + } + + public function test_register_metric_stores_metrics_and_runs_measure_callback() { + $called = false; + $this->server_timing->register_metric( + 'test-metric', + array( + 'measure_callback' => function() use ( &$called ) { + $called = true; + }, + 'access_cap' => 'exist', + ) + ); + + $this->assertTrue( $this->server_timing->has_registered_metric( 'test-metric' ), 'Metric not registered' ); + $this->assertTrue( $called, 'Measure callback not run' ); + } + + public function test_register_metric_runs_measure_callback_based_on_access_cap() { + $called = false; + $args = array( + 'measure_callback' => function() use ( &$called ) { + $called = true; + }, + 'access_cap' => 'manage_options', // Admin capability. + ); + + $this->server_timing->register_metric( 'test-metric', $args ); + + $this->assertTrue( $this->server_timing->has_registered_metric( 'test-metric' ), 'Metric without cap should still be registered' ); + $this->assertFalse( $called, 'Measure callback without cap must not be run' ); + + wp_set_current_user( self::$admin_id ); + $this->server_timing->register_metric( 'test-metric-2', $args ); + + $this->assertTrue( $this->server_timing->has_registered_metric( 'test-metric-2' ), 'Metric with cap should be registered' ); + $this->assertTrue( $called, 'Measure callback with cap should be run' ); + } + + public function test_register_metric_prevents_duplicates() { + $this->setExpectedIncorrectUsage( Perflab_Server_Timing::class . '::register_metric' ); + + $this->server_timing->register_metric( 'duplicate-metric', self::$dummy_args ); + $this->server_timing->register_metric( 'duplicate-metric', self::$dummy_args ); + + $this->assertTrue( $this->server_timing->has_registered_metric( 'duplicate-metric' ) ); + } + + public function test_register_metric_prevents_late_registration() { + $this->setExpectedIncorrectUsage( Perflab_Server_Timing::class . '::register_metric' ); + + $this->server_timing->register_metric( 'registered-in-time', self::$dummy_args ); + do_action( 'perflab_server_timing_send_header' ); + $this->server_timing->register_metric( 'registered-too-late', self::$dummy_args ); + + $this->assertTrue( $this->server_timing->has_registered_metric( 'registered-in-time' ), 'Metric registered in time should be stored' ); + $this->assertFalse( $this->server_timing->has_registered_metric( 'registered-too-late' ), 'Metric registered too late should not be stored' ); + } + + public function test_register_metric_requires_measure_callback() { + $this->setExpectedIncorrectUsage( Perflab_Server_Timing::class . '::register_metric' ); + + $this->server_timing->register_metric( + 'metric-without-measure-callback', + array( 'access_cap' => 'exist' ) + ); + + $this->assertFalse( $this->server_timing->has_registered_metric( 'metric-without-measure-callback' ) ); + } + + public function test_register_metric_requires_access_cap() { + $this->setExpectedIncorrectUsage( Perflab_Server_Timing::class . '::register_metric' ); + + $this->server_timing->register_metric( + 'metric-without-access-cap', + array( 'measure_callback' => function() {} ) + ); + + $this->assertFalse( $this->server_timing->has_registered_metric( 'metric-without-access-cap' ) ); + } + + public function test_has_registered_metric() { + $this->assertFalse( $this->server_timing->has_registered_metric( 'metric-to-check-for' ), 'Metric should not be available before registration' ); + + $this->server_timing->register_metric( 'metric-to-check-for', self::$dummy_args ); + $this->assertTrue( $this->server_timing->has_registered_metric( 'metric-to-check-for' ), 'Metric should be available after registration' ); + } + + /** + * @dataProvider data_get_header + */ + public function test_get_header( $expected, $metrics ) { + foreach ( $metrics as $metric_slug => $args ) { + $this->server_timing->register_metric( $metric_slug, $args ); + } + $this->assertSame( $expected, $this->server_timing->get_header() ); + } + + public function data_get_header() { + $measure_42 = function( $metric ) { + $metric->set_value( 42 ); + }; + $measure_300 = function( $metric ) { + $metric->set_value( 300 ); + }; + $measure_12point345 = function( $metric ) { + $metric->set_value( 12.345 ); + }; + + return array( + 'single metric' => array( + 'wp-integer;dur=300', + array( + 'integer' => array( + 'measure_callback' => $measure_300, + 'access_cap' => 'exist', + ), + ), + ), + 'multiple metrics' => array( + 'wp-integer;dur=300, wp-float;dur=12.35, wp-bttf;dur=42, wp-bttf2;dur=42', + array( + 'integer' => array( + 'measure_callback' => $measure_300, + 'access_cap' => 'exist', + ), + 'float' => array( + 'measure_callback' => $measure_12point345, + 'access_cap' => 'exist', + ), + 'bttf' => array( + 'measure_callback' => $measure_42, + 'access_cap' => 'exist', + ), + 'bttf2' => array( + 'measure_callback' => $measure_42, + 'access_cap' => 'exist', + ), + ), + ), + 'metrics with partially missing cap' => array( + 'wp-with-cap;dur=42', + array( + 'without-cap' => array( + 'measure_callback' => $measure_42, + 'access_cap' => 'cap_that_nobody_has', + ), + 'with-cap' => array( + 'measure_callback' => $measure_42, + 'access_cap' => 'exist', + ), + ), + ), + ); + } + + public function test_use_output_buffer() { + $this->assertFalse( $this->server_timing->use_output_buffer() ); + + add_filter( 'perflab_server_timing_use_output_buffer', '__return_true' ); + $this->assertTrue( $this->server_timing->use_output_buffer() ); + } +}