Skip to content

15: End-to-end suite - #16

Closed
nikolaystrikhar wants to merge 4 commits into
14-activation-error-rewritefrom
15-e2e-suite
Closed

15: End-to-end suite#16
nikolaystrikhar wants to merge 4 commits into
14-activation-error-rewritefrom
15-e2e-suite

Conversation

@nikolaystrikhar

@nikolaystrikhar nikolaystrikhar commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

What: 16 scenarios driving the library through the bootstrap a host runs — Config::set_hook_prefix(), Config::set_container(), Loader::register(), Loader::boot() — and then through the hooks boot() wired, dispatching plugins_loaded for the conflict step at priority 1 and the load pass at 2, all_admin_notices for the queue and wp_admin_notice_markup for the re-activation rewrite, against real active_plugins, core's own deactivate_plugins() and real site options; no source files, all tests.

Usage: the shape of a scenario — a bootstrap, a request, and assertions against what WordPress actually holds afterwards.

$constant = $this->register( [ 'standalone_plugin_basename' => self::STANDALONE ] );

$this->boot();                              // set_container() with a bare container, then boot()
$location = $this->run_halted_request();    // do_action( 'plugins_loaded' )

$this->assertNotContains( self::STANDALONE, $this->active_plugins() );
$this->assertSame( admin_url( 'plugins.php' ), $location );

$rendered = $this->render_admin_notices();  // do_action( 'all_admin_notices' )

$this->assertStringContainsString( 'has been deactivated', $rendered );
$this->assertSame( [], $this->notice_queue(), 'Rendering consumes the queue.' );

Why this way:

The hooks are dispatched, not the steps called. The earlier draft ran a request as Loader::run_conflict_resolution() followed by Loader::load_all(); a request is now do_action( 'plugins_loaded' ) and an admin page load do_action( 'all_admin_notices' ), so what runs is whatever boot() wired, in the order and at the priorities it wired it. That is the half where the bugs are — an admin-only add_action() that never ran, a step wired into a dispatch window that had already closed, a resolution ordered behind the load pass — and calling the steps directly is exactly the arrangement that cannot see any of it. The container is handed over bare rather than pre-registered through WithContainer, for the same reason: boot() running the provider over it is one of the steps under test.

Each load-path test writes its own fixture file and its own guard constant. require_once dedupes by resolved path for the lifetime of the process and define() lasts just as long, so a fixture shared between two tests lets the second pass without loading anything — including if the load logic were deleted outright — and a reused constant makes a later sub-plugin read as already loaded. WithBundledPlugins now cleans up on its own @after hook rather than trusting a caller's tearDown: a failed assertion aborts the test where it stands, so a cleanup line at the end of a body is the one that does not run on the day it matters.

The host-binding tests assert the defaults did not run. Across the two of them a host binds seven of the library's registrations — registrar, plugin checker, deactivator, notice queue, activator, gatekeeper and resolver — and each is asserted to be the object the request reached. On their own those positive assertions would hold just as well if the library had also resolved a second, default copy behind the host's back, so the standalone is left really active, and the test asserts active_plugins still contains it, the default queue is empty and the activation record is empty.

exit is never mocked, even end to end. wp_safe_redirect throws instead, which stops the request where production stops it: run_halted_request() returns where the user was sent and asserts the destination, while run_request() installs the same throwing stub for every request that must not redirect and fails the test if one does. preventExit() would let a request carry on past the line production never returns from, which turns a failure into a pass.

Four preconditions decide whether any of this means anything, so setUp establishes all four. An interactive admin GET and a user who can activate_plugins, or Conflict\Gatekeeper turns every policy scenario away and they pass while resolving nothing; the hook prefix, which both plugins_loaded steps and both option names derive from; and a rewound plugins_loaded counter, because the harness dispatched that hook before any test ran and boot() would rightly report it is too late to wire. All four are process-global, and all four are restored in tearDown rather than at the end of a test body.

Conflict\Resolver takes its four collaborators as required constructor
arguments -- the checker, the deactivator, the notice queue and the redirector --
because the container is now mandatory and nothing has to be constructible
without one. The nullable peers and the accessors that fell back to a static
are gone with the class they fell back to.

Conflict\Gatekeeper owns who may resolve: an interactive admin GET, the
activate_plugins capability, and a hook prefix. plugins_loaded runs on every
request and fires before auth_redirect(), so an unauthenticated GET of an admin
URL reaches this code, and the capability gate covers every policy rather than
only the destructive one -- the other branches queue a notice the same user could
not render anyway. The priority-1 step asks the gatekeeper before it resolves
Resolver_Interface at all, so a host binding its own resolver cannot drop either
gate by omission.

Conflict\Destination becomes Conflict\Redirector. It decides where to send the
user and never goes there; wp_safe_redirect() and the exit after it stay in the
resolver, so the policy action and the admin-URL knowledge change for separate
reasons. A class that returns a URL from a filter still earns the agent noun.

The boot barrier now measures from the lowest priority in the sequence rather
than from the load priority, or a host booting between conflict resolution and
the load would be told nothing while half its wiring silently failed.
@nikolaystrikhar
nikolaystrikhar force-pushed the 14-activation-error-rewrite branch from 55bc54a to d2daabd Compare August 12, 2026 13:30
@nikolaystrikhar
nikolaystrikhar marked this pull request as draft August 12, 2026 13:47
Activation becomes Activator. An abstract -ion noun names a directory in this
ecosystem and never a class -- Activation/ holds an Activator and a Deactivator --
and the class does something rather than being something.

Load\Runner takes it injected and calls it immediately after a successful
require_once, so a callback never runs for a file that was not loaded. The
bookkeeping is an option keyed by slug, because a callback that ran and a
callback that failed have to be told apart across requests, and a sub-plugin
absorbed into a host has no activation hook of its own to hang this on.

There is no Loader::activation() accessor. The activator has exactly one caller
and a host that wants different once-ever bookkeeping binds Activator_Interface,
which is the same line the accessor would have been -- and public API is forever.
An admin who still has the standalone installed and clicks Activate gets
WordPress's own fatal-error screen, because the bundled copy already defined
everything the standalone is about to. wp_admin_notice_markup lets us replace
that wording with an explanation of the merge, on the one screen where the site
owner is actively trying to do the thing we have made impossible.

The rewrite lives on Notices\Queue rather than in a class of its own: it is
notice wording for a sub-plugin, keyed by the same standalone basename the queue
already reasons about, and a separate class would have to be handed the registrar
to find out which sub-plugin the screen is even about.

The filter is a named static trampoline rather than a closure over the container,
unlike the plugins_loaded steps. This is the one hook that rewrites a screen
WordPress drew instead of adding one of ours, so a host that wants core's wording
back needs a callback it can remove_filter() -- and a closure cannot be removed.
The plugins_loaded steps are closures because that sequence has to run inline
when boot came too late, which is a constraint these two admin hooks do not have.
Fifteen requests, each one dispatched through the hooks a host would fire
rather than by calling the steps directly, so the priorities, the gatekeeper
and the boot barrier are exercised instead of bypassed.

The container being mandatory is what makes the last two tests possible: a host
binds its own registrar, checker, deactivator, notice queue, activator,
gatekeeper and resolver before boot, and every one is asserted to be the object
the request actually used -- with the defaults asserted not to have run beside
it, since active_plugins untouched and both options unwritten is the only way to
tell "the binding was used" from "the binding was ignored and nothing happened".

Each load-path test writes its own bundled fixture, because require_once caches
by resolved path for the whole process and a shared file would make every later
test pass without loading anything. The trait that writes them gained the old
suite's @after cleanup and kept ours, so a fixture is removed whether or not the
test remembers.
@nikolaystrikhar
nikolaystrikhar force-pushed the 14-activation-error-rewrite branch from d2daabd to 66eba85 Compare August 12, 2026 14:01
@nikolaystrikhar
nikolaystrikhar force-pushed the 14-activation-error-rewrite branch 4 times, most recently from 1aaffc3 to f7875e9 Compare August 13, 2026 11:01
@nikolaystrikhar

Copy link
Copy Markdown
Contributor Author

Superseded by #28, #29 and #30, which split this into three reviewable branches on top of the restacked 14.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant