diff --git a/src/wp-includes/class-wp-speculation-rules.php b/src/wp-includes/class-wp-speculation-rules.php new file mode 100644 index 0000000000000..8d23eefe1207a --- /dev/null +++ b/src/wp-includes/class-wp-speculation-rules.php @@ -0,0 +1,293 @@ + $rules` pairs. + * + * Every `$rules` value is a map of `$id => $rule` pairs. + * + * @since 6.8.0 + * @var array> + */ + private $rules_by_mode = array(); + + /** + * The allowed speculation rules modes as a map, used for validation. + * + * @since 6.8.0 + * @var array + */ + private static $mode_allowlist = array( + 'prefetch' => true, + 'prerender' => true, + ); + + /** + * The allowed speculation rules eagerness levels as a map, used for validation. + * + * @since 6.8.0 + * @var array + */ + private static $eagerness_allowlist = array( + 'immediate' => true, + 'eager' => true, + 'moderate' => true, + 'conservative' => true, + ); + + /** + * The allowed speculation rules sources as a map, used for validation. + * + * @since 6.8.0 + * @var array + */ + private static $source_allowlist = array( + 'list' => true, + 'document' => true, + ); + + /** + * Adds a speculation rule to the speculation rules to consider. + * + * @since 6.8.0 + * + * @param string $mode Speculative loading mode. Either 'prefetch' or 'prerender'. + * @param string $id Unique string identifier for the speculation rule. + * @param array $rule Associative array of rule arguments. + * @return bool True on success, false if invalid parameters are provided. + */ + public function add_rule( string $mode, string $id, array $rule ): bool { + if ( ! self::is_valid_mode( $mode ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: invalid mode value */ + __( 'The value "%s" is not a valid speculation rules mode.' ), + esc_html( $mode ) + ), + '6.8.0' + ); + return false; + } + + if ( ! $this->is_valid_id( $id ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: invalid ID value */ + __( 'The value "%s" is not a valid ID for a speculation rule.' ), + esc_html( $id ) + ), + '6.8.0' + ); + return false; + } + + if ( $this->has_rule( $mode, $id ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: invalid ID value */ + __( 'A speculation rule with ID "%s" already exists.' ), + esc_html( $id ) + ), + '6.8.0' + ); + return false; + } + + /* + * Perform some basic speculation rule validation. + * Every rule must have either a 'where' key or a 'urls' key, but not both. + * The presence of a 'where' key implies a 'source' of 'document', while the presence of a 'urls' key implies + * a 'source' of 'list'. + */ + if ( + ( ! isset( $rule['where'] ) && ! isset( $rule['urls'] ) ) || + ( isset( $rule['where'] ) && isset( $rule['urls'] ) ) + ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: 1: allowed key, 2: alternative allowed key */ + __( 'A speculation rule must include either a "%1$s" key or a "%2$s" key, but not both.' ), + 'where', + 'urls' + ), + '6.8.0' + ); + return false; + } + if ( isset( $rule['source'] ) ) { + if ( ! self::is_valid_source( $rule['source'] ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: invalid source value */ + __( 'The value "%s" is not a valid source for a speculation rule.' ), + esc_html( $rule['source'] ) + ), + '6.8.0' + ); + return false; + } + + if ( 'list' === $rule['source'] && isset( $rule['where'] ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: 1: source value, 2: forbidden key */ + __( 'A speculation rule of source "%1$s" must not include a "%2$s" key.' ), + 'list', + 'where' + ), + '6.8.0' + ); + return false; + } + + if ( 'document' === $rule['source'] && isset( $rule['urls'] ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: 1: source value, 2: forbidden key */ + __( 'A speculation rule of source "%1$s" must not include a "%2$s" key.' ), + 'document', + 'urls' + ), + '6.8.0' + ); + return false; + } + } + + // If there is an 'eagerness' key specified, make sure it's valid. + if ( isset( $rule['eagerness'] ) ) { + if ( ! self::is_valid_eagerness( $rule['eagerness'] ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: invalid eagerness value */ + __( 'The value "%s" is not a valid eagerness for a speculation rule.' ), + esc_html( $rule['eagerness'] ) + ), + '6.8.0' + ); + return false; + } + + if ( isset( $rule['where'] ) && 'immediate' === $rule['eagerness'] ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: forbidden eagerness value */ + __( 'The eagerness value "%s" is forbidden for document-level speculation rules.' ), + 'immediate' + ), + '6.8.0' + ); + return false; + } + } + + if ( ! isset( $this->rules_by_mode[ $mode ] ) ) { + $this->rules_by_mode[ $mode ] = array(); + } + + $this->rules_by_mode[ $mode ][ $id ] = $rule; + return true; + } + + /** + * Checks whether a speculation rule for the given mode and ID already exists. + * + * @since 6.8.0 + * + * @param string $mode Speculative loading mode. Either 'prefetch' or 'prerender'. + * @param string $id Unique string identifier for the speculation rule. + * @return bool True if the rule already exists, false otherwise. + */ + public function has_rule( string $mode, string $id ): bool { + return isset( $this->rules_by_mode[ $mode ][ $id ] ); + } + + /** + * Returns the speculation rules data ready to be JSON-encoded. + * + * @since 6.8.0 + * + * @return array> Speculation rules data. + */ + #[ReturnTypeWillChange] + public function jsonSerialize() { + // Strip the IDs for JSON output, since they are not relevant for the Speculation Rules API. + return array_map( + static function ( array $rules ) { + return array_values( $rules ); + }, + array_filter( $this->rules_by_mode ) + ); + } + + /** + * Checks whether the given ID is valid. + * + * @since 6.8.0 + * + * @param string $id Unique string identifier for the speculation rule. + * @return bool True if the ID is valid, false otherwise. + */ + private function is_valid_id( string $id ): bool { + return (bool) preg_match( '/^[a-z][a-z0-9_-]+$/', $id ); + } + + /** + * Checks whether the given speculation rules mode is valid. + * + * @since 6.8.0 + * + * @param string $mode Speculation rules mode. + * @return bool True if valid, false otherwise. + */ + public static function is_valid_mode( string $mode ): bool { + return isset( self::$mode_allowlist[ $mode ] ); + } + + /** + * Checks whether the given speculation rules eagerness is valid. + * + * @since 6.8.0 + * + * @param string $eagerness Speculation rules eagerness. + * @return bool True if valid, false otherwise. + */ + public static function is_valid_eagerness( string $eagerness ): bool { + return isset( self::$eagerness_allowlist[ $eagerness ] ); + } + + /** + * Checks whether the given speculation rules source is valid. + * + * @since 6.8.0 + * + * @param string $source Speculation rules source. + * @return bool True if valid, false otherwise. + */ + public static function is_valid_source( string $source ): bool { + return isset( self::$source_allowlist[ $source ] ); + } +} diff --git a/src/wp-includes/class-wp-url-pattern-prefixer.php b/src/wp-includes/class-wp-url-pattern-prefixer.php new file mode 100644 index 0000000000000..a79e6e10c8cc0 --- /dev/null +++ b/src/wp-includes/class-wp-url-pattern-prefixer.php @@ -0,0 +1,135 @@ + $base_path` pairs. + * + * @since 6.8.0 + * @var array + */ + private $contexts; + + /** + * Constructor. + * + * @since 6.8.0 + * + * @param array $contexts Optional. Map of `$context_string => $base_path` pairs. Default is the + * contexts returned by the + * {@see WP_URL_Pattern_Prefixer::get_default_contexts()} method. + */ + public function __construct( array $contexts = array() ) { + if ( count( $contexts ) > 0 ) { + $this->contexts = array_map( + static function ( string $str ): string { + return self::escape_pattern_string( trailingslashit( $str ) ); + }, + $contexts + ); + } else { + $this->contexts = self::get_default_contexts(); + } + } + + /** + * Prefixes the given URL path pattern with the base path for the given context. + * + * This ensures that these path patterns work correctly on WordPress subdirectory sites, for example in a multisite + * network, or when WordPress itself is installed in a subdirectory of the hostname. + * + * The given URL path pattern is only prefixed if it does not already include the expected prefix. + * + * @since 6.8.0 + * + * @param string $path_pattern URL pattern starting with the path segment. + * @param string $context Optional. Context to use for prefixing the path pattern. Default 'home'. + * @return string URL pattern, prefixed as necessary. + */ + public function prefix_path_pattern( string $path_pattern, string $context = 'home' ): string { + // If context path does not exist, the context is invalid. + if ( ! isset( $this->contexts[ $context ] ) ) { + _doing_it_wrong( + __FUNCTION__, + esc_html( + sprintf( + /* translators: %s: context string */ + __( 'Invalid URL pattern context %s.' ), + $context + ) + ), + '6.8.0' + ); + return $path_pattern; + } + + /* + * In the event that the context path contains a :, ? or # (which can cause the URL pattern parser to switch to + * another state, though only the latter two should be percent encoded anyway), it additionally needs to be + * enclosed in grouping braces. The final forward slash (trailingslashit ensures there is one) affects the + * meaning of the * wildcard, so is left outside the braces. + */ + $context_path = $this->contexts[ $context ]; + $escaped_context_path = $context_path; + if ( strcspn( $context_path, ':?#' ) !== strlen( $context_path ) ) { + $escaped_context_path = '{' . substr( $context_path, 0, -1 ) . '}/'; + } + + /* + * If the path already starts with the context path (including '/'), remove it first + * since it is about to be added back. + */ + if ( str_starts_with( $path_pattern, $context_path ) ) { + $path_pattern = substr( $path_pattern, strlen( $context_path ) ); + } + + return $escaped_context_path . ltrim( $path_pattern, '/' ); + } + + /** + * Returns the default contexts used by the class. + * + * @since 6.8.0 + * + * @return array Map of `$context_string => $base_path` pairs. + */ + public static function get_default_contexts(): array { + return array( + 'home' => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ) ) ), + 'site' => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( site_url( '/' ), PHP_URL_PATH ) ) ), + 'uploads' => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( wp_upload_dir( null, false )['baseurl'], PHP_URL_PATH ) ) ), + 'content' => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( content_url(), PHP_URL_PATH ) ) ), + 'plugins' => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( plugins_url(), PHP_URL_PATH ) ) ), + 'template' => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( get_stylesheet_directory_uri(), PHP_URL_PATH ) ) ), + 'stylesheet' => self::escape_pattern_string( trailingslashit( (string) wp_parse_url( get_template_directory_uri(), PHP_URL_PATH ) ) ), + ); + } + + /** + * Escapes a string for use in a URL pattern component. + * + * @since 6.8.0 + * @see https://urlpattern.spec.whatwg.org/#escape-a-pattern-string + * + * @param string $str String to be escaped. + * @return string String with backslashes added where required. + */ + private static function escape_pattern_string( string $str ): string { + return addcslashes( $str, '+*?:{}()\\' ); + } +} diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 54883b840d7f4..3a11e94a854de 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -358,6 +358,7 @@ add_action( 'wp_head', 'wp_shortlink_wp_head', 10, 0 ); add_action( 'wp_head', 'wp_custom_css_cb', 101 ); add_action( 'wp_head', 'wp_site_icon', 99 ); +add_action( 'wp_footer', 'wp_print_speculation_rules' ); add_action( 'wp_footer', 'wp_print_footer_scripts', 20 ); add_action( 'template_redirect', 'wp_shortlink_header', 11, 0 ); add_action( 'wp_print_footer_scripts', '_wp_footer_scripts' ); diff --git a/src/wp-includes/speculative-loading.php b/src/wp-includes/speculative-loading.php new file mode 100644 index 0000000000000..ee9d4b6381bb9 --- /dev/null +++ b/src/wp-includes/speculative-loading.php @@ -0,0 +1,254 @@ +|null Associative array with 'mode' and 'eagerness' keys, or null if speculative + * loading is disabled. + */ +function wp_get_speculation_rules_configuration(): ?array { + // By default, speculative loading is only enabled for sites with pretty permalinks when no user is logged in. + if ( ! is_user_logged_in() && get_option( 'permalink_structure' ) ) { + $config = array( + 'mode' => 'auto', + 'eagerness' => 'auto', + ); + } else { + $config = null; + } + + /** + * Filters the way that speculation rules are configured. + * + * The Speculation Rules API is a web API that allows to automatically prefetch or prerender certain URLs on the + * page, which can lead to near-instant page load times. This is also referred to as speculative loading. + * + * There are two aspects to the configuration: + * * The "mode" (whether to "prefetch" or "prerender" URLs). + * * The "eagerness" (whether to speculatively load URLs in an "eager", "moderate", or "conservative" way). + * + * By default, the speculation rules configuration is decided by WordPress Core ("auto"). This filter can be used + * to force a certain configuration, which could for instance load URLs more or less eagerly. + * + * For logged-in users or for sites that are not configured to use pretty permalinks, the default value is `null`, + * indicating that speculative loading is entirely disabled. + * + * @since 6.8.0 + * @see https://developer.chrome.com/docs/web-platform/prerender-pages + * + * @param array|null $config Associative array with 'mode' and 'eagerness' keys, or `null`. The + * default value for both of the keys is 'auto'. Other possible values + * for 'mode' are 'prefetch' and 'prerender'. Other possible values for + * 'eagerness' are 'eager', 'moderate', and 'conservative'. The value + * `null` is used to disable speculative loading entirely. + */ + $config = apply_filters( 'wp_speculation_rules_configuration', $config ); + + // Allow the value `null` to indicate that speculative loading is disabled. + if ( null === $config ) { + return null; + } + + // Sanitize the configuration and replace 'auto' with current defaults. + $default_mode = 'prefetch'; + $default_eagerness = 'conservative'; + if ( ! is_array( $config ) ) { + return array( + 'mode' => $default_mode, + 'eagerness' => $default_eagerness, + ); + } + if ( + ! isset( $config['mode'] ) || + 'auto' === $config['mode'] || + ! WP_Speculation_Rules::is_valid_mode( $config['mode'] ) + ) { + $config['mode'] = $default_mode; + } + if ( + ! isset( $config['eagerness'] ) || + 'auto' === $config['eagerness'] || + ! WP_Speculation_Rules::is_valid_eagerness( $config['eagerness'] ) || + // 'immediate' is a valid eagerness, but for safety WordPress does not allow it for document-level rules. + 'immediate' === $config['eagerness'] + ) { + $config['eagerness'] = $default_eagerness; + } + + return array( + 'mode' => $config['mode'], + 'eagerness' => $config['eagerness'], + ); +} + +/** + * Returns the full speculation rules data based on the configuration. + * + * Plugins with features that rely on frontend URLs to exclude from prefetching or prerendering should use the + * {@see 'wp_speculation_rules_href_exclude_paths'} filter to ensure those URL patterns are excluded. + * + * Additional speculation rules other than the default rule from WordPress Core can be provided by using the + * {@see 'wp_load_speculation_rules'} action and amending the passed WP_Speculation_Rules object. + * + * @since 6.8.0 + * @access private + * + * @return WP_Speculation_Rules|null Object representing the speculation rules to use, or null if speculative loading + * is disabled in the current context. + */ +function wp_get_speculation_rules(): ?WP_Speculation_Rules { + $configuration = wp_get_speculation_rules_configuration(); + if ( null === $configuration ) { + return null; + } + + $mode = $configuration['mode']; + $eagerness = $configuration['eagerness']; + + $prefixer = new WP_URL_Pattern_Prefixer(); + + $base_href_exclude_paths = array( + $prefixer->prefix_path_pattern( '/wp-*.php', 'site' ), + $prefixer->prefix_path_pattern( '/wp-admin/*', 'site' ), + $prefixer->prefix_path_pattern( '/*', 'uploads' ), + $prefixer->prefix_path_pattern( '/*', 'content' ), + $prefixer->prefix_path_pattern( '/*', 'plugins' ), + $prefixer->prefix_path_pattern( '/*', 'template' ), + $prefixer->prefix_path_pattern( '/*', 'stylesheet' ), + ); + + /* + * If pretty permalinks are enabled, exclude any URLs with query parameters. + * Otherwise, exclude specifically the URLs with a `_wpnonce` query parameter or any other query parameter + * containing the word `nonce`. + */ + if ( get_option( 'permalink_structure' ) ) { + $base_href_exclude_paths[] = $prefixer->prefix_path_pattern( '/*\\?(.+)', 'home' ); + } else { + $base_href_exclude_paths[] = $prefixer->prefix_path_pattern( '/*\\?*(^|&)*nonce*=*', 'home' ); + } + + /** + * Filters the paths for which speculative loading should be disabled. + * + * All paths should start in a forward slash, relative to the root document. The `*` can be used as a wildcard. + * If the WordPress site is in a subdirectory, the exclude paths will automatically be prefixed as necessary. + * + * Note that WordPress always excludes certain path patterns such as `/wp-login.php` and `/wp-admin/*`, and those + * cannot be modified using the filter. + * + * @since 6.8.0 + * + * @param string[] $href_exclude_paths Additional path patterns to disable speculative loading for. + * @param string $mode Mode used to apply speculative loading. Either 'prefetch' or 'prerender'. + */ + $href_exclude_paths = (array) apply_filters( 'wp_speculation_rules_href_exclude_paths', array(), $mode ); + + // Ensure that: + // 1. There are no duplicates. + // 2. The base paths cannot be removed. + // 3. The array has sequential keys (i.e. array_is_list()). + $href_exclude_paths = array_values( + array_unique( + array_merge( + $base_href_exclude_paths, + array_map( + static function ( string $href_exclude_path ) use ( $prefixer ): string { + return $prefixer->prefix_path_pattern( $href_exclude_path ); + }, + $href_exclude_paths + ) + ) + ) + ); + + $speculation_rules = new WP_Speculation_Rules(); + + $main_rule_conditions = array( + // Include any URLs within the same site. + array( + 'href_matches' => $prefixer->prefix_path_pattern( '/*' ), + ), + // Except for excluded paths. + array( + 'not' => array( + 'href_matches' => $href_exclude_paths, + ), + ), + // Also exclude rel=nofollow links, as certain plugins use that on their links that perform an action. + array( + 'not' => array( + 'selector_matches' => 'a[rel~="nofollow"]', + ), + ), + // Also exclude links that are explicitly marked to opt out. + array( + 'not' => array( + 'selector_matches' => ".no-{$mode}", + ), + ), + ); + + // If using 'prerender', also exclude links that opt-out of 'prefetch' because it's part of 'prerender'. + if ( 'prerender' === $mode ) { + $main_rule_conditions[] = array( + 'not' => array( + 'selector_matches' => '.no-prefetch', + ), + ); + } + + $speculation_rules->add_rule( + $mode, + 'main', + array( + 'source' => 'document', + 'where' => array( + 'and' => $main_rule_conditions, + ), + 'eagerness' => $eagerness, + ) + ); + + /** + * Fires when speculation rules data is loaded, allowing to amend the rules. + * + * @since 6.8.0 + * + * @param WP_Speculation_Rules $speculation_rules Object representing the speculation rules to use. + */ + do_action( 'wp_load_speculation_rules', $speculation_rules ); + + return $speculation_rules; +} + +/** + * Prints the speculation rules. + * + * For browsers that do not support speculation rules yet, the `script[type="speculationrules"]` tag will be ignored. + * + * @since 6.8.0 + * @access private + */ +function wp_print_speculation_rules(): void { + $speculation_rules = wp_get_speculation_rules(); + if ( null === $speculation_rules ) { + return; + } + + wp_print_inline_script_tag( + (string) wp_json_encode( + $speculation_rules + ), + array( 'type' => 'speculationrules' ) + ); +} diff --git a/src/wp-settings.php b/src/wp-settings.php index 05a13f21a7166..8f835bc31b985 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -405,6 +405,9 @@ require ABSPATH . WPINC . '/interactivity-api/class-wp-interactivity-api-directives-processor.php'; require ABSPATH . WPINC . '/interactivity-api/interactivity-api.php'; require ABSPATH . WPINC . '/class-wp-plugin-dependencies.php'; +require ABSPATH . WPINC . '/class-wp-url-pattern-prefixer.php'; +require ABSPATH . WPINC . '/class-wp-speculation-rules.php'; +require ABSPATH . WPINC . '/speculative-loading.php'; add_action( 'after_setup_theme', array( wp_script_modules(), 'add_hooks' ) ); add_action( 'after_setup_theme', array( wp_interactivity(), 'add_hooks' ) ); diff --git a/tests/phpunit/tests/speculative-loading/wpGetSpeculationRules.php b/tests/phpunit/tests/speculative-loading/wpGetSpeculationRules.php new file mode 100644 index 0000000000000..d285974070527 --- /dev/null +++ b/tests/phpunit/tests/speculative-loading/wpGetSpeculationRules.php @@ -0,0 +1,561 @@ + 'prefetch', + 'eagerness' => 'conservative', + ); + private $prerender_config = array( + 'mode' => 'prerender', + 'eagerness' => 'conservative', + ); + + public function set_up() { + parent::set_up(); + + add_filter( + 'template_directory_uri', + static function () { + return content_url( 'themes/template' ); + } + ); + + add_filter( + 'stylesheet_directory_uri', + static function () { + return content_url( 'themes/stylesheet' ); + } + ); + + update_option( 'permalink_structure', '/%year%/%monthnum%/%day%/%postname%/' ); + } + + /** + * Tests speculation rules output with prefetch for the different eagerness levels. + * + * @ticket 62503 + * @dataProvider data_eagerness + */ + public function test_wp_get_speculation_rules_with_prefetch( string $eagerness ) { + remove_all_filters( 'wp_speculation_rules_configuration' ); + add_filter( + 'wp_speculation_rules_configuration', + static function () use ( $eagerness ) { + return array( + 'mode' => 'prefetch', + 'eagerness' => $eagerness, + ); + } + ); + + $rules = wp_get_speculation_rules(); + + $this->assertInstanceOf( WP_Speculation_Rules::class, $rules ); + $rules = $rules->jsonSerialize(); + + $this->assertArrayHasKey( 'prefetch', $rules ); + $this->assertIsArray( $rules['prefetch'] ); + foreach ( $rules['prefetch'] as $entry ) { + $this->assertIsArray( $entry ); + $this->assertArrayHasKey( 'source', $entry ); + $this->assertSame( 'document', $entry['source'] ); + $this->assertArrayHasKey( 'eagerness', $entry ); + $this->assertSame( $eagerness, $entry['eagerness'] ); + } + } + + /** + * Tests speculation rules output with prerender for the different eagerness levels. + * + * @ticket 62503 + * @dataProvider data_eagerness + */ + public function test_wp_get_speculation_rules_with_prerender( string $eagerness ) { + remove_all_filters( 'wp_speculation_rules_configuration' ); + add_filter( + 'wp_speculation_rules_configuration', + static function () use ( $eagerness ) { + return array( + 'mode' => 'prerender', + 'eagerness' => $eagerness, + ); + } + ); + + $rules = wp_get_speculation_rules(); + + $this->assertInstanceOf( WP_Speculation_Rules::class, $rules ); + $rules = $rules->jsonSerialize(); + + $this->assertArrayHasKey( 'prerender', $rules ); + $this->assertIsArray( $rules['prerender'] ); + foreach ( $rules['prerender'] as $entry ) { + $this->assertIsArray( $entry ); + $this->assertArrayHasKey( 'source', $entry ); + $this->assertSame( 'document', $entry['source'] ); + $this->assertArrayHasKey( 'eagerness', $entry ); + $this->assertSame( $eagerness, $entry['eagerness'] ); + } + } + + public static function data_eagerness(): array { + return array( + array( 'conservative' ), + array( 'moderate' ), + array( 'eager' ), + ); + } + + /** + * Tests that the number of entries included for prefetch configuration is correct. + * + * @ticket 62503 + */ + public function test_wp_get_speculation_rules_prefetch_entries() { + add_filter( + 'wp_speculation_rules_configuration', + function () { + return $this->prefetch_config; + } + ); + + $rules = wp_get_speculation_rules(); + + $this->assertInstanceOf( WP_Speculation_Rules::class, $rules ); + $rules = $rules->jsonSerialize(); + + $this->assertArrayHasKey( 'prefetch', $rules ); + $this->assertCount( 4, $rules['prefetch'][0]['where']['and'] ); + $this->assertArrayHasKey( 'not', $rules['prefetch'][0]['where']['and'][3] ); + $this->assertArrayHasKey( 'selector_matches', $rules['prefetch'][0]['where']['and'][3]['not'] ); + $this->assertSame( '.no-prefetch', $rules['prefetch'][0]['where']['and'][3]['not']['selector_matches'] ); + } + + /** + * Tests that the number of entries included for prerender configuration is correct. + * + * @ticket 62503 + */ + public function test_wp_get_speculation_rules_prerender_entries() { + add_filter( + 'wp_speculation_rules_configuration', + function () { + return $this->prerender_config; + } + ); + + $rules = wp_get_speculation_rules(); + + $this->assertInstanceOf( WP_Speculation_Rules::class, $rules ); + $rules = $rules->jsonSerialize(); + + $this->assertArrayHasKey( 'prerender', $rules ); + $this->assertCount( 5, $rules['prerender'][0]['where']['and'] ); + $this->assertArrayHasKey( 'not', $rules['prerender'][0]['where']['and'][3] ); + $this->assertArrayHasKey( 'selector_matches', $rules['prerender'][0]['where']['and'][3]['not'] ); + $this->assertSame( '.no-prerender', $rules['prerender'][0]['where']['and'][3]['not']['selector_matches'] ); + $this->assertArrayHasKey( 'not', $rules['prerender'][0]['where']['and'][4] ); + $this->assertArrayHasKey( 'selector_matches', $rules['prerender'][0]['where']['and'][4]['not'] ); + $this->assertSame( '.no-prefetch', $rules['prerender'][0]['where']['and'][4]['not']['selector_matches'] ); + } + + /** + * Tests the default exclude paths and ensures they cannot be altered via filter. + * + * @ticket 62503 + */ + public function test_wp_get_speculation_rules_href_exclude_paths() { + add_filter( + 'wp_speculation_rules_configuration', + function () { + return $this->prefetch_config; + } + ); + + $rules = wp_get_speculation_rules(); + $this->assertInstanceOf( WP_Speculation_Rules::class, $rules ); + $rules = $rules->jsonSerialize(); + + $href_exclude_paths = $rules['prefetch'][0]['where']['and'][1]['not']['href_matches']; + + $this->assertSameSets( + array( + '/wp-*.php', + '/wp-admin/*', + '/wp-content/uploads/*', + '/wp-content/*', + '/wp-content/plugins/*', + '/wp-content/themes/stylesheet/*', + '/wp-content/themes/template/*', + '/*\\?(.+)', + ), + $href_exclude_paths, + 'Snapshot: ' . var_export( $href_exclude_paths, true ) + ); + + // Add filter that attempts to replace base exclude paths with a custom path to exclude. + add_filter( + 'wp_speculation_rules_href_exclude_paths', + static function () { + return array( 'custom-file.php' ); + } + ); + + $rules = wp_get_speculation_rules(); + $this->assertInstanceOf( WP_Speculation_Rules::class, $rules ); + $rules = $rules->jsonSerialize(); + + $href_exclude_paths = $rules['prefetch'][0]['where']['and'][1]['not']['href_matches']; + + // Ensure the base exclude paths are still present and that the custom path was formatted correctly. + $this->assertSameSets( + array( + '/wp-*.php', + '/wp-admin/*', + '/wp-content/uploads/*', + '/wp-content/*', + '/wp-content/plugins/*', + '/wp-content/themes/stylesheet/*', + '/wp-content/themes/template/*', + '/*\\?(.+)', + '/custom-file.php', + ), + $href_exclude_paths, + 'Snapshot: ' . var_export( $href_exclude_paths, true ) + ); + } + + /** + * Tests the default exclude paths and ensures they cannot be altered via filter. + * + * @ticket 62503 + */ + public function test_wp_get_speculation_rules_href_exclude_paths_without_pretty_permalinks() { + update_option( 'permalink_structure', '' ); + + add_filter( + 'wp_speculation_rules_configuration', + function () { + return $this->prefetch_config; + } + ); + + $rules = wp_get_speculation_rules(); + $this->assertInstanceOf( WP_Speculation_Rules::class, $rules ); + $rules = $rules->jsonSerialize(); + + $href_exclude_paths = $rules['prefetch'][0]['where']['and'][1]['not']['href_matches']; + + $this->assertSameSets( + array( + '/wp-*.php', + '/wp-admin/*', + '/wp-content/uploads/*', + '/wp-content/*', + '/wp-content/plugins/*', + '/wp-content/themes/stylesheet/*', + '/wp-content/themes/template/*', + '/*\\?*(^|&)*nonce*=*', + ), + $href_exclude_paths, + 'Snapshot: ' . var_export( $href_exclude_paths, true ) + ); + } + + /** + * Tests that exclude paths can be altered specifically based on the mode used. + * + * @ticket 62503 + */ + public function test_wp_get_speculation_rules_href_exclude_paths_with_mode() { + // Add filter that adds an exclusion only if the mode is 'prerender'. + add_filter( + 'wp_speculation_rules_href_exclude_paths', + static function ( $exclude_paths, $mode ) { + if ( 'prerender' === $mode ) { + $exclude_paths[] = '/products/*'; + } + return $exclude_paths; + }, + 10, + 2 + ); + + add_filter( + 'wp_speculation_rules_configuration', + function () { + return $this->prerender_config; + } + ); + $rules = wp_get_speculation_rules(); + $this->assertInstanceOf( WP_Speculation_Rules::class, $rules ); + $rules = $rules->jsonSerialize(); + + $href_exclude_paths = $rules['prerender'][0]['where']['and'][1]['not']['href_matches']; + + // Ensure the additional exclusion is present because the mode is 'prerender'. + // Also ensure keys are sequential starting from 0 (that is, that array_is_list()). + $this->assertSame( + array( + '/wp-*.php', + '/wp-admin/*', + '/wp-content/uploads/*', + '/wp-content/*', + '/wp-content/plugins/*', + '/wp-content/themes/stylesheet/*', + '/wp-content/themes/template/*', + '/*\\?(.+)', + '/products/*', + ), + $href_exclude_paths, + 'Snapshot: ' . var_export( $href_exclude_paths, true ) + ); + + // Redo with 'prefetch'. + add_filter( + 'wp_speculation_rules_configuration', + function () { + return $this->prefetch_config; + } + ); + $rules = wp_get_speculation_rules(); + $this->assertInstanceOf( WP_Speculation_Rules::class, $rules ); + $rules = $rules->jsonSerialize(); + + $href_exclude_paths = $rules['prefetch'][0]['where']['and'][1]['not']['href_matches']; + + // Ensure the additional exclusion is not present because the mode is 'prefetch'. + $this->assertSame( + array( + '/wp-*.php', + '/wp-admin/*', + '/wp-content/uploads/*', + '/wp-content/*', + '/wp-content/plugins/*', + '/wp-content/themes/stylesheet/*', + '/wp-content/themes/template/*', + '/*\\?(.+)', + ), + $href_exclude_paths, + 'Snapshot: ' . var_export( $href_exclude_paths, true ) + ); + } + + /** + * Tests filter that explicitly adds non-sequential keys. + * + * @ticket 62503 + */ + public function test_wp_get_speculation_rules_with_filtering_bad_keys() { + + add_filter( + 'wp_speculation_rules_href_exclude_paths', + static function ( array $exclude_paths ): array { + $exclude_paths[] = '/next/'; + array_unshift( $exclude_paths, '/unshifted/' ); + $exclude_paths[-1] = '/negative-one/'; + $exclude_paths[100] = '/one-hundred/'; + $exclude_paths['a'] = '/letter-a/'; + return $exclude_paths; + } + ); + + add_filter( + 'wp_speculation_rules_configuration', + function () { + return $this->prerender_config; + } + ); + $rules = wp_get_speculation_rules(); + $this->assertInstanceOf( WP_Speculation_Rules::class, $rules ); + $rules = $rules->jsonSerialize(); + + $href_exclude_paths = $rules['prerender'][0]['where']['and'][1]['not']['href_matches']; + $this->assertSame( + array( + '/wp-*.php', + '/wp-admin/*', + '/wp-content/uploads/*', + '/wp-content/*', + '/wp-content/plugins/*', + '/wp-content/themes/stylesheet/*', + '/wp-content/themes/template/*', + '/*\\?(.+)', + '/unshifted/', + '/next/', + '/negative-one/', + '/one-hundred/', + '/letter-a/', + ), + $href_exclude_paths, + 'Snapshot: ' . var_export( $href_exclude_paths, true ) + ); + } + + /** + * Tests scenario when the home_url and site_url have different paths. + * + * @ticket 62503 + */ + public function test_wp_get_speculation_rules_different_home_and_site_urls() { + add_filter( + 'site_url', + static function (): string { + return 'https://example.com/wp/'; + } + ); + add_filter( + 'home_url', + static function (): string { + return 'https://example.com/blog/'; + } + ); + add_filter( + 'wp_speculation_rules_href_exclude_paths', + static function ( array $exclude_paths ): array { + $exclude_paths[] = '/store/*'; + return $exclude_paths; + } + ); + + add_filter( + 'wp_speculation_rules_configuration', + function () { + return $this->prerender_config; + } + ); + $rules = wp_get_speculation_rules(); + $this->assertInstanceOf( WP_Speculation_Rules::class, $rules ); + $rules = $rules->jsonSerialize(); + + $href_exclude_paths = $rules['prerender'][0]['where']['and'][1]['not']['href_matches']; + $this->assertSame( + array( + '/wp/wp-*.php', + '/wp/wp-admin/*', + '/wp-content/uploads/*', + '/wp-content/*', + '/wp-content/plugins/*', + '/wp-content/themes/stylesheet/*', + '/wp-content/themes/template/*', + '/blog/*\\?(.+)', + '/blog/store/*', + ), + $href_exclude_paths, + 'Snapshot: ' . var_export( $href_exclude_paths, true ) + ); + } + + /** + * Tests that passing an invalid configuration to the function does not lead to unexpected problems. + * + * This is mostly an integration test as it is resolved as part of wp_get_speculation_rules_configuration(). + * + * @ticket 62503 + */ + public function test_wp_get_speculation_rules_with_invalid_configuration() { + add_filter( + 'wp_speculation_rules_configuration', + static function () { + return array( + 'mode' => 'none', + 'eagerness' => 'none', + ); + } + ); + $rules = wp_get_speculation_rules(); + + $this->assertInstanceOf( WP_Speculation_Rules::class, $rules ); + $rules = $rules->jsonSerialize(); + + $this->assertArrayHasKey( 'prefetch', $rules ); + $this->assertSame( 'conservative', $rules['prefetch'][0]['eagerness'] ); + } + + /** + * Tests that passing no configuration (`null`) results in no speculation rules being returned. + * + * This is used to effectively disable the feature. + * + * @ticket 62503 + */ + public function test_wp_get_speculation_rules_with_null() { + add_filter( 'wp_speculation_rules_configuration', '__return_null' ); + + $rules = wp_get_speculation_rules(); + $this->assertNull( $rules ); + } + + /** + * Tests that the 'wp_load_speculation_rules' action allows providing additional rules. + * + * @ticket 62503 + */ + public function test_wp_get_speculation_rules_with_additional_rules() { + $filtered_obj = null; + add_action( + 'wp_load_speculation_rules', + static function ( $speculation_rules ) use ( &$filtered_obj ) { + $filtered_obj = $speculation_rules; + + /* + * In practice, these rules would ensure that links marked with the classes would be opt in to + * prerendering with moderate and eager eagerness respectively. + */ + $speculation_rules->add_rule( + 'prerender', + 'prerender-moderate-marked-links', + array( + 'source' => 'document', + 'where' => array( + 'selector_matches' => '.moderate-prerender, .moderate-prerender a', + ), + 'eagerness' => 'moderate', + ) + ); + $speculation_rules->add_rule( + 'prerender', + 'prerender-eager-marked-links', + array( + 'source' => 'document', + 'where' => array( + 'selector_matches' => '.eager-prerender, .eager-prerender a', + ), + 'eagerness' => 'eager', + ) + ); + } + ); + + add_filter( + 'wp_speculation_rules_configuration', + function () { + return $this->prefetch_config; + } + ); + $rules = wp_get_speculation_rules(); + $this->assertInstanceOf( WP_Speculation_Rules::class, $rules ); + $this->assertSame( $filtered_obj, $rules ); + + $rules = $rules->jsonSerialize(); + + $this->assertArrayHasKey( 'prefetch', $rules ); + $this->assertCount( 1, $rules['prefetch'] ); + $this->assertArrayHasKey( 'prerender', $rules ); + $this->assertCount( 2, $rules['prerender'] ); + $this->assertSame( 'conservative', $rules['prefetch'][0]['eagerness'] ); + $this->assertSame( 'moderate', $rules['prerender'][0]['eagerness'] ); + $this->assertSame( 'eager', $rules['prerender'][1]['eagerness'] ); + } +} diff --git a/tests/phpunit/tests/speculative-loading/wpGetSpeculationRulesConfiguration.php b/tests/phpunit/tests/speculative-loading/wpGetSpeculationRulesConfiguration.php new file mode 100644 index 0000000000000..a4eb9c8a6878b --- /dev/null +++ b/tests/phpunit/tests/speculative-loading/wpGetSpeculationRulesConfiguration.php @@ -0,0 +1,267 @@ +assertSame( + array( + 'mode' => 'auto', + 'eagerness' => 'auto', + ), + $filter_default + ); + $this->assertSame( + array( + 'mode' => 'prefetch', + 'eagerness' => 'conservative', + ), + $config_default + ); + } + + /** + * Tests that the speculative loading is disabled by default when not using pretty permalinks. + * + * @ticket 62503 + */ + public function test_wp_get_speculation_rules_configuration_without_pretty_permalinks() { + update_option( 'permalink_structure', '' ); + $this->assertNull( wp_get_speculation_rules_configuration() ); + } + + /** + * Tests that the speculative loading is disabled by default for logged-in users. + * + * @ticket 62503 + */ + public function test_wp_get_speculation_rules_configuration_with_logged_in_user() { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + $this->assertNull( wp_get_speculation_rules_configuration() ); + } + + /** + * Tests that the configuration can be filtered and leads to the expected results. + * + * @ticket 62503 + * @dataProvider data_wp_get_speculation_rules_configuration_filter + */ + public function test_wp_get_speculation_rules_configuration_filter( $filter_value, $expected ) { + add_filter( + 'wp_speculation_rules_configuration', + function () use ( $filter_value ) { + return $filter_value; + } + ); + + $this->assertSame( $expected, wp_get_speculation_rules_configuration() ); + } + + public static function data_wp_get_speculation_rules_configuration_filter(): array { + return array( + 'conservative prefetch' => array( + array( + 'mode' => 'prefetch', + 'eagerness' => 'conservative', + ), + array( + 'mode' => 'prefetch', + 'eagerness' => 'conservative', + ), + ), + 'moderate prefetch' => array( + array( + 'mode' => 'prefetch', + 'eagerness' => 'moderate', + ), + array( + 'mode' => 'prefetch', + 'eagerness' => 'moderate', + ), + ), + 'eager prefetch' => array( + array( + 'mode' => 'prefetch', + 'eagerness' => 'eager', + ), + array( + 'mode' => 'prefetch', + 'eagerness' => 'eager', + ), + ), + 'conservative prerender' => array( + array( + 'mode' => 'prerender', + 'eagerness' => 'conservative', + ), + array( + 'mode' => 'prerender', + 'eagerness' => 'conservative', + ), + ), + 'moderate prerender' => array( + array( + 'mode' => 'prerender', + 'eagerness' => 'moderate', + ), + array( + 'mode' => 'prerender', + 'eagerness' => 'moderate', + ), + ), + 'eager prerender' => array( + array( + 'mode' => 'prerender', + 'eagerness' => 'eager', + ), + array( + 'mode' => 'prerender', + 'eagerness' => 'eager', + ), + ), + 'auto' => array( + array( + 'mode' => 'auto', + 'eagerness' => 'auto', + ), + array( + 'mode' => 'prefetch', + 'eagerness' => 'conservative', + ), + ), + 'auto mode only' => array( + array( + 'mode' => 'auto', + 'eagerness' => 'eager', + ), + array( + 'mode' => 'prefetch', + 'eagerness' => 'eager', + ), + ), + 'auto eagerness only' => array( + array( + 'mode' => 'prerender', + 'eagerness' => 'auto', + ), + array( + 'mode' => 'prerender', + 'eagerness' => 'conservative', + ), + ), + // 'immediate' is a valid eagerness, but for safety WordPress does not allow it for document-level rules. + 'immediate eagerness' => array( + array( + 'mode' => 'auto', + 'eagerness' => 'immediate', + ), + array( + 'mode' => 'prefetch', + 'eagerness' => 'conservative', + ), + ), + 'null' => array( + null, + null, + ), + 'false' => array( + false, + array( + 'mode' => 'prefetch', + 'eagerness' => 'conservative', + ), + ), + 'true' => array( + true, + array( + 'mode' => 'prefetch', + 'eagerness' => 'conservative', + ), + ), + 'missing mode' => array( + array( + 'eagerness' => 'eager', + ), + array( + 'mode' => 'prefetch', + 'eagerness' => 'eager', + ), + ), + 'missing eagerness' => array( + array( + 'mode' => 'prerender', + ), + array( + 'mode' => 'prerender', + 'eagerness' => 'conservative', + ), + ), + 'empty array' => array( + array(), + array( + 'mode' => 'prefetch', + 'eagerness' => 'conservative', + ), + ), + 'invalid mode' => array( + array( + 'mode' => 'invalid', + 'eagerness' => 'eager', + ), + array( + 'mode' => 'prefetch', + 'eagerness' => 'eager', + ), + ), + 'invalid eagerness' => array( + array( + 'mode' => 'prerender', + 'eagerness' => 'invalid', + ), + array( + 'mode' => 'prerender', + 'eagerness' => 'conservative', + ), + ), + 'invalid type' => array( + 42, + array( + 'mode' => 'prefetch', + 'eagerness' => 'conservative', + ), + ), + ); + } +} diff --git a/tests/phpunit/tests/speculative-loading/wpPrintSpeculationRules.php b/tests/phpunit/tests/speculative-loading/wpPrintSpeculationRules.php new file mode 100644 index 0000000000000..e2887f8b0b6ce --- /dev/null +++ b/tests/phpunit/tests/speculative-loading/wpPrintSpeculationRules.php @@ -0,0 +1,89 @@ +original_wp_theme_features = $GLOBALS['_wp_theme_features']; + } + + public function tear_down() { + $GLOBALS['_wp_theme_features'] = $this->original_wp_theme_features; + parent::tear_down(); + } + + /** + * Tests that the hook for printing speculation rules is set up. + * + * @ticket 62503 + */ + public function test_hook() { + $this->assertSame( 10, has_action( 'wp_footer', 'wp_print_speculation_rules' ) ); + } + + /** + * Tests speculation rules script output with HTML5 support. + * + * @ticket 62503 + */ + public function test_wp_print_speculation_rules_with_html5_support() { + add_theme_support( 'html5', array( 'script' ) ); + + add_filter( + 'wp_speculation_rules_configuration', + static function () { + return array( + 'mode' => 'prerender', + 'eagerness' => 'moderate', + ); + } + ); + + $output = get_echo( 'wp_print_speculation_rules' ); + $this->assertStringContainsString( '' ), '', $output ); + $rules = json_decode( $json, true ); + $this->assertIsArray( $rules ); + $this->assertArrayHasKey( 'prerender', $rules ); + } + + /** + * Tests speculation rules script output without HTML5 support. + * + * @ticket 62503 + */ + public function test_wp_print_speculation_rules_without_html5_support() { + remove_theme_support( 'html5' ); + + add_filter( + 'wp_speculation_rules_configuration', + static function () { + return array( + 'mode' => 'prerender', + 'eagerness' => 'moderate', + ); + } + ); + + $output = get_echo( 'wp_print_speculation_rules' ); + $this->assertStringContainsString( '' ), '', $output ); + $rules = json_decode( $json, true ); + $this->assertIsArray( $rules ); + $this->assertArrayHasKey( 'prerender', $rules ); + } +} diff --git a/tests/phpunit/tests/speculative-loading/wpSpeculationRules.php b/tests/phpunit/tests/speculative-loading/wpSpeculationRules.php new file mode 100644 index 0000000000000..9a59583f3f20c --- /dev/null +++ b/tests/phpunit/tests/speculative-loading/wpSpeculationRules.php @@ -0,0 +1,369 @@ +setExpectedIncorrectUsage( 'WP_Speculation_Rules::add_rule' ); + } + + $result = $speculation_rules->add_rule( $mode, $id, $rule ); + if ( $expected ) { + $this->assertTrue( $result ); + } else { + $this->assertFalse( $result ); + } + } + + /** + * Tests that adding a speculation rule with a duplicate ID results in the expected behavior. + * + * @ticket 62503 + * @covers ::add_rule + */ + public function test_add_rule_with_duplicate() { + $speculation_rules = new WP_Speculation_Rules(); + + $this->assertTrue( $speculation_rules->add_rule( 'prerender', 'my-custom-rule', array( 'where' => array( 'href_matches' => '/*' ) ) ) ); + + // It should be possible to add a rule of the same ID for another mode. + $this->assertTrue( $speculation_rules->add_rule( 'prefetch', 'my-custom-rule', array( 'where' => array( 'href_matches' => '/*' ) ) ) ); + + // But it should not be possible to add a rule of the same ID to a mode where it's already present. + $this->setExpectedIncorrectUsage( 'WP_Speculation_Rules::add_rule' ); + $this->assertFalse( $speculation_rules->add_rule( 'prerender', 'my-custom-rule', array( 'urls' => array( 'https://important-url.com/' ) ) ) ); + } + + public static function data_add_rule(): array { + return array( + 'basic-prefetch' => array( + 'prefetch', + 'test-rule-1', + array( + 'source' => 'document', + 'where' => array( 'selector_matches' => '.prefetch' ), + 'eagerness' => 'eager', + ), + true, + ), + 'basic-prefetch-no-source' => array( + 'prefetch', + 'test-rule-2', + array( + 'where' => array( 'selector_matches' => '.prefetch' ), + 'eagerness' => 'eager', + ), + true, + ), + 'basic-prefetch-no-eagerness' => array( + 'prefetch', + 'test-rule-3', + array( + 'source' => 'document', + 'where' => array( 'selector_matches' => '.prefetch' ), + ), + true, + ), + 'basic-prerender' => array( + 'prerender', + 'test-rule-1', + array( + 'source' => 'list', + 'urls' => array( 'https://example.org/high-priority-url/', 'https://example.org/another-high-priority-url/' ), + 'eagerness' => 'eager', + ), + true, + ), + 'basic-prerender-no-source' => array( + 'prerender', + 'test-rule-2', + array( + 'urls' => array( 'https://example.org/high-priority-url/', 'https://example.org/another-high-priority-url/' ), + 'eagerness' => 'eager', + ), + true, + ), + 'basic-prerender-no-eagerness' => array( + 'prerender', + 'test-rule-3', + array( + 'source' => 'list', + 'urls' => array( 'https://example.org/high-priority-url/', 'https://example.org/another-high-priority-url/' ), + ), + true, + ), + 'invalid-mode' => array( + 'load-fast', // Only 'prefetch' and 'prerender' are allowed. + 'test-rule-1', + array( + 'source' => 'document', + 'where' => array( 'selector_matches' => '.prefetch' ), + 'eagerness' => 'eager', + ), + false, + ), + 'invalid-id-characters' => array( + 'prefetch', + 'test rule 1', // Spaces are not allowed. + array( + 'source' => 'document', + 'where' => array( 'selector_matches' => '.prefetch' ), + 'eagerness' => 'eager', + ), + false, + ), + 'invalid-id-start' => array( + 'prefetch', + '1_test_rule', // The first character must be a lower-case letter. + array( + 'source' => 'document', + 'where' => array( 'selector_matches' => '.prefetch' ), + 'eagerness' => 'eager', + ), + false, + ), + 'invalid-source' => array( + 'prerender', + 'test-rule-1', + array( + 'source' => 'magic', // Only 'list' and 'document' are allowed. + 'where' => array( 'selector_matches' => '.prerender' ), + 'eagerness' => 'eager', + ), + false, + ), + 'missing-keys' => array( + 'prefetch', + 'test-rule-1', + array(), // The minimum requirements are presence of either a 'where' or 'urls' key. + false, + ), + 'conflicting-keys' => array( + 'prefetch', + 'test-rule-1', + array( // Only 'where' or 'urls' is allowed, but not both. + 'where' => array( 'selector_matches' => '.prefetch' ), + 'urls' => array( 'https://example.org/high-priority-url/', 'https://example.org/another-high-priority-url/' ), + ), + false, + ), + 'conflicting-list-source' => array( + 'prefetch', + 'test-rule-1', + array( + 'source' => 'list', // Source 'list' can only be used with key 'urls', but not 'where'. + 'where' => array( 'selector_matches' => '.prefetch' ), + 'eagerness' => 'eager', + ), + false, + ), + 'conflicting-document-source' => array( + 'prefetch', + 'test-rule-1', + array( + 'source' => 'document', // Source 'document' can only be used with key 'where', but not 'urls'. + 'urls' => array( 'https://example.org/high-priority-url/', 'https://example.org/another-high-priority-url/' ), + 'eagerness' => 'eager', + ), + false, + ), + 'invalid-eagerness' => array( + 'prefetch', + 'test-rule-1', + array( + 'source' => 'document', + 'where' => array( 'selector_matches' => '.prefetch' ), + 'eagerness' => 'fast', // Only 'immediate', 'eager, 'moderate', and 'conservative' are allowed. + ), + false, + ), + 'immediate-eagerness-list' => array( + 'prefetch', + 'test-rule-1', + array( + 'source' => 'list', + 'urls' => array( 'https://example.org/high-priority-url/', 'https://example.org/another-high-priority-url/' ), + 'eagerness' => 'immediate', + ), + true, + ), + // 'immediate' is a valid eagerness, but for safety WordPress does not allow it for document-level rules. + 'immediate-eagerness-document' => array( + 'prefetch', + 'test-rule-1', + array( + 'source' => 'document', + 'where' => array( 'selector_matches' => '.prefetch' ), + 'eagerness' => 'immediate', + ), + false, + ), + ); + } + + /** + * Tests that checking for existence of a rule works as expected. + * + * @ticket 62503 + * @covers ::has_rule + */ + public function test_has_rule() { + $speculation_rules = new WP_Speculation_Rules(); + + $this->assertFalse( $speculation_rules->has_rule( 'prerender', 'my-custom-rule' ), 'Custom rule should not be marked as present before it is added' ); + + $speculation_rules->add_rule( 'prerender', 'my-custom-rule', array( 'urls' => array( 'https://url-to-prerender.com/' ) ) ); + $this->assertTrue( $speculation_rules->has_rule( 'prerender', 'my-custom-rule' ), 'Custom rule should be marked as present after it has been added' ); + $this->assertFalse( $speculation_rules->has_rule( 'prefetch', 'my-custom-rule' ), 'Custom rule should not be marked as present for different mode even after it has been added' ); + } + + /** + * Tests that transforming a speculation rules object into JSON-encodable data works as expected. + * + * @ticket 62503 + * @covers ::jsonSerialize + */ + public function test_jsonSerialize() { + $prefetch_rule_1 = array( 'where' => array( 'href_matches' => '/*' ) ); + $prefetch_rule_2 = array( 'where' => array( 'selector_matches' => '.prefetch-opt-in' ) ); + $prerender_rule_1 = array( 'urls' => array( 'https://example.org/high-priority-url/', 'https://example.org/another-high-priority-url/' ) ); + $prerender_rule_2 = array( + 'where' => array( + 'or' => array( + array( 'selector_matches' => '.prerender-opt-in' ), + array( 'selector_matches' => '.prerender-fast' ), + ), + ), + 'eagerness' => 'moderate', + ); + + $speculation_rules = new WP_Speculation_Rules(); + $this->assertSame( array(), $speculation_rules->jsonSerialize(), 'Speculation rules JSON data should be empty before adding any rules' ); + + $speculation_rules->add_rule( 'prefetch', 'prefetch-rule-1', $prefetch_rule_1 ); + $this->assertSame( + array( + 'prefetch' => array( $prefetch_rule_1 ), + ), + $speculation_rules->jsonSerialize(), + 'Speculation rules JSON data should only contain a single "prefetch" entry when only that rule is added' + ); + + $speculation_rules->add_rule( 'prefetch', 'prefetch-rule-2', $prefetch_rule_2 ); + $speculation_rules->add_rule( 'prerender', 'prerender-rule-1', $prerender_rule_1 ); + $speculation_rules->add_rule( 'prerender', 'prerender-rule-2', $prerender_rule_2 ); + $this->assertSame( + array( + 'prefetch' => array( + $prefetch_rule_1, + $prefetch_rule_2, + ), + 'prerender' => array( + $prerender_rule_1, + $prerender_rule_2, + ), + ), + $speculation_rules->jsonSerialize(), + 'Speculation rules JSON data should contain all added rules' + ); + } + + /** + * Tests that the mode validation method correctly identifies valid and invalid values. + * + * @ticket 62503 + * @covers ::is_valid_mode + * @dataProvider data_is_valid_mode + */ + public function test_is_valid_mode( $mode, $expected ) { + if ( $expected ) { + $this->assertTrue( WP_Speculation_Rules::is_valid_mode( $mode ) ); + } else { + $this->assertFalse( WP_Speculation_Rules::is_valid_mode( $mode ) ); + } + } + + public static function data_is_valid_mode(): array { + return array( + 'prefetch' => array( 'prefetch', true ), + 'prerender' => array( 'prerender', true ), + 'auto' => array( 'auto', false ), + 'none' => array( 'none', false ), + '42' => array( 42, false ), + 'empty string' => array( '', false ), + ); + } + + /** + * Tests that the eagerness validation method correctly identifies valid and invalid values. + * + * @ticket 62503 + * @covers ::is_valid_eagerness + * @dataProvider data_is_valid_eagerness + */ + public function test_is_valid_eagerness( $eagerness, $expected ) { + if ( $expected ) { + $this->assertTrue( WP_Speculation_Rules::is_valid_eagerness( $eagerness ) ); + } else { + $this->assertFalse( WP_Speculation_Rules::is_valid_eagerness( $eagerness ) ); + } + } + + public static function data_is_valid_eagerness(): array { + return array( + 'conservative' => array( 'conservative', true ), + 'moderate' => array( 'moderate', true ), + 'eager' => array( 'eager', true ), + 'immediate' => array( 'immediate', true ), + 'auto' => array( 'auto', false ), + 'none' => array( 'none', false ), + '42' => array( 42, false ), + 'empty string' => array( '', false ), + ); + } + + /** + * Tests that the source validation method correctly identifies valid and invalid values. + * + * @ticket 62503 + * @covers ::is_valid_source + * @dataProvider data_is_valid_source + */ + public function test_is_valid_source( $source, $expected ) { + if ( $expected ) { + $this->assertTrue( WP_Speculation_Rules::is_valid_source( $source ) ); + } else { + $this->assertFalse( WP_Speculation_Rules::is_valid_source( $source ) ); + } + } + + public static function data_is_valid_source(): array { + return array( + 'list' => array( 'list', true ), + 'document' => array( 'document', true ), + 'auto' => array( 'auto', false ), + 'none' => array( 'none', false ), + '42' => array( 42, false ), + 'empty string' => array( '', false ), + ); + } +} diff --git a/tests/phpunit/tests/speculative-loading/wpUrlPatternPrefixer.php b/tests/phpunit/tests/speculative-loading/wpUrlPatternPrefixer.php new file mode 100644 index 0000000000000..f818ed4b2f4c9 --- /dev/null +++ b/tests/phpunit/tests/speculative-loading/wpUrlPatternPrefixer.php @@ -0,0 +1,94 @@ + $base_path ) ); + + $this->assertSame( + $expected, + $p->prefix_path_pattern( $path_pattern, 'demo' ) + ); + } + + public static function data_prefix_path_pattern(): array { + return array( + array( '/', '/my-page/', '/my-page/' ), + array( '/', 'my-page/', '/my-page/' ), + array( '/wp/', '/my-page/', '/wp/my-page/' ), + array( '/wp/', 'my-page/', '/wp/my-page/' ), + array( '/wp/', '/blog/2023/11/new-post/', '/wp/blog/2023/11/new-post/' ), + array( '/wp/', 'blog/2023/11/new-post/', '/wp/blog/2023/11/new-post/' ), + array( '/subdir', '/my-page/', '/subdir/my-page/' ), + array( '/subdir', 'my-page/', '/subdir/my-page/' ), + // Missing trailing slash still works, does not consider "cut-off" directory names. + array( '/subdir', '/subdirectory/my-page/', '/subdir/subdirectory/my-page/' ), + array( '/subdir', 'subdirectory/my-page/', '/subdir/subdirectory/my-page/' ), + // A base path containing a : must be enclosed in braces to avoid confusion. + array( '/scope:0/', '/*/foo', '{/scope\\:0}/*/foo' ), + ); + } + + /** + * Tests the values of the default URL pattern contexts. + * + * @ticket 62503 + * @covers ::get_default_contexts + */ + public function test_get_default_contexts() { + $contexts = WP_URL_Pattern_Prefixer::get_default_contexts(); + + $this->assertArrayHasKey( 'home', $contexts ); + $this->assertArrayHasKey( 'site', $contexts ); + $this->assertSame( '/', $contexts['home'] ); + $this->assertSame( '/', $contexts['site'] ); + } + + /** + * Tests the values of the default URL pattern contexts when using subdirectories. + * + * @ticket 62503 + * @covers ::get_default_contexts + * @dataProvider data_default_contexts_with_subdirectories + */ + public function test_get_default_contexts_with_subdirectories( string $context, string $unescaped, string $expected ) { + add_filter( + $context . '_url', + static function () use ( $unescaped ) { + return $unescaped; + } + ); + + $contexts = WP_URL_Pattern_Prefixer::get_default_contexts(); + + $this->assertArrayHasKey( $context, $contexts ); + $this->assertSame( $expected, $contexts[ $context ] ); + } + + public static function data_default_contexts_with_subdirectories(): array { + return array( + array( 'home', 'https://example.com/subdir/', '/subdir/' ), + array( 'site', 'https://example.com/subdir/wp/', '/subdir/wp/' ), + // If the context URL has URL pattern special characters it may need escaping. + array( 'home', 'https://example.com/scope:0.*/', '/scope\\:0.\\*/' ), + array( 'site', 'https://example.com/scope:0.*/wp+/', '/scope\\:0.\\*/wp\\+/' ), + ); + } +}