Commit e6197658ccb for woocommerce
commit e6197658ccbaacc8119e065f250a3ba915fdf110
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Fri Aug 28 18:08:51 2026 +0300
Fix plugin install/activate failure notices: name the plugin once and state the real reason (#68087)
* feat(plugins-helper): add get_error_reason() for upgrader failures
Plugin install failures caused by an unmet requirement (PHP or WP
version) make Plugin_Upgrader::install() return null; the structured
WP_Error only survives on the upgrader skin's result property. Nothing
in WooCommerce reads it, so the merchant never learns why.
Add a small helper that turns the first WP_Error among its arguments
into a plain sentence (message plus string data, HTML stripped) so the
install and activate paths can show WordPress's own reason.
Refs #68084
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(plugins-helper): return the failure reason instead of a slug sentence
The install and activate error strings each formed a full sentence
naming the plugin slug. The client (@woocommerce/data) wraps every
server error in its own 'Could not install <slug> plugin, ...' frame,
so merchants saw the slug twice, joined by a comma splice, and never
the actual reason, which was only sent to Tracks.
Make the server side state only the reason. For upgrader failures,
read the WP_Error the skin retained (Plugin_Upgrader::install()
returns null on unmet requirements) and fall back to the previous
generic advice only when WordPress supplied nothing. Response shape,
error keys, hooks and Tracks properties are unchanged.
Refs #68084
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(data): end the plugin error frame with a sentence boundary
formatErrorMessage() spliced the server-provided error onto its own
'Could not install <slug> plugin' frame with a comma, producing a
run-on such as 'Could not install x plugin, We couldn't install x...'.
Now that the server returns only the reason as a complete sentence,
end the frame with a period so the two read as one message. The
permission, connection and plural branches are unchanged.
Refs #68084
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings-payments): name the provider and style install failures as errors
The install/activate failure handler passed the rejected PluginError to
createNoticesFromResponse(), which only knows REST response shapes. It
displayed the slug-framed message and, because a PluginError has no
'code' property, rendered it as a success notice.
Compose the notice locally from the provider title and the per-plugin
server reason when present, falling back to the framed message for
permission and connection errors, and dispatch it as an error notice.
Refs #68084
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(plugins-helper): explain unmet PHP/WP requirements in our own words
For an install that fails a 'Requires PHP' or 'Requires at least'
check, WordPress reports a generic 'The package could not be
installed.' followed by detail copy written for the zip-upload flow
('...however the uploaded plugin requires 8.2.0.'). Shown after our
'Could not install <name>.' frame, the first sentence is redundant and
the second is wrong for a wp.org install.
Compose the reason from data already in hand (the wp.org API's
requires_php / requires and the runtime versions) for those two error
codes only. Every other failure keeps WordPress's own localized reason.
Refs #68084
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings-payments): name the provider on permission and connection errors
getPluginActionErrorMessage() re-framed the failure with the provider
title only when the rejection carried the per-plugin server response.
On a plugin permission error (403 woocommerce_rest_cannot_update) or a
connection failure it returned the message already framed by
@woocommerce/data, which names the plugin by slug, so Settings ->
Payments showed "Could not install visa-acceptance-solutions. ..." on
those paths while every other path used the title.
Take the reason from the original rejection kept in PluginError.data
instead: repeat @woocommerce/data's permission copy for the 403 case
(that package only exposes it glued to the slug frame) and append the
underlying error's own message otherwise. The already-framed message
remains the last resort when the rejection carries no usable data.
Refs #68084
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(plugins-helper): bound the reason built from upgrader errors
get_error_reason() joined a WP_Error's message with its string data to
form the reason shown for a failed install or activation.
WP_Error data is not always a short detail sentence. activate_plugin()
returns 'unexpected_output' carrying the plugin's entire captured
output buffer, and upgrader failures can carry equally large payloads.
The reason does not stop at the notice: AsyncPluginsInstallLogger
persists it with update_option() and @woocommerce/data sends it on as a
Tracks property, so an unbounded payload was both stored and
transmitted.
Skip the data for codes whose payload is machine output rather than a
readable detail, and cap the composed reason at 300 characters. The
message itself is always kept, so every failure still states a reason.
Refs #68084
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(data): attribute plural plugin failures to the plugin that failed
Now that the server returns only the reason, the per-plugin messages no
longer name their own plugin. formatErrorMessage() listed every
requested plugin and joined the reasons without their slugs, so a
notice covering several plugins named plugins that had succeeded and
gave no way to tell which reason belonged to which.
isPluginResponseError() also tested only the first requested plugin, so
a run where the first plugin installed and a later one failed missed
the per-plugin branch entirely and fell through to JSON.stringify().
Match the response when any requested plugin is among the errors, frame
only the plugins that actually failed, and prefix each reason with its
slug when more than one failed. A single failure is already named by
the surrounding sentence and stays unprefixed.
Refs #68084
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(settings-payments): name the step that actually failed
installAndActivatePlugins() installs and then activates, but the
failure notice derived its verb from the plugin status read before the
request. An extension that installed successfully and then failed to
activate was reported as "Could not install <name>.", pointing the
merchant at the step that had worked, and the same inference chose the
Tracks event.
The data layer knows which step it was on: it throws from
handlePluginAPIError() with the action type already in hand. Carry that
on PluginError and read it back through getFailedPluginAction(),
falling back to the pre-request status when a rejection reports none.
The failure event follows the same step, so an activation failure now
records provider_extension_activation_failed rather than the
installation event.
Refs #68084
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(plugins-helper): keep sentences apart when stripping error markup
get_error_reason() runs the WP_Error message and its data through
wp_strip_all_tags() before showing them.
WordPress writes several of these messages as multiple paragraphs.
validate_plugin_requirements() returns "<p>...requires PHP 99.0.</p>
<p><a ...>Learn more about updating PHP</a>.</p>", and removing the
tags leaves no whitespace where the paragraph break was, so a merchant
saw "The plugin requires PHP 99.0.Learn more about updating PHP."
Stripped inline tags left a stray double space for the same reason.
Turn block-level boundaries into spaces before stripping, and let
wp_strip_all_tags() collapse the result so no run of whitespace
survives.
Refs #68084
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(data): read the per-plugin error list defensively
Attributing each reason to its plugin introduced a strict read of the
response: it called join() on every value in the error object.
The shape is only guaranteed by convention. The previous formatting
stringified the whole object, so a value that was not an array of
messages passed through harmlessly; joining each value throws a
TypeError instead. That escapes handlePluginAPIError, so the caller
never receives a PluginError at all and loses both the framed message
and the failed step.
Keep only the values that are non-empty strings, drop the rest, and
fall back to the raw payload when nothing readable remains.
Refs #68084
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(plugins-helper): normalize requirement reasons like every other reason
get_requirements_error_reason() builds its sentence from the wp.org
API's requires_php and requires values and returned it directly.
That response passes through the plugins_api_result filter, so the
values are not guaranteed to be version strings. empty() lets a
non-empty array through to sprintf, producing an "Array to string
conversion" warning and the text "It requires PHP Array or newer". The
sentence also bypassed the stripping and length cap the other reason
path applies, so markup survived and a long value travelled untrimmed
into the notice, the persisted install log and Tracks.
Accept only a string or numeric version, and send the composed sentence
through the same normalization get_error_reason() uses, now shared as
normalize_reason().
Refs #68084
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(data): report only the plugins the call asked about
isPluginResponseError() now matches when any requested plugin appears
among the errors, so a partial failure is recognised. The formatting
that follows read every entry in the payload rather than the requested
ones.
An entry for a plugin this call never asked about would be named in the
notice as though it had failed. Restrict the entries to the requested
set before deriving the reasons and the plugin list.
Also fix a yoda condition the package's eslint config rejects.
Refs #68084
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(plugins-helper): remove a duplicated docblock in the helper tests
A docblock for test_get_requirements_error_reason_for_php() was
duplicated, closing one comment and opening the next on the same line.
PHP parsed it as two comments, so the tests still ran, but phpcs failed
on the tab used for mid-line alignment and the asterisk indentation.
Refs #68084
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(plugins-helper): skip a non-string error message like non-string data
get_error_reason() joins a WP_Error's message and data into one
reason. The data was only appended when it is a string, but the
message was passed to trim() unconditionally.
WP_Error::add() stores whatever a filter callback passes in, and the
upgrader's errors reach us through upgrader_pre_download,
upgrader_pre_install and upgrader_post_install. A callback that builds
a WP_Error with a non-string message would make trim() throw a
TypeError on PHP 8 and turn a reported failure into a fatal.
Apply the same guard to the message: use it when it is a string, skip
it otherwise, so the reason falls back to the data or the generic
sentence instead.
Refs #68084
* fix(plugins-helper): take the required version from the package header
get_requirements_error_reason() built the "requires PHP X" sentence
from the wp.org listing's requires_php / requires fields.
That is not what WordPress checked. Plugin_Upgrader::check_package()
reads "Requires PHP" and "Requires at least" from the header of the
unpacked package, and the listing metadata can disagree with it (a
readme that was not updated with the plugin header, a filtered
plugins_api response, or no requirement field at all). In the last
case the reason fell through to core's "uploaded plugin" copy.
Read the version from Plugin_Upgrader::$new_plugin_data, which
check_package() fills right before it raises the error, so the
sentence cites the exact value that made the install fail. The method
now takes the header array instead of the API object; it is new in
this branch, so nothing else calls it.
An integration test installs a real zip whose header requires PHP 99.0
through a short-circuited plugins_api() and asserts the sentence.
Refs #68084
* fix(plugins-helper): keep the reason for failures before the package is unpacked
Plugin_Upgrader::install() returns $this->result, which
WP_Upgrader::run() only assigns once install_package() runs. A failed
filesystem connection, download or unpack returns early from run()
with a WP_Error that install() then discards, and the skin's result is
never set either. Those failures reached our error branch with nothing
to read, so merchants still got the generic "try again" sentence for
exactly the cases this branch set out to explain.
The only place run() reports those errors is the skin's error(). Use a
skin that records the last WP_Error it is handed and pass it to
get_error_reason() as a further candidate. The skin is still an
Automatic_Upgrader_Skin, so the upgrader handed to
woocommerce_plugins_install_error is unchanged for hook callbacks.
The integration test forces a download failure through
upgrader_pre_download and asserts the reason. It also closes the
output buffer WordPress leaves open on that path: WP_Upgrader_Skin::error()
calls header() again because Automatic_Upgrader_Skin::header() never
marks the header as done.
Refs #68084
* fix(data): keep failures for plugins the server added to the request
handlePluginAPIError() dropped every entry of the per-plugin error
payload whose slug this call did not request.
The server can extend the list through
woocommerce_admin_plugins_pre_install, and every entry in the payload
is a failure the server actually recorded. A filter-added plugin that
failed alongside a requested one silently disappeared from the notice
and from the failed-plugin list.
Read every entry instead. The plural frame already attributes each
reason to its slug, so an added plugin shows up named, next to the
one the merchant asked for.
Refs #68084
* fix(settings-payments): read the reason from the rejection instead of re-parsing it
getPluginActionErrorMessage() re-derived the reason from
PluginError.data: the per-plugin response, the permission check, the
REST message. That is the same logic handlePluginAPIError() in
@woocommerce/data runs to build rawErrorMessage, which it then folded
into the slug-framed sentence and discarded.
Two copies of that logic had already drifted: the settings copy joined
the per-plugin list without checking that its entries are strings, so
a filtered payload could put "[object Object]" in the notice.
Expose the unframed reason on PluginError, next to the step that
failed, and have Settings -> Payments frame that with the provider
title. The duplicated parsing goes away, and the slug parameter with
it.
Refs #68084
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
diff --git a/packages/js/data/changelog/fix-68084-plugin-install-error-message b/packages/js/data/changelog/fix-68084-plugin-install-error-message
new file mode 100644
index 00000000000..38d1839a3c9
--- /dev/null
+++ b/packages/js/data/changelog/fix-68084-plugin-install-error-message
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Plugin install/activate error messages now read as one sentence instead of splicing the server error onto the frame with a comma.
diff --git a/packages/js/data/src/plugins/actions.ts b/packages/js/data/src/plugins/actions.ts
index e69eb81c095..d354300bb4d 100644
--- a/packages/js/data/src/plugins/actions.ts
+++ b/packages/js/data/src/plugins/actions.ts
@@ -27,7 +27,13 @@ import {
class PluginError extends Error {
constructor(
message: string,
- public data: unknown
+ public data: unknown,
+ // The step that actually failed. installAndActivatePlugins runs install then
+ // activate, so callers cannot infer this from the status they saw beforehand.
+ public actionType: 'install' | 'activate',
+ // The reason without the plugin-naming frame, for callers that frame it
+ // themselves (for example with a display title instead of the slug).
+ public reason: string
) {
super( message );
}
@@ -39,7 +45,9 @@ const isPluginResponseError = (
plugins: Partial< PluginNames >[],
error: unknown
): error is PluginResponseErrors =>
- typeof error === 'object' && error !== null && plugins[ 0 ] in error;
+ typeof error === 'object' &&
+ error !== null &&
+ plugins.some( ( plugin ) => plugin in error );
const formatErrorMessage = (
actionType: 'install' | 'activate' = 'install',
@@ -47,10 +55,10 @@ const formatErrorMessage = (
rawErrorMessage: string
) => {
return sprintf(
- /* translators: %(actionType): install or activate (the plugin). %(pluginName): a plugin slug (e.g. woocommerce-services). %(error): a single error message or in plural a comma separated error message list.*/
+ /* translators: %(actionType)s: install or activate (the plugin). %(pluginName)s: a plugin slug (e.g. woocommerce-services) or, in plural, a comma separated list of slugs. %(error)s: a complete sentence describing the reason, or in plural a comma separated list of such sentences. */
_n(
- 'Could not %(actionType)s %(pluginName)s plugin, %(error)s',
- 'Could not %(actionType)s the following plugins: %(pluginName)s with these Errors: %(error)s',
+ 'Could not %(actionType)s %(pluginName)s. %(error)s',
+ 'Could not %(actionType)s the following plugins: %(pluginName)s. %(error)s',
Object.keys( plugins ).length || 1,
'woocommerce'
),
@@ -176,6 +184,8 @@ function* handlePluginAPIError(
error: unknown
) {
let rawErrorMessage;
+ // Name only the plugins that actually failed, not everything that was requested.
+ let failedPlugins = plugins;
// Check for plugin-management permission errors before generic handling.
// Match the specific code so we don't misattribute other 403s
@@ -192,7 +202,42 @@ function* handlePluginAPIError(
);
} else if ( isPluginResponseError( plugins, error ) ) {
// Backend error messages are in the form of { plugin-slug: [ error messages ] }.
- rawErrorMessage = Object.values( error ).join( ', \n' );
+ // Read them defensively: this is a parsed HTTP response, and a plugin filtering it
+ // can put anything here. Anything that is not a message is dropped.
+ // Every entry is a real failure, including plugins a server-side filter added to
+ // the request, so none of them is filtered out by the list this call asked for.
+ const failures = Object.entries( error )
+ .map(
+ ( [ slug, value ] ) =>
+ [
+ slug,
+ ( Array.isArray( value ) ? value : [ value ] ).filter(
+ ( message ): message is string =>
+ typeof message === 'string' && message !== ''
+ ),
+ ] as const
+ )
+ .filter( ( [ , messages ] ) => messages.length > 0 );
+
+ if ( failures.length ) {
+ failedPlugins = failures.map(
+ ( [ slug ] ) => slug
+ ) as Partial< PluginNames >[];
+
+ // The reasons no longer name their own plugin, so attribute them by slug when
+ // more than one failed. A lone failure is already named by the sentence around it.
+ rawErrorMessage =
+ failures.length > 1
+ ? failures
+ .map(
+ ( [ slug, messages ] ) =>
+ `${ slug }: ${ messages.join( ' ' ) }`
+ )
+ .join( ' \n' )
+ : failures[ 0 ][ 1 ].join( ' ' );
+ } else {
+ rawErrorMessage = JSON.stringify( error );
+ }
} else {
// Other error such as API connection errors.
rawErrorMessage =
@@ -217,8 +262,10 @@ function* handlePluginAPIError(
}
throw new PluginError(
- formatErrorMessage( actionType, plugins, rawErrorMessage ),
- error
+ formatErrorMessage( actionType, failedPlugins, rawErrorMessage ),
+ error,
+ actionType,
+ rawErrorMessage
);
}
diff --git a/packages/js/data/src/plugins/test/actions.ts b/packages/js/data/src/plugins/test/actions.ts
index 88c7c6392e7..4612bda96c7 100644
--- a/packages/js/data/src/plugins/test/actions.ts
+++ b/packages/js/data/src/plugins/test/actions.ts
@@ -17,6 +17,7 @@ jest.mock( '@wordpress/data', () => ( {
* External dependencies
*/
import { controls } from '@wordpress/data';
+import { apiFetch } from '@wordpress/data-controls';
/**
* Internal dependencies
@@ -24,6 +25,8 @@ import { controls } from '@wordpress/data';
import {
installJetpackAndConnect,
connectToJetpackWithFailureRedirect,
+ installPlugins,
+ activatePlugins,
} from '../actions';
import { STORE_NAME } from '../constants';
@@ -149,3 +152,226 @@ describe( 'connectToJetpack', () => {
expect( errorHandler ).toHaveBeenCalledWith( 'Failed!' );
} );
} );
+
+/**
+ * Drives a redux-routine generator to completion the way the runtime would:
+ * the `apiFetch` yield resolves to (or throws) `apiResult`, yielded generators
+ * are run in turn, every other yield resolves to undefined. Returns the error
+ * the generator threw, or null when it completed.
+ */
+function runUntilThrow(
+ generator: Generator< unknown, unknown, unknown >,
+ apiResult: unknown,
+ apiThrows = false
+): Error | null {
+ const drive = ( gen: Generator< unknown, unknown, unknown > ) => {
+ let step = gen.next();
+ while ( ! step.done ) {
+ const value = step.value as
+ | { type?: string; next?: unknown }
+ | undefined;
+ if ( value?.type === 'API_FETCH' ) {
+ step = apiThrows
+ ? gen.throw( apiResult )
+ : gen.next( apiResult );
+ } else if ( typeof value?.next === 'function' ) {
+ drive( value as Generator< unknown, unknown, unknown > );
+ step = gen.next();
+ } else {
+ step = gen.next();
+ }
+ }
+ };
+
+ try {
+ drive( generator );
+ return null;
+ } catch ( e ) {
+ return e as Error;
+ }
+}
+
+describe( 'installPlugins error message', () => {
+ beforeEach( () => {
+ ( apiFetch as jest.Mock ).mockReset();
+ ( apiFetch as jest.Mock ).mockImplementation( () => ( {
+ type: 'API_FETCH',
+ } ) );
+ } );
+
+ it( 'frames a single server reason as one sentence, naming the plugin once', () => {
+ const error = runUntilThrow(
+ installPlugins( [ 'visa-acceptance-solutions' ] ),
+ {
+ data: { installed: [], results: {} },
+ errors: {
+ errors: {
+ 'visa-acceptance-solutions': [
+ 'The package could not be installed. The PHP version on your server is 8.1.34, however the uploaded plugin requires 8.2.0.',
+ ],
+ },
+ },
+ success: false,
+ message: '',
+ }
+ );
+
+ expect( error?.message ).toBe(
+ 'Could not install visa-acceptance-solutions. The package could not be installed. The PHP version on your server is 8.1.34, however the uploaded plugin requires 8.2.0.'
+ );
+ expect( error?.message ).not.toContain( 'plugin, ' );
+ } );
+
+ it( 'frames a permission error without repeating the plugin name', () => {
+ const error = runUntilThrow(
+ installPlugins( [ 'woocommerce-payments' ] ),
+ {
+ code: 'woocommerce_rest_cannot_update',
+ message: 'Sorry',
+ data: { status: 403 },
+ },
+ true
+ );
+
+ expect( error?.message ).toBe(
+ 'Could not install woocommerce-payments. You do not have permissions to manage plugins. Please contact your site administrator.'
+ );
+ } );
+
+ it( 'frames a connection error message', () => {
+ const error = runUntilThrow(
+ installPlugins( [ 'woocommerce-payments' ] ),
+ new Error( 'Failed to fetch' ),
+ true
+ );
+
+ expect( error?.message ).toBe(
+ 'Could not install woocommerce-payments. Failed to fetch'
+ );
+ } );
+
+ it( 'attributes each reason to its plugin in the plural frame', () => {
+ const error = runUntilThrow( installPlugins( [ 'a', 'b' ] ), {
+ data: { installed: [], results: {} },
+ errors: { errors: { a: [ 'Reason A.' ], b: [ 'Reason B.' ] } },
+ success: false,
+ message: '',
+ } );
+
+ // The reasons no longer carry their own slug, so the frame has to supply it.
+ expect( error?.message ).toBe(
+ 'Could not install the following plugins: a, b. a: Reason A. \nb: Reason B.'
+ );
+ } );
+
+ it( 'names only the plugins that actually failed', () => {
+ const error = runUntilThrow( installPlugins( [ 'a', 'b' ] ), {
+ data: { installed: [ 'a' ], results: {} },
+ errors: { errors: { b: [ 'Reason B.' ] } },
+ success: false,
+ message: '',
+ } );
+
+ expect( error?.message ).toBe( 'Could not install b. Reason B.' );
+ } );
+
+ it( 'tolerates message lists the endpoint returned as bare strings', () => {
+ // The old formatting stringified the whole object, so a non-array value never threw.
+ const error = runUntilThrow( installPlugins( [ 'a', 'b' ] ), {
+ data: { installed: [], results: {} },
+ errors: { errors: { a: 'Reason A.', b: 'Reason B.' } },
+ success: false,
+ message: '',
+ } );
+
+ expect( error?.message ).toBe(
+ 'Could not install the following plugins: a, b. a: Reason A. \nb: Reason B.'
+ );
+ } );
+
+ it( 'drops values that are not messages and keeps the ones that are', () => {
+ const error = runUntilThrow( installPlugins( [ 'a', 'b' ] ), {
+ data: { installed: [], results: {} },
+ errors: {
+ errors: { a: { inner: 'not a message' }, b: [ 'Reason B.' ] },
+ },
+ success: false,
+ message: '',
+ } );
+
+ expect( error?.message ).toBe( 'Could not install b. Reason B.' );
+ } );
+
+ it( 'falls back to the raw payload when nothing readable remains', () => {
+ const error = runUntilThrow( installPlugins( [ 'a' ] ), {
+ data: { installed: [], results: {} },
+ errors: { errors: { a: [ null ] } },
+ success: false,
+ message: '',
+ } );
+
+ expect( error?.message ).toBe( 'Could not install a. {"a":[null]}' );
+ } );
+
+ it( 'keeps a failure for a plugin the server added to the request', () => {
+ // woocommerce_admin_plugins_pre_install can add plugins server-side; their failures
+ // come back in the same payload and must not vanish from the notice.
+ const error = runUntilThrow( installPlugins( [ 'a' ] ), {
+ data: { installed: [], results: {} },
+ errors: { errors: { a: [ 'Reason A.' ], added: [ 'Reason B.' ] } },
+ success: false,
+ message: '',
+ } );
+
+ expect( error?.message ).toBe(
+ 'Could not install the following plugins: a, added. a: Reason A. \nadded: Reason B.'
+ );
+ } );
+
+ it( 'exposes the unframed reason on the error', () => {
+ const error = runUntilThrow( installPlugins( [ 'a' ] ), {
+ data: { installed: [], results: {} },
+ errors: { errors: { a: [ 'Reason A.' ] } },
+ success: false,
+ message: '',
+ } );
+
+ expect( ( error as { reason?: string } )?.reason ).toBe( 'Reason A.' );
+ } );
+
+ it( 'reports the step that failed on the error', () => {
+ const error = runUntilThrow( installPlugins( [ 'a' ] ), {
+ data: { installed: [], results: {} },
+ errors: { errors: { a: [ 'Reason A.' ] } },
+ success: false,
+ message: '',
+ } );
+
+ expect( ( error as { actionType?: string } )?.actionType ).toBe(
+ 'install'
+ );
+ } );
+} );
+
+describe( 'activatePlugins error message', () => {
+ beforeEach( () => {
+ ( apiFetch as jest.Mock ).mockReset();
+ ( apiFetch as jest.Mock ).mockImplementation( () => ( {
+ type: 'API_FETCH',
+ } ) );
+ } );
+
+ it( 'reports activate as the failed step so callers do not infer it from stale status', () => {
+ const error = runUntilThrow( activatePlugins( [ 'a' ] ), {
+ data: { activated: [], active: [] },
+ errors: { errors: { a: [ 'Reason A.' ] } },
+ success: false,
+ message: '',
+ } );
+
+ expect( error?.message ).toBe( 'Could not activate a. Reason A.' );
+ expect( ( error as { actionType?: string } )?.actionType ).toBe(
+ 'activate'
+ );
+ } );
+} );
diff --git a/plugins/woocommerce/changelog/fix-68084-plugin-install-error-message b/plugins/woocommerce/changelog/fix-68084-plugin-install-error-message
new file mode 100644
index 00000000000..9f0bcecd994
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-68084-plugin-install-error-message
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Plugin install and activation failure notices now name the plugin once and state the reason WordPress reported (for example an unmet PHP version) instead of generic advice.
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/settings-payments-main.tsx b/plugins/woocommerce/client/admin/client/settings-payments/settings-payments-main.tsx
index 979a3c8c17d..cf431f26558 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/settings-payments-main.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-payments/settings-payments-main.tsx
@@ -9,7 +9,12 @@ import {
PaymentsProvider,
PaymentsEntity,
} from '@woocommerce/data';
-import { resolveSelect, useDispatch, useSelect } from '@wordpress/data';
+import {
+ dispatch,
+ resolveSelect,
+ useDispatch,
+ useSelect,
+} from '@wordpress/data';
import React, { useState, useEffect } from '@wordpress/element';
import apiFetch from '@wordpress/api-fetch';
import { getHistory, getNewPath } from '@woocommerce/navigation';
@@ -37,6 +42,8 @@ import {
isActionIncentive,
recordPaymentsEvent,
recordPaymentsOnboardingEvent,
+ getPluginActionErrorMessage,
+ getFailedPluginAction,
} from '~/settings-payments/utils';
import { WooPaymentsPostSandboxAccountSetupModal } from '~/settings-payments/components/modals';
import WooPaymentsModal from '~/settings-payments/onboarding/providers/woopayments';
@@ -416,21 +423,35 @@ export const SettingsPaymentsMain = () => {
}
}
} )
- .catch( ( response: { errors: Record< string, string > } ) => {
- let eventName = 'provider_extension_installation_failed';
- if ( paymentsEntity.plugin.status !== 'not_installed' ) {
- eventName = 'provider_extension_activation_failed';
- }
- recordPaymentsEvent( eventName, {
- provider_id: paymentsEntity.id,
- suggestion_id:
- paymentsEntity?._suggestion_id ?? 'unknown',
- provider_extension_slug: paymentsEntity.plugin.slug,
- from: context,
- source: wooPaymentsOnboardingSessionEntrySettings,
- reason: 'error',
- } );
- createNoticesFromResponse( response );
+ .catch( ( error: unknown ) => {
+ const actionType = getFailedPluginAction(
+ error,
+ paymentsEntity.plugin.status === 'not_installed'
+ ? 'install'
+ : 'activate'
+ );
+ recordPaymentsEvent(
+ actionType === 'install'
+ ? 'provider_extension_installation_failed'
+ : 'provider_extension_activation_failed',
+ {
+ provider_id: paymentsEntity.id,
+ suggestion_id:
+ paymentsEntity?._suggestion_id ?? 'unknown',
+ provider_extension_slug: paymentsEntity.plugin.slug,
+ from: context,
+ source: wooPaymentsOnboardingSessionEntrySettings,
+ reason: 'error',
+ }
+ );
+ dispatch( 'core/notices' ).createNotice(
+ 'error',
+ getPluginActionErrorMessage(
+ actionType,
+ paymentsEntity.title,
+ error
+ )
+ );
setInstallingPlugin( null );
} );
},
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/test/utils.test.ts b/plugins/woocommerce/client/admin/client/settings-payments/test/utils.test.ts
new file mode 100644
index 00000000000..cbe51cad7a9
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-payments/test/utils.test.ts
@@ -0,0 +1,88 @@
+/**
+ * Internal dependencies
+ */
+import { getPluginActionErrorMessage, getFailedPluginAction } from '../utils';
+
+describe( 'getPluginActionErrorMessage', () => {
+ const reason =
+ 'The package could not be installed. The PHP version on your server is 8.1.34, however the uploaded plugin requires 8.2.0.';
+
+ it( 'names the provider by title and appends the reason the rejection carries', () => {
+ const message = getPluginActionErrorMessage(
+ 'install',
+ 'Visa Acceptance Solutions',
+ {
+ message:
+ 'Could not install visa-acceptance-solutions. ' + reason,
+ reason,
+ }
+ );
+
+ expect( message ).toBe(
+ 'Could not install Visa Acceptance Solutions. ' + reason
+ );
+ } );
+
+ it( 'uses the activate frame for a failed activation', () => {
+ expect(
+ getPluginActionErrorMessage( 'activate', 'Foo', {
+ message: 'x',
+ reason: 'One. Two.',
+ } )
+ ).toBe( 'Could not activate Foo. One. Two.' );
+ } );
+
+ it( 'falls back to the framed message when the rejection carries no reason', () => {
+ const framed =
+ 'Could not install visa-acceptance-solutions. Something.';
+
+ expect(
+ getPluginActionErrorMessage(
+ 'install',
+ 'Visa Acceptance Solutions',
+ {
+ message: framed,
+ reason: '',
+ }
+ )
+ ).toBe( framed );
+ expect(
+ getPluginActionErrorMessage(
+ 'install',
+ 'Visa Acceptance Solutions',
+ new Error( framed )
+ )
+ ).toBe( framed );
+ } );
+
+ it( 'falls back to the frame alone when the error carries no message', () => {
+ expect(
+ getPluginActionErrorMessage( 'install', 'Foo', undefined )
+ ).toBe( 'Could not install Foo.' );
+ } );
+} );
+
+describe( 'getFailedPluginAction', () => {
+ it( 'prefers the step the rejection reports over the requested one', () => {
+ // Install succeeded, activation failed: the pre-request status still says
+ // 'not_installed', so only the rejection knows the notice needs "activate".
+ expect(
+ getFailedPluginAction( { actionType: 'activate' }, 'install' )
+ ).toBe( 'activate' );
+ } );
+
+ it( 'falls back to the requested step when the rejection reports none', () => {
+ expect(
+ getFailedPluginAction( new Error( 'Failed to fetch' ), 'install' )
+ ).toBe( 'install' );
+ expect( getFailedPluginAction( undefined, 'activate' ) ).toBe(
+ 'activate'
+ );
+ } );
+
+ it( 'ignores an unrecognised reported step', () => {
+ expect(
+ getFailedPluginAction( { actionType: 'nope' }, 'activate' )
+ ).toBe( 'activate' );
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/utils.ts b/plugins/woocommerce/client/admin/client/settings-payments/utils.ts
index 7173a658e63..397e8eed449 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/utils.ts
+++ b/plugins/woocommerce/client/admin/client/settings-payments/utils.ts
@@ -7,6 +7,7 @@ import {
RecommendedPaymentMethod,
} from '@woocommerce/data';
import { getAdminLink } from '@woocommerce/settings';
+import { __, sprintf } from '@wordpress/i18n';
import { recordEvent } from '@woocommerce/tracks';
import { parseAdminUrl } from '@woocommerce/navigation';
@@ -428,3 +429,73 @@ export const removeOriginFromURL = ( url: string ) => {
const parsedUrl = parseAdminUrl( url );
return parsedUrl.href?.replace( parsedUrl.origin, '' ) ?? url;
};
+
+/**
+ * Resolve which step of an install-and-activate run actually failed.
+ *
+ * `installAndActivatePlugins` installs and then activates, so an install that succeeded can
+ * still fail on activation. The rejection reports the step it failed on; the plugin status
+ * read before the request only says which step we expected to fail.
+ *
+ * @param error The rejection value from installAndActivatePlugins.
+ * @param requestedAction The step implied by the plugin status before the request.
+ * @return The step that failed.
+ */
+export const getFailedPluginAction = (
+ error: unknown,
+ requestedAction: 'install' | 'activate'
+): 'install' | 'activate' => {
+ const reportedAction =
+ typeof error === 'object' && error !== null && 'actionType' in error
+ ? ( error as { actionType: unknown } ).actionType
+ : undefined;
+
+ return reportedAction === 'install' || reportedAction === 'activate'
+ ? reportedAction
+ : requestedAction;
+};
+
+/**
+ * Build the notice text for a failed provider extension install or activation.
+ *
+ * `@woocommerce/data` frames its message with the plugin slug. Re-frame with the provider
+ * title the merchant clicked, using the unframed reason the rejection carries. Keep the
+ * already-framed message only when the rejection has no reason at all.
+ *
+ * @param actionType Whether the extension was being installed or activated.
+ * @param title The provider title shown in the UI.
+ * @param error The rejection value from installAndActivatePlugins.
+ * @return The notice text.
+ */
+export const getPluginActionErrorMessage = (
+ actionType: 'install' | 'activate',
+ title: string,
+ error: unknown
+): string => {
+ const frame =
+ actionType === 'install'
+ ? sprintf(
+ /* translators: %s: payment provider name (e.g. Visa Acceptance Solutions) */
+ __( 'Could not install %s.', 'woocommerce' ),
+ title
+ )
+ : sprintf(
+ /* translators: %s: payment provider name (e.g. Visa Acceptance Solutions) */
+ __( 'Could not activate %s.', 'woocommerce' ),
+ title
+ );
+
+ const rejection =
+ typeof error === 'object' && error !== null
+ ? ( error as { reason?: unknown; message?: unknown } )
+ : undefined;
+
+ if ( typeof rejection?.reason === 'string' && rejection.reason ) {
+ return `${ frame } ${ rejection.reason }`;
+ }
+
+ const message =
+ typeof rejection?.message === 'string' ? rejection.message : '';
+
+ return message || frame;
+};
diff --git a/plugins/woocommerce/src/Admin/PluginsHelper.php b/plugins/woocommerce/src/Admin/PluginsHelper.php
index e0509dd1884..d28b3c5f18f 100644
--- a/plugins/woocommerce/src/Admin/PluginsHelper.php
+++ b/plugins/woocommerce/src/Admin/PluginsHelper.php
@@ -219,6 +219,152 @@ class PluginsHelper {
return isset( $plugins[ $plugin_path ] ) ? $plugins[ $plugin_path ] : false;
}
+ /**
+ * Error codes whose WP_Error data is machine output rather than a readable detail.
+ *
+ * `activate_plugin()` puts the plugin's whole captured output buffer in the error data,
+ * which is never something a merchant can act on.
+ */
+ private const OPAQUE_ERROR_DATA_CODES = array( 'unexpected_output' );
+
+ /**
+ * Longest reason we pass on.
+ *
+ * Reasons reach a notice, the persisted install log option and a Tracks property, so an
+ * upgrader payload of unknown size must never travel untrimmed.
+ */
+ private const MAX_ERROR_REASON_LENGTH = 300;
+
+ /**
+ * Make a reason safe to show, store and transmit.
+ *
+ * Reasons come from WordPress and from values that reach us through filters, so they can
+ * carry markup or be arbitrarily long. WordPress also writes some messages as multiple
+ * paragraphs, so block boundaries become spaces first: stripping the tags would otherwise
+ * run the last word of one paragraph into the first word of the next.
+ *
+ * @since 11.2.0
+ *
+ * @param string $text The raw reason.
+ * @return string The reason, HTML-stripped, whitespace-collapsed and length-capped.
+ */
+ private static function normalize_reason( string $text ): string {
+ $spaced = preg_replace( '#<(?:/p|/div|/li|/h[1-6]|br\s*/?)>#i', ' ', $text ) ?? $text;
+ $reason = trim( wp_strip_all_tags( $spaced, true ) );
+
+ if ( mb_strlen( $reason ) > self::MAX_ERROR_REASON_LENGTH ) {
+ $reason = trim( mb_substr( $reason, 0, self::MAX_ERROR_REASON_LENGTH ) ) . "\u{2026}";
+ }
+
+ return $reason;
+ }
+
+ /**
+ * Read a version requirement from plugin header data.
+ *
+ * The headers come from `get_plugin_data()` on the unpacked package, whose values pass
+ * through filters, so the value is not guaranteed to be a string.
+ *
+ * @since 11.2.0
+ *
+ * @param mixed $value The raw `RequiresPHP` or `RequiresWP` value.
+ * @return string The version, or an empty string when the value is not one.
+ */
+ private static function get_required_version( $value ): string {
+ if ( ! is_string( $value ) && ! is_numeric( $value ) ) {
+ return '';
+ }
+
+ return trim( (string) $value );
+ }
+
+ /**
+ * Build a merchant-readable reason from the first WP_Error among the given candidates.
+ *
+ * WordPress's upgrader reports the real failure (unmet PHP/WP version, filesystem, download)
+ * as a WP_Error whose message is a short sentence and whose data is often the detail sentence.
+ * Non-error candidates (false, null, true) are skipped.
+ *
+ * The data is only appended when it reads as a detail: codes listed in
+ * OPAQUE_ERROR_DATA_CODES carry raw plugin output instead, and the result is capped at
+ * MAX_ERROR_REASON_LENGTH because callers persist and transmit it.
+ *
+ * @since 11.2.0
+ *
+ * @param mixed ...$candidates Values that may be WP_Error instances, in order of preference.
+ * @return string The reason, HTML-stripped, trimmed and length-capped, or an empty string when none is available.
+ */
+ public static function get_error_reason( ...$candidates ): string {
+ foreach ( $candidates as $candidate ) {
+ if ( ! is_wp_error( $candidate ) ) {
+ continue;
+ }
+
+ // Both come from WP_Error::add(), which stores whatever a filter callback passed in.
+ $message = $candidate->get_error_message();
+ $data = $candidate->get_error_data();
+
+ $parts = is_string( $message ) ? array( $message ) : array();
+ if ( is_string( $data ) && ! in_array( $candidate->get_error_code(), self::OPAQUE_ERROR_DATA_CODES, true ) ) {
+ $parts[] = $data;
+ }
+
+ return self::normalize_reason( implode( ' ', array_map( 'trim', $parts ) ) );
+ }
+
+ return '';
+ }
+
+ /**
+ * Compose a reason for an install that failed an unmet PHP or WordPress version requirement.
+ *
+ * WordPress reports these with copy written for the zip-upload flow ("the uploaded plugin requires"),
+ * so build our own sentence from the versions it actually checked: the package headers it read
+ * in `Plugin_Upgrader::check_package()`, which can differ from the wp.org listing metadata.
+ *
+ * @since 11.2.0
+ *
+ * @param mixed $error The WP_Error from the upgrader skin, or any non-error value.
+ * @param mixed $plugin_data The headers of the package that failed, as `Plugin_Upgrader::$new_plugin_data` holds them.
+ * @return string The reason, or an empty string when the error is not a requirement failure.
+ */
+ public static function get_requirements_error_reason( $error, $plugin_data ): string {
+ if ( ! is_wp_error( $error ) || ! is_array( $plugin_data ) ) {
+ return '';
+ }
+
+ switch ( $error->get_error_code() ) {
+ case 'incompatible_php_required_version':
+ $required_php = self::get_required_version( $plugin_data['RequiresPHP'] ?? null );
+ if ( '' === $required_php ) {
+ return '';
+ }
+ return self::normalize_reason(
+ sprintf(
+ /* translators: 1: PHP version the plugin requires, 2: PHP version this site runs. */
+ __( 'It requires PHP %1$s or newer, but this site runs PHP %2$s.', 'woocommerce' ),
+ $required_php,
+ PHP_VERSION
+ )
+ );
+ case 'incompatible_wp_required_version':
+ $required_wp = self::get_required_version( $plugin_data['RequiresWP'] ?? null );
+ if ( '' === $required_wp ) {
+ return '';
+ }
+ return self::normalize_reason(
+ sprintf(
+ /* translators: 1: WordPress version the plugin requires, 2: WordPress version this site runs. */
+ __( 'It requires WordPress %1$s or newer, but this site runs WordPress %2$s.', 'woocommerce' ),
+ $required_wp,
+ get_bloginfo( 'version' )
+ )
+ );
+ }
+
+ return '';
+ }
+
/**
* Install an array of plugins.
*
@@ -306,11 +452,7 @@ class PluginsHelper {
*/
do_action( 'woocommerce_plugins_install_api_error', $slug, $api );
- $error_message = sprintf(
- /* translators: %s: plugin slug (example: woocommerce-services) */
- __( 'We couldn\'t install `%s`. Try again in a few minutes, or install it later from the Extensions page.', 'woocommerce' ),
- $slug
- );
+ $error_message = __( 'Try again in a few minutes, or install it later from the Extensions page.', 'woocommerce' );
$errors->add( $plugin, $error_message );
$logger && $logger->add_error( $plugin, $error_message );
@@ -325,7 +467,31 @@ class PluginsHelper {
*/
do_action( 'woocommerce_plugins_install_before', $slug, $source );
- $upgrader = new Plugin_Upgrader( new Automatic_Upgrader_Skin() );
+ // Plugin_Upgrader::install() only returns errors raised once the package is unpacked.
+ // A failed filesystem connection, download or unpack is only reported to the skin, so
+ // keep it there for the reason below.
+ $skin = new class() extends Automatic_Upgrader_Skin {
+ /**
+ * The last error the upgrader reported.
+ *
+ * @var WP_Error|null
+ */
+ public $last_error = null;
+
+ /**
+ * Record the error before passing it on.
+ *
+ * @param string|WP_Error $errors The error the upgrader reports.
+ */
+ public function error( $errors ): void {
+ if ( is_wp_error( $errors ) ) {
+ $this->last_error = $errors;
+ }
+ parent::error( $errors );
+ }
+ };
+
+ $upgrader = new Plugin_Upgrader( $skin );
$result = $upgrader->install( $api->download_link );
// result can be false or WP_Error.
$results[ $plugin ] = $result;
@@ -361,11 +527,13 @@ class PluginsHelper {
*/
do_action( 'woocommerce_plugins_install_error', $slug, $api, $result, $upgrader );
- $install_error_message = sprintf(
- /* translators: %s: plugin slug (example: woocommerce-services) */
- __( 'We couldn\'t install `%s`. Try again, or install it manually. If it keeps failing, contact your host.', 'woocommerce' ),
- $slug
- );
+ $install_error_message = self::get_requirements_error_reason( $skin->result, $upgrader->new_plugin_data );
+ if ( '' === $install_error_message ) {
+ $install_error_message = self::get_error_reason( $skin->result, $result, $skin->last_error );
+ }
+ if ( '' === $install_error_message ) {
+ $install_error_message = __( 'Try again, or install it manually. If it keeps failing, contact your host.', 'woocommerce' );
+ }
$errors->add(
$plugin,
$install_error_message
@@ -479,8 +647,7 @@ class PluginsHelper {
$path = isset( $plugin_paths[ $slug ] ) ? $plugin_paths[ $slug ] : false;
if ( ! $path ) {
- /* translators: %s: plugin slug (example: woocommerce-services) */
- $message = sprintf( __( 'The requested plugin `%s`. is not yet installed.', 'woocommerce' ), $slug );
+ $message = __( 'The plugin is not installed yet.', 'woocommerce' );
$errors->add(
$plugin,
$message
@@ -501,8 +668,10 @@ class PluginsHelper {
*/
do_action( 'woocommerce_plugins_activate_error', $slug, $result );
- /* translators: %s: plugin slug (example: woocommerce-services) */
- $message = sprintf( __( 'The requested plugin `%s` could not be activated.', 'woocommerce' ), $slug );
+ $message = self::get_error_reason( $result );
+ if ( '' === $message ) {
+ $message = __( 'The plugin could not be activated.', 'woocommerce' );
+ }
$errors->add(
$plugin,
$message
diff --git a/plugins/woocommerce/tests/php/src/Admin/PluginsHelperTest.php b/plugins/woocommerce/tests/php/src/Admin/PluginsHelperTest.php
new file mode 100644
index 00000000000..a56aa6ca5c1
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Admin/PluginsHelperTest.php
@@ -0,0 +1,319 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Admin;
+
+use Automattic\WooCommerce\Admin\PluginsHelper;
+use WC_Unit_Test_Case;
+use WP_Error;
+
+/**
+ * Tests for the PluginsHelper class.
+ */
+class PluginsHelperTest extends WC_Unit_Test_Case {
+
+ /**
+ * @testdox Should join the error message and its string data into one reason.
+ */
+ public function test_get_error_reason_uses_message_and_string_data(): void {
+ $error = new WP_Error(
+ 'incompatible_php_required_version',
+ 'The package could not be installed.',
+ 'The PHP version on your server is 8.1.34, however the uploaded plugin requires 8.2.0.'
+ );
+
+ $reason = PluginsHelper::get_error_reason( $error );
+
+ $this->assertSame(
+ 'The package could not be installed. The PHP version on your server is 8.1.34, however the uploaded plugin requires 8.2.0.',
+ $reason,
+ 'Message and string data should be joined by a single space.'
+ );
+ }
+
+ /**
+ * @testdox Should skip a message that is not a string instead of failing on it.
+ */
+ public function test_get_error_reason_ignores_non_string_message(): void {
+ // WP_Error::add() stores whatever a filter callback passed, so the message can be anything.
+ $error = new WP_Error( 'custom', array( 'not', 'a', 'message' ), 'Detail sentence.' );
+
+ $this->assertSame( 'Detail sentence.', PluginsHelper::get_error_reason( $error ) );
+ }
+
+ /**
+ * @testdox Should return only the message when the error data is not a string.
+ */
+ public function test_get_error_reason_ignores_non_string_data(): void {
+ $error = new WP_Error( 'fs_error', 'Could not create directory.', array( 'path' => '/tmp' ) );
+
+ $this->assertSame(
+ 'Could not create directory.',
+ PluginsHelper::get_error_reason( $error ),
+ 'Array error data must not be rendered.'
+ );
+ }
+
+ /**
+ * @testdox Should skip non-error candidates and use the first WP_Error.
+ */
+ public function test_get_error_reason_uses_first_wp_error_candidate(): void {
+ $second = new WP_Error( 'download_failed', 'Download failed.' );
+
+ $this->assertSame(
+ 'Download failed.',
+ PluginsHelper::get_error_reason( false, null, $second ),
+ 'Non-error candidates (false, null) should be skipped.'
+ );
+ }
+
+ /**
+ * @testdox Should return an empty string when no candidate is a WP_Error.
+ */
+ public function test_get_error_reason_returns_empty_without_wp_error(): void {
+ $this->assertSame( '', PluginsHelper::get_error_reason( false, null, true ) );
+ $this->assertSame( '', PluginsHelper::get_error_reason() );
+ }
+
+ /**
+ * @testdox Should strip HTML and trim whitespace from the reason.
+ */
+ public function test_get_error_reason_strips_html_and_trims(): void {
+ $error = new WP_Error( 'x', ' <strong>Boom.</strong> ', '<a href="#">Details.</a>' );
+
+ $this->assertSame( 'Boom. Details.', PluginsHelper::get_error_reason( $error ) );
+ }
+
+ /**
+ * @testdox Should return an empty string for a WP_Error with an empty message and no data.
+ */
+ public function test_get_error_reason_returns_empty_for_empty_error(): void {
+ $this->assertSame( '', PluginsHelper::get_error_reason( new WP_Error( 'x', '' ) ) );
+ }
+
+ /**
+ * @testdox Should keep a sentence boundary where a paragraph break was stripped.
+ */
+ public function test_get_error_reason_separates_paragraphs(): void {
+ // The shape validate_plugin_requirements() really returns.
+ $error = new WP_Error(
+ 'plugin_php_incompatible',
+ '<p><strong>Error:</strong> Current PHP version (8.1.34) does not meet minimum requirements for Foo. The plugin requires PHP 99.0.</p><p><a href="https://wordpress.org/support/update-php/">Learn more about updating PHP</a>.</p>'
+ );
+
+ $this->assertSame(
+ 'Error: Current PHP version (8.1.34) does not meet minimum requirements for Foo. The plugin requires PHP 99.0. Learn more about updating PHP.',
+ PluginsHelper::get_error_reason( $error ),
+ 'A paragraph break must not run two sentences together.'
+ );
+ }
+
+ /**
+ * @testdox Should not append raw plugin output captured by activate_plugin().
+ */
+ public function test_get_error_reason_omits_opaque_error_data(): void {
+ $error = new WP_Error(
+ 'unexpected_output',
+ 'The plugin generated unexpected output.',
+ "Warning: include(): Failed opening 'x.php' in /var/www/plugin.php on line 12"
+ );
+
+ $this->assertSame(
+ 'The plugin generated unexpected output.',
+ PluginsHelper::get_error_reason( $error ),
+ 'A raw output buffer is not a reason a merchant can act on and must not be surfaced.'
+ );
+ }
+
+ /**
+ * @testdox Should cap a long reason so it stays safe to persist and transmit.
+ */
+ public function test_get_error_reason_caps_long_reason(): void {
+ $error = new WP_Error( 'fs_error', 'Could not create directory.', str_repeat( 'a', 1000 ) );
+
+ $reason = PluginsHelper::get_error_reason( $error );
+
+ $this->assertSame( 301, mb_strlen( $reason ), 'The reason should be capped at 300 characters plus an ellipsis.' );
+ $this->assertStringEndsWith( "\u{2026}", $reason );
+ $this->assertStringStartsWith( 'Could not create directory.', $reason );
+ }
+
+ /**
+ * @testdox Should report a not-installed plugin without repeating its slug.
+ */
+ public function test_activate_plugins_error_does_not_contain_slug(): void {
+ $result = PluginsHelper::activate_plugins( array( 'definitely-not-installed-plugin' ) );
+
+ $this->assertIsArray( $result );
+ $messages = $result['errors']->get_error_messages( 'definitely-not-installed-plugin' );
+
+ $this->assertSame(
+ array( 'The plugin is not installed yet.' ),
+ $messages,
+ 'Activation errors should state only the reason; the client adds the plugin name.'
+ );
+ }
+
+ /**
+ * @testdox Should ignore a requirement value that is not a version.
+ */
+ public function test_get_requirements_error_reason_ignores_non_version_values(): void {
+ $error = new WP_Error( 'incompatible_php_required_version', 'x' );
+
+ // Header values pass through filters, so this value is not guaranteed to be a string.
+ $this->assertSame(
+ '',
+ PluginsHelper::get_requirements_error_reason( $error, array( 'RequiresPHP' => array( '8.2' ) ) ),
+ 'An array requirement must not be coerced into the sentence.'
+ );
+ $this->assertSame(
+ '',
+ PluginsHelper::get_requirements_error_reason( $error, array( 'RequiresPHP' => true ) )
+ );
+ }
+
+ /**
+ * @testdox Should strip markup and cap the length of a requirement reason.
+ */
+ public function test_get_requirements_error_reason_is_normalized(): void {
+ $error = new WP_Error( 'incompatible_php_required_version', 'x' );
+
+ $this->assertSame(
+ 'It requires PHP 8.2 or newer, but this site runs PHP ' . PHP_VERSION . '.',
+ PluginsHelper::get_requirements_error_reason( $error, array( 'RequiresPHP' => '<b>8.2</b>' ) ),
+ 'Markup reaching us through a filter must not survive into the reason.'
+ );
+
+ $long = PluginsHelper::get_requirements_error_reason( $error, array( 'RequiresPHP' => str_repeat( '9', 5000 ) ) );
+ $this->assertSame( 301, mb_strlen( $long ), 'A requirement reason is capped like every other reason.' );
+ }
+
+ /**
+ * @testdox Should compose its own sentence for an unmet PHP requirement.
+ */
+ public function test_get_requirements_error_reason_for_php(): void {
+ $error = new WP_Error( 'incompatible_php_required_version', 'The package could not be installed.', 'core detail' );
+ $headers = array( 'RequiresPHP' => '8.2.0' );
+
+ $this->assertSame(
+ sprintf( 'It requires PHP 8.2.0 or newer, but this site runs PHP %s.', PHP_VERSION ),
+ PluginsHelper::get_requirements_error_reason( $error, $headers )
+ );
+ }
+
+ /**
+ * @testdox Should compose its own sentence for an unmet WordPress requirement.
+ */
+ public function test_get_requirements_error_reason_for_wordpress(): void {
+ $error = new WP_Error( 'incompatible_wp_required_version', 'The package could not be installed.', 'core detail' );
+ $headers = array( 'RequiresWP' => '9.9' );
+
+ $this->assertSame(
+ sprintf( 'It requires WordPress 9.9 or newer, but this site runs WordPress %s.', get_bloginfo( 'version' ) ),
+ PluginsHelper::get_requirements_error_reason( $error, $headers )
+ );
+ }
+
+ /**
+ * @testdox Should return an empty string for other error codes, non-errors, or a missing requirement value.
+ */
+ public function test_get_requirements_error_reason_returns_empty_otherwise(): void {
+ $headers = array( 'RequiresPHP' => '8.2.0' );
+
+ $this->assertSame( '', PluginsHelper::get_requirements_error_reason( new WP_Error( 'download_failed', 'x' ), $headers ) );
+ $this->assertSame( '', PluginsHelper::get_requirements_error_reason( null, $headers ) );
+ $this->assertSame(
+ '',
+ PluginsHelper::get_requirements_error_reason( new WP_Error( 'incompatible_php_required_version', 'x' ), array() ),
+ 'Without the required version from the package header there is nothing accurate to say.'
+ );
+ }
+
+ /**
+ * Close the output buffer the upgrader skin leaves open on its error path.
+ *
+ * WP_Upgrader_Skin::error() calls header() again because Automatic_Upgrader_Skin::header()
+ * never marks the header as done, so a failed install opens one more buffer than it closes.
+ *
+ * @param int $level The buffer level to return to.
+ */
+ private function close_upgrader_output_buffers( int $level ): void {
+ while ( ob_get_level() > $level ) {
+ ob_end_clean();
+ }
+ }
+
+ /**
+ * Short-circuit plugins_api() with a fixed response and point the upgrader at a package.
+ *
+ * @param string $download_link The package the upgrader should install.
+ * @return callable The filter callback, so the caller can remove it.
+ */
+ private function short_circuit_plugins_api( string $download_link ): callable {
+ $callback = function () use ( $download_link ) {
+ return (object) array(
+ 'name' => 'Foo',
+ 'slug' => 'foo',
+ 'version' => '1.0.0',
+ 'download_link' => $download_link,
+ );
+ };
+ add_filter( 'plugins_api', $callback );
+ return $callback;
+ }
+
+ /**
+ * @testdox Should report the reason for a failed download, which the upgrader only exposes through its skin.
+ */
+ public function test_install_plugins_reports_download_failure_reason(): void {
+ $api = $this->short_circuit_plugins_api( 'https://example.com/foo.zip' );
+ $download = function () {
+ return new WP_Error( 'download_failed', 'Download failed.', 'The site is unreachable.' );
+ };
+ add_filter( 'upgrader_pre_download', $download );
+
+ $ob_level = ob_get_level();
+ try {
+ $data = PluginsHelper::install_plugins( array( 'foo' ) );
+ } finally {
+ remove_filter( 'plugins_api', $api );
+ remove_filter( 'upgrader_pre_download', $download );
+ $this->close_upgrader_output_buffers( $ob_level );
+ }
+
+ $this->assertSame( array(), $data['installed'], 'A plugin whose download failed must not be reported as installed.' );
+ $this->assertSame( 'Download failed. The site is unreachable.', $data['errors']->get_error_message( 'foo' ) );
+ }
+
+ /**
+ * @testdox Should build the requirement reason from the version in the package header, which is what WordPress checked.
+ */
+ public function test_install_plugins_reports_requirement_from_package_header(): void {
+ if ( ! class_exists( 'ZipArchive' ) ) {
+ $this->markTestSkipped( 'ZipArchive is required to build the test package.' );
+ }
+
+ $package = wp_tempnam( 'foo.zip' );
+ $zip = new \ZipArchive();
+ $zip->open( $package, \ZipArchive::OVERWRITE );
+ $zip->addFromString( 'foo/foo.php', "<?php\n/**\n * Plugin Name: Foo\n * Requires PHP: 99.0\n */\n" );
+ $zip->close();
+
+ $api = $this->short_circuit_plugins_api( $package );
+
+ $ob_level = ob_get_level();
+ try {
+ $data = PluginsHelper::install_plugins( array( 'foo' ) );
+ } finally {
+ remove_filter( 'plugins_api', $api );
+ wp_delete_file( $package );
+ $this->close_upgrader_output_buffers( $ob_level );
+ }
+
+ $this->assertSame( array(), $data['installed'] );
+ $this->assertSame(
+ sprintf( 'It requires PHP 99.0 or newer, but this site runs PHP %s.', PHP_VERSION ),
+ $data['errors']->get_error_message( 'foo' )
+ );
+ }
+}