diff options
Diffstat (limited to 'tests/phpunit/includes')
4 files changed, 350 insertions, 7 deletions
diff --git a/tests/phpunit/includes/abstract-testcase.php b/tests/phpunit/includes/abstract-testcase.php index f665bdafb1..6750df13c7 100644 --- a/tests/phpunit/includes/abstract-testcase.php +++ b/tests/phpunit/includes/abstract-testcase.php @@ -1,5 +1,6 @@ <?php +require_once __DIR__ . '/build-visual-html-tree.php'; require_once __DIR__ . '/factory.php'; require_once __DIR__ . '/trac.php'; @@ -13,7 +14,6 @@ require_once __DIR__ . '/trac.php'; * All WordPress unit tests should inherit from this class. */ abstract class WP_UnitTestCase_Base extends PHPUnit_Adapter_TestCase { - protected static $forced_tickets = array(); protected $expected_deprecated = array(); protected $caught_deprecated = array(); @@ -1181,6 +1181,44 @@ abstract class WP_UnitTestCase_Base extends PHPUnit_Adapter_TestCase { } /** + * Check HTML markup (including blocks) for semantic equivalence. + * + * Given two markup strings, assert that they translate to the same semantic HTML tree, + * normalizing tag names, attribute names, and attribute order. Furthermore, attributes + * and class names are sorted and deduplicated, and whitespace in style attributes + * is normalized. Finally, block delimiter comments are recognized and normalized, + * applying the same principles. + * + * @since 6.9.0 + * + * @param string $expected The expected HTML. + * @param string $actual The actual HTML. + * @param string|null $fragment_context Optional. The fragment context, for example "<td>" expected HTML + * must occur within "<table><tr>" fragment context. Default "<body>". + * Only "<body>" or `null` are supported at this time. + * Set to `null` to parse a full HTML document. + * @param string|null $message Optional. The assertion error message. + */ + public function assertEqualHTML( string $expected, string $actual, ?string $fragment_context = '<body>', $message = 'HTML markup was not equivalent.' ): void { + try { + $tree_expected = build_visual_html_tree( $expected, $fragment_context ); + $tree_actual = build_visual_html_tree( $actual, $fragment_context ); + } catch ( Exception $e ) { + // For PHP 8.4+, we can retry, using the built-in DOM\HTMLDocument parser. + if ( class_exists( 'DOM\HtmlDocument' ) ) { + $dom_expected = DOM\HtmlDocument::createFromString( $expected, LIBXML_NOERROR ); + $tree_expected = build_visual_html_tree( $dom_expected->saveHtml(), $fragment_context ); + $dom_actual = DOM\HtmlDocument::createFromString( $actual, LIBXML_NOERROR ); + $tree_actual = build_visual_html_tree( $dom_actual->saveHtml(), $fragment_context ); + } else { + throw $e; + } + } + + $this->assertSame( $tree_expected, $tree_actual, $message ); + } + + /** * Helper function to convert a single-level array containing text strings to a named data provider. * * The value of the data set will also be used as the name of the data set. diff --git a/tests/phpunit/includes/build-visual-html-tree.php b/tests/phpunit/includes/build-visual-html-tree.php new file mode 100644 index 0000000000..e0f6c3ac3d --- /dev/null +++ b/tests/phpunit/includes/build-visual-html-tree.php @@ -0,0 +1,304 @@ +<?php + +/* phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped */ + +/** + * Generates representation of the semantic HTML tree structure. + * + * This is inspired by the representation used by the HTML5lib tests. It's been extended here for + * blocks to render the semantic structure of blocks and their attributes. + * The order of attributes and class names is normalized both for HTML tags and blocks, + * as is the whitespace in HTML tags' style attribute. + * + * For example, consider the following block markup: + * + * <!-- wp:separator {"className":"is-style-default has-custom-classname","style":{"spacing":{"margin":{"top":"50px","bottom":"50px"}}},"backgroundColor":"accent-1"} --> + * <hr class="wp-block-separator is-style-default has-custom-classname" style="margin-top: 50px; margin-bottom: 50px" /> + * <!-- /wp:separator --> + * + * This will be represented as: + * + * BLOCK["core/separator"] + * { + * "backgroundColor": "accent-1", + * "className": "has-custom-classname is-style-default", + * "style": { + * "spacing": { + * "margin": { + * "top": "50px", + * "bottom": "50px" + * } + * } + * } + * } + * <hr> + * class="has-custom-classname is-style-default wp-block-separator" + * style="margin-top:50px;margin-bottom:50px;" + * + * + * @see https://github.com/WordPress/wordpress-develop/blob/trunk/tests/phpunit/data/html5lib-tests/tree-construction/README.md + * + * @since 6.9.0 + * + * @throws WP_HTML_Unsupported_Exception|Error If the markup could not be parsed. + * + * @param string $html Given test HTML. + * @param string|null $fragment_context Context element in which to parse HTML, such as BODY or SVG. + * @return string Tree structure of parsed HTML, if supported. + */ +function build_visual_html_tree( string $html, ?string $fragment_context ): string { + $processor = $fragment_context + ? WP_HTML_Processor::create_fragment( $html, $fragment_context ) + : WP_HTML_Processor::create_full_parser( $html ); + if ( null === $processor ) { + throw new Error( 'Could not create a parser.' ); + } + $tree_indent = ' '; + + $output = ''; + $indent_level = 0; + $was_text = null; + $text_node = ''; + + $block_context = array(); + + while ( $processor->next_token() ) { + if ( null !== $processor->get_last_error() ) { + break; + } + + $token_name = $processor->get_token_name(); + $token_type = $processor->get_token_type(); + $is_closer = $processor->is_tag_closer(); + + if ( $was_text && '#text' !== $token_name ) { + if ( '' !== $text_node ) { + $output .= "{$text_node}\"\n"; + } + $was_text = false; + $text_node = ''; + } + + switch ( $token_type ) { + case '#doctype': + $doctype = $processor->get_doctype_info(); + $output .= "<!DOCTYPE {$doctype->name}"; + if ( null !== $doctype->public_identifier || null !== $doctype->system_identifier ) { + $output .= " \"{$doctype->public_identifier}\" \"{$doctype->system_identifier}\""; + } + $output .= ">\n"; + break; + + case '#tag': + $namespace = $processor->get_namespace(); + $tag_name = 'html' === $namespace + ? strtolower( $processor->get_tag() ) + : "{$namespace} {$processor->get_qualified_tag_name()}"; + + if ( $is_closer ) { + --$indent_level; + + if ( 'html' === $namespace && 'TEMPLATE' === $token_name ) { + --$indent_level; + } + + break; + } + + $tag_indent = $indent_level; + + if ( $processor->expects_closer() ) { + ++$indent_level; + } + + $output .= str_repeat( $tree_indent, $tag_indent ) . "<{$tag_name}>\n"; + + $attribute_names = $processor->get_attribute_names_with_prefix( '' ); + if ( $attribute_names ) { + $sorted_attributes = array(); + foreach ( $attribute_names as $attribute_name ) { + $sorted_attributes[ $attribute_name ] = $processor->get_qualified_attribute_name( $attribute_name ); + } + + /* + * Sorts attributes to match html5lib sort order. + * + * - First comes normal HTML attributes. + * - Then come adjusted foreign attributes; these have spaces in their names. + * - Finally come non-adjusted foreign attributes; these have a colon in their names. + * + * Example: + * + * From: <math xlink:author definitionurl xlink:title xlink:show> + * Sorted: 'definitionURL', 'xlink show', 'xlink title', 'xlink:author' + */ + uasort( + $sorted_attributes, + static function ( $a, $b ) { + $a_has_ns = str_contains( $a, ':' ); + $b_has_ns = str_contains( $b, ':' ); + + // Attributes with `:` should follow all other attributes. + if ( $a_has_ns !== $b_has_ns ) { + return $a_has_ns ? 1 : -1; + } + + $a_has_sp = str_contains( $a, ' ' ); + $b_has_sp = str_contains( $b, ' ' ); + + // Attributes with a namespace ' ' should come after those without. + if ( $a_has_sp !== $b_has_sp ) { + return $a_has_sp ? 1 : -1; + } + + return $a <=> $b; + } + ); + + foreach ( $sorted_attributes as $attribute_name => $display_name ) { + $val = $processor->get_attribute( $attribute_name ); + /* + * Attributes with no value are `true` with the HTML API, + * we use the empty string value in the tree structure. + */ + if ( true === $val ) { + $val = ''; + } elseif ( 'class' === $attribute_name ) { + $class_names = iterator_to_array( $processor->class_list() ); + sort( $class_names, SORT_STRING ); + $val = implode( ' ', $class_names ); + } elseif ( 'style' === $attribute_name ) { + $normalized_style = ''; + foreach ( explode( ';', $val ) as $style ) { + if ( empty( trim( $style ) ) ) { + continue; + } + list( $style_key, $style_val ) = explode( ':', $style ); + + $style_key = trim( $style_key ); + $style_val = trim( $style_val ); + + $normalized_style .= "{$style_key}:{$style_val};"; + } + $val = $normalized_style; + } + $output .= str_repeat( $tree_indent, $tag_indent + 1 ) . "{$display_name}=\"{$val}\"\n"; + } + } + + // Self-contained tags contain their inner contents as modifiable text. + $modifiable_text = $processor->get_modifiable_text(); + if ( '' !== $modifiable_text ) { + $output .= str_repeat( $tree_indent, $tag_indent + 1 ) . "\"{$modifiable_text}\"\n"; + } + + if ( 'html' === $namespace && 'TEMPLATE' === $token_name ) { + $output .= str_repeat( $tree_indent, $indent_level ) . "content\n"; + ++$indent_level; + } + + break; + + case '#cdata-section': + case '#text': + $text_content = $processor->get_modifiable_text(); + if ( '' === trim( $text_content, " \f\t\r\n" ) ) { + break; + } + $was_text = true; + if ( '' === $text_node ) { + $text_node .= str_repeat( $tree_indent, $indent_level ) . '"'; + } + $text_node .= $text_content; + break; + + case '#funky-comment': + // Comments must be "<" then "!-- " then the data then " -->". + $output .= str_repeat( $tree_indent, $indent_level ) . "<!-- {$processor->get_modifiable_text()} -->\n"; + break; + + case '#comment': + // Comments must be "<" then "!--" then the data then "-->". + $comment = "<!--{$processor->get_full_comment_text()}-->"; + + // Maybe the comment is a block delimiter. + $parser = new WP_Block_Parser(); + $parser->document = $comment; + $parser->offset = 0; + list( $delimiter_type, $block_name, $block_attrs, $start_offset, $token_length ) = $parser->next_token(); + + switch ( $delimiter_type ) { + case 'block-opener': + case 'void-block': + $output .= str_repeat( $tree_indent, $indent_level ) . "BLOCK[\"{$block_name}\"]\n"; + + if ( 'block-opener' === $delimiter_type ) { + $block_context[] = $block_name; + ++$indent_level; + } + + // If they're no attributes, we're done here. + if ( empty( $block_attrs ) ) { + break; + } + + // Normalize attribute order. + ksort( $block_attrs, SORT_STRING ); + + if ( isset( $block_attrs['className'] ) ) { + // Normalize class name order (and de-duplicate), as we need to be tolerant of different orders. + // (Style attributes don't need this treatment, as they are parsed into a nested array.) + $block_class_processor = new WP_HTML_Tag_Processor( '<div>' ); + $block_class_processor->next_token(); + $block_class_processor->set_attribute( 'class', $block_attrs['className'] ); + $class_names = iterator_to_array( $block_class_processor->class_list() ); + sort( $class_names, SORT_STRING ); + $block_attrs['className'] = implode( ' ', $class_names ); + } + + $block_attrs = json_encode( $block_attrs, JSON_PRETTY_PRINT ); + // Fix indentation by "halving" it (2 spaces instead of 4). + // Additionally, we need to indent each line by the current indentation level. + $block_attrs = preg_replace( '/^( +)\1/m', str_repeat( $tree_indent, $indent_level ) . '$1', $block_attrs ); + // Finally, indent the first line, and the last line (with the closing curly brace). + $output .= str_repeat( $tree_indent, $indent_level ) . substr( $block_attrs, 0, -1 ) . str_repeat( $tree_indent, $indent_level ) . "}\n"; + break; + case 'block-closer': + // Is this a closer for the currently open block? + if ( ! empty( $block_context ) && end( $block_context ) === $block_name ) { + // If it's a closer, we don't add it to the output. + // Instead, we decrease indentation and remove the block from block context stack. + --$indent_level; + array_pop( $block_context ); + } + break; + default: // Not a block delimiter. + $output .= str_repeat( $tree_indent, $indent_level ) . $comment . "\n"; + break; + } + break; + default: + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export + $serialized_token_type = var_export( $processor->get_token_type(), true ); + throw new Error( "Unhandled token type for tree construction: {$serialized_token_type}" ); + } + } + + if ( null !== $processor->get_unsupported_exception() ) { + throw $processor->get_unsupported_exception(); + } + + if ( null !== $processor->get_last_error() ) { + throw new Error( "Parser error: {$processor->get_last_error()}" ); + } + + if ( $processor->paused_at_incomplete_token() ) { + throw new Error( 'Paused at incomplete token.' ); + } + + if ( '' !== $text_node ) { + $output .= "{$text_node}\"\n"; + } + + return $output; +} diff --git a/tests/phpunit/includes/factory/class-wp-unittest-factory-for-attachment.php b/tests/phpunit/includes/factory/class-wp-unittest-factory-for-attachment.php index 262c6c4640..2c1872795f 100644 --- a/tests/phpunit/includes/factory/class-wp-unittest-factory-for-attachment.php +++ b/tests/phpunit/includes/factory/class-wp-unittest-factory-for-attachment.php @@ -50,12 +50,13 @@ class WP_UnitTest_Factory_For_Attachment extends WP_UnitTest_Factory_For_Post { } /** - * Saves an attachment. + * Saves a file as an attachment. * * @since 4.4.0 * @since 6.2.0 Returns a WP_Error object on failure. * - * @param string $file The file name to create attachment object for. + * @param string $file Full path to the file to create an attachment object for. + * The name of the file will be used as the attachment name. * @param int $parent_post_id ID of the post to attach the file to. * * @return int|WP_Error The attachment ID on success, WP_Error object on failure. diff --git a/tests/phpunit/includes/object-cache.php b/tests/phpunit/includes/object-cache.php index ef03546892..daffcabac5 100644 --- a/tests/phpunit/includes/object-cache.php +++ b/tests/phpunit/includes/object-cache.php @@ -160,8 +160,8 @@ function wp_cache_cas( $cas_token, $key, $value, $group = '', $expiration = 0 ) * * @link https://www.php.net/manual/en/memcached.casbykey.php * - * @param string $server_key The key identifying the server to store the value on. * @param float $cas_token Unique value associated with the existing item. Generated by memcached. + * @param string $server_key The key identifying the server to store the value on. * @param string $key The key under which to store the value. * @param mixed $value The value to store. * @param string $group The group value appended to the $key. @@ -1238,8 +1238,8 @@ class WP_Object_Cache { * * @link https://www.php.net/manual/en/memcached.casbykey.php * - * @param string $server_key The key identifying the server to store the value on. * @param float $cas_token Unique value associated with the existing item. Generated by memcached. + * @param string $server_key The key identifying the server to store the value on. * @param string $key The key under which to store the value. * @param mixed $value The value to store. * @param string $group The group value appended to the $key. @@ -1929,12 +1929,12 @@ class WP_Object_Cache { * * @link https://www.php.net/manual/en/memcached.replace.php * - * @param string $server_key The key identifying the server to store the value on. * @param string $key The key under which to store the value. * @param mixed $value The value to store. * @param string $group The group value appended to the $key. - * @param bool $by_key True to store in internal cache by key; false to not store by key. * @param int $expiration The expiration time, defaults to 0. + * @param string $server_key The key identifying the server to store the value on. + * @param bool $by_key True to store in internal cache by key; false to not store by key. * @return bool True on success, false on failure. */ public function replace( $key, $value, $group = 'default', $expiration = 0, $server_key = '', $by_key = false ) { |