NV oOS Test Suite — Repair & Triage Guide
Operational playbook for keeping the single-process PHPUnit suite green,
one cluster (suite) at a time. Distilled from ~70 cluster PRs (#6084–#6153)
against the alpha-working branch. Complements .context/testing.md (how to
write tests) — this skill covers how to fix a failing suite and how the
repair loop runs.
When to use this skill
- A PHPUnit suite is failing in CI, locally, or both
- Triaging a fresh CI log zip (
logs_*.zip)
- "Continue the test-suite cluster" / "fix the next cluster" requests
- Deciding whether a failure is test drift or a production bug
- Opening the cluster PR (branch/commit/validation conventions)
For proactive per-toolkit hardening audits (shell-call guards, nonexistent
client methods, provider array-content parsing, enum/service mismatches) use
the mcp-ai-wpoos-toolkit-audit skill — it drives the audit loop and reuses
this skill's cluster/PR/validation mechanics.
For SPA/JS tests (vitest + jsdom + Testing Library in the toolkit-shell,
chat-spa, and schedule-anything-spa addons — Radix polyfills, jsdom gotchas,
bundle gates) use the mcp-ai-wpoos-spa-ui skill. This skill covers the
PHPUnit suite only; the two stacks are disjoint.
Test environment (Docker)
The test suite runs inside the oos-wp container (plugin bind-mounted at
/var/www/html/wp-content/plugins/mcp-ai-wpoos, DB in oos-wp-db). Run from
the repo root on the Windows host with MSYS_NO_PATHCONV=1.
WP 6.9 (primary):
MSYS_NO_PATHCONV=1 docker exec -e WP_CORE_DIR=/var/www/html -e WP_DB_HOST=db -e WP_DB_NAME=wordpress_test -e WP_DB_USER=wordpress -e WP_DB_PASSWORD=wordpress oos-wp sh -c 'cd /var/www/html/wp-content/plugins/mcp-ai-wpoos && php -d memory_limit=1G vendor/bin/phpunit <paths> --no-coverage 2>&1'
WP 7.1 (second validation): swap WP_CORE_DIR=/tmp/wp71 and
WP_DB_NAME=wordpress_test_wp71.
phpcs (the CI gate counts ERRORS, not warnings):
MSYS_NO_PATHCONV=1 docker exec oos-wp sh -c 'cd /var/www/html/wp-content/plugins/mcp-ai-wpoos && php vendor/bin/phpcs --standard=phpcs.xml.dist --error-severity=1 --warning-severity=1 --report=summary <paths> 2>&1'
Rules:
- Never run two
docker exec phpunit concurrently — they collide on the
shared wordpress_test DB.
- Validate every cluster on both WP 6.9 and WP 7.1 before opening the PR.
- The local bootstrap lacks JetEngine and Graphify; the CI bootstrap loads
them. A suite skipped locally can still fail in CI — and vice versa.
phpcbf can auto-fix (e.g. array alignment): run it inside the container,
it edits the bind-mounted file directly.
Cross-worktree runs (when oos-wp mounts a different worktree)
The oos-wp plugin path bind-mounts one worktree. Check which one:
docker inspect oos-wp --format "{{json .Mounts}}"
If the mounted path is not the worktree you are editing, docker exec oos-wp
runs the wrong code. Instead run a one-off container on the same network
sharing the WP-core volume, with your worktree mounted over the plugin path:
# 1. Build a Linux vendor into a named volume (the Windows-host vendor/
# breaks inside Linux — classmap paths with backslashes →
# 'Class "PHPUnit\\TextUI\\Application" not found').
# REUSE an existing complete volume when present (docker volume ls):
# mcp-ai-wpoos-vendor ships with PHPUnit + wp-phpunit and needs no build.
docker volume create <worktree>-vendor
MSYS_NO_PATHCONV=1 docker run --rm \
-v F:/GITHUB/worktrees/mcp-ai-wpoos/<worktree>/mcp-ai-wpoos:/app:ro \
-v <worktree>-vendor:/app/vendor \
composer:2 sh -c 'cd /app && composer install --no-interaction --prefer-dist --no-progress'
# 2. Run the tests (nested mounts: volume over the worktree's vendor/ works).
MSYS_NO_PATHCONV=1 docker run --rm \
-e WP_CORE_DIR=/var/www/html -e WP_DB_HOST=db -e WP_DB_NAME=wordpress_test \
-e WP_DB_USER=wordpress -e WP_DB_PASSWORD=wordpress \
-v oos-wp_wp_core:/var/www/html \
-v F:/GITHUB/worktrees/mcp-ai-wpoos/<worktree>/mcp-ai-wpoos:/var/www/html/wp-content/plugins/mcp-ai-wpoos \
-v <worktree>-vendor:/var/www/html/wp-content/plugins/mcp-ai-wpoos/vendor \
--network oos-wp_default \
wordpress:6.9-php8.2-apache \
sh -c 'cd /var/www/html/wp-content/plugins/mcp-ai-wpoos && php -d memory_limit=1G vendor/bin/phpunit <paths> --no-coverage'
WP 7.1 cross-worktree: the wp71 core lives in the oos-wp container's own
/tmp/wp71 (NOT in the shared oos-wp_wp_core volume). Copy it out, mount it
at /tmp/wp71 in the one-off run, and swap WP_CORE_DIR=/tmp/wp71 +
WP_DB_NAME=wordpress_test_wp71:
docker cp oos-wp:/tmp/wp71 ./docker-tmp-wp71
# add: -v F:/GITHUB/worktrees/mcp-ai-wpoos/<worktree>/mcp-ai-wpoos/docker-tmp-wp71:/tmp/wp71
When /tmp/wp71 is gone (container restarts wipe it — docker cp errors
with Could not find the file /tmp/wp71 in container oos-wp), rebuild a
reusable named volume instead of polluting the repo:
# wordpress:cli runs as www-data — run as root to create the volume,
# and raise PHP memory or the extractor fatals at 128M:
docker volume create mf-wp71-core
docker run --rm --user 0:0 -v mf-wp71-core:/wp wordpress:cli-php8.3 sh -c \
'php -d memory_limit=512M /usr/local/bin/wp --allow-root --path=/wp core download --version=7.1.3 --quiet && chown -R 33:33 /wp'
# then mount it: -v mf-wp71-core:/tmp/wp71 with WP_CORE_DIR=/tmp/wp71 + WP_DB_NAME=wordpress_test_wp71
(WP_CLI_PHP_ARGS env is not honoured by the phar shim — pass
php -d memory_limit=512M /usr/local/bin/wp explicitly.)
Clean up afterwards: rm -rf docker-tmp-wp71 and docker volume rm <worktree>-vendor
— never stage either artifact. The no-concurrent-phpunit rule applies to
one-off runners too (same shared DB).
Pruned-vendor carry-over (hit on the 0.4.7 docs-hub pass): some
worktrees carry a partial vendor/ whose
vendor/composer/autoload_classmap.php has zero PHPUnit entries —
php -r 'require "vendor/autoload.php"; var_dump(class_exists("PHPUnit\\TextUI\\Application"));'
prints false on BOTH host and container, and vendor/bin/phpunit dies
with Class "PHPUnit\TextUI\Application" not found. Quick check:
grep -c PHPUnit vendor/composer/autoload_classmap.php (0 = pruned). The
Linux vendor volume is the fix for container runs; for host-side runs,
composer install on the host rebuilds a Windows vendor that then breaks
INSIDE Linux — pick one environment per session.
docker-tmp-logs/ artifact: running the root suite writes an untracked
docker-tmp-logs/ directory into the repo root (random .jpg files +
chunk8.txt). Delete it before staging anything (git status --porcelain
will show it as ??).
Pro addon dual-matrix runs (nvoos-content-graph-pro)
The PHPUnit Pro Addon CI workflow runs the addon suite twice (monolith +
standalone) — mirror it locally with the same one-off runner:
# Standalone matrix (base plugin skipped; add the env var):
-e WP_MCP_AI_PRO_STANDALONE=1 \
# Config: the addon's own phpunit config (bootstrap loads the ecosystem
# plugins + honours the env var):
php -d memory_limit=1G vendor/bin/phpunit -c plugins/nvoos-content-graph-pro/phpunit.xml.dist <paths> --no-coverage
# Monolith matrix: same command WITHOUT the env var.
Standalone plugin suites (plugins/nvoos-content-graph, -ai, -pro)
plugins/nvoos-content-graph ships its own composer.json +
phpunit.xml.dist (PHPUnit 9.6, wp-phpunit 7.0.2, PSR-4-only autoload — no
Windows classmap hazard, so a host-side composer install in the plugin dir
is safe and works inside the Linux container). No monorepo CI workflow runs
its phpunit.xml.dist (phpunit-ai.yml only covers the AI addon's
phpunit-ecosystem.xml.dist), so run it explicitly:
# Host: install the plugin's dev deps (creates
# plugins/nvoos-content-graph/vendor — gitignored):
cd plugins/nvoos-content-graph && composer install --no-interaction --prefer-dist
Mount-path caution: git rev-parse --show-toplevel BEFORE mounting —
the MAIN checkout is F:/GITHUB/mcp-ai-wpoos while
F:/GITHUB/worktrees/mcp-ai-wpoos is the PARENT of the git worktrees
(worktree repos live at .../mcp-ai-wpoos/<worktree-name>/mcp-ai-wpoos).
Mounting the parent silently runs the WRONG code — verify with
git worktree list. The Zed file tools may report the worktrees path as
the allowed directory even when the terminal's repo root is the main
checkout; trust git rev-parse, not the tool metadata.
Write the wp-phpunit config the plugin bootstrap expects. Default lookup is
vendor/wp-phpunit/wp-phpunit/wp-tests-config.php (dirname of includes/).
vendor/ paths are excluded from repo file tools — use the shell:
cat > vendor/wp-phpunit/wp-phpunit/wp-tests-config.php <<'EOF'
<?php
define( 'ABSPATH', '/var/www/html/' );
define( 'DB_NAME', 'wordpress_test' );
define( 'DB_USER', 'wordpress' );
define( 'DB_PASSWORD', 'wordpress' );
define( 'DB_HOST', 'db' );
define( 'DB_CHARSET', 'utf8' );
define( 'DB_COLLATE', '' );
define( 'WP_TESTS_DOMAIN', 'example.org' );
define( 'WP_TESTS_EMAIL', 'admin@example.org' );
define( 'WP_TESTS_TITLE', 'Test Blog' );
define( 'WP_PHP_BINARY', 'php' );
$table_prefix = 'wptests_';
EOF
# Ensure the DB exists (bootstrap auto-runs install.php single-site unless
# WP_TESTS_SKIP_INSTALL=1):
docker exec oos-wp-db mysql -uroot -pwordpress -e \
"CREATE DATABASE IF NOT EXISTS wordpress_test; GRANT ALL PRIVILEGES ON wordpress_test.* TO 'wordpress'@'%'; FLUSH PRIVILEGES;"
# Run (php:8.2-cli LACKS mysqli — wp_die "missing the MySQL extension"; use
# wordpress:6.9-php8.2-apache with --entrypoint php, which ships mysqli):
MSYS_NO_PATHCONV=1 docker run --rm --entrypoint php \
-v oos-wp_wp_core:/var/www/html \
-v F:/GITHUB/mcp-ai-wpoos:/var/www/html/wp-content/plugins/mcp-ai-wpoos \
--network oos-wp_default \
-e WP_TESTS_DIR=/var/www/html/wp-content/plugins/mcp-ai-wpoos/plugins/nvoos-content-graph/vendor/wp-phpunit/wp-phpunit \
wordpress:6.9-php8.2-apache \
/var/www/html/wp-content/plugins/mcp-ai-wpoos/plugins/nvoos-content-graph/vendor/bin/phpunit \
-c /var/www/html/wp-content/plugins/mcp-ai-wpoos/plugins/nvoos-content-graph/phpunit.xml.dist --testsuite=Unit
```
- "Full plugin run" = both suites: `--testsuite=Unit` then
`--testsuite=Integration` (integration needs only WP + bundled JetEngine
stubs). The no-concurrent-phpunit rule still applies (same DB).
- JS contract checks need no browser: `node scripts/verify-commerce-fallback.js`
(purchase-modal fallback redirect contract) and
`node scripts/verify-theme-engine.js` run on the host from the plugin dir.
- When only the client plugin is in scope this is faster than the root suite
(which boots the full base+Pro plugin); the root suite covers
`addons/checkout-api/tests` via the root phpunit.xml.dist.
**Shared-DB isolation (cross-worktree clobbering):** every worktree's one-off
runner hits the SAME `wordpress_test` DB by default — a concurrent worktree's
`install.php` re-runs wipe your schema mid-suite (mass "Table doesn't exist"
failures). Isolate per worktree with a dedicated DB:
```bash
# One-time: create the isolated DB (db container name from docker ps; creds
# in docker-compose.yml — oos-wp-db / wordpress:wordpress by default):
docker exec oos-wp-db mysql -uroot -pwordpress -e \
"CREATE DATABASE IF NOT EXISTS wordpress_test_pearl; GRANT ALL PRIVILEGES ON wordpress_test_pearl.* TO 'wordpress'@'%'; FLUSH PRIVILEGES;"
# Then point BOTH the schema install and every phpunit run at it:
-e WP_DB_NAME=wordpress_test_pearl \
# Schema install (once, or after the DB gets dropped):
php vendor/wp-phpunit/wp-phpunit/includes/install.php tests/wp-tests-config.php
```
The env var overrides `DB_NAME` in `tests/wp-tests-config.php` (which reads
`getenv( 'WP_DB_NAME' )`) — never edit the tracked config file.
- Run `--filter Test_X` first, then the full suite — the full run is ~10-20
min locally.
- Sequential monolith + standalone runs may reuse `wordpress_test`; keep the
no-concurrent rule.
- The `<worktree>-vendor` volume is reusable across clusters: re-run the
composer step (a no-op when current).
- Expected skips: standalone ~1 skip; monolith skips every standalone-gated
test (hundreds — not failures). A summary of "OK, but there were issues"
with only warnings/deprecations/skips is a pass.
- phpcs for the addon: `--standard=plugins/nvoos-content-graph-pro/phpcs.xml.dist`
(phpcbf exit 1 = fixed files, not an error; re-run phpcs to confirm exit 0).
- **content-graph session notes (1.0.9 pass):** the plugin's `vendor/` may
be PRUNED of PHPUnit entirely (`vendor/phpunit` gone; `vendor/bin/phpunit`
points at nothing) — a plain `composer install` in the plugin dir restores
the locked set (PHPUnit 9.6.35 + wp-phpunit 7.0.2) and the recipe above
then works as written. Point the plugin's gitignored
`vendor/wp-phpunit/wp-phpunit/wp-tests-config.php` at an isolated DB
(`wordpress_test_nvooscg` pattern) before the run. Current sizes: Unit
154 tests / 725 assertions (~7 s) and Integration 18 / 57 (~8 s) on
`wordpress:6.9-php8.2-apache`.
## Cluster → PR workflow
1. `git fetch origin alpha-working` (auto-gc may make this time out — verify
with `git --no-pager log -1 --oneline origin/alpha-working` and retry).
2. Create a branch per cluster from `origin/alpha-working`:
`git switch -c fix/<cluster-slug> origin/alpha-working`. Never stack on a
previous branch — uncommitted worktree edits carry over on switch.
3. Reproduce locally, fix, validate (both WP versions + phpcs).
4. Stage explicit paths only (`git add <file>` — **never `git add -A`**, and
**never stage `vendor/`**; the worktree carries local vendor edits that
must not be committed). On Windows Git Bash `2>nul` creates a literal
`nul` file in the repo — delete stray artifacts before staging.
5. Commit with imperative subject ≤ 50 chars; PR base is `alpha-working`.
6. The user merges manually; move to the next candidate regardless.
7. **Verify `git branch --show-current` before every commit.** If the
worktree gets switched under you (e.g. your PR merges and another agent
switches branches), a commit can land directly on `alpha-working`.
Recovery (done on the 1.0.9 pass): create the cluster branch from
`origin/alpha-working`, `git cherry-pick <sha>`, push, then
`git switch alpha-working && git reset --hard origin/alpha-working`.
Open PRs with `gh pr create` from the terminal — the GitHub-MCP
`create_pull_request` tool can fail with "Bad credentials" while the
`gh` CLI (keyring-authenticated) works.
### Watching CI checks
- Check-run viewers may return empty statuses while jobs run; the reliable
watcher is `gh pr checks <n> --repo nvdigitalsolutions/mcp-ai-wpoos` from
the terminal.
- The `PHPUnit Pro Addon` matrix jobs finish in ~3 min, but the `PHP Linting`
workflow (WPCS 3.0 + PHP Compatibility Check) is repo-wide and routinely
takes 20–30 min — it finishes long after the matrix checks. Local phpcs on
the changed files is the substantive gate; plan CI waits accordingly.
## CI log triage (`logs_*.zip`)
1. Extract to `logs_<id>/` and parse `0_test.txt`. Every line has a timestamp
prefix; failures are numbered blocks `NN) Suite::method` followed by the
assertion message.
2. Group by suite:
```bash
sed -n '<first-failure-line>,<last-failure-line>p' logs_<id>/0_test.txt \
| grep -oE "[0-9]+\) [A-Za-z_0-9]+::[A-Za-z_0-9]+" \
| sed 's/^[0-9]*) //' | awk -F'::' '{print $1}' | sort | uniq -c | sort -rn
```
3. Identify which code the run tested: grep `refs/remotes/pull/NNNN/merge` —
the run is the merge of PR NNNN into its base at checkout time.
4. A suite with **zero occurrences** in the log passed.
5. Standalone-pass-but-full-run-fail ⇒ order-dependent interference
(shared singletons, leaked filters) — see patterns below. Reproduce by
running the suspect suites in **one** phpunit invocation.
## Recurring root-cause patterns (symptom → fix)
1. **Hook-table restore between tests.** wp-phpunit restores `$wp_filter`
between tests, dropping hooks registered by other suites or by
`is_admin()`-gated bootstrap code. Symptom: `$submenu` null, enqueue hooks
dead, admin pages missing. Fix: re-register in `setUp()` — instantiate the
class, `require` the admin file, or re-invoke private init hooks via
`ReflectionClass` (see `Test_Admin_Hook_Suffixes` for the
`init_hooks()`-re-invoke pattern).
2. **`do_action( 'init' )` in tests re-fires WooCommerce/block registrations**
→ "…is already registered" incorrect-usage notices fail the test. Fix:
call the specific init function directly
(e.g. `wp_mcp_ai_init_slash_commands()`, `$shortcode->register_assets()`).
3. **WP_Error vs array envelope drift.** Tools were swept from
`array('success'=>false, …)` to `WP_Error`. Tests assert
`assertWPError()` + `get_error_code()`. Coordination tools wrap results in
nested envelopes (`result['team']`, `result['delegation']`,
`result['aggregation']`) — check the tool's `execute()` return before
asserting. The REST server converts a controller `WP_Error` into a
**400 `WP_REST_Response`** (`error_to_response`): assert status +
`$data['code']`, not `is_wp_error( $response )`.
4. **Nonces bind to the current user ID.** `wp_create_nonce()` must run
*after* `wp_set_current_user()`. Symptom: save/metabox tests silently bail
and meta stays empty.
5. **WooCommerce `get_current_page()` doing-it-wrong** when
`admin_enqueue_scripts` fires before `current_screen`. Fix:
`set_current_screen( '...' )` then
`do_action( 'current_screen', get_current_screen() )` — pass the
`WP_Screen` object, not nothing.
6. **Script/style queue leaks + WP 6.9 `all_queued_deps` memoization.**
`wp_script_is( $h, 'enqueued' )` falls back to a cached recursive dep set
that is NOT invalidated by direct array assignment. Reset via the public
API so the memo invalidates:
```php
global $wp_scripts;
foreach ( (array) $wp_scripts->queue as $handle ) {
wp_dequeue_script( $handle );
}
foreach ( (array) wp_styles()->queue as $handle ) {
wp_dequeue_style( $handle );
}
```
7. **Shared singleton state.** `WP_MCP_AI_Tool_Registry` (and friends) persist
across tests; suites like `test-tool-registry.php` and
`test-hooks-registry.php` call `clear_tools()` in their teardown. A later
suite's `init()` then no-ops on a partially bootstrapped state. Fix in the
consuming suite's `setUp()`:
```php
$registry = WP_MCP_AI_Tool_Registry::get_instance();
$registry->clear_tools();
$registry->init();
```
Likewise, filters added at bootstrap (e.g. the `tools-init.php` side-loader
on `wp_mcp_ai_default_tools`) pollute filter-contract tests — isolate with
`remove_all_filters( $hook )` inside the test.
8. **Anonymous classes cannot access `protected` members of the enclosing
class** (they do not inherit its scope). And production `catch ( Throwable )`
swallows the resulting `Error`, so the request returns 200 with **zero side
effects** — the failure shows up as "0 saved records", not an exception.
Fix: make the captured property `public` (test-only), or store records on
the mock itself (`public $records = array()`), like
`test-chat-transcript-cct-author-id.php`.
9. **Message content is stored as segments.** The REST validator stores
message `content` as `array( array( 'type' => 'text', 'text' => '…' ) )`.
Tests asserting `$msg['content']` as a string must extract
`$msg['content'][0]['text']`.
10. **Registered meta sanitization applies on `update_post_meta`.**
`wp_kses_post` HTML-escapes (`&` → `&`);
`sanitize_text_field` strips `<script>` elements **together with their
content** (so `'<script>alert(1)</script>'` → `''`, not `'alert(1)'`).
Assert against the sanitized value.
11. **`absint()` flips negatives to positives** (`absint(-999)` → `999`).
When the contract is "drop/clamp negatives", use
`max( 0, (int) $value )` — the codebase documents this exact anti-pattern
in `sanitize_associated_assistant_meta()`.
12. **`wp-admin/menu.php` never runs in PHPUnit**, so `add_submenu_page()`
falls back to the generic `admin_page_*` hook suffix instead of the
production `{post_type}_page_{slug}` suffix. Register the parent CPT in
`setUp()` and, if needed, inject the production suffix via reflection on
the page object's `page_hook`.
13. **Validated-tool auto-upgrade.** `WP_MCP_AI_Tool_Registry::get_tool(
'base_slug' )` resolves to the `_validated` variant when registered;
validation rejects out-of-range args with `validation_failed` before
execution. Expect that when asserting tool behavior.
14. **Risky tests (zero assertions).** Environment-dependent `if` branches
skip all assertions when vendor packages/Node/JetEngine exist on the host.
Risky tests don't fail CI (`phpunit.xml.dist` has no `failOnRisky`) but
they mask dead tests. Fix by adding a production filter seam
(e.g. `wp_mcp_ai_vendor_package_paths`) and forcing deterministic state in
the test.
15. **Obsolete skip gates hide failures.** A suite that mocks its
dependencies doesn't need the JetEngine skip gate — the gate only makes it
pass locally and fail in CI. Remove it when the mock makes the suite
self-contained.
16. **Test drift.** Handles renamed (`mcp-ai-*` → `wp-mcp-ai-*`), POST field
names changed, per-metabox nonces added, REST response shapes changed.
Check `git --no-pager log -1 -- <test-file>` and align the test with the
*current* production contract — but verify the production behavior is
intentional (see "Production fix vs test fix" below).
17. **Zombie mocks — PHPUnit clears mock stubs after each test.** A mock
stored in a static/global registry (`WP_MCP_AI_Settings_Registry`
sections) survives into later suites with its stubs wiped, so stubbed
methods return null. Symptom: "Section should have an ID" / "Failed
asserting that null is not null" while *iterating a later suite's*
sections. Fix: add an `unregister_*()` API to production and have the
registering test clean up after itself (#6139, #6144).
18. **`has_action()` / `has_filter()` return the priority (int), not bool.**
Assert `assertSame( 10, has_action( ... ) )`. Reverse failure: a
lazily-loaded self-instantiating class registers its hooks *after* the
process-wide hook-table backup, so wp-phpunit's per-test restore wipes
them — re-register in `setUp()` when the load happened out of order
(#6143).
19. **Sub-tabbed / view-routed settings sanitizers return `array()` when the
routing POST field is absent** (so nothing is saved). Set
`$_POST['subtab_<section_id>']` for sub-tabbed pages (#6147) or
`$_POST['view']` for orchestration views (#6148) before calling
`sanitize()` (#6139).
20. **SSE blocking-emitter contract.** Streaming paths echo SSE frames
directly during dispatch and return a response with no `Content-Type`/
CORS headers. Assert emitted frames (`retry:`, `event: …`, `data: {`,
`data: [DONE]`) via an output-capture helper — never response headers
(#6141, #6149).
21. **`rest_api_init` re-fire triggers third-party temp-table DDL**
(Elementor `e_events`), and DDL implicitly commits the per-test
transaction, leaking fixtures across tests. Bootstrap the REST controller
*before* creating fixtures. Probe DDL with a temp-table create/drop; the
DDL fires during `rest_api_init`, not during dispatch (#6141).
22. **`wp_next_scheduled()` defaults to empty `$args`**, so args-carrying
jobs (`wp_schedule_single_event( $hook, $job_args )`) are invisible to it.
Scan `_get_cron_array()` by hook name instead (#6153).
23. **Pro autoload gap.** CI runs `composer install` in `addons/pro/` so it
executes Pro-dependent suites that local runs silently skip (the committed
classmap does not map `includes/`). Close with a bootstrap fallback
autoloader (slug → `class-wp-mcp-ai-<slug>.php` glob). Note the slug
offset: `strlen( 'WP_MCP_AI_' )` is **10**, not 11 — an 11-char cut drops
the first slug char (`ro-agent-command-center`) (#6143).
24. **`get_post_meta()` returns strings** — registered `integer` meta
sanitizes on *write*, not read. Production itself `absint()`s on read;
assert via `absint( get_post_meta( ... ) )` (#6150).
25. **Settings feature-flag splits.** `enable_federation` no longer implies
`enable_federation_directory`; enable the specific flag and call the
feature loader directly (`maybe_load_federation_features()`) because
`init` already fired (#6150). After `update_option()` on
`WP_MCP_AI_Admin_Settings::OPTION_NAME`, call `reset_settings_cache()` to
drop the static settings cache (#6146).
26. **Addon custom tables never get installed under PHPUnit** — activation
hooks don't run. Per-addon suite bootstraps (`tests/*/bootstrap.php`)
must call the schema installer directly
(`NV_oOS_Graphify_DB::install()`) so CI tables exist (#6145).
27. **Settings defaults live in three layers** — the section field
`'default'` in `get_fields()` (drives checkbox render state via
`WP_MCP_AI_Settings_Section::render_field()`), the base defaults in
`WP_MCP_AI_Admin_Settings_Base::get_default_settings()` (merged into
`get_settings()`), and runtime fallbacks like
`isset( $settings[ $key ] ) ? $settings[ $key ] : false`. Fixing only one
layer leaves fresh installs inconsistent: #6255 flipped the runtime
fallback in `get_available_providers()` but the section fields still had
`'default' => true`, so the OpenAI/Anthropic/Gemini enable checkboxes
rendered checked on fresh installs and any Providers-page save persisted
them. When changing a default, update all three layers and the test that
codifies the field default (e.g. `test_provider_enable_field_defaults`).
28. **Capability-gated render paths.** The chat shortcode/block render
enforces the chat capability (`wp_mcp_ai_chat_capability` filter, default
`edit_posts`) — without a capable current user the render silently
produces no chat markup. Fix: create an administrator and
`wp_set_current_user()` in `setUp()` (reset to 0 in `tear_down()`),
like `test-chat-template-selector.php`.
29. **Rate-limiter test contract: non-429 means allowed through.** Any
status other than 429 means the limiter passed the request through to the
handler; without a configured provider the handler returns 400
`wp_mcp_ai_missing_api_key`, which is unrelated to the limiter. Don't
count 200/500 as "success". Also: `WP_MCP_AI_REST::check_rate_limit()`
accepts the dispatching request's HTTP method so internal dispatches
(`rest_do_request()`, WP-CLI) are classified by their real verb instead
of the ambient `$_SERVER['REQUEST_METHOD']`; GET/HEAD stay exempt.
30. **Dual-shape action emitters.** `wp_mcp_ai_before_chat_request` has a
canonical `( $assistant_id, $messages, $options, $request )` shape, but
legacy/custom emitters (and unit tests) fire the 2-arg
`( $messages, $request_data )` shape. Subscribers (nefarious monitor,
OOS shadow runner, harness trace capture) must default every parameter
and detect the legacy shape when the first argument is an array — never
give a subscriber a strict signature.
31. **Shared rate-limit counters halve budgets.** The nefarious-usage
monitor deliberately namespaces its transient
(`wp_mcp_ai_nefarious_rate_limit_`) away from the chat REST limiter
(`wp_mcp_ai_rate_limit_`) — sharing one counter halves the configured
chat budget and entangles the two enforcement paths in tests.
32. **Transcript payload includes the assistant reply.** The chat service
appends the final assistant response to the conversation before
persisting the transcript, so `$request_payload['messages']` carries the
user message plus the assistant reply. Assert both (role `user` then
`assistant`).
33. **Docs Hub TOC anchors must mirror github-slugger** (the library behind
rehype-slug): slug the Markdown-*stripped* text — not the raw line
(`### [Core Architecture](core/)` otherwise gets an anchor that doesn't
exist) — preserve Unicode letters, do NOT collapse hyphen runs, and
dedupe repeats with `-1`, `-2`, … suffixes.
34. **Docs Hub link fixer: resolve-then-contain path guard.** Link targets
must be plain relative paths — no URL schemes, absolute paths,
backslash separators, or NUL bytes. Relative `../` is legitimate and NOT
rejected; safety comes from `realpath()` containment of the resolved
destination against the filterable `nvoos_docs_hub_fixer_allowed_roots`
list (extend it with your temp dir in tests). Remote-sourced pages are
never fixable server-side (flat content-hash cache) — the admin UI shows
"Remote source" rows. Source resolution prefers the page `slug` because
relative paths like `README.md` collide across addons.
35. **Docs Hub rebuild job contracts.** The sync rebuild honours the
aggregate file cap (`nvoos_docs_hub_max_files_total`, default
`NV_oOS_Docs_Hub_Rebuild_State::DEFAULT_MAX_FILES_TOTAL` = 5000).
`promote_staging()` failure throws — never report "Rebuilt N pages"
with nothing persisted. The search REST envelope is identical with or
without an index (`results`, `total`, `query`).
36. **Core emoji loader corrupts React-managed SPAs.**
`_print_emoji_detection_script()` installs a MutationObserver that
replaces emoji text nodes with `<img>` elements anywhere in the
document; React keeps direct text-node references, so the next commit
throws `NotFoundError: Failed to execute 'removeChild'/'insertBefore' on
'Node'` and unmounts the app ("left panel links stopped working"). The
Docs Hub shortcode render removes that action (plus the legacy detection
and emoji styles) before output; tests assert the removals. No static
done-guard — it would break cross-suite test isolation.
37. **Addon test bootstrap without activation.** PHPUnit never runs
activation hooks, so addon suites define the addon's constants
(`NVOOS_DOCS_HUB_VERSION/PATH/URL/FILE`) and `require` the classes they
need directly in `setUp()`; filters like
`nvoos_docs_hub_fixer_allowed_roots` provide the temp-dir seams.
38. **Graphify bridge load flips `wake_up_context` into graph mode.** Once
any earlier suite `require`s `NV_oOS_Graphify_Memory_Bridge`
(`tests/graphify/bootstrap.php` also installs the graph tables), the
`auto` mode of `wake_up_context` switches to graph retrieval for the
rest of the process. The graph anchors only *boost* wing/room scores —
they never exclude — so cross-wing memories leak into a wing-scoped
block. Symptom: MemPalace phase-2 wing test fails (`count` 3 vs 2)
only when a graphify suite ran first. Fix (production, #6327): enforce
`wing`/`room` in `WP_MCP_AI_Tool_Wake_Up_Context::matches_wake_filters()`.
Reproduce with
`tests/graphify/test-graphify-connectors.php tests/test-mempalace-phase2-memory-tools.php`
in one invocation.
39. **`clear_tools()` on the SHARED registry instance destroys one-shot
bootstrap tools.** Swapping in a fresh per-test registry is safe only if
the ORIGINAL shared instance is restored untouched.
`test-hooks-registry.php` used to `clear_tools()` the saved shared
instance before restoring it; a later lazy `init()` re-registers
file-scanned default tools but NOT tools registered by one-shot actions
(`wp_mcp_ai_bootstrapped` — OKF Pro tools, composio, paper-store).
Symptom: `is_tool_registered( 'okf_enrich_site_content' )` false in later
suites while the class exists. Fix: restore the shared instance without
clearing; the stub tools live on the fresh instance and die with it.
40. **Opt-in logging gates read the static settings cache.**
`WP_MCP_AI_Logger::log_event()` early-returns on
`is_logging_enabled()`, which reads the merged settings through the
static cache. Tests asserting recent-activity/recent-errors entries must
write `enable_logging => true` AND call
`WP_MCP_AI_Admin_Settings::reset_settings_cache()` afterwards so the gate
observes the test's write, independent of options left behind by earlier
suites (house pattern from #6311's wp-cli fix, applied in #6327). Same
idea for provider-connectivity assertions: isolate the provider key under
test — `unset` the other provider keys before asserting `good`, or a
leftover invalid key downgrades the status to `recommended`.
41. **Ported-constant drift in ecosystem-port characterization tests.**
Port characterization tests must derive constants from the ported
source — never from memory or class-name guesses. The Wave F2 comic
data-layer test asserted `mcp_ai_comic_character`, but the byte-identical
test `mcp_ai_comic_character`, but the byte-identical
source declares `mcp_ai_comic_char` (the comic/panel/script slugs are all
shorter than the class names suggest). Before running a new port test,
grep the source's `const POST_TYPE` (and every other asserted constant)
and align the test — a wrong slug turns a byte-identical port into a
red-herring failure. Same discipline for module ordinals: count the
registry test's own `$expected` list rather than trusting handoff notes
(the comic handoff said "25th module"; the actual list has 24).
42. **Standalone-plugin vendor shadows the root PHPUnit.** Suites under
`plugins/nvoos-content-graph/tests` (and similar standalone plugins)
bootstrap through the plugin's own `vendor/autoload.php`, which pins
PHPUnit 9 while `wp-phpunit` 7.x needs PHPUnit 10/11. Symptoms: every
test errors with `Call to undefined method X::name()` or PHPUnit itself
dies with `Cannot instantiate interface PHPUnit\Runner\TestSuiteLoader`
(the plugin-vendor autoloader is registered last, so it wins lookups).
Fix for local validation: run the suite with the ROOT vendor's phpunit
binary (`php /path/to/root/vendor/bin/phpunit -c
plugins/nvoos-content-graph/phpunit.xml.dist ...`) and scrub the
plugin-vendor's PHPUnit mappings from its disposable vendor volume
(`autoload_psr4.php`/`autoload_classmap.php`/`autoload_static.php`
lines mentioning `PHPUnit` or `php-invoker`/`php-timer`, then `rm -rf
<plugin>/vendor/phpunit`). Never commit the scrubbed vendor.
43. **`get_routes()` maps each path to a LIST of endpoint objects** (one per
registered method), not a single `methods` map. Tests asserting route
registration must iterate the list:
`isset( $routes[$path] ) && is_array( $routes[$path] )` then check each
endpoint's `$endpoint['methods'][$method]`. Asserting
`$routes[$path]['methods']` directly silently fails.
44. **`register_rest_route()` outside `rest_api_init` fires
`_doing_it_wrong`**, which wp-phpunit converts into a test failure. To
register routes in a test: `add_action( 'rest_api_init', fn() =>
$controller->register_routes() )`, then force a fresh server
(`$GLOBALS['wp_rest_server'] = null; rest_get_server();`) so
`rest_api_init` re-fires and the hook runs on the correct action.
45. **`rest_url()` in tests uses the `?rest_route=` query form** (plain
permalinks), e.g. `http://example.org/index.php?rest_route=/ns/route` —
never assert a `/wp-json/` URL shape. Assert
`assertStringContainsString( 'ns/route', $url )` to accept both forms.
46. **Pro CLI include-time fatal on base constants.** The Pro addon main
file requires its CLI command files at include time under `WP_CLI`,
and `class-wp-mcp-ai-pro-cli-base-command.php` reads `WP_MCP_AI_PATH`
(a base-plugin constant) unguarded. When Pro is activated before the
base plugin (active_plugins order), every `wp` command dies with
`Undefined constant "WP_MCP_AI_PATH"`. Production fix (#6585): defer
the CLI require loop to `plugins_loaded` (priority 30) when the
constant is missing at include time, and bail cleanly in the CLI base
command. Site workaround: `wp --skip-plugins eval '...reorder
active_plugins...'` or run evals with `--skip-plugins` + manual
`require_once` of the needed plugin file.
47. **Edit-tool non-ASCII mangling (CP1252 bytes).** The edit tool may write
some non-ASCII characters (em-dashes etc.) as single CP1252 bytes
(e.g. `0x97` instead of UTF-8 `E2 80 94`), silently corrupting file
encoding — `php -l` and phpcs do NOT catch it. After editing files with
non-ASCII content, validate with `mb_check_encoding($c, "UTF-8")` and
repair byte-level with a PHP script (lone `0x97` -> `E2 80 94` etc.),
or write new text in pure ASCII. When scanning for damage, a byte
scanner must skip the continuation bytes of valid sequences
(`$i += $len - 1` on the VALID branch too), or every continuation byte
reports as a false-positive invalid sequence.
48. **Order-dependent `pro_schedules` cache interference (renderer suite).**
`addons/pro/tests/test-pro-schedule-result-renderer.php` writes
`SCHEDULES_OPTION`/`RESULTS_OPTION` via `update_option()` directly and
expects `render()` to read them back, but
`WP_MCP_AI_Pro_Schedule_Manager::load_schedules()` short-circuits
through `WP_MCP_AI_Cache_Helper` (`pro_schedules`, 300 s TTL) when
caching is enabled — so when a schedule-manager suite runs earlier in
the same process, the renderer reads the stale cached copy and fails
with `Schedule not found.` (proven pre-existing on a pristine worktree:
`tests/test-pro-schedule-manager.php`
+ `addons/pro/tests/test-pro-schedule-result-renderer.php` in one
invocation — not a regression of adjacent changes). Fix in the
renderer test's `setUp()`: `WP_MCP_AI_Cache_Helper::delete(
'pro_schedules' )`, or run the suite in isolation. Related fixture
note: `WP_MCP_AI_Pro_Remote_Site_Manager::save_connection()`
validates `url` for EVERY connection type — an OAuth fixture (upwork,
gmail) without a `url` fails with `wp_mcp_ai_pro_missing_url` before
the type-specific checks; `save_connection()` encrypts
`refresh_token`/`client_secret` and `get_access_token()` decrypts them,
so plain fixture credentials round-trip fine.
49. **`WP_MCP_AI_Logger::log_activity()` does not exist — latent fatal.**
Legacy tool code calls the nonexistent static `log_activity()`; the real
API is `log_event( $type, $message, $context )` (plus `log_error()`).
Because the dead call usually sits in an `is_available()`-false branch,
it never fires in happy-path tests — L2-style drivers that exercise an
unavailable branch (or a settings-gated tool without its flag) fatal
with `Call to undefined method`. Production fix (#6738): route to
`log_event( 'activity', ... )` — `'activity'` falls through
`should_log_event_type()`'s default allow gate and lands in the recent
activity buffer. Grep-replace ALL call sites in the touched family
(five webchat tools carried it) — leaving siblings dead for later is
the same bug waiting to fire.
50. **`$wpdb->prepare()` placeholder-count warnings from interpolated
WHERE + array-spread args.** phpcs cannot statically resolve an
interpolated `{$where_sql}` fragment, so `$wpdb->prepare( $query,
$query_values )` (array form) warns "1 replacement, expected 2" and
"no valid placeholders found" even though the runtime spread is valid.
Fix by branching the query so every placeholder is literal: build the
`message_type`-filtered and unfiltered variants as two explicit
`prepare()` calls with scalar args, keeping the
`phpcs:disable InterpolatedNotPrepared` block for the
esc_sql()-escaped `{$table}`. Behavior-identical; also run phpcbf or
align `=` spacing manually (WPCS alignment warnings follow).
51. **Addon tool classes fatal in standalone suites (trait/interface not found).**
`Trait "WP_MCP_AI_Tool_Default_Capability" not found` / `Interface
"WP_MCP_AI_Tool_Interface" not found` in the `test (standalone)` jobs
(`plugins/nvoos-content-graph-{ai,ai-platform,pro}/phpunit.xml.dist`).
Cause: an addon's `tests/bootstrap.php` — loaded from the shared root
bootstrap — requires tool classes that `implements
WP_MCP_AI_Tool_Interface` + `use WP_MCP_AI_Tool_Default_Capability`,
contracts that only exist when the base plugin is loaded; the
standalone matrixes boot through the root bootstrap WITHOUT the base
plugin, so the require fatals before any test runs. Fix (three layers,
as in `addons/nvoos-design-system`, PR #6810):
- **Self-guard the production files** — after the ABSPATH guard:
`if ( ! interface_exists( 'WP_MCP_AI_Tool_Interface' ) ||
! trait_exists( 'WP_MCP_AI_Tool_Default_Capability' ) ) { return; }`.
This also keeps the addon's autoloader safe standalone (class_exists
returns false instead of fataling).
- **Guard the addon's tests/bootstrap.php** requires with the same
interface/trait check.
- **`markTestSkipped()`** in the dependent test when the contracts are
absent.
Verify in two ISOLATED processes: no-contracts (require tools + entry →
no fatal, tool classes undefined, core classes load) and with-contracts
(tools load, capability correct).
**Gotcha:** PHP binds unconditional top-level `interface`/`trait`
declarations at COMPILE time — defining the contracts later in the same
file/process makes them exist from line 1, silently invalidating a
"contracts absent" harness. Always simulate the standalone state in a
separate process (`php -r` or a second file).
52. **`Call to undefined method WP_CLI::add_hook()` when a test constructs
real WooCommerce (stub constant leak).** A suite that instantiates the
real `WooCommerce` class fatals because another test file's **file-scope**
`WP_CLI` stub — loaded eagerly during PHPUnit discovery, before any test
runs — defines the `WP_CLI` constant + a minimal class without
`add_hook()` (the `addons/pro/tests/test-pro-cli-mcp-server-command.php`
stub is the in-repo offender). WooCommerce's `WC_CLI` then passes its
`defined('WP_CLI') && WP_CLI` guard and calls the missing method during
`new WooCommerce()`. Fix (both layers, as in the v1.1.89 CI repair):
- **Never construct a second real WooCommerce in a test.** When
`class_exists( 'WooCommerce' )`, drive the code under test against the
real singleton (`WC()->mailer()`) instead; snapshot
`$GLOBALS['wp_filter']['woocommerce_init']` before any
`remove_all_actions()` and restore it — plus the removed `WC_Emails`
`email_header`/`email_footer` handlers — in tearDown (removing the
real mailer's handlers without restoring them is order-dependent
pollution for later WooCommerce suites).
- **Defense in depth:** every file-scope `WP_CLI` stub must include a
no-op `add_hook( $when, $callback )` — defining the `WP_CLI` constant
alone makes real-WC boot paths assume the full WP-CLI API.
**Gotcha:** `class_exists( 'WooCommerce' )` is true in CI (the root
`tests/bootstrap.php` loads WC when `wp-content/plugins/woocommerce`
exists) but false on minimal local copies — the same suite must exercise
**both** branches, and only the stub branch may construct `WooCommerce`
freely.
53. **Bundled asset reads via the WP Filesystem API silently no-op in Docker
(template registry + merge-context assertions).** An addon's template
registry (`NV_oOS_Design_System_Email_Template_Registry::get_builtin_html()`,
the v1.1.90 letterhead fix) read bundled templates only through the WP
Filesystem API, which falls back to `ftpsockets` when the `direct`
transport is unavailable (e.g. Docker dev containers) — every read
silently failed, and tests asserting rendered output went red in
environment-dependent ways. Fix (both layers, as in PR #6816):
- **Prefer the `direct` transport with a plain local-read fallback** —
bundled files never need FTP/SSH; keep the WP Filesystem path only as
the direct-transport branch.
- **Assert on the merge context, not the rendered output, when the
template never renders the token.** The wrapper/renderer supported
`{{to_name}}` but the letterhead template never used it — the test
asserted the recipient name in rendered output and failed; assert the
assert the `nds_email_context` merge context instead (and cover the conditional
block's both branches: name present / absent).
54. **Self-instantiating class + duplicate `new` in the bootstrap loader →
double render (v1.1.91, PR #6830).** An admin class that
self-instantiates at the bottom of its own file
(`WP_MCP_AI_Admin_Orchestration_Dashboard` — the
`WP_MCP_AI_Admin_Multi_Agent_Dashboard` pattern) gained a second
`new` in `includes/bootstrap/loader.php`. `add_submenu_page()`
registers the page hook via `add_action($page_hook, $callback)` — two
distinct instances are two distinct callbacks, so firing the hook
rendered the dashboard **twice** (duplicate DOM IDs breaking
auto-refresh JS). Symptom in tests: `assertSame( 1, ... )` on the
number of callbacks for the page hook fails with 2. Fix: remove the
redundant `new` (keep the `require_once` outside the `is_admin()` gate
so CLI/test contexts still get the registration); assert exactly one
callback via `$GLOBALS['wp_filter'][ $page_hook ]` counting and
fire the hook to assert the wrapper renders once. Regression-proof:
re-adding the duplicate `new` makes the assertion fail — that is the
test's job.
55. **`$wpdb` error HTML leaking into JSON responses (v1.1.91, PR #6827).**
With WP_DEBUG + `show_errors` on, `$wpdb` prints a WordPress database
error HTML block *before* `wp_send_json_success()` — the AJAX client
fails with `parsererror: Unexpected token '<'` (the Pro orchestration
monitor polled every 5 s). Root cause was gating JetEngine CCT reads on
`is_available()` (content type *registered*) instead of the **physical
table** existing with the full schema. Fix layers:
- **Gate on storage readiness** — `table_exists()` probe
(`SELECT 1 … LIMIT 1`) → `is_storage_ready()` (table + every
required column derived from `get_meta_fields()`); consumers fall
back to transients; schema drift trips the gate and logs missing
columns; expose a `reset_storage_cache()` test seam for the
per-request cache.
- **Defense in depth** — the new `WP_MCP_AI_Db_Output_Guard::run()`
suppresses `$wpdb` error output around a callback (logging the
failure with the last query) and wraps the central dispatch
(`WP_MCP_AI_Tool_Registry::execute_tool()` + both REST tool
handlers); read AJAX handlers use `with_suppressed_db_errors()`.
Test both halves: the gate suite (table present / absent /
schema-drifted × consumer fallback) and the guard suite (error HTML
suppressed, failure logged, response still valid JSON). Never assert
raw `$wpdb->last_error` text — assert the envelope + the guard's log
event.
56. **Provider returns array `message.content` → `preg_match(): Argument #2
($subject) must be of type string, array given` (v1.1.92, PR #6845).**
Gemini (and some OpenAI-compatible gateways, incl. DeepSeek endpoints)
return `message.content` as an **array of parts**, not a string — any
consumer feeding it straight into `preg_match()`/`json_decode()`/
string functions fatals. The six research tools all did (provider
response, parse entry, and array content nested inside the JSON body).
Fix layers:
- **Normalize at every choke point** — the shared
`WP_MCP_AI_Tool_Research_Content_Normalization` trait flattens
content-part arrays to strings before any string operation; apply it
on the provider response, the parsed entry, and nested array content.
- **Normalize in the client** —
`WP_MCP_AI_DeepSeek_Client::normalize_response()` flattens array
`message.content` blocks so every DeepSeek consumer (chat,
transcripts, tools) gets a string; leave `reasoning_content` and
`tool_calls` untouched.
- **Test both shapes** — assert the tool handles array content **and**
plain-string content identically (the normalization suite feeds both
shapes through each choke point); a regression is re-feeding the raw
array into a string function.
57. **Pro tool coverage manifest CI failure —
`test_tool_class_manifest_is_up_to_date` (v1.1.92, PR #6847; the #6828
precedent).** Every new Pro tool class must land in
`addons/pro/tests/tools/.coverage-manifest.txt` — a PR that registers
tools without regenerating the manifest fails the registry-coverage
suite. Fix: run `bin/generate-tool-coverage-manifest.sh` in the same PR
and commit the manifest diff with the tools. The manifest lists tool
**classes** — a shared non-tool class in the same folder (e.g. the
fashion transform) appears there without adding a slug to the
registration count, so don't assert slug counts from the manifest
alone.
58. **New registry tools missing from every assistant preset →
`test_all_tools_accounted_for_in_presets` (v1.1.93, PR #6851).** When a
PR registers new tools in `includes/class-wp-mcp-ai-tool-registry.php`
(or the Pro map) without adding them to an assistant preset, the
all-tools-accounted-for gate fails. Fix layers:
- **Add the tools to a preset** — `get_fleet_status`/`get_site_uptime`
joined the `site_management` preset (site-information block); pick the
preset that matches the tool family.
- **Both coverage manifests** — the **base** manifest lives at
`tests/tools/.coverage-manifest.txt` (the Pro one at
`addons/pro/tests/tools/.coverage-manifest.txt`, pattern 57); new base
tool classes must land in both the preset(s) and the base manifest, or
`test_tool_class_manifest_is_up_to_date` fails too.
- **Image/attachment assertions follow the pipeline** — a test pinning
an earlier behavior (e.g. `convert_image_files_to_image_url` asserting
`http` URLs after #6846 switched to inline `data:image/…;base64`)
must move to the new contract when the production path changes.
59. **Tool-rule validation failures rendered as HTTP 500 — a `WP_Error`
without an HTTP status (v1.1.93, PR #6853).** `validate_tool_execution()`
returned `tool_validation_failed` with no `status` key, and the WP REST
layer converts a status-less `WP_Error` into a raw 500 (masked further by
`api_error_verbosity: safe`). Fix: every validation `WP_Error` carries
`status => 400` with the error list, so failures surface as readable 400s
with the real reason. Regression-test the failure path asserting both the
HTTP status **and** the error payload, and verify the credential gate:
`validate_dependencies()` must resolve `required_settings` keys through
`WP_MCP_AI_Credential_Resolver` (env vars, constants, WP 7.0 Connectors)
so keys configured outside the settings array can't false-negative the
gate.
60. **Registry auto-upgrade to the `_validated` variant rejects values the
base tool advertises (v1.1.93, PR #6859).**
`WP_MCP_AI_Tool_Registry::get_tool()` auto-upgrades
`edit_gemini_image`/`generate_gemini_image` to their `_validated`
wrappers whenever registered (PHP 8+ with Symfony — the default), so the
validator's constraints are the real gate. The validated `aspect_ratio`
`Choice` allowed only `1:1, 3:4, 4:3, 9:16, 16:9` while the base tools
advertise `'auto'` in `get_allowed_aspect_ratios()` — callers passing the
advertised default got a status-less `validation_failed` → REST 500. Fix:
keep every validated `Choice`/constraint in sync with the base tool's
advertised set, and regression-test both directions (the advertised
value passes; an unknown value still fails).
61. **New severity-5 PHPCS sniffs gate previously-unchecked dispatch
patterns (v1.1.94, PR #6866).** The `WPMCPAI.Decisions.ScopeDeclared`
sniff requires every `->decide()` / `->create_decision()` call to sit in
a method whose docblock declares `@decision-domain` +
`@decision-authority` — an existing dispatch that predates the sniff
(e.g. the verification cascade, `typesafe_*` tools) fails CI until the
docblock + `WP_MCP_AI_Decision_Scope_Guard::gate()` call are added.
Triage: run the sniff standalone over the tree first (`phpcs --sniffs=
WPMCPAI.Decisions.ScopeDeclared`), fix the declarations before touching
behavior (the guard must be a zero-behavior-change pass), and assert the
banned-domain fail-closed path (the formula never runs — assert the
deterministic fallback result, not an absence of calls).
62. **`wp_check_php_version()` polyfill declared before the admin-includes
load → `Cannot redeclare` fatal (v1.1.94, PR #6870).** `get_site_health`
pre-loaded `wp-admin/includes/misc.php` only when `WP_Site_Health`
wasn't loaded — in REST/CLI/chat contexts the file was skipped, the
polyfill declared, and core's later lazy require re-declared the
function → critical-error page. Fix: `file_exists()`/`function_exists()`
guard the **pre-load** (`require_once ABSPATH . 'wp-admin/includes/misc.php'`
when the function is missing) *before* declaring the polyfill, and
regression-test with `WP_Site_Health` already loaded (the exact
condition that used to skip the require).
63. **A domain-event object fired through a legacy `wp_mcp_ai_*` hook
crashes every subscriber with required parameters → HTTP 500 on the
OOS path (v1.1.94, PR #6872).** The WordPress `EventDispatcher` fired
the four mapped domain events as raw event objects; legacy subscribers
like `WP_MCP_AI_Response_Attachments::handle_chat_response()` declared
`( $content, $assistant_id, $request )` and threw `ArgumentCountError`.
Fix: the dispatcher translates mapped events into the documented legacy
argument tuples before firing (unknown events keep the single-event
shape), and every subscriber must stay null-safe for the `$request`
slot (it is null on the OOS path). Regression-test the end-to-end
dispatch (REST request with the OOS engine enabled + intercepted
provider HTTP) rather than the hook alone.
64. **Chat SSE streams reset by proxies during long agentic tool runs —
`net::ERR_HTTP2_PROTOCOL_ERROR` / `SSE stream processing error`.
Cloudflare (~100 s read timeout) and nginx (`proxy_read_timeout`, often
60 s on Cloudways) reset connections that sit idle while PHP is blocked
inside a tool call or waiting on the model. PHP is single-threaded, so
NO frames can be emitted during a blocking tool call — server-side
headers alone cannot fix this. The shipped fix (v1.9.5) emits SSE
keepalive comment frames (`send_sse_keepalive()` →
`WP_MCP_AI_SSE_Handler::send_sse_comment()`) at every inter-step
boundary in `handle_chat_request_with_streaming()`: after the SSE
headers, before the initial LLM call, between `tool_start` and
`execute_tool_call_internal()`, and before each in-loop LLM call.
Comment frames are invisible to EventSource clients but flush through
proxies and reset idle counters. Test via an anonymous recording
`WP_MCP_AI_SSE_Handler` subclass injected through the REST constructor's
`$sse_handler` param (don't delegate to the parent — it echoes and
touches headers) + a mock router returning tool_calls then a final
response; assert the ordered call log
(`tests/rest/test-sse-stream-keepalive.php`). Limits: a single tool
call longer than the proxy timeout still kills the stream — the durable
fix is deadline-triggered offload to the job stream (proposal:
`docs/project/proposals/sse-stream-hardening-long-run-offload-proposal.md`).
65. **A test context mysteriously lacks tools after v1.1.96 — the lazy
registry, not a missing registration.** #6896 defers the ~350 default
tool loads on plain front-end requests; `wp_mcp_ai_is_plugin_runtime_context()`
returns true for REST/admin/AJAX/cron/CLI/WP-CLI/installing **and when
`WP_TESTS_CONFIG_FILE_PATH` is defined**, so PHPUnit runs still get the
eager defaults. A test that runs in a non-runtime context (e.g. a raw
front-end `do_action('wp')` simulation or a hand-rolled bootstrap that
unsets the config constant) will see third-party tools but no defaults
until first access — assert through `ensure_default_tools_loaded()`
(protected; exercise via a real execution or Reflection) or set the
`wp_mcp_ai_is_plugin_runtime_context` filter to true in `setUp()`. Also
from #6896: the `WP_MCP_AI_Inline_Async_Tick_Trait` double-declaration
fatal — addons shipping a trait stub (docs-hub, graphify, saas-controller)
crashed plugin activation in tests with addons loaded; the fix is the
`trait_exists()` guard pattern — when a plugin trait may collide with an
addon stub, guard every `require` of the trait file with `trait_exists()`
(never rely on require_once ordering alone).
66. **The `parity-check` CI job fails after touching a chat/guardrail surface
(v1.1.97+).** #6913 added a CI job that runs `bin/check-chat-parity.php`,
which now tracks **all four** OOS/legacy guardrail surfaces (35 → 38
features, 100% parity): the gate-exception → envelope translation
(428 confirmation / 429 limits via `translate_gate_exception()`),
`apply_pre_response_render()` at the final payload (legacy non-streaming,
legacy streaming's final SSE event, OOS handler), the
`wp_mcp_ai_agentic_iteration_complete` domain-event bridge, and the
`wp_mcp_ai_before_tool_execute` waterfall bridge. Any PR that adds,
moves, or gates one of those surfaces without updating the script's
feature assertions fails the `parity-check` job — extend the script in
the same PR (the script is the regression net for guardrail symmetry
between the legacy and OOS paths; its 100% parity is asserted by
`tests/test-oos-parity-gaps.php`).
67. **Helper/stub files in a `suffix=".php"`-scanned test dir must be
excluded in `phpunit.xml.dist` (v1.1.95+, PR #6886 follow-up).** The
root config scans `addons/pro/tests` (and the other addon test dirs)
with `suffix=".php"`, so every PHP file there is loaded as a test
file. A helper whose declared class does not match the
filename-derived name emits a PHPUnit runner warning: `Class
class-wp-mcp-ai-media-toolkit-stub cannot be found in
.../addons/pro/tests/class-wp-mcp-ai-media-toolkit-stub.php`. Fix:
add an `<exclude>` entry next to the
`class-wp-mcp-ai-workflow-log-context-recorder-tool.php` precedent
(the stub stays `require_once`'d by its test, so the tests keep
running). Every new `class-*.php` / `trait-*.php` helper added to a
scanned dir needs the exclude in the same PR; the toolkit-audit loop
generates these regularly.
68. **Standalone addon commerce/checkout suites (docs-hub 0.5.2, content-graph).**
Call the REST controller methods **directly**
(`NV_oOS_Docs_Hub_Checkout_REST::create_session( new WP_REST_Request() )`)
so `WP_Error`s come back raw — `$server->dispatch()` wraps errors in
`WP_REST_Response`, so through dispatch assert `get_status()` (403 for
permission) and `get_data()['code']` (`rest_invalid_param`) instead of
`instanceof WP_Error`. Mock the vendor with a `pre_http_request` filter
that routes by URL substring (`/session`, `/verify`, `/health`) and
counts calls for zero-HTTP assertions (already-licensed short-circuits
and throttle blocks must never hit the wire). Stripe intent fixtures
must be **alphanumeric-only** (`pi_1234567890abcdef`) — `pi_test_…`
contains `_`, which fails the `^pi_[A-Za-z0-9]{8,}$` REST validator and
silently 400s before the callback runs. Already-licensed state:
license option + `update_option( 'active_plugins', array(
BUNDLE_BASENAME ) )`. Install-conflict path: guarded
`define( 'WP_MCP_AI_VERSION', … )` trips the base-plugin guard so
verify records the license and returns the manual-download error
without any download attempt. Upsell render tests capture
`render_page()` via `ob_start()` (require
`wp-admin/includes/template.php` for `submit_button()` first). Throttle
tests: five direct session calls pass, the sixth returns 429 with zero
extra HTTP. Full conventions:
`.agents/skills/mcp-ai-wpoos-checkout-integration/SKILL.md`.
69. **A raw pass-through filter-chain assertion breaks when a global
prompt-filter subscriber appends (v1.2.2, PR #6980).** The chat-profile
system (#6977) registers `WP_MCP_AI_Chat_Profile_Prompt_Hint` at
priority 20 on `wp_mcp_ai_resolved_system_prompt` — any test that
asserts the resolver passes the prompt through **unmodified** fails
with an appended "Read-only mode" cue because the test runs as guest
(user 0) and guests resolve to the restrictive read-only profile.
Production behaviour is correct by design (Proposal 015) — the test is
simply not isolated from unrelated prompt-filter subscribers. Fix: pin
the test's user to the `write` chat profile before exercising the
chain (create an admin user, set the profile, then assert the
pass-through), and guard the profile calls with `class_exists()` so
standalone/addon test matrices without the chat-profile system remain
unaffected. Rule: a pass-through assertion on any
the `wp_mcp_ai_resolved_system_prompt`-family filter is fragile by
definition — isolate the subscriber set first.
70. **`class_exists()`-guarded base-plugin calls must use the global
namespace (v1.1.0 content-graph bearer auth, PR #6998).** Inside a
namespaced class, an unqualified `WP_MCP_AI_Credentials::validate_token()`
resolves to `Current\Namespace\WP_MCP_AI_Credentials` — the string form
`class_exists( 'WP_MCP_AI_Credentials' )` passes (strings never resolve),
then the call fatals with `Class "NvoosContentGraph\Rest\WP_MCP_AI_Credentials"
not found` the moment a request presents a credential. Fix: leading
backslash on every class reference (`\WP_MCP_AI_Credentials::...`). Tests
that stub the base class via a no-namespace helper file
(`tests/helpers/base-plugin-credential-stubs.php` pattern: seed tokens,
`is_token_format()` + `validate_token()` static methods) catch this only
if they exercise the credential path with a real REST request.
71. **Filesystem fixture accumulation in a fixed uploads path (v1.2.4,
Model Foundry Phase 1, `test-model-foundry-exporters.php`).** A tool that
scans a fixed uploads directory (`wp_upload_dir()['basedir'] . '/nvoos-docs-hub/content'`)
picks up files left behind by other tests or earlier runs — the WP test
framework's uploads dir persists across suites in the same run and across
runs on a shared Docker core volume, so assertions on row counts or the
first row flake (`rows == 3` instead of 2; preview text from a stale
file). Fix: **wipe the fixed directory inside the test before writing
fixtures** (a recursive `wipe_dir()` helper, tracked in `$cleanup_dirs`
and re-wiped in `tearDown`), or pass a `uniqid()`-suffixed directory when
the production API accepts an explicit path. Same rule for any
corpus/exporter test asserting counts over a directory scan — never
assume the directory is empty.
## CI workflow hardening — setup-php `tools: wp-cli` flakes
`shivammathur/setup-php` `tools: wp-cli` fails intermittently (`✗ wp-cli
Could not setup wp-cli`) WITHOUT failing the step, so the next `wp core
download` dies with `wp: command not found` (exit 127) — same code, same
workflow, alternating green/red runs. Fixed in PR #6998 for
`.github/workflows/phpunit-content-graph.yml` + the plugin-check job of
`.github/workflows/build-nvoos-content-graph.yml`:
```yaml
- name: Ensure WP-CLI is available
run: |
if ! command -v wp >/dev/null 2>&1; then
curl -sSLo wp-cli.phar https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
chmod +x wp-cli.phar
sudo mv wp-cli.phar /usr/local/bin/wp
fi
wp --version
```
Diagnose via `gh run view <id> --log | grep -iE "wp-cli"` — look for the
red ✗ tool line in the Set up PHP step, not the step that died. Any new
workflow using setup-php `tools: wp-cli` should include this fallback step.
## Production fix vs test fix
- **Fix production** when the test exposes a genuine bug: unsafe coercion
(pattern 11), a latent fatal on a real code path (Graphify admin classes
required only under bootstrap `is_admin()`, #6106), or a missing filter seam
that blocks deterministic testing (#6097). Keep production edits minimal and
behavior-compatible; document the contract change in the PR.
- **Fix the test** when it documents an outdated contract or an environment
assumption. Never weaken a security assertion (nonce/capability) to make a
test pass; prefer structural fixes over `setExpectedIncorrectUsage()`
(ineffective on WP 7.1).
## Debugging workflow
Scratch test files under `tests/` with `fwrite( STDERR, … )` are the fastest
instrument; `spl_object_hash( $this )` distinguishes instance identity when a
mock seems to write to the "wrong" object. **Delete scratch files before
committing.**
```php
fwrite( STDERR, 'STATE: ' . wp_json_encode( $data ) . PHP_EOL );
```
### Order-dependent bisection pitfalls (session 2026-09-05)
- **Chunk-only prefix runs produce false results.** Suites depend on side
effects of earlier suites (registry state, generated skill knowledge,
installed addon tables), so running an arbitrary subset can produce
failures that don't exist in CI and mask the ones that do. Bisect with
ever-larger prefixes of the full sorted order — never random subsets.
- **Reset the DB between experiments.** A killed run leaves residue (open
transactions, deadlocks, mid-run DDL) that corrupts the next run:
`docker exec oos-wp-db sh -c 'mysql -uwordpress -pwordpress -e "DROP
DATABASE wordpress_test; CREATE DATABASE wordpress_test;"'`. The fresh
install then takes a few minutes on the first run.
- **Scratch phpunit XML configs** must carry the `external-http` group
exclusion (otherwise suites hit the real network and crawl) and list each
`<file>` exactly once in sorted order — duplicates abort with "already
added to test suite". Multiple `<directory>` entries append per-directory
sorted files; do NOT merge directories into one sort.
- **Local full-prefix runs are 5-10x slower than CI** (loopback HTTP per
test). Always `--log-events-text /tmp/events.txt` with a host-mounted
`/tmp` so the per-suite order survives even if the run is killed by
`timeout`.
- **Config/exclude changes: verify via `--list-tests`.** Loader warnings
("Class X cannot be found in Y") fire at suite construction, so
`php -d memory_limit=1G vendor/bin/phpunit --list-tests --no-coverage`
(no tests executed) reproduces them in minutes instead of a full run.
Grep the output for `warning` and the suspect filename to confirm the
fix before running any suite.
- **CI-log archaeology.** Progress lines (`NNNN / 17245 (NN%)`) plus
`[NV oOS]` debug lines locate suites; a suite whose logging gate is off
emits no `[NV oOS]` lines while its neighbours do. Consecutive failing
tests can straddle a progress-line boundary (`FF` at the end of one line
plus `F` at the start of the next = three consecutive failures).
## Enumerating the suite
Canonical scan dirs: `tests`, `addons/pro/tests`,
`addons/canvas-toolkit/tests`, `addons/media-studio/tests`,
`addons/saas-controller/tests`. Exclude: `tests/manual`, `tests/fixtures`,
`tests/regression`, `tests/helpers`, all `bootstrap.php` /
`wp-tests-config.php` / `wp-cli-smoke.php` files, the abilities mock tool and
bootstrap trait, the paper-store helpers trait, the graphify and
saas-controller bootstraps,
`addons/pro/tests/class-wp-mcp-ai-workflow-log-context-recorder-tool.php`,
and `addons/pro/tests/class-wp-mcp-ai-media-toolkit-stub.php`.
Batched runs sort the remaining files alphabetically and resume from a
suffix index (chunk manifests list files, they are not inputs).
## Cluster state board
Completed clusters (merged or open against `alpha-working`): #6084 CRM
toolkit, #6085 CRM data store, #6086 Huggingface, #6087 Veo, #6088 transcript
mining, #6089 quiz admin pages, #6090 document template pages, #6091
multi-agent AJAX, #6092 chart JS enqueue, #6093 admin hook suffixes, #6094
orchestration modes, #6095 REST tools controller, #6096 multi-agent
orchestration, #6097 NPM notice, #6098 multi-agent dashboard, #6099 slash
command integration, #6100 profession media vector, #6101 base knowledge
seeder, #6102 pro dashboard diagnostic, #6103 chat conversation CCT, #6104
admin test model, #6105 profession team CPT sanitization, #6106 Graphify admin
classes, #6107 toolkit + hooks registry, #6108 test-suite skill, #6109 Shopify
sync CCT manager, #6110 semantic compressor, #6111 usage tracker pricing
drift, #6112 docs catch-up, #6113 Content Graph AI credential encryption,
#6114 WP 7.1 Woo role resync, #6115 create post taxonomy application, #6116
site creator tools drift, #6117 toolkit registry singleton drift, #6118
PHPUnit loader excludes, #6119 credential resolver cache invalidation, #6120
capability flags model drift, #6121 settings suite test drift, #6122 slash
command tool mode contract, #6124 crawler job contract, #6125 Crawl4AI tool
failures, #6126 transcript mining job cluster, #6127 save post content drift,
#6128 tool slug integrity drift, #6129 tool coverage manifest, #6130 bulk
auto dispatch inline, #6131 TPM fallback model drift, #6132 async registry
construct errors, #6133 base version polluters, #6134 cache helper cluster,
#6135 transcript recorder repository, #6136 translation loading timing,
#6137 profession playbook seeder, #6138 site health tool, #6139 settings
dashboard, #6140 slash command sync docs, #6141 REST assistant directory,
#6143 Pro test autoload, #6144 new regressions, #6145 Graphify connectors,
#6146 rate limit backoff, #6147 provider subtab settings, #6148 orchestration
slider settings, #6149 MCP client configuration, #6150 federation test,
#6153 Google Chat fields (open).
Newer merged clusters (post-#6153, discovered via the PR merge log):
#6249 Hermes dashboard fleet extensions, #6251 Auth0 toggle, #6252 Auth0
menu, #6253 docs-hub emoji DOM crash, #6254 auto-categorize, #6255 provider
defaults, #6256 Pro SPA v2 shortcode, #6257 chart tiers, #6258 theme
sortable compat, #6259 chat attachments, #6260 count-tokens params, #6262
continuation seam, #6263 chat SSE handler, #6264 cache helper, #6265
nefarious rate limiter + chat hook tolerance (open).
Session 2026-09-04/05 wave-5 clusters (runs 91006542428 + 91771001271,
all merged): #6280 chat transcripts + attachments, #6281 charts + Pro
dashboard, #6282 professions/teams, #6283 presets/remote connections,
#6284 Cloudflare, #6285 mesh networking, #6286 memory auto-capture,
#6287 AI CPT management integration, #6288 PayHere vs Remote
Connections, #6289 Mubert music contract, #6290 OpenAI classic Images
API, #6291/#6297 composer metadata drift, #6292 Content Graph AI
ecosystem, #6293 pro schedule AJAX + curriculum exporter, #6294
cluster-board docs, #6295 exec-disabled host hardening, #6296
conversation-import CCT gate, #6298 model routing/complexity routing,
#6299 Gemini, #6300 JetEngine/Graphify stub isolation + gates, #6301
OAuth/credentials encryption, #6302 REST + skill-pack registry, #6303
admin/settings drift, #6304 K10 Elementor, #6305 K11 permission gates
(acting-user capability checks), #6306 K12 HTTP/provider clients, #6307
K13 orchestration budget, #6308 K14 logging/events (recent-activity
allowlist + trace durations), #6311 K16 misc singles (webhook
round-trip, settings-repository blob fallback, chart HTML attachment
gating, site-builder heading tags, PSO keyword inflections,
scheduled-post publish date reset + 16 suite contract updates), #6312
K15 skill-suite upload isolation (pack normalisation + install action
landed via direct commit d452a125bf).
Session 2026-09-05 cluster (run 91942465749): #6327 post-K16 singles
(merged) — chat transcript session-key mock restore, MemPalace wing-scope
graph leak (production fix in `wake_up_context`), OKF Pro tools lost to
`clear_tools()` on the shared registry, Site Health provider-key
isolation, transcript-mining logging cache reset.
Remaining candidates change quickly; re-triage from the latest CI log rather
than trusting an old list.
## References
- Test-writing patterns & coverage policy: `.context/testing.md`
- Remaining-fixes tracker: `docs/developer/testing-docs/TEST-SUITE-REMAINING-FIXES-PLAN.md`
- Plugin operational guide: `.agents/skills/mcp-ai-wpoos-plugin/SKILL.md`
- Per-toolkit hardening audit loop: `.agents/skills/mcp-ai-wpoos-toolkit-audit/SKILL.md`
- **ID-handoff contract suites (permanent CI infrastructure):**
`tests/test-tool-id-handoff-contract.php` (L1 honesty — checks
`tests/fixtures/tool-contract-manifest.php` against the live registry in
both directions; an unvetted annotation or a missing manifest entry fails
CI), `tests/test-tool-id-handoff-round-trip.php` (L2 deterministic
create→fetch→update→delete drivers, no LLM). Rollout semantics:
`docs/project/proposals/P3-data-contract-rollout-plan-2026-09.md`; the
manifest is the single source of truth — new tool annotations land in the
same PR as their manifest entry (rollout complete via #6729–#6738; docs
closure #6739).