diff --git a/cspell.json b/cspell.json index b4c52d8..b5ae9a6 100644 --- a/cspell.json +++ b/cspell.json @@ -78,6 +78,7 @@ "wpautop", "wpdb", "WPMU", + "wpnonce", "wpunit" ], "ignoreWords": [ diff --git a/docs/conflict-handling.md b/docs/conflict-handling.md index fd284ea..df9afc7 100644 --- a/docs/conflict-handling.md +++ b/docs/conflict-handling.md @@ -135,15 +135,27 @@ example of this behavior. The guard cannot help on the request that *activates* the standalone: WordPress includes the plugin being activated **after** the bundled copy has already loaded, so that re-declaration is a real fatal. Core catches it in its activation sandbox and prints *"Plugin could not be activated because -it triggered a fatal error."* — true, and useless to whoever pressed the button. - -So the library filters `wp_admin_notice_markup` and swaps that sentence for the sub-plugin's -`conflict_notice_message`, falling back to a generic one naming the slug. This is what puts the -WordPress floor at 6.4: the filter does not exist before it. - -It touches nothing else. The markup comes back untouched unless every one of these holds — the -screen is `plugins`, or `plugins-network` in the network admin; the `plugin` query arg names a -standalone this library has registered; and `_error_nonce` verifies. +it triggered a fatal error."* — true, and useless to whoever pressed the button. Directly under that +sentence it embeds an iframe that runs the activation a second time with errors on display, so the +raw `Cannot redeclare …` prints inside the same notice box, contradicting whatever explanation sits +above it. + +So the library filters `wp_admin_notice_markup`, swaps that sentence for the sub-plugin's +`conflict_notice_message` — falling back to a generic one naming the slug — and takes the sandbox +iframe out with it. This is what puts the WordPress floor at 6.4: the filter does not exist before +it. + +**The same conflict has a second screen.** A fatal in the activation sandbox leaves the standalone +*paused*, so it returns to the plugins list with a **Resume** link, and pressing that fails +identically — worded *"Plugin could not be resumed because it triggered a fatal error."* Both +sentences are rewritten. + +It touches nothing else. The markup comes back exactly as it arrived unless every one of these holds +— the screen is `plugins`, or `plugins-network` in the network admin; `_error_nonce` verifies for a +standalone this library has registered, named by the `plugin` query arg on the activation screen and +by the nonce alone on the resume one, which carries no plugin name; and one of core's two sentences +is still there to replace. A notice failing any of them keeps its iframe as well: where this library +has nothing to say, core's diagnostic is the only thing that does. The replacement runs through `wp_kses_post()`, so a knowledge-base link survives, and a message that filters down to nothing leaves core's wording in place rather than blanking the notice. A host that diff --git a/src/Conflict/Rewriter.php b/src/Conflict/Rewriter.php index a0a6bb0..38e9b52 100644 --- a/src/Conflict/Rewriter.php +++ b/src/Conflict/Rewriter.php @@ -20,6 +20,11 @@ * useless to whoever pressed the button. All this library gets to do about it is reword that one * sentence, and the wording is the sub-plugin's own `conflict_notice_message`. * + * Rewording is not the whole of that screen, though. Core appends an `error_scrape` iframe to the + * sentence, and the iframe re-runs the activation sandbox with `display_errors` forced on — so the + * raw `Cannot redeclare …` fatal prints directly beneath the friendly explanation unless the iframe + * comes out with the sentence. + * * Its own class rather than a method on the notice writer, though the message is shared with the * merge notice. Nothing here is stored, drawn or queued: it reads the request, checks the screen, * verifies a nonce and edits markup core wrote. The writer would have needed a registry to find out @@ -54,8 +59,9 @@ public function __construct( Reader $registry ) { /** * The markup to print in place of the one WordPress was about to. * - * Handed back untouched unless the request really is a nonce-verified activation error, on a - * plugins screen, for a standalone this library has registered. + * Handed back untouched unless the request really is a nonce-verified activation or resume + * error, on a plugins screen, for a standalone this library has registered — and unless one of + * the two sentences core reports a plugin fatal with is still there to swap out. * * @since 1.0.0 * @@ -81,43 +87,21 @@ public function rewrite( string $markup ): string { return $markup; } - // phpcs:disable WordPress.Security.NonceVerification.Recommended -- verified below, once - // the plugin named turns out to be one this library owns. Nothing is acted on until then. - $basename = isset( $_GET['plugin'] ) ? wp_unslash( $_GET['plugin'] ) : ''; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- this read *is* the nonce. + $nonce = isset( $_GET['_error_nonce'] ) + ? sanitize_text_field( wp_unslash( $_GET['_error_nonce'] ) ) + : ''; - // Unslashed and no further. Core mints the activation-error nonce from - // wp_unslash( $_REQUEST['plugin'] ) verbatim (wp-admin/plugins.php), so sanitizing here - // would verify an action core never signed: a plugin whose folder name holds a '%xx' - // sequence, a '<' or a leading space comes back changed from sanitize_text_field(), and - // both the nonce check and the registry lookup below would then miss -- silently, and on - // the one screen this class exists to improve. Nothing sanitizing would remove is needed - // here either: the value is compared against a basename the host configured and hashed into - // a nonce action, and never reaches the page. What does reach it is the sub-plugin's message. - // - // is_string() because sanitize_text_field() was doing that job: '?plugin[]=x' arrives as an - // array, and an array reaching wp_verify_nonce() is a string conversion, not a refusal. - if ( ! is_string( $basename ) || $basename === '' ) { + if ( $nonce === '' ) { return $markup; } - // Looked up before the nonce is checked, deliberately: there is no nonce work to do for a - // plugin this library does not own, and the nonce is still verified before any markup is - // touched. - $sub_plugin = $this->find_by_standalone_basename( $basename ); + $sub_plugin = $this->find_by_activation_error( $nonce ) ?? $this->find_by_resume_error( $nonce ); if ( $sub_plugin === null ) { return $markup; } - $nonce = isset( $_GET['_error_nonce'] ) - ? sanitize_text_field( wp_unslash( $_GET['_error_nonce'] ) ) - : ''; - // phpcs:enable WordPress.Security.NonceVerification.Recommended - - if ( ! wp_verify_nonce( $nonce, 'plugin-activation-error_' . $basename ) ) { - return $markup; - } - $message = $sub_plugin->get_conflict_notice_message( sprintf( '%s is bundled with this plugin and loads automatically. The standalone copy cannot' @@ -137,10 +121,139 @@ public function rewrite( string $markup ): string { return $markup; } - // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- core's own string, matched on purpose. - $core_text = __( 'Plugin could not be activated because it triggered a fatal error.', 'default' ); + foreach ( $this->sentences_core_reports_a_fatal_with() as $sentence ) { + if ( strpos( $markup, $sentence ) === false ) { + continue; + } + + return $this->without_the_error_scrape( str_replace( $sentence, $message, $markup ) ); + } + + // Neither sentence is in there, so this notice was authored by something other than the + // screen this class knows how to improve. The iframe stays with it: a notice nobody + // explained is bad, and a notice nobody explained with its one diagnostic quietly deleted + // is worse. + return $markup; + } + + /** + * The two sentences core reports a sandboxed plugin fatal with, exactly as it prints them. + * + * Read back through `__()` against core's own text domain rather than written out as literals, + * so the swap still happens on a site running WordPress in another language: `wp_admin_notice()` + * receives the translated sentence, and an English needle would match nothing there. + * + * The resume wording belongs beside the activation one because the conflict outlives the + * activation screen. Core's fatal-error handler pauses the plugin its sandbox died in, so the + * standalone comes back on the plugins list with a Resume link, and pressing that re-runs the + * same sandbox into the same re-declaration — reported just as uselessly, in a different verb. + * + * @since 1.0.0 + * + * @return string[] + */ + private function sentences_core_reports_a_fatal_with(): array { + return [ + // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- core's own string, matched on purpose. + __( 'Plugin could not be activated because it triggered a fatal error.', 'default' ), + // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- core's own string, matched on purpose. + __( 'Plugin could not be resumed because it triggered a fatal error.', 'default' ), + ]; + } + + /** + * The registered sub-plugin an activation-error request names, if the nonce agrees. + * + * The `plugin` argument is unslashed and no further. Core mints the activation-error nonce from + * `wp_unslash( $_REQUEST['plugin'] )` verbatim (wp-admin/plugins.php), so sanitizing here would + * verify an action core never signed: a plugin whose folder name holds a '%xx' sequence, a '<' + * or a leading space comes back changed from sanitize_text_field(), and both the nonce check and + * the registry lookup would then miss -- silently, and on the one screen this class exists to + * improve. Nothing sanitizing would remove is needed here either: the value is compared against + * a basename the host configured and hashed into a nonce action, and never reaches the page. + * What does reach it is the sub-plugin's message. + * + * is_string() because sanitize_text_field() was doing that job: '?plugin[]=x' arrives as an + * array, and an array reaching wp_verify_nonce() is a string conversion, not a refusal. + * + * The registry is read before the nonce is verified, deliberately: there is no nonce work to do + * for a plugin this library does not own, and the nonce is still verified before any markup is + * touched. + * + * @since 1.0.0 + * + * @param string $nonce The `_error_nonce` this request carries. + * + * @return Sub_Plugin|null + */ + private function find_by_activation_error( string $nonce ): ?Sub_Plugin { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- verified below, once the + // plugin named turns out to be one this library owns. Nothing is acted on until then. + $basename = isset( $_GET['plugin'] ) ? wp_unslash( $_GET['plugin'] ) : ''; + + if ( ! is_string( $basename ) || $basename === '' ) { + return null; + } + + $sub_plugin = $this->find_by_standalone_basename( $basename ); + + if ( $sub_plugin === null ) { + return null; + } + + return wp_verify_nonce( $nonce, 'plugin-activation-error_' . $basename ) ? $sub_plugin : null; + } + + /** + * The registered sub-plugin a resume-error request is about, if the nonce agrees. + * + * Identified from the nonce alone, because core's resume redirect carries no `plugin` argument + * to read: `resume_plugin()` appends `_error_nonce` and nothing else, minted from + * `plugin-resume-error_` and the basename. The nonce is therefore the only thing on the request + * that names a plugin, so every registered standalone is offered to it in turn and the one it + * was signed for answers. A forged value still matches nothing -- being unable to name the + * plugin does not make the check weaker, only the loop longer. + * + * Behind `error=resuming`, which is not a second guess at the same thing. Without it the loop + * ran on every plugins-screen request carrying an `_error_nonce` the activation lookup missed -- + * an ordinary activation failure for somebody else's plugin -- and each miss fires core's + * `wp_verify_nonce_failed`, which security plugins hook to count and rate-limit failed nonces. A + * site with five sub-plugins produced five of them on a screen this library has nothing to do + * with. The gate costs nothing, because it is the request core itself dispatches the resume + * wording on: `resume_plugin()` is only ever handed `plugins.php?error=resuming`, and + * `wp-admin/plugins.php` words the notice off exactly that value. + * + * A sub-plugin with no standalone configured is skipped rather than tested against an action + * that ends in nothing, which is a question about a plugin that does not exist. + * + * @since 1.0.0 + * + * @param string $nonce The `_error_nonce` this request carries. + * + * @return Sub_Plugin|null + */ + private function find_by_resume_error( string $nonce ): ?Sub_Plugin { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- which screen this is, not + // an act to authorise; the nonce below is what authorises. + $error = isset( $_GET['error'] ) ? sanitize_text_field( wp_unslash( $_GET['error'] ) ) : ''; + + if ( $error !== 'resuming' ) { + return null; + } + + foreach ( $this->registry->all() as $sub_plugin ) { + if ( ! $sub_plugin->has_standalone_plugin() ) { + continue; + } + + $action = 'plugin-resume-error_' . $sub_plugin->get_standalone_plugin_basename(); - return str_replace( $core_text, $message, $markup ); + if ( wp_verify_nonce( $nonce, $action ) ) { + return $sub_plugin; + } + } + + return null; } /** @@ -166,4 +279,36 @@ private function find_by_standalone_basename( string $basename ): ?Sub_Plugin { return null; } + + /** + * The same notice with core's activation-sandbox iframe taken out of it. + * + * Core appends `
', $this->markup ) + ); + + $this->assertStringContainsString( 'Ours.', $filtered ); + $this->assertStringContainsString( $foreign, $filtered ); + $this->assertStringNotContainsString( 'error_scrape', $filtered ); + } + + /** + * Core's second wording for the same conflict. Its fatal-error handler pauses the plugin the + * activation sandbox died in, so the standalone reappears on the plugins list with a Resume + * link; pressing it re-runs the same sandbox into the same re-declaration, and core reports + * "could not be resumed" instead of "could not be activated". Matching only the activation + * wording leaves the owner reading core's useless sentence on the second screen after having + * been given a real explanation on the first. + */ + public function test_it_replaces_the_resume_wording(): void { + $this->arrange_resume_error(); + + $markup = $this->resume_error_notice(); + $rewriter = $this->make_rewriter( $this->standalone_owner( [ 'conflict_notice_message' => static fn() => 'Ours.' ] ) ); + + $filtered = $rewriter->rewrite( $markup ); + + $this->assertStringContainsString( 'Ours.', $filtered ); + $this->assertStringNotContainsString( self::RESUME_TEXT, $filtered ); + $this->assertStringStartsWith( '', $filtered );
+ }
+
+ /**
+ * The resume redirect carries no `plugin` argument at all — `resume_plugin()` appends only an
+ * `_error_nonce`, minted from `plugin-resume-error_` and the basename — so the nonce is the one
+ * thing on the request naming a plugin, and a nonce signed for somebody else's names somebody
+ * else's.
+ */
+ public function test_it_leaves_the_resume_markup_alone_for_a_plugin_no_sub_plugin_claims(): void {
+ $this->arrange_resume_error();
+
+ $markup = $this->resume_error_notice();
+
+ $this->assertStringContainsString(
+ 'Ours.',
+ $this->make_rewriter( $this->standalone_owner( [ 'conflict_notice_message' => static fn() => 'Ours.' ] ) )
+ ->rewrite( $markup )
+ );
+
+ $_GET['_error_nonce'] = wp_create_nonce( 'plugin-resume-error_akismet/akismet.php' );
+
+ $rewriter = $this->make_rewriter( $this->standalone_owner( [ 'conflict_notice_message' => static fn() => 'Ours.' ] ) );
+
+ $this->assertSame( $markup, $rewriter->rewrite( $markup ) );
+ }
+
+ /**
+ * The canary for the two constants above. Both are transcriptions of sentences that live in
+ * somebody else's source file, and the rewrite is a `str_replace()` against them — so the day
+ * core rewords either one, the replacement silently stops happening and every other test in this
+ * class goes on passing, because they all match our transcription against our transcription.
+ *
+ * Read out of `wp-admin/plugins.php` rather than through `__()`, which would hand back whatever
+ * string was passed in and assert nothing at all. The failure this catches is benign on a site —
+ * no match means core's own wording stands — but it is invisible without this, and the whole
+ * class exists to replace that wording.
+ */
+ public function test_core_still_words_the_fatal_the_way_this_class_expects(): void {
+ $plugins_screen = ABSPATH . 'wp-admin/plugins.php';
+
+ $this->assertFileExists( $plugins_screen );
+
+ $source = (string) file_get_contents( $plugins_screen );
+
+ $this->assertStringContainsString(
+ self::ACTIVATION_TEXT,
+ $source,
+ 'WordPress reworded the activation fatal. The rewrite no longer matches, and it fails silently.'
+ );
+ $this->assertStringContainsString(
+ self::RESUME_TEXT,
+ $source,
+ 'WordPress reworded the resume fatal. The rewrite no longer matches, and it fails silently.'
+ );
+ }
+
+ /**
+ * The resume lookup is the only one that has to try every registered standalone in turn, because
+ * the resume redirect carries no `plugin` argument to narrow it with — and every miss fires
+ * core's `wp_verify_nonce_failed`, which security plugins hook to count and rate-limit failed
+ * nonces. So it may only run on the request core dispatches the resume wording on. Here the
+ * request is an ordinary activation error for somebody else's plugin, which is the shape a site
+ * produces whenever any plugin fatals on activation: the rewrite declines, and it declines
+ * without asking a single question about our standalone's nonce.
+ */
+ public function test_it_does_not_hunt_for_a_resume_nonce_outside_a_resume_error(): void {
+ $asked = [];
+
+ add_action(
+ 'wp_verify_nonce_failed',
+ static function ( $nonce, $action ) use ( &$asked ) {
+ $asked[] = $action;
+ },
+ 10,
+ 2
+ );
+
+ // Somebody else's plugin fataled on activation: error=true, their basename, their nonce.
+ $_GET['error'] = 'true';
+ $_GET['plugin'] = 'akismet/akismet.php';
+ $_GET['_error_nonce'] = wp_create_nonce( 'plugin-activation-error_akismet/akismet.php' );
+
+ $markup = $this->activation_error_notice();
+ $rewriter = $this->make_rewriter( $this->standalone_owner() );
+
+ $this->assertSame( $markup, $rewriter->rewrite( $markup ) );
+ $this->assertSame(
+ [],
+ $asked,
+ 'A plugins screen about somebody else\'s plugin must not spend a nonce check per'
+ . ' registered standalone, one failed-nonce action each.'
+ );
+
+ // The recorder works: the same rewrite on a real resume request does ask, so the assertion
+ // above is about the gate rather than about a listener that never attached.
+ $this->arrange_resume_error();
+ $_GET['_error_nonce'] = wp_create_nonce( 'plugin-resume-error_akismet/akismet.php' );
+
+ $this->make_rewriter( $this->standalone_owner() )->rewrite( $this->resume_error_notice() );
+
+ $this->assertSame( [ 'plugin-resume-error_' . self::STANDALONE ], $asked );
}
/**
@@ -124,7 +307,7 @@ public function test_it_uses_the_sub_plugin_whose_standalone_the_request_names()
$_GET['plugin'] = 'give-fee-recovery/give-fee-recovery.php';
$_GET['_error_nonce'] = wp_create_nonce( 'plugin-activation-error_give-fee-recovery/give-fee-recovery.php' );
- $filtered = $rewriter->rewrite( self::MARKUP );
+ $filtered = $rewriter->rewrite( $this->activation_error_notice() );
$this->assertStringContainsString( 'The right one.', $filtered );
$this->assertStringNotContainsString( 'The wrong one.', $filtered );
@@ -154,7 +337,7 @@ public function test_it_rewrites_for_a_standalone_whose_basename_sanitizing_woul
$_GET['plugin'] = $standalone;
$_GET['_error_nonce'] = wp_create_nonce( 'plugin-activation-error_' . $standalone );
- $this->assertStringContainsString( 'Ours.', $rewriter->rewrite( self::MARKUP ) );
+ $this->assertStringContainsString( 'Ours.', $rewriter->rewrite( $this->activation_error_notice() ) );
}
/**
@@ -182,7 +365,7 @@ public function test_it_leaves_the_markup_alone_outside_the_admin(): void {
set_current_screen( 'front' );
- $this->assertSame( self::MARKUP, $rewriter->rewrite( self::MARKUP ) );
+ $this->assertSame( $this->markup, $rewriter->rewrite( $this->markup ) );
}
/**
@@ -197,7 +380,7 @@ public function test_it_leaves_the_markup_alone_off_the_plugins_screen(): void {
set_current_screen( 'dashboard' );
- $this->assertSame( self::MARKUP, $rewriter->rewrite( self::MARKUP ) );
+ $this->assertSame( $this->markup, $rewriter->rewrite( $this->markup ) );
}
/**
@@ -211,13 +394,14 @@ public function test_it_rewrites_the_markup_on_the_network_plugins_screen(): voi
set_current_screen( 'plugins-network' );
- $this->assertStringContainsString( 'Ours.', $rewriter->rewrite( self::MARKUP ) );
+ $this->assertStringContainsString( 'Ours.', $rewriter->rewrite( $this->markup ) );
}
/**
* The nonce is valid here, so the ownership lookup is the only thing that can stop the rewrite:
* an activation error for a plugin this library knows nothing about keeps core's wording, which
- * for that plugin is the accurate one.
+ * for that plugin is the accurate one — and keeps the sandbox iframe, which for that plugin is
+ * the only diagnostic anyone has.
*/
public function test_it_leaves_the_markup_alone_for_a_plugin_no_sub_plugin_claims(): void {
$this->assert_the_arrangement_rewrites();
@@ -227,7 +411,12 @@ public function test_it_leaves_the_markup_alone_for_a_plugin_no_sub_plugin_claim
$_GET['plugin'] = 'akismet/akismet.php';
$_GET['_error_nonce'] = wp_create_nonce( 'plugin-activation-error_akismet/akismet.php' );
- $this->assertSame( self::MARKUP, $rewriter->rewrite( self::MARKUP ) );
+ $markup = $this->activation_error_notice();
+
+ $filtered = $rewriter->rewrite( $markup );
+
+ $this->assertSame( $markup, $filtered );
+ $this->assertStringContainsString( 'error_scrape', $filtered );
}
/**
@@ -242,7 +431,7 @@ public function test_it_leaves_the_markup_alone( callable $arrange ): void {
$arrange();
- $this->assertSame( self::MARKUP, $rewriter->rewrite( self::MARKUP ) );
+ $this->assertSame( $this->markup, $rewriter->rewrite( $this->markup ) );
}
/**
@@ -299,11 +488,31 @@ static function (): void {
];
}
+ /**
+ * A notice carrying neither of core's two sentences was authored by something else, and this
+ * library has nothing to say about it. The iframe stays with it: a notice nobody explained is
+ * bad, and a notice nobody explained with its one diagnostic quietly deleted is worse.
+ */
+ public function test_it_leaves_a_notice_holding_neither_sentence_alone(): void {
+ $this->assert_the_arrangement_rewrites();
+
+ $rewriter = $this->make_rewriter( $this->standalone_owner( [ 'conflict_notice_message' => static fn() => 'Ours.' ] ) );
+
+ $markup = str_replace( self::ACTIVATION_TEXT, 'Something else went wrong.', $this->markup );
+
+ $filtered = $rewriter->rewrite( $markup );
+
+ $this->assertSame( $markup, $filtered );
+ $this->assertStringContainsString( 'error_scrape', $filtered );
+ }
+
/**
* The message is sanitised before it is checked for emptiness, and this is the case that pins
* the order: `wp_kses_post( '' )` is the empty string, so swapping core's
* wording for it would leave an empty notice box where the explanation should be. Leaving
- * core's sentence in place is the better of the two bad outcomes.
+ * core's sentence in place is the better of the two bad outcomes — and the iframe stays under
+ * it, because a screen still showing core's wording is a screen this library did not improve,
+ * and its raw fatal is the only thing left explaining anything.
*/
public function test_a_message_that_sanitises_away_leaves_the_markup_alone(): void {
$this->assert_the_arrangement_rewrites();
@@ -312,7 +521,10 @@ public function test_a_message_that_sanitises_away_leaves_the_markup_alone(): vo
$this->standalone_owner( [ 'conflict_notice_message' => static fn() => '' ] )
);
- $this->assertSame( self::MARKUP, $rewriter->rewrite( self::MARKUP ) );
+ $filtered = $rewriter->rewrite( $this->markup );
+
+ $this->assertSame( $this->markup, $filtered );
+ $this->assertStringContainsString( 'error_scrape', $filtered );
}
/**
@@ -323,7 +535,7 @@ public function test_a_whitespace_only_message_leaves_the_markup_alone(): void {
$rewriter = $this->make_rewriter( $this->standalone_owner( [ 'conflict_notice_message' => static fn() => " \n\t" ] ) );
- $this->assertSame( self::MARKUP, $rewriter->rewrite( self::MARKUP ) );
+ $this->assertSame( $this->markup, $rewriter->rewrite( $this->markup ) );
}
/**
@@ -342,7 +554,7 @@ public function test_it_strips_unsafe_markup_from_the_replacement_but_keeps_a_li
)
);
- $filtered = $rewriter->rewrite( self::MARKUP );
+ $filtered = $rewriter->rewrite( $this->markup );
$this->assertStringContainsString( 'the docs', $filtered );
$this->assertStringNotContainsString( 'onclick', $filtered );
@@ -366,7 +578,78 @@ private function assert_the_arrangement_rewrites(): void {
$this->assertStringContainsString(
'Ours.',
$this->make_rewriter( $this->standalone_owner( [ 'conflict_notice_message' => static fn() => 'Ours.' ] ) )
- ->rewrite( self::MARKUP )
+ ->rewrite( $this->markup )
+ );
+ }
+
+ /**
+ * Turn the request in setUp() into the one core redirects to when recovery mode cannot resume a
+ * plugin: `resume_plugin()` appends an `_error_nonce` for `plugin-resume-error_ ' . self::CORE_TEXT . ' ', $filtered );
+ // Core's own diagnostic goes with the sentence it explained. Left behind, the iframe
+ // re-includes the standalone in a sandbox and prints the re-declaration fatal underneath
+ // the friendly message that just said there was nothing wrong.
+ $this->assertStringNotContainsString( 'error_scrape', $filtered );
+
+ // The notice box stays core's to draw — its id, its classes, its wrapper. Only the sentence
+ // inside belongs to this library.
+ $this->assertStringStartsWith( ' ', $filtered );
+ }
+
+ /**
+ * The activation-error notice as `wp-admin/plugins.php` assembles it: core's sentence, the
+ * `error_scrape` iframe it appends whenever the request carries a verifying `_error_nonce`, and
+ * core's own notice box around both.
+ *
+ * @return string
+ */
+ private function activation_error_notice(): string {
+ $plugin = isset( $_GET['plugin'] ) && is_string( $_GET['plugin'] ) ? $_GET['plugin'] : '';
+ $nonce = isset( $_GET['_error_nonce'] ) && is_string( $_GET['_error_nonce'] ) ? $_GET['_error_nonce'] : '';
+
+ $iframe_url = add_query_arg(
+ [
+ 'action' => 'error_scrape',
+ 'plugin' => urlencode( $plugin ),
+ '_wpnonce' => urlencode( $nonce ),
+ ],
+ admin_url( 'plugins.php' )
+ );
+
+ return wp_get_admin_notice(
+ self::CORE_TEXT
+ . '