diff --git a/AGENTS.md b/AGENTS.md index dd8ccc5..4508e44 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -212,7 +212,7 @@ that drives the whole of it against a real WordPress is `tests/unit/Scenario/`. | `src/Plugin/` | `Deactivator` (turns the standalone off), `Checker` (answers whether a plugin is active in either scope, and whether it is network-active — `is_plugin_active_for_network()`, which is `false` off a network so no caller needs an `is_multisite()` guard), `Loads_Plugin_Functions` (pulls in `wp-admin/includes/plugin.php`), `Contracts\Deactivator_Interface`, `Contracts\Checker_Interface`. The only files that touch WordPress plugin functions. | | `src/Registry/` | `Registrar` (holds registered `Sub_Plugin` objects), `Reader` (the registration buffer, drained into the registrar on the way past; the object every pass reads the registry through), `Contracts\Registrar_Interface`. | | `src/Activator.php` | Runs a sub-plugin's activation callback once ever, recorded in one option. | -| `src/Conflict/` | `Detector` (whether a standalone is in the way), `Resolver` (which policy branch to take), `Gatekeeper` (which requests, and which users, may have one resolved), `Redirector` (where the user lands afterwards), `Rewriter` (rewrites the activation-error screen for a registered standalone), `Contracts\Resolver_Interface`. | +| `src/Conflict/` | `Detector` (whether a standalone is in the way), `Resolver` (which policy branch to take), `Gatekeeper` (which requests, and which users, may have one resolved), `Redirector` (where the user lands afterwards), `Rewriter` (rewrites the activation- and resume-error screens for a registered standalone), `Contracts\Resolver_Interface`. | | `src/Traits/` | `Guards_Hook_Prefix` (a missing prefix warns and stands down rather than throwing), `Guards_Plugin_Capability` (which capability a plugin act asks for, shared by the conflict gate and the notice queue). Cross-cutting only: a trait used by one folder lives in that folder. | | `src/Notices/` | `Writer` (what a notice says, stored under `slug:type` — `merge`, `conflict`, `stranding`, `dependency`), `Presenter` (who may consume it, render-then-clear), `Store` (keeps it), `Renderer` (draws it, `notice-error` for `dependency` and `notice-warning` for the rest), `Contracts\Writer_Interface`. | | `src/Contracts/`, `src/Exceptions/` | `Provider_Interface`, `Activator_Interface`, `Config_Exception`. | @@ -415,9 +415,21 @@ takes an **untyped** argument: a filter receives whatever the filter before it r the screen least able to afford a second one. The rewrite refuses unless the screen is `plugins` or `plugins-network` — `wp-admin/network/plugins.php` is a one-line require of the other and draws the identical error, and on a default multisite it is the only screen an absorbed standalone can be -reactivated from — the `plugin` query arg names a registered standalone and `_error_nonce` verifies. -And it sanitises with `wp_kses_post()` *before* testing for emptiness, since a message that filters -down to nothing must leave core's wording standing rather than blank the notice box. +reactivated from — `_error_nonce` verifies for a registered standalone, and one of core's two +sentences is still there to swap out. And it sanitises with `wp_kses_post()` *before* testing for +emptiness, since a message that filters down to nothing must leave core's wording standing rather +than blank the notice box. + +**Two sentences, and the sandbox iframe comes out with whichever one is rewritten.** The resume screen +is not the activation screen's sequel: `plugin_sandbox_scrape()` defines `WP_SANDBOX_SCRAPING`, which +stands the fatal-error handler down, so a failed activation pauses nothing. What pauses a standalone +is a fatal on an ordinary request, and the Resume link appears in recovery mode. That redirect carries +no `plugin` arg, so the nonce is tried against every registered standalone — behind `error=resuming`, +since otherwise somebody else's failed activation fires `wp_verify_nonce_failed` once per sub-plugin. +Core's `error_scrape` iframe re-runs the fatal with `display_errors` on, so it goes with a sentence +that was replaced and stays on a notice that was not, where it is the only diagnostic left. Core has +stripped that iframe itself since 6.4 — `wp_admin_notice()` echoes through `wp_kses_post()`, which +allows no `#i', '', $markup ); + + // Null means the match itself failed -- a backtrack limit on a notice far larger than core's. + // The reworded sentence is worth keeping on its own. + return is_string( $stripped ) ? $stripped : $markup; + } } diff --git a/tests/README.md b/tests/README.md index 752625b..b04794f 100644 --- a/tests/README.md +++ b/tests/README.md @@ -935,10 +935,15 @@ load guard cannot prevent: the owner reinstalls the standalone and presses Activate, WordPress includes it on top of the bundled copy, and the re-declaration is a real fatal that core's sandbox reports as "the plugin triggered a fatal error" — true, and useless. All the library gets to do is -reword the sentence. Driven through core's own filter dispatch rather than by -calling the rewriter, because the admin-only `add_filter()` is half of what has -to work. The notice box stays core's — its classes, its dismiss button, its -wrapper; only the sentence inside is ours. +reword the sentence — either of core's two, since the same box carries "could +not be resumed" out of recovery mode — and take core's `error_scrape` iframe +down with it, which would otherwise re-include the standalone in a sandbox and +print the re-declaration fatal under the message that just explained it away. +Driven through core's own filter dispatch rather than by calling the rewriter, +because the admin-only `add_filter()` is half of what has to work, and off a +fixture core's own `wp_get_admin_notice()` assembled, iframe included — an +invented notice would leave the removal proven by nothing. The notice box stays +core's: its id, its classes, its wrapper; only the sentence inside is ours. ```mermaid sequenceDiagram @@ -952,7 +957,7 @@ sequenceDiagram WP->>WP: redirects to plugins.php with plugin and _error_nonce WP->>Rw: wp_admin_notice_markup filter Rw->>Rw: screen is plugins, arg names a registered standalone, nonce verifies - Rw-->>WP: core's sentence swapped for the host's, wrapper untouched + Rw-->>WP: core's sentence swapped for the host's, error_scrape iframe removed, wrapper untouched ``` #### `Scenario/HostTest.php` — the host's own wiring diff --git a/tests/unit/Conflict/RewriterTest.php b/tests/unit/Conflict/RewriterTest.php index d2e2a38..85a80d0 100644 --- a/tests/unit/Conflict/RewriterTest.php +++ b/tests/unit/Conflict/RewriterTest.php @@ -20,7 +20,8 @@ * The one conflict the load guard cannot prevent: WordPress includes the standalone after the * bundled copy has already loaded, the re-declaration is a real fatal, and core's activation sandbox * reports it as "the plugin triggered a fatal error" — true, and useless to whoever pressed the - * button. This class covers rewriting that sentence, and every reason not to. + * button. This class covers rewriting that sentence, taking core's sandbox iframe out from under it, + * and every reason not to do either. * * @since 1.0.0 */ @@ -35,7 +36,8 @@ class RewriterTest extends WPTestCase { private const STANDALONE = 'give-recurring/give-recurring.php'; /** - * Core's own sentence, spelled out rather than built with `__()`. + * Core's sentence for a plugin that fataled while being activated, spelled out rather than built + * with `__()`. * * Restating it is the point: the rewrite is a `str_replace()` against this exact string, so if * core ever rewords it the replacement silently stops happening, and a test that asked core for @@ -43,14 +45,25 @@ class RewriterTest extends WPTestCase { * * @var string */ - private const CORE_TEXT = 'Plugin could not be activated because it triggered a fatal error.'; + private const ACTIVATION_TEXT = 'Plugin could not be activated because it triggered a fatal error.'; + + /** + * Core's other wording for the same fatal, printed when recovery mode fails to resume the + * plugin (`wp-admin/plugins.php`, the `'resuming' === $_GET['error']` branch). + * + * @var string + */ + private const RESUME_TEXT = 'Plugin could not be resumed because it triggered a fatal error.'; /** * The notice core is about to print, as `wp_admin_notice_markup` hands it over. * + * Rebuilt per test rather than held as a constant, because the iframe core appends carries the + * request's own plugin name and nonce, and a constant cannot call `admin_url()`. + * * @var string */ - private const MARKUP = '

' . self::CORE_TEXT . '

'; + private $markup = ''; public function setUp(): void { parent::setUp(); @@ -64,15 +77,18 @@ public function setUp(): void { // Every test starts from the request core actually redirects to after a sandboxed fatal, and // states only the part it is about. + $_GET['error'] = 'true'; $_GET['plugin'] = self::STANDALONE; $_GET['_error_nonce'] = wp_create_nonce( 'plugin-activation-error_' . self::STANDALONE ); + + $this->markup = $this->activation_error_notice(); } public function tearDown(): void { // In tearDown rather than at the end of each test body: a failed assertion would otherwise // leave an admin screen and a half-built activation-error request standing for every test // that runs afterwards in this process. - unset( $_GET['plugin'], $_GET['_error_nonce'] ); + unset( $_GET['error'], $_GET['plugin'], $_GET['_error_nonce'] ); set_current_screen( 'front' ); Config_State::reset(); @@ -84,24 +100,191 @@ public function test_it_replaces_the_fatal_error_text_with_the_configured_messag $this->standalone_owner( [ 'conflict_notice_message' => static fn() => 'Give Recurring is already bundled with Give.' ] ) ); - $filtered = $rewriter->rewrite( self::MARKUP ); + $filtered = $rewriter->rewrite( $this->markup ); $this->assertStringContainsString( 'Give Recurring is already bundled with Give.', $filtered ); - $this->assertStringNotContainsString( self::CORE_TEXT, $filtered ); + $this->assertStringNotContainsString( self::ACTIVATION_TEXT, $filtered ); - // The notice box stays core's to draw — its classes, its dismiss button, its wrapper. Only - // the sentence inside belongs to this library. - $this->assertStringStartsWith( '

', $filtered ); + // The notice box stays core's to draw — its id, its classes, its wrapper. Only what is + // inside the paragraph belongs to this library. + $this->assertStringStartsWith( '

', $filtered ); $this->assertStringEndsWith( '

', $filtered ); } public function test_the_default_names_the_sub_plugin(): void { - $filtered = $this->make_rewriter( $this->standalone_owner() )->rewrite( self::MARKUP ); + $filtered = $this->make_rewriter( $this->standalone_owner() )->rewrite( $this->markup ); // The fallback is not pinned word for word — it is allowed to be reworded, as long as it // still names the sub-plugin and still displaces core's sentence. $this->assertStringContainsString( 'give-recurring', $filtered ); - $this->assertStringNotContainsString( self::CORE_TEXT, $filtered ); + $this->assertStringNotContainsString( self::ACTIVATION_TEXT, $filtered ); + } + + /** + * Rewording the sentence is only half the screen. Core appends an iframe requesting + * `plugins.php?action=error_scrape`, which runs `plugin_sandbox_scrape()` again with + * `display_errors` forced on — so the raw `Cannot redeclare …` fatal prints inside the same + * notice box, immediately under the friendly explanation that just said the situation is + * handled. Whichever of the two the owner believes, one of them wasted their afternoon. + */ + public function test_it_removes_the_activation_sandbox_iframe(): void { + // The fixture really is core's: the iframe is in it before the rewrite runs. + $this->assertStringContainsString( 'error_scrape', $this->markup ); + $this->assertStringContainsString( 'markup ); + + $rewriter = $this->make_rewriter( $this->standalone_owner( [ 'conflict_notice_message' => static fn() => 'Ours.' ] ) ); + + $filtered = $rewriter->rewrite( $this->markup ); + + $this->assertStringContainsString( 'Ours.', $filtered ); + $this->assertStringNotContainsString( 'assertStringNotContainsString( 'error_scrape', $filtered ); + + // Nothing else core wrote went with it. Asserted against core's own builder rather than a + // literal box, so this stays a claim about the iframe and not about the day core adds a + // class to its notices. + $this->assertSame( $this->notice_box( 'Ours.' ), $filtered ); + } + + /** + * The removal is aimed at one request in wp-admin — the one that re-runs the fatal — and not at + * iframes. Another plugin filtering this notice ahead of us is entitled to have put something of + * its own in it, and deleting that would be this library breaking a screen it came to fix. + */ + public function test_it_leaves_an_iframe_that_is_not_the_sandbox_scrape_alone(): void { + $foreign = ''; + + $rewriter = $this->make_rewriter( $this->standalone_owner( [ 'conflict_notice_message' => static fn() => 'Ours.' ] ) ); + + $filtered = $rewriter->rewrite( + str_replace( '

', $foreign . '

', $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_` to + * `plugins.php?error=resuming&…`, and there is no `plugin` argument anywhere on it. + */ + private function arrange_resume_error(): void { + unset( $_GET['plugin'] ); + + $_GET['error'] = 'resuming'; + $_GET['_error_nonce'] = wp_create_nonce( 'plugin-resume-error_' . self::STANDALONE ); + } + + /** + * The activation-error notice as `wp-admin/plugins.php` assembles it, for whatever plugin and + * nonce the request currently names. + * + * Transcribed from core rather than approximated, because both halves of the rewrite are + * decided by what is really in that string: the sentence core's `else` branch chooses, and the + * `error_scrape` iframe appended after it whenever the request carries a verifying + * `_error_nonce` — which, on every request this class acts on, it does. A fixture ending at the + * sentence would leave the iframe removal proven by nothing. + */ + 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 $this->notice_box( + self::ACTIVATION_TEXT + . '' + ); + } + + /** + * The resume-error notice as `wp-admin/plugins.php` assembles it. + * + * No iframe under this one, and that is core's doing rather than a shortcut here: the iframe is + * appended only when `_error_nonce` verifies against `plugin-activation-error_` plus the + * `plugin` argument, and the resume redirect carries neither a `plugin` argument nor that kind + * of nonce. + */ + private function resume_error_notice(): string { + return $this->notice_box( self::RESUME_TEXT ); + } + + /** + * The box core wraps either sentence in, built by core's own function so the id, the classes and + * the paragraph wrap are whatever this WordPress really produces. + * + * @param string $errmsg The message core assembled. + * + * @return string + */ + private function notice_box( string $errmsg ): string { + return wp_get_admin_notice( + $errmsg, + [ + 'id' => 'message', + 'additional_classes' => [ 'error' ], + ] ); } diff --git a/tests/unit/Scenario/ConflictTest.php b/tests/unit/Scenario/ConflictTest.php index db87a27..9c3399a 100644 --- a/tests/unit/Scenario/ConflictTest.php +++ b/tests/unit/Scenario/ConflictTest.php @@ -33,13 +33,6 @@ class ConflictTest extends Bootstrap_Test_Case { */ private const CORE_TEXT = 'Plugin could not be activated because it triggered a fatal error.'; - /** - * The notice core is about to print, as `wp_admin_notice_markup` hands it over. - * - * @var string - */ - private const MARKUP = '

' . self::CORE_TEXT . '

'; - /** * A second sub-plugin, for the scenarios that need the conflict to sit behind one. * @@ -690,13 +683,22 @@ public function test_a_reactivation_attempt_yields_the_friendly_message(): void ] ); - $this->boot(); - // The request core redirects to once the sandboxed activation has fataled. $_GET['plugin'] = self::STANDALONE; $_GET['_error_nonce'] = wp_create_nonce( 'plugin-activation-error_' . self::STANDALONE ); - $rewritten = apply_filters( 'wp_admin_notice_markup', self::MARKUP, self::CORE_TEXT, [] ); + // Built before boot(), because wp_get_admin_notice() dispatches wp_admin_notice_markup + // itself: assembled afterwards, the fixture would arrive already rewritten and the dispatch + // below would be asserting against its own output. + $markup = $this->activation_error_notice(); + + // The arrangement is only worth asserting on if the fixture is the thing core really hands + // over, iframe included -- an approximation of it would leave the removal proven by nothing. + $this->assertStringContainsString( 'error_scrape', $markup ); + + $this->boot(); + + $rewritten = apply_filters( 'wp_admin_notice_markup', $markup, self::CORE_TEXT, [] ); $this->assertIsString( $rewritten, 'The filter must hand back markup, whatever it did with it.' ); @@ -705,9 +707,44 @@ public function test_a_reactivation_attempt_yields_the_friendly_message(): void $this->assertStringContainsString( 'Recurring is already bundled with the host plugin.', $filtered ); $this->assertStringNotContainsString( self::CORE_TEXT, $filtered ); - // The notice box stays core's to draw — its classes, its dismiss button, its wrapper. Only - // the sentence inside belongs to this library. - $this->assertStringStartsWith( '

', $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 + . '', + [ + 'id' => 'message', + 'additional_classes' => [ 'error' ], + ] + ); } /**