Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ whose bindings were discarded.
- [Conflict handling](docs/conflict-handling.md) — the policies, when they run, and the guard's limits.
- [Filters](docs/filters.md) — the runtime overrides for policies and notice text.
- [Notices](docs/notices.md) — where the queue lives, who may see it, and how to render it yourself.
- [Tests](tests/README.md) — running the suite, the fixtures and traits it offers, and every scenario
it drives the library through.

## License

Expand Down
247 changes: 247 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,3 +289,250 @@ $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 scenario suite

`tests/unit/Scenario/` 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 scenarios that are *about* a host binding its own collaborators.

Everything else in `tests/unit/` mirrors `src/` and tests one class with its
neighbours doubled. This folder is the exception, and it is named for what its
files describe rather than for a class: a **scenario** is one host bootstrap,
one or more requests, and assertions about what WordPress holds afterwards.

`Scenario/Bootstrap_Test_Case.php` is the abstract parent every scenario file
extends. It is not collected as a test — the runner takes `*Test.php`, and it is
deliberately not one.

### What a scenario may call

It reaches for no entry point a host does not have. The bootstrap is
`Config::set_hook_prefix()`, `Config::set_container()`, `Absorber::register()`
and `Absorber::boot()`, and everything after that arrives through the hooks
`boot()` wired:

| Helper | What it really does |
|---|---|
| `boot()` | `Config::set_container()` with a **bare** container, then `Absorber::boot()` |
| `run_request()` | `do_action( 'plugins_loaded' )`, and fails the test if it redirects |
| `run_halted_request()` | the same, for a request that must end in a redirect; returns where the user was sent |
| `render_admin_notices()` | `do_action( 'all_admin_notices' )`, and returns what was printed |
| `register()` | `Absorber::register()`, backed by a bundled fixture file that really exists |

The container is handed over bare rather than through `WithContainer`, because
`boot()` running the provider over it is one of the steps under test. Calling
the steps directly — `Loader::load_all()`, `Resolver::resolve_all()` — would
skip 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.

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. `preventExit()` is never used — it would let a request
carry on past the line production never returns from, which turns a failure into
a pass.

### Four preconditions

None of it means anything unless all four hold, and setUp establishes all four:

- **An interactive admin GET** — `set_current_screen( 'plugins' )` plus
`set_request_method( 'GET' )`. `Conflict\Gatekeeper` turns away anything else,
so without both of these every policy scenario would pass while resolving
nothing at all.
- **A user who can `activate_plugins`** — `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** — 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.

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. 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 the activation record are `get_site_option()` values, which
are network options there. Every precondition above resolves differently on the
second leg.

### The cases

Every scenario shares one shape, so it is drawn once here rather than six times
below. A host bootstraps, and from then on the library is only ever reached
through hooks:

```mermaid
sequenceDiagram
autonumber
participant Host as Host plugin
participant Cfg as Config
participant Abs as Absorber
participant WP as WordPress

Host->>Cfg: set_hook_prefix(), set_container()
Host->>Abs: register( config ) — buffered, resolves nothing
Host->>Abs: boot()
Abs->>Abs: Provider binds what the container lacks
Abs->>WP: Scheduler wires the hooks
Note over WP: plugins_loaded priority 5 — conflict resolution
Note over WP: plugins_loaded priority 6 — the load pass
Note over WP: all_admin_notices — the queue renders
```

#### `Scenario/LoadTest.php` — a bundled plugin nothing is fighting over

No standalone is in the way in any of these, so priority 5 finds nothing to do
and what is under test is the chain priority 6 walks, in the order it walks it:

```mermaid
flowchart LR
A[enabled] --> B[not already loaded]
B --> C[dependencies met]
C --> D[file exists]
D --> E[should_load filter]
E --> F[require_once]
F --> G[activation callback]
```

Each gate skips to the next sub-plugin on the first failure, and the activation
callback runs only after a require that actually happened.

**A fresh load defines the guard and activates exactly once.** Nothing else
claims the plugin, and the host supplied an activation callback. The file is
required once, the guard constant is defined, the callback runs, and the
once-ever record is written — and a second request repeats none of it.

```mermaid
sequenceDiagram
autonumber
participant WP as WordPress
participant L as Loader
participant F as Bundled file
participant Act as Activator

WP->>L: plugins_loaded priority 6
L->>L: every gate passes
L->>F: require_once
F-->>F: define( guard constant )
L->>Act: maybe_run()
Act-->>Act: writes the record
Note over WP,Act: second request, nothing re-registered
WP->>L: plugins_loaded priority 6
L->>L: the guard is defined — stand down
```

**The bundled copy stands down when the guard is already defined.** A must-use
copy, a second host bundling the same code, or the owner's own snippet has
already defined the constant. Nothing is required — and nothing is queued
either, because a plugin the admin can watch working has nothing to explain.

```mermaid
sequenceDiagram
autonumber
participant WP as WordPress
participant L as Loader
participant F as Bundled file

Note over L: the guard constant is already defined
WP->>L: plugins_loaded priority 6
L->>L: gate two fails — already loaded
L--xF: no require
Note right of L: no notice: this is the ordinary success case
```

**A sub-plugin toggled off loads nothing.** The host's `enabled` callback
returns false, and then true. Nothing loads and nothing is said while it is off;
the load happens on the request after it is switched on, which is what proves
the toggle was the only thing stopping it — a missing file would have left the
same empty counter.

```mermaid
sequenceDiagram
autonumber
participant WP as WordPress
participant L as Loader
participant H as Host callback
participant F as Bundled file

WP->>L: plugins_loaded priority 6
L->>H: enabled?
H-->>L: false
L--xF: no require
Note over H: the host flips the toggle
WP->>L: plugins_loaded priority 6
L->>H: enabled?
H-->>L: true
L->>F: require_once
```

**The `should_load` filter can veto a load.** The host's last word before the
require, on the hook name its own prefix builds. No require, no guard constant,
and no notice — a host that vetoed the load does not need telling about it.

```mermaid
sequenceDiagram
autonumber
participant WP as WordPress
participant L as Loader
participant Flt as prefix/plugin_absorber/should_load
participant F as Bundled file

WP->>L: plugins_loaded priority 6
L->>L: enabled, not loaded, deps, file all pass
L->>Flt: apply_filters( true, sub_plugin )
Flt-->>L: false
L--xF: no require
```

**Two sub-plugins load in one request, in registration order.** A host bundles
plugins that depend on one another, and the order it registers them in is the
only say it gets — not slug order, and not filesystem order. The order is read
back from each activation callback, which runs immediately after its own
require, so it is the order the files were really required in.

```mermaid
sequenceDiagram
autonumber
participant WP as WordPress
participant L as Loader
participant F1 as First file
participant F2 as Second file

WP->>L: plugins_loaded priority 6
L->>F1: require_once
F1-->>L: activation callback records it first
L->>F2: require_once
F2-->>L: activation callback records it second
```

**An unmet dependency blocks the load and queues the explanation.** All the way
to the screen from the other end: `dependency_check` returns false, so nothing
loads, the host's own sentence is queued, and the next admin page draws it as an
error and consumes it — the owner is told once, not on every page load for ever.

```mermaid
sequenceDiagram
autonumber
participant WP as WordPress
participant L as Loader
participant Q as Notice queue
participant F as Bundled file

WP->>L: plugins_loaded priority 6
L->>L: dependency_check returns false
L--xF: no require
L->>Q: queue_dependency_notice()
Note over WP,Q: the same request reaches the admin screen
WP->>Q: all_admin_notices
Q-->>WP: draws it as notice-error
Q->>Q: clears the queue
```
21 changes: 20 additions & 1 deletion tests/_support/Traits/WithBundledPlugins.php
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,26 @@ protected function reset_bundled_plugin_loads(): void {
}

/**
* Remove every fixture this test wrote. Call from tearDown.
* Remove every fixture written by a test that never reached its own cleanup.
*
* PHPUnit runs an `@after` method whether the test passed, failed or errored, which a line at the
* end of a test body does not survive: a failed assertion aborts the test where it stands, so that
* is exactly the line that does not run on the day it matters. Tests that clear other state
* alongside these files still call `remove_bundled_plugin_files()` from their own tearDown, and
* the second call is a no-op over an emptied list.
*
* @since 1.0.0
*
* @after
*
* @return void
*/
protected function remove_bundled_plugin_files_after_test(): void {
$this->remove_bundled_plugin_files();
}

/**
* Remove every fixture this test wrote. Safe to call more than once.
*
* @since 1.0.0
*
Expand Down
Loading
Loading