diff --git a/src/js/_enqueues/wp/updates.js b/src/js/_enqueues/wp/updates.js index a994fda694..a11f47ab54 100644 --- a/src/js/_enqueues/wp/updates.js +++ b/src/js/_enqueues/wp/updates.js @@ -413,6 +413,31 @@ } }; + /** + * Sends a message from a modal to the main screen to update buttons in plugin cards. + * + * @since 6.5.0 + * + * @param {Object} data An object of data to use for the button. + * @param {string} data.slug The plugin's slug. + * @param {string} data.text The text to use for the button. + * @param {string} data.ariaLabel The value for the button's aria-label attribute. An empty string removes the attribute. + * @param {string=} data.status Optional. An identifier for the status. + * @param {string=} data.removeClasses Optional. A space-separated list of classes to remove from the button. + * @param {string=} data.addClasses Optional. A space-separated list of classes to add to the button. + * @param {string=} data.href Optional. The button's URL. + * @param {string=} data.pluginName Optional. The plugin's name. + * @param {string=} data.plugin Optional. The plugin file, relative to the plugins directory. + */ + wp.updates.setCardButtonStatus = function( data ) { + var target = window.parent === window ? null : window.parent; + + $.support.postMessage = !! window.postMessage; + if ( false !== $.support.postMessage && null !== target && -1 === window.parent.location.pathname.indexOf( 'index.php' ) ) { + target.postMessage( JSON.stringify( data ), window.location.origin ); + } + }; + /** * Decrements the update counts throughout the various menus. * @@ -452,7 +477,8 @@ */ wp.updates.updatePlugin = function( args ) { var $updateRow, $card, $message, message, - $adminBarUpdates = $( '#wp-admin-bar-updates' ); + $adminBarUpdates = $( '#wp-admin-bar-updates' ), + buttonText = __( 'Updating...' ); args = _.extend( { success: wp.updates.updatePluginSuccess, @@ -468,7 +494,7 @@ $updateRow.find( '.plugin-title strong' ).text() ); } else if ( 'plugin-install' === pagenow || 'plugin-install-network' === pagenow ) { - $card = $( '.plugin-card-' + args.slug ); + $card = $( '.plugin-card-' + args.slug + ', #plugin-information-footer' ); $message = $card.find( '.update-now' ).addClass( 'updating-message' ); message = sprintf( /* translators: %s: Plugin name and version. */ @@ -488,10 +514,22 @@ $message .attr( 'aria-label', message ) - .text( __( 'Updating...' ) ); + .text( buttonText ); $document.trigger( 'wp-plugin-updating', args ); + if ( 'plugin-information-footer' === $card.attr('id' ) ) { + wp.updates.setCardButtonStatus( + { + status: 'updating-plugin', + slug: args.slug, + addClasses: 'updating-message', + text: buttonText, + ariaLabel: message + } + ); + } + return wp.updates.ajax( 'update-plugin', args ); }; @@ -511,7 +549,13 @@ */ wp.updates.updatePluginSuccess = function( response ) { var $pluginRow, $updateMessage, newText, - $adminBarUpdates = $( '#wp-admin-bar-updates' ); + $adminBarUpdates = $( '#wp-admin-bar-updates' ), + buttonText = _x( 'Updated!', 'plugin' ), + ariaLabel = sprintf( + /* translators: %s: Plugin name and version. */ + _x( '%s updated!', 'plugin' ), + response.pluginName + ); if ( 'plugins' === pagenow || 'plugins-network' === pagenow ) { $pluginRow = $( 'tr[data-plugin="' + response.plugin + '"]' ) @@ -528,7 +572,7 @@ // Clear the "time to next auto-update" text. $pluginRow.find( '.auto-update-time' ).empty(); } else if ( 'plugin-install' === pagenow || 'plugin-install-network' === pagenow ) { - $updateMessage = $( '.plugin-card-' + response.slug ).find( '.update-now' ) + $updateMessage = $( '.plugin-card-' + response.slug + ', #plugin-information-footer' ).find( '.update-now' ) .removeClass( 'updating-message' ) .addClass( 'button-disabled updated-message' ); } @@ -536,19 +580,25 @@ $adminBarUpdates.removeClass( 'spin' ); $updateMessage - .attr( - 'aria-label', - sprintf( - /* translators: %s: Plugin name and version. */ - _x( '%s updated!', 'plugin' ), - response.pluginName - ) - ) - .text( _x( 'Updated!', 'plugin' ) ); + .attr( 'aria-label', ariaLabel ) + .text( buttonText ); wp.a11y.speak( __( 'Update completed successfully.' ) ); - wp.updates.decrementCount( 'plugin' ); + if ( 'plugin_install_from_iframe' !== $updateMessage.attr( 'id' ) ) { + wp.updates.decrementCount( 'plugin' ); + } else { + wp.updates.setCardButtonStatus( + { + status: 'updated-plugin', + slug: response.slug, + removeClasses: 'updating-message', + addClasses: 'button-disabled updated-message', + text: buttonText, + ariaLabel: ariaLabel + } + ); + } $document.trigger( 'wp-plugin-update-success', response ); }; @@ -567,7 +617,7 @@ * @param {string} response.errorMessage The error that occurred. */ wp.updates.updatePluginError = function( response ) { - var $pluginRow, $card, $message, errorMessage, + var $pluginRow, $card, $message, errorMessage, buttonText, ariaLabel, $adminBarUpdates = $( '#wp-admin-bar-updates' ); if ( ! wp.updates.isValidResponse( response, 'update' ) ) { @@ -608,28 +658,32 @@ $message.find( 'p' ).removeAttr( 'aria-label' ); } } else if ( 'plugin-install' === pagenow || 'plugin-install-network' === pagenow ) { - $card = $( '.plugin-card-' + response.slug ) - .addClass( 'plugin-card-update-failed' ) + buttonText = __( 'Update failed.' ); + + $card = $( '.plugin-card-' + response.slug + ', #plugin-information-footer' ) .append( wp.updates.adminNotice( { className: 'update-message notice-error notice-alt is-dismissible', message: errorMessage } ) ); + if ( $card.hasClass( 'plugin-card-' + response.slug ) ) { + $card.addClass( 'plugin-card-update-failed' ); + } + $card.find( '.update-now' ) - .text( __( 'Update failed.' ) ) + .text( buttonText ) .removeClass( 'updating-message' ); if ( response.pluginName ) { - $card.find( '.update-now' ) - .attr( - 'aria-label', - sprintf( - /* translators: %s: Plugin name and version. */ - _x( '%s update failed.', 'plugin' ), - response.pluginName - ) - ); + ariaLabel = sprintf( + /* translators: %s: Plugin name and version. */ + _x( '%s update failed.', 'plugin' ), + response.pluginName + ); + + $card.find( '.update-now' ).attr( 'aria-label', ariaLabel ); } else { + ariaLabel = ''; $card.find( '.update-now' ).removeAttr( 'aria-label' ); } @@ -652,6 +706,18 @@ wp.a11y.speak( errorMessage, 'assertive' ); + if ( 'plugin-information-footer' === $card.attr('id' ) ) { + wp.updates.setCardButtonStatus( + { + status: 'plugin-update-failed', + slug: response.slug, + removeClasses: 'updating-message', + text: buttonText, + ariaLabel: ariaLabel + } + ); + } + $document.trigger( 'wp-plugin-update-error', response ); }; @@ -668,8 +734,10 @@ * decorated with an abort() method. */ wp.updates.installPlugin = function( args ) { - var $card = $( '.plugin-card-' + args.slug ), - $message = $card.find( '.install-now' ); + var $card = $( '.plugin-card-' + args.slug + ', #plugin-information-footer' ), + $message = $card.find( '.install-now' ), + buttonText = __( 'Installing...' ), + ariaLabel; args = _.extend( { success: wp.updates.installPluginSuccess, @@ -684,17 +752,16 @@ $message.data( 'originaltext', $message.html() ); } + ariaLabel = sprintf( + /* translators: %s: Plugin name and version. */ + _x( 'Installing %s...', 'plugin' ), + $message.data( 'name' ) + ); + $message .addClass( 'updating-message' ) - .attr( - 'aria-label', - sprintf( - /* translators: %s: Plugin name and version. */ - _x( 'Installing %s...', 'plugin' ), - $message.data( 'name' ) - ) - ) - .text( __( 'Installing...' ) ); + .attr( 'aria-label', ariaLabel ) + .text( buttonText ); wp.a11y.speak( __( 'Installing... please wait.' ) ); @@ -703,6 +770,18 @@ $document.trigger( 'wp-plugin-installing', args ); + if ( 'plugin-information-footer' === $message.parent().attr( 'id' ) ) { + wp.updates.setCardButtonStatus( + { + status: 'installing-plugin', + slug: args.slug, + addClasses: 'updating-message', + text: buttonText, + ariaLabel: ariaLabel + } + ); + } + return wp.updates.ajax( 'install-plugin', args ); }; @@ -717,20 +796,19 @@ * @param {string} response.activateUrl URL to activate the just installed plugin. */ wp.updates.installPluginSuccess = function( response ) { - var $message = $( '.plugin-card-' + response.slug ).find( '.install-now' ); + var $message = $( '.plugin-card-' + response.slug + ', #plugin-information-footer' ).find( '.install-now' ), + buttonText = _x( 'Installed!', 'plugin' ), + ariaLabel = sprintf( + /* translators: %s: Plugin name and version. */ + _x( '%s installed!', 'plugin' ), + response.pluginName + ); $message .removeClass( 'updating-message' ) .addClass( 'updated-message installed button-disabled' ) - .attr( - 'aria-label', - sprintf( - /* translators: %s: Plugin name and version. */ - _x( '%s installed!', 'plugin' ), - response.pluginName - ) - ) - .text( _x( 'Installed!', 'plugin' ) ); + .attr( 'aria-label', ariaLabel ) + .text( buttonText ); wp.a11y.speak( __( 'Installation completed successfully.' ) ); @@ -738,37 +816,24 @@ if ( response.activateUrl ) { setTimeout( function() { - - // Transform the 'Install' button into an 'Activate' button. - $message.removeClass( 'install-now installed button-disabled updated-message' ) - .addClass( 'activate-now button-primary' ) - .attr( 'href', response.activateUrl ); - - if ( 'plugins-network' === pagenow ) { - $message - .attr( - 'aria-label', - sprintf( - /* translators: %s: Plugin name. */ - _x( 'Network Activate %s', 'plugin' ), - response.pluginName - ) - ) - .text( __( 'Network Activate' ) ); - } else { - $message - .attr( - 'aria-label', - sprintf( - /* translators: %s: Plugin name. */ - _x( 'Activate %s', 'plugin' ), - response.pluginName - ) - ) - .text( __( 'Activate' ) ); - } + wp.updates.checkPluginDependencies( { + slug: response.slug + } ); }, 1000 ); } + + if ( 'plugin-information-footer' === $message.parent().attr( 'id' ) ) { + wp.updates.setCardButtonStatus( + { + status: 'installed-plugin', + slug: response.slug, + removeClasses: 'updating-message', + addClasses: 'updated-message installed button-disabled', + text: buttonText, + ariaLabel: ariaLabel + } + ); + } }; /** @@ -783,8 +848,14 @@ * @param {string} response.errorMessage The error that occurred. */ wp.updates.installPluginError = function( response ) { - var $card = $( '.plugin-card-' + response.slug ), + var $card = $( '.plugin-card-' + response.slug + ', #plugin-information-footer' ), $button = $card.find( '.install-now' ), + buttonText = __( 'Installation failed.' ), + ariaLabel = sprintf( + /* translators: %s: Plugin name and version. */ + _x( '%s installation failed', 'plugin' ), + $button.data( 'name' ) + ), errorMessage; if ( ! wp.updates.isValidResponse( response, 'install' ) ) { @@ -817,21 +888,334 @@ $button .removeClass( 'updating-message' ).addClass( 'button-disabled' ) - .attr( - 'aria-label', - sprintf( - /* translators: %s: Plugin name and version. */ - _x( '%s installation failed', 'plugin' ), - $button.data( 'name' ) - ) - ) - .text( __( 'Installation failed.' ) ); + .attr( 'aria-label', ariaLabel ) + .text( buttonText ); wp.a11y.speak( errorMessage, 'assertive' ); + wp.updates.setCardButtonStatus( + { + status: 'plugin-install-failed', + slug: response.slug, + removeClasses: 'updating-message', + addClasses: 'button-disabled', + text: buttonText, + ariaLabel: ariaLabel + } + ); + $document.trigger( 'wp-plugin-install-error', response ); }; + /** + * Sends an Ajax request to the server to check a plugin's dependencies. + * + * @since 6.5.0 + * + * @param {Object} args Arguments. + * @param {string} args.slug Plugin identifier in the WordPress.org Plugin repository. + * @param {checkPluginDependenciesSuccess=} args.success Optional. Success callback. Default: wp.updates.checkPluginDependenciesSuccess + * @param {checkPluginDependenciesError=} args.error Optional. Error callback. Default: wp.updates.checkPluginDependenciesError + * @return {$.promise} A jQuery promise that represents the request, + * decorated with an abort() method. + */ + wp.updates.checkPluginDependencies = function( args ) { + args = _.extend( { + success: wp.updates.checkPluginDependenciesSuccess, + error: wp.updates.checkPluginDependenciesError + }, args ); + + wp.a11y.speak( __( 'Checking plugin dependencies... please wait.' ) ); + $document.trigger( 'wp-checking-plugin-dependencies', args ); + + return wp.updates.ajax( 'check_plugin_dependencies', args ); + }; + + /** + * Updates the UI appropriately after a successful plugin dependencies check. + * + * @since 6.5.0 + * + * @param {Object} response Response from the server. + * @param {string} response.slug Slug of the checked plugin. + * @param {string} response.pluginName Name of the checked plugin. + * @param {string} response.plugin The plugin file, relative to the plugins directory. + * @param {string} response.activateUrl URL to activate the just checked plugin. + */ + wp.updates.checkPluginDependenciesSuccess = function( response ) { + var $message = $( '.plugin-card-' + response.slug + ', #plugin-information-footer' ).find( '.install-now' ), + buttonText, ariaLabel; + + // Transform the 'Install' button into an 'Activate' button. + $message + .removeClass( 'install-now installed button-disabled updated-message' ) + .addClass( 'activate-now button-primary' ) + .attr( 'href', response.activateUrl ); + + wp.a11y.speak( __( 'Plugin dependencies check completed successfully.' ) ); + $document.trigger( 'wp-check-plugin-dependencies-success', response ); + + if ( 'plugins-network' === pagenow ) { + buttonText = _x( 'Network Activate' ); + ariaLabel = sprintf( + /* translators: %s: Plugin name. */ + _x( 'Network Activate %s', 'plugin' ), + response.pluginName + ); + + $message + .attr( 'aria-label', ariaLabel ) + .text( buttonText ); + } else { + buttonText = _x( 'Activate', 'plugin' ); + ariaLabel = sprintf( + /* translators: %s: Plugin name. */ + _x( 'Activate %s', 'plugin' ), + response.pluginName + ); + + $message + .attr( 'aria-label', ariaLabel ) + .attr( 'data-name', response.pluginName ) + .attr( 'data-slug', response.slug ) + .attr( 'data-plugin', response.plugin ) + .text( buttonText ); + } + + if ( 'plugin-information-footer' === $message.parent().attr( 'id' ) ) { + wp.updates.setCardButtonStatus( + { + status: 'dependencies-check-success', + slug: response.slug, + removeClasses: 'install-now installed button-disabled updated-message', + addClasses: 'activate-now button-primary', + text: buttonText, + ariaLabel: ariaLabel, + pluginName: response.pluginName, + plugin: response.plugin, + href: response.activateUrl + } + ); + } + }; + + /** + * Updates the UI appropriately after a failed plugin dependencies check. + * + * @since 6.5.0 + * + * @param {Object} response Response from the server. + * @param {string} response.slug Slug of the plugin to be checked. + * @param {string=} response.pluginName Optional. Name of the plugin to be checked. + * @param {string} response.errorCode Error code for the error that occurred. + * @param {string} response.errorMessage The error that occurred. + */ + wp.updates.checkPluginDependenciesError = function( response ) { + var $message = $( '.plugin-card-' + response.slug + ', #plugin-information-footer' ).find( '.install-now' ), + buttonText = __( 'Activate' ), + ariaLabel = sprintf( + /* translators: 1: Plugin name, 2. The reason the plugin cannot be activated. */ + _x( 'Cannot activate %1$s. %2$s', 'plugin' ), + response.pluginName, + response.errorMessage + ), + errorMessage; + + if ( ! wp.updates.isValidResponse( response, 'check-dependencies' ) ) { + return; + } + + errorMessage = sprintf( + /* translators: %s: Error string for a failed activation. */ + __( 'Activation failed: %s' ), + response.errorMessage + ); + + wp.a11y.speak( errorMessage, 'assertive' ); + $document.trigger( 'wp-check-plugin-dependencies-error', response ); + + $message + .removeClass( 'install-now installed updated-message' ) + .addClass( 'activate-now button-primary' ) + .attr( 'aria-label', ariaLabel ) + .text( buttonText ); + + if ( 'plugin-information-footer' === $message.parent().attr('id' ) ) { + wp.updates.setCardButtonStatus( + { + status: 'dependencies-check-failed', + slug: response.slug, + removeClasses: 'install-now installed updated-message', + addClasses: 'activate-now button-primary', + text: buttonText, + ariaLabel: ariaLabel + } + ); + } + }; + + /** + * Sends an Ajax request to the server to activate a plugin. + * + * @since 6.5.0 + * + * @param {Object} args Arguments. + * @param {string} args.name The name of the plugin. + * @param {string} args.slug Plugin identifier in the WordPress.org Plugin repository. + * @param {string} args.plugin The plugin file, relative to the plugins directory. + * @param {activatePluginSuccess=} args.success Optional. Success callback. Default: wp.updates.activatePluginSuccess + * @param {activatePluginError=} args.error Optional. Error callback. Default: wp.updates.activatePluginError + * @return {$.promise} A jQuery promise that represents the request, + * decorated with an abort() method. + */ + wp.updates.activatePlugin = function( args ) { + var $message = $( '.plugin-card-' + args.slug + ', #plugin-information-footer' ).find( '.activate-now, .activating-message' ); + + args = _.extend( { + success: wp.updates.activatePluginSuccess, + error: wp.updates.activatePluginError + }, args ); + + wp.a11y.speak( __( 'Activating... please wait.' ) ); + $document.trigger( 'wp-activating-plugin', args ); + + if ( 'plugin-information-footer' === $message.parent().attr( 'id' ) ) { + wp.updates.setCardButtonStatus( + { + status: 'activating-plugin', + slug: args.slug, + removeClasses: 'installed updated-message button-primary', + addClasses: 'activating-message', + text: _x( 'Activating...', 'plugin' ), + ariaLabel: sprintf( + /* translators: %s: Plugin name. */ + _x( 'Activating %s', 'plugin' ), + args.name + ) + } + ); + } + + return wp.updates.ajax( 'activate-plugin', args ); + }; + + /** + * Updates the UI appropriately after a successful plugin activation. + * + * @since 6.5.0 + * + * @param {Object} response Response from the server. + * @param {string} response.slug Slug of the activated plugin. + * @param {string} response.pluginName Name of the activated plugin. + * @param {string} response.plugin The plugin file, relative to the plugins directory. + */ + wp.updates.activatePluginSuccess = function( response ) { + var $message = $( '.plugin-card-' + response.slug + ', #plugin-information-footer' ).find( '.activating-message' ), + buttonText = _x( 'Activated!', 'plugin' ), + ariaLabel = sprintf( + /* translators: %s: The plugin name. */ + '%s activated successfully.', + response.pluginName + ); + + wp.a11y.speak( __( 'Activation completed successfully.' ) ); + $document.trigger( 'wp-plugin-activate-success', response ); + + $message + .removeClass( 'activating-message' ) + .addClass( 'activated-message button-disabled' ) + .attr( 'aria-label', ariaLabel ) + .text( buttonText ); + + if ( 'plugin-information-footer' === $message.parent().attr( 'id' ) ) { + wp.updates.setCardButtonStatus( + { + status: 'activated-plugin', + slug: response.slug, + removeClasses: 'activating-message', + addClasses: 'activated-message button-disabled', + text: buttonText, + ariaLabel: ariaLabel + } + ); + } + + setTimeout( function() { + $message.removeClass( 'activated-message' ) + .text( _x( 'Active', 'plugin' ) ); + + if ( 'plugin-information-footer' === $message.parent().attr( 'id' ) ) { + wp.updates.setCardButtonStatus( + { + status: 'plugin-active', + slug: response.slug, + removeClasses: 'activated-message', + text: _x( 'Active', 'plugin' ), + ariaLabel: sprintf( + /* translators: %s: The plugin name. */ + '%s is active.', + response.pluginName + ) + } + ); + } + }, 1000 ); + }; + + /** + * Updates the UI appropriately after a failed plugin activation. + * + * @since 6.5.0 + * + * @param {Object} response Response from the server. + * @param {string} response.slug Slug of the plugin to be activated. + * @param {string=} response.pluginName Optional. Name of the plugin to be activated. + * @param {string} response.errorCode Error code for the error that occurred. + * @param {string} response.errorMessage The error that occurred. + */ + wp.updates.activatePluginError = function( response ) { + var $message = $( '.plugin-card-' + response.slug + ', #plugin-information-footer' ).find( '.activating-message' ), + buttonText = __( 'Activation failed.' ), + ariaLabel = sprintf( + /* translators: %s: Plugin name. */ + _x( '%s activation failed', 'plugin' ), + response.pluginName + ), + errorMessage; + + if ( ! wp.updates.isValidResponse( response, 'activate' ) ) { + return; + } + + errorMessage = sprintf( + /* translators: %s: Error string for a failed activation. */ + __( 'Activation failed: %s' ), + response.errorMessage + ); + + wp.a11y.speak( errorMessage, 'assertive' ); + $document.trigger( 'wp-plugin-activate-error', response ); + + $message + .removeClass( 'install-now installed activating-message' ) + .addClass( 'button-disabled' ) + .attr( 'aria-label', ariaLabel ) + .text( buttonText ); + + if ( 'plugin-information-footer' === $message.parent().attr( 'id' ) ) { + wp.updates.setCardButtonStatus( + { + status: 'plugin-activation-failed', + slug: response.slug, + removeClasses: 'install-now installed activating-message', + addClasses: 'button-disabled', + text: buttonText, + ariaLabel: ariaLabel + } + ); + } + }; + /** * Updates the UI appropriately after a successful importer install. * @@ -1970,6 +2354,16 @@ errorMessage = __( 'Installation failed: %s' ); break; + case 'check-dependencies': + /* translators: %s: Error string for a failed dependencies check. */ + errorMessage = __( 'Dependencies check failed: %s' ); + break; + + case 'activate': + /* translators: %s: Error string for a failed activation. */ + errorMessage = __( 'Activation failed: %s' ); + break; + case 'delete': /* translators: %s: Error string for a failed deletion. */ errorMessage = __( 'Deletion failed: %s' ); @@ -2025,7 +2419,7 @@ }; $( function() { - var $pluginFilter = $( '#plugin-filter' ), + var $pluginFilter = $( '#plugin-filter, #plugin-information-footer' ), $bulkActionForm = $( '#bulk-action-form' ), $filesystemForm = $( '#request-filesystem-credentials-form' ), $filesystemModal = $( '#request-filesystem-credentials-dialog' ), @@ -2241,6 +2635,44 @@ } ); } ); + /** + * Click handler for plugin activations in plugin activation view. + * + * @since 6.5.0 + * + * @param {Event} event Event interface. + */ + $pluginFilter.on( 'click', '.activate-now', function( event ) { + var $activateButton = $( event.target ); + + event.preventDefault(); + + if ( $activateButton.hasClass( 'activating-message' ) || $activateButton.hasClass( 'button-disabled' ) ) { + return; + } + + $activateButton + .removeClass( 'activate-now button-primary' ) + .addClass( 'activating-message' ) + .attr( + 'aria-label', + sprintf( + /* translators: %s: Plugin name. */ + _x( 'Activating %s', 'plugin' ), + $activateButton.data( 'name' ) + ) + ) + .text( _x( 'Activating...', 'plugin' ) ); + + wp.updates.activatePlugin( + { + name: $activateButton.data( 'name' ), + slug: $activateButton.data( 'slug' ), + plugin: $activateButton.data( 'plugin' ) + } + ); + }); + /** * Click handler for importer plugins installs in the Import screen. * @@ -2766,35 +3198,6 @@ target.postMessage( JSON.stringify( update ), window.location.origin ); } ); - /** - * Click handler for installing a plugin from the details modal on `plugin-install.php`. - * - * @since 4.6.0 - * - * @param {Event} event Event interface. - */ - $( '#plugin_install_from_iframe' ).on( 'click', function( event ) { - var target = window.parent === window ? null : window.parent, - install; - - $.support.postMessage = !! window.postMessage; - - if ( false === $.support.postMessage || null === target || -1 !== window.parent.location.pathname.indexOf( 'index.php' ) ) { - return; - } - - event.preventDefault(); - - install = { - action: 'install-plugin', - data: { - slug: $( this ).data( 'slug' ) - } - }; - - target.postMessage( JSON.stringify( install ), window.location.origin ); - } ); - /** * Handles postMessage events. * @@ -2818,7 +3221,45 @@ return; } - if ( ! message || 'undefined' === typeof message.action ) { + if ( ! message ) { + return; + } + + if ( + 'undefined' !== typeof message.status && + 'undefined' !== typeof message.slug && + 'undefined' !== typeof message.text && + 'undefined' !== typeof message.ariaLabel + ) { + var $card = $( '.plugin-card-' + message.slug ), + $message = $card.find( '[data-slug="' + message.slug + '"]' ); + + if ( 'undefined' !== typeof message.removeClasses ) { + $message.removeClass( message.removeClasses ); + } + + if ( 'undefined' !== typeof message.addClasses ) { + $message.addClass( message.addClasses ); + } + + if ( '' === message.ariaLabel ) { + $message.removeAttr( 'aria-label' ); + } else { + $message.attr( 'aria-label', message.ariaLabel ); + } + + if ( 'dependencies-check-success' === message.status ) { + $message + .attr( 'data-name', message.pluginName ) + .attr( 'data-slug', message.slug ) + .attr( 'data-plugin', message.plugin ) + .attr( 'href', message.href ); + } + + $message.text( message.text ); + } + + if ( 'undefined' === typeof message.action || 'undefined' === typeof message.data.slug ) { return; } @@ -2832,10 +3273,6 @@ case 'install-plugin': case 'update-plugin': - /* jscs:disable requireCamelCaseOrUpperCaseIdentifiers */ - window.tb_remove(); - /* jscs:enable */ - message.data = wp.updates._addCallbacks( message.data, message.action ); wp.updates.queue.push( message ); diff --git a/src/wp-admin/admin-ajax.php b/src/wp-admin/admin-ajax.php index fb19110029..b6645fd5ca 100644 --- a/src/wp-admin/admin-ajax.php +++ b/src/wp-admin/admin-ajax.php @@ -117,6 +117,7 @@ $core_actions_post = array( 'parse-media-shortcode', 'destroy-sessions', 'install-plugin', + 'activate-plugin', 'update-plugin', 'crop-image', 'generate-password', @@ -169,6 +170,9 @@ add_action( 'wp_ajax_nopriv_generate-password', 'wp_ajax_nopriv_generate_passwor add_action( 'wp_ajax_nopriv_heartbeat', 'wp_ajax_nopriv_heartbeat', 1 ); +// Register Plugin Dependencies Ajax calls. +add_action( 'wp_ajax_check_plugin_dependencies', array( 'WP_Plugin_Dependencies', 'check_plugin_dependencies_during_ajax' ) ); + $action = $_REQUEST['action']; if ( is_user_logged_in() ) { diff --git a/src/wp-admin/css/common.css b/src/wp-admin/css/common.css index c86481e741..0ff5ec39cf 100644 --- a/src/wp-admin/css/common.css +++ b/src/wp-admin/css/common.css @@ -1500,6 +1500,22 @@ div.error { background-color: #f0f6fc; } +#plugin-information-footer .update-now:not(.button-disabled):before { + color: #d63638; + content: "\f463"; + display: inline-block; + font: normal 20px/1 dashicons; + margin: -3px 5px 0 -2px; + speak: never; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + vertical-align: middle; +} + +#plugin-information-footer .notice { + margin-top: -5px; +} + .update-message p:before, .updating-message p:before, .updated-message p:before, @@ -1507,7 +1523,9 @@ div.error { .button.updating-message:before, .button.updated-message:before, .button.installed:before, -.button.installing:before { +.button.installing:before, +.button.activating-message:before, +.button.activated-message:before { display: inline-block; font: normal 20px/1 'dashicons'; -webkit-font-smoothing: antialiased; @@ -1544,7 +1562,8 @@ div.error { .updating-message p:before, .import-php .updating-message:before, .button.updating-message:before, -.button.installing:before { +.button.installing:before, +.button.activating-message:before { color: #d63638; content: "\f463"; } @@ -1554,6 +1573,7 @@ div.error { .import-php .updating-message:before, .button.updating-message:before, .button.installing:before, +.button.activating-message:before, .plugins .column-auto-updates .dashicons-update.spin, .theme-overlay .theme-autoupdate .dashicons-update.spin { animation: rotation 2s infinite linear; @@ -1564,6 +1584,7 @@ div.error { .import-php .updating-message:before, .button.updating-message:before, .button.installing:before, + .button.activating-message:before, .plugins .column-auto-updates .dashicons-update.spin, .theme-overlay .theme-autoupdate .dashicons-update.spin { animation: none; @@ -1577,7 +1598,8 @@ div.error { /* Updated icon (check mark). */ .updated-message p:before, .installed p:before, -.button.updated-message:before { +.button.updated-message:before, +.button.activated-message:before { color: #68de7c; content: "\f147"; } @@ -1662,19 +1684,37 @@ p.auto-update-status { .button.updating-message:before, .button.updated-message:before, .button.installed:before, -.button.installing:before { +.button.installing:before, +.button.activated-message:before, +.button.activating-message:before { margin: 3px 5px 0 -2px; } -.button-primary.updating-message:before { +#plugin-information-footer .button.installed:before, +#plugin-information-footer .button.installing:before, +#plugin-information-footer .button.updating-message:before, +#plugin-information-footer .button.updated-message:before, +#plugin-information-footer .button.activated-message:before, +#plugin-information-footer .button.activating-message:before { + margin: 9px 5px 0 -2px; +} + +#plugin-information-footer .button.update-now.updating-message:before { + margin: -3px 5px 0 -2px; +} + +.button-primary.updating-message:before, +.button-primary.activating-message:before { color: #fff; } -.button-primary.updated-message:before { +.button-primary.updated-message:before, +.button-primary.activated-message:before { color: #9ec2e6; } -.button.updated-message { +.button.updated-message, +.button.activated-message { transition-property: border, background, color; transition-duration: .05s; transition-timing-function: ease-in-out; diff --git a/src/wp-admin/css/list-tables.css b/src/wp-admin/css/list-tables.css index 3c7c2c9656..ed7d458488 100644 --- a/src/wp-admin/css/list-tables.css +++ b/src/wp-admin/css/list-tables.css @@ -585,8 +585,7 @@ th.sorted.desc:hover .sorting-indicator.asc:before { z-index: 1; } -.check-column input:where(:not(:disabled)):hover, -.check-column:hover input:where(:not(:disabled)) { +.check-column .label-covers-full-cell:hover + input:not(:disabled) { box-shadow: 0 0 0 1px #2271b1; } @@ -1548,10 +1547,96 @@ div.action-links, line-height: 1.3; } -.plugin-card .name, .plugin-card .desc { - margin-left: 148px; /* icon + margin */ - margin-right: 128px; /* action links + margin */ + margin-inline: 0; +} + +.plugin-card .name, .plugin-card .desc > p { + margin-left: 148px; +} + +@media (min-width: 1101px) { + .plugin-card .name, .plugin-card .desc > p { + margin-right: 128px; + } +} + +@media (min-width: 481px) and (max-width: 781px) { + .plugin-card .name, .plugin-card .desc > p { + margin-right: 128px; + } +} + +.plugin-card .column-description { + display: flex; + flex-direction: column; + justify-content: flex-start; +} + +.plugin-card .column-description > p { + margin-top: 0; +} + +.plugin-card .column-description .authors { + order: 1; +} + +.plugin-card .column-description .plugin-dependencies { + order: 2; +} + +.plugin-card .column-description p:empty { + display: none; +} + +.plugin-card .plugin-dependencies { + background-color: #e5f5fa; + border-left: 3px solid #72aee6; + margin-bottom: .5em; + padding: 15px; +} + +.plugin-card .plugin-dependencies-explainer-text { + margin-block: 0; +} + +.plugin-card .plugin-dependency { + align-items: center; + display: flex; + flex-wrap: wrap; + margin-top: .5em; + column-gap: 1%; + row-gap: .5em; +} + +.plugin-card .plugin-dependency:nth-child(2), +.plugin-card .plugin-dependency:last-child { + margin-top: 1em; +} + +.plugin-card .plugin-dependency-name { + flex-basis: 74%; +} + +.plugin-card .plugin-dependency .more-details-link { + margin-left: auto; +} + +.rtl .plugin-card .plugin-dependency .more-details-link { + margin-right: auto; +} + +@media (max-width: 939px) { + .plugin-card .plugin-dependency-name { + flex-basis: 69%; + } + .plugin-card .plugin-dependency .more-details-link { + } +} + +.plugins #the-list .required-by, +.plugins #the-list .requires { + margin-top: 1em; } .plugin-card .action-links { diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php index b8e8246eb7..7541b28d51 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -4578,6 +4578,56 @@ function wp_ajax_install_plugin() { wp_send_json_success( $status ); } +/** + * Handles activating a plugin via AJAX. + * + * @since 6.5.0 + */ +function wp_ajax_activate_plugin() { + check_ajax_referer( 'updates' ); + + if ( empty( $_POST['name'] ) || empty( $_POST['slug'] ) || empty( $_POST['plugin'] ) ) { + wp_send_json_error( + array( + 'slug' => '', + 'pluginName' => '', + 'plugin' => '', + 'errorCode' => 'no_plugin_specified', + 'errorMessage' => __( 'No plugin specified.' ), + ) + ); + } + + $status = array( + 'activate' => 'plugin', + 'slug' => wp_unslash( $_POST['slug'] ), + 'pluginName' => wp_unslash( $_POST['name'] ), + 'plugin' => wp_unslash( $_POST['plugin'] ), + ); + + if ( ! current_user_can( 'activate_plugin', $status['plugin'] ) ) { + $status['errorMessage'] = __( 'Sorry, you are not allowed to activate plugins on this site.' ); + wp_send_json_error( $status ); + } + + if ( is_plugin_active( $status['plugin'] ) ) { + $status['errorMessage'] = sprintf( + /* translators: %s: Plugin name. */ + __( '%s is already active.' ), + $status['pluginName'] + ); + } + + $activated = activate_plugin( $status['plugin'] ); + + if ( is_wp_error( $activated ) ) { + $status['errorMessage'] = $activated->get_error_message(); + wp_send_json_error( $status ); + } + + wp_send_json_success( $status ); +} + /** * Handles updating a plugin via AJAX. * diff --git a/src/wp-admin/includes/class-plugin-upgrader.php b/src/wp-admin/includes/class-plugin-upgrader.php index 091cfebc18..97343fcf9d 100644 --- a/src/wp-admin/includes/class-plugin-upgrader.php +++ b/src/wp-admin/includes/class-plugin-upgrader.php @@ -155,6 +155,12 @@ class Plugin_Upgrader extends WP_Upgrader { // Force refresh of plugin update information. wp_clean_plugins_cache( $parsed_args['clear_update_cache'] ); + $all_plugin_data = get_option( 'plugin_data', array() ); + $plugin_file = $this->new_plugin_data['file']; + unset( $this->new_plugin_data['file'] ); + $all_plugin_data[ $plugin_file ] = $this->new_plugin_data; + update_option( 'plugin_data', $all_plugin_data ); + if ( $parsed_args['overwrite_package'] ) { /** * Fires when the upgrader has successfully overwritten a currently installed @@ -482,7 +488,16 @@ class Plugin_Upgrader extends WP_Upgrader { foreach ( $files as $file ) { $info = get_plugin_data( $file, false, false ); if ( ! empty( $info['Name'] ) ) { - $this->new_plugin_data = $info; + $basename = basename( $file ); + $dirname = basename( dirname( $file ) ); + + if ( '.' === $dirname ) { + $plugin_file = $basename; + } else { + $plugin_file = "$dirname/$basename"; + } + $this->new_plugin_data = ( $info ); + $this->new_plugin_data['file'] = $plugin_file; break; } } diff --git a/src/wp-admin/includes/class-wp-plugin-install-list-table.php b/src/wp-admin/includes/class-wp-plugin-install-list-table.php index 7823f00b70..132af29452 100644 --- a/src/wp-admin/includes/class-wp-plugin-install-list-table.php +++ b/src/wp-admin/includes/class-wp-plugin-install-list-table.php @@ -525,6 +525,8 @@ class WP_Plugin_Install_List_Table extends WP_List_Table { // Remove any HTML from the description. $description = strip_tags( $plugin['short_description'] ); + $description .= $this->get_dependencies_notice( $plugin ); + /** * Filters the plugin card description on the Add Plugins screen. * @@ -555,102 +557,7 @@ class WP_Plugin_Install_List_Table extends WP_List_Table { $action_links = array(); - if ( current_user_can( 'install_plugins' ) || current_user_can( 'update_plugins' ) ) { - $status = install_plugin_install_status( $plugin ); - - switch ( $status['status'] ) { - case 'install': - if ( $status['url'] ) { - if ( $compatible_php && $compatible_wp ) { - $action_links[] = sprintf( - '%s', - esc_attr( $plugin['slug'] ), - esc_url( $status['url'] ), - /* translators: %s: Plugin name and version. */ - esc_attr( sprintf( _x( 'Install %s now', 'plugin' ), $name ) ), - esc_attr( $name ), - __( 'Install Now' ) - ); - } else { - $action_links[] = sprintf( - '', - _x( 'Cannot Install', 'plugin' ) - ); - } - } - break; - - case 'update_available': - if ( $status['url'] ) { - if ( $compatible_php && $compatible_wp ) { - $action_links[] = sprintf( - '%s', - esc_attr( $status['file'] ), - esc_attr( $plugin['slug'] ), - esc_url( $status['url'] ), - /* translators: %s: Plugin name and version. */ - esc_attr( sprintf( _x( 'Update %s now', 'plugin' ), $name ) ), - esc_attr( $name ), - __( 'Update Now' ) - ); - } else { - $action_links[] = sprintf( - '', - _x( 'Cannot Update', 'plugin' ) - ); - } - } - break; - - case 'latest_installed': - case 'newer_installed': - if ( is_plugin_active( $status['file'] ) ) { - $action_links[] = sprintf( - '', - _x( 'Active', 'plugin' ) - ); - } elseif ( current_user_can( 'activate_plugin', $status['file'] ) ) { - if ( $compatible_php && $compatible_wp ) { - $button_text = __( 'Activate' ); - /* translators: %s: Plugin name. */ - $button_label = _x( 'Activate %s', 'plugin' ); - $activate_url = add_query_arg( - array( - '_wpnonce' => wp_create_nonce( 'activate-plugin_' . $status['file'] ), - 'action' => 'activate', - 'plugin' => $status['file'], - ), - network_admin_url( 'plugins.php' ) - ); - - if ( is_network_admin() ) { - $button_text = __( 'Network Activate' ); - /* translators: %s: Plugin name. */ - $button_label = _x( 'Network Activate %s', 'plugin' ); - $activate_url = add_query_arg( array( 'networkwide' => 1 ), $activate_url ); - } - - $action_links[] = sprintf( - '%3$s', - esc_url( $activate_url ), - esc_attr( sprintf( $button_label, $plugin['name'] ) ), - $button_text - ); - } else { - $action_links[] = sprintf( - '', - _x( 'Cannot Activate', 'plugin' ) - ); - } - } else { - $action_links[] = sprintf( - '', - _x( 'Installed', 'plugin' ) - ); - } - break; - } - } + $action_links[] = wp_get_plugin_action_button( $name, $plugin, $compatible_php, $compatible_wp ); $details_link = self_admin_url( 'plugin-install.php?tab=plugin-information&plugin=' . $plugin['slug'] . @@ -828,4 +735,90 @@ class WP_Plugin_Install_List_Table extends WP_List_Table { echo ''; } } + + /** + * Returns a notice containing a list of dependencies required by the plugin. + * + * @since 6.5.0 + * + * @param array $plugin_data An array of plugin data. See {@see plugins_api()} + * for the list of possible values. + * @return string A notice containing a list of dependencies required by the plugin, + * or an empty string if none is required. + */ + protected function get_dependencies_notice( $plugin_data ) { + if ( empty( $plugin_data['requires_plugins'] ) ) { + return ''; + } + + $no_name_markup = '
%s
%s%1$s %2$s
%3$s
%1$s %2$s
%3$s
' . sprintf( + /* translators: %s: Plugin name. */ + _x( 'Error: %s requires plugins that are not installed or activated.', 'plugin' ), + $plugin_headers['Name'] + ) . '
', + $unmet_dependencies + ); + } + return true; } diff --git a/src/wp-admin/plugin-install.php b/src/wp-admin/plugin-install.php index 571b9a9875..a8beb8249b 100644 --- a/src/wp-admin/plugin-install.php +++ b/src/wp-admin/plugin-install.php @@ -134,6 +134,10 @@ get_current_screen()->set_screen_reader_content( * WordPress Administration Template Header. */ require_once ABSPATH . 'wp-admin/admin-header.php'; + +WP_Plugin_Dependencies::display_admin_notice_for_unmet_dependencies(); +WP_Plugin_Dependencies::display_admin_notice_for_deactivated_dependents(); +WP_Plugin_Dependencies::display_admin_notice_for_circular_dependencies(); ?>%1$s
%3$s
', + __( 'These plugins cannot be activated because their requirements are invalid. ' ), + $circular_dependency_lines, + __( 'Please contact the plugin authors for more information.' ) + ), + array( + 'type' => 'warning', + 'paragraph_wrap' => false, + ) + ); + } + } + + /** + * Checks plugin dependencies after a plugin is installed via AJAX. + * + * @since 6.5.0 + */ + public static function check_plugin_dependencies_during_ajax() { + check_ajax_referer( 'updates' ); + + if ( empty( $_POST['slug'] ) ) { + wp_send_json_error( + array( + 'slug' => '', + 'pluginName' => '', + 'errorCode' => 'no_plugin_specified', + 'errorMessage' => __( 'No plugin specified.' ), + ) + ); + } + + $slug = sanitize_key( wp_unslash( $_POST['slug'] ) ); + $status = array( 'slug' => $slug ); + + self::get_plugins(); + self::get_plugin_dirnames(); + + if ( ! isset( self::$plugin_dirnames[ $slug ] ) ) { + $status['errorCode'] = 'plugin_not_installed'; + $status['errorMessage'] = __( 'The plugin is not installed.' ); + wp_send_json_error( $status ); + } + + $plugin_file = self::$plugin_dirnames[ $slug ]; + $status['pluginName'] = self::$plugins[ $plugin_file ]['Name']; + $status['plugin'] = $plugin_file; + + if ( current_user_can( 'activate_plugin', $plugin_file ) && is_plugin_inactive( $plugin_file ) ) { + $status['activateUrl'] = add_query_arg( + array( + '_wpnonce' => wp_create_nonce( 'activate-plugin_' . $plugin_file ), + 'action' => 'activate', + 'plugin' => $plugin_file, + ), + is_multisite() ? network_admin_url( 'plugins.php' ) : admin_url( 'plugins.php' ) + ); + } + + if ( is_multisite() && current_user_can( 'manage_network_plugins' ) ) { + $status['activateUrl'] = add_query_arg( array( 'networkwide' => 1 ), $status['activateUrl'] ); + } + + $dependencies = self::get_dependencies( $plugin_file ); + if ( empty( $dependencies ) ) { + $status['message'] = __( 'The plugin has no required plugins.' ); + wp_send_json_success( $status ); + } + + require_once ABSPATH . '/wp-admin/includes/plugin.php'; + + $inactive_dependencies = array(); + foreach ( $dependencies as $dependency ) { + if ( false === self::$plugin_dirnames[ $dependency ] || is_plugin_inactive( self::$plugin_dirnames[ $dependency ] ) ) { + $inactive_dependencies[] = $dependency; + } + } + + if ( ! empty( $inactive_dependencies ) ) { + $inactive_dependency_names = array_map( + function ( $dependency ) { + if ( isset( self::$dependency_api_data[ $dependency ]['Name'] ) ) { + $inactive_dependency_name = self::$dependency_api_data[ $dependency ]['Name']; + } else { + $inactive_dependency_name = $dependency; + } + return $inactive_dependency_name; + }, + $inactive_dependencies + ); + + $status['errorCode'] = 'inactive_dependencies'; + $status['errorMessage'] = sprintf( + /* translators: %s: A list of inactive dependency plugin names. */ + __( 'The following plugins must be activated first: %s.' ), + implode( ', ', $inactive_dependency_names ) + ); + $status['errorData'] = array_combine( $inactive_dependencies, $inactive_dependency_names ); + + wp_send_json_error( $status ); + } + + $status['message'] = __( 'All required plugins are installed and activated.' ); + wp_send_json_success( $status ); + } + + /** + * Gets data for installed plugins. + * + * @since 6.5.0 + * + * @return array An array of plugin data. + */ + protected static function get_plugins() { + if ( is_array( self::$plugins ) ) { + return self::$plugins; + } + + $all_plugin_data = get_option( 'plugin_data', array() ); + + if ( empty( $all_plugin_data ) ) { + require_once ABSPATH . '/wp-admin/includes/plugin.php'; + $all_plugin_data = get_plugins(); + } + + self::$plugins = $all_plugin_data; + + return self::$plugins; + } + + /** + * Reads and stores dependency slugs from a plugin's 'Requires Plugins' header. + * + * @since 6.5.0 + * + * @global WP_Filesystem_Base $wp_filesystem WordPress filesystem subclass. + */ + protected static function read_dependencies_from_plugin_headers() { + self::$dependencies = array(); + self::$dependency_slugs = array(); + self::$dependent_slugs = array(); + $plugins = self::get_plugins(); + foreach ( $plugins as $plugin => $header ) { + if ( '' === $header['RequiresPlugins'] ) { + continue; + } + + $dependency_slugs = self::sanitize_dependency_slugs( $header['RequiresPlugins'] ); + self::$dependencies[ $plugin ] = $dependency_slugs; + self::$dependency_slugs = array_merge( self::$dependency_slugs, $dependency_slugs ); + + $dependent_slug = self::convert_to_slug( $plugin ); + self::$dependent_slugs[ $plugin ] = $dependent_slug; + } + self::$dependency_slugs = array_unique( self::$dependency_slugs ); + } + + /** + * Sanitizes slugs. + * + * @since 6.5.0 + * + * @param string $slugs A comma-separated string of plugin dependency slugs. + * @return array An array of sanitized plugin dependency slugs. + */ + protected static function sanitize_dependency_slugs( $slugs ) { + $sanitized_slugs = array(); + $slugs = explode( ',', $slugs ); + + foreach ( $slugs as $slug ) { + $slug = trim( $slug ); + + /** + * Filters a plugin dependency's slug before matching to + * the WordPress.org slug format. + * + * Can be used to switch between free and premium plugin slugs, for example. + * + * @since 6.5.0 + * + * @param string $slug The slug. + */ + $slug = apply_filters( 'wp_plugin_dependencies_slug', $slug ); + + // Match to WordPress.org slug format. + if ( preg_match( '/^[a-z0-9]+(-[a-z0-9]+)*$/mu', $slug ) ) { + $sanitized_slugs[] = $slug; + } + } + $sanitized_slugs = array_unique( $sanitized_slugs ); + sort( $sanitized_slugs ); + + return $sanitized_slugs; + } + + /** + * Gets plugin filepaths for active plugins that depend on the dependency. + * + * Recurses for each dependent that is also a dependency. + * + * @param string $plugin_file The dependency's filepath, relative to the plugin directory. + * @return string[] An array of active dependent plugin filepaths, relative to the plugin directory. + */ + protected static function get_active_dependents_in_dependency_tree( $plugin_file ) { + $all_dependents = array(); + $dependents = self::get_dependents( self::convert_to_slug( $plugin_file ) ); + + if ( empty( $dependents ) ) { + return $all_dependents; + } + + require_once ABSPATH . '/wp-admin/includes/plugin.php'; + foreach ( $dependents as $dependent ) { + if ( is_plugin_active( $dependent ) ) { + $all_dependents[] = $dependent; + $all_dependents = array_merge( + $all_dependents, + self::get_active_dependents_in_dependency_tree( $dependent ) + ); + } + } + + return $all_dependents; + } + + /** + * Deactivates dependent plugins with unmet dependencies. + * + * @since 6.5.0 + */ + protected static function deactivate_dependents_with_unmet_dependencies() { + $dependents_to_deactivate = array(); + $circular_dependencies = array_reduce( + self::get_circular_dependencies(), + function ( $all_circular, $circular_pair ) { + return array_merge( $all_circular, $circular_pair ); + }, + array() + ); + + require_once ABSPATH . '/wp-admin/includes/plugin.php'; + foreach ( self::$dependencies as $dependent => $dependencies ) { + // Skip dependents that are no longer installed or aren't active. + if ( ! array_key_exists( $dependent, self::$plugins ) || is_plugin_inactive( $dependent ) ) { + continue; + } + + // Skip plugins within a circular dependency tree or plugins that have no unmet dependencies. + if ( in_array( $dependent, $circular_dependencies, true ) || ! self::has_unmet_dependencies( $dependent ) ) { + continue; + } + + $dependents_to_deactivate[] = $dependent; + + // Also add any plugins that rely on any of this plugin's dependents. + $dependents_to_deactivate = array_merge( + $dependents_to_deactivate, + self::get_active_dependents_in_dependency_tree( $dependent ) + ); + } + + $dependents_to_deactivate = array_unique( $dependents_to_deactivate ); + + deactivate_plugins( $dependents_to_deactivate ); + set_site_transient( 'wp_plugin_dependencies_deactivated_plugins', $dependents_to_deactivate, 10 ); + } + + /** + * Gets the filepath of installed dependencies. + * If a dependency is not installed, the filepath defaults to false. + * + * @since 6.5.0 + * + * @return array An array of install dependencies filepaths, relative to the plugins directory. + */ + protected static function get_dependency_filepaths() { + if ( is_array( self::$dependency_filepaths ) ) { + return self::$dependency_filepaths; + } + + self::$dependency_filepaths = array(); + + $plugin_dirnames = self::get_plugin_dirnames(); + foreach ( self::$dependency_slugs as $slug ) { + if ( isset( $plugin_dirnames[ $slug ] ) ) { + self::$dependency_filepaths[ $slug ] = $plugin_dirnames[ $slug ]; + continue; + } + + self::$dependency_filepaths[ $slug ] = false; + } + + return self::$dependency_filepaths; + } + + /** + * Retrieves and stores dependency plugin data from the WordPress.org Plugin API. + * + * @since 6.5.0 + * + * @global string $pagenow The filename of the current screen. + * + * @return array|void An array of dependency API data, or void on early exit. + */ + protected static function get_dependency_api_data() { + global $pagenow; + + if ( ! is_admin() || ( 'plugins.php' !== $pagenow && 'plugin-install.php' !== $pagenow ) ) { + return; + } + + if ( is_array( self::$dependency_api_data ) ) { + return self::$dependency_api_data; + } + + $plugins = self::get_plugins(); + self::$dependency_api_data = (array) get_site_transient( 'wp_plugin_dependencies_plugin_data' ); + foreach ( self::$dependency_slugs as $slug ) { + // Set transient for individual data, remove from self::$dependency_api_data if transient expired. + if ( ! get_site_transient( "wp_plugin_dependencies_plugin_timeout_{$slug}" ) ) { + unset( self::$dependency_api_data[ $slug ] ); + set_site_transient( "wp_plugin_dependencies_plugin_timeout_{$slug}", true, 12 * HOUR_IN_SECONDS ); + } + + if ( isset( self::$dependency_api_data[ $slug ] ) ) { + if ( false === self::$dependency_api_data[ $slug ] ) { + $dependency_file = self::get_dependency_filepath( $slug ); + + if ( false === $dependency_file ) { + self::$dependency_api_data[ $slug ] = array( 'Name' => $slug ); + } else { + self::$dependency_api_data[ $slug ] = array( 'Name' => $plugins[ $dependency_file ]['Name'] ); + } + continue; + } + + // Don't hit the Plugin API if data exists. + if ( ! empty( self::$dependency_api_data[ $slug ]['last_updated'] ) ) { + continue; + } + } + + if ( ! function_exists( 'plugins_api' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin-install.php'; + } + + $information = plugins_api( + 'plugin_information', + array( + 'slug' => $slug, + 'fields' => array( + 'short_description' => true, + 'icons' => true, + ), + ) + ); + + if ( is_wp_error( $information ) ) { + continue; + } + + self::$dependency_api_data[ $slug ] = (array) $information; + // plugins_api() returns 'name' not 'Name'. + self::$dependency_api_data[ $information->slug ]['Name'] = self::$dependency_api_data[ $information->slug ]['name']; + set_site_transient( 'wp_plugin_dependencies_plugin_data', self::$dependency_api_data, 0 ); + } + + // Remove from self::$dependency_api_data if slug no longer a dependency. + $differences = array_diff( array_keys( self::$dependency_api_data ), self::$dependency_slugs ); + foreach ( $differences as $difference ) { + unset( self::$dependency_api_data[ $difference ] ); + } + + ksort( self::$dependency_api_data ); + // Remove empty elements. + self::$dependency_api_data = array_filter( self::$dependency_api_data ); + set_site_transient( 'wp_plugin_dependencies_plugin_data', self::$dependency_api_data, 0 ); + + return self::$dependency_api_data; + } + + /** + * Gets plugin directory names. + * + * @since 6.5.0 + * + * @return array An array of plugin directory names. + */ + protected static function get_plugin_dirnames() { + if ( is_array( self::$plugin_dirnames ) ) { + return self::$plugin_dirnames; + } + + self::$plugin_dirnames = array(); + + $plugin_files = array_keys( self::get_plugins() ); + foreach ( $plugin_files as $plugin_file ) { + $slug = self::convert_to_slug( $plugin_file ); + self::$plugin_dirnames[ $slug ] = $plugin_file; + } + + return self::$plugin_dirnames; + } + + /** + * Gets circular dependency data. + * + * @since 6.5.0 + * + * @return array[] An array of circular dependency pairings. + */ + protected static function get_circular_dependencies() { + if ( is_array( self::$circular_dependencies_pairs ) ) { + return self::$circular_dependencies_pairs; + } + + self::$circular_dependencies_slugs = array(); + + self::$circular_dependencies_pairs = array(); + foreach ( self::$dependencies as $dependent => $dependencies ) { + /* + * $dependent is in 'a/a.php' format. Dependencies are stored as slugs, i.e. 'a'. + * + * Convert $dependent to slug format for checking. + */ + $dependent_slug = self::convert_to_slug( $dependent ); + + self::$circular_dependencies_pairs = array_merge( + self::$circular_dependencies_pairs, + self::check_for_circular_dependencies( array( $dependent_slug ), $dependencies ) + ); + } + + return self::$circular_dependencies_pairs; + } + + /** + * Checks for circular dependencies. + * + * @since 6.5.0 + * + * @param array $dependents Array of dependent plugins. + * @param array $dependencies Array of plugins dependencies. + * @return array A circular dependency pairing, or an empty array if none exists. + */ + protected static function check_for_circular_dependencies( $dependents, $dependencies ) { + $circular_dependencies_pairs = array(); + + // Check for a self-dependency. + $dependents_location_in_its_own_dependencies = array_intersect( $dependents, $dependencies ); + if ( ! empty( $dependents_location_in_its_own_dependencies ) ) { + foreach ( $dependents_location_in_its_own_dependencies as $self_dependency ) { + self::$circular_dependencies_slugs[] = $self_dependency; + $circular_dependencies_pairs[] = array( $self_dependency, $self_dependency ); + + // No need to check for itself again. + unset( $dependencies[ array_search( $self_dependency, $dependencies, true ) ] ); + } + } + + /* + * Check each dependency to see: + * 1. If it has dependencies. + * 2. If its list of dependencies includes one of its own dependents. + */ + foreach ( $dependencies as $dependency ) { + // Check if the dependency is also a dependent. + $dependency_location_in_dependents = array_search( $dependency, self::$dependent_slugs, true ); + + if ( false !== $dependency_location_in_dependents ) { + $dependencies_of_the_dependency = self::$dependencies[ $dependency_location_in_dependents ]; + + foreach ( $dependents as $dependent ) { + // Check if its dependencies includes one of its own dependents. + $dependent_location_in_dependency_dependencies = array_search( + $dependent, + $dependencies_of_the_dependency, + true + ); + + if ( false !== $dependent_location_in_dependency_dependencies ) { + self::$circular_dependencies_slugs[] = $dependent; + self::$circular_dependencies_slugs[] = $dependency; + $circular_dependencies_pairs[] = array( $dependent, $dependency ); + + // Remove the dependent from its dependency's dependencies. + unset( $dependencies_of_the_dependency[ $dependent_location_in_dependency_dependencies ] ); + } + } + + $dependents[] = $dependency; + + /* + * Now check the dependencies of the dependency's dependencies for the dependent. + * + * Yes, that does make sense. + */ + $circular_dependencies_pairs = array_merge( + $circular_dependencies_pairs, + self::check_for_circular_dependencies( $dependents, array_unique( $dependencies_of_the_dependency ) ) + ); + } + } + + return $circular_dependencies_pairs; + } + + /** + * Converts a plugin filepath to a slug. + * + * @since 6.5.0 + * + * @param string $plugin_file The plugin's filepath, relative to the plugins directory. + * @return string The plugin's slug. + */ + protected static function convert_to_slug( $plugin_file ) { + if ( 'hello.php' === $plugin_file ) { + return 'hello-dolly'; + } + return str_contains( $plugin_file, '/' ) ? dirname( $plugin_file ) : str_replace( '.php', '', $plugin_file ); + } +} diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index 416d429207..368315b482 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -3359,7 +3359,7 @@ function wp_get_image_mime( $file ) { * specification and the AV1-AVIF spec, see https://aomediacodec.github.io/av1-avif/v1.1.0.html#brands. */ - // Divide the header string into 4 byte groups. + // Divide the header string into 4 byte groups. $magic = str_split( $magic, 8 ); if ( diff --git a/src/wp-includes/load.php b/src/wp-includes/load.php index 6c28453b21..78961bccec 100644 --- a/src/wp-includes/load.php +++ b/src/wp-includes/load.php @@ -986,6 +986,7 @@ function wp_get_active_and_valid_plugins() { $network_plugins = is_multisite() ? wp_get_active_network_plugins() : false; + $invalid_plugins = array(); foreach ( $active_plugins as $plugin ) { if ( ! validate_file( $plugin ) // $plugin must validate as file. && str_ends_with( $plugin, '.php' ) // $plugin must end with '.php'. @@ -994,6 +995,20 @@ function wp_get_active_and_valid_plugins() { && ( ! $network_plugins || ! in_array( WP_PLUGIN_DIR . '/' . $plugin, $network_plugins, true ) ) ) { $plugins[] = WP_PLUGIN_DIR . '/' . $plugin; + } else { + $invalid_plugins[] = $plugin; + } + } + + if ( ! empty( $invalid_plugins ) ) { + $all_plugin_data = get_option( 'plugin_data', array() ); + + if ( ! empty( $all_plugin_data ) ) { + foreach ( $invalid_plugins as $invalid_plugin ) { + unset( $all_plugin_data[ $invalid_plugin ] ); + } + + update_option( 'plugin_data', $all_plugin_data ); } } @@ -1190,6 +1205,7 @@ function is_protected_ajax_action() { 'search-install-plugins', // Searching for a plugin in the plugin install screen. 'update-plugin', // Update an existing plugin. 'update-theme', // Update an existing theme. + 'activate-plugin', // Activating an existing plugin. ); /** diff --git a/src/wp-settings.php b/src/wp-settings.php index 22683b37d1..c881eaa5bf 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -412,6 +412,12 @@ wp_plugin_directory_constants(); $GLOBALS['wp_plugin_paths'] = array(); +// Load and initialize WP_Plugin_Dependencies. +require_once ABSPATH . WPINC . '/class-wp-plugin-dependencies.php'; +if ( ! defined( 'WP_RUN_CORE_TESTS' ) ) { + WP_Plugin_Dependencies::initialize(); +} + // Load must-use plugins. foreach ( wp_get_mu_plugins() as $mu_plugin ) { $_wp_plugin_file = $mu_plugin; @@ -486,7 +492,91 @@ if ( ! is_multisite() && wp_is_fatal_error_handler_enabled() ) { } // Load active plugins. +$all_plugin_data = get_option( 'plugin_data', array() ); +$failed_plugins = array(); +$update_plugin_data = false; foreach ( wp_get_active_and_valid_plugins() as $plugin ) { + $plugin_file = str_replace( trailingslashit( WP_PLUGIN_DIR ), '', $plugin ); + if ( ! isset( $all_plugin_data[ $plugin_file ] ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + $all_plugin_data[ $plugin_file ] = get_plugin_data( WP_PLUGIN_DIR . "/$plugin_file" ); + + $update_plugin_data = true; + } + + $plugin_headers = $all_plugin_data[ $plugin_file ]; + $errors = array(); + $requirements = array( + 'requires' => ! empty( $plugin_headers['RequiresWP'] ) ? $plugin_headers['RequiresWP'] : '', + 'requires_php' => ! empty( $plugin_headers['RequiresPHP'] ) ? $plugin_headers['RequiresPHP'] : '', + 'requires_plugins' => ! empty( $plugin_headers['RequiresPlugins'] ) ? $plugin_headers['RequiresPlugins'] : '', + ); + $compatible_wp = is_wp_version_compatible( $requirements['requires'] ); + $compatible_php = is_php_version_compatible( $requirements['requires_php'] ); + $dependencies_met = ! WP_Plugin_Dependencies::has_unmet_dependencies( $plugin_file ); + + $php_update_message = '' . sprintf( + /* translators: %s: URL to Update PHP page. */ + __( 'Learn more about updating PHP.' ), + esc_url( wp_get_update_php_url() ) + ); + + $annotation = wp_get_update_php_annotation(); + + if ( $annotation ) { + $php_update_message .= '
' . $annotation . ''; + } + + if ( ! $dependencies_met ) { + $errors[] = sprintf( + /* translators: %s: The plugin's name. */ + _x( '%s has unmet dependencies.', 'plugin' ), + $plugin_headers['Name'] + ); + } + + if ( ! $compatible_wp && ! $compatible_php ) { + $errors[] = sprintf( + /* translators: 1: Current WordPress version, 2: Current PHP version, 3: Plugin name, 4: Required WordPress version, 5: Required PHP version. */ + _x( 'Error: Current versions of WordPress (%1$s) and PHP (%2$s) do not meet minimum requirements for %3$s. The plugin requires WordPress %4$s and PHP %5$s.', 'plugin' ), + get_bloginfo( 'version' ), + PHP_VERSION, + $plugin_headers['Name'], + $requirements['requires'], + $requirements['requires_php'] + ) . $php_update_message; + } elseif ( ! $compatible_php ) { + $errors[] = sprintf( + /* translators: 1: Current PHP version, 2: Plugin name, 3: Required PHP version. */ + _x( 'Error: Current PHP version (%1$s) does not meet minimum requirements for %2$s. The plugin requires PHP %3$s.', 'plugin' ), + PHP_VERSION, + $plugin_headers['Name'], + $requirements['requires_php'] + ) . $php_update_message; + } elseif ( ! $compatible_wp ) { + $errors[] = sprintf( + /* translators: 1: Current WordPress version, 2: Plugin name, 3: Required WordPress version. */ + _x( 'Error: Current WordPress version (%1$s) does not meet minimum requirements for %2$s. The plugin requires WordPress %3$s.', 'plugin' ), + get_bloginfo( 'version' ), + $plugin_headers['Name'], + $requirements['requires'] + ); + } + + if ( ! empty( $errors ) ) { + $failed_plugins[ $plugin_file ] = ''; + foreach ( $errors as $error ) { + $failed_plugins[ $plugin_file ] .= wp_get_admin_notice( + $error, + array( + 'type' => 'error', + 'dismissible' => true, + ) + ); + } + continue; + } + wp_register_plugin_realpath( $plugin ); $_wp_plugin_file = $plugin; @@ -504,6 +594,24 @@ foreach ( wp_get_active_and_valid_plugins() as $plugin ) { } unset( $plugin, $_wp_plugin_file ); +if ( $update_plugin_data ) { + update_option( 'plugin_data', $all_plugin_data ); +} + +if ( ! empty( $failed_plugins ) ) { + add_action( + 'admin_notices', + function () use ( $failed_plugins ) { + global $pagenow; + + if ( 'index.php' === $pagenow || 'plugins.php' === $pagenow ) { + echo implode( '', $failed_plugins ); + } + } + ); +} +unset( $failed_plugins ); + // Load pluggable functions. require ABSPATH . WPINC . '/pluggable.php'; require ABSPATH . WPINC . '/pluggable-deprecated.php'; diff --git a/tests/phpunit/tests/admin/plugin-dependencies/base.php b/tests/phpunit/tests/admin/plugin-dependencies/base.php new file mode 100644 index 0000000000..a35ed6f096 --- /dev/null +++ b/tests/phpunit/tests/admin/plugin-dependencies/base.php @@ -0,0 +1,126 @@ + null, + 'plugin_dirnames' => null, + 'dependencies' => null, + 'dependency_slugs' => null, + 'dependent_slugs' => null, + 'dependency_api_data' => null, + 'dependency_filepaths' => null, + 'circular_dependencies_pairs' => null, + 'circular_dependencies_slugs' => null, + ); + + /** + * An array of reflected class members. + * + * @var ReflectionMethod[]|ReflectionProperty[] + */ + protected static $reflected_members = array(); + + /** + * Sets up the WP_Plugin_Dependencies instance before any tests run. + */ + public static function set_up_before_class() { + parent::set_up_before_class(); + + self::$instance = new WP_Plugin_Dependencies(); + } + + /** + * Empties the '$reflected_members' property after all tests run. + */ + public static function tear_down_after_class() { + self::$reflected_members = array(); + + parent::tear_down_after_class(); + } + + /** + * Resets all static properties to a default value after each test. + */ + public function set_up() { + parent::set_up(); + + foreach ( self::$static_properties as $name => $default_value ) { + $this->set_property_value( $name, $default_value ); + } + } + + /** + * Temporarily modifies the accessibility of a property to change its value. + * + * @param string $property The property's name. + * @param mixed $value The new value. + */ + public function set_property_value( $property, $value ) { + if ( ! isset( self::$reflected_members[ $property ] ) ) { + self::$reflected_members[ $property ] = new ReflectionProperty( self::$instance, $property ); + } + + self::$reflected_members[ $property ]->setAccessible( true ); + self::$reflected_members[ $property ]->setValue( self::$instance, $value ); + self::$reflected_members[ $property ]->setAccessible( false ); + } + + /** + * Temporarily modifies the accessibility of a property to get its value. + * + * @param string $property The property's name. + * @return mixed The value of the property. + */ + public function get_property_value( $property ) { + if ( ! isset( self::$reflected_members[ $property ] ) ) { + self::$reflected_members[ $property ] = new ReflectionProperty( self::$instance, $property ); + } + + self::$reflected_members[ $property ]->setAccessible( true ); + $value = self::$reflected_members[ $property ]->getValue( self::$instance ); + self::$reflected_members[ $property ]->setAccessible( false ); + + return $value; + } + + /** + * Temporarily modifies the accessibility of a method to invoke it + * and return its result. + * + * @param string $method The method's name. + * @param mixed ...$args Arguments for the method. + * @return mixed The result of the method call. + */ + protected function call_method( $method, ...$args ) { + if ( ! isset( self::$reflected_members[ $method ] ) ) { + self::$reflected_members[ $method ] = new ReflectionMethod( self::$instance, $method ); + } + + self::$reflected_members[ $method ]->setAccessible( true ); + $value = self::$reflected_members[ $method ]->invokeArgs( self::$instance, $args ); + self::$reflected_members[ $method ]->setAccessible( false ); + + return $value; + } +} diff --git a/tests/phpunit/tests/admin/plugin-dependencies/getDependencies.php b/tests/phpunit/tests/admin/plugin-dependencies/getDependencies.php new file mode 100644 index 0000000000..ca9510493e --- /dev/null +++ b/tests/phpunit/tests/admin/plugin-dependencies/getDependencies.php @@ -0,0 +1,40 @@ +assertSame( array(), self::$instance::get_dependencies( 'dependent/dependent.php' ) ); + } + + /** + * Tests that a plugin with dependencies will return an array of dependencies. + * + * @ticket 22316 + */ + public function test_should_return_an_array_of_dependencies_when_a_plugin_has_dependencies() { + $expected = array( 'dependency', 'dependency2' ); + $this->set_property_value( + 'dependencies', + array( 'dependent/dependent.php' => $expected ) + ); + $this->assertSame( $expected, self::$instance::get_dependencies( 'dependent/dependent.php' ) ); + } +} diff --git a/tests/phpunit/tests/admin/plugin-dependencies/getDependencyData.php b/tests/phpunit/tests/admin/plugin-dependencies/getDependencyData.php new file mode 100644 index 0000000000..0e9f68b1dc --- /dev/null +++ b/tests/phpunit/tests/admin/plugin-dependencies/getDependencyData.php @@ -0,0 +1,73 @@ + 'Dependency 1' ); + $this->set_property_value( 'dependency_api_data', array( 'dependency' => $expected ) ); + + $actual = self::$instance::get_dependency_data( 'dependency' ); + + // Restore $pagenow. + $pagenow = $old_pagenow; + + $this->assertSame( $expected, $actual ); + } + + /** + * Tests that false is returned when no dependency data exists. + * + * @ticket 22316 + * + * @global string $pagenow The filename of the current screen. + */ + public function test_should_return_false_when_no_dependency_data_exists() { + global $pagenow; + + // Backup $pagenow. + $old_pagenow = $pagenow; + + // Ensure is_admin() and screen checks pass. + $pagenow = 'plugins.php'; + set_current_screen( 'plugins.php' ); + + $this->set_property_value( 'dependency_api_data', array() ); + + $actual = self::$instance::get_dependency_data( 'dependency' ); + + // Restore $pagenow. + $pagenow = $old_pagenow; + + $this->assertFalse( $actual ); + } +} diff --git a/tests/phpunit/tests/admin/plugin-dependencies/getDependencyFilepath.php b/tests/phpunit/tests/admin/plugin-dependencies/getDependencyFilepath.php new file mode 100644 index 0000000000..473609f0fb --- /dev/null +++ b/tests/phpunit/tests/admin/plugin-dependencies/getDependencyFilepath.php @@ -0,0 +1,135 @@ +set_property_value( 'plugins', $plugins ); + $this->assertNull( $this->get_property_value( 'dependency_filepaths' ) ); + self::$instance::initialize(); + + $this->assertSame( + $expected, + self::$instance::get_dependency_filepath( $dependency_slug ), + 'The incorrect filepath was returned.' + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_get_dependency_filepath() { + return array( + 'no plugins' => array( + 'dependency_slug' => 'dependency', + 'plugins' => array(), + 'expected' => false, + ), + 'a plugin that starts with slug/' => array( + 'dependency_slug' => 'dependency', + 'plugins' => array( + 'dependency-pro/dependency.php' => array( 'RequiresPlugins' => '' ), + 'dependent/dependent.php' => array( 'RequiresPlugins' => 'dependency' ), + ), + 'expected' => false, + ), + 'a plugin that ends with slug/' => array( + 'dependency_slugs' => 'dependency', + 'plugins' => array( + 'addon-for-dependency/dependency.php' => array( 'RequiresPlugins' => '' ), + 'dependent/dependent.php' => array( 'RequiresPlugins' => 'dependency' ), + ), + 'expected' => false, + ), + 'a plugin that does not exist' => array( + 'dependency_slugs' => 'dependency2', + 'plugins' => array( + 'dependency/dependency.php' => array( 'RequiresPlugins' => '' ), + 'dependent/dependent.php' => array( 'RequiresPlugins' => 'dependency2' ), + ), + 'expected' => false, + ), + 'a plugin that exists' => array( + 'dependency_slugs' => 'dependency', + 'plugins' => array( + 'dependency/dependency.php' => array( 'RequiresPlugins' => '' ), + 'dependent/dependent.php' => array( 'RequiresPlugins' => 'dependency' ), + ), + 'expected' => 'dependency/dependency.php', + ), + ); + } + + /** + * Tests that an existing value for dependency filepaths is returned. + * + * @ticket 22316 + */ + public function test_should_return_existing_value_for_dependency_filepaths() { + $expected = 'dependency/dependency.php'; + + $this->set_property_value( 'dependency_filepaths', array( 'dependency' => $expected ) ); + + /* + * If existing dependency filepaths are not returned, + * they'll be built from this data. + * + * This data is explicitly set to ensure that no + * test plugins ever interfere with this test. + */ + $this->set_property_value( + 'dependency_slugs', + array( 'dependency', 'dependency2', 'dependency3' ) + ); + + $this->set_property_value( + 'plugins', + array( + // This is flipped as paths are stored in the keys. + 'dependency/dependency.php' => array(), + 'dependency2/dependency2.php' => array(), + 'dependency3/dependency3.php' => array(), + ) + ); + + $this->assertSame( $expected, self::$instance::get_dependency_filepath( 'dependency' ) ); + } + + /** + * Tests that an empty array is returned when + * no plugin directory names are stored. + * + * @ticket 22316 + */ + public function test_should_return_empty_array_for_no_plugin_dirnames() { + $this->set_property_value( 'dependency_slugs', array() ); + $this->assertFalse( self::$instance::get_dependency_filepath( 'dependency' ) ); + } +} diff --git a/tests/phpunit/tests/admin/plugin-dependencies/getDependencyNames.php b/tests/phpunit/tests/admin/plugin-dependencies/getDependencyNames.php new file mode 100644 index 0000000000..bd6fac8c77 --- /dev/null +++ b/tests/phpunit/tests/admin/plugin-dependencies/getDependencyNames.php @@ -0,0 +1,224 @@ +set_property_value( + 'plugins', + array( 'dependent/dependent.php' => array( 'RequiresPlugins' => 'dependency, dependency2' ) ) + ); + + self::$instance::initialize(); + + $this->set_property_value( + 'dependency_filepaths', + array( + 'dependency' => 'dependency/dependency.php', + 'dependency2' => 'dependency2/dependency2.php', + ) + ); + + $this->set_property_value( + 'dependency_api_data', + array( + 'dependency' => array( + 'name' => 'Dependency 1', + ), + 'dependency2' => array( + 'name' => 'Dependency 2', + ), + ) + ); + + $actual = self::$instance::get_dependency_names( 'dependent/dependent.php' ); + + // Restore $pagenow. + $pagenow = $old_pagenow; + + $this->assertSame( + array( + 'dependency' => 'Dependency 1', + 'dependency2' => 'Dependency 2', + ), + $actual + ); + } + + /** + * Tests that dependency slugs are used if their name is not available. + * + * @ticket 22316 + * + * @global string $pagenow The filename of the current screen. + */ + public function test_should_use_dependency_name_from_file() { + global $pagenow; + + // Backup $pagenow. + $old_pagenow = $pagenow; + + // Ensure is_admin() and screen checks pass. + $pagenow = 'plugins.php'; + set_current_screen( 'plugins.php' ); + + $this->set_property_value( + 'plugins', + array( + 'dependent/dependent.php' => array( 'RequiresPlugins' => 'dependency, dependency2' ), + 'dependency/dependency.php' => array( + 'Name' => 'Dependency 1', + 'RequiresPlugins' => '', + ), + 'dependency2/dependency2.php' => array( + 'Name' => 'Dependency 2', + 'RequiresPlugins' => '', + ), + ) + ); + + self::$instance::initialize(); + + $this->set_property_value( + 'dependency_filepaths', + array( + 'dependency' => 'dependency/dependency.php', + 'dependency2' => 'dependency2/dependency2.php', + ) + ); + + // The plugins are not in the Plugins repository. + $this->set_property_value( 'dependency_api_data', array() ); + + $actual = self::$instance::get_dependency_names( 'dependent/dependent.php' ); + + // Restore $pagenow. + $pagenow = $old_pagenow; + + $this->assertSame( + array( + 'dependency' => 'Dependency 1', + 'dependency2' => 'Dependency 2', + ), + $actual + ); + } + + /** + * Tests that dependency slugs are used if their name is not available. + * + * @ticket 22316 + * + * @global string $pagenow The filename of the current screen. + */ + public function test_should_use_dependency_slugs() { + global $pagenow; + + // Backup $pagenow. + $old_pagenow = $pagenow; + + // Ensure is_admin() and screen checks pass. + $pagenow = 'plugins.php'; + set_current_screen( 'plugins.php' ); + + $this->set_property_value( + 'plugins', + array( 'dependent/dependent.php' => array( 'RequiresPlugins' => 'dependency, dependency2' ) ) + ); + + self::$instance::initialize(); + + // The plugins are not in the Plugins repository. + $this->set_property_value( 'dependency_api_data', array() ); + + $actual = self::$instance::get_dependency_names( 'dependent/dependent.php' ); + + // Restore $pagenow. + $pagenow = $old_pagenow; + + $this->assertSame( + array( + 'dependency' => 'dependency', + 'dependency2' => 'dependency2', + ), + $actual + ); + } + + /** + * Tests that `$dependency_api_data` is set when it's not already available. + * + * @ticket 22316 + * + * @global string $pagenow The filename of the current screen. + */ + public function test_should_set_dependency_data_when_not_already_available() { + global $pagenow; + + // Backup $pagenow. + $old_pagenow = $pagenow; + + // Ensure is_admin() and screen checks pass. + $pagenow = 'plugins.php'; + set_current_screen( 'plugins.php' ); + + $this->set_property_value( + 'plugins', + array( + 'dependent/dependent.php' => array( + 'Name' => 'Dependent 1', + 'RequiresPlugins' => 'dependency', + ), + 'dependency/dependency.php' => array( + 'Name' => 'Dependency 1', + 'RequiresPlugins' => '', + ), + ) + ); + + $this->set_property_value( 'dependency_slugs', array( 'dependency' ) ); + + set_site_transient( 'wp_plugin_dependencies_plugin_data', array( 'dependency' => false ) ); + set_site_transient( 'wp_plugin_dependencies_plugin_timeout_dependency', true, 12 * HOUR_IN_SECONDS ); + self::$instance::get_dependency_names( 'dependent' ); + + // Restore $pagenow. + $pagenow = $old_pagenow; + + $this->assertSame( + array( 'dependency' => array( 'Name' => 'Dependency 1' ) ), + $this->get_property_value( 'dependency_api_data' ) + ); + } +} diff --git a/tests/phpunit/tests/admin/plugin-dependencies/getDependentFilepath.php b/tests/phpunit/tests/admin/plugin-dependencies/getDependentFilepath.php new file mode 100644 index 0000000000..1cfdb30b13 --- /dev/null +++ b/tests/phpunit/tests/admin/plugin-dependencies/getDependentFilepath.php @@ -0,0 +1,75 @@ +set_property_value( 'plugins', $plugins ); + self::$instance::initialize(); + + $this->assertSame( + $expected, + self::$instance::get_dependent_filepath( $dependent_slug ), + 'The incorrect filepath was returned.' + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_get_dependent_filepath() { + return array( + 'a plugin that exists' => array( + 'dependent_slug' => 'dependent', + 'plugins' => array( 'dependent/dependent.php' => array( 'RequiresPlugins' => 'woocommerce' ) ), + 'expected' => 'dependent/dependent.php', + ), + 'no plugins' => array( + 'dependent_slug' => 'dependent', + 'plugins' => array(), + 'expected' => false, + ), + 'a plugin that starts with slug/' => array( + 'dependent_slug' => 'dependent', + 'plugins' => array( 'dependent-pro/dependent.php' => array( 'RequiresPlugins' => 'woocommerce' ) ), + 'expected' => false, + ), + 'a plugin that ends with slug/' => array( + 'dependent_slug' => 'dependent', + 'plugins' => array( 'not-dependent/not-dependent.php' => array( 'RequiresPlugins' => 'woocommerce' ) ), + 'expected' => false, + ), + 'a plugin that does not exist' => array( + 'dependent_slug' => 'dependent2', + 'plugins' => array( 'dependent/dependent.php' => array( 'RequiresPlugins' => 'woocommerce' ) ), + 'expected' => false, + ), + ); + } +} diff --git a/tests/phpunit/tests/admin/plugin-dependencies/getDependentNames.php b/tests/phpunit/tests/admin/plugin-dependencies/getDependentNames.php new file mode 100644 index 0000000000..751e588c21 --- /dev/null +++ b/tests/phpunit/tests/admin/plugin-dependencies/getDependentNames.php @@ -0,0 +1,76 @@ +set_property_value( + 'plugins', + array( + 'dependent/dependent.php' => array( + 'Name' => 'Dependent 1', + 'RequiresPlugins' => 'dependency', + ), + 'dependent2/dependent2.php' => array( + 'Name' => 'Dependent 2', + 'RequiresPlugins' => 'dependency', + ), + ) + ); + + self::$instance::initialize(); + + $this->assertSame( + array( 'Dependent 1', 'Dependent 2' ), + self::$instance::get_dependent_names( 'dependency/dependency.php' ) + ); + } + + /** + * Tests that dependent names are sorted. + * + * @ticket 22316 + */ + public function test_should_sort_dependent_names() { + $this->set_property_value( + 'plugins', + array( + 'dependent2/dependent2.php' => array( + 'Name' => 'Dependent 2', + 'RequiresPlugins' => 'dependency', + ), + 'dependent/dependent.php' => array( + 'Name' => 'Dependent 1', + 'RequiresPlugins' => 'dependency', + ), + ) + ); + + self::$instance::initialize(); + + $this->assertSame( + array( 'Dependent 1', 'Dependent 2' ), + self::$instance::get_dependent_names( 'dependency/dependency.php' ) + ); + } +} diff --git a/tests/phpunit/tests/admin/plugin-dependencies/getDependents.php b/tests/phpunit/tests/admin/plugin-dependencies/getDependents.php new file mode 100644 index 0000000000..da8522bfb7 --- /dev/null +++ b/tests/phpunit/tests/admin/plugin-dependencies/getDependents.php @@ -0,0 +1,50 @@ +assertSame( + array(), + self::$instance::get_dependents( 'dependency' ) + ); + } + + /** + * Tests that a plugin with dependents will return an array of dependents. + * + * @ticket 22316 + */ + public function test_should_return_an_array_of_dependents_when_a_plugin_has_dependents() { + $this->set_property_value( + 'dependencies', + array( + 'dependent/dependent.php' => array( 'dependency' ), + 'dependent2/dependent2.php' => array( 'dependency' ), + ) + ); + + $this->assertSame( + array( 'dependent/dependent.php', 'dependent2/dependent2.php' ), + self::$instance::get_dependents( 'dependency' ) + ); + } +} diff --git a/tests/phpunit/tests/admin/plugin-dependencies/hasActiveDependents.php b/tests/phpunit/tests/admin/plugin-dependencies/hasActiveDependents.php new file mode 100644 index 0000000000..b4d5243376 --- /dev/null +++ b/tests/phpunit/tests/admin/plugin-dependencies/hasActiveDependents.php @@ -0,0 +1,201 @@ +set_property_value( + 'dependencies', + array( 'dependent/dependent.php' => array( 'dependency' ) ) + ); + + update_option( 'active_plugins', array( 'dependent/dependent.php' ) ); + + $this->assertFalse( self::$instance::has_active_dependents( 'dependency2/dependency2.php' ) ); + } + + /** + * Tests that a plugin with active dependents will return true. + * + * @ticket 22316 + */ + public function test_should_return_true_when_a_plugin_has_active_dependents() { + $this->set_property_value( + 'dependencies', + array( 'dependent/dependent.php' => array( 'dependency' ) ) + ); + + update_option( 'active_plugins', array( 'dependent/dependent.php' ) ); + + $this->assertTrue( self::$instance::has_active_dependents( 'dependency/dependency.php' ) ); + } + + /** + * Tests that a plugin with one inactive and one active dependent will return true. + * + * @ticket 22316 + */ + public function test_should_return_true_when_a_plugin_has_one_inactive_and_one_active_dependent() { + $this->set_property_value( + 'dependencies', + array( + 'dependent2/dependent2.php' => array( 'dependency' ), + 'dependent/dependent.php' => array( 'dependency' ), + ) + ); + + update_option( 'active_plugins', array( 'dependent/dependent.php' ) ); + + $this->assertTrue( self::$instance::has_active_dependents( 'dependency/dependency.php' ) ); + } + + /** + * Tests that a plugin with one active and one inactive dependent will return true. + * + * @ticket 22316 + */ + public function test_should_return_true_when_a_plugin_has_one_active_and_one_inactive_dependent() { + $this->set_property_value( + 'dependencies', + array( + 'dependent/dependent.php' => array( 'dependency' ), + 'dependent2/dependent2.php' => array( 'dependency' ), + ) + ); + + update_option( 'active_plugins', array( 'dependent/dependent.php' ) ); + + $this->assertTrue( self::$instance::has_active_dependents( 'dependency/dependency.php' ) ); + } + + /** + * Tests that when a plugin with active dependents is earlier in the list, + * it will return true if a later plugin has no active dependents. + * + * @ticket 22316 + */ + public function test_should_return_true_when_the_earlier_plugin_has_active_dependents_but_the_later_plugin_does_not() { + $this->set_property_value( + 'dependencies', + array( 'dependent2/dependent2.php' => array( 'dependency' ) ) + ); + + $this->set_property_value( + 'dependencies', + array( + 'dependent/dependent.php' => array( 'dependency' ), + 'dependent2/dependent2.php' => array( 'dependency2' ), + ) + ); + + update_option( 'active_plugins', array( 'dependent/dependent.php' ) ); + + $this->assertTrue( self::$instance::has_active_dependents( 'dependency/dependency.php' ) ); + } + + /** + * Tests that when a plugin with active dependents is later in the list, + * it will return true if an earlier plugin has no active dependents. + * + * @ticket 22316 + */ + public function test_should_return_true_when_the_later_plugin_has_active_dependents_but_the_earlier_plugin_does_not() { + $this->set_property_value( + 'dependencies', + array( 'dependent2/dependent2.php' => array( 'dependency' ) ) + ); + + $this->set_property_value( + 'dependencies', + array( + 'dependent/dependent.php' => array( 'dependency' ), + 'dependent2/dependent2.php' => array( 'dependency2' ), + ) + ); + + update_option( 'active_plugins', array( 'dependent2/dependent2.php' ) ); + + $this->assertTrue( self::$instance::has_active_dependents( 'dependency2/dependency2.php' ) ); + } + + /** + * Tests that a plugin with no dependents will return false. + * + * @ticket 22316 + */ + public function test_should_return_false_when_a_plugin_has_no_active_dependents() { + $this->set_property_value( + 'dependencies', + array( 'dependent/dependent.php' => array( 'dependency' ) ) + ); + + $this->assertFalse( self::$instance::has_active_dependents( 'dependency/dependency.php' ) ); + } + + /** + * Tests that when a plugin with no active dependents is earlier in the list, + * it will return false if a later plugin has active dependents. + * + * @ticket 22316 + */ + public function test_should_return_false_when_the_earlier_plugin_has_no_active_dependents_but_the_later_plugin_does() { + $this->set_property_value( + 'dependencies', + array( 'dependent2/dependent2.php' => array( 'dependency' ) ) + ); + + $this->set_property_value( + 'dependencies', + array( + 'dependent/dependent.php' => array( 'dependency' ), + 'dependent2/dependent2.php' => array( 'dependency2' ), + ) + ); + + update_option( 'active_plugins', array( 'dependent2/dependent2.php' ) ); + + $this->assertFalse( self::$instance::has_active_dependents( 'dependency/dependency.php' ) ); + } + + /** + * Tests that when a plugin with no active dependents is later in the list, + * it will return false if an earlier plugin has active dependents. + * + * @ticket 22316 + */ + public function test_should_return_false_when_the_later_plugin_has_no_active_dependents_but_the_earlier_plugin_does() { + $this->set_property_value( + 'dependencies', + array( 'dependent2/dependent2.php' => array( 'dependency' ) ) + ); + + $this->set_property_value( + 'dependencies', + array( + 'dependent/dependent.php' => array( 'dependency' ), + 'dependent2/dependent2.php' => array( 'dependency2' ), + ) + ); + + update_option( 'active_plugins', array( 'dependent/dependent.php' ) ); + + $this->assertFalse( self::$instance::has_active_dependents( 'dependency2/dependency2.php' ) ); + } +} diff --git a/tests/phpunit/tests/admin/plugin-dependencies/hasCircularDependency.php b/tests/phpunit/tests/admin/plugin-dependencies/hasCircularDependency.php new file mode 100644 index 0000000000..7e86303c5f --- /dev/null +++ b/tests/phpunit/tests/admin/plugin-dependencies/hasCircularDependency.php @@ -0,0 +1,127 @@ +set_property_value( 'plugins', $plugins ); + self::$instance::initialize(); + + $this->assertTrue( self::$instance::has_circular_dependency( $plugin_to_check ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_circular_dependencies() { + return array( + 'a plugin that depends on itself' => array( + 'plugin_to_check' => 'dependency/dependency.php', + 'plugins' => array( + 'dependency/dependency.php' => array( + 'Name' => 'Dependency 1', + 'RequiresPlugins' => 'dependency', + ), + ), + ), + 'two plugins' => array( + 'plugin_to_check' => 'dependency/dependency.php', + 'plugins' => array( + 'dependency/dependency.php' => array( + 'Name' => 'Dependency 1', + 'RequiresPlugins' => 'dependency2', + ), + 'dependency2/dependency2.php' => array( + 'Name' => 'Dependency 2', + 'RequiresPlugins' => 'dependency', + ), + ), + ), + 'three plugins' => array( + 'plugin_to_check' => 'dependency/dependency.php', + 'plugins' => array( + 'dependency/dependency.php' => array( + 'Name' => 'Dependency 1', + 'RequiresPlugins' => 'dependency2', + ), + 'dependency2/dependency2.php' => array( + 'Name' => 'Dependency 2', + 'RequiresPlugins' => 'dependency3', + ), + 'dependency3/dependency3.php' => array( + 'Name' => 'Dependency 3', + 'RequiresPlugins' => 'dependency', + ), + ), + ), + 'four plugins' => array( + 'plugin_to_check' => 'dependency/dependency.php', + 'plugins' => array( + 'dependency/dependency.php' => array( + 'Name' => 'Dependency 1', + 'RequiresPlugins' => 'dependency4', + ), + 'dependency2/dependency2.php' => array( + 'Name' => 'Dependency 2', + 'RequiresPlugins' => 'dependency3', + ), + 'dependency3/dependency3.php' => array( + 'Name' => 'Dependency 3', + 'RequiresPlugins' => 'dependency', + ), + 'dependency4/dependency4.php' => array( + 'Name' => 'Dependency 4', + 'RequiresPlugins' => 'dependency2', + ), + ), + ), + ); + } + + /** + * Tests that a plugin with no circular dependencies will return false. + * + * @ticket 22316 + */ + public function test_should_return_false_when_a_plugin_has_no_circular_dependency() { + $this->set_property_value( + 'plugins', + array( + 'dependency/dependency.php' => array( + 'Name' => 'Dependency 1', + 'RequiresPlugins' => 'dependency2', + ), + ) + ); + + self::$instance::initialize(); + + $this->assertFalse( self::$instance::has_circular_dependency( 'dependent/dependent.php' ) ); + } +} diff --git a/tests/phpunit/tests/admin/plugin-dependencies/hasDependencies.php b/tests/phpunit/tests/admin/plugin-dependencies/hasDependencies.php new file mode 100644 index 0000000000..bd59b6acfe --- /dev/null +++ b/tests/phpunit/tests/admin/plugin-dependencies/hasDependencies.php @@ -0,0 +1,37 @@ +set_property_value( 'dependencies', array( 'dependent/dependent.php' => array() ) ); + $this->assertTrue( self::$instance::has_dependencies( 'dependent/dependent.php' ) ); + } + + /** + * Tests that a plugin with no dependencies will return false. + * + * @ticket 22316 + */ + public function test_should_return_false_when_a_plugin_has_no_dependencies() { + $this->set_property_value( 'dependencies', array( 'dependent2/dependent2.php' => array() ) ); + $this->assertFalse( self::$instance::has_dependencies( 'dependent/dependent.php' ) ); + } +} diff --git a/tests/phpunit/tests/admin/plugin-dependencies/hasDependents.php b/tests/phpunit/tests/admin/plugin-dependencies/hasDependents.php new file mode 100644 index 0000000000..43093537ba --- /dev/null +++ b/tests/phpunit/tests/admin/plugin-dependencies/hasDependents.php @@ -0,0 +1,58 @@ +set_property_value( 'dependency_slugs', array( 'dependent' ) ); + $this->assertTrue( self::$instance::has_dependents( 'dependent/dependent.php' ) ); + } + + /** + * Tests that a single file plugin with dependents will return true. + * + * @ticket 22316 + */ + public function test_should_return_true_when_a_single_file_plugin_has_dependents() { + $this->set_property_value( 'dependency_slugs', array( 'dependent' ) ); + $this->assertTrue( self::$instance::has_dependents( 'dependent.php' ) ); + } + + /** + * Tests that a plugin with no dependents will return false. + * + * @ticket 22316 + */ + public function test_should_return_false_when_a_plugin_has_no_dependents() { + $this->set_property_value( 'dependency_slugs', array( 'dependent2' ) ); + $this->assertFalse( self::$instance::has_dependents( 'dependent/dependent.php' ) ); + } + + /** + * Tests that 'hello.php' is converted to 'hello-dolly'. + * + * @ticket 22316 + */ + public function test_should_convert_hellophp_to_hello_dolly() { + $this->set_property_value( 'dependency_slugs', array( 'hello-dolly' ) ); + $this->assertTrue( self::$instance::has_dependents( 'hello.php' ) ); + } +} diff --git a/tests/phpunit/tests/admin/plugin-dependencies/hasUnmetDependencies.php b/tests/phpunit/tests/admin/plugin-dependencies/hasUnmetDependencies.php new file mode 100644 index 0000000000..28ed59354f --- /dev/null +++ b/tests/phpunit/tests/admin/plugin-dependencies/hasUnmetDependencies.php @@ -0,0 +1,146 @@ +set_property_value( 'dependencies', array( 'dependent/dependent.php' => array( 'dependency' ) ) ); + $this->assertFalse( self::$instance::has_unmet_dependencies( 'dependent2/dependent2.php' ) ); + } + + /** + * Tests that a plugin whose dependencies are installed and active will return false. + * + * @ticket 22316 + */ + public function test_should_return_false_when_a_plugin_has_no_unmet_dependencies() { + $this->set_property_value( + 'dependencies', + array( 'dependent/dependent.php' => array( 'dependency' ) ) + ); + + $this->set_property_value( + 'dependency_filepaths', + array( 'dependency' => 'dependency/dependency.php' ) + ); + + update_option( 'active_plugins', array( 'dependency/dependency.php' ) ); + + $this->assertFalse( self::$instance::has_unmet_dependencies( 'dependent/dependent.php' ) ); + } + + /** + * Tests that a plugin with a dependency that is not installed will return true. + * + * @ticket 22316 + */ + public function test_should_return_true_when_a_plugin_has_a_dependency_that_is_not_installed() { + self::$instance::initialize(); + $this->set_property_value( + 'dependencies', + array( 'dependent/dependent.php' => array( 'dependency' ) ) + ); + + $this->assertTrue( self::$instance::has_unmet_dependencies( 'dependent/dependent.php' ) ); + } + + /** + * Tests that a plugin with a dependency that is inactive will return true. + * + * @ticket 22316 + */ + public function test_should_return_true_when_a_plugin_has_a_dependency_that_is_inactive() { + $this->set_property_value( + 'dependencies', + array( 'dependent/dependent.php' => array( 'dependency' ) ) + ); + + $this->set_property_value( + 'dependency_filepaths', + array( 'dependency' => 'dependency/dependency.php' ) + ); + + $this->assertTrue( self::$instance::has_unmet_dependencies( 'dependent/dependent.php' ) ); + } + + /** + * Tests that a plugin with one dependency that is active and one dependency that is inactive will return true. + * + * @ticket 22316 + */ + public function test_should_return_true_when_a_plugin_has_one_active_dependency_and_one_inactive_dependency() { + $this->set_property_value( + 'dependencies', + array( 'dependent/dependent.php' => array( 'dependency', 'dependency2' ) ) + ); + + $this->set_property_value( + 'dependency_filepaths', + array( + 'dependency' => 'dependency/dependency.php', + 'dependency2' => 'dependency2/dependency2.php', + ) + ); + + update_option( 'active_plugins', array( 'dependency/dependency.php' ) ); + + $this->assertTrue( self::$instance::has_unmet_dependencies( 'dependent/dependent.php' ) ); + } + + /** + * Tests that a plugin with one dependency that is active and one dependency that is not installed will return true. + * + * @ticket 22316 + */ + public function test_should_return_true_when_a_plugin_has_one_active_dependency_and_one_that_is_not_installed() { + $this->set_property_value( + 'dependencies', + array( 'dependent/dependent.php' => array( 'dependency', 'dependency2' ) ) + ); + + $this->set_property_value( + 'dependency_filepaths', + array( 'dependency' => 'dependency/dependency.php' ) + ); + + update_option( 'active_plugins', array( 'dependency/dependency.php' ) ); + + $this->assertTrue( self::$instance::has_unmet_dependencies( 'dependent/dependent.php' ) ); + } + + /** + * Tests that a plugin with one dependency that is inactive and one dependency that is not installed will return true. + * + * @ticket 22316 + */ + public function test_should_return_true_when_a_plugin_has_one_inactive_dependency_and_one_that_is_not_installed() { + $this->set_property_value( + 'dependencies', + array( 'dependent/dependent.php' => array( 'dependency', 'dependency2' ) ) + ); + + $this->set_property_value( + 'dependency_filepaths', + array( 'dependency' => 'dependency/dependency.php' ) + ); + + $this->assertTrue( self::$instance::has_unmet_dependencies( 'dependent/dependent.php' ) ); + } +} diff --git a/tests/phpunit/tests/admin/plugin-dependencies/initialize.php b/tests/phpunit/tests/admin/plugin-dependencies/initialize.php new file mode 100644 index 0000000000..fe15cd2188 --- /dev/null +++ b/tests/phpunit/tests/admin/plugin-dependencies/initialize.php @@ -0,0 +1,336 @@ +get_property_value( 'dependency_api_data' ); + + $this->assertIsArray( $dependency_api_data, '$dependency_api_data is not an array.' ); + $this->assertEmpty( $dependency_api_data, '$dependency_api_data is not empty.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_screens() { + return array( + 'plugins.php' => array( + 'screen' => 'plugins.php', + ), + 'plugin-install.php' => array( + 'screen' => 'plugin-install.php', + ), + ); + } + + /** + * Tests that `$dependency_api_data` is not set by default. + * + * @ticket 22316 + * + * @covers WP_Plugin_Dependencies::get_dependency_api_data + */ + public function test_should_not_set_dependency_api_data() { + self::$instance::initialize(); + + $dependency_api_data = $this->get_property_value( 'dependency_api_data' ); + + $this->assertNull( $dependency_api_data, '$dependency_api_data was set.' ); + } + + /** + * Tests that dependency slugs are loaded and sanitized. + * + * @ticket 22316 + * + * @covers WP_Plugin_Dependencies::read_dependencies_from_plugin_headers + * @covers WP_Plugin_Dependencies::sanitize_dependency_slugs + * + * @dataProvider data_should_sanitize_slugs + * + * @param string $requires_plugins The unsanitized dependency slug(s). + * @param array $expected Optional. The sanitized dependency slug(s). Default empty array. + */ + public function test_initialize_should_load_and_sanitize_dependency_slugs_from_plugin_headers( $requires_plugins, $expected = array() ) { + $this->set_property_value( 'plugins', array( 'dependent/dependent.php' => array( 'RequiresPlugins' => $requires_plugins ) ) ); + self::$instance->initialize(); + $this->assertSame( $expected, $this->get_property_value( 'dependency_slugs' ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_should_sanitize_slugs() { + return array( + // Valid slugs. + 'one dependency' => array( + 'requires_plugins' => 'hello-dolly', + 'expected' => array( 'hello-dolly' ), + ), + 'two dependencies in alphabetical order' => array( + 'requires_plugins' => 'hello-dolly, woocommerce', + 'expected' => array( + 'hello-dolly', + 'woocommerce', + ), + ), + 'two dependencies in reverse alphabetical order' => array( + 'requires_plugins' => 'woocommerce, hello-dolly', + 'expected' => array( + 'hello-dolly', + 'woocommerce', + ), + ), + 'two dependencies with a space' => array( + 'requires_plugins' => 'hello-dolly , woocommerce', + 'expected' => array( + 'hello-dolly', + 'woocommerce', + ), + ), + 'a repeated dependency' => array( + 'requires_plugins' => 'hello-dolly, woocommerce, hello-dolly', + 'expected' => array( + 'hello-dolly', + 'woocommerce', + ), + ), + 'a dependency with multiple dashes' => array( + 'requires_plugins' => 'this-is-a-valid-slug', + 'expected' => array( 'this-is-a-valid-slug' ), + ), + 'a dependency starting with numbers' => array( + 'requires_plugins' => '123slug', + 'expected' => array( '123slug' ), + ), + 'a dependency with a trailing comma' => array( + 'requires_plugins' => 'hello-dolly,', + 'expected' => array( 'hello-dolly' ), + ), + 'a dependency with a leading comma' => array( + 'requires_plugins' => ',hello-dolly', + 'expected' => array( 'hello-dolly' ), + ), + 'a dependency with leading and trailing commas' => array( + 'requires_plugins' => ',hello-dolly,', + 'expected' => array( 'hello-dolly' ), + ), + 'a dependency with a trailing comma and a space' => array( + 'requires_plugins' => 'hello-dolly, ', + 'expected' => array( 'hello-dolly' ), + ), + + // Invalid or empty slugs. + 'no dependencies' => array( + 'requires_plugins' => '', + ), + 'a dependency with an underscore' => array( + 'requires_plugins' => 'hello_dolly', + ), + 'a dependency with a space' => array( + 'requires_plugins' => 'hello dolly', + ), + 'a dependency in quotes' => array( + 'requires_plugins' => '"hello-dolly"', + ), + 'two dependencies in quotes' => array( + 'requires_plugins' => '"hello-dolly, woocommerce"', + ), + 'a dependency with trailing dash' => array( + 'requires_plugins' => 'ending-dash-', + ), + 'a dependency with leading dash' => array( + 'requires_plugins' => '-slug', + ), + 'a dependency with double dashes' => array( + 'requires_plugins' => 'abc--123', + ), + 'cyrillic dependencies' => array( + 'requires_plugins' => 'я-делюсь', + ), + 'arabic dependencies' => array( + 'requires_plugins' => 'لينوكس-ويكى', + ), + 'chinese dependencies' => array( + 'requires_plugins' => '唐诗宋词chinese-poem,社交登录,腾讯微博一键登录,豆瓣秀-for-wordpress', + ), + 'symbol dependencies' => array( + 'requires_plugins' => '★-wpsymbols-★', + ), + ); + } + + /** + * Tests that dependent files are loaded and slugified. + * + * @ticket 22316 + * + * @covers WP_Plugin_Dependencies::read_dependencies_from_plugin_headers + * @covers WP_Plugin_Dependencies::convert_to_slug + */ + public function test_should_slugify_dependent_files() { + $plugins = get_plugins(); + + $expected_slugs = array(); + foreach ( $plugins as $plugin_file => &$headers ) { + // Create the expected slugs. + if ( 'hello.php' === $plugin_file ) { + $slug = 'hello-dolly'; + } else { + $slug = str_replace( '.php', '', explode( '/', $plugin_file )[0] ); + } + + $expected_slugs[ $plugin_file ] = $slug; + + // While here, ensure the plugins are all dependents. + $headers['RequiresPlugins'] = 'dependency'; + } + unset( $headers ); + + // Set the plugins property with the plugin data modified to make them dependents. + $this->set_property_value( 'plugins', $plugins ); + + self::$instance->initialize(); + $this->assertSame( $expected_slugs, $this->get_property_value( 'dependent_slugs' ) ); + } + + /** + * Tests that dependents with unmet dependencies are deactivated. + * + * @ticket 22316 + * + * @covers WP_Plugin_Dependencies::deactivate_dependents_with_unmet_dependencies + * @covers WP_Plugin_Dependencies::has_unmet_dependencies + * @covers WP_Plugin_Dependencies::get_active_dependents_in_dependency_tree + * + * @dataProvider data_should_only_deactivate_dependents_with_unmet_dependencies + * + * @param array $active_plugins An array of active plugin paths. + * @param array $plugins An array of installed plugins. + * @param array $expected The expected value of 'active_plugins' after initialization. + */ + public function test_should_deactivate_dependents_with_uninstalled_dependencies( $active_plugins, $plugins, $expected ) { + update_option( 'active_plugins', $active_plugins ); + + $this->set_property_value( 'plugins', $plugins ); + self::$instance::initialize(); + + $this->assertSame( $expected, array_values( get_option( 'active_plugins', array() ) ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_should_only_deactivate_dependents_with_unmet_dependencies() { + return array( + 'a dependent with an uninstalled dependency' => array( + 'active_plugins' => array( 'dependent/dependent.php' ), + 'plugins' => array( + 'dependent/dependent.php' => array( 'RequiresPlugins' => 'dependency' ), + ), + 'expected' => array(), + ), + 'a dependent with an inactive dependency' => array( + 'active_plugins' => array( 'dependent/dependent.php' ), + 'plugins' => array( + 'dependent/dependent.php' => array( 'RequiresPlugins' => 'dependency' ), + 'dependency/dependency.php' => array( 'RequiresPlugins' => '' ), + ), + 'expected' => array(), + ), + 'a dependent with two dependencies, one uninstalled, one inactive' => array( + 'active_plugins' => array( 'dependent/dependent.php' ), + 'plugins' => array( + 'dependent/dependent.php' => array( 'RequiresPlugins' => 'dependency, dependency2' ), + 'dependency2/dependency2.php' => array( 'RequiresPlugins' => '' ), + ), + 'expected' => array(), + ), + 'a dependent with a dependency that is installed and active' => array( + 'active_plugins' => array( 'dependent/dependent.php', 'dependency/dependency.php' ), + 'plugins' => array( + 'dependent/dependent.php' => array( 'RequiresPlugins' => 'dependency' ), + 'dependency/dependency.php' => array( 'RequiresPlugins' => '' ), + ), + 'expected' => array( 'dependent/dependent.php', 'dependency/dependency.php' ), + ), + 'one dependent with two dependencies that are installed and active' => array( + 'active_plugins' => array( + 'dependent/dependent.php', + 'dependency/dependency.php', + 'dependency2/dependency2.php', + ), + 'plugins' => array( + 'dependent/dependent.php' => array( 'RequiresPlugins' => 'dependency, dependency2' ), + 'dependency/dependency.php' => array( 'RequiresPlugins' => '' ), + 'dependency2/dependency2.php' => array( 'RequiresPlugins' => '' ), + ), + 'expected' => array( + 'dependent/dependent.php', + 'dependency/dependency.php', + 'dependency2/dependency2.php', + ), + ), + 'two dependents, one with an uninstalled dependency, and one with an active dependency' => array( + 'active_plugins' => array( + 'dependent/dependent.php', + 'dependent2/dependent2.php', + 'dependency2/dependency2.php', + ), + 'plugins' => array( + 'dependent/dependent.php' => array( 'RequiresPlugins' => 'dependency' ), + 'dependent2/dependent2.php' => array( 'RequiresPlugins' => 'dependency2' ), + 'dependency2/dependency2.php' => array( 'RequiresPlugins' => '' ), + ), + 'expected' => array( 'dependent2/dependent2.php', 'dependency2/dependency2.php' ), + ), + ); + } +}