diff --git a/src/class-tiny-compress-client.php b/src/class-tiny-compress-client.php index 5bbf8c9..b41aa6c 100644 --- a/src/class-tiny-compress-client.php +++ b/src/class-tiny-compress-client.php @@ -30,6 +30,15 @@ class Tiny_Compress_Client extends Tiny_Compress { + /** + * API request timeout in seconds. + * + * @since 3.6.8 + * @var int + */ + const API_TIMEOUT = 120; + const CONNECT_TIMEOUT = 8; + private $last_error_code = 0; private $last_message = ''; private $proxy; @@ -92,7 +101,6 @@ protected function compress( $input, $resize_opts, $preserve_opts, $convert_to ) try { $this->last_error_code = 0; $this->set_request_options( \Tinify\Tinify::getClient() ); - $source = \Tinify\fromBuffer( $input ); if ( $resize_opts ) { @@ -138,6 +146,11 @@ protected function compress( $input, $resize_opts, $preserve_opts, $convert_to ) } catch ( \Tinify\Exception $err ) { $this->last_error_code = $err->status; + Tiny_Logger::error('client compress error', array( + 'error' => $err->getMessage(), + 'status' => $err->status, + )); + throw new Tiny_Exception( $err->getMessage(), get_class( $err ), @@ -172,6 +185,10 @@ private function set_request_options( $client ) { $property->setAccessible( true ); $options = $property->getValue( $client ); + // Set API request timeout to prevent indefinite hanging + $options[ CURLOPT_TIMEOUT ] = self::API_TIMEOUT; + $options[ CURLOPT_CONNECTTIMEOUT ] = self::CONNECT_TIMEOUT; + if ( TINY_DEBUG ) { $file = fopen( dirname( __FILE__ ) . '/curl.log', 'w' ); if ( is_resource( $file ) ) { @@ -190,5 +207,7 @@ private function set_request_options( $client ) { $options[ CURLOPT_PROXYUSERPWD ] = $this->proxy->authentication(); } } + + $property->setValue( $client, $options ); } } diff --git a/src/class-tiny-compress-fopen.php b/src/class-tiny-compress-fopen.php index 4e7a9cd..83a1928 100644 --- a/src/class-tiny-compress-fopen.php +++ b/src/class-tiny-compress-fopen.php @@ -86,6 +86,12 @@ protected function compress( $input, $resize_opts, $preserve_opts, $convert_to ) $params = $this->request_options( 'POST', $input ); list($details, $headers, $status_code) = $this->request( $params ); + Tiny_Logger::debug('client fopen compress out', array( + 'details' => $details, + 'headers' => $headers, + 'status' => $status_code, + )); + $output_url = isset( $headers['location'] ) ? $headers['location'] : null; if ( $status_code >= 400 && is_array( $details ) && isset( $details['error'] ) ) { throw new Tiny_Exception( diff --git a/src/class-tiny-diagnostics.php b/src/class-tiny-diagnostics.php new file mode 100644 index 0000000..b335042 --- /dev/null +++ b/src/class-tiny-diagnostics.php @@ -0,0 +1,259 @@ +settings = $settings; + + add_action( + 'wp_ajax_tiny_download_diagnostics', + array( $this, 'download_diagnostics' ) + ); + } + + /** + * Collects all diagnostic information. + * + * File contains: + * - timestamp of export + * - server information + * - site information + * - plugin list + * - tinify settings + * - image settings + * - logs + * + * @since 3.7.0 + * + * @return array Array of diagnostic information. + */ + public function collect_info() { + $info = array( + 'timestamp' => current_time( 'Y-m-d H:i:s' ), + 'site_info' => self::get_site_info(), + 'active_plugins' => self::get_active_plugins(), + 'tiny_info' => $this->get_tiny_info(), + 'image_sizes' => $this->settings->get_active_tinify_sizes(), + ); + + return $info; + } + + /** + * Gets site information. + * + * @since 3.7.0 + * + * @return array Site information. + */ + private static function get_site_info() { + global $wp_version; + $theme = wp_get_theme(); + + return array( + 'wp_version' => $wp_version, + 'site_url' => get_site_url(), + 'home_url' => get_home_url(), + 'is_multisite' => is_multisite(), + 'site_language' => get_locale(), + 'timezone' => wp_timezone_string(), + 'theme_name' => $theme->get( 'Name' ), + 'theme_version' => $theme->get( 'Version' ), + 'theme_uri' => $theme->get( 'ThemeURI' ), + ); + } + + /** + * Gets list of active plugins. + * + * @since 3.7.0 + * + * @return array List of active plugins. + */ + private static function get_active_plugins() { + $active_plugins = get_option( 'active_plugins', array() ); + $plugins = array(); + + foreach ( $active_plugins as $plugin ) { + $plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin ); + $plugins[] = array( + 'name' => $plugin_data['Name'], + 'version' => $plugin_data['Version'], + 'author' => $plugin_data['Author'], + 'file' => $plugin, + ); + } + + return $plugins; + } + + /** + * Gets TinyPNG plugin info & settings. + * + * @since 3.7.0 + * + * @return array Plugin settings + */ + private function get_tiny_info() { + return array( + 'version' => Tiny_Plugin::version(), + 'status' => $this->settings->get_status(), + 'php_client_supported' => Tiny_PHP::client_supported(), + + 'compression_count' => $this->settings->get_compression_count(), + 'compression_timing' => $this->settings->get_compression_timing(), + 'conversion' => $this->settings->get_conversion_options(), + 'paying_state' => $this->settings->get_paying_state(), + ); + } + + public function download_diagnostics() { + $zippath = $this->create_diagnostic_zip(); + return $this->download_zip( $zippath ); + } + + /** + * Creates a diagnostic zip file. + * + * @since 3.7.0 + * + * @return string|WP_Error Path to the created zip file or WP_Error on failure. + */ + public function create_diagnostic_zip() { + if ( ! class_exists( 'ZipArchive' ) ) { + return new WP_Error( + 'zip_not_available', + __( 'ZipArchive class is not available on this server.', + 'tiny-compress-images' + ) + ); + } + + $upload_dir = wp_upload_dir(); + $temp_dir = trailingslashit( $upload_dir['basedir'] ) . 'tiny-compress-temp'; + if ( ! file_exists( $temp_dir ) ) { + wp_mkdir_p( $temp_dir ); + } + + $zip_filename = 'tiny-compress-diagnostics-' . gmdate( 'Y-m-d-His' ) . '.zip'; + $zip_path = trailingslashit( $temp_dir ) . $zip_filename; + + $zip = new ZipArchive(); + if ( true !== $zip->open( $zip_path, ZipArchive::CREATE | ZipArchive::OVERWRITE ) ) { + return new WP_Error( 'zip_create_failed', + __( 'Failed to create zip file.', + 'tiny-compress-images' ) + ); + } + + // Add diagnostic info. + $info = self::collect_info(); + $zip->addFromString( 'tiny-diagnostics.json', wp_json_encode( $info, JSON_PRETTY_PRINT ) ); + + // Add phpinfo HTML. + ob_start(); + phpinfo( INFO_GENERAL ); + phpinfo( INFO_CONFIGURATION ); + phpinfo( INFO_MODULES ); + $phpinfo_html = ob_get_clean(); + $zip->addFromString( 'phpinfo.html', $phpinfo_html ); + + // Add log files. + $logger = Tiny_Logger::get_instance(); + $log_files = $logger->get_log_files(); + + foreach ( $log_files as $log_file ) { + if ( file_exists( $log_file ) ) { + $zip->addFile( $log_file, 'logs/' . basename( $log_file ) ); + } + } + + $zip->close(); + return $zip_path; + } + + /** + * Downloads and removes the zip + * + * @since 3.7.0 + * + * @param string $zip_path Path to the zip file. + */ + public static function download_zip( $zip_path ) { + if ( ! file_exists( $zip_path ) ) { + wp_die( esc_html__( 'Diagnostic file not found.', 'tiny-compress-images' ) ); + } + + header( 'Content-Type: application/zip' ); + header( 'Content-Disposition: attachment; filename="' . basename( $zip_path ) . '"' ); + header( 'Content-Length: ' . filesize( $zip_path ) ); + header( 'Pragma: no-cache' ); + header( 'Expires: 0' ); + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile + readfile( $zip_path ); + + // Clean up. + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink + unlink( $zip_path ); + + exit; + } + + /** + * Cleans up old diagnostic zip files. + * + * @since 3.7.0 + */ + public static function cleanup_old_diagnostics() { + $upload_dir = wp_upload_dir(); + $temp_dir = trailingslashit( $upload_dir['basedir'] ) . 'tiny-compress-temp'; + + if ( ! file_exists( $temp_dir ) ) { + return; + } + + $files = glob( trailingslashit( $temp_dir ) . 'tiny-compress-diagnostics-*.zip' ); + $max_age = DAY_IN_SECONDS; // 1 day. + + foreach ( $files as $file ) { + if ( file_exists( $file ) && (time() - filemtime( $file )) > $max_age ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink + unlink( $file ); + } + } + } +} diff --git a/src/class-tiny-image.php b/src/class-tiny-image.php index 3a5a267..adb0056 100644 --- a/src/class-tiny-image.php +++ b/src/class-tiny-image.php @@ -185,6 +185,10 @@ public function get_mime_type() { } public function compress() { + Tiny_Logger::debug('compress', array( + 'image_id' => $this->id, + 'name' => $this->name, + )); if ( $this->settings->get_compressor() === null || ! $this->file_type_allowed() ) { return; } @@ -212,6 +216,20 @@ public function compress() { $this->update_tiny_post_meta(); $resize = $this->settings->get_resize_options( $size_name ); $preserve = $this->settings->get_preserve_options( $size_name ); + Tiny_Logger::debug('compress size', array( + 'image_id' => $this->id, + 'size' => $size_name, + 'resize' => $resize, + 'preserve' => $preserve, + 'convert' => $convert_to, + 'modified' => $size->modified(), + 'filename' => $size->filename, + 'is_duplicate' => $size->is_duplicate(), + 'exists' => $size->exists(), + 'has_been_compressed' => $size->has_been_compressed(), + 'filesize' => $size->filesize(), + 'mimetype' => $size->mimetype(), + )); try { $response = $compressor->compress_file( $size->filename, @@ -227,14 +245,23 @@ public function compress() { $size->add_tiny_meta( $response ); $success++; + Tiny_Logger::debug('compress success', array( + 'size' => $size_name, + 'image_id' => $this->id, + )); } catch ( Tiny_Exception $e ) { $size->add_tiny_meta_error( $e ); $failed++; + Tiny_Logger::error('compress failed', array( + 'error' => $e->get_message(), + 'size' => $size_name, + 'image_id' => $this->id, + )); } $this->add_wp_metadata( $size_name, $size ); $this->update_tiny_post_meta(); - } - } + }// End if(). + }// End foreach(). /* Other plugins can hook into this action to execute custom logic diff --git a/src/class-tiny-logger.php b/src/class-tiny-logger.php new file mode 100644 index 0000000..11f9465 --- /dev/null +++ b/src/class-tiny-logger.php @@ -0,0 +1,236 @@ +log_file_path = $this->resolve_log_file_path(); + $this->log_enabled = 'on' === get_option( 'tinypng_logging_enabled', false ); + } + + /** + * Initializes the logger by registering WordPress hooks. + * + * This method hooks into 'pre_update_option_tinypng_logging_enabled' to + * intercept and process logging settings before they are saved to the database. + * + * @return void + */ + public static function init() { + add_filter( + 'pre_update_option_tinypng_logging_enabled', + 'Tiny_Logger::on_save_log_enabled', 10, 3 ); + } + + /** + * Resets the singleton instance. + * Used primarily for unit testing. + */ + public static function reset() { + self::$instance = null; + } + + /** + * Retrieves whether logging is currently enabled. + * + * @return bool True if logging is enabled, false otherwise. + */ + public function get_log_enabled() { + return $this->log_enabled; + } + + /** + * Retrieves the absolute filesystem path to the log file. + * + * @return string The full filesystem path to the tiny-compress.log file. + */ + public function get_log_file_path() { + return $this->log_file_path; + } + + /** + * Triggered when log_enabled is saved + * - set the setting on the instance + * - if turn on, clear the old logs + */ + public static function on_save_log_enabled( $log_enabled, $old, $option ) { + $instance = self::get_instance(); + $instance->log_enabled = 'on' === $log_enabled; + + if ( $instance->get_log_enabled() ) { + $instance->clear_logs(); + } + + return $log_enabled; + } + + /** + * Retrieves the log path using wp_upload_dir. This operation + * should only be used internally. Use the getter to get the + * memoized function. + * + * @return string The log file path. + */ + private function resolve_log_file_path() { + $upload_dir = wp_upload_dir(); + $log_dir = trailingslashit( $upload_dir['basedir'] ) . 'tiny-compress-logs'; + return trailingslashit( $log_dir ) . 'tiny-compress.log'; + } + + /** + * Logs an error message. + * + * @param string $message The message to log. + * @param array $context Optional. Additional context data. Default empty array. + */ + public static function error( $message, $context = array() ) { + $instance = self::get_instance(); + $instance->log( self::LOG_LEVEL_ERROR, $message, $context ); + } + + /** + * Logs a debug message. + * + * @param string $message The message to log. + * @param array $context Optional. Additional context data. Default empty array. + */ + public static function debug( $message, $context = array() ) { + $instance = self::get_instance(); + $instance->log( self::LOG_LEVEL_DEBUG, $message, $context ); + } + + /** + * Logs a message. + * + * @param string $level The log level. + * @param string $message The message to log. + * @param array $context Optional. Additional context data. Default empty array. + * @return void + */ + private function log( $level, $message, $context = array() ) { + if ( ! $this->log_enabled ) { + return; + } + + $this->rotate_logs(); + + // Ensure log directory exists. + $log_dir = dirname( $this->log_file_path ); + if ( ! file_exists( $log_dir ) ) { + wp_mkdir_p( $log_dir ); + } + + $timestamp = current_time( 'Y-m-d H:i:s' ); + $level_str = strtoupper( $level ); + $context_str = ! empty( $context ) ? ' ' . wp_json_encode( $context ) : ''; + $log_entry = "[{$timestamp}] [{$level_str}] {$message}{$context_str}" . PHP_EOL; + $file = fopen( $this->log_file_path, 'a' ); + if ( $file ) { + if ( flock( $file, LOCK_EX ) ) { + fwrite( $file, $log_entry ); + fflush( $file ); + flock( $file, LOCK_UN ); + } + fclose( $file ); + } + } + + /** + * Deletes log file and creates a new one when the + * MAX_LOG_SIZE is met. + * + * @return void + */ + private function rotate_logs() { + if ( ! file_exists( $this->log_file_path ) ) { + return; + } + + $file_size = filesize( $this->log_file_path ); + if ( $file_size < self::MAX_LOG_SIZE ) { + return; + } + + unlink( $this->log_file_path ); + } + + /** + * Clears log file + * + * @return bool True if logs were cleared successfully. + */ + public function clear_logs() { + if ( file_exists( $this->log_file_path ) ) { + return unlink( $this->log_file_path ); + } + + return true; + } + + /** + * Gets all log file paths. + * + * @return array Array of log file paths. + */ + public function get_log_files() { + $files = array(); + + if ( file_exists( $this->log_file_path ) ) { + $files[] = $this->log_file_path; + } + + return $files; + } +} diff --git a/src/class-tiny-plugin.php b/src/class-tiny-plugin.php index f6ff1d1..8c9c05f 100644 --- a/src/class-tiny-plugin.php +++ b/src/class-tiny-plugin.php @@ -178,6 +178,7 @@ public function admin_init() { $this->tiny_compatibility(); add_thickbox(); + Tiny_Logger::init(); } public function admin_menu() { @@ -314,6 +315,11 @@ public function process_attachment( $metadata, $attachment_id ) { public function blocking_compress_on_upload( $metadata, $attachment_id ) { if ( ! empty( $metadata ) ) { $tiny_image = new Tiny_Image( $this->settings, $attachment_id, $metadata ); + + Tiny_Logger::debug('blocking compress on upload', array( + 'image_id' => $attachment_id, + )); + $result = $tiny_image->compress(); return $tiny_image->get_wp_metadata(); } else { @@ -354,6 +360,10 @@ public function async_compress_on_upload( $metadata, $attachment_id ) { set_transient( 'tiny_rpc_' . $rpc_hash, get_current_user_id(), 10 ); } + Tiny_Logger::debug('remote post', array( + 'image_id' => $attachment_id, + )); + if ( getenv( 'WORDPRESS_HOST' ) !== false ) { wp_remote_post( getenv( 'WORDPRESS_HOST' ) . '/wp-admin/admin-ajax.php', $args ); } else { @@ -406,6 +416,11 @@ public function compress_on_upload() { $metadata = $_POST['metadata']; if ( is_array( $metadata ) ) { $tiny_image = new Tiny_Image( $this->settings, $attachment_id, $metadata ); + + Tiny_Logger::debug('compress on upload', array( + 'image_id' => $attachment_id, + )); + $result = $tiny_image->compress(); // The wp_update_attachment_metadata call is thrown because the // dimensions of the original image can change. This will then @@ -469,8 +484,12 @@ public function compress_image_from_library() { echo $response['error']; exit(); } - list($id, $metadata) = $response['data']; + + Tiny_Logger::debug('compress from library', array( + 'image_id' => $id, + )); + $tiny_image = new Tiny_Image( $this->settings, $id, $metadata ); $result = $tiny_image->compress(); @@ -503,6 +522,11 @@ public function compress_image_for_bulk() { $size_before = $image_statistics_before['compressed_total_size']; $tiny_image = new Tiny_Image( $this->settings, $id, $metadata ); + + Tiny_Logger::debug('compress from bulk', array( + 'image_id' => $id, + )); + $result = $tiny_image->compress(); $image_statistics = $tiny_image->get_statistics( $this->settings->get_sizes(), diff --git a/src/class-tiny-settings.php b/src/class-tiny-settings.php index 19c3308..4793a6a 100644 --- a/src/class-tiny-settings.php +++ b/src/class-tiny-settings.php @@ -30,6 +30,7 @@ class Tiny_Settings extends Tiny_WP_Base { public function __construct() { parent::__construct(); $this->notices = new Tiny_Notices(); + new Tiny_Diagnostics( $this ); } private function init_compressor() { @@ -127,6 +128,9 @@ public function admin_init() { $field = self::get_prefixed_name( 'convert_format' ); register_setting( 'tinify', $field ); + + $field = self::get_prefixed_name( 'logging_enabled' ); + register_setting( 'tinify', $field ); } public function admin_menu() { diff --git a/src/css/admin.css b/src/css/admin.css index f87d6dc..977dadf 100644 --- a/src/css/admin.css +++ b/src/css/admin.css @@ -473,4 +473,12 @@ fieldset.tinypng_convert_fields label span { fieldset.tinypng_convert_fields[disabled] { opacity: 0.6; +} + +.tiny-d-flex { + display: flex; +} + +.tiny-mt-2 { + margin-top: 10px; } \ No newline at end of file diff --git a/src/js/admin.js b/src/js/admin.js index b8a972f..0ead0f4 100644 --- a/src/js/admin.js +++ b/src/js/admin.js @@ -1,4 +1,17 @@ (function() { + function downloadDiagnostics() { + try { + jQuery('#download-diagnostics-spinner').show(); + jQuery('#tiny-download-diagnostics').attr('disabled', true); + const downloadURL = `${ajaxurl}?action=tiny_download_diagnostics&security=${tinyCompress.nonce}`; + window.location.href = downloadURL; + } finally { + jQuery('#tiny-download-diagnostics').attr('disabled', false); + jQuery('#download-diagnostics-spinner').hide(); + } + } + jQuery('#tiny-download-diagnostics').click(downloadDiagnostics); + function compressImage(event) { var element = jQuery(event.target); var container = element.closest('div.tiny-ajax-container'); diff --git a/src/views/settings-diagnostics.php b/src/views/settings-diagnostics.php new file mode 100644 index 0000000..4a678a3 --- /dev/null +++ b/src/views/settings-diagnostics.php @@ -0,0 +1,33 @@ + + + + + + + + +
+

+ +

+

+ > + +

+
+ + +
+
diff --git a/src/views/settings.php b/src/views/settings.php index 07946ac..df06cb9 100644 --- a/src/views/settings.php +++ b/src/views/settings.php @@ -59,6 +59,9 @@ + + +

diff --git a/test/helpers/wordpress.php b/test/helpers/wordpress.php index 184b57a..6f684d9 100644 --- a/test/helpers/wordpress.php +++ b/test/helpers/wordpress.php @@ -1,16 +1,19 @@ values = array( + public function __construct() + { + $this->values = array( 'thumbnail_size_w' => 150, 'thumbnail_size_h' => 150, 'medium_size_w' => 300, @@ -19,26 +22,29 @@ public function __construct() { 'medium_large_size_h' => 0, 'large_size_w' => 1024, 'large_size_h' => 1024, - ); + ); } - public function set( $key, $value ) { - if ( preg_match( '#^(.+)\[(.+)\]$#', $key, $match ) ) { - if ( ! isset( $this->values[ $match[1] ] ) ) { - $this->values[ $match[1] ] = array(); + public function set($key, $value) + { + if (preg_match('#^(.+)\[(.+)\]$#', $key, $match)) { + if (! isset($this->values[$match[1]])) { + $this->values[$match[1]] = array(); } - $this->values[ $match[1] ][ $match[2] ] = $value; + $this->values[$match[1]][$match[2]] = $value; } else { - $this->values[ $key ] = $value; + $this->values[$key] = $value; } } - public function get( $key, $default = null ) { - return isset( $this->values[ $key ] ) ? $this->values[ $key ] : $default; + public function get($key, $default = null) + { + return isset($this->values[$key]) ? $this->values[$key] : $default; } } -class WordPressStubs { +class WordPressStubs +{ const UPLOAD_DIR = 'wp-content/uploads'; private $vfs; @@ -50,46 +56,59 @@ class WordPressStubs { private $stubs; private $filters; - public function __construct( $vfs ) { + public function __construct($vfs) + { $GLOBALS['wp'] = $this; + $GLOBALS['wpdb'] = $this; $this->vfs = $vfs; - $this->addMethod( 'add_action' ); - $this->addMethod( 'do_action' ); - $this->addMethod( 'add_filter' ); - $this->addMethod( 'apply_filters' ); - $this->addMethod( 'register_setting' ); - $this->addMethod( 'add_settings_section' ); - $this->addMethod( 'add_settings_field' ); - $this->addMethod( 'get_option' ); - $this->addMethod( 'get_site_option' ); - $this->addMethod( 'update_site_option' ); - $this->addMethod( 'get_post_meta' ); - $this->addMethod( 'update_post_meta' ); - $this->addMethod( 'get_intermediate_image_sizes' ); - $this->addMethod( 'add_image_size' ); - $this->addMethod( 'translate' ); - $this->addMethod( 'load_plugin_textdomain' ); - $this->addMethod( 'get_post_mime_type' ); - $this->addMethod( 'get_plugin_data' ); - $this->addMethod( 'wp_upload_dir' ); - $this->addMethod( 'get_site_url' ); - $this->addMethod( 'plugin_basename' ); - $this->addMethod( 'is_multisite' ); - $this->addMethod( 'current_user_can' ); - $this->addMethod( 'wp_get_attachment_metadata' ); - $this->addMethod( 'is_admin' ); - $this->addMethod( 'is_customize_preview' ); - $this->addMethod( 'is_plugin_active' ); + $this->addMethod('add_action'); + $this->addMethod('do_action'); + $this->addMethod('add_filter'); + $this->addMethod('apply_filters'); + $this->addMethod('register_setting'); + $this->addMethod('add_settings_section'); + $this->addMethod('add_settings_field'); + $this->addMethod('get_option'); + $this->addMethod('get_site_option'); + $this->addMethod('update_site_option'); + $this->addMethod('get_post_meta'); + $this->addMethod('update_post_meta'); + $this->addMethod('get_intermediate_image_sizes'); + $this->addMethod('add_image_size'); + $this->addMethod('translate'); + $this->addMethod('load_plugin_textdomain'); + $this->addMethod('get_post_mime_type'); + $this->addMethod('get_plugin_data'); + $this->addMethod('wp_upload_dir'); + $this->addMethod('get_site_url'); + $this->addMethod('plugin_basename'); + $this->addMethod('is_multisite'); + $this->addMethod('current_user_can'); + $this->addMethod('wp_get_attachment_metadata'); + $this->addMethod('is_admin'); + $this->addMethod('is_customize_preview'); + $this->addMethod('is_plugin_active'); + $this->addMethod('trailingslashit'); + $this->addMethod('current_time'); + $this->addMethod('wp_mkdir_p'); + $this->addMethod('db_version'); + $this->addMethod('wp_get_theme'); + $this->addMethod('get_home_url'); + $this->addMethod('get_locale'); + $this->addMethod('wp_timezone_string'); + $this->addMethod('update_option'); $this->defaults(); $this->create_filesystem(); } - public function create_filesystem() { - vfsStream::newDirectory( self::UPLOAD_DIR ) - ->at( $this->vfs ); + public function create_filesystem() + { + vfsStream::newDirectory(self::UPLOAD_DIR) + ->at($this->vfs); } - public function defaults() { + public function defaults() + { $this->initFunctions = array(); $this->admin_initFunctions = array(); $this->options = new WordPressOptions(); @@ -98,49 +117,56 @@ public function defaults() { $GLOBALS['_wp_additional_image_sizes'] = array(); } - public function call( $method, $args ) { - $this->calls[ $method ][] = $args; - if ( 'add_action' === $method ) { - if ( 'init' === $args[0] ) { + public function __call($method, $args) + { + return $this->call($method, $args); + } + + public function call($method, $args) + { + $mocks = new WordPressMocks(); + $this->calls[$method][] = $args; + if ('add_action' === $method) { + if ('init' === $args[0]) { $this->initFunctions[] = $args[1]; - } elseif ( 'admin_init' === $args[0] ) { + } elseif ('admin_init' === $args[0]) { $this->admin_initFunctions[] = $args[1]; } } // Allow explicit stubs to override defaults/behaviors - if ( isset( $this->stubs[ $method ] ) && $this->stubs[ $method ] ) { - return call_user_func_array( $this->stubs[ $method ], $args ); + if (isset($this->stubs[$method]) && $this->stubs[$method]) { + return call_user_func_array($this->stubs[$method], $args); } - if ( 'add_filter' === $method ) { - $tag = isset( $args[0] ) ? $args[0] : ''; - $function_to_add = isset( $args[1] ) ? $args[1] : ''; - $priority = isset( $args[2] ) ? intval( $args[2] ) : 10; - $accepted_args = isset( $args[3] ) ? intval( $args[3] ) : 1; - if ( ! isset( $this->filters[ $tag ] ) ) { - $this->filters[ $tag ] = array(); + if ('add_filter' === $method) { + $tag = isset($args[0]) ? $args[0] : ''; + $function_to_add = isset($args[1]) ? $args[1] : ''; + $priority = isset($args[2]) ? intval($args[2]) : 10; + $accepted_args = isset($args[3]) ? intval($args[3]) : 1; + if (! isset($this->filters[$tag])) { + $this->filters[$tag] = array(); } - if ( ! isset( $this->filters[ $tag ][ $priority ] ) ) { - $this->filters[ $tag ][ $priority ] = array(); + if (! isset($this->filters[$tag][$priority])) { + $this->filters[$tag][$priority] = array(); } - $this->filters[ $tag ][ $priority ][] = array( + $this->filters[$tag][$priority][] = array( 'function' => $function_to_add, 'accepted_args' => $accepted_args, ); return true; } - if ( 'apply_filters' === $method ) { - $tag = isset( $args[0] ) ? $args[0] : ''; + if ('apply_filters' === $method) { + $tag = isset($args[0]) ? $args[0] : ''; // $value is the first value passed to filters - $value = isset( $args[1] ) ? $args[1] : null; - $call_args = array_slice( $args, 1 ); - if ( isset( $this->filters[ $tag ] ) ) { - $priorities = array_keys( $this->filters[ $tag ] ); - sort( $priorities, SORT_NUMERIC ); - foreach ( $priorities as $priority ) { - foreach ( $this->filters[ $tag ][ $priority ] as $callback ) { - $accepted = max( 1, intval( $callback['accepted_args'] ) ); - $args_to_pass = array_slice( $call_args, 0, $accepted ); - $returned = call_user_func_array( $callback['function'], $args_to_pass ); + $value = isset($args[1]) ? $args[1] : null; + $call_args = array_slice($args, 1); + if (isset($this->filters[$tag])) { + $priorities = array_keys($this->filters[$tag]); + sort($priorities, SORT_NUMERIC); + foreach ($priorities as $priority) { + foreach ($this->filters[$tag][$priority] as $callback) { + $accepted = max(1, intval($callback['accepted_args'])); + $args_to_pass = array_slice($call_args, 0, $accepted); + $returned = call_user_func_array($callback['function'], $args_to_pass); // Filters should return the (possibly modified) value as first argument. $call_args[0] = $returned; } @@ -148,116 +174,132 @@ public function call( $method, $args ) { } return $call_args[0]; } - if ( 'translate' === $method ) { + if ('translate' === $method) { return $args[0]; - } elseif ( 'get_option' === $method ) { - return call_user_func_array( array( $this->options, 'get' ), $args ); - } elseif ( 'get_post_meta' === $method ) { - return call_user_func_array( array( $this, 'getMetadata' ), $args ); - } elseif ( 'add_image_size' === $method ) { - return call_user_func_array( array( $this, 'addImageSize' ), $args ); - } elseif ( 'update_post_meta' === $method ) { - return call_user_func_array( array( $this, 'updateMetadata' ), $args ); - } elseif ( 'get_intermediate_image_sizes' === $method ) { - return array_merge( array( 'thumbnail', 'medium', 'medium_large', 'large' ), array_keys( $GLOBALS['_wp_additional_image_sizes'] ) ); - } elseif ( 'get_plugin_data' === $method ) { - return array( 'Version' => '1.7.2' ); - } elseif ( 'plugin_basename' === $method ) { + } elseif ('get_option' === $method) { + return call_user_func_array(array($this->options, 'get'), $args); + } elseif ('get_post_meta' === $method) { + return call_user_func_array(array($this, 'getMetadata'), $args); + } elseif ('add_image_size' === $method) { + return call_user_func_array(array($this, 'addImageSize'), $args); + } elseif ('update_post_meta' === $method) { + return call_user_func_array(array($this, 'updateMetadata'), $args); + } elseif ('get_intermediate_image_sizes' === $method) { + return array_merge(array('thumbnail', 'medium', 'medium_large', 'large'), array_keys($GLOBALS['_wp_additional_image_sizes'])); + } elseif ('get_plugin_data' === $method) { + return array('Version' => '1.7.2'); + } elseif ('plugin_basename' === $method) { return 'tiny-compress-images'; - } elseif ( 'wp_upload_dir' === $method ) { - return array( 'basedir' => $this->vfs->url() . '/' . self::UPLOAD_DIR, 'baseurl' => '/' . self::UPLOAD_DIR ); - } elseif ( 'is_admin' === $method ) { + } elseif ('wp_upload_dir' === $method) { + return array('basedir' => $this->vfs->url() . '/' . self::UPLOAD_DIR, 'baseurl' => '/' . self::UPLOAD_DIR); + } elseif ('is_admin' === $method) { return true; + } elseif (method_exists($mocks, $method)) { + return call_user_func_array(array($mocks, $method), $args); } } - public function addMethod( $method ) { - $this->calls[ $method ] = array(); - $this->stubs[ $method ] = array(); - if ( ! function_exists( $method ) ) { - eval( "function $method() { return \$GLOBALS['wp']->call('$method', func_get_args()); }" ); + public function addMethod($method) + { + $this->calls[$method] = array(); + $this->stubs[$method] = array(); + if (! function_exists($method)) { + eval("function $method() { return \$GLOBALS['wp']->call('$method', func_get_args()); }"); } } - public function addOption( $key, $value ) { - $this->options->set( $key, $value ); + public function addOption($key, $value) + { + $this->options->set($key, $value); } - public function addImageSize( $size, $values ) { - $GLOBALS['_wp_additional_image_sizes'][ $size ] = $values; + public function addImageSize($size, $values) + { + $GLOBALS['_wp_additional_image_sizes'][$size] = $values; } - public function getMetadata( $id, $key, $single = false ) { - $values = isset( $this->metadata[ $id ] ) ? $this->metadata[ $id ] : array(); - $value = isset( $values[ $key ] ) ? $values[ $key ] : ''; - return $single ? $value : array( $value ); + public function getMetadata($id, $key, $single = false) + { + $values = isset($this->metadata[$id]) ? $this->metadata[$id] : array(); + $value = isset($values[$key]) ? $values[$key] : ''; + return $single ? $value : array($value); } - public function updateMetadata( $id, $key, $values ) { - $this->metadata[ $id ][ $key ] = $values; + public function updateMetadata($id, $key, $values) + { + $this->metadata[$id][$key] = $values; } - public function setTinyMetadata( $id, $values ) { - $this->metadata[ $id ] = array( Tiny_Config::META_KEY => $values ); + public function setTinyMetadata($id, $values) + { + $this->metadata[$id] = array(Tiny_Config::META_KEY => $values); } - public function getCalls( $method ) { - return $this->calls[ $method ]; + public function getCalls($method) + { + return $this->calls[$method]; } - public function init() { - foreach ( $this->initFunctions as $func ) { - call_user_func( $func ); + public function init() + { + foreach ($this->initFunctions as $func) { + call_user_func($func); } } - public function admin_init() { - foreach ( $this->admin_initFunctions as $func ) { - call_user_func( $func ); + public function admin_init() + { + foreach ($this->admin_initFunctions as $func) { + call_user_func($func); } } - public function stub( $method, $func ) { - $this->stubs[ $method ] = $func; + public function stub($method, $func) + { + $this->stubs[$method] = $func; } - public function createImage( $file_size, $path, $name ) { - if ( ! $this->vfs->hasChild( self::UPLOAD_DIR . "/$path" ) ) { - vfsStream::newDirectory( self::UPLOAD_DIR . "/$path" )->at( $this->vfs ); + public function createImage($file_size, $path, $name) + { + if (! $this->vfs->hasChild(self::UPLOAD_DIR . "/$path")) { + vfsStream::newDirectory(self::UPLOAD_DIR . "/$path")->at($this->vfs); } - $dir = $this->vfs->getChild( self::UPLOAD_DIR . "/$path" ); + $dir = $this->vfs->getChild(self::UPLOAD_DIR . "/$path"); - vfsStream::newFile( $name ) - ->withContent( new LargeFileContent( $file_size ) ) - ->at( $dir ); + vfsStream::newFile($name) + ->withContent(new LargeFileContent($file_size)) + ->at($dir); } - public function createImages( $sizes = null, $original_size = 12345, $path = '14/01', $name = 'test' ) { - vfsStream::newDirectory( self::UPLOAD_DIR . "/$path" )->at( $this->vfs ); - $dir = $this->vfs->getChild( self::UPLOAD_DIR . '/' . $path ); + public function createImages($sizes = null, $original_size = 12345, $path = '14/01', $name = 'test') + { + vfsStream::newDirectory(self::UPLOAD_DIR . "/$path")->at($this->vfs); + $dir = $this->vfs->getChild(self::UPLOAD_DIR . '/' . $path); - vfsStream::newFile( "$name.png" ) - ->withContent( new LargeFileContent( $original_size ) ) - ->at( $dir ); + vfsStream::newFile("$name.png") + ->withContent(new LargeFileContent($original_size)) + ->at($dir); - if ( is_null( $sizes ) ) { - $sizes = array( 'thumbnail' => 100, 'medium' => 1000 , 'large' => 10000, 'post-thumbnail' => 1234 ); + if (is_null($sizes)) { + $sizes = array('thumbnail' => 100, 'medium' => 1000, 'large' => 10000, 'post-thumbnail' => 1234); } - foreach ( $sizes as $key => $size ) { - vfsStream::newFile( "$name-$key.png" ) - ->withContent( new LargeFileContent( $size ) ) - ->at( $dir ); + foreach ($sizes as $key => $size) { + vfsStream::newFile("$name-$key.png") + ->withContent(new LargeFileContent($size)) + ->at($dir); } } - public function createImagesFromJSON( $virtual_images ) { - foreach ( $virtual_images['images'] as $image ) { - self::createImage( $image['size'], $virtual_images['path'], $image['file'] ); + public function createImagesFromJSON($virtual_images) + { + foreach ($virtual_images['images'] as $image) { + self::createImage($image['size'], $virtual_images['path'], $image['file']); } } - public function getTestMetadata( $path = '14/01', $name = 'test' ) { + public function getTestMetadata($path = '14/01', $name = 'test') + { $metadata = array( 'file' => "$path/$name.png", 'width' => 4000, @@ -265,29 +307,169 @@ public function getTestMetadata( $path = '14/01', $name = 'test' ) { 'sizes' => array(), ); - $regex = '#^' . preg_quote( $name ) . '-([^.]+)[.](png|jpe?g)$#'; - $dir = $this->vfs->getChild( self::UPLOAD_DIR . "/$path" ); - foreach ( $dir->getChildren() as $child ) { + $regex = '#^' . preg_quote($name) . '-([^.]+)[.](png|jpe?g)$#'; + $dir = $this->vfs->getChild(self::UPLOAD_DIR . "/$path"); + foreach ($dir->getChildren() as $child) { $file = $child->getName(); - if ( preg_match( $regex, $file, $match ) ) { - $metadata['sizes'][ $match[1] ] = array( 'file' => $file ); + if (preg_match($regex, $file, $match)) { + $metadata['sizes'][$match[1]] = array('file' => $file); } } return $metadata; } + + /** + * Testhelper to easily assert if a hook has been invoked + * + * @param string $hookname name of the filter or action + * @param mixed $expected_args arguments to the hook + */ + public static function assertHook($hookname, $expected_args = null) + { + $hooks = array('add_action', 'add_filter'); + $found = false; + + foreach ($hooks as $method) { + if (! isset($GLOBALS['wp'])) { + break; + } + + foreach ($GLOBALS['wp']->getCalls($method) as $call) { + if (! isset($call[0]) || $call[0] !== $hookname) { + continue; + } + + if (is_null($expected_args)) { + $found = true; + break 2; + } + + if ($expected_args === array_slice($call, 1)[0]) { + $found = true; + break 2; + } + } + } + + $message = is_null($expected_args) + ? sprintf('Expected hook "%s" to be called.', $hookname) + : sprintf('Expected hook "%s" to be called with the given arguments.', $hookname); + + Assert::assertTrue($found, $message); + } } -class WP_HTTP_Proxy { - public function is_enabled() { +class WordPressMocks +{ + /** + * Mocked function for https://developer.wordpress.org/reference/functions/trailingslashit/ + * + * @return void + */ + public function trailingslashit($value) + { + return $value . '/'; + } + + /** + * Mocked function for https://developer.wordpress.org/reference/functions/current_time/ + * + * @return int|string + */ + public function current_time() + { + $dt = new DateTime('now'); + return $dt->format('Y-m-d H:i:s'); + } + + /** + * Mocked function for https://developer.wordpress.org/reference/functions/wp_mkdir_p/ + * + * @return bool + */ + public function wp_mkdir_p($dir) + { + mkdir($dir, 0755, true); + } + + /** + * https://developer.wordpress.org/reference/classes/wpdb/db_version/ + * + * @return string|null database version + */ + public function db_version() + { + return 'mysqlv'; + } + + /** + * https://developer.wordpress.org/reference/classes/wpdb/db_version/ + * + * @return string|null database version + */ + public function wp_get_theme() + { + return new class { + function get($val) + { + return $val; + } + }; + } + + /** + * https://developer.wordpress.org/reference/classes/wpdb/db_version/ + * + * @return string|null database version + */ + public function get_home_url() + { + return 'http://localhost'; + } + + /** + * https://developer.wordpress.org/reference/classes/wpdb/db_version/ + * + * @return string|null database version + */ + public function get_locale() + { + return 'gb_GB'; + } + /** + * https://developer.wordpress.org/reference/functions/wp_timezone_string/ + * + * @return string|null database version + */ + public function wp_timezone_string() + { + return 'timezone'; + } + /** + * https://developer.wordpress.org/reference/functions/wp_timezone_string/ + * + * @return void + */ + public function update_option() + { + } +} + +class WP_HTTP_Proxy +{ + public function is_enabled() + { return false; } } -function __( $text, $domain = 'default' ) { - return translate( $text, $domain ); +function __($text, $domain = 'default') +{ + return translate($text, $domain); } -function esc_html__( $text, $domain = 'default' ) { - return translate( $text, $domain ); -} \ No newline at end of file +function esc_html__($text, $domain = 'default') +{ + return translate($text, $domain); +} diff --git a/test/unit/TinyDiagnosticsTest.php b/test/unit/TinyDiagnosticsTest.php new file mode 100644 index 0000000..548934c --- /dev/null +++ b/test/unit/TinyDiagnosticsTest.php @@ -0,0 +1,35 @@ +collect_info(); + + // were just verifying the main structure + assertArrayHasKey('timestamp', $info); + assertArrayHasKey('site_info', $info); + assertArrayHasKey('active_plugins', $info); + assertArrayHasKey('tiny_info', $info); + assertArrayHasKey('image_sizes', $info); + } +} diff --git a/test/unit/TinyLoggerTest.php b/test/unit/TinyLoggerTest.php new file mode 100644 index 0000000..7140c0e --- /dev/null +++ b/test/unit/TinyLoggerTest.php @@ -0,0 +1,116 @@ +clear_logs(); + $logger->reset(); + } + + public function test_logger_always_has_one_instance() + { + $instance1 = Tiny_Logger::get_instance(); + $instance2 = Tiny_Logger::get_instance(); + assertEquals($instance1, $instance2, 'logger should be a singleton'); + } + + public function test_get_log_enabled_memoizes_log_enabled() + { + $this->wp->addOption('tinypng_logging_enabled', 'on'); + $logger = Tiny_Logger::get_instance(); + assertTrue($logger->get_log_enabled(), 'log should be enabled when tinypng_logging_enabled is on'); + } + + public function test_sets_log_path_on_construct() + { + $logger = Tiny_Logger::get_instance(); + assertEquals($logger->get_log_file_path(), 'vfs://root/wp-content/uploads/tiny-compress-logs/tiny-compress.log'); + } + + public function test_registers_save_update_when_log_enabled() + { + $logger = Tiny_Logger::get_instance(); + $logger->init(); + WordPressStubs::assertHook('pre_update_option_tinypng_logging_enabled', 'Tiny_Logger::on_save_log_enabled'); + } + + public function test_option_hook_updates_log_enabled() + { + $this->wp->addOption('tinypng_logging_enabled', false); + Tiny_Logger::init(); + $logger = Tiny_Logger::get_instance(); + + assertFalse($logger->get_log_enabled(), 'option is not set so should be false'); + + apply_filters('pre_update_option_tinypng_logging_enabled', 'on', null, ''); + + assertTrue($logger->get_log_enabled(), 'when option is updated, should be true'); + } + + public function test_will_not_log_if_disabled() + { + $this->wp->addOption('tinypng_logging_enabled', false); + $logger = Tiny_Logger::get_instance(); + + Tiny_Logger::error('This should not be logged'); + Tiny_Logger::debug('This should also not be logged'); + + $log_path = $logger->get_log_file_path(); + $log_exists = file_exists($log_path); + assertFalse($log_exists, 'log file should not exist when logging is disabled'); + } + + public function test_creates_log_when_log_is_enabled() + { + $this->wp->addOption('tinypng_logging_enabled', 'on'); + + $logger = Tiny_Logger::get_instance(); + $log_path = $logger->get_log_file_path(); + $log_exists = file_exists($log_path); + assertFalse($log_exists, 'log file should not exist initially'); + + Tiny_Logger::error('This should be logged'); + Tiny_Logger::debug('This should also be logged'); + + $log_path = $logger->get_log_file_path(); + $log_exists = file_exists($log_path); + assertTrue($log_exists, 'log file is created when logging is enabled'); + } + + public function test_removes_full_log_and_creates_new() + { + $this->wp->addOption('tinypng_logging_enabled', 'on'); + + $log_dir_path = 'wp-content/uploads/tiny-compress-logs'; + vfsStream::newDirectory($log_dir_path)->at($this->vfs); + $log_dir = $this->vfs->getChild($log_dir_path); + + vfsStream::newFile('tiny-compress.log') + ->withContent(LargeFileContent::withMegabytes(2.1)) + ->at($log_dir); + + $logger = Tiny_Logger::get_instance(); + + assertTrue(filesize($logger->get_log_file_path()) > 2097152, 'log file should be larger than 2MB'); + + Tiny_Logger::error('This should be logged'); + + assertTrue(filesize($logger->get_log_file_path()) < 1048576, 'log file rotated and less than 1MB'); + } +} diff --git a/test/unit/TinySettingsAdminTest.php b/test/unit/TinySettingsAdminTest.php index 0fdbf7f..d5577aa 100644 --- a/test/unit/TinySettingsAdminTest.php +++ b/test/unit/TinySettingsAdminTest.php @@ -20,6 +20,7 @@ public function test_admin_init_should_register_keys() { array( 'tinify', 'tinypng_resize_original' ), array( 'tinify', 'tinypng_preserve_data' ), array( 'tinify', 'tinypng_convert_format' ), + array( 'tinify', 'tinypng_logging_enabled' ), ), $this->wp->getCalls( 'register_setting' )); } diff --git a/test/unit/TinySettingsAjaxTest.php b/test/unit/TinySettingsAjaxTest.php index 4501fd0..40fb44b 100644 --- a/test/unit/TinySettingsAjaxTest.php +++ b/test/unit/TinySettingsAjaxTest.php @@ -3,37 +3,27 @@ require_once dirname( __FILE__ ) . '/TinyTestCase.php'; class Tiny_Settings_Ajax_Test extends Tiny_TestCase { - protected $subject; protected $notices; public function set_up() { parent::set_up(); - $this->subject = new Tiny_Settings(); - $this->notices = new Tiny_Notices(); - $this->subject->ajax_init(); } + + public function test_settings_ajax_init() { + $tiny_settings = new Tiny_Settings(); + $tiny_settings->ajax_init(); + + WordPressStubs::assertHook('wp_ajax_tiny_image_sizes_notice', array( $tiny_settings, 'image_sizes_notice' )); + WordPressStubs::assertHook('wp_ajax_tiny_account_status', array( $tiny_settings, 'account_status' )); + WordPressStubs::assertHook('wp_ajax_tiny_settings_create_api_key', array( $tiny_settings, 'create_api_key' )); + WordPressStubs::assertHook('wp_ajax_tiny_settings_update_api_key', array( $tiny_settings, 'update_api_key' )); + } + + public function test_notices_ajax_init() { + $tiny_notices = new Tiny_Notices(); + $tiny_notices->ajax_init(); - public function test_ajax_init_should_add_actions() { - $this->assertEquals(array( - array( 'init', array( $this->subject, 'init' ) ), - array( 'rest_api_init', array( $this->subject, 'rest_init' ) ), - array( 'admin_init', array( $this->subject, 'admin_init' ) ), - array( 'admin_menu', array( $this->subject, 'admin_menu' ) ), - array( 'init', array( $this->notices, 'init' ) ), - array( 'rest_api_init', array( $this->notices, 'rest_init' ) ), - array( 'admin_init', array( $this->notices, 'admin_init' ) ), - array( 'admin_menu', array( $this->notices, 'admin_menu' ) ), - array( 'init', array( $this->notices, 'init' ) ), - array( 'rest_api_init', array( $this->notices, 'rest_init' ) ), - array( 'admin_init', array( $this->notices, 'admin_init' ) ), - array( 'admin_menu', array( $this->notices, 'admin_menu' ) ), - array( 'wp_ajax_tiny_image_sizes_notice', array( $this->subject, 'image_sizes_notice' ) ), - array( 'wp_ajax_tiny_account_status', array( $this->subject, 'account_status' ) ), - array( 'wp_ajax_tiny_settings_create_api_key', array( $this->subject, 'create_api_key' ) ), - array( 'wp_ajax_tiny_settings_update_api_key', array( $this->subject, 'update_api_key' ) ), - ), - $this->wp->getCalls( 'add_action' ) - ); + WordPressStubs::assertHook('wp_ajax_tiny_dismiss_notice', array( $tiny_notices, 'dismiss' )); } } diff --git a/tiny-compress-images.php b/tiny-compress-images.php index 2efa12f..95f8514 100644 --- a/tiny-compress-images.php +++ b/tiny-compress-images.php @@ -14,6 +14,8 @@ require dirname( __FILE__ ) . '/src/class-tiny-php.php'; require dirname( __FILE__ ) . '/src/class-tiny-wp-base.php'; require dirname( __FILE__ ) . '/src/class-tiny-exception.php'; +require dirname( __FILE__ ) . '/src/class-tiny-logger.php'; +require dirname( __FILE__ ) . '/src/class-tiny-diagnostics.php'; require dirname( __FILE__ ) . '/src/class-tiny-compress.php'; require dirname( __FILE__ ) . '/src/class-tiny-bulk-optimization.php'; require dirname( __FILE__ ) . '/src/class-tiny-image-size.php';