From eaf2cc9ab1c75504aa66b411956e833c9a47465d Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Mon, 12 Oct 2026 18:31:19 +0530 Subject: [PATCH] feat(init): add opt-in DevTools feature --- .claude/skills/README.md | 2 +- .claude/skills/init/SKILL.md | 15 +- .github/prompts/init.prompt.md | 13 +- AGENTS.md | 1 + README.md | 2 +- bin/dev-tools-e2e.sh | 324 +++++++++++++++++++++++ bin/features/dev-tools.js | 289 +++++++++++++++++++++ bin/scaffold.config.js | 22 +- docs/dev-tools-demo.md | 205 +++++++++++++++ docs/features.md | 1 + docs/getting-started.md | 2 +- docs/index.md | 3 +- docs/initialization.md | 11 +- docs/internal/maintenance.md | 7 +- tests/js/scaffold-config.test.js | 431 +++++++++++++++++++++++++++++++ 15 files changed, 1302 insertions(+), 26 deletions(-) create mode 100755 bin/dev-tools-e2e.sh create mode 100644 bin/features/dev-tools.js create mode 100644 docs/dev-tools-demo.md create mode 100644 tests/js/scaffold-config.test.js diff --git a/.claude/skills/README.md b/.claude/skills/README.md index 935bcd7..86e6ece 100644 --- a/.claude/skills/README.md +++ b/.claude/skills/README.md @@ -4,7 +4,7 @@ Skills for AI assistants (Claude Code, Cursor, and any tool that reads the Claud | Skill | Invoke | What it does | |---|---|---| -| [`init/`](init/SKILL.md) | `/init` | Turn this cloned skeleton into a named project and manage it afterwards: rename the starter tokens, keep/remove the example sets, toggle optional features (Tailwind, HMR, Dev Tools). Drives `npm run init`. | +| [`init/`](init/SKILL.md) | `/init` | Turn this cloned skeleton into a named project and manage it afterwards: rename the starter tokens, keep/remove the example sets, toggle optional features (Tailwind, HMR, DevTools). Drives `npm run init`. | | [`scaffold/`](scaffold/SKILL.md) | `/scaffold` | Add one feature (CPT, taxonomy, block, REST controller, shortcode, cron, CLI, settings page, user role, service, CI workflow) via `npx wp-tooling add`, TDD-first. | | [`setup/`](setup/SKILL.md) | `/setup` | Generic tooling bootstrap for plugins and themes, shipped with `@rtcamp/wp-tooling`. Not a personalization path for this skeleton; use `/init`. | diff --git a/.claude/skills/init/SKILL.md b/.claude/skills/init/SKILL.md index d70a589..76482a1 100644 --- a/.claude/skills/init/SKILL.md +++ b/.claude/skills/init/SKILL.md @@ -10,7 +10,7 @@ Two jobs: **bootstrap + setup** a fresh clone into a real plugin, or **manage** ## Use for - First run: install dependencies, rename starter tokens, pick capabilities to keep. - First run WITH a feature brief: after rename + capabilities, implement the described features by chaining to the **scaffold** skill (TDD), replacing the matching `Example*` placeholders (step 5). -- Later: rename / re-prefix, toggle features (Tailwind, HMR, Dev Tools). +- Later: rename / re-prefix, toggle features (Tailwind, HMR, DevTools). ## Do not use for - Adding a feature class → the **scaffold** skill (`npx wp-tooling add`). @@ -46,7 +46,7 @@ Then verify a clean working tree (`git status`) - init rewrites files irreversib ### 3. Ask, confirm, run - one step `npm run init` is a single command; don't turn gathering its inputs into a multi-round-trip wizard. -**Setup:** in ONE message ask for project name (required, e.g. `Acme Blog` → namespace `Acme_Blog\Features`, package `rtcamp/acme-blog-features`, text domain, prefixes, main file - show these back), version (default `1.0.0`), which capability sets to remove, and which features to enable (defaults: keep all sets, `hmr` on, `tailwind` off - see Capability model). The engine derives the tokens itself; do not read the engine source (`identity.js` etc.) - the mapping above is the contract, and the graph answers any deeper question (see the graphify policy in `AGENTS.md`). +**Setup:** in ONE message ask for project name (required, e.g. `Acme Blog` → namespace `Acme_Blog\Features`, package `rtcamp/acme-blog-features`, text domain, prefixes, main file - show these back), version (default `1.0.0`), which capability sets to remove, and which features to enable (defaults: keep all sets, `hmr` on, `tailwind` and `dev-tools` off - see Capability model). The engine derives the tokens itself; do not read the engine source (`identity.js` etc.) - the mapping above is the contract, and the graph answers any deeper question (see the graphify policy in `AGENTS.md`). **Manage:** ask which of identity / features to change. It's a single flag on an existing project - no wizard needed. @@ -54,10 +54,10 @@ Get explicit consent on the resolved values (init is destructive), then run: ```bash # Setup: npm run init -- --name="Acme Blog" --version=1.0.0 --yes \ - --remove-examples=cron,rest --features=hmr,tailwind + --remove-examples=cron,rest --features=hmr,tailwind,dev-tools # Manage: npm run init -- --list -npm run init -- --enable=tailwind --yes +npm run init -- --enable=dev-tools --yes npm run init -- --features=hmr --yes # exact enabled set (empty = none) ``` - `--keep-examples` keeps all; `--remove-examples` (no value) removes all; `--remove-examples=a,b` removes listed keys. @@ -69,6 +69,7 @@ npm run init -- --features=hmr --yes # exact enabled set (empty = none) ### 4. After init - Tailwind enabled → developer runs `npm install` (it added `src/css/tailwind.css`, `postcss.config.js`, and pinned `@rtcamp/tailwind-config`). +- DevTools enabled → it's still a private package (`rtcamp/wp-devtools`, VCS-sourced): developer runs `composer update rtcamp/wp-devtools`, which may need GitHub auth, then `npm run dev:connect`. - `composer dump-autoload` (engine runs it when `composer.json` is present). - Trust the engine's own output to verify (it reports the removed sets, drops their `Main::CLASSES` lines and tests, and regenerates the autoloader). To confirm a symbol or reference, run a single `graphify query`/`affected` against the graph - never grep `Main.php`/`inc/` to check removal. - If you hand-edited PHP and are NOT handing off to scaffold (e.g. a manage-mode dangling `Main::CLASSES` cleanup), run `composer lint` on the change and fix it. When a brief follows (step 5), leave the lint/PHPStan/test gates to the scaffold skill - init does not run them. @@ -96,9 +97,9 @@ ONE "Select the capabilities to include" prompt. Each is keep-or-remove; removin | APIs & Automation | `rest`, `cli`, `cron` | | Admin | `settings`, `admin`, `roles` | | Utilities | `cache`, `transients` | -| Developer Tooling | `test-measure` (CI workflow), `hmr` (feature) | +| Developer Tooling | `test-measure` (CI workflow), `hmr` (feature), `dev-tools` (feature) | -Sets are kept by default; pass keys to `--remove-examples` to drop. Features: `hmr` on, `tailwind` off; toggle via `--features`/`--enable`/`--disable`. Everything here installs from public registries. +Sets are kept by default; pass keys to `--remove-examples` to drop. Features: `hmr` on, `tailwind` and `dev-tools` off; toggle via `--features`/`--enable`/`--disable`. `dev-tools` (`rtcamp/wp-devtools`, runtime telemetry over MCP - Query Monitor + the MCP Adapter) is the one feature still sourced from a private VCS repo; enabling it needs GitHub auth on `composer update` (see step 4). Everything else here installs from public registries. ### Changing capabilities after setup Add later → scaffold skill / `npx wp-tooling add /` (writes the class, wires the module). Remove later → delete its `inc/Modules//` classes + `Main::CLASSES` line by hand. Toggle a feature any time via `npm run init -- --enable=`/`--disable=`. Do not re-run init to change the capability set. @@ -112,6 +113,6 @@ Add later → scaffold skill / `npx wp-tooling add /` (writes th - Never invent flags. Supported: `--name`, `--version`, `--yes`, `--keep-examples`, `--remove-examples[=...]`, `--features`, `--enable`, `--disable`, `--reinit`, `--list`, `--clean`, `--help`. Run `npm run init -- --help` if unsure. ## Reference -- Engine: `@rtcamp/wp-tooling/init` (via `bin/init.js`). Capability map: `bin/scaffold.config.js`. +- Engine: `@rtcamp/wp-tooling/init` (via `bin/init.js`). Capability map: `bin/scaffold.config.js`. DevTools feature: `bin/features/dev-tools.js`. - Conventions renamed into: `AGENTS.md`, `.github/instructions/structure.instructions.md`. - Graphify policy: `AGENTS.md`. diff --git a/.github/prompts/init.prompt.md b/.github/prompts/init.prompt.md index e4f438b..be10fd5 100644 --- a/.github/prompts/init.prompt.md +++ b/.github/prompts/init.prompt.md @@ -20,7 +20,7 @@ Copilot must gather inputs BEFORE acting. Do not assume any value. ## Use for - First run: install dependencies, rename starter tokens, pick capabilities to keep. -- Later: rename / re-prefix, toggle features (Tailwind, HMR, Dev Tools). +- Later: rename / re-prefix, toggle features (Tailwind, HMR, DevTools). ## Do not use for - Adding a feature class → the `/scaffold` prompt. @@ -45,15 +45,15 @@ Then verify a clean working tree (`git status`) - init rewrites files irreversib ### 3. Ask, confirm, run - one step `npm run init` is a single command; don't turn gathering its inputs into a multi-round-trip wizard. -**Setup:** in ONE message ask for project name (required, e.g. `Acme Blog` → namespace `Acme_Blog\Features`, package `rtcamp/acme-blog-features`, text domain, prefixes, main file - show these back), version (default `1.0.0`), which capability sets to remove, and which features to enable (defaults: keep all sets, `hmr` on, `tailwind` off - see Capability model). +**Setup:** in ONE message ask for project name (required, e.g. `Acme Blog` → namespace `Acme_Blog\Features`, package `rtcamp/acme-blog-features`, text domain, prefixes, main file - show these back), version (default `1.0.0`), which capability sets to remove, and which features to enable (defaults: keep all sets, `hmr` on, `tailwind` and `dev-tools` off - see Capability model). **Manage:** ask which of identity / features to change - it's a single flag on an existing project, no wizard needed. Get explicit consent on the resolved values (init is destructive), then run: ```bash -npm run init -- --name="Acme Blog" --version=1.0.0 --yes --remove-examples=cron,rest --features=hmr,tailwind +npm run init -- --name="Acme Blog" --version=1.0.0 --yes --remove-examples=cron,rest --features=hmr,tailwind,dev-tools # Manage: npm run init -- --list -npm run init -- --enable=tailwind --yes +npm run init -- --enable=dev-tools --yes npm run init -- --features=hmr --yes # exact enabled set (empty = none) ``` - `--keep-examples` keeps all; `--remove-examples` (no value) removes all; `--remove-examples=a,b` removes listed keys. @@ -62,6 +62,7 @@ npm run init -- --features=hmr --yes # exact enabled set (empty = none) ### 4. After init - Tailwind enabled → developer runs `npm install` (added `src/css/tailwind.css`, `postcss.config.js`, pinned `@rtcamp/tailwind-config`). +- DevTools enabled → still a private package (`rtcamp/wp-devtools`, VCS-sourced): developer runs `composer update rtcamp/wp-devtools` (may need GitHub auth), then `npm run dev:connect`. - Suggest `composer dump-autoload`. - If you hand-edited PHP under `inc/`, run `composer format` → `composer lint` → `composer phpstan` and resolve every finding. Never silence a real issue; if unclear, STOP and ask. - Refresh the LOCAL knowledge graph with `graphify update .`. Never commit `graphify-out/graph.json`, and never run the cross-repo `/graphify . --update` or `merge-graphs`. If not installed, say so in one line; do not block. See `AGENTS.md` (graphify). @@ -77,9 +78,9 @@ ONE keep-or-remove prompt; removing deletes the capability entirely (classes, `i | APIs & Automation | `rest`, `cli`, `cron` | | Admin | `settings`, `admin`, `roles` | | Utilities | `cache`, `transients` | -| Developer Tooling | `test-measure` (CI workflow), `hmr` (feature) | +| Developer Tooling | `test-measure` (CI workflow), `hmr` (feature), `dev-tools` (feature) | -Sets kept by default; pass keys to `--remove-examples` to drop. Features: `hmr` on, `tailwind` off. Everything installs from public registries. +Sets kept by default; pass keys to `--remove-examples` to drop. Features: `hmr` on, `tailwind` and `dev-tools` off. `dev-tools` is the one feature still sourced from a private VCS repo (see step 4); everything else installs from public registries. ## Hard rules - Package managers only with consent: `npm install`, `composer install` (step 2), and `npm run init`. Otherwise surface install commands as developer actions. diff --git a/AGENTS.md b/AGENTS.md index 18eb384..b09eafd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,7 @@ To add a feature: write the test, create the concrete class extending the right - PHP: `composer lint` (PHPCS) · `composer format` (phpcbf) · `composer phpstan`; the PHPUnit suite runs under `wp-env` via `npm run test:php`. - JS/build: `npm run build:dev` · `npm run build:prod` · `npm run lint` · `npm run test`. - Setup / features: `npm run init` (setup + manage wizard) · `npx wp-tooling add /` (add a feature). +- DevTools (opt-in): `npm run init -- --enable=dev-tools` then `npm run dev:connect` exposes runtime telemetry over MCP — [demo](docs/dev-tools-demo.md) · `bash bin/dev-tools-e2e.sh` checks the setup. Human documentation: [README.md](README.md) → [docs/index.md](docs/index.md) (developer guides), [DEVELOPMENT.md](DEVELOPMENT.md) (extending by hand), [CONTRIBUTING.md](CONTRIBUTING.md) and [docs/internal/](docs/internal/README.md) (skeleton maintainers). Framework API detail is linked, not copied, from the `rtcamp/wp-primitives` docs at the locked tag. diff --git a/README.md b/README.md index 7989926..0b754bc 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Then follow [Getting Started](docs/getting-started.md): prerequisites, getting t - **Infrastructure** that stays in every project: bootstrap and loader, asset registration, theme-overridable templates and components, a shared logger, an encryption service, activation and deactivation handling, and PHPUnit, PHPCS, PHPStan, ESLint and Stylelint setups. - **Twelve example sets**, one per feature domain (post types, taxonomies, blocks, REST, WP-CLI, cron, settings pages, admin pages, user roles, shortcodes, cache, transients). Each is a working, tested reference you keep or remove at setup. -- **Optional development features**: HMR live reload (on by default), Tailwind CSS, and Dev Tools runtime telemetry over MCP. +- **Optional development features**: HMR live reload (on by default), Tailwind CSS, and DevTools runtime telemetry over MCP. See [Included features](docs/features.md) for where each one lives and how to see it working. diff --git a/bin/dev-tools-e2e.sh b/bin/dev-tools-e2e.sh new file mode 100755 index 0000000..fc9e7bb --- /dev/null +++ b/bin/dev-tools-e2e.sh @@ -0,0 +1,324 @@ +#!/usr/bin/env bash +# +# dev-tools-e2e.sh - prove the whole wp-devtools loop against this project's +# wp-env, from package boot through to a real MCP tool call. +# +# Exit codes: +# 0 every check passed +# 1 one or more checks failed +# 2 a prerequisite is missing (dev-tools not enabled or not installed in +# this project, Docker down, wp-env not started) +# +# Run it after any wp-devtools change, and after `composer update +# rtcamp/wp-devtools -W`, to catch a regression before it reaches a demo. +# +# This script only reads. It curls the site and makes read-only WP-CLI and MCP +# calls inside the container. It never installs anything, never writes a file +# and never runs git -- a missing prerequisite is printed as the command for +# you to run, not run for you. +# +set -uo pipefail + +# ---- configuration ----------------------------------------------------------- + +SITE_URL="http://localhost:8888" +SKIP_CWV=0 + +# The allow-list, mirrored from Mcp\ServerRegistrar::ABILITIES. MCP sanitises +# the ability id by replacing "/" with "-", so these are the tool ids a client +# sees, not the ability ids. +EXPECTED_TOOLS="wp-devtools-audit-rest-permissions +wp-devtools-compare-requests +wp-devtools-get-runtime-health +wp-devtools-get-telemetry +wp-devtools-get-web-vitals +wp-devtools-list-requests +wp-devtools-profile-url" + +EXPECTED_COUNT=7 + +# ---- output helpers ---------------------------------------------------------- + +log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } +ok() { printf '\033[1;32m ok:\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; } +err() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; } + +have() { command -v "$1" >/dev/null 2>&1; } + +# `printf ... | grep -q` is a trap here: grep exits on the first match, the +# producer takes SIGPIPE, and `pipefail` then reports the whole pipeline as +# failed even though the match succeeded. A herestring keeps it one command. +contains() { grep -q "$1" <<<"$2"; } + +FAILURES=0 +fail() { err "$*"; FAILURES=$(( FAILURES + 1 )); } + +usage() { + awk '/^# dev-tools-e2e/,/^set -uo/ { if ($0 !~ /^set -uo/) { sub(/^#[ ]?/, ""); print } }' "$0" + cat <<'USAGE' +Usage: bash bin/dev-tools-e2e.sh [options] + + --url=URL Site URL to probe (default http://localhost:8888) + --skip-cwv Skip the Core Web Vitals beacon check + -h, --help Show this help +USAGE +} + +for arg in "$@"; do + case "$arg" in + --url=*) SITE_URL="${arg#*=}" ;; + --skip-cwv) SKIP_CWV=1 ;; + -h|--help) usage; exit 0 ;; + *) err "Unknown option: $arg"; usage; exit 2 ;; + esac +done + +SITE_URL="${SITE_URL%/}" + +# ---- container helpers ------------------------------------------------------- + +# wp-env prints its own progress chatter on stderr; only stdout is the payload. +wp_cli() { npx wp-env run cli -- wp "$@" 2>/dev/null; } + +# One STDIO MCP session: initialize, announce, then whatever is piped in. +# $1 is the server id; the remaining JSON-RPC lines come from stdin. +mcp_session() { + local server="$1" + { + printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"dev-tools-e2e","version":"1"}}}' + printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}' + cat + } | npx wp-env run cli -- wp mcp-adapter serve --server="$server" --user=admin 2>/dev/null +} + +# ---- prerequisites ----------------------------------------------------------- + +log "wp-devtools end-to-end check against ${SITE_URL}" + +# wp-env resolves the environment from the working directory, so run from the +# project root wherever the script was called from. +cd "$(dirname "$0")/.." || exit 2 + +have npx || { err "npx not found; install Node 22 (nvm use)."; exit 2; } + +if ! grep -q '"rtcamp/wp-devtools"' composer.json 2>/dev/null || [ ! -f .wp-env.override.json ]; then + err "DevTools is not enabled in this project. Enable it:" + err " npm run init -- --enable=dev-tools" + exit 2 +fi + +if [ ! -d vendor/rtcamp/wp-devtools ]; then + err "rtcamp/wp-devtools is not installed. Install it:" + err " composer update rtcamp/wp-devtools -W" + exit 2 +fi + +if ! have docker || ! docker info >/dev/null 2>&1; then + err "Docker is not running. Start Docker Desktop, then: npm run wp-env start" + exit 2 +fi + +if ! curl -fsS -o /dev/null --max-time 10 "$SITE_URL/"; then + err "$SITE_URL is not responding. Start the environment:" + err " npm run wp-env start" + exit 2 +fi + +# ---- 1. the package booted, and the gate is open ----------------------------- + +log "1. Package booted" + +BOOTED="$(wp_cli eval 'echo defined( "RT_DEV_TOOLS_BOOTED" ) ? "yes" : "no";')" +case "$BOOTED" in + *yes*) ok "RT_DEV_TOOLS_BOOTED is defined" ;; + *) fail "RT_DEV_TOOLS_BOOTED is not defined. The package boots from the plugin's autoloader, so check the plugin is active:" + warn " npx wp-env run cli -- wp plugin list" + warn " npx wp-env run cli -- wp plugin activate \$(basename \"\$PWD\")" ;; +esac + +# BOOTED only proves the autoload ran inside WordPress. This is the check that +# proves the two hard gates (RT_DEV_TOOLS_DEV_MODE + environment type) passed. +ENABLED="$(wp_cli eval 'echo \rtCamp\WPDevTools\Support\Config::is_enabled() ? "yes" : "no";')" +case "$ENABLED" in + *yes*) ok "the dev gate is open (Config::is_enabled)" ;; + *) fail "the dev gate is closed -- check RT_DEV_TOOLS_DEV_MODE and WP_ENVIRONMENT_TYPE in .wp-env.override.json, then: npm run wp-env start" ;; +esac + +# ---- 2. abilities registered ------------------------------------------------- + +log "2. Abilities registered" + +# The single quotes are deliberate: this is PHP for `wp eval`, and $k/$a must +# reach the container unexpanded. +# shellcheck disable=SC2016 +ABILITIES="$(wp_cli eval 'foreach ( wp_get_abilities() as $k => $a ) { $n = is_object( $a ) && method_exists( $a, "get_name" ) ? $a->get_name() : (string) $k; if ( 0 === strpos( $n, "wp-devtools/" ) ) { echo $n, "\n"; } }' | grep '^wp-devtools/' | sort)" +ABILITY_COUNT="$(printf '%s\n' "$ABILITIES" | grep -c '^wp-devtools/')" + +if [ "$ABILITY_COUNT" -eq "$EXPECTED_COUNT" ]; then + ok "$ABILITY_COUNT abilities registered under wp-devtools/" +else + fail "expected $EXPECTED_COUNT abilities under wp-devtools/, found $ABILITY_COUNT" + printf '%s\n' "$ABILITIES" | sed 's/^/ /' +fi + +# ---- 3. the dedicated MCP route responds ------------------------------------- + +log "3. Dedicated MCP route" + +ROUTE_CODE="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 -X POST \ + -H 'Content-Type: application/json' \ + --data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \ + "$SITE_URL/wp-json/wp-devtools/mcp")" + +# Only a refusal is a pass: the route exists and is turning away an +# unauthenticated call. Anything else -- including curl's 000 for a timeout or +# a dropped connection -- is a failure. +case "$ROUTE_CODE" in + 401|403) ok "/wp-json/wp-devtools/mcp responds (HTTP $ROUTE_CODE)" ;; + 404) fail "/wp-json/wp-devtools/mcp returned 404 -- the MCP Adapter plugin is inactive, or the gate is closed" ;; + 000) fail "/wp-json/wp-devtools/mcp did not answer within 10s (connection failed or timed out)" ;; + 2*) fail "/wp-json/wp-devtools/mcp accepted an unauthenticated call (HTTP $ROUTE_CODE) -- it must refuse one" ;; + *) fail "/wp-json/wp-devtools/mcp returned HTTP $ROUTE_CODE, expected 401 or 403" ;; +esac + +# ---- 4. STDIO tools/list is exactly the allow-list ---------------------------- + +log "4. STDIO tools/list" + +TOOLS_RAW="$(printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | mcp_session wp-devtools)" +PROTOCOL="$(grep -o '"protocolVersion":"[^"]*"' <<<"$TOOLS_RAW" | head -1 | cut -d'"' -f4)" +TOOLS="$(grep -o '"wp-devtools-[a-z-]*"' <<<"$TOOLS_RAW" | tr -d '"' | sort -u)" +TOOL_COUNT="$(printf '%s\n' "$TOOLS" | grep -c '^wp-devtools-')" + +if [ -n "$PROTOCOL" ]; then + ok "negotiated MCP protocol version $PROTOCOL" +else + fail "no protocolVersion in the initialize response -- the STDIO session did not start" +fi + +if [ "$TOOL_COUNT" -eq "$EXPECTED_COUNT" ] && [ "$TOOLS" = "$EXPECTED_TOOLS" ]; then + ok "tools/list is exactly the $EXPECTED_COUNT-tool allow-list" +else + fail "tools/list does not match the allow-list (found $TOOL_COUNT)" + diff <(printf '%s\n' "$EXPECTED_TOOLS") <(printf '%s\n' "$TOOLS") | sed 's/^/ /' +fi + +# ---- 5. the capture loop ----------------------------------------------------- + +log "5. Capture loop" + +# Capture is on shutdown, and WP-CLI requests are never captured, so the loop +# needs two real HTTP requests to compare. Each is tagged so it can be picked +# out of the list by URL; the pause keeps their second-resolution captured_at +# values apart. +curl -s -o /dev/null --max-time 10 "$SITE_URL/?dev-tools-e2e=before" +sleep 1 +curl -s -o /dev/null --max-time 10 "$SITE_URL/?dev-tools-e2e=after" + +LIST="$(printf '%s\n' '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"wp-devtools-list-requests","arguments":{"limit":10}}}' | mcp_session wp-devtools)" + +# Decode the rows rather than scraping UUIDs out of the raw JSON, whose text +# order says nothing about which capture came first. Prints " " for +# the newest capture of each tagged request, newest first by captured_at. +PICKED="$(node -e ' + let raw = ""; + process.stdin.on("data", (c) => (raw += c)).on("end", () => { + for (const line of raw.split("\n")) { + let msg; + try { msg = JSON.parse(line); } catch { continue; } + if (msg.id !== 3 || !msg.result) continue; + let data = msg.result.structuredContent; + if (!data) { + try { data = JSON.parse(msg.result.content[0].text); } catch { data = {}; } + } + const rows = (data.requests || []) + .filter((r) => r && r.id && r.captured_at) + .sort((a, b) => Date.parse(b.captured_at) - Date.parse(a.captured_at)); + for (const tag of ["after", "before"]) { + const row = rows.find((r) => String(r.url).includes("dev-tools-e2e=" + tag)); + if (row) console.log(tag + " " + row.id); + } + } + }); +' <<<"$LIST")" + +AFTER="$(awk '$1 == "after" { print $2; exit }' <<<"$PICKED")" +BEFORE="$(awk '$1 == "before" { print $2; exit }' <<<"$PICKED")" + +if [ -n "$BEFORE" ] && [ -n "$AFTER" ]; then + ok "list-requests returned both tagged captures" +else + fail "list-requests is missing the tagged captures (before: ${BEFORE:-none}, after: ${AFTER:-none}) -- is Query Monitor active? (curl once and retry)" +fi + +if [ -n "$AFTER" ]; then + TELEMETRY="$(printf '%s\n' "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"wp-devtools-get-telemetry\",\"arguments\":{\"request_id\":\"$AFTER\"}}}" | mcp_session wp-devtools)" + if contains '"db_queries"' "$TELEMETRY" && contains '"metrics"' "$TELEMETRY"; then + ok "get-telemetry returned the normalised sections" + else + fail "get-telemetry did not return db_queries + metrics for $AFTER" + fi + + # host_file is null for core-owned frames by design; at least one frame from + # this project's own code must translate, or the root pair is wrong. + if contains '"host_file":"/' "$TELEMETRY"; then + ok "host file:line translation is working" + else + warn "no translated host_file in this capture -- fine if nothing in your own code ran a query, wrong CONTAINER_ROOT/HOST_ROOT otherwise" + fi +fi + +if [ -n "$BEFORE" ] && [ -n "$AFTER" ]; then + COMPARE="$(printf '%s\n' "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\",\"params\":{\"name\":\"wp-devtools-compare-requests\",\"arguments\":{\"before_request_id\":\"$BEFORE\",\"after_request_id\":\"$AFTER\"}}}" | mcp_session wp-devtools)" + if contains '"deltas"' "$COMPARE"; then + ok "compare-requests returned deltas" + else + fail "compare-requests did not return deltas for $BEFORE -> $AFTER" + fi +fi + +# ---- 6. the CWV beacon is enqueued on the front end -------------------------- + +if [ "$SKIP_CWV" -eq 1 ]; then + log "6. CWV beacon (skipped)" +else + log "6. CWV beacon" + HTML="$(curl -s "$SITE_URL/")" + if contains 'rt-dev-tools-cwv-beacon' "$HTML" && contains 'rtDevToolsCwv' "$HTML"; then + ok "the beacon script and its config are enqueued on the front end" + else + fail "the CWV beacon is not on the front end -- it is skipped in wp-admin, so check you probed a front-end URL" + fi +fi + +# ---- 7. the default adapter server exposes none of ours ---------------------- + +log "7. Default adapter server isolation" + +DEFAULT_TOOLS="$(printf '%s\n' '{"jsonrpc":"2.0","id":6,"method":"tools/list"}' | mcp_session mcp-adapter-default-server)" +LEAKED="$(grep -o '"wp-devtools-[a-z-]*"' <<<"$DEFAULT_TOOLS" | sort -u | tr -d '"')" + +if [ -z "$LEAKED" ]; then + ok "the default server exposes no wp-devtools-* tools" +else + fail "the default server is leaking tools -- abilities must not be flagged meta.mcp.public:" + printf '%s\n' "$LEAKED" | sed 's/^/ /' +fi + +if contains 'discover-abilities' "$DEFAULT_TOOLS"; then + ok "the default server is up (discover-abilities present), so the check is meaningful" +else + warn "the default server listed no tools at all -- the isolation check above proves less than it looks" +fi + +# ---- summary ----------------------------------------------------------------- + +echo +if [ "$FAILURES" -eq 0 ]; then + log "All checks passed." + exit 0 +fi + +log "$FAILURES check(s) failed." +exit 1 diff --git a/bin/features/dev-tools.js b/bin/features/dev-tools.js new file mode 100644 index 0000000..3252390 --- /dev/null +++ b/bin/features/dev-tools.js @@ -0,0 +1,289 @@ +/** + * WP DevTools feature, declared for the @rtcamp/wp-tooling init engine. + * + * Writes committed manifest wiring (composer.json, package.json scripts) plus + * local dev-environment state (.wp-env.override.json, gitignored). The + * committed .wp-env.json is never touched, so enabling this never forces Query + * Monitor and the MCP Adapter onto teammates. + */ + +/** + * External dependencies + */ +const path = require( 'path' ); + +const PACKAGE = 'rtcamp/wp-devtools'; + +const CONSTRAINT = 'dev-release/v1.0.0'; +const REPOSITORY = { + type: 'vcs', + url: 'https://github.com/rtCamp/wp-devtools.git', + 'no-api': true, +}; + +// wp-env plugins, not Composer deps: Query Monitor collects, the MCP Adapter +// exposes. The adapter is pinned; Query Monitor tracks its latest release. +const PLUGINS = [ + 'https://downloads.wordpress.org/plugin/query-monitor.zip', + 'https://github.com/WordPress/mcp-adapter/releases/download/v0.5.0/mcp-adapter.zip', +]; + +const COMPOSER_FILE = 'composer.json'; +const GITIGNORE_FILE = '.gitignore'; +const WP_ENV_FILE = '.wp-env.json'; +const WP_ENV_OVERRIDE = '.wp-env.override.json'; + +const DEV_MODE_KEY = 'RT_DEV_TOOLS_DEV_MODE'; + +// `--user=admin`: every ability is capability-checked and fails anonymously. +const CONNECT_SCRIPT = + 'claude mcp add wp-devtools -- npx wp-env run cli -- wp mcp-adapter serve --server=wp-devtools --user=admin'; +const DISCONNECT_SCRIPT = 'claude mcp remove wp-devtools'; + +// `{}` for a missing file, null for malformed -- callers decide which matters. +const readJson = ( api, rel ) => { + try { + return JSON.parse( api.read( rel ) || '{}' ); + } catch { + return null; + } +}; + +// So disable leaves no empty `"require-dev": {}` behind. +const deleteIfEmpty = ( parent, key ) => { + if ( parent[ key ] && ! Object.keys( parent[ key ] ).length ) { + delete parent[ key ]; + } +}; + +// wp-env mounts "." as the host folder name, not the text domain. +const containerRoot = ( api ) => { + const bucket = 'theme' === api.identity.kind ? 'themes' : 'plugins'; + return `/var/www/html/wp-content/${ bucket }/${ path.basename( + api.root + ) }`; +}; + +const isSingleEnv = ( api ) => + false === ( readJson( api, WP_ENV_FILE ) || {} ).testsEnvironment; + +// The block wp-env applies to the development site. With the tests environment +// disabled, wp-env ignores `env` and reads the root. +const devBlock = ( api, json ) => { + if ( isSingleEnv( api ) ) { + return json; + } + json.env = json.env || {}; + json.env.development = json.env.development || {}; + return json.env.development; +}; + +// wp-env replaces the plugins array rather than merging it, so the override has +// to re-emit whatever .wp-env.json already lists. +const basePlugins = ( api ) => { + const wpEnv = readJson( api, WP_ENV_FILE ) || {}; + const development = + ( ! isSingleEnv( api ) && wpEnv.env && wpEnv.env.development ) || {}; + if ( Array.isArray( development.plugins ) ) { + return development.plugins; + } + if ( Array.isArray( wpEnv.plugins ) ) { + return wpEnv.plugins; + } + return 'theme' === api.identity.kind ? [] : [ '.' ]; +}; + +// Override entries that are neither committed nor ours: a developer's own. +const developerPlugins = ( api, development ) => { + const base = basePlugins( api ); + const listed = Array.isArray( development.plugins ) + ? development.plugins + : []; + return listed.filter( + ( url ) => ! base.includes( url ) && ! PLUGINS.includes( url ) + ); +}; + +// SAVEQUERIES gives Query Monitor the backtraces behind file:line, and the +// container/host root pair maps those paths back to this checkout. +const overrideConfig = ( api ) => ( { + WP_ENVIRONMENT_TYPE: 'local', + SAVEQUERIES: true, + [ DEV_MODE_KEY ]: true, + RT_DEV_TOOLS_TELEMETRY_CONTAINER_ROOT: containerRoot( api ), + RT_DEV_TOOLS_TELEMETRY_HOST_ROOT: api.root, + RT_DEV_TOOLS_TELEMETRY_LOOPBACK_BASE: 'http://wordpress', +} ); + +// The override holds absolute host paths, so keep it ignored even if a fork +// trimmed its .gitignore. +const ensureGitignored = ( api ) => { + const current = api.read( GITIGNORE_FILE ); + if ( null === current ) { + api.write( GITIGNORE_FILE, `/${ WP_ENV_OVERRIDE }\n` ); + return; + } + const listed = current + .split( '\n' ) + .some( + ( line ) => line.trim().replace( /^\//, '' ) === WP_ENV_OVERRIDE + ); + if ( listed ) { + return; + } + const prefix = current.endsWith( '\n' ) ? current : `${ current }\n`; + api.write( GITIGNORE_FILE, `${ prefix }/${ WP_ENV_OVERRIDE }\n` ); +}; + +const addComposerEntry = ( api ) => { + api.editJson( COMPOSER_FILE, ( json ) => { + const repositories = json.repositories || []; + const hasRepository = repositories.some( + ( entry ) => entry.url === REPOSITORY.url + ); + if ( ! hasRepository ) { + repositories.push( { ...REPOSITORY } ); + } + json.repositories = repositories; + json[ 'require-dev' ] = { + ...json[ 'require-dev' ], + [ PACKAGE ]: CONSTRAINT, + }; + } ); +}; + +const removeComposerEntry = ( api ) => { + api.editJson( COMPOSER_FILE, ( json ) => { + if ( json[ 'require-dev' ] ) { + delete json[ 'require-dev' ][ PACKAGE ]; + deleteIfEmpty( json, 'require-dev' ); + } + if ( Array.isArray( json.repositories ) ) { + json.repositories = json.repositories.filter( + ( entry ) => entry.url !== REPOSITORY.url + ); + deleteIfEmpty( json, 'repositories' ); + } + } ); +}; + +const writeOverride = ( api ) => { + api.editJson( + WP_ENV_OVERRIDE, + ( json ) => { + const development = devBlock( api, json ); + // Rebuilt from the current committed list, so a .wp-env.json that + // gained a plugin is picked up rather than masked. + development.plugins = [ + ...new Set( [ + ...basePlugins( api ), + ...developerPlugins( api, development ), + ...PLUGINS, + ] ), + ]; + development.config = { + ...development.config, + ...overrideConfig( api ), + }; + }, + { create: true, indentFrom: WP_ENV_FILE } + ); +}; + +// A plugins list left here would replace the committed one, so drop it unless +// the developer added entries of their own (kept beside the base list). +const prunePlugins = ( api, development ) => { + const kept = developerPlugins( api, development ); + if ( ! kept.length ) { + delete development.plugins; + return; + } + development.plugins = [ ...new Set( [ ...basePlugins( api ), ...kept ] ) ]; +}; + +// Remove our entries, then the file if nothing else is left in it. +const pruneOverride = ( api ) => { + if ( ! api.exists( WP_ENV_OVERRIDE ) ) { + return; + } + + api.editJson( WP_ENV_OVERRIDE, ( json ) => { + const development = devBlock( api, json ); + if ( development.config ) { + Object.keys( overrideConfig( api ) ).forEach( + ( key ) => delete development.config[ key ] + ); + deleteIfEmpty( development, 'config' ); + } + if ( Array.isArray( development.plugins ) ) { + prunePlugins( api, development ); + } + if ( json.env ) { + deleteIfEmpty( json.env, 'development' ); + deleteIfEmpty( json, 'env' ); + } + } ); + + const remaining = readJson( api, WP_ENV_OVERRIDE ) || {}; + if ( ! Object.keys( remaining ).length ) { + api.remove( WP_ENV_OVERRIDE ); + } +}; + +const onEnable = ( api ) => { + addComposerEntry( api ); + writeOverride( api ); + ensureGitignored( api ); + + // `update`, not `install`: the lock has no entry for a just-added package. + api.note( + `wp-devtools: run \`composer update ${ PACKAGE } -W\`, then \`npm run wp-env start && npm run dev:connect\`.` + ); + api.note( + `wp-devtools: ${ WP_ENV_OVERRIDE } carries this machine's paths and is gitignored -- teammates run \`npm run init -- --enable=dev-tools\` to wire their own.` + ); +}; + +const onDisable = ( api ) => { + removeComposerEntry( api ); + api.note( + `wp-devtools: run \`composer update ${ PACKAGE } -W\` to drop it from composer.lock and vendor/.` + ); + api.note( + `wp-devtools: if Claude Code is still connected, run \`${ DISCONNECT_SCRIPT }\`.` + ); + pruneOverride( api ); +}; + +// Needs both halves: a fresh clone has the composer entry but not the +// gitignored override, and nothing works there. Must not throw: `init --list` +// runs it too. +const detect = ( api ) => { + const composer = readJson( api, COMPOSER_FILE ); + const override = readJson( api, WP_ENV_OVERRIDE ); + if ( ! composer || ! override ) { + return false; + } + const development = devBlock( api, override ); + return ( + Boolean( ( composer[ 'require-dev' ] || {} )[ PACKAGE ] ) && + true === ( development.config || {} )[ DEV_MODE_KEY ] + ); +}; + +module.exports = { + key: 'dev-tools', + label: 'WP DevTools (runtime telemetry over MCP)', + category: 'Developer Tooling', + description: + 'Opt-in. Adds rtcamp/wp-devtools as a dev dependency and the dev:connect script, and writes the local wp-env override (Query Monitor + the MCP Adapter, the dev-mode gate, and the host path map). The committed .wp-env.json is left untouched.', + apply: { + scripts: { + 'dev:connect': CONNECT_SCRIPT, + 'dev:disconnect': DISCONNECT_SCRIPT, + }, + }, + onEnable, + onDisable, + detect, +}; diff --git a/bin/scaffold.config.js b/bin/scaffold.config.js index 846bb3f..2c0e7de 100644 --- a/bin/scaffold.config.js +++ b/bin/scaffold.config.js @@ -19,6 +19,11 @@ * `features` instead, so the engine can add/remove those deps on toggle. */ +/** + * Internal dependencies + */ +const devTools = require( './features/dev-tools' ); + const mainFile = ( api ) => `${ api.identity.package.split( '/' )[ 1 ] }.php`; const tailwindConst = ( api ) => `${ api.identity.constantPrefix }_FEATURES_ENABLE_TAILWIND`; @@ -102,7 +107,7 @@ module.exports = { { path: 'package.json', kind: 'json', key: 'version' }, ], - steps: { composer: true, cleanup: false, git: true, hooks: true }, + steps: { composer: true, cleanup: true, git: true, hooks: true }, // Dep/build-config-carrying capabilities. Toggling adds/removes their deps. featuresDir: 'bin/features', @@ -159,6 +164,7 @@ module.exports = { ); }, }, + devTools, ], // Keep/remove capabilities. Removing deletes the module + classes + its @@ -228,6 +234,20 @@ module.exports = { ], }, + // Skeleton-only files a generated plugin should not inherit. `languages` goes + // because its .pot still lists strings from removed example sets; `npm run + // pot` recreates it. + cleanup: { + targets: [ + 'tests/js/scaffold-config.test.js', + '.github/workflows/documentation.yml', + '.github/CODEOWNERS', + '.claude/skills/scaffold/evals', + '.claude/skills/setup/evals', + 'languages', + ], + }, + docsUrl: 'https://github.com/rtCamp/plugin-elementary/blob/main/docs/index.md', repoUrl: 'https://github.com/rtCamp/plugin-elementary', diff --git a/docs/dev-tools-demo.md b/docs/dev-tools-demo.md new file mode 100644 index 0000000..2e726b5 --- /dev/null +++ b/docs/dev-tools-demo.md @@ -0,0 +1,205 @@ +# WP DevTools demo — seven beats + +A copy-paste tour of [wp-devtools](https://github.com/rtCamp/wp-devtools) on this project: seven prompts that walk the whole loop, from "what can you even see?" to an agent diagnosing a slow page from live runtime data and pointing at the line of code responsible. It takes about ten minutes on a fresh environment. + +The point of the package is that it gives a coding agent things it *cannot* read from source: which queries actually ran, how long they actually took, what the browser actually measured. Every beat below is a prompt you type into Claude Code, not a command you run — the shell lines are only there to generate traffic. + +> Beats 1–5 and 7 need nothing but the setup below. **Beat 6 needs a real browser visit** — `curl` cannot produce Core Web Vitals. Read that beat before you start if you want the browser visit to line up with the rest. + +## Setup + +```bash +# 1. Enable the feature. Writes composer.json, the dev:connect scripts, and the +# gitignored .wp-env.override.json carrying this machine's paths. +npm run init -- --enable=dev-tools + +# 2. Resolve the package. `update`, not `install`: the lock has no entry yet. +composer update rtcamp/wp-devtools -W + +# 3. Boot. wp-env installs Query Monitor and the MCP Adapter from the override. +npm run wp-env start + +# 4. Register the MCP server with Claude Code. Local scope; nothing is committed. +npm run dev:connect +``` + +`/mcp` inside Claude Code should now show `wp-devtools` connected with **7 tools**. `npm run dev:disconnect` undoes step 4. + +Confirm the plugin itself is active — the package boots from its Composer autoloader, so an inactive plugin means nothing registers: + +```bash +npx wp-env run cli -- wp plugin activate "$(basename "$PWD")" +``` + +Three of the settings step 1 writes are worth knowing, because each one has a distinctive failure: + +| Setting | What it buys you | If it is wrong | +|---|---|---| +| `SAVEQUERIES` | Query Monitor's query backtraces | telemetry has no `file`/`line` at all | +| `CONTAINER_ROOT` / `HOST_ROOT` | the prefix map turning `/var/www/html/…` into a path your editor can open | every `host_file` comes back `null` | +| `LOOPBACK_BASE` | an in-container base URL, since `localhost:8888` is unreachable from inside | `profile-url` fails to capture | + +### Give it something to find + +The demo is more convincing with a real problem in it, so add one. Paste this at the bottom of your main plugin file — it is deliberately throwaway, and deleting it *is* beat 5: + +```php +// --- TEMPORARY: wp-devtools demo. Delete this block in beat 5. --- +add_action( + 'wp', + static function (): void { + if ( is_admin() ) { + return; + } + global $wpdb; + for ( $i = 0; $i < 25; $i++ ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $wpdb->get_var( 'SELECT SLEEP(0.01)' ); + } + } +); +``` + +It lives in your own code on purpose. Telemetry only translates container paths that sit under `CONTAINER_ROOT` — your plugin directory — so a slow query raised from *your* file is one the agent can point you straight at. The same block dropped into `wp-content/mu-plugins/` would report `host_file: null` and the best part of the demo would be lost. The `phpcs:ignore` line keeps `npm run lint:php` clean while the block is in place, so nothing else in your workflow trips over it. + +## Beat 1 — What can you see? + +``` +What wp-devtools tools do you have available? List them with one line each on what data they surface. +``` + +Expect all seven: `list-requests`, `get-telemetry`, `compare-requests`, `profile-url`, `get-web-vitals`, `get-runtime-health`, `audit-rest-permissions`. If the list is empty, `/mcp` will show whether the server is actually connected. + +## Beat 2 — What just happened? + +Generate some traffic first — capture runs on `shutdown`, and WP-CLI requests are never captured: + +```bash +curl -s -o /dev/null http://localhost:8888/ +``` + +``` +Use list-requests to show the last few captured requests with their headline metrics. +``` + +You get one row per request, with `id`, `url`, `type`, `status`, `captured_at` and the headline numbers. The slow block is already visible in the aggregate — 50 queries instead of 25, and two orders of magnitude more time in the database: + +```text +captured_at type status total queries query time url +2026-08-18T12:12:03+00:00 frontend 200 539.00ms 50 365.23ms http://localhost:8888/ +2026-08-18T12:09:59+00:00 frontend 200 64.50ms 25 3.14ms http://localhost:8888/ +``` + +## Beat 3 — Why was it slow? + +``` +Take the most recent capture and show me its slowest database query — the SQL, how long it took, and the host file:line I can open. +``` + +This is the beat that justifies the whole package. The answer names a real file on your machine: + +```text +16.19ms SELECT SLEEP(0.01) + caller Acme_Analytics\Features\{closure}() + host_file /path/to/acme-analytics/acme-analytics-features.php:89 +``` + +In that capture, 25 of the 50 query frames carried a translated `host_file` and 25 came back `null`. **That is correct, not a bug** — the null ones are core-owned frames outside `CONTAINER_ROOT`, and the translator returns `null` rather than inventing a path. + +`get-telemetry` returns far more than queries: `db_queries`, `http`, `php_errors`, `assets`, `doing_it_wrong`, `cache`, `hooks` and a `metrics` summary. Worth a follow-up prompt: + +``` +Anything else in that capture worth worrying about? Check the cache hit rate, the doing_it_wrong notices and the PHP errors. +``` + +## Beat 4 — Is it actually consistent? + +One capture could be a cold cache or a noisy neighbour. `profile-url` issues its own loopback requests and reports medians, so it works even with zero prior traffic: + +``` +Profile http://localhost:8888/ with 5 samples and give me the median timings. +``` + +```text +batch 8ff46e21-d503-4648-8629-f4c5d72b892e +samples 5 × HTTP 200 +medians total 470.60ms · queries 50 · query time 355.48ms +``` + +Five for five, so it is the code, not the weather. + +## Beat 5 — Prove the fix + +Now delete the temporary block you added during setup, and capture the same URL again: + +```bash +curl -s -o /dev/null http://localhost:8888/ +``` + +``` +Compare the capture from before I removed that block with the one after, and tell me exactly what improved. +``` + +```text +total_time_ms 467.5 -> 104.1 -363.4 -77.7% +query_count 50 -> 25 -25 -50% +query_time_ms 312.74 -> 4.57 -308.17 -98.5% +peak_memory_mb 7.8 -> 7.7 -0.1 -1.3% +php_error_count 0 -> 0 0 null +http_call_count 0 -> 0 0 null +``` + +That is the loop closing: a change was made, and the runtime data proves what it did. (`delta_pct` is `null` where the before value was zero — there is no percentage change from nothing.) + +## Beat 6 — What did the browser see? + +Server timings say nothing about what a user experiences. Field Core Web Vitals come from a real browser, and only from a real browser: + +1. Open `http://localhost:8888/` in Chrome. +2. Leave the tab **focused for about seven seconds** — the beacon's idle flush fires at six. +3. Switch to another tab or close it. That triggers the authoritative flush that finalises CLS and INP. + +Click something on the page while you are there, or INP will have nothing to report. + +``` +Show the Core Web Vitals for the homepage, and correlate them with the server telemetry for the same request. +``` + +You get the field metrics with their attribution (what element was the LCP, what shifted, what the input delay was), a p75 aggregate across recent samples, and a correlation block tying them back to the server-side capture — duplicate queries, slow queries, error counts. + +Skip any of the three steps and the tool tells you so rather than inventing numbers: + +```text +No Core Web Vitals captured for http://localhost:8888/ yet. Open it in a real +browser (Chrome); field metrics are beaconed when the tab is hidden or after a +short idle. Then retry. +``` + +If you want the vitals and the server telemetry to describe the same regression, do this browser visit **before** you delete the block in beat 5. + +## Beat 7 — The finale + +Everything so far named a tool. This one does not: + +``` +The homepage feels slow. Find out why from runtime data, and point me at the code. +``` + +A connected agent should work it out on its own: list the recent captures, pull the telemetry for the slowest, sort the queries, and come back with the file and line. It may reach for `profile-url` to confirm the pattern repeats, or `get-runtime-health` to rule out overdue cron and heavy autoloaded options. Put the temporary block back first if you want it to have something to find. + +This is the whole pitch in one prompt: no tool names, no dashboard, no guessing from source — the agent reads what actually happened. + +## Hygiene notes + +- **`list-requests` came back empty.** Nothing has been requested yet. Capture runs on `shutdown`, and WP-CLI requests are never captured — `curl` the site once and retry. +- **Every ability errors.** The dev gate is shut. Check `RT_DEV_TOOLS_DEV_MODE` and `WP_ENVIRONMENT_TYPE` in `.wp-env.override.json`, then `npm run wp-env start` — constants only reach `wp-config.php` on a restart. +- **Every `host_file` is `null`.** The `CONTAINER_ROOT`/`HOST_ROOT` pair is wrong. Re-run `npm run init -- --enable=dev-tools`; it derives both from the current checkout. +- **Some `host_file`s are `null`.** Expected. Only paths under `CONTAINER_ROOT` translate. +- **Old captures vanish.** By design. The store is a ring buffer, pruned on write and hourly, against both a count cap (200 captures) and a TTL (7 days). +- **`curl` produced no Core Web Vitals.** It never will, and neither will `profile-url`. Beat 6 needs a browser. +- **Nothing is exposed in production.** The package is a `require-dev` dependency, and it registers nothing unless `RT_DEV_TOOLS_DEV_MODE` is truthy *and* the environment type is `local`. + +## Related + +- [Initialization](initialization.md#optional-dependencies) — enabling and disabling the feature. +- [wp-devtools package guide](https://github.com/rtCamp/wp-devtools/blob/release/v1.0.0/README.md) — configuration and the full tool reference (repository access required). diff --git a/docs/features.md b/docs/features.md index aa220ff..5b8994f 100644 --- a/docs/features.md +++ b/docs/features.md @@ -62,3 +62,4 @@ Unlike example sets, these can be turned on and off at any time with `npm run in | --- | --- | --- | --- | --- | | `hmr` | `webpack.config.js`, `webpack.blocks.config.js`, `inc/Core/Assets.php`, `.env.local` | On | BrowserSync live reload for assets and PHP; Fast Refresh for block editor components. Toggling only flips `ENABLE_HMR`; dependencies stay installed. | [Live reload](hmr.md) | | `tailwind` | `bin/features/tailwind/`, `inc/Core/Assets.php`, main plugin file | Off | Adds `src/css/tailwind.css` and `postcss.config.js`, declares Tailwind dependencies, and sets `ACME_CONTENT_FEATURES_ENABLE_TAILWIND` to `true` so the stylesheet is enqueued. | [Tailwind](tailwind.md) | +| `dev-tools` | `bin/features/dev-tools.js` | Off | Adds `rtcamp/wp-devtools` (private) as a dev dependency, `dev:connect` / `dev:disconnect` scripts, and a gitignored `.wp-env.override.json` with Query Monitor and the MCP Adapter. A coding agent can then read live request telemetry. | [DevTools demo](dev-tools-demo.md) | diff --git a/docs/getting-started.md b/docs/getting-started.md index 1646b5e..b6f159a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -59,7 +59,7 @@ git status --short npm run init ``` -Use **Acme Content** as the plugin name. For this first walkthrough, keep every example set and the default features: HMR on, Tailwind and Dev Tools off. [Initialization](initialization.md) explains each prompt. +Use **Acme Content** as the plugin name. For this first walkthrough, keep every example set and the default features: HMR on, Tailwind and DevTools off. [Initialization](initialization.md) explains each prompt. Alternatively, open the clone in Claude Code and ask: diff --git a/docs/index.md b/docs/index.md index e147ba1..1fa8f4d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,6 +12,7 @@ The skeleton is a consumer of shared rtCamp libraries. It owns the plugin's stru | [`rtcamp/wp-primitives`](https://github.com/rtCamp/wp-primitives/blob/v2.0.0/docs/index.md) | `Registrable` + `Loader` + `Container`, the `Abstract*` base classes, asset/component/template loaders, `Cache`, `Transients`, `Encryptor`, `Logger` | Framework docs (linked from each guide) | | [`@rtcamp/wp-tooling`](https://github.com/rtCamp/wp-tooling/blob/main/node-packages/wp-tooling/README.md) | `npm run init` engine, `npx wp-tooling add` scaffolds, shared ESLint/Stylelint configs, release scripts | wp-tooling docs | | [`rtCamp/wp-shared-workflows`](https://github.com/rtCamp/wp-shared-workflows) | Reusable lint, test and build workflows | wp-shared-workflows docs | +| [wp-devtools](https://github.com/rtCamp/wp-devtools/blob/release/v1.0.0/README.md) (optional) | Runtime telemetry over MCP | wp-devtools docs (repository access required) | ## Two ways to work @@ -34,7 +35,7 @@ Both are valid: pick the CLI for full control over every input, or the AI skill ## Further reading -- [Live reload and block HMR](hmr.md) and [Tailwind](tailwind.md) cover the optional development features. +- [Live reload and block HMR](hmr.md), [Tailwind](tailwind.md), and the [DevTools demo](dev-tools-demo.md) cover the optional development features. - [Contributing](../CONTRIBUTING.md) and the [maintainer guidance](internal/README.md) are for developers working on the skeleton itself. ## Command reference diff --git a/docs/initialization.md b/docs/initialization.md index c9a29b0..25d2c18 100644 --- a/docs/initialization.md +++ b/docs/initialization.md @@ -39,10 +39,10 @@ Init rewrites and deletes files in place. A clean checkout is your only undo. `.wp-scaffold.json` stores these values. The generated code adds `-features`: plugin header `Acme Content Features`, text domain `acme-content-features`, prefixes `acme_content_features_` and `ACME_CONTENT_FEATURES_`, and main file `acme-content-features.php`. 3. **Select capabilities.** One grouped prompt lists the twelve example sets, the CI workflow and the three optional features. Space toggles, Enter confirms. Unchecking an example set removes it entirely. -4. **Apply.** Init rewrites the tokens, removes deselected sets, writes `.wp-scaffold.json`, and regenerates the Composer autoloader. The wrapper then runs `npm run sync-ai`. +4. **Apply.** Init rewrites the tokens, removes deselected sets, writes `.wp-scaffold.json`, regenerates the Composer autoloader, and runs cleanup. The wrapper then runs `npm run sync-ai`. 5. **Git and hooks.** A first run can start a new Git repository. This defaults to **No**; accepting deletes the existing `.git` and the skeleton's history, so decline it when keeping that history or working inside another repository. Non-interactive `--yes` skips optional Git setup. -Defaults: every example set kept, HMR on, Tailwind and Dev Tools off. +Defaults: every example set kept, HMR on, Tailwind and DevTools off. For a scripted first run: @@ -60,7 +60,7 @@ npm run init -- --name="Acme Content" --version=1.0.0 --yes \ | APIs & Automation | `rest`, `cli`, `cron` | | | Admin | `settings`, `admin`, `roles` | | | Utilities | `cache`, `transients` | | -| Developer Tooling | `test-measure` (the CI caller workflow) | `hmr` | +| Developer Tooling | `test-measure` (the CI caller workflow) | `hmr`, `dev-tools` | What each set contains is listed in [Included features](features.md). @@ -72,9 +72,9 @@ What each set contains is listed in [Included features](features.md). | Version | Written to the main file's header and `package.json`. | | Removed example sets | The module file (`inc/Modules/.php`), its folder, its test (`tests/php/Test.php`), its `Main::CLASSES` line and any coupled region (for example the static block list in `inc/Core/Assets.php` for `blocks`, the unschedule call in `inc/Core/PluginSetup.php` for `cron`). | | Kept example sets | Stay as working references. Their `// wp:example:` markers are removed. | -| Optional features | `hmr` edits `.env.local`; `tailwind` adds `src/css/tailwind.css`, `postcss.config.js`, dependencies and flips the Tailwind constant. | +| Optional features | `hmr` edits `.env.local`; `tailwind` adds `src/css/tailwind.css`, `postcss.config.js`, dependencies and flips the Tailwind constant; `dev-tools` adds Composer and npm script wiring plus a gitignored `.wp-env.override.json`. | | State | `.wp-scaffold.json` records identity and feature choices. Do not edit it by hand. | -| Cleanup | Nothing is removed. Copilot files, workflows and templates under `.github/` stay. | +| Cleanup | Removes files that only serve the skeleton: `tests/js/scaffold-config.test.js`, the docs-publishing workflow `.github/workflows/documentation.yml`, `.github/CODEOWNERS`, the skill evals under `.claude/skills/`, and `languages/` (run `npm run pot` to generate a fresh template). Copilot files, the other workflows and the templates under `.github/` stay. | | AI instructions | `sync-ai` refreshes `.github/instructions/framework-php.instructions.md` from the installed framework. | Init does not generate a translation template. Run `npm run pot` when preparing translations. @@ -109,6 +109,7 @@ npm run init -- --help # options for your installed engine - **HMR:** toggling only flips `ENABLE_HMR` in `.env.local`, a per-developer file. See [Live reload](hmr.md). - **Tailwind:** enabling changes declarations and adds files; run `npm install` afterwards. See [Tailwind](tailwind.md). +- **DevTools:** needs access to the private [wp-devtools](https://github.com/rtCamp/wp-devtools) repository. Enable it, then follow init's printed steps: `composer update rtcamp/wp-devtools -W`, `npm run wp-env start`, `npm run dev:connect`. The override file holds this machine's paths, so each developer enables it locally. See the [DevTools demo](dev-tools-demo.md) and the [package guide](https://github.com/rtCamp/wp-devtools/blob/release/v1.0.0/README.md). ## Troubleshooting diff --git a/docs/internal/maintenance.md b/docs/internal/maintenance.md index afa5ae1..f378217 100644 --- a/docs/internal/maintenance.md +++ b/docs/internal/maintenance.md @@ -26,7 +26,8 @@ The skeleton is a consumer of three shared repositories. Fix a problem where it - **`source`**: the placeholder identity (`Project Name`, `Project_Name\Features`, `rtcamp/plugin-elementary`) the engine search-replaces. The engine never rewrites files under `bin/`, so placeholders there are safe. - **`versionFiles`**: where the version is written (main-file header, `package.json`). - **`examples.groups`**: one entry per example set, built with `capability( key, label, category, { module, strip, remove, tests } )`, plus one `workflow()` entry for the CI caller. -- **`features`**: the toggleable features (`tailwind` and `hmr`). +- **`features`**: the toggleable features (`tailwind`, `hmr`, and `dev-tools` from `bin/features/dev-tools.js`). +- **`cleanup.targets`**: skeleton-only paths deleted after setup: the init config test, the docs-publishing workflow, `CODEOWNERS`, the skill evals and `languages/`. Anything still linked from a kept file (`CONTRIBUTING.md`, `docs/internal/`, `graphify-out/`) stays until the engine can replace those pages. ### Markers @@ -45,6 +46,7 @@ Rules that keep init working for every downstream project: - Keep marker pairs balanced and on their own lines. A broken pair breaks removal for everyone. - A capability's footprint must be complete: module file, class folder, test file, and every coupled region. Anything left behind references deleted classes. - Markers are consumed by the first run, so removal is one-shot by design. +- [`tests/js/scaffold-config.test.js`](../../tests/js/scaffold-config.test.js) asserts the config's shape; run `npm run test:js` after editing it. ### Add an example set @@ -66,7 +68,7 @@ Record `git rev-parse HEAD`, Node and PHP versions, the framework revision in `c 1. Follow [Getting Started](../getting-started.md) in a fresh clone with a fresh dependency install from the declared sources. Do not substitute a local engine. 2. Run interactive init and a separate non-interactive setup. Check identity, kept sets, `.wp-scaffold.json`, and the Git decision. 3. Exercise each example-set removal in a fresh clone. Check deleted files and the remaining registration, then run `composer dump-autoload` and `php -l` on touched files before loading WordPress. The engine's own output (renamed, removed, toggled) is the primary signal; do not diff `inc/` by hand. -4. Enable and disable each optional feature. Install the changed declarations before checking Tailwind output. +4. Enable and disable each optional feature. Install the changed declarations before checking Tailwind output; run `bash bin/dev-tools-e2e.sh` for `dev-tools`. 5. Start WordPress, activate the plugin, build, and confirm a source edit on the frontend and in the editor. 6. Follow the [scaffolding example](../scaffolding.md) and the [manual examples](../../DEVELOPMENT.md). Verify paths, registration, behaviour and focused tests. 7. Run the [checks](#checks) and build the documentation (see [Documentation publishing](#documentation-publishing)). @@ -162,5 +164,4 @@ Keep implementation follow-ups out of documentation pull requests; record each w - **Dangling references:** `PluginSetup::deactivate()` mentions a root `uninstall.php` and `Util::get_data()` reads `inc/data/`; neither ships. - **Stale wording:** `.npmrc` still calls `wp-tooling` a private repository, and `bin/init.js` suggests the pilot `npm install --install-links`. - **Lockfile URLs:** `package-lock.json` resolves `@rtcamp/*` over `git+ssh://`. Confirm a clean `npm ci` works for a developer without a GitHub SSH key. -- **Downstream docs workflow:** `documentation.yml` ships into client projects. It is inert there (its jobs only run in `rtCamp/plugin-elementary`), but decide whether init cleanup should remove it. - **Multisite tests:** `composer test-multisite` has no `wp-env` wrapper. diff --git a/tests/js/scaffold-config.test.js b/tests/js/scaffold-config.test.js new file mode 100644 index 0000000..34fd928 --- /dev/null +++ b/tests/js/scaffold-config.test.js @@ -0,0 +1,431 @@ +/** + * dev-tools feature hooks from bin/scaffold.config.js. + * + * Driven through the real FeatureApi rather than a stand-in, so the hooks are + * exercised against the same editJson/read/write semantics the init engine + * uses. The declarative `apply` half (scripts) is the engine's job and is + * covered by its own tests; this file covers the decisions the config makes. + */ + +const fs = require( 'fs' ); +const os = require( 'os' ); +const path = require( 'path' ); + +const { makeFeatureApi } = require( '@rtcamp/wp-tooling/init' ); +const config = require( '../../bin/scaffold.config.js' ); + +const OVERRIDE = '.wp-env.override.json'; +const WP_ENV = '.wp-env.json'; +const QM = 'https://downloads.wordpress.org/plugin/query-monitor.zip'; +const ADAPTER = + 'https://github.com/WordPress/mcp-adapter/releases/download/v0.5.0/mcp-adapter.zip'; + +const feature = config.features.find( ( f ) => 'dev-tools' === f.key ); + +const write = ( root, rel, body ) => + fs.writeFileSync( path.join( root, rel ), body ); + +const readJson = ( root, rel ) => + JSON.parse( fs.readFileSync( path.join( root, rel ), 'utf8' ) ); + +const exists = ( root, rel ) => fs.existsSync( path.join( root, rel ) ); + +/** + * Project fixture with a committed wp-env config listing `plugins`. + * + * @param {Array} basePlugins - env.development.plugins in .wp-env.json. + * @param {string} kind - Consumer kind ('plugin' or 'theme'). + * @return {Object} { root, api }. + */ +const setup = ( basePlugins, kind = 'plugin' ) => { + const root = fs.mkdtempSync( path.join( os.tmpdir(), 'dev-tools-' ) ); + const base = basePlugins + ? { env: { development: { plugins: basePlugins } } } + : { core: null }; + write( root, WP_ENV, `${ JSON.stringify( base, null, '\t' ) }\n` ); + write( root, 'composer.json', '{\n\t"name": "acme/demo"\n}\n' ); + write( root, 'package.json', '{\n\t"name": "demo"\n}\n' ); + write( root, '.gitignore', `/${ OVERRIDE }\n` ); + + const api = makeFeatureApi( + root, + { kind, slug: 'demo' }, + { info: () => {}, warn: () => {} } + ); + return { root, api }; +}; + +/** + * Rewrite the committed plugin list, simulating a teammate adding one. + * + * @param {string} root - Project root. + * @param {Array} plugins - New env.development.plugins. + * @return {void} + */ +const setBasePlugins = ( root, plugins ) => { + const json = readJson( root, WP_ENV ); + json.env.development.plugins = plugins; + write( root, WP_ENV, `${ JSON.stringify( json, null, '\t' ) }\n` ); +}; + +describe( 'dev-tools feature', () => { + let root; + let api; + + afterEach( () => { + if ( root ) { + fs.rmSync( root, { recursive: true, force: true } ); + root = null; + } + } ); + + describe( 'enable', () => { + beforeEach( () => { + ( { root, api } = setup( [ '.' ] ) ); + } ); + + it( 'adds the composer dependency and its VCS repository', () => { + feature.onEnable( api ); + + const composer = readJson( root, 'composer.json' ); + expect( composer[ 'require-dev' ] ).toHaveProperty( + 'rtcamp/wp-devtools' + ); + expect( composer.repositories ).toEqual( [ + expect.objectContaining( { + type: 'vcs', + url: 'https://github.com/rtCamp/wp-devtools.git', + } ), + ] ); + } ); + + it( 'leaves the committed .wp-env.json untouched', () => { + const before = fs.readFileSync( path.join( root, WP_ENV ), 'utf8' ); + + feature.onEnable( api ); + + expect( fs.readFileSync( path.join( root, WP_ENV ), 'utf8' ) ).toBe( + before + ); + } ); + + it( 'writes the override with the base list plus the dev plugins', () => { + feature.onEnable( api ); + + const development = readJson( root, OVERRIDE ).env.development; + expect( development.plugins ).toEqual( [ '.', QM, ADAPTER ] ); + expect( development.config ).toEqual( { + WP_ENVIRONMENT_TYPE: 'local', + SAVEQUERIES: true, + RT_DEV_TOOLS_DEV_MODE: true, + RT_DEV_TOOLS_TELEMETRY_CONTAINER_ROOT: `/var/www/html/wp-content/plugins/${ path.basename( + root + ) }`, + RT_DEV_TOOLS_TELEMETRY_HOST_ROOT: root, + RT_DEV_TOOLS_TELEMETRY_LOOPBACK_BASE: 'http://wordpress', + } ); + } ); + + it( 'never writes the tests environment', () => { + feature.onEnable( api ); + + expect( readJson( root, OVERRIDE ).env.tests ).toBeUndefined(); + } ); + + it( 'reads a top-level plugins list from the committed config', () => { + write( + root, + WP_ENV, + `${ JSON.stringify( + { + core: null, + plugins: [ '.', 'team-plugin.zip' ], + testsEnvironment: false, + }, + null, + '\t' + ) }\n` + ); + + feature.onEnable( api ); + + expect( readJson( root, OVERRIDE ).plugins ).toEqual( [ + '.', + 'team-plugin.zip', + QM, + ADAPTER, + ] ); + } ); + + it( 'is idempotent -- no duplicate repository or plugin entries', () => { + feature.onEnable( api ); + const composer = fs.readFileSync( + path.join( root, 'composer.json' ), + 'utf8' + ); + + feature.onEnable( api ); + + expect( + fs.readFileSync( path.join( root, 'composer.json' ), 'utf8' ) + ).toBe( composer ); + expect( + readJson( root, OVERRIDE ).env.development.plugins + ).toHaveLength( 3 ); + } ); + + it( 'defers next steps rather than printing mid-spinner', () => { + feature.onEnable( api ); + + expect( api._notes.length ).toBeGreaterThan( 0 ); + expect( api._notes[ 0 ] ).toContain( 'composer update' ); + } ); + + it( 'refreshes from the committed list on re-enable', () => { + feature.onEnable( api ); + setBasePlugins( root, [ '.', 'team-plugin.zip' ] ); + + feature.onEnable( api ); + + expect( + readJson( root, OVERRIDE ).env.development.plugins + ).toEqual( [ '.', 'team-plugin.zip', QM, ADAPTER ] ); + } ); + } ); + + describe( 'disable', () => { + beforeEach( () => { + ( { root, api } = setup( [ '.' ] ) ); + } ); + + it( 'leaves no trace of itself', () => { + feature.onEnable( api ); + + feature.onDisable( api ); + + const composer = readJson( root, 'composer.json' ); + expect( composer[ 'require-dev' ] ).toBeUndefined(); + expect( composer.repositories ).toBeUndefined(); + expect( exists( root, OVERRIDE ) ).toBe( false ); + expect( feature.detect( api ) ).toBe( false ); + } ); + + // The empty-container case above only happens in a bare fixture; a real + // project has its own dev deps and VCS entries, which must survive. + it( "keeps the project's other dev deps and repositories", () => { + api.editJson( 'composer.json', ( json ) => { + json[ 'require-dev' ] = { 'phpunit/phpunit': '^9.6' }; + json.repositories = [ + { + type: 'vcs', + url: 'https://github.com/rtCamp/wp-primitives.git', + }, + ]; + } ); + feature.onEnable( api ); + + feature.onDisable( api ); + + const composer = readJson( root, 'composer.json' ); + expect( composer[ 'require-dev' ] ).toEqual( { + 'phpunit/phpunit': '^9.6', + } ); + expect( composer.repositories ).toEqual( [ + { + type: 'vcs', + url: 'https://github.com/rtCamp/wp-primitives.git', + }, + ] ); + } ); + + it( 'says how to drop the package from the lock and vendor', () => { + feature.onEnable( api ); + api._notes.length = 0; + + feature.onDisable( api ); + + expect( api._notes.join( '\n' ) ).toContain( 'composer update' ); + } ); + + // Disabling also removes the dev:disconnect script, but not the MCP + // server already registered with Claude Code. + it( 'says how to disconnect the MCP server', () => { + feature.onEnable( api ); + api._notes.length = 0; + + feature.onDisable( api ); + + expect( api._notes.join( '\n' ) ).toContain( + 'claude mcp remove wp-devtools' + ); + } ); + + // Regression: an exact base/remainder comparison kept `plugins: ["."]` + // pinned in the override once .wp-env.json had moved on. wp-env replaces + // that array, so the dev env silently lost the newly added plugin. + it( 'drops the plugin pin when the committed list has moved on', () => { + feature.onEnable( api ); + setBasePlugins( root, [ '.', 'team-plugin.zip' ] ); + + feature.onDisable( api ); + + expect( exists( root, OVERRIDE ) ).toBe( false ); + } ); + + it( "keeps a developer's own override plugin, refreshed against the base", () => { + feature.onEnable( api ); + api.editJson( OVERRIDE, ( json ) => { + json.env.development.plugins.push( 'my-own.zip' ); + } ); + setBasePlugins( root, [ '.', 'team-plugin.zip' ] ); + + feature.onDisable( api ); + + expect( readJson( root, OVERRIDE ).env.development ).toEqual( { + plugins: [ '.', 'team-plugin.zip', 'my-own.zip' ], + } ); + } ); + + it( "keeps a developer's own override config key", () => { + feature.onEnable( api ); + api.editJson( OVERRIDE, ( json ) => { + json.env.development.config.MY_OWN_KEY = 'keep me'; + } ); + + feature.onDisable( api ); + + expect( readJson( root, OVERRIDE ).env.development.config ).toEqual( + { MY_OWN_KEY: 'keep me' } + ); + } ); + + it( 'is a no-op when the override was never written', () => { + expect( () => feature.onDisable( api ) ).not.toThrow(); + expect( exists( root, OVERRIDE ) ).toBe( false ); + } ); + } ); + + describe( 'themes', () => { + it( 'omits "." and targets the themes bucket', () => { + ( { root, api } = setup( null, 'theme' ) ); + + feature.onEnable( api ); + + const development = readJson( root, OVERRIDE ).env.development; + expect( development.plugins ).toEqual( [ QM, ADAPTER ] ); + expect( + development.config.RT_DEV_TOOLS_TELEMETRY_CONTAINER_ROOT + ).toBe( + `/var/www/html/wp-content/themes/${ path.basename( root ) }` + ); + } ); + } ); + + // With the tests environment disabled, wp-env ignores the `env` key and + // reads every option from the root, so the override must live there. + describe( 'single-environment config', () => { + beforeEach( () => { + ( { root, api } = setup( null ) ); + write( + root, + WP_ENV, + `${ JSON.stringify( + { core: null, plugins: [ '.' ], testsEnvironment: false }, + null, + '\t' + ) }\n` + ); + } ); + + it( 'writes plugins and config at the root of the override', () => { + feature.onEnable( api ); + + const override = readJson( root, OVERRIDE ); + expect( override.env ).toBeUndefined(); + expect( override.plugins ).toEqual( [ '.', QM, ADAPTER ] ); + expect( override.config ).toMatchObject( { + SAVEQUERIES: true, + RT_DEV_TOOLS_DEV_MODE: true, + RT_DEV_TOOLS_TELEMETRY_HOST_ROOT: root, + } ); + } ); + + it( 'is detected from the root override', () => { + feature.onEnable( api ); + + expect( feature.detect( api ) ).toBe( true ); + } ); + + it( 'leaves no trace of itself on disable', () => { + feature.onEnable( api ); + feature.onDisable( api ); + + expect( exists( root, OVERRIDE ) ).toBe( false ); + expect( feature.detect( api ) ).toBe( false ); + } ); + + it( "keeps a developer's own root override key on disable", () => { + write( + root, + OVERRIDE, + `${ JSON.stringify( { port: 8890 }, null, '\t' ) }\n` + ); + + feature.onEnable( api ); + feature.onDisable( api ); + + expect( readJson( root, OVERRIDE ) ).toEqual( { port: 8890 } ); + } ); + } ); + + describe( 'detect', () => { + beforeEach( () => { + ( { root, api } = setup( [ '.' ] ) ); + } ); + + it( 'is true only when both halves are present', () => { + expect( feature.detect( api ) ).toBe( false ); + + feature.onEnable( api ); + + expect( feature.detect( api ) ).toBe( true ); + } ); + + // A teammate's fresh clone has the committed dependency but not the + // gitignored override, and nothing actually works there. + it( 'is false with the composer entry alone', () => { + feature.onEnable( api ); + fs.rmSync( path.join( root, OVERRIDE ) ); + + expect( feature.detect( api ) ).toBe( false ); + } ); + + it( 'is false with the override alone', () => { + feature.onEnable( api ); + api.editJson( 'composer.json', ( json ) => { + delete json[ 'require-dev' ][ 'rtcamp/wp-devtools' ]; + } ); + + expect( feature.detect( api ) ).toBe( false ); + } ); + + // detect also runs read-only under `init --list`, before any rename. + it( 'is total against an empty project and malformed JSON', () => { + const bare = fs.mkdtempSync( path.join( os.tmpdir(), 'bare-' ) ); + try { + const bareApi = makeFeatureApi( + bare, + { kind: 'plugin', slug: 'demo' }, + {} + ); + expect( feature.detect( bareApi ) ).toBe( false ); + + write( bare, 'composer.json', '{ not json' ); + expect( () => feature.detect( bareApi ) ).not.toThrow(); + expect( feature.detect( bareApi ) ).toBe( false ); + } finally { + fs.rmSync( bare, { recursive: true, force: true } ); + } + } ); + } ); +} );