Skip to content

Every WP-CLI subcommand, run through wp rather than through PHP - #2

Merged
sean-e-dietrich merged 2 commits into
mainfrom
cli-command-coverage
Sep 23, 2026
Merged

sean-e-dietrich merged 2 commits into
mainfrom
cli-command-coverage

Conversation

@sean-e-dietrich

Copy link
Copy Markdown
Contributor

CI ran three of the thirteen WP-CLI subcommands and asserted nothing about any of them beyond an exit status, plus one hand-rolled check that import --mode=replace refuses without --yes. The other ten were covered by nobody — not by the integration suite, which calls PHP and so never sees WP-CLI, and not by CI.

I ran all thirteen by hand against the dev site first. They work. Then I wrote the thing that means nobody has to do that again.

What this covers that PHPUnit cannot

Everything WP-CLI owns: whether a method is registered as a subcommand at all, whether its ## OPTIONS block parses, whether a flag the docblock advertises is accepted, and what the process exits with. A command can be correct PHP and unreachable from a shell, which is the only place anybody runs it.

tests/cli/commands.sh — 13 subcommands, 25 assertions, same invocation by hand and in CI:

BFW_WP="ddev wp" bash tests/cli/commands.sh

Two behaviours are pinned because they are the ones that get read wrong:

  • check exits 0 whether or not the address was blocked. The status reports whether the query ran. A deploy script treating non-zero as "this address is blocked" would be wrong on every run.
  • A destructive command must exit NON-zero when it refuses. WP_CLI::confirm() calls halt(0) on EOF, so the failure being guarded against is a prompt nobody answers followed by an exit 0 that a deploy script reads as success.

Safe to run against a site you care about

Agreeing to a destructive command (clear-blocked --yes, import --mode=replace --yes) is opt-in through BFW_CLI_DESTRUCTIVE=1. CI opts in because its site goes away with the runner; a hand run does not, so pointing this at your dev site does not empty its block list. The refusals are asserted either way — that is the half that has actually broken.

Two things the manual pass turned up

sources lists presets. refresh-sources re-fetches the lists a rule references. Two different things wearing one word, and wp basic-firewall sources returning presets is a surprise if you came looking for your referenced lists. The README now says so plainly. Renaming is free while nothing is released — worth doing, and not something I'd change unasked.

refresh-sources was missing from the README's command list entirely — it shipped without ever being documented. Fixed.

Verification

25 assertions pass locally in both modes; the dev site is unchanged afterwards (Rules configured 7, mode unchanged). shellcheck clean. phpcs, phpstan, 55 unit and 122 integration tests unaffected. The exact BFW_WP="wp --path=… --allow-root" form CI uses was run inside the container first, because global flags ahead of the subcommand is the sort of thing that works everywhere except where you need it.

  • Was AI used in this pull request?

CI exercised three of the thirteen subcommands and asserted nothing about
any of them beyond an exit status, plus one hand-rolled check that
`import --mode=replace` refuses without `--yes`. The other ten were
covered by nobody: not by the integration suite, which calls PHP and so
never sees WP-CLI, and not by CI.

That gap is not theoretical. What PHPUnit cannot reach is everything
WP-CLI owns -- whether a method is registered as a subcommand at all,
whether its `## OPTIONS` block parses, whether a flag the docblock
advertises is accepted, and what the process exits with. A command can be
correct PHP and unreachable from a shell, which is the only place anybody
runs it.

tests/cli/commands.sh runs all thirteen and makes 25 assertions about
what they print and what they exit with. It runs the same way by hand and
in CI:

    BFW_WP="ddev wp" bash tests/cli/commands.sh

Two behaviours are pinned because they are the ones that would be read
wrong. `check` exits 0 whether or not the address was blocked -- the
status reports whether the query ran, so a deploy script treating
non-zero as "blocked" would be wrong on every run. And a destructive
command must exit NON-zero when it refuses: WP_CLI::confirm() calls
halt(0) on EOF, so the failure being guarded against is a prompt nobody
answers followed by an exit 0 that reads as success.

Agreeing to a destructive command is opt-in through
BFW_CLI_DESTRUCTIVE=1. CI opts in, because its site goes away with the
runner; a hand run does not, so pointing this at a site you care about
does not empty its block list. The refusals are still asserted either
way, which is the half that has actually broken.

Nothing here reads a file back from the script's own filesystem. `ddev
wp` runs inside a container, so a path this script can see is not a path
the command can write to -- the first draft used mktemp and failed on
exactly that.

Found while running them by hand, and left alone pending a decision:
`sources` lists presets while `refresh-sources` re-fetches the lists a
rule references. Two different things wearing one word. The README now
says so; renaming is free while nothing is released, and is not mine to
do unasked. `refresh-sources` was also missing from the README's command
list entirely, which is fixed.
Same nine jobs, same matrix, same refusal to let anything fail softly.
Kanopi runs CircleCI everywhere else, and a plugin whose CI lives
somewhere other than the rest of the estate is one nobody watches.

Not built on kanopi/ci-tools. The orb's `composer` job is what the site
repos use for PHPCS and PHPStan, and it is a good fit there -- but it
carries Pantheon deploy machinery and the kanopi-code context, and this
repo needs a PHP matrix and a MySQL service that the orb job does not
express. The config is self-contained instead, in the same house style:
versions as anchors at the top, cimg images, caches keyed on the lock.

Two things the images forced. `cimg/php` carries Composer, mysqli,
pdo_mysql, unzip and dockerize, and carries neither the mysql client nor
WP-CLI. So WP-CLI is installed from the phar, and the root password is
set in the service's own environment rather than ALTERed afterwards --
which removes the mysql client the old workflow needed, and is a
simplification the move paid for rather than a compromise it forced.

Better than what it replaces, in three small ways. `dockerize -wait`
instead of `sleep 3` before the database and before the built-in web
server, which is the fragility behind the one e2e flake seen on nightly.
JUnit output from all three suites into `store_test_results`, so a
failure is a named test in the UI rather than a log to scroll. And the
zip as a build artifact on every run, which is the first time it has
been retrievable without running the build yourself.

Verified by running the jobs, not by validating the YAML. `circleci
config validate` passes and `config process` expands all nine job names,
but that proves only that the file parses. Each job was then run in the
image it will actually use, with MySQL in a shared network namespace so
it answers on 127.0.0.1 exactly as CircleCI arranges it:

  static      cimg/php:8.1   platform-reqs, phpcs, phpstan   green
  unit        cimg/php:8.2   55 tests, junit written         green
  unit        cimg/php:8.4   55 tests, junit written         green
  integration cimg/php:8.1   122 + 6 e2e + 25 wp-cli         green
  package     cimg/php:8.3   scoped zip, installs with no
                             composer on PATH                green

That run also caught the kind of thing validation cannot: `wp core
download` takes an optional positional download URL, so a stray flag
becomes the URL and the command fails with "Version option is not
available for URL downloads". Mine was in the throwaway script rather
than in the config, but the config would have failed the same way.

The repo still has to be enabled in the CircleCI app before any of this
runs, and GitHub's required checks still name the old jobs.
@sean-e-dietrich
sean-e-dietrich merged commit 41650bd into main Sep 23, 2026
10 checks passed
@sean-e-dietrich
sean-e-dietrich deleted the cli-command-coverage branch September 23, 2026 05:16
@sean-e-dietrich sean-e-dietrich mentioned this pull request Sep 23, 2026
1 task done
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