diff --git a/docs/superpowers/plans/2026-08-12-container-required-rework.md b/docs/superpowers/plans/2026-08-12-container-required-rework.md index 7b41c1e..e27f91b 100644 --- a/docs/superpowers/plans/2026-08-12-container-required-rework.md +++ b/docs/superpowers/plans/2026-08-12-container-required-rework.md @@ -295,7 +295,9 @@ level 9, so most breakage surfaces from `composer test:analysis` without standin ### Task 15 — `15-e2e-suite` -- [ ] The end-to-end bootstrap now builds a container and calls `Provider::register()` before `Loader::boot()`. +- [x] The end-to-end bootstrap now hands `Config::set_container()` a bare container and lets `Loader::boot()` + run `Provider::register()` over it, which is the sequence a host runs. A test about a rebinding host binds + its own implementations into that container first. ### Task 16 — `16-readme-release` diff --git a/tests/README.md b/tests/README.md index f2246d1..6c7c165 100644 --- a/tests/README.md +++ b/tests/README.md @@ -116,9 +116,21 @@ $this->assertSame( 1, $this->bundled_plugin_loads() ); Every call writes a *new* file under a unique name, and every guard constant is unique too. Neither is tidiness: `require_once` dedupes by resolved path for the lifetime of the PHP process, so a shared fixture lets a later test pass -without loading anything, and the fixture defines its constant for real, so a -reused name makes a later sub-plugin read as already loaded. Call -`remove_bundled_plugin_files()` from tearDown. +without loading anything — including if the load logic were deleted outright — +and the fixture defines its constant for real, so a reused name makes a later +sub-plugin read as already loaded. + +The generated file does two things and nothing else: it increments a load +counter in `$GLOBALS`, and it defines the constant inside a `defined()` check, +the shape a real bundled sub-plugin has. The counter is what separates "loaded +twice" and "never loaded" from "loaded once"; the constant alone cannot tell +those three apart. + +`remove_bundled_plugin_files()` cleans up on the trait's own `@after` hook, and +the tests that clear other state alongside it call it from tearDown as well. +Never leave it to the end of a test body: a failed assertion aborts the test +where it stands, so that is exactly the line that does not run on the day it +matters. A fixture helper cannot be called `make()`, `makeEmpty()`, `construct()`, or `constructEmpty()`: those are public methods on `Codeception\Test\Unit`, which @@ -289,3 +301,52 @@ $this->assert_the_library_reported_incorrect_usage(); An unexpected report still fails the test, because everything the listener sees is recorded and asserted to belong to this library. Call `stop_expecting_incorrect_usage()` from tearDown. + +## The end-to-end suite + +`tests/unit/EndToEndTest.php` drives the library the way a host plugin does, +against real WordPress state: the real `active_plugins` option, a real +`deactivate_plugins()` that really writes it, and real site options behind the +notice queue and the activation record. Nothing about the library is doubled, +except in the two tests that are *about* a host binding its own collaborators. + +It reaches for no entry point a host does not have. The bootstrap is +`Config::set_hook_prefix()`, `Config::set_container()`, `Loader::register()` and +`Loader::boot()`, and everything after that arrives through the hooks boot() +wired — a request is `do_action( 'plugins_loaded' )`, an admin page load is +`do_action( 'all_admin_notices' )`. The container is handed over bare rather +than through `WithContainer`, because `boot()` running the provider over it is +one of the steps under test. + +Only two functions are stubbed: `wp_safe_redirect`, which throws so the request +halts where production calls `exit`, and `wp_get_referer`, which is a request +header no test can send and which decides where that redirect would have gone. + +Four preconditions have to hold before any of it means anything, and setUp +establishes all four: + +- **An interactive admin GET** — `set_current_screen( 'plugins' )` plus + `$_SERVER['REQUEST_METHOD'] = 'GET'`. `Conflict\Gatekeeper` turns away + anything else, so without both of these every policy test would pass while + resolving nothing at all. +- **A user who can `activate_plugins`** — `WithUsers::become_plugin_administrator()`. + The gatekeeper checks the capability before anything is resolved, and the + queue checks the same one before it renders, so as nobody the suite would be + asserting that a no-op is a no-op. +- **The hook prefix** — `Config::set_hook_prefix()`. Both plugins_loaded steps + report and return without one, and the queue and activation option names are + derived from it. +- **A rewound `plugins_loaded` counter** — the harness dispatched the hook + before any test ran, so `boot()` would rightly report that it is too late to + wire and run everything inline. tearDown puts the count back. + +The screen, the request method and that counter are all process-global, so all +three are restored in teardown; leaving any of them set turns an unrelated later +test into an admin request. + +Run both legs — `slic run unit` and `slic run unit --env multisite`. Multisite +is not a formality here: `deactivate_plugins()` is network-aware, +`activate_plugins` maps through `manage_network_plugins` so the administrator +who passes on singlesite is not the one who passes on multisite, and the queue +and activation record are `get_site_option()` values, which are network options +there. Every precondition above resolves differently on the second leg. diff --git a/tests/_support/Traits/WithBundledPlugins.php b/tests/_support/Traits/WithBundledPlugins.php index 6d96ef2..96f0bd9 100644 --- a/tests/_support/Traits/WithBundledPlugins.php +++ b/tests/_support/Traits/WithBundledPlugins.php @@ -30,6 +30,14 @@ trait WithBundledPlugins { /** * Write a bundled plugin that counts its own loads and defines its guard constant. * + * The counter is what separates "loaded twice" and "never loaded" from "loaded once". The + * constant alone cannot tell those three apart, because it ends up defined exactly once either + * way. + * + * The constant is defined inside a `defined()` check, because the file stands in for a real + * plugin: a bundled copy that redeclared a constant the standalone had already defined would + * raise a notice, and the guard is what a plugin actually ships. + * * @since 1.0.0 * * @param string $constant Guard constant the file defines, as a bundled plugin's own header would. @@ -100,7 +108,14 @@ protected function reset_bundled_plugin_loads(): void { } /** - * Remove every fixture this test wrote. Call from tearDown. + * Remove every fixture this test wrote. + * + * Runs itself, as PHPUnit's own `@after` hook, and is safe to call from tearDown as well — which + * is where the tests that clear other state alongside it do call it. A test body must never be + * the only thing that removes these: a failed assertion aborts the test where it stands, so a + * cleanup line at the end of the body is exactly the one that does not run on the day it matters. + * + * @after * * @since 1.0.0 * diff --git a/tests/unit/EndToEndTest.php b/tests/unit/EndToEndTest.php new file mode 100644 index 0000000..8b8daf1 --- /dev/null +++ b/tests/unit/EndToEndTest.php @@ -0,0 +1,955 @@ +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 . '
', $filtered );
+ }
+
+ /**
+ * The guard is not only about the standalone. A must-use copy, a second host plugin bundling the
+ * same code, or the site owner's own snippet all define the same constant, and any of them means
+ * the code is already in memory.
+ */
+ public function test_the_bundled_copy_stands_down_when_the_guard_is_already_defined(): void {
+ $constant = $this->define_guard( 'ABSORBER_E2E_ALREADY_LOADED_GUARD' );
+
+ $this->register( [], $constant );
+
+ $this->boot();
+ $this->run_request();
+
+ $this->assertSame( 0, $this->bundled_plugin_loads() );
+ $this->assertSame( [], $this->notice_queue(), 'A plugin the admin can see running has nothing to explain.' );
+ }
+
+ /**
+ * The toggle is read on every request rather than resolved at registration, so flipping it and
+ * running the next request is what proves the first load was skipped for the toggle and not for
+ * something else entirely — a missing file, say, which would leave the same empty counter.
+ */
+ public function test_a_sub_plugin_toggled_off_loads_nothing(): void {
+ $enabled = false;
+
+ $constant = $this->register(
+ [
+ 'enabled' => static function () use ( &$enabled ) {
+ return $enabled;
+ },
+ ]
+ );
+
+ $this->boot();
+ $this->run_request();
+
+ $this->assertSame( 0, $this->bundled_plugin_loads() );
+ $this->assertFalse( defined( $constant ) );
+ $this->assertSame( [], $this->notice_queue(), 'A sub-plugin nobody asked for has nothing to report.' );
+
+ $enabled = true;
+
+ $this->run_request();
+
+ $this->assertSame( 1, $this->bundled_plugin_loads(), 'The toggle is the only thing that was stopping it.' );
+ }
+
+ /**
+ * The host's last word before the require, on the hook name its own prefix builds.
+ */
+ public function test_the_should_load_filter_can_veto_a_load(): void {
+ $constant = $this->register();
+
+ $this->add_tracked_filter(
+ Config::get_hook_name( 'should_load' ),
+ static function ( $should_load, $sub_plugin ) {
+ return $sub_plugin instanceof Sub_Plugin && $sub_plugin->get_slug() === self::SLUG
+ ? false
+ : $should_load;
+ },
+ 10,
+ 2
+ );
+
+ $this->boot();
+ $this->run_request();
+
+ $this->assertSame( 0, $this->bundled_plugin_loads() );
+ $this->assertFalse( defined( $constant ) );
+ $this->assertSame( [], $this->notice_queue(), 'A host that vetoed the load does not need telling about it.' );
+ }
+
+ /**
+ * Registration order, not slug order and not filesystem order: a host bundles plugins that
+ * depend on one another, and the order it registers them in is the only say it gets.
+ */
+ public function test_two_sub_plugins_load_in_one_request_in_registration_order(): void {
+ $loaded = [];
+
+ // Recorded from the activation callback, which runs immediately after each require — so this
+ // is the order the files were really required in, not the order they were registered in.
+ $record = static function ( Sub_Plugin $sub_plugin ) use ( &$loaded ): void {
+ $loaded[] = $sub_plugin->get_slug();
+ };
+
+ $first = $this->register( [ 'activation_callback' => $record ] );
+ $second = $this->register(
+ [
+ 'slug' => 'absorber-fee-recovery',
+ 'activation_callback' => $record,
+ ]
+ );
+
+ $this->boot();
+ $this->run_request();
+
+ $this->assertSame( 2, $this->bundled_plugin_loads() );
+ $this->assertTrue( defined( $first ) );
+ $this->assertTrue( defined( $second ) );
+ $this->assertSame( [ self::SLUG, 'absorber-fee-recovery' ], $loaded );
+ }
+
+ /**
+ * All the way to the screen again, from the other end: the load is skipped, the host's own
+ * explanation is queued, the render draws it as an error, and the render consumes the queue so
+ * the owner is told once rather than on every admin page load for ever.
+ */
+ public function test_an_unmet_dependency_blocks_the_load_and_queues_the_explanation(): void {
+ $this->register(
+ [
+ 'dependency_check' => static fn() => false,
+ 'dependency_notice_message' => static fn() => 'GiveWP 3.0 or later is required.',
+ ]
+ );
+
+ $this->boot();
+ $this->run_request();
+
+ $this->assertSame( 0, $this->bundled_plugin_loads() );
+ $this->assertSame(
+ [ self::SLUG . ':dependency' => 'GiveWP 3.0 or later is required.' ],
+ $this->notice_queue()
+ );
+
+ $rendered = $this->render_admin_notices();
+
+ $this->assertStringContainsString( 'GiveWP 3.0 or later is required.', $rendered );
+ $this->assertStringContainsString( 'notice-error', $rendered, 'A plugin that did not load at all is an error.' );
+ $this->assertSame( [], $this->notice_queue(), 'Rendering consumes the queue.' );
+ }
+
+ /**
+ * Booting from plugins_loaded at the default priority is the commonest hook mistake there is, and
+ * an add_action() at a priority the running dispatch has already passed is accepted and then never
+ * fires. The library reports the mistake and runs the sequence inline, so the site the host
+ * shipped still gets its bundled plugins.
+ */
+ public function test_a_host_that_boots_too_late_still_gets_its_sub_plugins(): void {
+ $this->expect_incorrect_usage();
+
+ $constant = $this->register();
+
+ $this->add_tracked_action(
+ 'plugins_loaded',
+ function (): void {
+ $this->boot();
+ }
+ );
+
+ $this->run_request();
+
+ $this->assertSame( 1, $this->bundled_plugin_loads(), 'A late boot must still load.' );
+ $this->assertTrue( defined( $constant ) );
+ $this->assert_the_library_reported_incorrect_usage();
+ }
+
+ /**
+ * The whole point of a required container: a host binds its own implementation of an interface
+ * before boot, and that is the object the library uses for the rest of the request.
+ *
+ * One request covers the conflict step and the load pass because the referrer is the plugins
+ * list, where the redirector says to stay put. The defaults are asserted *not* to have run
+ * alongside the doubles — a library that resolved a second copy of the queue or the deactivator
+ * behind the host's back would satisfy every positive assertion here.
+ */
+ public function test_a_host_binding_reaches_every_step_of_the_request(): void {
+ $registrar = new Spy_Registrar();
+ $notices = new Spy_Queue();
+ $activator = new Spy_Activator();
+
+ $checker = new class() implements Plugin_Checker_Interface {
+ /**
+ * @var string[]
+ */
+ public $basenames = [];
+
+ /**
+ * @param string $basename Plugin basename.
+ *
+ * @return bool
+ */
+ public function is_active( string $basename ): bool {
+ $this->basenames[] = $basename;
+
+ return true;
+ }
+ };
+
+ $deactivator = new class() implements Plugin_Deactivator_Interface {
+ /**
+ * @var string[]
+ */
+ public $basenames = [];
+
+ /**
+ * @param string $basename Plugin basename.
+ *
+ * @return void
+ */
+ public function deactivate( string $basename ): void {
+ $this->basenames[] = $basename;
+ }
+ };
+
+ // Really active, so that the default deactivator would have emptied this option had it been
+ // the one reached. Nothing else in this test would notice the difference.
+ update_option( 'active_plugins', [ self::STANDALONE ] );
+
+ $container = new Test_Container();
+ $container->singleton(
+ Registrar_Interface::class,
+ static function () use ( $registrar ): Registrar_Interface {
+ return $registrar;
+ }
+ );
+ $container->singleton(
+ Plugin_Checker_Interface::class,
+ static function () use ( $checker ): Plugin_Checker_Interface {
+ return $checker;
+ }
+ );
+ $container->singleton(
+ Plugin_Deactivator_Interface::class,
+ static function () use ( $deactivator ): Plugin_Deactivator_Interface {
+ return $deactivator;
+ }
+ );
+ $container->singleton(
+ Queue_Interface::class,
+ static function () use ( $notices ): Queue_Interface {
+ return $notices;
+ }
+ );
+ $container->singleton(
+ Activator_Interface::class,
+ static function () use ( $activator ): Activator_Interface {
+ return $activator;
+ }
+ );
+
+ // From the plugins list the redirector says to stay put, so the request runs on into the load
+ // pass instead of ending in the resolver.
+ $this->setFunctionReturn( 'wp_get_referer', admin_url( 'plugins.php' ) );
+
+ $this->register(
+ [
+ 'standalone_plugin_basename' => self::STANDALONE,
+ 'conflict_policy' => Conflict_Policy::DEACTIVATE,
+ 'activation_callback' => static fn() => null,
+ ]
+ );
+
+ $this->boot( $container );
+ $this->run_request();
+
+ $this->assertSame( [ self::SLUG ], array_keys( $registrar->sub_plugins ), 'The host registrar holds the registration.' );
+ $this->assertSame( [ self::STANDALONE ], $checker->basenames, 'The host checker answers whether the standalone is active.' );
+ $this->assertSame( [ self::STANDALONE ], $deactivator->basenames, 'The host deactivator is the one asked to turn it off.' );
+ $this->assertSame( [ self::SLUG ], $notices->merge_notices, 'The host queue is told what happened.' );
+ $this->assertSame( [ self::SLUG ], $activator->slugs, 'The host activator runs the one-time setup.' );
+ $this->assertSame( 1, $this->bundled_plugin_loads() );
+
+ $this->assertContains( self::STANDALONE, $this->active_plugins(), 'The default deactivator must not have run too.' );
+ $this->assertSame( [], $this->notice_queue(), 'The default queue must not have been resolved alongside it.' );
+ $this->assertSame( [], $this->activation_record(), 'The default activator must not have recorded anything.' );
+ }
+
+ /**
+ * The same guarantee for the two the conflict step resolves itself. A host owns what a conflict
+ * means — but not who may have one resolved, which is why the gate is asked first and separately,
+ * and is asserted here to have been asked at all.
+ */
+ public function test_a_host_binding_replaces_the_gatekeeper_and_the_resolver(): void {
+ $gatekeeper = new Spy_Gatekeeper( true );
+ $resolver = new Spy_Resolver();
+
+ update_option( 'active_plugins', [ self::STANDALONE ] );
+
+ $container = new Test_Container();
+ $container->singleton(
+ Gatekeeper::class,
+ static function () use ( $gatekeeper ): Gatekeeper {
+ return $gatekeeper;
+ }
+ );
+ $container->singleton(
+ Resolver_Interface::class,
+ static function () use ( $resolver ): Resolver_Interface {
+ return $resolver;
+ }
+ );
+
+ $this->register(
+ [
+ 'standalone_plugin_basename' => self::STANDALONE,
+ 'conflict_policy' => Conflict_Policy::DEACTIVATE,
+ ]
+ );
+
+ $this->boot( $container );
+ $this->run_request();
+
+ $this->assertSame( 1, $gatekeeper->may_resolve_calls, 'The conflict step has to ask the gate.' );
+ $this->assertSame( 1, $resolver->resolve_calls );
+ $this->assertContains(
+ self::STANDALONE,
+ $this->active_plugins(),
+ 'A host resolver that does nothing means nothing is deactivated.'
+ );
+ $this->assertSame( [], $this->notice_queue() );
+ $this->assertSame( 1, $this->bundled_plugin_loads(), 'The load pass still runs after it.' );
+ }
+
+ /**
+ * The second half of the bootstrap, and the point every test above starts from.
+ *
+ * The container is handed over bare: `Loader::boot()` is what runs the provider over it, so a
+ * test that pre-registered the bindings would be asserting against a container the library never
+ * had to teach.
+ *
+ * @param Test_Container|null $container Container to bootstrap with, when a test has bound its
+ * own implementations into one.
+ *
+ * @return void
+ */
+ private function boot( ?Test_Container $container = null ): void {
+ Config::set_container( $container ?? new Test_Container() );
+
+ Loader::boot();
+ }
+
+ /**
+ * One page view, which must not end in a redirect.
+ *
+ * `wp_safe_redirect()` is stubbed even here, where nothing should reach it. The real one is
+ * followed by `exit`, which would take the whole test process down rather than fail one test —
+ * so the stub throws, and a request that redirected when it should not have fails right here
+ * instead of silently passing somewhere else.
+ *
+ * @return void
+ */
+ private function run_request(): void {
+ $message = self::halted_at_exit_message();
+
+ $this->setFunctionReturn(
+ 'wp_safe_redirect',
+ static function () use ( $message ) {
+ throw new TestException( $message );
+ },
+ true
+ );
+
+ try {
+ do_action( 'plugins_loaded' );
+ } catch ( TestException $exception ) {
+ $this->fail( 'The request must not redirect and end here. ' . $exception->getMessage() );
+ } finally {
+ // In a finally block so a failed assertion cannot strand the stub for the rest of the
+ // process, where a later test's redirect would throw for no reason it can see.
+ $this->unsetFunctionReturn( 'wp_safe_redirect' );
+ }
+ }
+
+ /**
+ * One page view that must end where production calls exit(), and where it sent the user.
+ *
+ * @return string
+ */
+ private function run_halted_request(): string {
+ return $this->capture_redirect(
+ static function (): void {
+ do_action( 'plugins_loaded' );
+ }
+ );
+ }
+
+ /**
+ * An admin page load, as far as this library is concerned: the hook it renders the queue on.
+ *
+ * Dispatched rather than calling `Loader::render_notices()`, because the admin-only `add_action()`
+ * is half of what has to work — a queue nothing renders is a queue nothing clears either.
+ *
+ * @return string
+ */
+ private function render_admin_notices(): string {
+ ob_start();
+
+ do_action( 'all_admin_notices' );
+
+ return (string) ob_get_clean();
+ }
+
+ /**
+ * Register one sub-plugin, backed by a bundled file that exists.
+ *
+ * Called before the container is set, which is legal and deliberate: registration is buffered, so
+ * a host that builds its config array before it builds its container still works. The guard
+ * constant is unique per call unless the test names one, because loading the file defines it with
+ * a real `define()` that lasts for the whole PHP process.
+ *
+ * @param array