diff --git a/.github/workflows/php-test-plugins.yml b/.github/workflows/php-test-plugins.yml index 15c2f5df8c..e1e9ce13f6 100644 --- a/.github/workflows/php-test-plugins.yml +++ b/.github/workflows/php-test-plugins.yml @@ -78,7 +78,7 @@ jobs: npm run wp-env start fi - name: Composer Install - run: npm run wp-env run tests-cli -- --env-cwd="wp-content/plugins/$(basename $(pwd))" composer install --no-interaction --no-progress + run: npm run wp-env run tests-wordpress -- --env-cwd="wp-content/plugins/$(basename $(pwd))" composer install --no-interaction --no-progress - name: Update Composer Dependencies run: composer update --with-all-dependencies --no-interaction --no-progress - name: Install PHPUnit diff --git a/package.json b/package.json index 65fc10ac24..f2b5e9650b 100644 --- a/package.json +++ b/package.json @@ -63,29 +63,29 @@ "test-e2e:debug": "wp-scripts test-playwright --config tools/e2e/playwright.config.ts --ui", "test-e2e:auto-sizes": "wp-scripts test-playwright --config tools/e2e/playwright.config.ts --project=auto-sizes", "lint-php": "composer lint:all", - "test-php": "wp-env run tests-cli --env-cwd=/var/www/html/wp-content/plugins/performance composer test:plugins", + "test-php": "wp-env run tests-wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test:plugins", "test-php-watch": "./bin/test-php-watch.sh", - "test-php-multisite": "wp-env run tests-cli --env-cwd=/var/www/html/wp-content/plugins/performance composer test-multisite:plugins", - "test-php:performance-lab": "wp-env run tests-cli --env-cwd=/var/www/html/wp-content/plugins/performance composer test:performance-lab", - "test-php:auto-sizes": "wp-env run tests-cli --env-cwd=/var/www/html/wp-content/plugins/performance composer test:auto-sizes", - "test-php:dominant-color-images": "wp-env run tests-cli --env-cwd=/var/www/html/wp-content/plugins/performance composer test:dominant-color-images", - "test-php:embed-optimizer": "wp-env run tests-cli --env-cwd=/var/www/html/wp-content/plugins/performance composer test:embed-optimizer", - "test-php:image-prioritizer": "wp-env run tests-cli --env-cwd=/var/www/html/wp-content/plugins/performance composer test:image-prioritizer", - "test-php:optimization-detective": "wp-env run tests-cli --env-cwd=/var/www/html/wp-content/plugins/performance composer test:optimization-detective", - "test-php:speculation-rules": "wp-env run tests-cli --env-cwd=/var/www/html/wp-content/plugins/performance composer test:speculation-rules", - "test-php:view-transitions": "wp-env run tests-cli --env-cwd=/var/www/html/wp-content/plugins/performance composer test:view-transitions", - "test-php:web-worker-offloading": "wp-env run tests-cli --env-cwd=/var/www/html/wp-content/plugins/performance composer test:web-worker-offloading", - "test-php:webp-uploads": "wp-env run tests-cli --env-cwd=/var/www/html/wp-content/plugins/performance composer test:webp-uploads", - "test-php-multisite:performance-lab": "wp-env run tests-cli --env-cwd=/var/www/html/wp-content/plugins/performance composer test-multisite:performance-lab", - "test-php-multisite:auto-sizes": "wp-env run tests-cli --env-cwd=/var/www/html/wp-content/plugins/performance composer test-multisite:auto-sizes", - "test-php-multisite:dominant-color-images": "wp-env run tests-cli --env-cwd=/var/www/html/wp-content/plugins/performance composer test-multisite:dominant-color-images", - "test-php-multisite:embed-optimizer": "wp-env run tests-cli --env-cwd=/var/www/html/wp-content/plugins/performance composer test-multisite:embed-optimizer", - "test-php-multisite:image-prioritizer": "wp-env run tests-cli --env-cwd=/var/www/html/wp-content/plugins/performance composer test-multisite:image-prioritizer", - "test-php-multisite:optimization-detective": "wp-env run tests-cli --env-cwd=/var/www/html/wp-content/plugins/performance composer test-multisite:optimization-detective", - "test-php-multisite:speculation-rules": "wp-env run tests-cli --env-cwd=/var/www/html/wp-content/plugins/performance composer test-multisite:speculation-rules", - "test-php-multisite:view-transitions": "wp-env run tests-cli --env-cwd=/var/www/html/wp-content/plugins/performance composer test-multisite:view-transitions", - "test-php-multisite:web-worker-offloading": "wp-env run tests-cli --env-cwd=/var/www/html/wp-content/plugins/performance composer test-multisite:web-worker-offloading", - "test-php-multisite:webp-uploads": "wp-env run tests-cli --env-cwd=/var/www/html/wp-content/plugins/performance composer test-multisite:webp-uploads", + "test-php-multisite": "wp-env run tests-wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test-multisite:plugins", + "test-php:performance-lab": "wp-env run tests-wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test:performance-lab", + "test-php:auto-sizes": "wp-env run tests-wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test:auto-sizes", + "test-php:dominant-color-images": "wp-env run tests-wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test:dominant-color-images", + "test-php:embed-optimizer": "wp-env run tests-wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test:embed-optimizer", + "test-php:image-prioritizer": "wp-env run tests-wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test:image-prioritizer", + "test-php:optimization-detective": "wp-env run tests-wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test:optimization-detective", + "test-php:speculation-rules": "wp-env run tests-wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test:speculation-rules", + "test-php:view-transitions": "wp-env run tests-wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test:view-transitions", + "test-php:web-worker-offloading": "wp-env run tests-wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test:web-worker-offloading", + "test-php:webp-uploads": "wp-env run tests-wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test:webp-uploads", + "test-php-multisite:performance-lab": "wp-env run tests-wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test-multisite:performance-lab", + "test-php-multisite:auto-sizes": "wp-env run tests-wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test-multisite:auto-sizes", + "test-php-multisite:dominant-color-images": "wp-env run tests-wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test-multisite:dominant-color-images", + "test-php-multisite:embed-optimizer": "wp-env run tests-wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test-multisite:embed-optimizer", + "test-php-multisite:image-prioritizer": "wp-env run tests-wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test-multisite:image-prioritizer", + "test-php-multisite:optimization-detective": "wp-env run tests-wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test-multisite:optimization-detective", + "test-php-multisite:speculation-rules": "wp-env run tests-wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test-multisite:speculation-rules", + "test-php-multisite:view-transitions": "wp-env run tests-wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test-multisite:view-transitions", + "test-php-multisite:web-worker-offloading": "wp-env run tests-wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test-multisite:web-worker-offloading", + "test-php-multisite:webp-uploads": "wp-env run tests-wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test-multisite:webp-uploads", "update-test-case-snapshots": "bin/update-test-case-snapshots.sh", "wp-env": "wp-env", "prepare": "husky" diff --git a/plugins/dominant-color-images/tests/data/class-testcase.php b/plugins/dominant-color-images/tests/data/class-testcase.php index f6026dde7c..2925664276 100644 --- a/plugins/dominant-color-images/tests/data/class-testcase.php +++ b/plugins/dominant-color-images/tests/data/class-testcase.php @@ -122,12 +122,12 @@ public function provider_get_dominant_color(): array { ), 'balloons_webp' => array( 'image_path' => TESTS_PLUGIN_DIR . '/tests/data/images/balloons.webp', - 'expected_color' => array( 'c1bbb9', 'c0bbb9', 'c0bab8', 'c3bdbd', 'bfbab8' ), + 'expected_color' => array( 'c1bbb9', 'c0bbb9', 'c0bab8', 'c3bdbd', 'bfbab8', 'c2bdbc' ), 'expected_transparency' => false, ), 'half_opaque' => array( 'image_path' => TESTS_PLUGIN_DIR . '/tests/data/images/half-opaque.png', - 'expected_color' => array( '7e7e7e' ), + 'expected_color' => array( '7e7e7e', 'ffffff' ), 'expected_transparency' => true, ), ); @@ -214,7 +214,7 @@ public function test_get_dominant_color_invalid( string $image_path ): void { $dominant_color_data = dominant_color_get_dominant_color_data( $attachment_id ); $this->assertWPError( $dominant_color_data ); - $this->assertStringContainsString( 'image_no_editor', $dominant_color_data->get_error_code() ); + $this->assertStringContainsString( 'unsupported_attachment_type', $dominant_color_data->get_error_code() ); } /** diff --git a/plugins/webp-uploads/class-webp-uploads-image-editor-imagick.php b/plugins/webp-uploads/class-webp-uploads-image-editor-imagick.php new file mode 100644 index 0000000000..468c7080fd --- /dev/null +++ b/plugins/webp-uploads/class-webp-uploads-image-editor-imagick.php @@ -0,0 +1,160 @@ + Associative array with file paths as keys and transparency detection results as values. + */ + private static $checked_images = array(); + + /** + * Load the image and set the current instance. + * + * @since n.e.x.t + * + * @return WP_Error|true True on success, WP_Error on failure. + */ + public function load() { + // @phpstan-ignore-next-line -- Parent class is created via class_alias at runtime. + $result = parent::load(); + if ( ! is_wp_error( $result ) ) { + self::$current_instance = $this; + } + return $result; + } + + /** + * Get the file path of the image. + * + * @since n.e.x.t + * + * @return string|null The file path of the image, or null if not available. + */ + public function get_file(): ?string { + if ( property_exists( $this, 'file' ) && is_string( $this->file ) ) { + return $this->file; + } + return null; + } + + /** + * Looks for transparent pixels in the image. + * If there are none, it returns false. + * + * @since n.e.x.t + * + * @return bool|WP_Error True or false based on whether there are transparent pixels, or an error on failure. + */ + public function has_transparency() { + if ( ! property_exists( $this, 'image' ) || ! $this->image instanceof Imagick ) { + return new WP_Error( 'image_editor_has_transparency_error_no_image', __( 'Transparency detection no image found.', 'webp-uploads' ) ); + } + + $file_path = $this->get_file(); + if ( isset( $file_path, self::$checked_images[ $file_path ] ) ) { + return self::$checked_images[ $file_path ]; + } + $transparency = false; + $use_fallback = false; + + try { + /* + * Check if the image has an alpha channel if false, then it can't have transparency so return early. + * + * Note that Imagick::getImageAlphaChannel() is only available if Imagick + * has been compiled against ImageMagick version 6.4.0 or newer. + */ + if ( Imagick::ALPHACHANNEL_UNDEFINED === $this->image->getImageAlphaChannel() ) { + self::$checked_images[ $file_path ] = false; + return false; + } + + // Use mean and range to determine if there is any transparency more efficiently. + $rgb_mean = $this->image->getImageChannelMean( Imagick::CHANNEL_DEFAULT ); + $alpha_range = $this->image->getImageChannelRange( Imagick::CHANNEL_ALPHA ); + + if ( isset( $rgb_mean['mean'], $alpha_range['maxima'] ) ) { + $maxima = (int) $alpha_range['maxima']; + $mean = (int) $rgb_mean['mean']; + + if ( 0 > $maxima || 0 > $mean ) { + // For invalid values use fallback. + $use_fallback = true; + } elseif ( 0 === $maxima && 0 === $mean ) { + // Alpha channel is all zeros AND no RGB content indicates fully transparent image. + $transparency = true; + } elseif ( 0 === $maxima && $mean > 0 ) { + // Alpha maxima of 0 with RGB content present indicates no real alpha channel exists (hence fully opaque). + $transparency = false; + } elseif ( 0 < $maxima && 0 < $mean ) { + // Non-zero alpha values with RGB content present indicates some transparency. + $transparency = true; + } else { + // For any other case use fallback. + $use_fallback = true; + } + } else { + $use_fallback = true; + } + + if ( $use_fallback ) { + // Fallback to walk through the pixels and look for transparent pixels. + $w = $this->image->getImageWidth(); + $h = $this->image->getImageHeight(); + for ( $x = 0; $x < $w; $x++ ) { + for ( $y = 0; $y < $h; $y++ ) { + $pixel = $this->image->getImagePixelColor( $x, $y ); + $color = $pixel->getColor( 2 ); + if ( $color['a'] < 255 ) { + $transparency = true; + break 2; + } + } + } + } + + self::$checked_images[ $file_path ] = $transparency; + return $transparency; + } catch ( Throwable $e ) { + /* translators: %s is the error message */ + return new WP_Error( 'image_editor_has_transparency_error', sprintf( __( 'Transparency detection failed: %s', 'webp-uploads' ), $e->getMessage() ) ); + } + } + } +} diff --git a/plugins/webp-uploads/helper.php b/plugins/webp-uploads/helper.php index 318d284645..6ad8250e69 100644 --- a/plugins/webp-uploads/helper.php +++ b/plugins/webp-uploads/helper.php @@ -21,12 +21,16 @@ * @since 2.0.0 Added support for AVIF. * @since 2.2.0 Added support for PNG. * + * @param string|null $filename Optional. The filename. Default null. * @return array> An array of valid mime types, where the key is the mime type and the value is the extension type. */ -function webp_uploads_get_upload_image_mime_transforms(): array { - +function webp_uploads_get_upload_image_mime_transforms( ?string $filename = null ): array { // Check the selected output format. - $output_format = webp_uploads_mime_type_supported( 'image/avif' ) ? webp_uploads_get_image_output_format() : 'webp'; + $output_format = webp_uploads_get_image_output_format(); + + if ( 'avif' === $output_format && ( ! webp_uploads_mime_type_supported( 'image/avif' ) || webp_uploads_check_image_transparency( $filename ) ) ) { + $output_format = 'webp'; + } $default_transforms = array( 'image/jpeg' => array( 'image/' . $output_format ), @@ -512,3 +516,110 @@ function webp_uploads_get_attachment_file_mime_type( int $attachment_id, string $mime_type = $filetype['type'] ?? get_post_mime_type( $attachment_id ); return is_string( $mime_type ) ? $mime_type : ''; } + +/** + * Checks if Imagick has AVIF transparency support. + * + * @since n.e.x.t + * + * @param string|null $version Optional Imagick version string. If not provided, the version will be retrieved from the Imagick class. + * @return bool True if Imagick has AVIF transparency support, false otherwise. + */ +function webp_uploads_imagick_avif_transparency_supported( ?string $version = null ): bool { + $supported = false; + $imagick_version = $version; + + if ( null === $imagick_version && extension_loaded( 'imagick' ) && class_exists( 'Imagick' ) ) { + $imagick_version = Imagick::getVersion(); + $imagick_version = $imagick_version['versionString']; + } + + if ( null !== $imagick_version && '' !== $imagick_version && (bool) preg_match( '/\d+(?:\.\d+)+(?:-\d+)?/', $imagick_version, $matches ) ) { + $imagick_version = $matches[0]; + } + + if ( null === $imagick_version || '' === $imagick_version ) { + return false; + } + + $supported = version_compare( $imagick_version, '7.0.25', '>=' ); + + /** + * Filters whether Imagick has AVIF transparency support. + * + * @since n.e.x.t + * + * @param bool $supported Whether AVIF transparency is supported. + */ + return (bool) apply_filters( 'webp_uploads_imagick_avif_transparency_supported', $supported ); +} + +/** + * Checks if an image has transparency when AVIF output is configured and AVIF transparency support is missing. + * + * @since n.e.x.t + * + * @param string|null $filename The uploaded file name. + * @return bool Whether the image has transparency. + */ +function webp_uploads_check_image_transparency( ?string $filename ): bool { + static $processed_images = array(); + + if ( 'avif' !== webp_uploads_get_image_output_format() || webp_uploads_imagick_avif_transparency_supported() ) { + return false; + } + + if ( ! class_exists( 'WebP_Uploads_Image_Editor_Imagick' ) ) { + // Calls filter `wp_image_editors` internally which makes sure `webp_uploads_set_image_editors` is called. + wp_image_editor_supports(); + } + + if ( ! class_exists( 'WebP_Uploads_Image_Editor_Imagick' ) ) { + return false; + } + + /* + * When WordPress generates subsizes (thumbnail, medium, large, etc.), the 'image_editor_output_format' + * filter is triggered without a filename parameter. In these cases, we need to retrieve the filename + * from the current editor instance that was used to load the original image. This allows us to perform + * the transparency check on the source file even when generating derivative sizes. + */ + if ( null === $filename ) { + if ( null === WebP_Uploads_Image_Editor_Imagick::$current_instance ) { + return false; + } + $file = WebP_Uploads_Image_Editor_Imagick::$current_instance->get_file(); + if ( null === $file ) { + return false; + } + $filename = $file; + } + + if ( ! is_string( $filename ) || ! file_exists( $filename ) ) { + return false; + } + + if ( isset( $processed_images[ $filename ] ) ) { + return $processed_images[ $filename ]; + } + $processed_images[ $filename ] = false; + + $editor = wp_get_image_editor( + $filename, + array( + 'methods' => array( + 'get_file', + 'has_transparency', + ), + ) + ); + + if ( is_wp_error( $editor ) || ! $editor instanceof WebP_Uploads_Image_Editor_Imagick ) { + return false; + } + + $has_transparency = $editor->has_transparency(); + $processed_images[ $filename ] = is_wp_error( $has_transparency ) ? false : $has_transparency; + + return $processed_images[ $filename ]; +} diff --git a/plugins/webp-uploads/hooks.php b/plugins/webp-uploads/hooks.php index 84d93a3afa..f25330f3b2 100644 --- a/plugins/webp-uploads/hooks.php +++ b/plugins/webp-uploads/hooks.php @@ -65,8 +65,7 @@ function webp_uploads_create_sources_property( array $metadata, int $attachment_ return $metadata; } - $valid_mime_transforms = webp_uploads_get_upload_image_mime_transforms(); - + $valid_mime_transforms = webp_uploads_get_upload_image_mime_transforms( $file ); // Not a supported mime type to create the sources property. if ( ! isset( $valid_mime_transforms[ $mime_type ] ) ) { return $metadata; @@ -334,7 +333,7 @@ function webp_uploads_filter_image_editor_output_format( $output_format, ?string } // Use the original mime type if this type is allowed. - $valid_mime_transforms = webp_uploads_get_upload_image_mime_transforms(); + $valid_mime_transforms = webp_uploads_get_upload_image_mime_transforms( $filename ); if ( ! isset( $valid_mime_transforms[ $mime_type ] ) || in_array( $mime_type, $valid_mime_transforms[ $mime_type ], true ) @@ -968,5 +967,55 @@ function webp_uploads_convert_palette_png_to_truecolor( $file ): array { return $file; } -add_filter( 'wp_handle_upload_prefilter', 'webp_uploads_convert_palette_png_to_truecolor' ); -add_filter( 'wp_handle_sideload_prefilter', 'webp_uploads_convert_palette_png_to_truecolor' ); +add_filter( 'wp_handle_upload_prefilter', 'webp_uploads_convert_palette_png_to_truecolor' ); // @codeCoverageIgnore +add_filter( 'wp_handle_sideload_prefilter', 'webp_uploads_convert_palette_png_to_truecolor' ); // @codeCoverageIgnore + +/** + * Filters the list of image editors to load the extended class when AVIF transparency is not supported. + * + * @since n.e.x.t + * + * @param class-string[]|mixed $editors Array of available image editor class names. Defaults are 'WP_Image_Editor_Imagick', 'WP_Image_Editor_GD'. + * @return class-string[] Registered image editors class names. + */ +function webp_uploads_set_image_editors( $editors ): array { + if ( ! is_array( $editors ) ) { + return array(); + } + + if ( + 'avif' !== webp_uploads_get_image_output_format() || + webp_uploads_imagick_avif_transparency_supported() || + ! isset( $editors[0] ) || + ! class_exists( $editors[0] ) || + ! ( WP_Image_Editor_Imagick::class === $editors[0] || is_subclass_of( $editors[0], WP_Image_Editor_Imagick::class ) ) + ) { + return $editors; + } + + if ( WP_Image_Editor_Imagick::class !== $editors[0] ) { + $reflection = new ReflectionClass( $editors[0] ); + if ( $reflection->isFinal() ) { + return $editors; + } + } + + if ( ! class_exists( 'WebP_Uploads_Image_Editor_Imagick_Base' ) ) { + if ( WP_Image_Editor_Imagick::class !== $editors[0] ) { + class_alias( $editors[0], 'WebP_Uploads_Image_Editor_Imagick_Base' ); + } else { + class_alias( WP_Image_Editor_Imagick::class, 'WebP_Uploads_Image_Editor_Imagick_Base' ); + } + } + + if ( ! class_exists( 'WebP_Uploads_Image_Editor_Imagick' ) ) { + require_once __DIR__ . '/class-webp-uploads-image-editor-imagick.php'; // @codeCoverageIgnore + } + + if ( class_exists( 'WebP_Uploads_Image_Editor_Imagick' ) ) { + array_unshift( $editors, WebP_Uploads_Image_Editor_Imagick::class ); + } + + return $editors; +} +add_filter( 'wp_image_editors', 'webp_uploads_set_image_editors' ); // @codeCoverageIgnore diff --git a/plugins/webp-uploads/image-edit.php b/plugins/webp-uploads/image-edit.php index 34ad6d897e..6c89b9a9fd 100644 --- a/plugins/webp-uploads/image-edit.php +++ b/plugins/webp-uploads/image-edit.php @@ -127,7 +127,7 @@ function webp_uploads_update_image_onchange( $override, string $file_path, WP_Im return (bool) $override; } - $transforms = webp_uploads_get_upload_image_mime_transforms(); + $transforms = webp_uploads_get_upload_image_mime_transforms( $file_path ); if ( ! isset( $transforms[ $mime_type ] ) || ! is_array( $transforms[ $mime_type ] ) || 0 === count( $transforms[ $mime_type ] ) ) { return null; } diff --git a/plugins/webp-uploads/load.php b/plugins/webp-uploads/load.php index c2dee6af25..a63e38b6c4 100644 --- a/plugins/webp-uploads/load.php +++ b/plugins/webp-uploads/load.php @@ -29,6 +29,7 @@ define( 'WEBP_UPLOADS_VERSION', '2.6.1' ); define( 'WEBP_UPLOADS_MAIN_FILE', plugin_basename( __FILE__ ) ); +// @codeCoverageIgnoreStart require_once __DIR__ . '/helper.php'; require_once __DIR__ . '/rest-api.php'; require_once __DIR__ . '/image-edit.php'; @@ -36,3 +37,5 @@ require_once __DIR__ . '/picture-element.php'; require_once __DIR__ . '/hooks.php'; require_once __DIR__ . '/deprecated.php'; +require_once __DIR__ . '/site-health/load.php'; +// @codeCoverageIgnoreEnd diff --git a/plugins/webp-uploads/site-health/imagick-avif-transparency-support/helper.php b/plugins/webp-uploads/site-health/imagick-avif-transparency-support/helper.php new file mode 100644 index 0000000000..e4f6b67b80 --- /dev/null +++ b/plugins/webp-uploads/site-health/imagick-avif-transparency-support/helper.php @@ -0,0 +1,51 @@ + __( 'Your site supports AVIF image format transparency with ImageMagick', 'webp-uploads' ), + 'status' => 'good', + 'badge' => array( + 'label' => __( 'Performance', 'webp-uploads' ), + 'color' => 'blue', + ), + 'description' => sprintf( + '

%s

', + __( 'Older versions of ImageMagick do not support transparency in AVIF images, which can result in loss of transparency when uploading AVIF files.', 'webp-uploads' ) + ), + 'actions' => sprintf( + '

%s

', + __( 'Your ImageMagick installation supports AVIF transparency.', 'webp-uploads' ) + ), + 'test' => 'is_imagick_avif_transparency_supported_enabled', + ); + + if ( ! webp_uploads_imagick_avif_transparency_supported() ) { + $result['status'] = 'recommended'; + $result['label'] = __( 'Your site does not support AVIF transparency', 'webp-uploads' ); + $result['actions'] = sprintf( + '

%s

', + __( 'Update ImageMagick to the latest version by contacting your hosting provider.', 'webp-uploads' ) + ); + } + + return $result; +} diff --git a/plugins/webp-uploads/site-health/imagick-avif-transparency-support/hooks.php b/plugins/webp-uploads/site-health/imagick-avif-transparency-support/hooks.php new file mode 100644 index 0000000000..7b9929a329 --- /dev/null +++ b/plugins/webp-uploads/site-health/imagick-avif-transparency-support/hooks.php @@ -0,0 +1,30 @@ +} $tests Site Health Tests. + * @return array{direct: array} Amended tests. + */ +function webp_uploads_add_imagick_avif_transparency_supported_test( array $tests ): array { + $tests['direct']['imagick_avif_transparency_supported'] = array( + 'label' => __( 'Imagick AVIF Transparency Support', 'webp-uploads' ), + 'test' => 'webp_uploads_imagick_avif_transparency_supported_test', + ); + return $tests; +} +add_filter( 'site_status_tests', 'webp_uploads_add_imagick_avif_transparency_supported_test' ); // @codeCoverageIgnore diff --git a/plugins/webp-uploads/site-health/load.php b/plugins/webp-uploads/site-health/load.php new file mode 100644 index 0000000000..8fad6dcd21 --- /dev/null +++ b/plugins/webp-uploads/site-health/load.php @@ -0,0 +1,17 @@ +> An array of valid image types. */ public function data_provider_supported_image_types(): array { - return array( + $data = array( 'webp' => array( 'webp' ), - 'avif' => array( 'avif' ), ); + + if ( $this->check_avif_encoding_support() ) { + $data['avif'] = array( 'avif' ); + } + + return $data; } /** @@ -786,12 +791,17 @@ public function data_provider_supported_image_types(): array { * @return array> An array of valid image types. */ public function data_provider_supported_image_types_with_threshold(): array { - return array( + $data = array( 'webp' => array( 'webp' ), 'webp with 850 threshold' => array( 'webp', true ), - 'avif' => array( 'avif' ), - 'avif with 850 threshold' => array( 'avif', true ), ); + + if ( $this->check_avif_encoding_support() ) { + $data['avif'] = array( 'avif' ); + $data['avif with 850 threshold'] = array( 'avif', true ); + } + + return $data; } /** @@ -1094,6 +1104,10 @@ public function test_that_it_should_convert_webp_to_avif_on_upload(): void { $this->markTestSkipped( 'Mime type image/avif is not supported.' ); } + if ( ! $this->check_avif_encoding_support() ) { + $this->markTestSkipped( 'AVIF encoding is not supported.' ); + } + $this->set_image_output_type( 'avif' ); $attachment_id = self::factory()->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/data/images/balloons.webp' ); @@ -1147,6 +1161,10 @@ static function () { } ); + if ( ! webp_uploads_mime_type_supported( 'image/webp' ) ) { + $this->markTestSkipped( 'Mime type image/webp is not supported.' ); + } + // Temp file will be copied and unlinked by WordPress core during sideload processing. $tmp_file = wp_tempnam(); copy( $image_path, $tmp_file ); @@ -1310,4 +1328,30 @@ public function test_webp_uploads_update_featured_image_picture_element_enabled( $featured_image = get_the_post_thumbnail( $post_id ); $this->assertStringStartsWith( 'newImage( 10, 10, 'white' ); + $i->setImageFormat( 'avif' ); + $i->getImageBlob(); + $encoding_support = true; + } catch ( ImagickException $e ) { + $encoding_support = false; + } + } else { + $encoding_support = false; + } + } + + return $encoding_support; + } } diff --git a/plugins/webp-uploads/tests/test-transparency.php b/plugins/webp-uploads/tests/test-transparency.php new file mode 100644 index 0000000000..457a18183b --- /dev/null +++ b/plugins/webp-uploads/tests/test-transparency.php @@ -0,0 +1,726 @@ +newImage( 10, 10, 'white' ); + $i->setImageFormat( 'avif' ); + $i->getImageBlob(); + } catch ( ImagickException $e ) { + self::markTestSkipped( 'Imagick does not support AVIF encoding.' ); + } + } + + public function set_up(): void { + parent::set_up(); + $this->set_image_output_type( 'avif' ); + } + + /** + * Tests that AVIF transparency-related hooks are added. + */ + public function test_webp_uploads_avif_transparency_related_hooks(): void { + $this->assertSame( 10, has_filter( 'wp_image_editors', 'webp_uploads_set_image_editors' ) ); + $this->assertSame( 10, has_filter( 'site_status_tests', 'webp_uploads_add_imagick_avif_transparency_supported_test' ) ); + } + + /** + * Data provider for ImageMagick version strings. + * + * @return array Test data with version strings and expected support. + */ + public function data_provider_imagick_versions(): array { + return array( + 'ImageMagick 6.8.9 Q16 x86_64' => array( 'ImageMagick 6.8.9-9 Q16 x86_64 2018-09-28 https://imagemagick.org/index.php', false ), + 'ImageMagick 6.9.12 Q16 x86_64' => array( 'ImageMagick 6.9.12-27 Q16 x86_64 2021-10-24 https://imagemagick.org', false ), + 'ImageMagick 7.1.0 Q16-HDRI x86_64' => array( 'ImageMagick 7.1.0-57 Q16-HDRI x86_64 d68553b17:20221230 https://imagemagick.org', true ), + 'ImageMagick 7.1.2 Q16-HDRI x86_64' => array( 'ImageMagick 7.1.2-7 Q16-HDRI x86_64 23405 https://imagemagick.org', true ), + 'ImageMagick 6.9.13 Q16 x86_64' => array( 'ImageMagick 6.9.13-17 Q16 x86_64', false ), + 'ImageMagick 7.1.1 Q16 aarch64' => array( 'ImageMagick 7.1.1-15 Q16 aarch64 98eceff6a:20230729 https://imagemagick.org', true ), + 'ImageMagick 7.0.25 (exact minimum version)' => array( 'ImageMagick 7.0.25 Q16 x86_64', true ), + 'ImageMagick 7.0.24 (just below minimum)' => array( 'ImageMagick 7.0.24 Q16 x86_64', false ), + 'Empty string should return false' => array( '', false ), + 'Invalid string without version should be false' => array( 'Invalid version string', false ), + 'String with only text should be false' => array( 'ImageMagick', false ), + 'Malformed version string should be false' => array( 'ImageMagick x.y.z', false ), + ); + } + + /** + * Tests webp_uploads_imagick_avif_transparency_supported checks version correctly. + * + * @dataProvider data_provider_imagick_versions + * @covers ::webp_uploads_imagick_avif_transparency_supported + * + * @param string $version ImageMagick version string. + * @param bool $expected_support Expected transparency support result. + */ + public function test_webp_uploads_imagick_avif_transparency_supported_checks_version( string $version, bool $expected_support ): void { + remove_all_filters( 'webp_uploads_imagick_avif_transparency_supported' ); + + $result = webp_uploads_imagick_avif_transparency_supported( $version ); + + $this->assertSame( $expected_support, $result ); + } + + /** + * Tests webp_uploads_check_image_transparency returns false when output format is not AVIF. + * + * @covers ::webp_uploads_check_image_transparency + */ + public function test_webp_uploads_check_image_transparency_returns_false_for_non_avif_format(): void { + $this->set_image_output_type( 'webp' ); + + $result = webp_uploads_check_image_transparency( TESTS_PLUGIN_DIR . '/tests/data/images/dice.png' ); + $this->assertFalse( $result ); + } + + /** + * Tests webp_uploads_check_image_transparency returns false when Imagick supports AVIF transparency. + * + * @covers ::webp_uploads_check_image_transparency + */ + public function test_webp_uploads_check_image_transparency_returns_false_when_imagick_supports_transparency(): void { + $this->mock_avif_transparency_support( true ); + + $result = webp_uploads_check_image_transparency( TESTS_PLUGIN_DIR . '/tests/data/images/dice.png' ); + $this->assertFalse( $result ); + } + + /** + * Tests webp_uploads_check_image_transparency returns false when file does not exist. + * + * @covers ::webp_uploads_check_image_transparency + */ + public function test_webp_uploads_check_image_transparency_returns_false_for_nonexistent_file(): void { + $result = webp_uploads_check_image_transparency( '/nonexistent/path/image.png' ); + $this->assertFalse( $result ); + } + + /** + * Tests webp_uploads_check_image_transparency returns false when filename is null without current editor instance. + * + * @covers ::webp_uploads_check_image_transparency + */ + public function test_webp_uploads_check_image_transparency_returns_false_for_null_filename_without_instance(): void { + // Ensure no current instance is set. + if ( class_exists( 'WebP_Uploads_Image_Editor_Imagick' ) ) { + WebP_Uploads_Image_Editor_Imagick::$current_instance = null; + } + + $result = webp_uploads_check_image_transparency( null ); + $this->assertFalse( $result ); + } + + /** + * Tests WebP_Uploads_Image_Editor_Imagick::get_file returns correct file path. + * + * @covers WebP_Uploads_Image_Editor_Imagick::get_file + */ + public function test_get_file_returns_correct_path(): void { + $this->setup_custom_image_editor( false ); + + $image_path = TESTS_PLUGIN_DIR . '/tests/data/images/dice.png'; + $editor = wp_get_image_editor( $image_path ); + + $this->assertNotWPError( $editor, 'Failed to create image editor.' ); + // @phpstan-ignore-next-line Class extends runtime alias WebP_Uploads_Image_Editor_Imagick_Base. + $this->assertInstanceOf( WebP_Uploads_Image_Editor_Imagick::class, $editor, 'Editor is not the custom image editor class.' ); + $this->assertSame( $image_path, $editor->get_file() ); + } + + /** + * Tests WebP_Uploads_Image_Editor_Imagick sets current_instance on load. + * + * @covers WebP_Uploads_Image_Editor_Imagick::load + */ + public function test_load_sets_current_instance(): void { + $this->setup_custom_image_editor( false ); + + // Reset current instance. + WebP_Uploads_Image_Editor_Imagick::$current_instance = null; + + $editor = wp_get_image_editor( TESTS_PLUGIN_DIR . '/tests/data/images/dice.png' ); + + $this->assertNotWPError( $editor, 'Failed to create image editor.' ); + // @phpstan-ignore-next-line Class extends runtime alias WebP_Uploads_Image_Editor_Imagick_Base. + $this->assertInstanceOf( WebP_Uploads_Image_Editor_Imagick::class, $editor, 'Editor is not the custom image editor class.' ); + $this->assertNotNull( WebP_Uploads_Image_Editor_Imagick::$current_instance ); + } + + /** + * Tests webp_uploads_set_image_editors prepends custom editor when conditions are met. + * + * @covers ::webp_uploads_set_image_editors + */ + public function test_webp_uploads_set_image_editors_prepends_custom_editor(): void { + $this->mock_avif_transparency_support( false ); + + $editors = webp_uploads_set_image_editors( array( 'WP_Image_Editor_Imagick' ) ); + + $this->assertContains( 'WebP_Uploads_Image_Editor_Imagick', $editors ); + $this->assertSame( 'WebP_Uploads_Image_Editor_Imagick', $editors[0] ); + } + + /** + * Tests webp_uploads_set_image_editors returns original editors when Imagick supports AVIF transparency. + * + * @covers ::webp_uploads_set_image_editors + */ + public function test_webp_uploads_set_image_editors_returns_original_when_transparency_supported(): void { + $this->mock_avif_transparency_support( true ); + + $original_editors = array( 'WP_Image_Editor_Imagick', 'WP_Image_Editor_GD' ); + $editors = webp_uploads_set_image_editors( $original_editors ); + + $this->assertSame( $original_editors, $editors ); + } + + /** + * Tests webp_uploads_set_image_editors returns empty array for invalid input. + * + * @covers ::webp_uploads_set_image_editors + */ + public function test_webp_uploads_set_image_editors_returns_empty_array_for_invalid_input(): void { + $editors = webp_uploads_set_image_editors( 'not an array' ); + + $this->assertSame( array(), $editors ); + } + + /** + * Tests that transparency check falls back to WebP for transparent PNG images. + * + * @covers ::webp_uploads_get_upload_image_mime_transforms + */ + public function test_upload_image_mime_transforms_fallback_to_webp_for_transparent_png(): void { + $this->mock_avif_transparency_support( false ); + + $this->ensure_custom_editor_loaded(); + + // Load the class WebP_Uploads_Image_Editor_Imagick by triggering the filter. + webp_uploads_set_image_editors( array( 'WP_Image_Editor_Imagick' ) ); + + $this->assertTrue( class_exists( 'WebP_Uploads_Image_Editor_Imagick' ), 'Custom image editor class should be loaded.' ); + + $transparent_image = TESTS_PLUGIN_DIR . '/tests/data/images/dice.png'; + $transforms = webp_uploads_get_upload_image_mime_transforms( $transparent_image ); + + // For transparent images, should fall back to WebP. + $this->assertArrayHasKey( 'image/png', $transforms ); + $this->assertContains( 'image/webp', $transforms['image/png'] ); + } + + /** + * Tests webp_uploads_get_upload_image_mime_transforms returns AVIF for non-transparent images. + * + * @covers ::webp_uploads_get_upload_image_mime_transforms + */ + public function test_upload_image_mime_transforms_uses_avif_for_non_transparent_images(): void { + if ( ! webp_uploads_mime_type_supported( 'image/avif' ) ) { + $this->markTestSkipped( 'AVIF is not supported.' ); + } + + $this->mock_avif_transparency_support( false ); + + $non_transparent_image = TESTS_PLUGIN_DIR . '/tests/data/images/car.jpeg'; + $transforms = webp_uploads_get_upload_image_mime_transforms( $non_transparent_image ); + + // For non-transparent images, should use AVIF. + $this->assertArrayHasKey( 'image/jpeg', $transforms ); + $this->assertContains( 'image/avif', $transforms['image/jpeg'] ); + } + + /** + * Tests webp_uploads_get_upload_image_mime_transforms with WebP output format ignores transparency. + * + * @covers ::webp_uploads_get_upload_image_mime_transforms + */ + public function test_upload_image_mime_transforms_ignores_transparency_for_webp_output(): void { + $this->set_image_output_type( 'webp' ); + + $transparent_image = TESTS_PLUGIN_DIR . '/tests/data/images/dice.png'; + $transforms = webp_uploads_get_upload_image_mime_transforms( $transparent_image ); + + // WebP output should not check transparency. + $this->assertArrayHasKey( 'image/png', $transforms ); + $this->assertContains( 'image/webp', $transforms['image/png'] ); + } + + /** + * Tests site health function returns good status when transparency is supported. + * + * @covers ::webp_uploads_imagick_avif_transparency_supported_test + */ + public function test_site_health_returns_good_status_when_supported(): void { + $this->mock_avif_transparency_support( true ); + + $result = webp_uploads_imagick_avif_transparency_supported_test(); + + $this->assertSame( 'good', $result['status'] ); + $this->assertStringContainsString( 'supports AVIF', $result['label'] ); + } + + /** + * Tests site health function returns recommended status when transparency is not supported. + * + * @covers ::webp_uploads_imagick_avif_transparency_supported_test + */ + public function test_site_health_returns_recommended_status_when_not_supported(): void { + $this->mock_avif_transparency_support( false ); + + $result = webp_uploads_imagick_avif_transparency_supported_test(); + + $this->assertSame( 'recommended', $result['status'] ); + $this->assertStringContainsString( 'does not support', $result['label'] ); + } + + /** + * Tests webp_uploads_check_image_transparency caches results for same file. + * + * @covers ::webp_uploads_check_image_transparency + */ + public function test_webp_uploads_check_image_transparency_caches_results(): void { + $this->mock_avif_transparency_support( false ); + + $this->ensure_custom_editor_loaded(); + + // Force loading of the extended editor class. + webp_uploads_set_image_editors( array( 'WP_Image_Editor_Imagick' ) ); + + $this->assertTrue( class_exists( 'WebP_Uploads_Image_Editor_Imagick' ), 'Custom image editor class should be loaded.' ); + + $image_path = TESTS_PLUGIN_DIR . '/tests/data/images/dice.png'; + + // Call the function twice - second call should use cache. + $result1 = webp_uploads_check_image_transparency( $image_path ); + $result2 = webp_uploads_check_image_transparency( $image_path ); + + $this->assertSame( $result1, $result2 ); + $this->assertTrue( $result1 ); + } + + /** + * Tests webp_uploads_check_image_transparency when editor cannot be instantiated. + * + * @covers ::webp_uploads_check_image_transparency + */ + public function test_webp_uploads_check_image_transparency_when_editor_fails(): void { + $this->mock_avif_transparency_support( false ); + + $this->ensure_custom_editor_loaded(); + + webp_uploads_set_image_editors( array( 'WP_Image_Editor_Imagick' ) ); + + // Temporarily disable image editors to make wp_get_image_editor fail. + add_filter( 'wp_image_editors', '__return_empty_array' ); + + $image_path = TESTS_PLUGIN_DIR . '/tests/data/images/earth.gif'; + $result = webp_uploads_check_image_transparency( $image_path ); + + remove_filter( 'wp_image_editors', '__return_empty_array' ); + + $this->assertFalse( $result ); + } + + /** + * Tests webp_uploads_check_image_transparency with null filename and valid current instance. + * + * @covers ::webp_uploads_check_image_transparency + */ + public function test_webp_uploads_check_image_transparency_with_null_filename_and_current_instance(): void { + $this->mock_avif_transparency_support( false ); + + $this->ensure_custom_editor_loaded(); + + webp_uploads_set_image_editors( array( 'WP_Image_Editor_Imagick' ) ); + + $this->assertTrue( class_exists( 'WebP_Uploads_Image_Editor_Imagick' ), 'Custom image editor class should be loaded.' ); + + $editor = wp_get_image_editor( TESTS_PLUGIN_DIR . '/tests/data/images/dice.png' ); + $this->assertNotWPError( $editor ); + + // Now call with null filename - should use current instance's file. + $result = webp_uploads_check_image_transparency( null ); + + $this->assertTrue( $result ); + } + + /** + * Tests webp_uploads_check_image_transparency returns false when current instance file is empty. + * + * @covers ::webp_uploads_check_image_transparency + */ + public function test_webp_uploads_check_image_transparency_with_empty_current_instance_file(): void { + $this->mock_avif_transparency_support( false ); + + webp_uploads_set_image_editors( array( 'WP_Image_Editor_Imagick' ) ); + + $this->assertTrue( class_exists( 'WebP_Uploads_Image_Editor_Imagick' ), 'Custom image editor class should be loaded.' ); + + // Create a mock editor with empty file property. + $editor = $this->getMockBuilder( 'WebP_Uploads_Image_Editor_Imagick' ) + ->disableOriginalConstructor() + ->getMock(); + + $editor->method( 'get_file' )->willReturn( '' ); + + WebP_Uploads_Image_Editor_Imagick::$current_instance = $editor; + + $result = webp_uploads_check_image_transparency( null ); + + // Clean up. + WebP_Uploads_Image_Editor_Imagick::$current_instance = null; + + $this->assertFalse( $result ); + } + + /** + * Tests editor methods return expected values when properties are not set. + * + * @covers WebP_Uploads_Image_Editor_Imagick::has_transparency + * @covers WebP_Uploads_Image_Editor_Imagick::get_file + */ + public function test_editor_methods_with_unset_properties(): void { + $this->mock_avif_transparency_support( false ); + + webp_uploads_set_image_editors( array( 'WP_Image_Editor_Imagick' ) ); + + $this->assertTrue( class_exists( 'WebP_Uploads_Image_Editor_Imagick' ), 'Custom image editor class should be loaded.' ); + + $test_file = TESTS_PLUGIN_DIR . '/tests/data/images/dice.png'; + // @phpstan-ignore-next-line Constructor inherited from parent class. + $editor = new WebP_Uploads_Image_Editor_Imagick( $test_file ); + $editor->load(); + $reflection = new ReflectionClass( $editor ); + + // Test has_transparency() with null image property. + $image_prop = $reflection->getProperty( 'image' ); + $image_prop->setAccessible( true ); + $image_prop->setValue( $editor, null ); + + $result = $editor->has_transparency(); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'image_editor_has_transparency_error_no_image', $result->get_error_code() ); + + // Test get_file() with empty file property. + $file_prop = $reflection->getProperty( 'file' ); + $file_prop->setAccessible( true ); + $file_prop->setValue( $editor, '' ); + + $this->assertSame( '', $editor->get_file() ); + } + + /** + * Tests webp_uploads_add_imagick_avif_transparency_supported_test adds test to site health. + * + * @covers ::webp_uploads_add_imagick_avif_transparency_supported_test + */ + public function test_webp_uploads_add_imagick_avif_transparency_supported_test_adds_test(): void { + $tests = array( + 'direct' => array(), + ); + + $result = webp_uploads_add_imagick_avif_transparency_supported_test( $tests ); + + $this->assertArrayHasKey( 'direct', $result ); + $this->assertArrayHasKey( 'imagick_avif_transparency_supported', $result['direct'] ); + $this->assertArrayHasKey( 'label', $result['direct']['imagick_avif_transparency_supported'] ); + $this->assertArrayHasKey( 'test', $result['direct']['imagick_avif_transparency_supported'] ); + $this->assertSame( 'webp_uploads_imagick_avif_transparency_supported_test', $result['direct']['imagick_avif_transparency_supported']['test'] ); + } + + /** + * Tests webp_uploads_add_imagick_avif_transparency_supported_test preserves existing tests. + * + * @covers ::webp_uploads_add_imagick_avif_transparency_supported_test + */ + public function test_webp_uploads_add_imagick_avif_transparency_supported_test_preserves_existing_tests(): void { + $tests = array( + 'direct' => array( + 'existing_test' => array( + 'label' => 'Existing Test', + 'test' => 'existing_test_callback', + ), + ), + ); + + $result = webp_uploads_add_imagick_avif_transparency_supported_test( $tests ); + + $this->assertArrayHasKey( 'existing_test', $result['direct'] ); + $this->assertArrayHasKey( 'imagick_avif_transparency_supported', $result['direct'] ); + } + + /** + * Tests integration: upload transparent PNG with AVIF output falls back to WebP. + * + * @covers ::webp_uploads_create_sources_property + * @covers ::webp_uploads_get_upload_image_mime_transforms + * @covers ::webp_uploads_check_image_transparency + */ + public function test_upload_transparent_png_with_avif_output_uses_webp(): void { + if ( ! wp_image_editor_supports( array( 'mime_type' => 'image/webp' ) ) ) { + $this->markTestSkipped( 'Mime type image/webp is not supported.' ); + } + + $this->mock_avif_transparency_support( false ); + update_option( 'perflab_generate_webp_and_jpeg', '1' ); + + // Ensure the custom editor class is loaded. + $this->ensure_custom_editor_loaded(); + + // Set up the image editors filter. + add_filter( + 'wp_image_editors', + static function ( $editors ) { + return webp_uploads_set_image_editors( $editors ); + } + ); + + $attachment_id = self::factory()->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/data/images/dice.png' ); + $metadata = wp_get_attachment_metadata( $attachment_id ); + + $this->assertIsArray( $metadata ); + $this->assertArrayHasKey( 'sources', $metadata ); + + // Should have PNG and WebP sources, not AVIF due to transparency. + $this->assertArrayHasKey( 'image/png', $metadata['sources'] ); + $this->assertArrayHasKey( 'image/webp', $metadata['sources'] ); + } + + /** + * Tests webp_uploads_set_image_editors handles final class gracefully. + * + * @covers ::webp_uploads_set_image_editors + */ + public function test_webp_uploads_set_image_editors_with_final_class(): void { + $this->mock_avif_transparency_support( false ); + + // Ensure WordPress's base image editor classes are loaded. + require_once ABSPATH . WPINC . '/class-wp-image-editor.php'; + require_once ABSPATH . WPINC . '/class-wp-image-editor-imagick.php'; + + if ( ! class_exists( 'WP_Image_Editor_Imagick' ) ) { + $this->markTestSkipped( 'WP_Image_Editor_Imagick class is not available.' ); + } + + require TESTS_PLUGIN_DIR . '/tests/data/class-final-test-image-editor.php'; + + if ( ! class_exists( 'Final_Test_Image_Editor' ) ) { + $this->markTestSkipped( 'Final_Test_Image_Editor class could not be loaded.' ); + } + + $editors = webp_uploads_set_image_editors( array( 'Final_Test_Image_Editor' ) ); + + // Should return original editors array when first editor is final. + $this->assertSame( array( 'Final_Test_Image_Editor' ), $editors ); + } + + /** + * Tests webp_uploads_set_image_editors creates class_alias for subclass. + * + * @covers ::webp_uploads_set_image_editors + */ + public function test_webp_uploads_set_image_editors_creates_class_alias_for_subclass(): void { + $this->mock_avif_transparency_support( false ); + + // Ensure WordPress's base image editor classes are loaded. + require_once ABSPATH . WPINC . '/class-wp-image-editor.php'; + require_once ABSPATH . WPINC . '/class-wp-image-editor-imagick.php'; + + if ( ! class_exists( 'WP_Image_Editor_Imagick' ) ) { + $this->markTestSkipped( 'WP_Image_Editor_Imagick class is not available.' ); + } + + require TESTS_PLUGIN_DIR . '/tests/data/class-custom-image-editor-imagick.php'; + + if ( ! class_exists( 'Custom_Image_Editor_Imagick' ) ) { + $this->markTestSkipped( 'Custom_Image_Editor_Imagick class could not be loaded.' ); + } + + $editors = webp_uploads_set_image_editors( array( 'Custom_Image_Editor_Imagick' ) ); + + // Should prepend custom editor. + $this->assertContains( 'WebP_Uploads_Image_Editor_Imagick', $editors ); + $this->assertSame( 'WebP_Uploads_Image_Editor_Imagick', $editors[0] ); + } + + /** + * Tests webp_uploads_set_image_editors with class that doesn't exist. + * + * @covers ::webp_uploads_set_image_editors + */ + public function test_webp_uploads_set_image_editors_with_nonexistent_class(): void { + $original_editors = array( 'NonExistent_Editor' ); + $editors = webp_uploads_set_image_editors( $original_editors ); + + $this->assertSame( $original_editors, $editors ); + } + + /** + * Data provider for testing various image files for transparency detection. + * + * @return array Test data. + */ + public function data_provider_image_transparency_detection(): array { + return array( + 'transparent PNG' => array( 'dice.png', true ), + 'transparent palette PNG' => array( 'dice-palette.png', true ), + 'fully transparent PNG' => array( 'transparent.png', true ), + 'non-transparent JPEG' => array( 'car.jpeg', false ), + 'non-transparent WebP' => array( 'balloons.webp', false ), + ); + } + + /** + * Tests has_transparency with various image types using data provider. + * + * @dataProvider data_provider_image_transparency_detection + * @covers WebP_Uploads_Image_Editor_Imagick::has_transparency + * + * @param string $image_filename The image filename. + * @param bool $expected_transparency Expected transparency result. + */ + public function test_has_transparency_with_various_images( string $image_filename, bool $expected_transparency ): void { + $this->setup_custom_image_editor( false ); + + $editor = wp_get_image_editor( TESTS_PLUGIN_DIR . '/tests/data/images/' . $image_filename ); + + $this->assertNotWPError( $editor, 'Failed to create image editor.' ); + // @phpstan-ignore-next-line Class extends runtime alias WebP_Uploads_Image_Editor_Imagick_Base. + $this->assertInstanceOf( WebP_Uploads_Image_Editor_Imagick::class, $editor, 'Editor is not the custom image editor class.' ); + + $has_transparency = $editor->has_transparency(); + + $this->assertNotInstanceOf( WP_Error::class, $has_transparency ); + $this->assertSame( $expected_transparency, $has_transparency ); + } + + /** + * Data provider for testing webp_uploads_set_image_editors with different conditions. + * + * @return array, bool}> Test data. + */ + public function data_provider_set_image_editors_conditions(): array { + return array( + 'empty editors array' => array( 'avif', array(), false ), + 'WebP output format' => array( 'webp', array( 'WP_Image_Editor_Imagick' ), false ), + 'GD editor first' => array( 'avif', array( 'WP_Image_Editor_GD', 'WP_Image_Editor_Imagick' ), false ), + ); + } + + /** + * Tests webp_uploads_set_image_editors returns original array for various conditions. + * + * @dataProvider data_provider_set_image_editors_conditions + * @covers ::webp_uploads_set_image_editors + * + * @param string $output_format Output format setting. + * @param string[] $editors Array of editor class names. + * @param bool $should_modify Whether the array should be modified. + */ + public function test_webp_uploads_set_image_editors_with_various_conditions( string $output_format, array $editors, bool $should_modify ): void { + $this->mock_avif_transparency_support( false ); + + $this->set_image_output_type( $output_format ); + + $result = webp_uploads_set_image_editors( $editors ); + + if ( ! $should_modify ) { + $this->assertSame( $editors, $result ); + } else { + $this->assertNotSame( $editors, $result ); + $this->assertContains( 'WebP_Uploads_Image_Editor_Imagick', $result ); + } + } + + /** + * Data provider for testing site health responses. + * + * @return array Test data. + */ + public function data_provider_site_health_structure(): array { + return array( + 'label key' => array( 'label', 'string' ), + 'status key' => array( 'status', 'string' ), + 'badge key' => array( 'badge', 'array' ), + 'description key' => array( 'description', 'string' ), + 'actions key' => array( 'actions', 'string' ), + 'test key' => array( 'test', 'string' ), + ); + } + + /** + * Tests site health function structure using data provider. + * + * @dataProvider data_provider_site_health_structure + * @covers ::webp_uploads_imagick_avif_transparency_supported_test + * + * @param string $key The array key to check. + * @param string $type The expected type. + */ + public function test_site_health_structure_has_required_keys( string $key, string $type ): void { + $result = webp_uploads_imagick_avif_transparency_supported_test(); + + $this->assertArrayHasKey( $key, $result ); + + if ( 'string' === $type ) { + $this->assertIsString( $result[ $key ] ); + } elseif ( 'array' === $type ) { + $this->assertIsArray( $result[ $key ] ); + } + } + + /** + * Mocks AVIF transparency support to force a specific scenario. + * + * @param bool $supported Whether to mock AVIF transparency as supported. + */ + private function mock_avif_transparency_support( bool $supported ): void { + add_filter( + 'webp_uploads_imagick_avif_transparency_supported', + static function () use ( $supported ) { + return $supported; + }, + 1 + ); + } + + /** + * Ensures the WebP_Uploads_Image_Editor_Imagick class is loaded for testing. + */ + private function ensure_custom_editor_loaded(): void { + wp_image_editor_supports(); + } + + /** + * Sets up custom image editor for tests that need it. + * + * @param bool $transparency_supported Whether AVIF transparency is supported. + */ + private function setup_custom_image_editor( bool $transparency_supported = false ): void { + $this->mock_avif_transparency_support( $transparency_supported ); + $this->ensure_custom_editor_loaded(); + add_filter( 'wp_image_editors', 'webp_uploads_set_image_editors' ); + + if ( ! class_exists( 'WebP_Uploads_Image_Editor_Imagick' ) ) { + $this->markTestSkipped( 'WebP_Uploads_Image_Editor_Imagick class is not available.' ); + } + } +}