Repair and triage guide for the NV oOS PHPUnit test suite — Docker test environment (incl. cross-worktree one-off runners), CI log triage, 47 recurring root-cause patterns (hook resets, singleton interference, zombie mocks, WP_Error envelope drift, SSE blocking-emitter contract, sub-tab sanitizer routing, rest_api_init DDL commits, cron-array lookups, Pro autoload gaps, three-layer settings defaults, capability-gated renders, rate-limiter contracts, dual-shape action emitters, Docs Hub addon ...
Scanned 9/11/2026
Install to Claude Code
npx -y skills add nvdigitalsolutions/mcp-ai-wpoos --skill mcp-ai-wpoos-test-suite --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Mcp Ai Wpoos Test Suite?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/nvdigitalsolutions-mcp-ai-wpoos-test-suite)More formats (shields.io, HTML) on the badges page.
---
type: Skill
name: mcp-ai-wpoos-test-suite
description: Repair and triage guide for the NV oOS PHPUnit test suite — Docker test environment (incl. cross-worktree one-off runners), CI log triage, 47 recurring root-cause patterns (hook resets, singleton interference, zombie mocks, WP_Error envelope drift, SSE blocking-emitter contract, sub-tab sanitizer routing, rest_api_init DDL commits, cron-array lookups, Pro autoload gaps, three-layer settings defaults, capability-gated renders, rate-limiter contracts, dual-shape action emitters, Docs Hub addon contracts, Graphify bridge graph-mode flip, opt-in logging cache gates), cluster-by-cluster PR workflow against alpha-working, and validation gates. Use when fixing failing PHPUnit tests, triaging CI logs, repairing test drift, deciding between a production fix and a test fix, or starting a new fix cluster.
license: Proprietary. See LICENSE.txt
metadata:
plugin: mcp-ai-wpoos
last-updated: "2026-09-05"
---
# 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)
## 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):**
```bash
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):**
```bash
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:
```bash
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:
```bash
# 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').
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`:
```bash
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
```
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).
### 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:
```bash
# 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:
```bash
# 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
# 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).
## 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.
### 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.
## 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`.
- **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, and
`addons/pro/tests/class-wp-mcp-ai-workflow-log-context-recorder-tool.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`
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!