From bd6a5abdb65bffb4db81434c3d63fc94bea9d090 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Wed, 15 Jul 2026 02:50:45 +0530 Subject: [PATCH 01/13] feat(perf): add wp-tooling perf runner (web-vitals + lighthouse + server xhprof) Mirrors the a11y runner: two-layer normalized report (lab Core Web Vitals + Lighthouse, plus optional server-side xhprof hotspots over WP-CLI). Ships src/perf/*, the perf CLI command, package.json exports, tests, the setup/perf scaffold with a hardened server-profile.php shim, and CHANGELOG/issue-tracking entries. Includes two pre-existing lint-gate fixes (no-shadow + prettier in src/init/index.js and tests/ui/selects.test.js) needed for npm run check to pass, matching the fix already on the a11y branch. --- .claude/issues/wp-devtools-22-perf-runner.md | 93 ++++ node-packages/wp-tooling/CHANGELOG.md | 2 + node-packages/wp-tooling/package.json | 1 + .../scaffolds/setup/perf/scaffold.json | 56 +++ .../perf/templates/.perfrc.json.mustache | 28 ++ .../setup/perf/templates/server-profile.php | 108 +++++ .../wp-tooling/src/cli/commands/perf.js | 17 + .../wp-tooling/src/perf/collect-vitals.js | 145 ++++++ node-packages/wp-tooling/src/perf/config.js | 150 ++++++ node-packages/wp-tooling/src/perf/errors.js | 29 ++ node-packages/wp-tooling/src/perf/index.js | 15 + .../wp-tooling/src/perf/lighthouse.js | 88 ++++ .../wp-tooling/src/perf/normalize.js | 324 +++++++++++++ .../wp-tooling/src/perf/resolve-bin.js | 105 ++++ .../wp-tooling/src/perf/resolve-module.js | 137 ++++++ node-packages/wp-tooling/src/perf/run.js | 456 ++++++++++++++++++ .../wp-tooling/src/perf/server-profile.js | 117 +++++ .../wp-tooling/tests/perf/cli.test.js | 299 ++++++++++++ .../tests/perf/collect-vitals.test.js | 151 ++++++ .../wp-tooling/tests/perf/config.test.js | 141 ++++++ .../tests/perf/fixtures/.perfrc.json | 12 + .../tests/perf/fixtures/.perfrc.no-urls.json | 3 + .../tests/perf/fixtures/lighthouse-lhr.json | 61 +++ .../tests/perf/fixtures/malformed.perfrc.json | 2 + .../tests/perf/fixtures/partial.perfrc.json | 4 + .../fixtures/web-vitals.attribution.iife.js | 6 + .../tests/perf/fixtures/xhprof.json | 5 + .../wp-tooling/tests/perf/lighthouse.test.js | 91 ++++ .../wp-tooling/tests/perf/normalize.test.js | 370 ++++++++++++++ .../tests/perf/resolve-module.test.js | 192 ++++++++ .../tests/perf/server-profile.test.js | 126 +++++ .../tests/scaffolds/bundled-manifests.test.js | 92 ++++ 32 files changed, 3426 insertions(+) create mode 100644 .claude/issues/wp-devtools-22-perf-runner.md create mode 100644 node-packages/wp-tooling/scaffolds/setup/perf/scaffold.json create mode 100644 node-packages/wp-tooling/scaffolds/setup/perf/templates/.perfrc.json.mustache create mode 100644 node-packages/wp-tooling/scaffolds/setup/perf/templates/server-profile.php create mode 100644 node-packages/wp-tooling/src/cli/commands/perf.js create mode 100644 node-packages/wp-tooling/src/perf/collect-vitals.js create mode 100644 node-packages/wp-tooling/src/perf/config.js create mode 100644 node-packages/wp-tooling/src/perf/errors.js create mode 100644 node-packages/wp-tooling/src/perf/index.js create mode 100644 node-packages/wp-tooling/src/perf/lighthouse.js create mode 100644 node-packages/wp-tooling/src/perf/normalize.js create mode 100644 node-packages/wp-tooling/src/perf/resolve-bin.js create mode 100644 node-packages/wp-tooling/src/perf/resolve-module.js create mode 100644 node-packages/wp-tooling/src/perf/run.js create mode 100644 node-packages/wp-tooling/src/perf/server-profile.js create mode 100644 node-packages/wp-tooling/tests/perf/cli.test.js create mode 100644 node-packages/wp-tooling/tests/perf/collect-vitals.test.js create mode 100644 node-packages/wp-tooling/tests/perf/config.test.js create mode 100644 node-packages/wp-tooling/tests/perf/fixtures/.perfrc.json create mode 100644 node-packages/wp-tooling/tests/perf/fixtures/.perfrc.no-urls.json create mode 100644 node-packages/wp-tooling/tests/perf/fixtures/lighthouse-lhr.json create mode 100644 node-packages/wp-tooling/tests/perf/fixtures/malformed.perfrc.json create mode 100644 node-packages/wp-tooling/tests/perf/fixtures/partial.perfrc.json create mode 100644 node-packages/wp-tooling/tests/perf/fixtures/web-vitals.attribution.iife.js create mode 100644 node-packages/wp-tooling/tests/perf/fixtures/xhprof.json create mode 100644 node-packages/wp-tooling/tests/perf/lighthouse.test.js create mode 100644 node-packages/wp-tooling/tests/perf/normalize.test.js create mode 100644 node-packages/wp-tooling/tests/perf/resolve-module.test.js create mode 100644 node-packages/wp-tooling/tests/perf/server-profile.test.js diff --git a/.claude/issues/wp-devtools-22-perf-runner.md b/.claude/issues/wp-devtools-22-perf-runner.md new file mode 100644 index 0000000..962b0f4 --- /dev/null +++ b/.claude/issues/wp-devtools-22-perf-runner.md @@ -0,0 +1,93 @@ +# Issue wp-devtools#22 — Add `wp-tooling perf` command + runner (two-layer: web-vitals/Lighthouse + server xhprof) + +**Status:** in-progress +**Branch:** `v1.0.0/task/perf-runner` +**PR:** # +**Assignee:** @Adi-ty + +--- + +## Summary + +rtCamp/wp-devtools#22 asks for a `wp-tooling perf` command mirroring the shipped `a11y` runner: one normalised JSON report an agent (or CI) can act on. Per the (cross-repo) planning docs' two-layer model, Layer 1 is the *symptom* — lab Core Web Vitals via the `web-vitals` attribution build under headless Chromium, plus Lighthouse — and Layer 2 is the *cause* — server-side function hotspots via xhprof, run over WP-CLI through a consumer-installed `server-profile.php` shim. This task ships both layers, the `setup/perf` scaffold that installs the consumer-side wiring, and verifies end-to-end on two consumers with real profiler data. + +--- + +## Decisions made + +- [2026-07-14] The server-side profiler moved mid-task from `rtCamp\WPFramework\Utils\XHProf_Profiler` (wp-framework) to `rtCamp\WPDevTools\Support\XHProfProfiler` (wp-dev-tools) — wp-framework#51 was closed unmerged because profiling is dev tooling, not an architectural component; the reviewed class was ported to wp-dev-tools (PR #66) as a plain class outside the `RT_DEV_TOOLS_DEV_MODE` gate, installed by consumers as `composer require --dev rtcamp/wp-dev-tools`. This task consumes that class; it does not reimplement xhprof. +- [2026-07-14] `server-profile.php`'s hardening (canonical-redirect hazard, `$_GET` query-string copy, output-buffer draining, STDERR route diagnostic, no `declare(strict_types)`) was designed and live-tested against `features-plugin-skeleton` before being adapted into the scaffold template — `redirect_canonical()` ends the request with `exit()`, which bypasses `finally`, so a canonical redirect would otherwise kill the process before the profiler's `stop()` runs or the JSON is echoed; the shim removes that hook, profiles with `start()`/`stop()` instead of `profile()`, and installs a `register_shutdown_function` fallback that drains open output buffers before emitting JSON. +- [2026-07-14] Config file is `.perfrc.json` (analogous to `.pa11yci.json`), but unlike `a11y` it is OPTIONAL when `--url` is supplied — the perf issue explicitly adds a repeatable `--url` flag, and a project should be able to run a one-off perf check with zero setup. `--url` replaces the config's `urls[]` entirely; every other section (webVitals/lighthouse/server/thresholds) still comes from the file when one exists. A malformed config is always `EBADJSON`, even with `--url`. +- [2026-07-14] Every per-URL layer failure downgrades independently rather than aborting the run: a page-load failure becomes a `scanError` (contributes to `summary.failedUrls`, final exit 1 — same semantics as `a11y`'s unreachable-URL handling); a Lighthouse or server-profile runtime failure degrades that URL's layer to `null`/empty + a note, with **no effect on the exit code** — the server layer especially is auxiliary cause-data, so a broken WP-CLI invocation must never take down the frontend result. +- [2026-07-14] `server-profile.js` (the Node-side WP-CLI invoker) never throws — every failure mode (spawn error, non-zero exit, unparseable stdout) returns `{ data: null, error: }` instead, because a thrown error would need per-call try/catch discipline at every call site to preserve the degrade-not-abort policy; returning a tagged result makes that policy the only path. +- [2026-07-14] INP is hardcoded to `null` in the normalised report regardless of what the frontend collector harvests — the collector never performs a user interaction, so a raw INP reading would be a fluke, not a measurement. Surfaced as an explicit `assessment` line rather than silently omitted. +- [2026-07-14] `puppeteer` resolution for `run.js` goes through a new `requireModule()` helper in `resolve-module.js` (walk `node_modules`, then `require()` the resolved directory) rather than an inline dynamic `require()` in `run.js` itself — this keeps `run.js` mockable via `jest.mock('./resolve-module')` with zero real puppeteer install needed in CI, mirroring how `collect-vitals.js` takes the browser as a parameter instead of resolving it. +- [2026-07-14] Housed in wp-tooling (`src/perf/`), distributed via a new `setup/perf` scaffold, matching the `a11y`/`setup/pa11y` precedent — issue #22 defers the scaffold to a later task, but end-to-end verification against a real consumer needs the consumer-side wiring (deps, config, shim) to exist, so it ships in this PR rather than a follow-up. +- [2026-07-14] `npm run check` on the branch point (`release/v1.0.0`) had two pre-existing failures unrelated to this task (`no-shadow` on `cap` + prettier wrapping in `src/init/index.js` and `tests/ui/selects.test.js`) — the same issue already fixed on the (still-open) a11y branch but never merged. Fixed here with the identical minimal rename (`cap` → `entry`) so `npm run check` is verifiable; documented rather than silently folded in. +- [2026-07-14] Live end-to-end testing surfaced a real bug: puppeteer 25.x's `executablePath()` returns a `Promise`, not a string (older versions returned it synchronously). `run.js` now `await`s it — safe either way, since `await` on a non-Promise value just returns it. Caught only because verification used a real, current puppeteer install rather than a mocked one. + +--- + +## Files changed so far + +- `src/perf/errors.js` — new (`RunnerError`: `EBINMISSING` / `EBINFAIL` / `EBADJSON` / `ENOURLS`) +- `src/perf/resolve-bin.js` — new (consumer binary resolution for `lighthouse`, mirrors `src/a11y/resolve-bin.js`) +- `src/perf/resolve-module.js` — new (consumer module resolution for `puppeteer`/`web-vitals`; `requireModule` loader for testability) +- `src/perf/config.js` — new (`.perfrc.json` resolution, section-merge over defaults, `--url` precedence) +- `src/perf/collect-vitals.js` — new (headless web-vitals attribution collection; puppeteer/browser passed in as parameters) +- `src/perf/lighthouse.js` — new (per-URL Lighthouse invocation, `CHROME_PATH` pin) +- `src/perf/server-profile.js` — new (per-URL WP-CLI shim invocation; never throws, always degrades) +- `src/perf/normalize.js` — new (pure two-layer normaliser: ratings, worst-metric pick, issue counting, Lighthouse/server extraction) +- `src/perf/run.js` — new (`runPerf()` core + `runCli()` adapter; exit codes 0/1/2/3) +- `src/perf/index.js` — new (barrel exposed as `@rtcamp/wp-tooling/perf`) +- `src/cli/commands/perf.js` — new (dispatcher shim) +- `package.json` — edited (`"./perf"` exports entry) +- `tests/perf/*` — new (cli, config, collect-vitals, lighthouse, server-profile, normalize, resolve-module specs + fixtures) +- `scaffolds/setup/perf/scaffold.json` — new +- `scaffolds/setup/perf/templates/.perfrc.json.mustache` — new +- `scaffolds/setup/perf/templates/server-profile.php` — new (raw copy, no Mustache rendering) +- `tests/scaffolds/bundled-manifests.test.js` — edited (three `setup/perf rendered config` cases: default render, custom inputs + server enabled, raw-copy byte-equality) +- `CHANGELOG.md` — edited (two Unreleased entries) +- `src/init/index.js`, `tests/ui/selects.test.js` — edited (pre-existing lint-gate errors at `release/v1.0.0` HEAD: `no-shadow` on `cap`, prettier wrapping; `npm run check` fails without these fixes) + +--- + +## Verification run + +```bash +$ npm run check # eslint src tests && jest +# ESLint: clean +# Tests: 761 passed, 57 suites +``` + +Tested live end-to-end on two independent WordPress installs running under wp-env (Alpine `cli` containers, xhprof pecl-installed fresh into each), each with `@rtcamp/wp-tooling` installed as a dev dependency and `rtcamp/wp-dev-tools` wired in via composer for the server layer: + +- `--dry-run` resolved the local `puppeteer` (25.3.0), the `web-vitals` attribution build, `lighthouse` (13.4.0), and the WP-CLI server command on both installs, with nothing reported `NOT FOUND`. +- A full `--output json` run against three URLs (front page, a single post, a search page) on each install returned complete data for every result: real LCP/CLS/FCP/TTFB values with ratings (INP `null` throughout, as designed), real Lighthouse `performance` scores with audits, and real xhprof top-N function lists (wall-time descending, plausible WordPress call stacks) — `failedUrls: 0` on both, exits split across 0 (clean) and 3 (a forced Lighthouse-threshold breach) as expected. +- Exercised against **two independently written `server-profile.php` variants** (each install's own pre-existing shim, left untouched rather than overwritten by the scaffold — the engine correctly skipped both): both returned identical-shaped, complete profiler output, confirming the Node-side `server-profile.js`/`normalize.js` handle real-world shim variance correctly. +- Specifically proved the canonical-redirect hardening: switched one install to pretty permalinks (`wp rewrite structure '/%postname%/'`), confirmed via `curl` that the query-string URL now issues a real HTTP 301, then re-ran the shim against that exact URL — it still returned complete, valid JSON (no truncation from a mid-render `exit()`), then reverted the permalink structure. +- Verified the config-less path (`--config --url `, no `.perfrc.json` at all): frontend + Lighthouse layers still ran; `server: null` as designed (defaults to disabled without a config). +- Verified `--url` overriding an *existing* config's `urls[]` while its other sections (server, thresholds) still applied. +- Verified the scaffold never overwrites an existing `server-profile.php` (both installs already had one). +- Found and fixed one real bug via this live testing: puppeteer 25.x's `executablePath()` returns a `Promise`, not a string — `run.js` was calling it synchronously. Fixed by awaiting it (safe for both sync and async puppeteer versions). + +--- + +## Open questions + +- _(none blocking)_ + +--- + +## Notes for the reviewer + +- The exit-code contract mirrors `a11y`: 0 clean · 1 run failure or unreachable URL · 2 usage/module-missing · 3 issues found. `failedUrls > 0` downgrades an otherwise-clean run to exit 1, same as `a11y`. +- `puppeteer`, `web-vitals`, and `lighthouse` are never dependencies of `@rtcamp/wp-tooling` (zero-runtime-deps rule) — the runner resolves the consumer's install; Lighthouse falls back to `npx --no-install` and never fetches from the network. +- The server layer's dependency (`rtcamp/wp-dev-tools`) is not on Packagist — consumers add it via a path or VCS composer repository. The scaffold description says so; the shim's `class_exists` guard keeps a project without it working (frontend layers only, `server: null`). +- `wp-dev-tools` is itself mid-development (`XHProfProfiler` port on an open PR, not yet on its default branch) — this task tracks the class name/namespace it settled on, not a specific commit; if the class is renamed again before merge, only the scaffold's shim template and this file's decisions need updating, not the Node runner (it only shells out to WP-CLI, it never references the PHP class by name). + +--- + +## Handoff log + +_(no rotations yet — delete this line when the first entry is added)_ diff --git a/node-packages/wp-tooling/CHANGELOG.md b/node-packages/wp-tooling/CHANGELOG.md index 0648c52..3b183d5 100644 --- a/node-packages/wp-tooling/CHANGELOG.md +++ b/node-packages/wp-tooling/CHANGELOG.md @@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- `wp-tooling perf` — two-layer performance runner mirroring `a11y`. Layer 1 (frontend, always on): launches consumer-installed `puppeteer`, injects the `web-vitals` attribution build, and collects LCP/CLS/FCP/TTFB with `reportAllChanges: true` (LCP/CLS never finalize headless without input, so the latest reported candidate is harvested after a settle delay); INP is always `null` in the lab layer (no interaction is performed). Optional Lighthouse pass (`--only-categories=performance`, pinned to the puppeteer-installed Chrome via `CHROME_PATH`) contributes category scores + top failing audits. Layer 2 (server, opt-in via `server.enabled`): runs the consumer's `server-profile.php` shim over WP-CLI (`wp eval-file`) to get xhprof/tideways function hotspots, normalized with a CLI-context fidelity note; degrades to an empty `top[]` with guidance — never an error — when no backend or `rtcamp/wp-dev-tools` is installed, and a broken invocation degrades the same way rather than failing the run (the server layer is auxiliary cause-data). `src/perf/{errors,resolve-bin,resolve-module,config,collect-vitals,lighthouse,server-profile,normalize,run,index}.js`; `"./perf"` exports entry; `src/cli/commands/perf.js` (auto-discovered). Flags mirror `a11y`: `--config ` (default `.perfrc.json`, optional when `--url` is given — unlike `a11y`'s config), repeatable `--url` (replaces the config's `urls[]` entirely), `--output text|json`, `--dry-run`. Same exit contract: 0 clean · 1 run failure or unreachable URL · 2 usage or module/binary missing · 3 issues found (a page-load failure or `EBADJSON`/`EBINFAIL` still exits 1; a missing `puppeteer`/`web-vitals`/config-and-no-`--url` exits 2). Zero runtime dependencies — Node built-ins plus the consumer-installed `puppeteer`, `web-vitals`, and `lighthouse` dev dependencies; no `src/ui`, plain `process.stdout`/`stderr.write`. +- `setup/perf` scaffold — renders `.perfrc.json` (project-owned URL slots: `sample_page`/`search_page`/optional `extra_page`, same pattern as `setup/pa11y`) and ships a hardened `server-profile.php` shim (`raw: true`, copied verbatim) plus `web-vitals`/`lighthouse`/`puppeteer` dev-dependency pins and `test:perf` / `profile:server` npm scripts. The shim removes `template_redirect`'s `redirect_canonical` before rendering (a canonical redirect ends the request with `exit()`, which bypasses `finally` and would otherwise kill the process before the profiler stops or the JSON is echoed), profiles with `start()`/`stop()` plus a `register_shutdown_function` fallback that drains open output buffers, copies the target's query string into `$_GET` before `wp()` (`WP::parse_request()` reads `$_GET`, not `REQUEST_URI`), and emits a STDERR route + backend diagnostic. Consumes `rtCamp\WPDevTools\Support\XHProfProfiler` (require-dev `rtcamp/wp-dev-tools`) — never reimplements xhprof; degrades to `[]` + a STDERR note when the class isn't installed. - Remote scaffolds — a scaffold's `scaffold.json` + templates can live in another repo. `scaffolds/sources.json` lists the source repos (pinned `{ repository, ref, path }`); each repo publishes a `scaffolds/index.json` enumerating the scaffolds it offers, which the registry fetches to discover them (one PR in the owning repo adds/changes a scaffold; wp-tooling only changes to onboard a new repo). Manifests + templates are fetched on `add`, cached under `${XDG_CACHE_HOME:-$HOME/.cache}/wp-tooling/remote/` and validated with ETag conditional requests (`304 Not Modified` serves the cache; movable tags refresh when they move). New error code `EFETCHFAIL` (network/HTTP) distinct from `EBADSCAFFOLD` (bad index/manifest). `list` is online-preferred with a cache fallback and reports unreachable sources as warnings; `validate --remote` fetches + schema-validates each index + manifest; `wp-tooling cache clear` empties the cache. Dormant by default — no `sources.json` ships. - Engine-side input discovery (`discover_from`) — an input declaration can say where to source its value from the project, so the engine fills it instead of the caller guessing. Resolves from `composer.json` / `package.json` (dotted paths; `autoload.psr-4` yields the root namespace) and `.wp-tooling.json`, with precedence `supplied → discovered → default`. Fail-safe: a missing or malformed project file falls through to the input's `default`, so a project without those files behaves exactly as before the resolver existed. Adds an optional `transform` step for derived inputs (`json-escape` doubles backslashes for the PSR-4 composer key). The inputs the engine actually rendered with are surfaced on `execute()` as `engine.inputs`. Bundled `setup/psr4` + `wp/*` scaffolds annotated with `discover_from`. - Feature toggle layer — a scaffold may declare an optional `feature` block (`config_key`, `owned_files`, `confirm_remove`, `gitignore`) marking it as a toggleable project feature. New TTY-free `enable` / `disable` / `status` verbs create or remove the owned files idempotently, manage `.gitignore` lines (Mustache-rendered against resolved inputs), prompt before deleting consumer-editable files (`confirm_remove`, overridable with `--force`), and persist on/off state in `.wp-tooling.json`. New `wp-tooling features` command — lists feature status by default, with `--enable` / `--disable` to toggle (plus `--json`, `--force`, `--no-install`, `--dry-run`); `setup/tailwind` ships as the first such feature. Additive — the `feature` block never affects the `add` / `execute` path. diff --git a/node-packages/wp-tooling/package.json b/node-packages/wp-tooling/package.json index 396c5ff..8529018 100644 --- a/node-packages/wp-tooling/package.json +++ b/node-packages/wp-tooling/package.json @@ -27,6 +27,7 @@ "./release": "./src/release/index.js", "./hooks": "./src/hooks/index.js", "./ci": "./src/ci/index.js", + "./perf": "./src/perf/index.js", "./version-monitor": "./src/version-monitor/index.js" }, "files": [ diff --git a/node-packages/wp-tooling/scaffolds/setup/perf/scaffold.json b/node-packages/wp-tooling/scaffolds/setup/perf/scaffold.json new file mode 100644 index 0000000..55d9e47 --- /dev/null +++ b/node-packages/wp-tooling/scaffolds/setup/perf/scaffold.json @@ -0,0 +1,56 @@ +{ + "slug": "perf", + "category": "setup", + "name": "perf (web-vitals + lighthouse + server xhprof)", + "description": "Adds .perfrc.json and server-profile.php for two-layer performance testing against a running WordPress environment: lab Core Web Vitals (web-vitals attribution build under headless Chromium) + Lighthouse performance scores, and optional server-side xhprof function profiling via WP-CLI. The server layer needs `composer require --dev rtcamp/wp-dev-tools` (not on Packagist — add it via a path or VCS repository) and the xhprof or tideways_xhprof PHP extension in the WP-CLI environment; without either it degrades gracefully rather than erroring.", + "source": "template", + "files": [ + { + "src": "templates/.perfrc.json.mustache", + "dest": ".perfrc.json" + }, + { + "src": "templates/server-profile.php", + "dest": "server-profile.php", + "raw": true + } + ], + "inputs": [ + { + "key": "base_url", + "description": "Base URL of the WordPress environment to test against (e.g. http://localhost:8888).", + "required": true + }, + { + "key": "sample_page", + "description": "Path of a post or page to test, appended to base_url (e.g. /hello-world/ or a permalink path).", + "default": "/?p=1" + }, + { + "key": "search_page", + "description": "Path of the search-results page to test, appended to base_url.", + "default": "/?s=hello" + }, + { + "key": "extra_page", + "description": "Optional path of one more page to test, appended to base_url. Omitted when empty; add further URLs directly in .perfrc.json.", + "default": "" + }, + { + "key": "server_env_cwd", + "description": "Project path inside the WP-CLI environment (e.g. wp-content/plugins/my-plugin), as `wp-env run cli --env-cwd` expects it. Leave empty to disable the server-side xhprof layer.", + "default": "" + } + ], + "npm_dev_dependencies": { + "web-vitals": "^5.3.0", + "lighthouse": "^13.4.0", + "puppeteer": "^25.3.0" + }, + "scripts": { + "npm": { + "test:perf": "wp-tooling perf", + "profile:server": "wp-env run cli --env-cwd=wp-content/plugins/$(basename \"$PWD\") -- wp eval-file server-profile.php" + } + } +} diff --git a/node-packages/wp-tooling/scaffolds/setup/perf/templates/.perfrc.json.mustache b/node-packages/wp-tooling/scaffolds/setup/perf/templates/.perfrc.json.mustache new file mode 100644 index 0000000..25a494d --- /dev/null +++ b/node-packages/wp-tooling/scaffolds/setup/perf/templates/.perfrc.json.mustache @@ -0,0 +1,28 @@ +{ + "urls": [ + "{{base_url}}/", + "{{base_url}}{{sample_page}}", + "{{base_url}}{{search_page}}"{{#extra_page}}, + "{{base_url}}{{extra_page}}"{{/extra_page}} + ], + "webVitals": { + "settleMs": 3000, + "timeoutMs": 30000, + "chromeArgs": ["--no-sandbox"] + }, + "lighthouse": { + "enabled": true, + "categories": ["performance"], + "topAudits": 5 + }, + "server": { + "enabled": {{#server_env_cwd}}true{{/server_env_cwd}}{{^server_env_cwd}}false{{/server_env_cwd}}, + "command": ["npx", "wp-env", "run", "cli", "--env-cwd={{^server_env_cwd}}.{{/server_env_cwd}}{{server_env_cwd}}", "--", "wp"], + "shim": "server-profile.php", + "top": 15 + }, + "thresholds": { + "cwv": "poor", + "lighthousePerformance": 0.5 + } +} diff --git a/node-packages/wp-tooling/scaffolds/setup/perf/templates/server-profile.php b/node-packages/wp-tooling/scaffolds/setup/perf/templates/server-profile.php new file mode 100644 index 0000000..ee86028 --- /dev/null +++ b/node-packages/wp-tooling/scaffolds/setup/perf/templates/server-profile.php @@ -0,0 +1,108 @@ +] [] + * # or directly: + * wp eval-file server-profile.php [] [] [--url=] + * + * Profiles the WordPress render path for (default "/") with + * rtCamp\WPDevTools\Support\XHProfProfiler and prints the top- + * (default 15) functions by wall time as JSON: { "fn": {ct,wt,cpu,mu,pmu} }. + * Prints [] when no xhprof/tideways_xhprof backend is loaded, or when + * rtcamp/wp-dev-tools is not installed (`composer require --dev + * rtcamp/wp-dev-tools`). A route diagnostic goes to STDERR so a + * mis-resolved path — or a missing profiler — is visible next to the data. + * A CLI render approximates but does not equal a web-server request + * (routing/superglobals and opcache warmth differ). + * + * Hardening: redirect_canonical() ends the request with exit(), and exit() + * bypasses finally — so canonical redirects are unhooked up front, profiling + * uses start()/stop() rather than profile(), and a shutdown handler drains + * the output buffer and emits the JSON if some other exit() still terminates + * the render early. + * + * NOTE: no declare(strict_types) here — `wp eval-file` runs the file through + * eval(), where a declare() is no longer the first statement of the script. + */ + +if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) { + exit( 'Run via: wp eval-file server-profile.php [] []' . PHP_EOL ); +} + +$server_profile_path = isset( $args[0] ) ? (string) $args[0] : '/'; +$server_profile_top = isset( $args[1] ) ? max( 1, (int) $args[1] ) : 15; +$server_profile_backend = function_exists( 'xhprof_enable' ) + ? 'xhprof' + : ( function_exists( 'tideways_xhprof_enable' ) ? 'tideways' : 'none' ); + +if ( ! class_exists( \rtCamp\WPDevTools\Support\XHProfProfiler::class ) ) { + echo wp_json_encode( array() ) . PHP_EOL; + fwrite( + STDERR, + sprintf( + '[server-profile] path=%s backend=%s profiler=missing — install rtcamp/wp-dev-tools (composer require --dev rtcamp/wp-dev-tools)%s', + $server_profile_path, + $server_profile_backend, + PHP_EOL + ) + ); + exit( 0 ); +} + +$server_profile_profiler = new \rtCamp\WPDevTools\Support\XHProfProfiler(); + +// A canonical redirect would exit() before stop() runs or the JSON is echoed. +remove_action( 'template_redirect', 'redirect_canonical' ); + +// Fallback emitter: if the render exit()s anyway, still stop the session and print +// JSON. Open buffers are discarded first — shutdown output would otherwise flush +// behind them and partial render HTML would corrupt the JSON on stdout. +register_shutdown_function( + static function () use ( $server_profile_profiler, $server_profile_top ): void { + if ( ! $server_profile_profiler->is_running() ) { + return; + } + + while ( ob_get_level() > 0 ) { + ob_end_clean(); + } + + echo wp_json_encode( $server_profile_profiler->stop( $server_profile_top, 'server-profile' ) ) . PHP_EOL; + } +); + +// Simulate the front-end request inside this CLI process. Query-string args must land +// in $_GET too: WP::parse_request() reads query vars from $_GET, not REQUEST_URI — +// without this, "/?p=123"-style paths silently profile the homepage. +$_SERVER['REQUEST_URI'] = $server_profile_path; +parse_str( (string) wp_parse_url( $server_profile_path, PHP_URL_QUERY ), $_GET ); +$_REQUEST = array_merge( $_REQUEST, $_GET ); + +$server_profile_profiler->start(); + +ob_start(); +wp(); +if ( ! defined( 'WP_USE_THEMES' ) ) { + define( 'WP_USE_THEMES', true ); +} +require ABSPATH . WPINC . '/template-loader.php'; +ob_end_clean(); + +echo wp_json_encode( $server_profile_profiler->stop( $server_profile_top, 'server-profile' ) ) . PHP_EOL; + +// Route diagnostic (STDERR): makes a silently mis-routed path -- or a missing +// profiling backend -- visible next to the JSON. +$server_profile_query = $GLOBALS['wp_query']; +fwrite( + STDERR, + sprintf( + '[server-profile] path=%s backend=%s resolved=%s object_id=%d%s', + $server_profile_path, + $server_profile_backend, + $server_profile_query->is_singular() ? ( $server_profile_query->is_page() ? 'page' : 'singular' ) : ( $server_profile_query->is_home() ? 'home' : ( $server_profile_query->is_archive() ? 'archive' : ( $server_profile_query->is_404() ? '404' : 'other' ) ) ), + (int) get_queried_object_id(), + PHP_EOL + ) +); diff --git a/node-packages/wp-tooling/src/cli/commands/perf.js b/node-packages/wp-tooling/src/cli/commands/perf.js new file mode 100644 index 0000000..65b15f8 --- /dev/null +++ b/node-packages/wp-tooling/src/cli/commands/perf.js @@ -0,0 +1,17 @@ +/** + * perf subcommand registration. + * + * The dispatcher (`src/cli/index.js`) auto-discovers every `*.js` file in + * this directory. Each module must export `{ name, summary, run }`. + * `run` is required lazily so cold-start cost stays close to a single + * subcommand's footprint. + */ + +'use strict'; + +module.exports = { + name: 'perf', + summary: + 'Run web-vitals + Lighthouse (and optional server xhprof) and emit a normalized performance report', + run: (argv) => require('../../perf/run').runCli(argv), +}; diff --git a/node-packages/wp-tooling/src/perf/collect-vitals.js b/node-packages/wp-tooling/src/perf/collect-vitals.js new file mode 100644 index 0000000..b38fc58 --- /dev/null +++ b/node-packages/wp-tooling/src/perf/collect-vitals.js @@ -0,0 +1,145 @@ +/** + * Lab Core Web Vitals collection under headless Chromium. + * + * Takes the consumer-installed `puppeteer` module and an already-launched + * browser as PARAMETERS rather than requiring them itself — this is what + * keeps the module unit-testable with a hand-built fake browser/page and no + * `jest.mock`. `run.js` is the only place that resolves the real module. + * + * LCP and CLS never "finalize" on a headless page with no user input, so the + * web-vitals listeners are registered with `reportAllChanges: true` and we + * harvest the latest reported candidate after a settle delay instead of + * waiting for a finalization event that never arrives. INP requires a user + * interaction that this collector never performs, so it is always `null`. + */ + +'use strict'; + +const { RunnerError } = require('./errors'); + +const METRIC_NAMES = ['LCP', 'CLS', 'INP', 'FCP', 'TTFB']; + +/** + * Registers web-vitals attribution listeners and stashes the latest reading + * for each metric on `window.__wpToolingVitals`, keyed by metric name. + * Injected via `page.evaluateOnNewDocument` immediately after the web-vitals + * attribution IIFE source, so `webVitals` is already a global when this runs. + */ +const REGISTER_SNIPPET = ` +window.__wpToolingVitals = {}; +(function () { + var store = function (m) { + window.__wpToolingVitals[m.name] = { + value: m.value, + rating: m.rating, + attribution: { + element: (m.attribution && m.attribution.element) || null, + largestShiftTarget: (m.attribution && m.attribution.largestShiftTarget) || null, + interactionTarget: (m.attribution && m.attribution.interactionTarget) || null, + }, + }; + }; + var opts = { reportAllChanges: true }; + webVitals.onLCP(store, opts); + webVitals.onCLS(store, opts); + webVitals.onINP(store, opts); + webVitals.onFCP(store, opts); + webVitals.onTTFB(store, opts); +})(); +`; + +/** + * Launch a headless browser via the consumer-installed puppeteer module. + * + * @param {Object} puppeteer Consumer-installed `puppeteer` module. + * @param {Object} [options] + * @param {string[]} [options.chromeArgs] Extra Chrome launch args. + * @return {Promise} A puppeteer `Browser` instance. + * @throws {RunnerError} `EBINFAIL` when the browser fails to launch. + */ +async function launchBrowser(puppeteer, options = {}) { + try { + return await puppeteer.launch({ + headless: true, + args: options.chromeArgs || [], + }); + } catch (err) { + const detail = (err && err.message ? err.message : '').toString(); + throw new RunnerError( + 'EBINFAIL', + `headless Chromium failed to launch: ${detail}`, + { detail } + ); + } +} + +/** + * Collect lab Core Web Vitals for one URL under an already-launched browser. + * + * @param {Object} browser Puppeteer `Browser` instance. + * @param {string} scriptSource The web-vitals attribution IIFE source. + * @param {string} url Target URL. + * @param {Object} [options] + * @param {number} [options.settleMs=3000] Time to wait after load before harvesting. + * @param {number} [options.timeoutMs=30000] Navigation timeout. + * @return {Promise<{metrics: Object, attribution: Object}>} Collected metrics + attribution. + * Rejects when the page fails to load (the caller records this as a per-URL scan error). + */ +async function collectVitals(browser, scriptSource, url, options = {}) { + const settleMs = options.settleMs || 3000; + const timeoutMs = options.timeoutMs || 30000; + + const page = await browser.newPage(); + try { + await page.evaluateOnNewDocument( + `${scriptSource}\n${REGISTER_SNIPPET}` + ); + await page.goto(url, { waitUntil: 'networkidle2', timeout: timeoutMs }); + await new Promise((resolve) => { + setTimeout(resolve, settleMs); + }); + const raw = (await page.evaluate(() => window.__wpToolingVitals)) || {}; + return buildResult(raw); + } finally { + await page.close(); + } +} + +/** + * Shape the raw harvested vitals into `{ metrics, attribution }`. + * + * @param {Object} raw Harvested `window.__wpToolingVitals`. + * @return {{metrics: Object, attribution: Object}} Shaped result. + */ +function buildResult(raw) { + const metrics = {}; + for (const name of METRIC_NAMES) { + const m = raw[name]; + metrics[name] = + m && typeof m.value === 'number' + ? { value: m.value, rating: m.rating || null } + : null; + } + + const lcpAttr = (raw.LCP && raw.LCP.attribution) || {}; + const clsAttr = (raw.CLS && raw.CLS.attribution) || {}; + const inpAttr = (raw.INP && raw.INP.attribution) || {}; + + return { + metrics, + attribution: { + lcpElement: lcpAttr.element || null, + clsSources: clsAttr.largestShiftTarget + ? [clsAttr.largestShiftTarget] + : [], + inpTarget: inpAttr.interactionTarget || null, + }, + }; +} + +module.exports = { + launchBrowser, + collectVitals, + buildResult, + REGISTER_SNIPPET, +}; diff --git a/node-packages/wp-tooling/src/perf/config.js b/node-packages/wp-tooling/src/perf/config.js new file mode 100644 index 0000000..238818c --- /dev/null +++ b/node-packages/wp-tooling/src/perf/config.js @@ -0,0 +1,150 @@ +/** + * Resolve the perf runner's config and the URLs it should test. + * + * URLs and layer settings come from the project's perf config — + * `.perfrc.json` by default, or an explicit `--config` path — mirroring + * `src/a11y/urls.js`. Unlike the a11y config, the perf config is OPTIONAL + * when `--url` is supplied: a project with no `.perfrc.json` can still run + * `wp-tooling perf --url ` against every layer's built-in defaults. + * Repeatable `--url` values REPLACE the config's `urls[]` entirely; every + * other section (webVitals, lighthouse, server, thresholds) still comes + * from the config when one is present. Read-only — never mutates the + * config. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { RunnerError } = require('./errors'); + +/** Default perf config filename, relative to the project root. */ +const DEFAULT_CONFIG = '.perfrc.json'; + +/** Built-in defaults for every config section. */ +const DEFAULTS = { + urls: [], + webVitals: { + settleMs: 3000, + timeoutMs: 30000, + chromeArgs: ['--no-sandbox'], + }, + lighthouse: { + enabled: true, + categories: ['performance'], + topAudits: 5, + }, + server: { + enabled: false, + command: ['npx', 'wp-env', 'run', 'cli', '--env-cwd=.', '--', 'wp'], + shim: 'server-profile.php', + top: 15, + }, + thresholds: { + cwv: 'poor', + lighthousePerformance: 0.5, + }, +}; + +/** + * Shallow-merge a config section over its defaults. + * + * @param {Object} defaults Section defaults. + * @param {*} override Raw override value from the parsed config. + * @return {Object} Merged section. + */ +function mergeSection(defaults, override) { + if (!override || typeof override !== 'object' || Array.isArray(override)) { + return { ...defaults }; + } + return { ...defaults, ...override }; +} + +/** + * Merge a raw parsed config over the built-in defaults, section by section. + * + * @param {*} raw Parsed config (or `null`/`undefined` when there is none). + * @return {Object} Fully merged config. + */ +function mergeConfig(raw) { + const cfg = raw && typeof raw === 'object' ? raw : {}; + const urls = Array.isArray(cfg.urls) + ? cfg.urls.filter((u) => typeof u === 'string' && u.length > 0) + : DEFAULTS.urls; + return { + urls, + webVitals: mergeSection(DEFAULTS.webVitals, cfg.webVitals), + lighthouse: mergeSection(DEFAULTS.lighthouse, cfg.lighthouse), + server: mergeSection(DEFAULTS.server, cfg.server), + thresholds: mergeSection(DEFAULTS.thresholds, cfg.thresholds), + }; +} + +/** + * Resolve the perf config and the URLs to test. + * + * @param {Object} [options] + * @param {string} [options.configPath] Path to the perf config (default `.perfrc.json`). + * @param {string[]} [options.urls] Repeatable `--url` values; replaces the config's `urls[]` when non-empty. + * @param {string} [options.cwd] Project root. + * @return {{config: Object, configPath: string|null, urls: string[]}} Resolved config, the + * config path actually read (`null` when none was read), and the effective URL list. + * @throws {RunnerError} `ENOURLS` when no URLs are available; `EBADJSON` when the config is malformed. + */ +function resolveConfig(options = {}) { + const cwd = options.cwd || process.cwd(); + const explicitUrls = Array.isArray(options.urls) + ? options.urls.filter((u) => typeof u === 'string' && u.length > 0) + : []; + const configPath = options.configPath + ? path.resolve(cwd, options.configPath) + : path.join(cwd, DEFAULT_CONFIG); + + let text; + let resolvedConfigPath = configPath; + try { + text = fs.readFileSync(configPath, 'utf8'); + } catch (err) { + if (explicitUrls.length === 0) { + throw new RunnerError( + 'ENOURLS', + `no URLs to test: could not read ${configPath} (${( + err.message || '' + ).toString()}). Add a "${DEFAULT_CONFIG}" with a "urls" array, or pass --url — \`wp-tooling add setup/perf\` can scaffold one.`, + { configPath } + ); + } + resolvedConfigPath = null; + } + + let raw = null; + if (text !== undefined) { + try { + raw = JSON.parse(text); + } catch (err) { + throw new RunnerError( + 'EBADJSON', + `invalid JSON in ${configPath}: ${err.message}`, + { configPath } + ); + } + } + + const config = mergeConfig(raw); + const urls = explicitUrls.length > 0 ? explicitUrls : config.urls; + config.urls = urls; + + if (urls.length === 0) { + throw new RunnerError( + 'ENOURLS', + resolvedConfigPath + ? `no "urls" entries found in ${resolvedConfigPath}. Add the URLs to test there, or pass --url.` + : `no URLs to test. Pass --url, or add a "${DEFAULT_CONFIG}" with a "urls" array — \`wp-tooling add setup/perf\` can scaffold one.`, + { configPath: resolvedConfigPath } + ); + } + + return { config, configPath: resolvedConfigPath, urls }; +} + +module.exports = { resolveConfig, mergeConfig, DEFAULT_CONFIG, DEFAULTS }; diff --git a/node-packages/wp-tooling/src/perf/errors.js b/node-packages/wp-tooling/src/perf/errors.js new file mode 100644 index 0000000..bc7af90 --- /dev/null +++ b/node-packages/wp-tooling/src/perf/errors.js @@ -0,0 +1,29 @@ +/** + * RunnerError: the structured error type thrown by the perf runner library. + * + * Mirrors `src/a11y/errors.js` so callers branch on a stable machine-readable + * `code` while the message stays human-readable. Extra fields supplied via + * `details` are attached verbatim (e.g. `install`, `configPath`, `detail`). + * + * Codes: + * EBINMISSING a required consumer-installed module or binary is missing + * (puppeteer, the web-vitals attribution build, or lighthouse + * while enabled) + * EBINFAIL a browser or binary launch failed for a reason other than + * "found issues" + * EBADJSON the perf config could not be parsed as JSON + * ENOURLS no URLs could be resolved from the perf config or --url + */ + +'use strict'; + +class RunnerError extends Error { + constructor(code, message, details = {}) { + super(message); + this.name = 'RunnerError'; + this.code = code; + Object.assign(this, details); + } +} + +module.exports = { RunnerError }; diff --git a/node-packages/wp-tooling/src/perf/index.js b/node-packages/wp-tooling/src/perf/index.js new file mode 100644 index 0000000..7508d3f --- /dev/null +++ b/node-packages/wp-tooling/src/perf/index.js @@ -0,0 +1,15 @@ +/** + * Barrel for the perf runner library exposed as `@rtcamp/wp-tooling/perf`. + */ + +'use strict'; + +const { runPerf } = require('./run'); +const { normalizePerf } = require('./normalize'); +const { resolveConfig } = require('./config'); + +module.exports = { + runPerf, + normalizePerf, + resolveConfig, +}; diff --git a/node-packages/wp-tooling/src/perf/lighthouse.js b/node-packages/wp-tooling/src/perf/lighthouse.js new file mode 100644 index 0000000..8df337a --- /dev/null +++ b/node-packages/wp-tooling/src/perf/lighthouse.js @@ -0,0 +1,88 @@ +/** + * Lighthouse performance layer for one URL. + * + * Runs the consumer-installed `lighthouse` binary (resolved via + * `resolve-bin.js`, the same local/hoisted/`npx --no-install` chain as the + * a11y runner's `pa11y-ci` resolution) restricted to the `performance` + * category, with `--chrome-flags` pointed at Chrome for Testing via + * `CHROME_PATH` so the consumer machine needs no system Chrome install. + * A per-URL failure here is a degrade, not a run failure — `run.js` catches + * `RunnerError`s from this module and continues with `lighthouse: null` for + * that URL. + */ + +'use strict'; + +const { execFileSync } = require('child_process'); +const { RunnerError } = require('./errors'); + +const BIN = 'lighthouse'; +const MAX_BUFFER = 64 * 1024 * 1024; +const RUN_TIMEOUT_MS = 180000; + +/** + * Build the lighthouse argument vector for one URL. + * + * @param {Object} bin Resolved binary ({ command, args }). + * @param {string} url Target URL. + * @param {Object} lighthouse Resolved `lighthouse` config section. + * @return {string[]} Argument vector. + */ +function buildArgs(bin, url, lighthouse) { + return [ + ...bin.args, + url, + '--output=json', + '--output-path=stdout', + `--only-categories=${lighthouse.categories.join(',')}`, + '--quiet', + '--chrome-flags=--headless=new --no-sandbox', + ]; +} + +/** + * Run Lighthouse against one URL and return the parsed LHR. + * + * @param {Object} bin Resolved binary. + * @param {string} url Target URL. + * @param {Object} lighthouse Resolved `lighthouse` config section. + * @param {Object} [options] + * @param {string} [options.cwd] Working directory. + * @param {string|null} [options.chromePath] Chrome executable path (from puppeteer), or null. + * @return {Object} Parsed Lighthouse result (LHR). + * @throws {RunnerError} `EBINFAIL` / `EBADJSON`. + */ +function runLighthouse(bin, url, lighthouse, options = {}) { + const cwd = options.cwd || process.cwd(); + const env = options.chromePath + ? { ...process.env, CHROME_PATH: options.chromePath } + : process.env; + + let stdout; + try { + stdout = execFileSync(bin.command, buildArgs(bin, url, lighthouse), { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: MAX_BUFFER, + timeout: RUN_TIMEOUT_MS, + env, + }); + } catch (err) { + const detail = (err.stderr || err.message || '').toString().trim(); + throw new RunnerError('EBINFAIL', `${BIN} failed to run: ${detail}`, { + detail, + }); + } + + try { + return JSON.parse(stdout); + } catch (err) { + throw new RunnerError( + 'EBADJSON', + `${BIN} produced output that could not be parsed as JSON: ${err.message}` + ); + } +} + +module.exports = { runLighthouse, buildArgs, BIN }; diff --git a/node-packages/wp-tooling/src/perf/normalize.js b/node-packages/wp-tooling/src/perf/normalize.js new file mode 100644 index 0000000..e15cbce --- /dev/null +++ b/node-packages/wp-tooling/src/perf/normalize.js @@ -0,0 +1,324 @@ +/** + * Normalise raw per-URL perf capture into the two-layer report the + * performance skill consumes. Pure — no I/O — so it is unit-testable + * against fixtures, mirroring `src/a11y/normalize.js`. + * + * Normalised shape: + * { tool: 'web-vitals+lighthouse', + * summary: { urls, passedUrls, failedUrls, issues, worst }, + * results: [ { url, scanError, metrics: { LCP, CLS, INP, FCP, TTFB }, + * attribution, lighthouse, server, assessment, notes } ] } + * + * A URL puppeteer could not load is a scan failure, not an issue: its entry + * carries `scanError` with empty metrics, and counts towards + * `summary.failedUrls` rather than `summary.issues` — the same split a11y + * makes for `net::ERR_*` load failures. + */ + +'use strict'; + +/** good/needs-improvement/poor band edges, keyed by metric name. */ +const THRESHOLDS = { + LCP: [2500, 4000], + FCP: [1800, 3000], + TTFB: [800, 1800], + CLS: [0.1, 0.25], + INP: [200, 500], +}; + +/** Fidelity caveat surfaced on every server-profile result (epic task 03). */ +const SERVER_FIDELITY_NOTE = + 'profiled via `wp eval-file` in CLI context — representative for hot functions and N+1s, not a real HTTP request (routing/superglobals differ and it will not reflect web-server/opcache warmth).'; + +/** + * Rate a metric value against its good/needs-improvement/poor band. Used as + * a fallback when the web-vitals library did not supply its own `rating`. + * + * @param {string} name Metric name (LCP, FCP, TTFB, CLS, INP). + * @param {number} value Metric value. + * @return {'good'|'needs-improvement'|'poor'} Rating. + */ +function rateMetric(name, value) { + const band = THRESHOLDS[name]; + if (!band) { + return 'good'; + } + if (value <= band[0]) { + return 'good'; + } + if (value <= band[1]) { + return 'needs-improvement'; + } + return 'poor'; +} + +/** + * Extract Lighthouse category scores + top failing audits from a raw LHR. + * + * @param {Object|null} lhr Raw Lighthouse result. + * @param {Object} [options] + * @param {number} [options.topAudits=5] Max failing audits to report. + * @return {{scores: Object, audits: Object[]}|null} Extracted layer, or null. + */ +function extractLighthouse(lhr, options = {}) { + if (!lhr || typeof lhr !== 'object') { + return null; + } + const topAudits = options.topAudits || 5; + const categories = + lhr.categories && typeof lhr.categories === 'object' + ? lhr.categories + : {}; + const scores = {}; + for (const [id, cat] of Object.entries(categories)) { + if (cat && typeof cat.score === 'number') { + scores[id] = cat.score; + } + } + + const audits = + lhr.audits && typeof lhr.audits === 'object' ? lhr.audits : {}; + const failing = Object.entries(audits) + .filter(([, a]) => a && typeof a.score === 'number' && a.score < 0.9) + .map(([id, a]) => ({ + id, + title: typeof a.title === 'string' ? a.title : id, + score: a.score, + displayValue: + typeof a.displayValue === 'string' ? a.displayValue : null, + })) + .sort((a, b) => a.score - b.score) + .slice(0, topAudits); + + return { scores, audits: failing }; +} + +/** + * Normalise one server-profile.js result into the report's `server` section. + * + * @param {{data: *, diagnostic: (string|null), error: (string|null)}|null} result + * Raw result from `server-profile.js`'s `runServerProfile`, or `null` when the layer is disabled. + * @return {Object|null} Normalised `server` section, or `null` when the layer is disabled. + */ +function normalizeServer(result) { + if (!result) { + return null; + } + const { data, diagnostic, error } = result; + const top = []; + if (data && typeof data === 'object' && !Array.isArray(data)) { + for (const [fn, m] of Object.entries(data)) { + top.push({ + fn, + calls: Number(m.ct) || 0, + wallMs: (Number(m.wt) || 0) / 1000, + cpuMs: (Number(m.cpu) || 0) / 1000, + memBytes: Number(m.mu) || 0, + peakMemBytes: Number(m.pmu) || 0, + }); + } + } + + let note = SERVER_FIDELITY_NOTE; + if (!error && top.length === 0) { + note = `${SERVER_FIDELITY_NOTE} No hotspots captured — the xhprof/tideways_xhprof PHP extension may not be loaded in the WP-CLI environment, or rtcamp/wp-dev-tools is not installed.`; + } + + return { top, note, diagnostic, error: error || null }; +} + +/** + * Determine whether a rating counts as an issue under the configured + * `thresholds.cwv` mode. + * + * @param {string} rating Metric rating. + * @param {string} mode `thresholds.cwv` mode ('poor'|'needs-improvement'|'never'). + * @return {boolean} True when the rating counts as an issue. + */ +function isCwvIssue(rating, mode) { + if (mode === 'never') { + return false; + } + if (mode === 'needs-improvement') { + return rating === 'poor' || rating === 'needs-improvement'; + } + return rating === 'poor'; +} + +/** + * Build the per-URL human-readable `assessment` lines. + * + * @param {Object} metrics Normalised metrics for the URL. + * @param {Object|null} lighthouse Extracted lighthouse layer for the URL. + * @param {Object} thresholds Resolved `thresholds` config section. + * @return {string[]} Assessment lines. + */ +function buildAssessment(metrics, lighthouse, thresholds) { + const lines = []; + for (const name of ['LCP', 'CLS', 'FCP', 'TTFB']) { + const m = metrics[name]; + if (!m) { + continue; + } + const band = THRESHOLDS[name]; + const unit = name === 'CLS' ? '' : 'ms'; + lines.push( + `${name} ${m.value}${unit} — ${m.rating} (good ≤ ${band[0]}${unit}, poor > ${band[1]}${unit})` + ); + } + lines.push('INP: not measurable in lab (no interaction performed)'); + if ( + lighthouse && + typeof lighthouse.scores.performance === 'number' && + typeof thresholds.lighthousePerformance === 'number' + ) { + const perf = lighthouse.scores.performance; + const verdict = + perf < thresholds.lighthousePerformance + ? 'below threshold' + : 'above threshold'; + lines.push( + `lighthouse performance ${perf} — ${verdict} ${thresholds.lighthousePerformance}` + ); + } + return lines; +} + +/** + * Normalise raw per-URL perf capture into the final two-layer report. + * + * @param {Object[]} rawResults Raw per-URL capture (see `run.js`'s `collectOne`): + * `{ url, scanError, vitals, lighthouse, server, notes }`. + * @param {Object} [options] + * @param {Object} [options.thresholds] Resolved `thresholds` config section. + * @param {number} [options.topAudits=5] Max failing lighthouse audits per URL. + * @return {Object} Normalised report. + */ +function normalizePerf(rawResults, options = {}) { + const thresholds = options.thresholds || { + cwv: 'poor', + lighthousePerformance: 0.5, + }; + const topAudits = options.topAudits || 5; + + const results = []; + let passedUrls = 0; + let failedUrls = 0; + let issues = 0; + let worst = null; + + for (const raw of rawResults || []) { + if (raw.scanError) { + failedUrls++; + results.push({ + url: raw.url, + scanError: raw.scanError, + metrics: { + LCP: null, + CLS: null, + INP: null, + FCP: null, + TTFB: null, + }, + attribution: { + lcpElement: null, + clsSources: [], + inpTarget: null, + }, + lighthouse: null, + server: normalizeServer(raw.server), + assessment: [], + notes: raw.notes || [], + }); + continue; + } + + const vitals = raw.vitals || { metrics: {}, attribution: {} }; + const metrics = {}; + let urlIssues = 0; + + for (const name of ['LCP', 'CLS', 'FCP', 'TTFB']) { + const m = vitals.metrics[name]; + if (m && typeof m.value === 'number') { + const rating = m.rating || rateMetric(name, m.value); + metrics[name] = { value: m.value, rating }; + if (isCwvIssue(rating, thresholds.cwv)) { + urlIssues++; + } + if (rating !== 'good') { + const severity = m.value / THRESHOLDS[name][1]; + if (!worst || severity > worst.severity) { + worst = { + metric: name, + url: raw.url, + value: m.value, + rating, + severity, + }; + } + } + } else { + metrics[name] = null; + } + } + metrics.INP = null; + + const lighthouse = extractLighthouse(raw.lighthouse, { topAudits }); + if ( + lighthouse && + typeof lighthouse.scores.performance === 'number' && + typeof thresholds.lighthousePerformance === 'number' && + lighthouse.scores.performance < thresholds.lighthousePerformance + ) { + urlIssues++; + } + + issues += urlIssues; + if (urlIssues === 0) { + passedUrls++; + } + + results.push({ + url: raw.url, + scanError: null, + metrics, + attribution: vitals.attribution || { + lcpElement: null, + clsSources: [], + inpTarget: null, + }, + lighthouse, + server: normalizeServer(raw.server), + assessment: buildAssessment(metrics, lighthouse, thresholds), + notes: raw.notes || [], + }); + } + + return { + tool: 'web-vitals+lighthouse', + summary: { + urls: results.length, + passedUrls, + failedUrls, + issues, + worst: worst + ? { + metric: worst.metric, + url: worst.url, + value: worst.value, + rating: worst.rating, + } + : null, + }, + results, + }; +} + +module.exports = { + normalizePerf, + rateMetric, + extractLighthouse, + normalizeServer, + THRESHOLDS, + SERVER_FIDELITY_NOTE, +}; diff --git a/node-packages/wp-tooling/src/perf/resolve-bin.js b/node-packages/wp-tooling/src/perf/resolve-bin.js new file mode 100644 index 0000000..e0ebb7e --- /dev/null +++ b/node-packages/wp-tooling/src/perf/resolve-bin.js @@ -0,0 +1,105 @@ +/** + * Consumer binary resolution for the perf runner. + * + * `@rtcamp/wp-tooling` has zero runtime dependencies, so `lighthouse` is + * never a dependency here — it lives in the CONSUMER project's own dev + * dependencies. These helpers locate that consumer-installed binary + * (a direct or workspace-hoisted `node_modules/.bin/`), falling back to + * `npx --no-install` so we never silently fetch it from the network. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const VERSION_PROBE_TIMEOUT_MS = 20000; + +/** + * Walk up from `cwd` looking for `node_modules/.bin/`. + * + * @param {string} binName Binary name (e.g. `lighthouse`). + * @param {string} cwd Directory to start the search from. + * @return {{command: string, source: 'local'|'hoisted'}|null} The resolved + * binary, or `null` when no installed copy is found. + */ +function findInNodeModules(binName, cwd) { + const start = path.resolve(cwd); + let dir = start; + for (;;) { + const candidate = path.join(dir, 'node_modules', '.bin', binName); + if (fs.existsSync(candidate)) { + return { + command: candidate, + source: dir === start ? 'local' : 'hoisted', + }; + } + const parent = path.dirname(dir); + if (parent === dir) { + return null; + } + dir = parent; + } +} + +/** + * Resolve how to invoke a consumer-installed binary. + * + * @param {string} binName Binary name. + * @param {Object} [options] + * @param {string} [options.cwd] Directory to resolve from. + * @return {{command: string, args: string[], source: 'local'|'hoisted'|'npx'}} + * Command + leading args + how it was resolved. + */ +function resolveBin(binName, options = {}) { + const cwd = options.cwd || process.cwd(); + const found = findInNodeModules(binName, cwd); + if (found) { + return { command: found.command, args: [], source: found.source }; + } + // `--no-install` keeps npx from fetching the package: if the consumer has + // not installed it, the probe below simply reports it unavailable and the + // caller surfaces the install hint. + return { command: 'npx', args: ['--no-install', binName], source: 'npx' }; +} + +/** + * Probe a binary's `--version` to confirm it is actually runnable. + * + * @param {string} binName Binary name. + * @param {Object} [options] + * @param {string} [options.cwd] Directory to run in. + * @return {{available: boolean, version: string|null, command: string, + * args: string[], source: string, error?: string}} Probe result. + */ +function detectBin(binName, options = {}) { + const cwd = options.cwd || process.cwd(); + const { command, args, source } = resolveBin(binName, { cwd }); + try { + const out = execFileSync(command, [...args, '--version'], { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: VERSION_PROBE_TIMEOUT_MS, + }); + return { + available: true, + version: out.toString().trim(), + command, + args, + source, + }; + } catch (err) { + return { + available: false, + version: null, + command, + args, + source, + error: (err.stderr || err.message || '').toString().trim(), + }; + } +} + +module.exports = { resolveBin, detectBin, findInNodeModules }; diff --git a/node-packages/wp-tooling/src/perf/resolve-module.js b/node-packages/wp-tooling/src/perf/resolve-module.js new file mode 100644 index 0000000..878bfab --- /dev/null +++ b/node-packages/wp-tooling/src/perf/resolve-module.js @@ -0,0 +1,137 @@ +/** + * Consumer module resolution for the perf runner. + * + * `puppeteer` and `web-vitals` are consumer dev dependencies, never runtime + * dependencies of `@rtcamp/wp-tooling`. These helpers walk up from `cwd` + * looking for an installed copy in `node_modules`, the same shape as + * `resolve-bin.js` for binaries. Unlike `detectBin`, no child process is + * spawned — the version comes straight from the module's own package.json. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +/** + * Walk up from `cwd` looking for `node_modules/`. + * + * @param {string} moduleName Package name (e.g. `puppeteer`). + * @param {string} cwd Directory to start the search from. + * @return {{dir: string, source: 'local'|'hoisted'}|null} The resolved + * module directory, or `null` when no installed copy is found. + */ +function findModuleDir(moduleName, cwd) { + const start = path.resolve(cwd); + let dir = start; + for (;;) { + const candidate = path.join(dir, 'node_modules', moduleName); + if (fs.existsSync(path.join(candidate, 'package.json'))) { + return { + dir: candidate, + source: dir === start ? 'local' : 'hoisted', + }; + } + const parent = path.dirname(dir); + if (parent === dir) { + return null; + } + dir = parent; + } +} + +/** + * Resolve a consumer-installed module's directory. + * + * @param {string} moduleName Package name. + * @param {Object} [options] + * @param {string} [options.cwd] Directory to resolve from. + * @return {string|null} Absolute directory, or `null` when not installed. + */ +function resolveModuleDir(moduleName, options = {}) { + const cwd = options.cwd || process.cwd(); + const found = findModuleDir(moduleName, cwd); + return found ? found.dir : null; +} + +/** + * Resolve an absolute path to a file inside a consumer-installed module. + * + * @param {string} moduleName Package name. + * @param {string} relFile File path relative to the module's directory. + * @param {Object} [options] + * @param {string} [options.cwd] Directory to resolve from. + * @return {string|null} Absolute file path when it exists, else `null`. + */ +function resolveModuleFile(moduleName, relFile, options = {}) { + const dir = resolveModuleDir(moduleName, options); + if (!dir) { + return null; + } + const file = path.join(dir, relFile); + return fs.existsSync(file) ? file : null; +} + +/** + * Resolve AND `require` a consumer-installed module, returning the loaded + * module (never a path) so callers — and their tests — depend only on this + * function's return value, not on Node's real module resolution. `run.js` + * uses this exclusively to obtain `puppeteer`; tests substitute a fake + * module by mocking this file, no real puppeteer install required. + * + * @param {string} moduleName Package name. + * @param {Object} [options] + * @param {string} [options.cwd] Directory to resolve from. + * @return {*} The loaded module, or `null` when not installed. + */ +function requireModule(moduleName, options = {}) { + const dir = resolveModuleDir(moduleName, options); + if (!dir) { + return null; + } + // Intentional dynamic require: path resolved by walking the consumer's + // own node_modules for a known package name, mirroring the dispatcher's + // command auto-discovery in src/cli/index.js. No user input involved. + + return require(dir); +} + +/** + * Detect a consumer-installed module and report its declared version. + * + * @param {string} moduleName Package name. + * @param {Object} [options] + * @param {string} [options.cwd] Directory to resolve from. + * @return {{available: boolean, version: string|null, dir: string|null, + * source: 'local'|'hoisted'|null}} Detection result. + */ +function detectModule(moduleName, options = {}) { + const cwd = options.cwd || process.cwd(); + const found = findModuleDir(moduleName, cwd); + if (!found) { + return { available: false, version: null, dir: null, source: null }; + } + let version = null; + try { + const pkg = JSON.parse( + fs.readFileSync(path.join(found.dir, 'package.json'), 'utf8') + ); + version = typeof pkg.version === 'string' ? pkg.version : null; + } catch { + version = null; + } + return { + available: true, + version, + dir: found.dir, + source: found.source, + }; +} + +module.exports = { + findModuleDir, + resolveModuleDir, + resolveModuleFile, + requireModule, + detectModule, +}; diff --git a/node-packages/wp-tooling/src/perf/run.js b/node-packages/wp-tooling/src/perf/run.js new file mode 100644 index 0000000..0e1304b --- /dev/null +++ b/node-packages/wp-tooling/src/perf/run.js @@ -0,0 +1,456 @@ +/** + * perf -- run lab Core Web Vitals + Lighthouse (+ optional server-side + * xhprof over WP-CLI) and emit a normalized two-layer performance report. + * + * Library API: + * const { runPerf } = require( '@rtcamp/wp-tooling/perf' ); + * + * CLI: + * wp-tooling perf [options] + * + * URLs and layer settings come from the project's perf config + * (`.perfrc.json`, or an explicit `--config` path), or from repeatable + * `--url` values when there is no config — the config is optional, unlike + * a11y's. Zero runtime dependencies: Node built-ins plus the + * project-installed `puppeteer`, `web-vitals`, and `lighthouse` dev + * dependencies (`wp-tooling add setup/perf` scaffolds a config and these + * deps for projects that need one). + */ + +'use strict'; + +const fs = require('fs'); +const { RunnerError } = require('./errors'); +const { resolveConfig } = require('./config'); +const { + resolveModuleFile, + requireModule, + detectModule, +} = require('./resolve-module'); +const { detectBin } = require('./resolve-bin'); +const { launchBrowser, collectVitals } = require('./collect-vitals'); +const { runLighthouse, BIN: LIGHTHOUSE_BIN } = require('./lighthouse'); +const { runServerProfile } = require('./server-profile'); +const { normalizePerf } = require('./normalize'); + +const INSTALL_HINT = 'wp-tooling add setup/perf'; +const WEB_VITALS_DIST = 'dist/web-vitals.attribution.iife.js'; + +/** + * Run the perf layers against the config's (or `--url`'s) URLs and return + * the normalized report. + * + * @param {Object} [options] + * @param {string} [options.configPath] Path to the perf config (default `.perfrc.json`). + * @param {string[]} [options.urls] Repeatable `--url` values. + * @param {string} [options.cwd] Project root. + * @return {Promise} Normalized report (see normalize.js). + * @throws {RunnerError} EBINMISSING / EBINFAIL / EBADJSON / ENOURLS. + */ +async function runPerf(options = {}) { + const cwd = options.cwd || process.cwd(); + const { config, urls } = resolveConfig({ + configPath: options.configPath, + urls: options.urls, + cwd, + }); + + const puppeteer = requireModule('puppeteer', { cwd }); + if (!puppeteer) { + throw new RunnerError( + 'EBINMISSING', + `puppeteer not found. Install it in the project (\`${INSTALL_HINT}\` sets it up).`, + { install: INSTALL_HINT } + ); + } + + const webVitalsFile = resolveModuleFile('web-vitals', WEB_VITALS_DIST, { + cwd, + }); + if (!webVitalsFile) { + throw new RunnerError( + 'EBINMISSING', + `web-vitals attribution build not found at node_modules/web-vitals/${WEB_VITALS_DIST}. Install it in the project (\`${INSTALL_HINT}\` sets it up).`, + { install: INSTALL_HINT } + ); + } + const scriptSource = fs.readFileSync(webVitalsFile, 'utf8'); + + let lighthouseBin = null; + if (config.lighthouse.enabled) { + lighthouseBin = detectBin(LIGHTHOUSE_BIN, { cwd }); + if (!lighthouseBin.available) { + throw new RunnerError( + 'EBINMISSING', + `${LIGHTHOUSE_BIN} not found. Install it in the project (\`${INSTALL_HINT}\` sets it up), or set lighthouse.enabled to false.`, + { bin: LIGHTHOUSE_BIN, install: INSTALL_HINT } + ); + } + } + + let chromePath = null; + try { + // `await` works whether the consumer's puppeteer version returns the + // path synchronously or (25.x+) as a Promise. + chromePath = await puppeteer.executablePath(); + } catch { + chromePath = null; + } + + const browser = await launchBrowser(puppeteer, { + chromeArgs: config.webVitals.chromeArgs, + }); + + const rawResults = []; + try { + for (const url of urls) { + rawResults.push( + await collectOne(url, { + browser, + scriptSource, + config, + lighthouseBin, + chromePath, + cwd, + }) + ); + } + } finally { + await browser.close(); + } + + return normalizePerf(rawResults, { + thresholds: config.thresholds, + topAudits: config.lighthouse.topAudits, + }); +} + +/** + * Run every layer for one URL. Frontend load failure becomes a per-URL + * `scanError` (the caller's run continues); lighthouse and server failures + * degrade to `null` + a note without affecting the overall run. + * + * @param {string} url Target URL. + * @param {Object} ctx Shared context for the run. + * @param {Object} ctx.browser Puppeteer `Browser` instance. + * @param {string} ctx.scriptSource web-vitals attribution IIFE source. + * @param {Object} ctx.config Resolved perf config. + * @param {Object|null} ctx.lighthouseBin Resolved lighthouse binary, or null when disabled. + * @param {string|null} ctx.chromePath Chrome executable path, or null. + * @param {string} ctx.cwd Working directory. + * @return {Promise} Raw per-URL capture consumed by `normalizePerf`. + */ +async function collectOne(url, ctx) { + const { browser, scriptSource, config, lighthouseBin, chromePath, cwd } = + ctx; + const notes = []; + let vitals = null; + let scanError = null; + + try { + vitals = await collectVitals( + browser, + scriptSource, + url, + config.webVitals + ); + } catch (err) { + scanError = (err && err.message ? err.message : '').toString(); + } + + let lighthouse = null; + if (config.lighthouse.enabled && lighthouseBin) { + try { + lighthouse = runLighthouse(lighthouseBin, url, config.lighthouse, { + cwd, + chromePath, + }); + } catch (err) { + const detail = (err && err.message ? err.message : '').toString(); + notes.push(`lighthouse: failed — ${detail}`); + process.stderr.write( + `perf: lighthouse failed for ${url}: ${detail}\n` + ); + } + } + + let server = null; + if (config.server.enabled) { + server = runServerProfile(config.server, url, { cwd }); + if (server.error) { + process.stderr.write( + `perf: server profile failed for ${url}: ${server.error}\n` + ); + } + } + + return { url, scanError, vitals, lighthouse, server, notes }; +} + +const VALID_OUTPUTS = ['text', 'json']; + +/** + * Consume the argv slot at `index` as a value for `flag`. + * + * @param {string[]} argv Argument vector. + * @param {number} index Position of the value. + * @param {string} flag Flag name, for the error message. + * @return {string} The validated value. + */ +function takeValue(argv, index, flag) { + const value = argv[index]; + if (value === undefined || value.startsWith('-')) { + throw new Error(`missing value for ${flag}`); + } + return value; +} + +/** + * Parse argv (without leading `node` and script path). + * + * @param {string[]} argv Argument vector. + * @return {Object} Parsed options. + */ +function parseArgs(argv) { + const opts = { output: 'text', urls: [] }; + let i = 0; + while (i < argv.length) { + const arg = argv[i]; + switch (arg) { + case '--config': + opts.configPath = takeValue(argv, ++i, '--config'); + break; + case '--url': + opts.urls.push(takeValue(argv, ++i, '--url')); + break; + case '--output': + opts.output = takeValue(argv, ++i, '--output'); + break; + case '--dry-run': + opts.dryRun = true; + break; + case '--help': + case '-h': + opts.help = true; + break; + default: + throw new Error(`unknown argument: ${arg}`); + } + i++; + } + return opts; +} + +/** + * Emit the normalized report in the requested output mode. + * + * @param {Object} report Normalized report. + * @param {string} mode 'text' | 'json'. + * @return {void} + */ +function emit(report, mode) { + if (mode === 'json') { + process.stdout.write(JSON.stringify(report) + '\n'); + return; + } + const s = report.summary; + const failed = s.failedUrls > 0 ? `, ${s.failedUrls} failed to load` : ''; + const lines = [ + `${report.tool}: ${s.issues} issue(s) across ${s.urls} URL(s); ${s.passedUrls} clean${failed}.`, + ]; + if (s.worst) { + lines.push( + `worst: ${s.worst.metric} ${s.worst.value} (${s.worst.rating}) on ${s.worst.url}` + ); + } + for (const r of report.results) { + lines.push(''); + if (r.scanError) { + lines.push(`${r.url} — scan failed`); + lines.push(` ${r.scanError}`); + continue; + } + lines.push(`${r.url}`); + for (const line of r.assessment) { + lines.push(` ${line}`); + } + if (r.server) { + const top = r.server.top + .slice(0, 3) + .map((f) => `${f.fn} (${f.wallMs.toFixed(1)}ms)`) + .join(', '); + lines.push(` server top: ${top || 'none'}`); + } + for (const note of r.notes) { + lines.push(` note: ${note}`); + } + } + lines.push(''); + process.stdout.write(lines.join('\n')); +} + +/** + * Print CLI usage. + * + * @return {void} + */ +function printUsage() { + process.stdout.write( + [ + 'Usage: perf [options]', + '', + ' Runs lab Core Web Vitals (web-vitals attribution build under headless', + " Chromium) and Lighthouse against the project perf config's URLs,", + ' optionally profiling the server-side render via WP-CLI + xhprof, and', + ' prints a normalized two-layer report. Requires the puppeteer and', + ' web-vitals dev dependencies (`wp-tooling add setup/perf` sets these', + ' up, along with lighthouse and the server-profile.php shim).', + '', + ' --config Path to the perf config (default: .perfrc.json).', + " --url Target URL; repeatable. Replaces the config's urls[] entirely.", + ' --output Output format (default: text).', + ' --dry-run Print the resolved config, modules and URLs; run nothing.', + ' --help, -h Print this help.', + '', + ' A config is only required when no --url is given. INP is not', + ' measurable in the lab layer (no user interaction is performed).', + '', + 'Exit codes: 0 clean · 1 run failure or unreachable URL · 2 usage or binary missing · 3 issues found.', + '', + ].join('\n') + ); +} + +/** + * Print the dry-run plan (resolved config, modules, binaries, URLs) without + * running anything. + * + * @param {Object} opts Parsed options. + * @param {string} cwd Working directory. + * @return {number} Exit code. + */ +function runDryRun(opts, cwd) { + let resolved; + try { + resolved = resolveConfig({ + configPath: opts.configPath, + urls: opts.urls, + cwd, + }); + } catch (err) { + return handleError(err); + } + const { config, configPath, urls } = resolved; + + const puppeteerInfo = detectModule('puppeteer', { cwd }); + const webVitalsFile = resolveModuleFile('web-vitals', WEB_VITALS_DIST, { + cwd, + }); + + const lines = [ + '[dry-run] perf would run:', + ` config: ${configPath || 'none — URLs from --url'}`, + ` urls: ${urls.join(', ')}`, + ` puppeteer: ${ + puppeteerInfo.available + ? `${puppeteerInfo.dir} (${puppeteerInfo.source}, ${puppeteerInfo.version})` + : 'NOT FOUND' + }`, + ` web-vitals: ${webVitalsFile || 'NOT FOUND'}`, + ]; + + if (config.lighthouse.enabled) { + const bin = detectBin(LIGHTHOUSE_BIN, { cwd }); + const state = bin.available ? bin.version : 'NOT FOUND'; + lines.push(` lighthouse: ${bin.command} (${bin.source}, ${state})`); + } else { + lines.push(' lighthouse: disabled'); + } + + if (config.server.enabled) { + lines.push( + ` server: ${config.server.command.join(' ')} eval-file ${config.server.shim} ${config.server.top} --url=` + ); + } else { + lines.push(' server: disabled'); + } + + lines.push(''); + process.stdout.write(lines.join('\n')); + return 0; +} + +/** + * Map a thrown error to an exit code and a stderr message. + * + * @param {Error} err The error. + * @return {number} Exit code: 2 (usage / module or binary missing), 1 (run failure). + */ +function handleError(err) { + process.stderr.write(`perf: ${err.message}\n`); + if ( + err instanceof RunnerError && + (err.code === 'EBINMISSING' || err.code === 'ENOURLS') + ) { + return 2; + } + return 1; +} + +/** + * Run the CLI. Returns the intended exit code. + * + * @param {string[]} argv argv slice (without `node` and script path). + * @return {Promise} 0 clean · 1 run failure or unreachable URL · 2 usage/module-missing · 3 issues found. + */ +async function runCli(argv) { + let opts; + try { + opts = parseArgs(argv); + } catch (err) { + process.stderr.write(`perf: ${err.message}\n`); + return 2; + } + + if (opts.help) { + printUsage(); + return 0; + } + + if (!VALID_OUTPUTS.includes(opts.output)) { + process.stderr.write( + `perf: invalid --output "${opts.output}" (expected one of: ${VALID_OUTPUTS.join( + ', ' + )})\n` + ); + return 2; + } + + const cwd = process.cwd(); + + if (opts.dryRun) { + return runDryRun(opts, cwd); + } + + let report; + try { + report = await runPerf({ + configPath: opts.configPath, + urls: opts.urls, + cwd, + }); + } catch (err) { + return handleError(err); + } + + emit(report, opts.output); + if (report.summary.failedUrls > 0) { + process.stderr.write( + `perf: ${report.summary.failedUrls} URL(s) failed to load — treating as a run failure.\n` + ); + return 1; + } + return report.summary.issues > 0 ? 3 : 0; +} + +module.exports = { runPerf, runCli }; diff --git a/node-packages/wp-tooling/src/perf/server-profile.js b/node-packages/wp-tooling/src/perf/server-profile.js new file mode 100644 index 0000000..41d12f8 --- /dev/null +++ b/node-packages/wp-tooling/src/perf/server-profile.js @@ -0,0 +1,117 @@ +/** + * Server-side xhprof layer for one URL, invoked over WP-CLI. + * + * Spawns the consumer's `server-profile.php` shim (installed by + * `wp-tooling add setup/perf`) through the configured WP-CLI command prefix + * — typically `npx wp-env run cli --env-cwd= -- wp`. The URL's origin + * is passed as WP-CLI's `--url` (site context; also what arms + * `redirect_canonical()` in the shim's render, which is why the shim removes + * that hook) and the path+query is passed positionally (the shim reads it + * into `$_GET` itself). + * + * Every failure mode here — spawn failure, non-zero exit, unparseable + * output — is a DEGRADE, never a thrown error: the server layer is + * auxiliary cause-data, so a broken WP-CLI invocation must not take down + * the frontend layers or affect the run's exit code. Callers read + * `result.error` to detect it. + */ + +'use strict'; + +const { spawnSync } = require('child_process'); + +const MAX_BUFFER = 64 * 1024 * 1024; +const RUN_TIMEOUT_MS = 120000; + +/** + * Split a target URL into its origin (scheme+host+port) and its path+query + * — the two pieces the shim's contract expects separately + * (`wp eval-file server-profile.php [] [] [--url=]`). + * + * @param {string} url Full target URL. + * @return {{origin: string, pathAndQuery: string}} Split URL. + */ +function splitUrl(url) { + const u = new URL(url); + return { origin: u.origin, pathAndQuery: `${u.pathname}${u.search}` }; +} + +/** + * Parse the shim's stdout, tolerating a leading non-JSON preamble line. + * + * @param {string} text Raw stdout. + * @return {*} Parsed value, or `null` when not parseable. + */ +function tryParse(text) { + if (!text) { + return null; + } + const trimmed = text.trim(); + const start = trimmed.search(/[[{]/); + if (start === -1) { + return null; + } + try { + return JSON.parse(trimmed.slice(start)); + } catch { + return null; + } +} + +/** + * Run the consumer's `server-profile.php` shim over WP-CLI for one URL. + * + * @param {Object} server Resolved `server` config section. + * @param {string[]} server.command WP-CLI invocation prefix (e.g. `['npx','wp-env','run','cli','--env-cwd=...','--','wp']`). + * @param {string} server.shim Shim path, as WP-CLI sees it. + * @param {number} server.top Top-N functions to request. + * @param {string} url Target URL (origin used for `--url`; path+query passed positionally). + * @param {Object} [options] + * @param {string} [options.cwd] Working directory. + * @return {{data: (Object|Array|null), diagnostic: (string|null), error: (string|null)}} + * Parsed profiler output (a bare `{fn: {ct,wt,cpu,mu,pmu}}` map, or `[]` when no backend + * was loaded), the STDERR route diagnostic when the shim printed one, and an `error` + * detail when the invocation could not be completed. + */ +function runServerProfile(server, url, options = {}) { + const cwd = options.cwd || process.cwd(); + const { origin, pathAndQuery } = splitUrl(url); + const [command, ...prefix] = server.command; + const args = [ + ...prefix, + 'eval-file', + server.shim, + pathAndQuery, + String(server.top), + `--url=${origin}`, + ]; + + const result = spawnSync(command, args, { + cwd, + encoding: 'utf8', + maxBuffer: MAX_BUFFER, + timeout: RUN_TIMEOUT_MS, + }); + + const diagnostic = (result.stderr || '').toString().trim() || null; + + if (result.error) { + return { data: null, diagnostic, error: result.error.message }; + } + + const parsed = tryParse((result.stdout || '').toString()); + if (parsed === null) { + const detail = + diagnostic || + `exit code ${result.status === null ? 'null (timed out?)' : result.status}`; + return { + data: null, + diagnostic, + error: `no parseable output (${detail})`, + }; + } + + return { data: parsed, diagnostic, error: null }; +} + +module.exports = { runServerProfile, splitUrl }; diff --git a/node-packages/wp-tooling/tests/perf/cli.test.js b/node-packages/wp-tooling/tests/perf/cli.test.js new file mode 100644 index 0000000..ba3a079 --- /dev/null +++ b/node-packages/wp-tooling/tests/perf/cli.test.js @@ -0,0 +1,299 @@ +'use strict'; + +jest.mock('child_process'); +jest.mock('../../src/perf/resolve-module'); +jest.mock('../../src/perf/collect-vitals'); + +const path = require('path'); +const { execFileSync, spawnSync } = require('child_process'); +const resolveModule = require('../../src/perf/resolve-module'); +const collectVitalsModule = require('../../src/perf/collect-vitals'); +const { runCli } = require('../../src/perf/run'); + +const FIXTURES = path.join(__dirname, 'fixtures'); +const FIXTURE_CONFIG = path.join(FIXTURES, '.perfrc.json'); +const PARTIAL_CONFIG = path.join(FIXTURES, 'partial.perfrc.json'); +const MISSING_CONFIG = path.join(FIXTURES, 'does-not-exist.json'); + +const GOOD_METRICS = { + metrics: { + LCP: { value: 1000, rating: 'good' }, + CLS: { value: 0.01, rating: 'good' }, + INP: null, + FCP: { value: 500, rating: 'good' }, + TTFB: { value: 100, rating: 'good' }, + }, + attribution: { lcpElement: null, clsSources: [], inpTarget: null }, +}; + +const GOOD_LHR = { + categories: { performance: { score: 0.95 } }, + audits: {}, +}; + +/** + * Drive the mocked lighthouse + WP-CLI invocations for one test. + * + * @param {Object} [o] + * @param {boolean} [o.lighthouseAvailable=true] Whether the --version probe succeeds. + * @param {*} [o.lhr=GOOD_LHR] Value returned by a real lighthouse run. + * @param {boolean} [o.lighthouseRunThrows] Whether the real lighthouse run throws. + * @param {string} [o.lighthouseRunReturn] Raw stdout for a non-throwing lighthouse run. + * @param {Object} [o.serverResult] `spawnSync` return value for the server layer. + */ +function mockChildProcess(o = {}) { + const lighthouseAvailable = o.lighthouseAvailable !== false; + execFileSync.mockImplementation((cmd, args) => { + if (args.includes('--version')) { + if (!lighthouseAvailable) { + const err = new Error('command not found'); + err.stderr = 'command not found'; + throw err; + } + return '13.4.0\n'; + } + if (o.lighthouseRunThrows) { + const err = new Error('exited non-zero'); + err.stderr = 'Chrome crashed'; + throw err; + } + if (o.lighthouseRunReturn !== undefined) { + return o.lighthouseRunReturn; + } + return JSON.stringify(o.lhr !== undefined ? o.lhr : GOOD_LHR); + }); + spawnSync.mockReturnValue( + o.serverResult !== undefined + ? o.serverResult + : { stdout: '{}', stderr: '', status: 0 } + ); +} + +describe('perf runCli', () => { + let stdout; + let stderr; + let outSpy; + let errSpy; + + beforeEach(() => { + stdout = []; + stderr = []; + outSpy = jest.spyOn(process.stdout, 'write').mockImplementation((c) => { + stdout.push(c.toString()); + return true; + }); + errSpy = jest.spyOn(process.stderr, 'write').mockImplementation((c) => { + stderr.push(c.toString()); + return true; + }); + + resolveModule.requireModule.mockReturnValue({ + launch: jest.fn(), + executablePath: jest.fn(() => '/chrome-for-testing'), + }); + resolveModule.resolveModuleFile.mockReturnValue( + path.join(FIXTURES, 'web-vitals.attribution.iife.js') + ); + resolveModule.detectModule.mockReturnValue({ + available: true, + version: '5.3.0', + dir: '/project/node_modules/puppeteer', + source: 'local', + }); + + collectVitalsModule.launchBrowser.mockResolvedValue({ + close: jest.fn(async () => {}), + }); + collectVitalsModule.collectVitals.mockResolvedValue({ + ...GOOD_METRICS, + }); + + mockChildProcess(); + }); + + afterEach(() => { + outSpy.mockRestore(); + errSpy.mockRestore(); + }); + + test('--help prints usage and exits 0', async () => { + expect(await runCli(['--help'])).toBe(0); + expect(stdout.join('')).toMatch(/Usage: perf/); + }); + + test('unknown flag exits 2', async () => { + expect(await runCli(['--bogus'])).toBe(2); + expect(stderr.join('')).toMatch(/unknown argument/); + }); + + test('--url with no value exits 2', async () => { + expect(await runCli(['--url'])).toBe(2); + expect(stderr.join('')).toMatch(/missing value for --url/); + }); + + test('invalid --output exits 2', async () => { + expect( + await runCli(['--output', 'xml', '--config', FIXTURE_CONFIG]) + ).toBe(2); + expect(stderr.join('')).toMatch(/invalid --output/); + }); + + test('missing puppeteer exits 2 with the install hint', async () => { + resolveModule.requireModule.mockReturnValue(null); + expect(await runCli(['--config', FIXTURE_CONFIG])).toBe(2); + expect(stderr.join('')).toMatch(/puppeteer not found/); + expect(stderr.join('')).toMatch(/wp-tooling add setup\/perf/); + }); + + test('no config and no --url exits 2 (ENOURLS)', async () => { + expect(await runCli(['--config', MISSING_CONFIG])).toBe(2); + expect(stderr.join('')).toMatch(/no URLs to test/); + }); + + test('--url runs without any config file at all', async () => { + const code = await runCli([ + '--config', + MISSING_CONFIG, + '--url', + 'http://localhost:8888/', + '--output', + 'json', + ]); + expect(code).toBe(0); + const parsed = JSON.parse(stdout.join('')); + expect(parsed.results[0].url).toBe('http://localhost:8888/'); + // Config-less: the server layer defaults to disabled. + expect(parsed.results[0].server).toBeNull(); + }); + + test('a clean run with both layers exits 0', async () => { + mockChildProcess({ + lhr: GOOD_LHR, + serverResult: { + stdout: JSON.stringify({ + 'WP_Query::get_posts': { + ct: 3, + wt: 41200, + cpu: 38000, + mu: 1048576, + pmu: 1148576, + }, + }), + stderr: '[server-profile] path=/ resolved=home object_id=0', + status: 0, + }, + }); + const code = await runCli([ + '--config', + FIXTURE_CONFIG, + '--output', + 'json', + ]); + expect(code).toBe(0); + const parsed = JSON.parse(stdout.join('')); + expect(parsed.summary.failedUrls).toBe(0); + expect(parsed.summary.issues).toBe(0); + expect(parsed.results[0].lighthouse.scores.performance).toBe(0.95); + expect(parsed.results[0].server.top[0].fn).toBe('WP_Query::get_posts'); + }); + + test('a poor metric exits 3', async () => { + collectVitalsModule.collectVitals.mockResolvedValue({ + metrics: { + ...GOOD_METRICS.metrics, + LCP: { value: 5000, rating: 'poor' }, + }, + attribution: GOOD_METRICS.attribution, + }); + const code = await runCli([ + '--config', + FIXTURE_CONFIG, + '--output', + 'json', + ]); + expect(code).toBe(3); + }); + + test('a page load failure is a run failure (exit 1), not an issue', async () => { + collectVitalsModule.collectVitals.mockRejectedValue( + new Error('net::ERR_CONNECTION_REFUSED') + ); + const code = await runCli([ + '--config', + FIXTURE_CONFIG, + '--output', + 'json', + ]); + expect(code).toBe(1); + const parsed = JSON.parse(stdout.join('')); + expect(parsed.summary.failedUrls).toBeGreaterThan(0); + expect(stderr.join('')).toMatch(/failed to load/); + }); + + test('a lighthouse runtime failure degrades that layer without affecting the exit code', async () => { + mockChildProcess({ lighthouseRunThrows: true }); + const code = await runCli([ + '--config', + FIXTURE_CONFIG, + '--output', + 'json', + ]); + expect(code).toBe(0); + const parsed = JSON.parse(stdout.join('')); + expect(parsed.results[0].lighthouse).toBeNull(); + expect(parsed.results[0].notes.join('')).toMatch(/lighthouse: failed/); + expect(stderr.join('')).toMatch(/lighthouse failed for/); + }); + + test('a server profile failure degrades that layer without affecting the exit code', async () => { + mockChildProcess({ + serverResult: { + stdout: 'PHP Fatal error: something exploded', + stderr: '', + status: 255, + }, + }); + const code = await runCli([ + '--config', + FIXTURE_CONFIG, + '--output', + 'json', + ]); + expect(code).toBe(0); + const parsed = JSON.parse(stdout.join('')); + expect(parsed.results[0].server.error).toMatch(/no parseable output/); + expect(stderr.join('')).toMatch(/server profile failed for/); + }); + + test('lighthouse.enabled: false never probes lighthouse, and the disabled server layer never spawns', async () => { + const code = await runCli([ + '--config', + PARTIAL_CONFIG, + '--output', + 'json', + ]); + expect(code).toBe(0); + expect(execFileSync).not.toHaveBeenCalled(); + expect(spawnSync).not.toHaveBeenCalled(); + const parsed = JSON.parse(stdout.join('')); + expect(parsed.results[0].lighthouse).toBeNull(); + expect(parsed.results[0].server).toBeNull(); + }); + + test('--dry-run resolves everything but runs nothing', async () => { + const code = await runCli(['--dry-run', '--config', FIXTURE_CONFIG]); + expect(code).toBe(0); + const out = stdout.join(''); + expect(out).toMatch(/\[dry-run\] perf would run:/); + expect(out).toMatch(/puppeteer:/); + expect(out).toMatch(/lighthouse:/); + expect(out).toMatch(/server:/); + + // Only the lighthouse --version probe ran; nothing else was invoked. + expect(execFileSync.mock.calls).toHaveLength(1); + expect(execFileSync.mock.calls[0][1]).toContain('--version'); + expect(spawnSync).not.toHaveBeenCalled(); + expect(collectVitalsModule.launchBrowser).not.toHaveBeenCalled(); + expect(collectVitalsModule.collectVitals).not.toHaveBeenCalled(); + }); +}); diff --git a/node-packages/wp-tooling/tests/perf/collect-vitals.test.js b/node-packages/wp-tooling/tests/perf/collect-vitals.test.js new file mode 100644 index 0000000..37386ed --- /dev/null +++ b/node-packages/wp-tooling/tests/perf/collect-vitals.test.js @@ -0,0 +1,151 @@ +'use strict'; + +const { + launchBrowser, + collectVitals, + buildResult, +} = require('../../src/perf/collect-vitals'); + +/** + * Build a fake puppeteer `Page`, recording every call it receives. + * + * @param {Object} [o] + * @param {Object} [o.harvested] Value `page.evaluate` resolves to (the harvested vitals). + * @param {Error} [o.gotoError] When set, `page.goto` rejects with this error. + * @return {{page: Object, calls: Array}} The fake page and its call log. + */ +function fakePage(o = {}) { + const calls = []; + const page = { + evaluateOnNewDocument: jest.fn(async (src) => { + calls.push(['evaluateOnNewDocument', src]); + }), + goto: jest.fn(async (url, opts) => { + calls.push(['goto', url, opts]); + if (o.gotoError) { + throw o.gotoError; + } + }), + evaluate: jest.fn(async () => { + calls.push(['evaluate']); + return o.harvested !== undefined ? o.harvested : {}; + }), + close: jest.fn(async () => { + calls.push(['close']); + }), + }; + return { page, calls }; +} + +describe('launchBrowser', () => { + test('launches headless with the given chrome args', async () => { + const launch = jest.fn(async () => ({ marker: 'browser' })); + const browser = await launchBrowser( + { launch }, + { chromeArgs: ['--no-sandbox'] } + ); + expect(browser).toEqual({ marker: 'browser' }); + expect(launch).toHaveBeenCalledWith({ + headless: true, + args: ['--no-sandbox'], + }); + }); + + test('wraps a launch failure in RunnerError EBINFAIL', async () => { + const launch = jest.fn(async () => { + throw new Error('no chrome binary'); + }); + await expect(launchBrowser({ launch })).rejects.toMatchObject({ + code: 'EBINFAIL', + }); + }); +}); + +describe('collectVitals', () => { + test('injects the script before navigating, then harvests and closes', async () => { + const { page, calls } = fakePage({ + harvested: { + LCP: { + value: 2431.2, + rating: 'good', + attribution: { element: 'img.hero' }, + }, + }, + }); + const browser = { newPage: jest.fn(async () => page) }; + + const result = await collectVitals( + browser, + '/* web-vitals iife */', + 'http://localhost:8888/', + { settleMs: 1, timeoutMs: 5000 } + ); + + expect(calls[0][0]).toBe('evaluateOnNewDocument'); + expect(calls[0][1]).toContain('/* web-vitals iife */'); + expect(calls[1]).toEqual([ + 'goto', + 'http://localhost:8888/', + { waitUntil: 'networkidle2', timeout: 5000 }, + ]); + expect(calls[calls.length - 1][0]).toBe('close'); + + expect(result.metrics.LCP).toEqual({ value: 2431.2, rating: 'good' }); + expect(result.attribution.lcpElement).toBe('img.hero'); + }); + + test('a goto rejection propagates, but the page is still closed', async () => { + const gotoError = new Error('net::ERR_CONNECTION_REFUSED'); + const { page, calls } = fakePage({ gotoError }); + const browser = { newPage: jest.fn(async () => page) }; + + await expect( + collectVitals(browser, '/* iife */', 'http://localhost:8888/', { + settleMs: 1, + }) + ).rejects.toThrow('net::ERR_CONNECTION_REFUSED'); + + expect(calls[calls.length - 1][0]).toBe('close'); + }); +}); + +describe('buildResult', () => { + test('maps present metrics and defaults missing ones to null', () => { + const result = buildResult({ + LCP: { + value: 2000, + rating: 'good', + attribution: { element: 'img' }, + }, + CLS: { + value: 0.05, + rating: 'good', + attribution: { largestShiftTarget: 'div.banner' }, + }, + }); + expect(result.metrics).toEqual({ + LCP: { value: 2000, rating: 'good' }, + CLS: { value: 0.05, rating: 'good' }, + INP: null, + FCP: null, + TTFB: null, + }); + expect(result.attribution).toEqual({ + lcpElement: 'img', + clsSources: ['div.banner'], + inpTarget: null, + }); + }); + + test('handles a completely empty harvest', () => { + const result = buildResult({}); + expect(Object.values(result.metrics).every((m) => m === null)).toBe( + true + ); + expect(result.attribution).toEqual({ + lcpElement: null, + clsSources: [], + inpTarget: null, + }); + }); +}); diff --git a/node-packages/wp-tooling/tests/perf/config.test.js b/node-packages/wp-tooling/tests/perf/config.test.js new file mode 100644 index 0000000..470714c --- /dev/null +++ b/node-packages/wp-tooling/tests/perf/config.test.js @@ -0,0 +1,141 @@ +'use strict'; + +const path = require('path'); + +const { + resolveConfig, + mergeConfig, + DEFAULTS, +} = require('../../src/perf/config'); +const { RunnerError } = require('../../src/perf/errors'); + +const FIXTURES = path.join(__dirname, 'fixtures'); + +/** + * Run `fn` and return whatever it throws (or null). Keeps assertions out of a + * catch block, which `jest/no-conditional-expect` forbids. + * + * @param {Function} fn Function expected to throw. + * @return {Error|null} The thrown error, or null if it did not throw. + */ +function grab(fn) { + try { + fn(); + } catch (err) { + return err; + } + return null; +} + +describe('resolveConfig — default path', () => { + test('reads urls and every section from the default .perfrc.json', () => { + const r = resolveConfig({ cwd: FIXTURES }); + expect(r.urls).toEqual([ + 'http://localhost:8888/', + 'http://localhost:8888/?p=1', + ]); + expect(r.configPath).toBe(path.join(FIXTURES, '.perfrc.json')); + expect(r.config.lighthouse.enabled).toBe(true); + expect(r.config.server.enabled).toBe(true); + expect(r.config.server.shim).toBe('server-profile.php'); + }); +}); + +describe('resolveConfig — --config', () => { + test('resolves a custom --config path relative to cwd', () => { + const r = resolveConfig({ + cwd: FIXTURES, + configPath: 'partial.perfrc.json', + }); + expect(r.configPath).toBe(path.join(FIXTURES, 'partial.perfrc.json')); + expect(r.urls).toEqual(['http://localhost:8888/']); + }); +}); + +describe('resolveConfig — --url precedence', () => { + test('--url replaces the config urls[] entirely, other sections still come from the file', () => { + const r = resolveConfig({ + cwd: FIXTURES, + urls: ['http://example.test/'], + }); + expect(r.urls).toEqual(['http://example.test/']); + expect(r.config.urls).toEqual(['http://example.test/']); + // Non-urls sections still come from the config file. + expect(r.config.server.enabled).toBe(true); + }); + + test('a missing config file + --url falls back to defaults with configPath null', () => { + const r = resolveConfig({ + cwd: FIXTURES, + configPath: 'does-not-exist.json', + urls: ['http://example.test/'], + }); + expect(r.configPath).toBeNull(); + expect(r.urls).toEqual(['http://example.test/']); + expect(r.config.lighthouse).toEqual(DEFAULTS.lighthouse); + expect(r.config.server.enabled).toBe(false); + }); +}); + +describe('resolveConfig — errors', () => { + test('a missing config with no --url throws ENOURLS with the install hint', () => { + const err = grab(() => + resolveConfig({ cwd: FIXTURES, configPath: 'does-not-exist.json' }) + ); + expect(err).toBeInstanceOf(RunnerError); + expect(err.code).toBe('ENOURLS'); + expect(err.message).toMatch(/wp-tooling add setup\/perf/); + }); + + test('empty urls with no --url throws ENOURLS', () => { + const err = grab(() => + resolveConfig({ + cwd: FIXTURES, + configPath: '.perfrc.no-urls.json', + }) + ); + expect(err.code).toBe('ENOURLS'); + }); + + test('malformed JSON throws EBADJSON even when --url is given', () => { + const err = grab(() => + resolveConfig({ + cwd: FIXTURES, + configPath: 'malformed.perfrc.json', + urls: ['http://example.test/'], + }) + ); + expect(err).toBeInstanceOf(RunnerError); + expect(err.code).toBe('EBADJSON'); + }); +}); + +describe('mergeConfig', () => { + test('merges a partial section over its defaults without touching others', () => { + const merged = mergeConfig({ + urls: ['http://x/'], + lighthouse: { enabled: false }, + }); + expect(merged.lighthouse).toEqual({ + enabled: false, + categories: DEFAULTS.lighthouse.categories, + topAudits: DEFAULTS.lighthouse.topAudits, + }); + expect(merged.webVitals).toEqual(DEFAULTS.webVitals); + expect(merged.server).toEqual(DEFAULTS.server); + expect(merged.thresholds).toEqual(DEFAULTS.thresholds); + }); + + test('tolerates null/undefined/non-object input', () => { + expect(mergeConfig(null)).toEqual({ ...DEFAULTS }); + expect(mergeConfig(undefined)).toEqual({ ...DEFAULTS }); + expect(mergeConfig('nope').urls).toEqual([]); + }); + + test('filters non-string / empty entries out of urls', () => { + expect(mergeConfig({ urls: ['a', '', 42, null, 'b'] }).urls).toEqual([ + 'a', + 'b', + ]); + }); +}); diff --git a/node-packages/wp-tooling/tests/perf/fixtures/.perfrc.json b/node-packages/wp-tooling/tests/perf/fixtures/.perfrc.json new file mode 100644 index 0000000..2575122 --- /dev/null +++ b/node-packages/wp-tooling/tests/perf/fixtures/.perfrc.json @@ -0,0 +1,12 @@ +{ + "urls": ["http://localhost:8888/", "http://localhost:8888/?p=1"], + "webVitals": { "settleMs": 100, "timeoutMs": 5000, "chromeArgs": ["--no-sandbox"] }, + "lighthouse": { "enabled": true, "categories": ["performance"], "topAudits": 5 }, + "server": { + "enabled": true, + "command": ["npx", "wp-env", "run", "cli", "--env-cwd=wp-content/plugins/dummy-plugin", "--", "wp"], + "shim": "server-profile.php", + "top": 15 + }, + "thresholds": { "cwv": "poor", "lighthousePerformance": 0.5 } +} diff --git a/node-packages/wp-tooling/tests/perf/fixtures/.perfrc.no-urls.json b/node-packages/wp-tooling/tests/perf/fixtures/.perfrc.no-urls.json new file mode 100644 index 0000000..24b45c7 --- /dev/null +++ b/node-packages/wp-tooling/tests/perf/fixtures/.perfrc.no-urls.json @@ -0,0 +1,3 @@ +{ + "urls": [] +} diff --git a/node-packages/wp-tooling/tests/perf/fixtures/lighthouse-lhr.json b/node-packages/wp-tooling/tests/perf/fixtures/lighthouse-lhr.json new file mode 100644 index 0000000..bf315c8 --- /dev/null +++ b/node-packages/wp-tooling/tests/perf/fixtures/lighthouse-lhr.json @@ -0,0 +1,61 @@ +{ + "lighthouseVersion": "13.4.0", + "requestedUrl": "http://localhost:8888/", + "finalUrl": "http://localhost:8888/", + "categories": { + "performance": { + "id": "performance", + "title": "Performance", + "score": 0.87 + } + }, + "audits": { + "render-blocking-resources": { + "id": "render-blocking-resources", + "title": "Eliminate render-blocking resources", + "score": 0.4, + "scoreDisplayMode": "numeric", + "displayValue": "Potential savings of 300 ms" + }, + "unused-css-rules": { + "id": "unused-css-rules", + "title": "Reduce unused CSS", + "score": 0.72, + "scoreDisplayMode": "numeric", + "displayValue": "Potential savings of 40 KiB" + }, + "largest-contentful-paint": { + "id": "largest-contentful-paint", + "title": "Largest Contentful Paint", + "score": 0.91, + "scoreDisplayMode": "numeric", + "displayValue": "2.4 s" + }, + "first-contentful-paint": { + "id": "first-contentful-paint", + "title": "First Contentful Paint", + "score": 0.96, + "scoreDisplayMode": "numeric", + "displayValue": "0.8 s" + }, + "uses-long-cache-ttl": { + "id": "uses-long-cache-ttl", + "title": "Uses efficient cache policy on static assets", + "score": 0.2, + "scoreDisplayMode": "numeric", + "displayValue": "3 resources found" + }, + "viewport": { + "id": "viewport", + "title": "Has a viewport meta tag", + "score": 1, + "scoreDisplayMode": "binary" + }, + "third-party-summary": { + "id": "third-party-summary", + "title": "Minimize third-party usage", + "score": null, + "scoreDisplayMode": "informative" + } + } +} diff --git a/node-packages/wp-tooling/tests/perf/fixtures/malformed.perfrc.json b/node-packages/wp-tooling/tests/perf/fixtures/malformed.perfrc.json new file mode 100644 index 0000000..a301932 --- /dev/null +++ b/node-packages/wp-tooling/tests/perf/fixtures/malformed.perfrc.json @@ -0,0 +1,2 @@ +{ "urls": [ "http://localhost:8888/" ] // trailing comment makes this invalid JSON +} diff --git a/node-packages/wp-tooling/tests/perf/fixtures/partial.perfrc.json b/node-packages/wp-tooling/tests/perf/fixtures/partial.perfrc.json new file mode 100644 index 0000000..9ddac86 --- /dev/null +++ b/node-packages/wp-tooling/tests/perf/fixtures/partial.perfrc.json @@ -0,0 +1,4 @@ +{ + "urls": ["http://localhost:8888/"], + "lighthouse": { "enabled": false } +} diff --git a/node-packages/wp-tooling/tests/perf/fixtures/web-vitals.attribution.iife.js b/node-packages/wp-tooling/tests/perf/fixtures/web-vitals.attribution.iife.js new file mode 100644 index 0000000..e332557 --- /dev/null +++ b/node-packages/wp-tooling/tests/perf/fixtures/web-vitals.attribution.iife.js @@ -0,0 +1,6 @@ +/** + * Fixture stand-in for the web-vitals attribution IIFE build — content is + * never executed in these tests (collect-vitals.js is mocked), it only + * needs to be a real, readable file for fs.readFileSync. + */ +globalThis.webVitals = {}; diff --git a/node-packages/wp-tooling/tests/perf/fixtures/xhprof.json b/node-packages/wp-tooling/tests/perf/fixtures/xhprof.json new file mode 100644 index 0000000..2307483 --- /dev/null +++ b/node-packages/wp-tooling/tests/perf/fixtures/xhprof.json @@ -0,0 +1,5 @@ +{ + "WP_Query::get_posts": { "ct": 3, "wt": 41200, "cpu": 38000, "mu": 1048576, "pmu": 1148576 }, + "WPDB::query": { "ct": 12, "wt": 18500, "cpu": 17000, "mu": 65536, "pmu": 98304 }, + "the_content": { "ct": 1, "wt": 4200, "cpu": 4000, "mu": 8192, "pmu": 16384 } +} diff --git a/node-packages/wp-tooling/tests/perf/lighthouse.test.js b/node-packages/wp-tooling/tests/perf/lighthouse.test.js new file mode 100644 index 0000000..6b7a310 --- /dev/null +++ b/node-packages/wp-tooling/tests/perf/lighthouse.test.js @@ -0,0 +1,91 @@ +'use strict'; + +jest.mock('child_process'); + +const { execFileSync } = require('child_process'); +const { runLighthouse, buildArgs } = require('../../src/perf/lighthouse'); + +const BIN_OBJ = { command: 'lighthouse', args: [] }; +const LIGHTHOUSE_CFG = { categories: ['performance'] }; + +/** + * Run `fn` and return whatever it throws (or null). + * + * @param {Function} fn Function expected to throw. + * @return {Error|null} The thrown error, or null if it did not throw. + */ +function grab(fn) { + try { + fn(); + } catch (err) { + return err; + } + return null; +} + +describe('buildArgs', () => { + test('builds the argument vector for one URL', () => { + expect(buildArgs(BIN_OBJ, 'http://x/', LIGHTHOUSE_CFG)).toEqual([ + 'http://x/', + '--output=json', + '--output-path=stdout', + '--only-categories=performance', + '--quiet', + '--chrome-flags=--headless=new --no-sandbox', + ]); + }); + + test('joins multiple categories', () => { + const args = buildArgs(BIN_OBJ, 'http://x/', { + categories: ['performance', 'accessibility'], + }); + expect(args).toContain('--only-categories=performance,accessibility'); + }); +}); + +describe('runLighthouse', () => { + test('passes CHROME_PATH when a chromePath is given', () => { + execFileSync.mockReturnValue(JSON.stringify({ categories: {} })); + runLighthouse(BIN_OBJ, 'http://x/', LIGHTHOUSE_CFG, { + chromePath: '/path/to/chrome', + }); + const [, , opts] = execFileSync.mock.calls[0]; + expect(opts.env.CHROME_PATH).toBe('/path/to/chrome'); + }); + + test('leaves env untouched when no chromePath is given', () => { + execFileSync.mockReturnValue(JSON.stringify({ categories: {} })); + runLighthouse(BIN_OBJ, 'http://x/', LIGHTHOUSE_CFG, {}); + const [, , opts] = execFileSync.mock.calls[0]; + expect(opts.env).toBe(process.env); + }); + + test('returns the parsed LHR on success', () => { + execFileSync.mockReturnValue( + JSON.stringify({ categories: { performance: { score: 0.9 } } }) + ); + const lhr = runLighthouse(BIN_OBJ, 'http://x/', LIGHTHOUSE_CFG); + expect(lhr.categories.performance.score).toBe(0.9); + }); + + test('throws RunnerError EBINFAIL when the binary fails to run', () => { + execFileSync.mockImplementation(() => { + const err = new Error('boom'); + err.stderr = 'Chrome crashed'; + throw err; + }); + const err = grab(() => + runLighthouse(BIN_OBJ, 'http://x/', LIGHTHOUSE_CFG) + ); + expect(err.code).toBe('EBINFAIL'); + expect(err.message).toMatch(/Chrome crashed/); + }); + + test('throws RunnerError EBADJSON on unparseable output', () => { + execFileSync.mockReturnValue('not json at all'); + const err = grab(() => + runLighthouse(BIN_OBJ, 'http://x/', LIGHTHOUSE_CFG) + ); + expect(err.code).toBe('EBADJSON'); + }); +}); diff --git a/node-packages/wp-tooling/tests/perf/normalize.test.js b/node-packages/wp-tooling/tests/perf/normalize.test.js new file mode 100644 index 0000000..563fcc7 --- /dev/null +++ b/node-packages/wp-tooling/tests/perf/normalize.test.js @@ -0,0 +1,370 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const { + normalizePerf, + rateMetric, + extractLighthouse, + normalizeServer, + SERVER_FIDELITY_NOTE, +} = require('../../src/perf/normalize'); + +const LHR = JSON.parse( + fs.readFileSync( + path.join(__dirname, 'fixtures', 'lighthouse-lhr.json'), + 'utf8' + ) +); +const XHPROF = JSON.parse( + fs.readFileSync(path.join(__dirname, 'fixtures', 'xhprof.json'), 'utf8') +); + +describe('rateMetric', () => { + test('rates LCP against its band', () => { + expect(rateMetric('LCP', 2000)).toBe('good'); + expect(rateMetric('LCP', 3000)).toBe('needs-improvement'); + expect(rateMetric('LCP', 5000)).toBe('poor'); + }); + + test('rates CLS against its (unitless) band', () => { + expect(rateMetric('CLS', 0.05)).toBe('good'); + expect(rateMetric('CLS', 0.2)).toBe('needs-improvement'); + expect(rateMetric('CLS', 0.4)).toBe('poor'); + }); + + test('defaults to good for an unknown metric name', () => { + expect(rateMetric('BOGUS', 999999)).toBe('good'); + }); +}); + +describe('extractLighthouse', () => { + test('returns null for a missing or malformed LHR', () => { + expect(extractLighthouse(null)).toBeNull(); + expect(extractLighthouse('nope')).toBeNull(); + }); + + test('extracts category scores and the top failing audits, ascending by score', () => { + const extracted = extractLighthouse(LHR); + expect(extracted.scores).toEqual({ performance: 0.87 }); + expect(extracted.audits.map((a) => a.id)).toEqual([ + 'uses-long-cache-ttl', + 'render-blocking-resources', + 'unused-css-rules', + ]); + expect(extracted.audits[0]).toEqual({ + id: 'uses-long-cache-ttl', + title: 'Uses efficient cache policy on static assets', + score: 0.2, + displayValue: '3 resources found', + }); + }); + + test('excludes passing (>= 0.9) and non-numeric-score audits', () => { + const extracted = extractLighthouse(LHR); + const ids = extracted.audits.map((a) => a.id); + expect(ids).not.toContain('viewport'); + expect(ids).not.toContain('largest-contentful-paint'); + expect(ids).not.toContain('third-party-summary'); + }); + + test('respects a custom topAudits cap', () => { + expect(extractLighthouse(LHR, { topAudits: 1 })).toEqual({ + scores: { performance: 0.87 }, + audits: [ + { + id: 'uses-long-cache-ttl', + title: 'Uses efficient cache policy on static assets', + score: 0.2, + displayValue: '3 resources found', + }, + ], + }); + }); +}); + +describe('normalizeServer', () => { + test('returns null when the layer is disabled', () => { + expect(normalizeServer(null)).toBeNull(); + }); + + test('maps a bare function map to top[], converting µs to ms and preserving order', () => { + const normalized = normalizeServer({ + data: XHPROF, + diagnostic: '[server-profile] path=/ resolved=home object_id=0', + error: null, + }); + expect(normalized.top.map((f) => f.fn)).toEqual([ + 'WP_Query::get_posts', + 'WPDB::query', + 'the_content', + ]); + expect(normalized.top[0]).toEqual({ + fn: 'WP_Query::get_posts', + calls: 3, + wallMs: 41.2, + cpuMs: 38, + memBytes: 1048576, + peakMemBytes: 1148576, + }); + expect(normalized.note).toBe(SERVER_FIDELITY_NOTE); + expect(normalized.diagnostic).toBe( + '[server-profile] path=/ resolved=home object_id=0' + ); + }); + + test('an empty array (no backend) is not an error but gets a guidance note', () => { + const normalized = normalizeServer({ + data: [], + diagnostic: null, + error: null, + }); + expect(normalized.top).toEqual([]); + expect(normalized.error).toBeNull(); + expect(normalized.note).toMatch(/xhprof\/tideways_xhprof/); + }); + + test('an invocation failure carries the error and skips the empty-backend guidance', () => { + const normalized = normalizeServer({ + data: null, + diagnostic: null, + error: 'no parseable output (exit code 255)', + }); + expect(normalized.top).toEqual([]); + expect(normalized.error).toBe('no parseable output (exit code 255)'); + expect(normalized.note).toBe(SERVER_FIDELITY_NOTE); + }); +}); + +describe('normalizePerf', () => { + test('a scan error counts towards failedUrls, not issues, with empty metrics', () => { + const report = normalizePerf([ + { + url: 'http://localhost:8888/', + scanError: 'net::ERR_CONNECTION_REFUSED', + vitals: null, + lighthouse: null, + server: null, + notes: [], + }, + ]); + expect(report.summary).toEqual({ + urls: 1, + passedUrls: 0, + failedUrls: 1, + issues: 0, + worst: null, + }); + const [result] = report.results; + expect(result.metrics).toEqual({ + LCP: null, + CLS: null, + INP: null, + FCP: null, + TTFB: null, + }); + expect(result.assessment).toEqual([]); + }); + + test('a clean URL with good metrics counts as passed with zero issues', () => { + const report = normalizePerf([ + { + url: 'http://localhost:8888/', + scanError: null, + vitals: { + metrics: { + LCP: { value: 2000, rating: 'good' }, + CLS: { value: 0.02, rating: 'good' }, + FCP: { value: 800, rating: 'good' }, + TTFB: { value: 200, rating: 'good' }, + }, + attribution: { + lcpElement: null, + clsSources: [], + inpTarget: null, + }, + }, + lighthouse: null, + server: null, + notes: [], + }, + ]); + expect(report.summary.passedUrls).toBe(1); + expect(report.summary.issues).toBe(0); + expect(report.summary.worst).toBeNull(); + expect(report.results[0].metrics.INP).toBeNull(); + }); + + test('INP is always forced to null even if the raw vitals carried a value', () => { + const report = normalizePerf([ + { + url: 'http://localhost:8888/', + scanError: null, + vitals: { + metrics: { INP: { value: 150, rating: 'good' } }, + attribution: {}, + }, + lighthouse: null, + server: null, + notes: [], + }, + ]); + expect(report.results[0].metrics.INP).toBeNull(); + expect(report.results[0].assessment).toContain( + 'INP: not measurable in lab (no interaction performed)' + ); + }); + + test('a poor metric is counted as an issue under the default (poor) threshold mode', () => { + const report = normalizePerf( + [ + { + url: 'http://localhost:8888/', + scanError: null, + vitals: { + metrics: { LCP: { value: 5000, rating: 'poor' } }, + attribution: {}, + }, + lighthouse: null, + server: null, + notes: [], + }, + ], + { thresholds: { cwv: 'poor', lighthousePerformance: 0.5 } } + ); + expect(report.summary.issues).toBe(1); + expect(report.summary.passedUrls).toBe(0); + expect(report.summary.worst).toEqual({ + metric: 'LCP', + url: 'http://localhost:8888/', + value: 5000, + rating: 'poor', + }); + }); + + test('needs-improvement only counts as an issue under the needs-improvement threshold mode', () => { + const raw = [ + { + url: 'http://localhost:8888/', + scanError: null, + vitals: { + metrics: { + LCP: { value: 3000, rating: 'needs-improvement' }, + }, + attribution: {}, + }, + lighthouse: null, + server: null, + notes: [], + }, + ]; + expect( + normalizePerf(raw, { thresholds: { cwv: 'poor' } }).summary.issues + ).toBe(0); + expect( + normalizePerf(raw, { thresholds: { cwv: 'needs-improvement' } }) + .summary.issues + ).toBe(1); + expect( + normalizePerf(raw, { thresholds: { cwv: 'never' } }).summary.issues + ).toBe(0); + }); + + test('worst is chosen by severity (value / poor-threshold) across URLs and metrics', () => { + const report = normalizePerf([ + { + url: 'http://a/', + scanError: null, + // LCP poor threshold is 4000ms; 4400 / 4000 = 1.1 + vitals: { + metrics: { LCP: { value: 4400, rating: 'poor' } }, + attribution: {}, + }, + lighthouse: null, + server: null, + notes: [], + }, + { + url: 'http://b/', + scanError: null, + // CLS poor threshold is 0.25; 0.4 / 0.25 = 1.6 -- worse. + vitals: { + metrics: { CLS: { value: 0.4, rating: 'poor' } }, + attribution: {}, + }, + lighthouse: null, + server: null, + notes: [], + }, + ]); + expect(report.summary.worst).toEqual({ + metric: 'CLS', + url: 'http://b/', + value: 0.4, + rating: 'poor', + }); + }); + + test('a lighthouse performance score below threshold is an issue', () => { + const report = normalizePerf( + [ + { + url: 'http://localhost:8888/', + scanError: null, + vitals: { + metrics: { LCP: { value: 1000, rating: 'good' } }, + attribution: {}, + }, + lighthouse: LHR, + server: null, + notes: [], + }, + ], + { thresholds: { cwv: 'poor', lighthousePerformance: 0.9 } } + ); + expect(report.summary.issues).toBe(1); + expect(report.results[0].lighthouse.scores.performance).toBe(0.87); + expect( + report.results[0].assessment.some((line) => + line.includes('below threshold') + ) + ).toBe(true); + }); + + test('the server section threads through normalizeServer unchanged in shape', () => { + const report = normalizePerf([ + { + url: 'http://localhost:8888/', + scanError: null, + vitals: { + metrics: { LCP: { value: 1000, rating: 'good' } }, + attribution: {}, + }, + lighthouse: null, + server: { data: XHPROF, diagnostic: null, error: null }, + notes: [], + }, + ]); + expect(report.results[0].server.top[0].fn).toBe('WP_Query::get_posts'); + }); + + test('carries per-URL notes through untouched', () => { + const report = normalizePerf([ + { + url: 'http://localhost:8888/', + scanError: null, + vitals: { + metrics: { LCP: { value: 1000, rating: 'good' } }, + attribution: {}, + }, + lighthouse: null, + server: null, + notes: ['lighthouse: failed — Chrome crashed'], + }, + ]); + expect(report.results[0].notes).toEqual([ + 'lighthouse: failed — Chrome crashed', + ]); + }); +}); diff --git a/node-packages/wp-tooling/tests/perf/resolve-module.test.js b/node-packages/wp-tooling/tests/perf/resolve-module.test.js new file mode 100644 index 0000000..86a75f5 --- /dev/null +++ b/node-packages/wp-tooling/tests/perf/resolve-module.test.js @@ -0,0 +1,192 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + findModuleDir, + resolveModuleDir, + resolveModuleFile, + requireModule, + detectModule, +} = require('../../src/perf/resolve-module'); + +const MODULE_NAME = 'wp-tooling-perf-fixture-module'; + +function tmpTree() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'perf-module-')); +} + +function makeModule(dir, name, { version = '1.2.3', main } = {}) { + const modDir = path.join(dir, 'node_modules', name); + fs.mkdirSync(modDir, { recursive: true }); + const pkg = { name, version }; + if (main) { + pkg.main = main; + } + fs.writeFileSync(path.join(modDir, 'package.json'), JSON.stringify(pkg)); + return modDir; +} + +describe('findModuleDir', () => { + let root; + + afterEach(() => { + if (root) { + fs.rmSync(root, { recursive: true, force: true }); + root = null; + } + }); + + test('finds a directly installed module as local', () => { + root = tmpTree(); + const dir = makeModule(root, MODULE_NAME); + expect(findModuleDir(MODULE_NAME, root)).toEqual({ + dir, + source: 'local', + }); + }); + + test('finds a hoisted module in an ancestor as hoisted', () => { + root = tmpTree(); + const dir = makeModule(root, MODULE_NAME); + const child = path.join(root, 'packages', 'app'); + fs.mkdirSync(child, { recursive: true }); + expect(findModuleDir(MODULE_NAME, child)).toEqual({ + dir, + source: 'hoisted', + }); + }); + + test('returns null when no installed copy exists', () => { + root = tmpTree(); + expect(findModuleDir('definitely-not-installed-xyz', root)).toBeNull(); + }); +}); + +describe('resolveModuleDir / resolveModuleFile', () => { + let root; + + afterEach(() => { + if (root) { + fs.rmSync(root, { recursive: true, force: true }); + root = null; + } + }); + + test('resolves the module directory when installed', () => { + root = tmpTree(); + const dir = makeModule(root, MODULE_NAME); + expect(resolveModuleDir(MODULE_NAME, { cwd: root })).toBe(dir); + }); + + test('returns null when not installed', () => { + root = tmpTree(); + expect( + resolveModuleDir('definitely-not-installed-xyz', { cwd: root }) + ).toBeNull(); + }); + + test('resolves a file inside the module when it exists', () => { + root = tmpTree(); + const dir = makeModule(root, MODULE_NAME); + fs.mkdirSync(path.join(dir, 'dist'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'dist', 'thing.js'), '// noop'); + expect( + resolveModuleFile(MODULE_NAME, 'dist/thing.js', { cwd: root }) + ).toBe(path.join(dir, 'dist', 'thing.js')); + }); + + test('returns null when the file does not exist inside an installed module', () => { + root = tmpTree(); + makeModule(root, MODULE_NAME); + expect( + resolveModuleFile(MODULE_NAME, 'dist/missing.js', { cwd: root }) + ).toBeNull(); + }); + + test('returns null when the module itself is not installed', () => { + root = tmpTree(); + expect( + resolveModuleFile('definitely-not-installed-xyz', 'dist/thing.js', { + cwd: root, + }) + ).toBeNull(); + }); +}); + +describe('requireModule', () => { + let root; + + afterEach(() => { + if (root) { + fs.rmSync(root, { recursive: true, force: true }); + root = null; + } + }); + + test('requires and returns the resolved module', () => { + root = tmpTree(); + const dir = makeModule(root, MODULE_NAME, { main: 'index.js' }); + fs.writeFileSync( + path.join(dir, 'index.js'), + 'module.exports = { marker: "fixture-loaded" };' + ); + expect(requireModule(MODULE_NAME, { cwd: root })).toEqual({ + marker: 'fixture-loaded', + }); + }); + + test('returns null when not installed', () => { + root = tmpTree(); + expect( + requireModule('definitely-not-installed-xyz', { cwd: root }) + ).toBeNull(); + }); +}); + +describe('detectModule', () => { + let root; + + afterEach(() => { + if (root) { + fs.rmSync(root, { recursive: true, force: true }); + root = null; + } + }); + + test('reports available with the declared version', () => { + root = tmpTree(); + const dir = makeModule(root, MODULE_NAME, { version: '9.9.9' }); + expect(detectModule(MODULE_NAME, { cwd: root })).toEqual({ + available: true, + version: '9.9.9', + dir, + source: 'local', + }); + }); + + test('reports unavailable when not installed', () => { + root = tmpTree(); + expect( + detectModule('definitely-not-installed-xyz', { cwd: root }) + ).toEqual({ + available: false, + version: null, + dir: null, + source: null, + }); + }); + + test('tolerates a package.json with no version field', () => { + root = tmpTree(); + const modDir = path.join(root, 'node_modules', MODULE_NAME); + fs.mkdirSync(modDir, { recursive: true }); + fs.writeFileSync( + path.join(modDir, 'package.json'), + JSON.stringify({ name: MODULE_NAME }) + ); + expect(detectModule(MODULE_NAME, { cwd: root }).version).toBeNull(); + }); +}); diff --git a/node-packages/wp-tooling/tests/perf/server-profile.test.js b/node-packages/wp-tooling/tests/perf/server-profile.test.js new file mode 100644 index 0000000..d4e9921 --- /dev/null +++ b/node-packages/wp-tooling/tests/perf/server-profile.test.js @@ -0,0 +1,126 @@ +'use strict'; + +jest.mock('child_process'); + +const { spawnSync } = require('child_process'); +const { runServerProfile, splitUrl } = require('../../src/perf/server-profile'); + +const SERVER = { + command: [ + 'npx', + 'wp-env', + 'run', + 'cli', + '--env-cwd=wp-content/plugins/dummy-plugin', + '--', + 'wp', + ], + shim: 'server-profile.php', + top: 15, +}; + +describe('splitUrl', () => { + test('splits origin from path + query', () => { + expect(splitUrl('http://localhost:8765/?p=1')).toEqual({ + origin: 'http://localhost:8765', + pathAndQuery: '/?p=1', + }); + }); + + test('a bare root path has no query', () => { + expect(splitUrl('http://localhost:8765/')).toEqual({ + origin: 'http://localhost:8765', + pathAndQuery: '/', + }); + }); +}); + +describe('runServerProfile', () => { + afterEach(() => { + spawnSync.mockReset(); + }); + + test('spawns the WP-CLI command with the documented argument order', () => { + spawnSync.mockReturnValue({ stdout: '{}', stderr: '', status: 0 }); + runServerProfile(SERVER, 'http://localhost:8765/?p=1', { + cwd: '/project', + }); + const [command, args, opts] = spawnSync.mock.calls[0]; + expect(command).toBe('npx'); + expect(args).toEqual([ + 'wp-env', + 'run', + 'cli', + '--env-cwd=wp-content/plugins/dummy-plugin', + '--', + 'wp', + 'eval-file', + 'server-profile.php', + '/?p=1', + '15', + '--url=http://localhost:8765', + ]); + expect(opts.cwd).toBe('/project'); + }); + + test('parses a bare function map and captures the STDERR diagnostic', () => { + spawnSync.mockReturnValue({ + stdout: '{"WP_Query::get_posts":{"ct":3,"wt":41200,"cpu":38000,"mu":1048576,"pmu":1148576}}\n', + stderr: '[server-profile] path=/?p=1 resolved=singular object_id=1\n', + status: 0, + }); + const result = runServerProfile(SERVER, 'http://localhost:8765/?p=1'); + expect(result.data).toEqual({ + 'WP_Query::get_posts': { + ct: 3, + wt: 41200, + cpu: 38000, + mu: 1048576, + pmu: 1148576, + }, + }); + expect(result.diagnostic).toBe( + '[server-profile] path=/?p=1 resolved=singular object_id=1' + ); + expect(result.error).toBeNull(); + }); + + test('an empty array means no profiling backend was loaded — not an error', () => { + spawnSync.mockReturnValue({ stdout: '[]\n', stderr: '', status: 0 }); + const result = runServerProfile(SERVER, 'http://localhost:8765/'); + expect(result.data).toEqual([]); + expect(result.error).toBeNull(); + }); + + test('tolerates a non-JSON preamble line before the JSON payload', () => { + spawnSync.mockReturnValue({ + stdout: 'Warning: something noisy\n{"fn":{"ct":1,"wt":1,"cpu":1,"mu":1,"pmu":1}}', + stderr: '', + status: 0, + }); + const result = runServerProfile(SERVER, 'http://localhost:8765/'); + expect(result.data.fn.ct).toBe(1); + }); + + test('degrades (never throws) on a spawn-level failure', () => { + spawnSync.mockReturnValue({ + error: new Error('spawn npx ENOENT'), + stdout: null, + stderr: null, + }); + const result = runServerProfile(SERVER, 'http://localhost:8765/'); + expect(result.data).toBeNull(); + expect(result.error).toMatch(/ENOENT/); + }); + + test('degrades (never throws) on unparseable output', () => { + spawnSync.mockReturnValue({ + stdout: 'PHP Fatal error: something exploded', + stderr: '', + status: 255, + }); + const result = runServerProfile(SERVER, 'http://localhost:8765/'); + expect(result.data).toBeNull(); + expect(result.error).toMatch(/no parseable output/); + }); +}); diff --git a/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js b/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js index 888d70b..fc3cbb0 100644 --- a/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js @@ -113,3 +113,95 @@ describe('ci/test-measure rendering', () => { expect(onYaml).toContain('run-a11y: true'); }); }); + +describe('setup/perf rendered config', () => { + it('renders valid JSON with default page paths and the server layer disabled', async () => { + const r = registry; + const target = makeTmpDir(); + await r.execute( + 'setup/perf', + { base_url: 'http://localhost:8888' }, + { cwd: target } + ); + const config = JSON.parse( + fs.readFileSync(path.join(target, '.perfrc.json'), 'utf8') + ); + expect(config.urls).toEqual([ + 'http://localhost:8888/', + 'http://localhost:8888/?p=1', + 'http://localhost:8888/?s=hello', + ]); + expect(config.lighthouse.enabled).toBe(true); + expect(config.server.enabled).toBe(false); + expect(config.server.command).toEqual([ + 'npx', + 'wp-env', + 'run', + 'cli', + '--env-cwd=.', + '--', + 'wp', + ]); + }); + + it('renders custom page paths, appends extra_page, and enables the server layer when server_env_cwd is given', async () => { + const r = registry; + const target = makeTmpDir(); + await r.execute( + 'setup/perf', + { + base_url: 'http://localhost:8765', + sample_page: '/hello-world/', + search_page: '/?s=wordpress', + extra_page: '/about/', + server_env_cwd: 'wp-content/plugins/dummy-plugin', + }, + { cwd: target } + ); + const config = JSON.parse( + fs.readFileSync(path.join(target, '.perfrc.json'), 'utf8') + ); + expect(config.urls).toEqual([ + 'http://localhost:8765/', + 'http://localhost:8765/hello-world/', + 'http://localhost:8765/?s=wordpress', + 'http://localhost:8765/about/', + ]); + expect(config.server.enabled).toBe(true); + expect(config.server.command).toEqual([ + 'npx', + 'wp-env', + 'run', + 'cli', + '--env-cwd=wp-content/plugins/dummy-plugin', + '--', + 'wp', + ]); + }); + + it('copies the server-profile.php shim verbatim (raw: true, no mustache rendering)', async () => { + const r = registry; + const target = makeTmpDir(); + await r.execute( + 'setup/perf', + { base_url: 'http://localhost:8888' }, + { cwd: target } + ); + const shim = fs.readFileSync( + path.join(target, 'server-profile.php'), + 'utf8' + ); + const source = fs.readFileSync( + path.join( + DEFAULTS_DIR, + 'setup', + 'perf', + 'templates', + 'server-profile.php' + ), + 'utf8' + ); + expect(shim).toBe(source); + expect(shim).toContain('\\rtCamp\\WPDevTools\\Support\\XHProfProfiler'); + }); +}); From 33f6c2184e55efde1630494a41d6bc87fc8b9ba1 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Wed, 15 Jul 2026 03:24:30 +0530 Subject: [PATCH 02/13] fix(perf): degrade on bad URL, skip Lighthouse on dead page, slim LHR retention - server-profile: catch malformed URL instead of throwing - run.js: skip Lighthouse when scanError set; extractLighthouse immediately after run; show server.error in text output; guard dry-run against string command - normalize.js: derive METRIC_NAMES from THRESHOLDS - collect-vitals.js: import METRIC_NAMES from normalize - scaffold: split server_enabled from server_env_cwd - test: de-flake dry-run assertion --- .claude/issues/wp-devtools-22-perf-runner.md | 12 ++- .../scaffolds/setup/perf/scaffold.json | 9 ++- .../perf/templates/.perfrc.json.mustache | 20 +---- .../wp-tooling/src/perf/collect-vitals.js | 4 +- .../wp-tooling/src/perf/normalize.js | 34 ++++---- node-packages/wp-tooling/src/perf/run.js | 81 ++++++++++++------- .../wp-tooling/src/perf/server-profile.js | 32 +++++--- .../wp-tooling/tests/perf/cli.test.js | 20 ++++- .../wp-tooling/tests/perf/normalize.test.js | 3 +- .../tests/perf/server-profile.test.js | 8 ++ .../tests/scaffolds/bundled-manifests.test.js | 33 +++++++- 11 files changed, 173 insertions(+), 83 deletions(-) diff --git a/.claude/issues/wp-devtools-22-perf-runner.md b/.claude/issues/wp-devtools-22-perf-runner.md index 962b0f4..8b0cfad 100644 --- a/.claude/issues/wp-devtools-22-perf-runner.md +++ b/.claude/issues/wp-devtools-22-perf-runner.md @@ -25,6 +25,14 @@ rtCamp/wp-devtools#22 asks for a `wp-tooling perf` command mirroring the shipped - [2026-07-14] Housed in wp-tooling (`src/perf/`), distributed via a new `setup/perf` scaffold, matching the `a11y`/`setup/pa11y` precedent — issue #22 defers the scaffold to a later task, but end-to-end verification against a real consumer needs the consumer-side wiring (deps, config, shim) to exist, so it ships in this PR rather than a follow-up. - [2026-07-14] `npm run check` on the branch point (`release/v1.0.0`) had two pre-existing failures unrelated to this task (`no-shadow` on `cap` + prettier wrapping in `src/init/index.js` and `tests/ui/selects.test.js`) — the same issue already fixed on the (still-open) a11y branch but never merged. Fixed here with the identical minimal rename (`cap` → `entry`) so `npm run check` is verifiable; documented rather than silently folded in. - [2026-07-14] Live end-to-end testing surfaced a real bug: puppeteer 25.x's `executablePath()` returns a `Promise`, not a string (older versions returned it synchronously). `run.js` now `await`s it — safe either way, since `await` on a non-Promise value just returns it. Caught only because verification used a real, current puppeteer install rather than a mocked one. +- [2026-07-15] `server-profile.js` was calling `new URL()` unguarded; a malformed URL (e.g. a scheme-less `base_url` typo) threw out of the per-URL loop and silently skipped every URL after it, contradicting the module's own "never throws" contract. Now wrapped, degrading that one result instead — re-verified live: a malformed `--url` alongside a working one now degrades just the bad one and still completes the good one, rather than aborting the run. +- [2026-07-15] Lighthouse needs the same network reachability as puppeteer, so `collectOne` was still running a full Lighthouse pass (up to its 180 s timeout) against a URL that had already failed to load, then discarding the result. Now skipped whenever `scanError` is set; the server layer still runs regardless, since it profiles over WP-CLI, not the browser — re-verified live: an unreachable URL alongside a working one now skips Lighthouse for the unreachable one while its server-profile data still comes back complete. +- [2026-07-15] A raw Lighthouse LHR (which can run multi-MB with the full audit tree) was being retained in `rawResults` for every URL until the whole run finished, only to be slimmed down at the very end. `collectOne` now calls `extractLighthouse` immediately after each successful run, so only the slim `{scores, audits}` shape is ever held; `normalizePerf` no longer re-extracts. +- [2026-07-15] Text-mode output showed `server top: none` identically for "no hotspots captured" and "the WP-CLI invocation itself failed" — now prints the error line too when the invocation failed. +- [2026-07-15] `runDryRun`'s server line called `.join(' ')` on `config.server.command` without checking it was an array — an uncaught crash on a malformed config, unlike the real run (which degrades). Guarded. +- [2026-07-15] Cleanup: consolidated three near-identical `EBINMISSING` throws in `run.js` into one helper; derived the metric-name list from `THRESHOLDS` (`normalize.js`) instead of repeating it at four call sites across `normalize.js`/`collect-vitals.js`; dropped the unused `REGISTER_SNIPPET` export; de-flaked a dry-run test that asserted a raw total `execFileSync` call count (filters for the `--version` probe specifically instead, since the total isn't isolation-safe across test files sharing the auto-mocked `child_process` module). +- [2026-07-15] Left as-is: the `MAX_BUFFER`/timeout constants duplicated across `lighthouse.js`/`server-profile.js`, and the empty-attribution literal repeated across `normalize.js`/`collect-vitals.js` — both match `a11y`'s own established convention and the project's "three similar lines beats a premature abstraction" rule. Also left the dynamic `require()` in `resolve-module.js` as-is — it has direct in-repo precedent (`src/cli/index.js`'s command auto-discovery), and reworking it to `require.resolve` + explicit `paths` would touch an already-verified module for a stylistic gain with no behaviour change. +- [2026-07-15] Split the scaffold's single `server_env_cwd` input (empty string doing double duty as both "disabled" and "no path") into `server_enabled` (boolean-string) + `server_env_cwd` (now defaults to `.`), so profiling from the WordPress root — a legitimate choice, not just a leftover default — is expressible; before, an empty `server_env_cwd` could only mean "disabled". The rendered `.perfrc.json` also dropped every section that already matches `config.js`'s built-in defaults (`webVitals`, `lighthouse`, `thresholds`, `server.shim`, `server.top`) — `mergeConfig` fills them in identically at read time, so keeping them out of the template removes a drift risk (a future default change no longer silently diverges between freshly-scaffolded and config-less projects). --- @@ -57,7 +65,7 @@ rtCamp/wp-devtools#22 asks for a `wp-tooling perf` command mirroring the shipped ```bash $ npm run check # eslint src tests && jest # ESLint: clean -# Tests: 761 passed, 57 suites +# Tests: 763 passed, 57 suites ``` Tested live end-to-end on two independent WordPress installs running under wp-env (Alpine `cli` containers, xhprof pecl-installed fresh into each), each with `@rtcamp/wp-tooling` installed as a dev dependency and `rtcamp/wp-dev-tools` wired in via composer for the server layer: @@ -71,6 +79,8 @@ Tested live end-to-end on two independent WordPress installs running under wp-en - Verified the scaffold never overwrites an existing `server-profile.php` (both installs already had one). - Found and fixed one real bug via this live testing: puppeteer 25.x's `executablePath()` returns a `Promise`, not a string — `run.js` was calling it synchronously. Fixed by awaiting it (safe for both sync and async puppeteer versions). +Re-verified live after the `server-profile.js`/`collectOne` fixes above (fresh scaffold apply with the new `server_enabled`/`server_env_cwd` inputs, xhprof reinstalled after the container was recreated): the leaner `.perfrc.json` (just `urls` + `server`) still resolves every omitted section correctly via `config.js`'s defaults on `--dry-run`; an unreachable URL alongside a working one now skips Lighthouse for the unreachable one while its server-profile data still comes back complete (proving the server layer's independence from the frontend layer); a malformed `--url` value degrades that one result (`server.error: 'Invalid URL'`) and the run still completes the next URL with full two-layer data, instead of aborting. + --- ## Open questions diff --git a/node-packages/wp-tooling/scaffolds/setup/perf/scaffold.json b/node-packages/wp-tooling/scaffolds/setup/perf/scaffold.json index 55d9e47..8396c18 100644 --- a/node-packages/wp-tooling/scaffolds/setup/perf/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/setup/perf/scaffold.json @@ -36,10 +36,15 @@ "description": "Optional path of one more page to test, appended to base_url. Omitted when empty; add further URLs directly in .perfrc.json.", "default": "" }, + { + "key": "server_enabled", + "description": "Enable the server-side xhprof layer (needs rtcamp/wp-dev-tools and the xhprof/tideways_xhprof PHP extension in the WP-CLI environment). One of true/false/yes/no.", + "default": "false" + }, { "key": "server_env_cwd", - "description": "Project path inside the WP-CLI environment (e.g. wp-content/plugins/my-plugin), as `wp-env run cli --env-cwd` expects it. Leave empty to disable the server-side xhprof layer.", - "default": "" + "description": "Project path inside the WP-CLI environment, as `wp-env run cli --env-cwd` expects it (e.g. wp-content/plugins/my-plugin, or `.` to profile from the WordPress root).", + "default": "." } ], "npm_dev_dependencies": { diff --git a/node-packages/wp-tooling/scaffolds/setup/perf/templates/.perfrc.json.mustache b/node-packages/wp-tooling/scaffolds/setup/perf/templates/.perfrc.json.mustache index 25a494d..945e4bb 100644 --- a/node-packages/wp-tooling/scaffolds/setup/perf/templates/.perfrc.json.mustache +++ b/node-packages/wp-tooling/scaffolds/setup/perf/templates/.perfrc.json.mustache @@ -5,24 +5,8 @@ "{{base_url}}{{search_page}}"{{#extra_page}}, "{{base_url}}{{extra_page}}"{{/extra_page}} ], - "webVitals": { - "settleMs": 3000, - "timeoutMs": 30000, - "chromeArgs": ["--no-sandbox"] - }, - "lighthouse": { - "enabled": true, - "categories": ["performance"], - "topAudits": 5 - }, "server": { - "enabled": {{#server_env_cwd}}true{{/server_env_cwd}}{{^server_env_cwd}}false{{/server_env_cwd}}, - "command": ["npx", "wp-env", "run", "cli", "--env-cwd={{^server_env_cwd}}.{{/server_env_cwd}}{{server_env_cwd}}", "--", "wp"], - "shim": "server-profile.php", - "top": 15 - }, - "thresholds": { - "cwv": "poor", - "lighthousePerformance": 0.5 + "enabled": {{#server_enabled}}true{{/server_enabled}}{{^server_enabled}}false{{/server_enabled}}, + "command": ["npx", "wp-env", "run", "cli", "--env-cwd={{server_env_cwd}}", "--", "wp"] } } diff --git a/node-packages/wp-tooling/src/perf/collect-vitals.js b/node-packages/wp-tooling/src/perf/collect-vitals.js index b38fc58..ca90fe6 100644 --- a/node-packages/wp-tooling/src/perf/collect-vitals.js +++ b/node-packages/wp-tooling/src/perf/collect-vitals.js @@ -16,8 +16,7 @@ 'use strict'; const { RunnerError } = require('./errors'); - -const METRIC_NAMES = ['LCP', 'CLS', 'INP', 'FCP', 'TTFB']; +const { METRIC_NAMES } = require('./normalize'); /** * Registers web-vitals attribution listeners and stashes the latest reading @@ -141,5 +140,4 @@ module.exports = { launchBrowser, collectVitals, buildResult, - REGISTER_SNIPPET, }; diff --git a/node-packages/wp-tooling/src/perf/normalize.js b/node-packages/wp-tooling/src/perf/normalize.js index e15cbce..7d03f17 100644 --- a/node-packages/wp-tooling/src/perf/normalize.js +++ b/node-packages/wp-tooling/src/perf/normalize.js @@ -26,6 +26,12 @@ const THRESHOLDS = { INP: [200, 500], }; +/** All lab metric names, derived from THRESHOLDS so the two stay in sync. */ +const METRIC_NAMES = Object.keys(THRESHOLDS); + +/** Metrics this collector actually measures — INP never is (no interaction is performed). */ +const MEASURABLE_METRIC_NAMES = METRIC_NAMES.filter((name) => name !== 'INP'); + /** Fidelity caveat surfaced on every server-profile result (epic task 03). */ const SERVER_FIDELITY_NOTE = 'profiled via `wp eval-file` in CLI context — representative for hot functions and N+1s, not a real HTTP request (routing/superglobals differ and it will not reflect web-server/opcache warmth).'; @@ -155,7 +161,7 @@ function isCwvIssue(rating, mode) { */ function buildAssessment(metrics, lighthouse, thresholds) { const lines = []; - for (const name of ['LCP', 'CLS', 'FCP', 'TTFB']) { + for (const name of MEASURABLE_METRIC_NAMES) { const m = metrics[name]; if (!m) { continue; @@ -187,11 +193,13 @@ function buildAssessment(metrics, lighthouse, thresholds) { /** * Normalise raw per-URL perf capture into the final two-layer report. * - * @param {Object[]} rawResults Raw per-URL capture (see `run.js`'s `collectOne`): - * `{ url, scanError, vitals, lighthouse, server, notes }`. + * `raw.lighthouse` is expected to already be the extracted `{scores, audits}` + * shape (or `null`), never a raw LHR. + * + * @param {Object[]} rawResults Raw per-URL capture: `{ url, scanError, + * vitals, lighthouse, server, notes }`. * @param {Object} [options] - * @param {Object} [options.thresholds] Resolved `thresholds` config section. - * @param {number} [options.topAudits=5] Max failing lighthouse audits per URL. + * @param {Object} [options.thresholds] Resolved `thresholds` config section. * @return {Object} Normalised report. */ function normalizePerf(rawResults, options = {}) { @@ -199,7 +207,6 @@ function normalizePerf(rawResults, options = {}) { cwv: 'poor', lighthousePerformance: 0.5, }; - const topAudits = options.topAudits || 5; const results = []; let passedUrls = 0; @@ -213,13 +220,9 @@ function normalizePerf(rawResults, options = {}) { results.push({ url: raw.url, scanError: raw.scanError, - metrics: { - LCP: null, - CLS: null, - INP: null, - FCP: null, - TTFB: null, - }, + metrics: Object.fromEntries( + METRIC_NAMES.map((name) => [name, null]) + ), attribution: { lcpElement: null, clsSources: [], @@ -237,7 +240,7 @@ function normalizePerf(rawResults, options = {}) { const metrics = {}; let urlIssues = 0; - for (const name of ['LCP', 'CLS', 'FCP', 'TTFB']) { + for (const name of MEASURABLE_METRIC_NAMES) { const m = vitals.metrics[name]; if (m && typeof m.value === 'number') { const rating = m.rating || rateMetric(name, m.value); @@ -263,7 +266,7 @@ function normalizePerf(rawResults, options = {}) { } metrics.INP = null; - const lighthouse = extractLighthouse(raw.lighthouse, { topAudits }); + const lighthouse = raw.lighthouse; if ( lighthouse && typeof lighthouse.scores.performance === 'number' && @@ -320,5 +323,6 @@ module.exports = { extractLighthouse, normalizeServer, THRESHOLDS, + METRIC_NAMES, SERVER_FIDELITY_NOTE, }; diff --git a/node-packages/wp-tooling/src/perf/run.js b/node-packages/wp-tooling/src/perf/run.js index 0e1304b..094e042 100644 --- a/node-packages/wp-tooling/src/perf/run.js +++ b/node-packages/wp-tooling/src/perf/run.js @@ -31,11 +31,29 @@ const { detectBin } = require('./resolve-bin'); const { launchBrowser, collectVitals } = require('./collect-vitals'); const { runLighthouse, BIN: LIGHTHOUSE_BIN } = require('./lighthouse'); const { runServerProfile } = require('./server-profile'); -const { normalizePerf } = require('./normalize'); +const { normalizePerf, extractLighthouse } = require('./normalize'); const INSTALL_HINT = 'wp-tooling add setup/perf'; const WEB_VITALS_DIST = 'dist/web-vitals.attribution.iife.js'; +/** + * Throw `EBINMISSING` with the standard install hint unless `condition` holds. + * + * @param {*} condition Truthy value required to proceed. + * @param {string} message Error message. + * @param {Object} [details] Extra `RunnerError` details (merged with `install`). + * @return {void} + * @throws {RunnerError} `EBINMISSING` when `condition` is falsy. + */ +function requireInstalled(condition, message, details = {}) { + if (!condition) { + throw new RunnerError('EBINMISSING', message, { + install: INSTALL_HINT, + ...details, + }); + } +} + /** * Run the perf layers against the config's (or `--url`'s) URLs and return * the normalized report. @@ -56,36 +74,28 @@ async function runPerf(options = {}) { }); const puppeteer = requireModule('puppeteer', { cwd }); - if (!puppeteer) { - throw new RunnerError( - 'EBINMISSING', - `puppeteer not found. Install it in the project (\`${INSTALL_HINT}\` sets it up).`, - { install: INSTALL_HINT } - ); - } + requireInstalled( + puppeteer, + `puppeteer not found. Install it in the project (\`${INSTALL_HINT}\` sets it up).` + ); const webVitalsFile = resolveModuleFile('web-vitals', WEB_VITALS_DIST, { cwd, }); - if (!webVitalsFile) { - throw new RunnerError( - 'EBINMISSING', - `web-vitals attribution build not found at node_modules/web-vitals/${WEB_VITALS_DIST}. Install it in the project (\`${INSTALL_HINT}\` sets it up).`, - { install: INSTALL_HINT } - ); - } + requireInstalled( + webVitalsFile, + `web-vitals attribution build not found at node_modules/web-vitals/${WEB_VITALS_DIST}. Install it in the project (\`${INSTALL_HINT}\` sets it up).` + ); const scriptSource = fs.readFileSync(webVitalsFile, 'utf8'); let lighthouseBin = null; if (config.lighthouse.enabled) { lighthouseBin = detectBin(LIGHTHOUSE_BIN, { cwd }); - if (!lighthouseBin.available) { - throw new RunnerError( - 'EBINMISSING', - `${LIGHTHOUSE_BIN} not found. Install it in the project (\`${INSTALL_HINT}\` sets it up), or set lighthouse.enabled to false.`, - { bin: LIGHTHOUSE_BIN, install: INSTALL_HINT } - ); - } + requireInstalled( + lighthouseBin.available, + `${LIGHTHOUSE_BIN} not found. Install it in the project (\`${INSTALL_HINT}\` sets it up), or set lighthouse.enabled to false.`, + { bin: LIGHTHOUSE_BIN } + ); } let chromePath = null; @@ -119,10 +129,7 @@ async function runPerf(options = {}) { await browser.close(); } - return normalizePerf(rawResults, { - thresholds: config.thresholds, - topAudits: config.lighthouse.topAudits, - }); + return normalizePerf(rawResults, { thresholds: config.thresholds }); } /** @@ -159,12 +166,20 @@ async function collectOne(url, ctx) { } let lighthouse = null; - if (config.lighthouse.enabled && lighthouseBin) { + // Lighthouse needs the same network reachability as puppeteer -- skip it + // once the page already failed to load, rather than spend its own timeout + // on a dead URL. + if (!scanError && config.lighthouse.enabled && lighthouseBin) { try { - lighthouse = runLighthouse(lighthouseBin, url, config.lighthouse, { + const lhr = runLighthouse(lighthouseBin, url, config.lighthouse, { cwd, chromePath, }); + // A raw LHR can run several MB; extract immediately so only the + // slim shape is kept for the rest of the run. + lighthouse = extractLighthouse(lhr, { + topAudits: config.lighthouse.topAudits, + }); } catch (err) { const detail = (err && err.message ? err.message : '').toString(); notes.push(`lighthouse: failed — ${detail}`); @@ -174,6 +189,8 @@ async function collectOne(url, ctx) { } } + // The server layer profiles via WP-CLI, not the browser, so it runs + // regardless of whether the page loaded. let server = null; if (config.server.enabled) { server = runServerProfile(config.server, url, { cwd }); @@ -280,6 +297,9 @@ function emit(report, mode) { .map((f) => `${f.fn} (${f.wallMs.toFixed(1)}ms)`) .join(', '); lines.push(` server top: ${top || 'none'}`); + if (r.server.error) { + lines.push(` server error: ${r.server.error}`); + } } for (const note of r.notes) { lines.push(` note: ${note}`); @@ -368,8 +388,11 @@ function runDryRun(opts, cwd) { } if (config.server.enabled) { + const commandParts = Array.isArray(config.server.command) + ? config.server.command + : [String(config.server.command)]; lines.push( - ` server: ${config.server.command.join(' ')} eval-file ${config.server.shim} ${config.server.top} --url=` + ` server: ${commandParts.join(' ')} eval-file ${config.server.shim} ${config.server.top} --url=` ); } else { lines.push(' server: disabled'); diff --git a/node-packages/wp-tooling/src/perf/server-profile.js b/node-packages/wp-tooling/src/perf/server-profile.js index 41d12f8..3e5ac72 100644 --- a/node-packages/wp-tooling/src/perf/server-profile.js +++ b/node-packages/wp-tooling/src/perf/server-profile.js @@ -75,17 +75,29 @@ function tryParse(text) { */ function runServerProfile(server, url, options = {}) { const cwd = options.cwd || process.cwd(); - const { origin, pathAndQuery } = splitUrl(url); - const [command, ...prefix] = server.command; - const args = [ - ...prefix, - 'eval-file', - server.shim, - pathAndQuery, - String(server.top), - `--url=${origin}`, - ]; + // splitUrl (and building args from server.command) can throw on malformed + // input -- this module always degrades instead, so a bad URL or config + // must not abort the URLs after this one. + let origin; + let pathAndQuery; + let args; + try { + ({ origin, pathAndQuery } = splitUrl(url)); + const [, ...prefix] = server.command; + args = [ + ...prefix, + 'eval-file', + server.shim, + pathAndQuery, + String(server.top), + `--url=${origin}`, + ]; + } catch (err) { + return { data: null, diagnostic: null, error: err.message }; + } + + const command = server.command[0]; const result = spawnSync(command, args, { cwd, encoding: 'utf8', diff --git a/node-packages/wp-tooling/tests/perf/cli.test.js b/node-packages/wp-tooling/tests/perf/cli.test.js index ba3a079..01454aa 100644 --- a/node-packages/wp-tooling/tests/perf/cli.test.js +++ b/node-packages/wp-tooling/tests/perf/cli.test.js @@ -228,6 +228,17 @@ describe('perf runCli', () => { const parsed = JSON.parse(stdout.join('')); expect(parsed.summary.failedUrls).toBeGreaterThan(0); expect(stderr.join('')).toMatch(/failed to load/); + + // Lighthouse needs the same reachability as puppeteer, so it must be + // skipped for a failed URL: only the initial --version probe ran, no + // per-URL lighthouse invocation. + const nonProbeCalls = execFileSync.mock.calls.filter( + (call) => !call[1].includes('--version') + ); + expect(nonProbeCalls).toHaveLength(0); + // The server layer profiles via WP-CLI, not the browser, so it still + // runs even though the frontend layer failed to load. + expect(spawnSync).toHaveBeenCalled(); }); test('a lighthouse runtime failure degrades that layer without affecting the exit code', async () => { @@ -290,8 +301,13 @@ describe('perf runCli', () => { expect(out).toMatch(/server:/); // Only the lighthouse --version probe ran; nothing else was invoked. - expect(execFileSync.mock.calls).toHaveLength(1); - expect(execFileSync.mock.calls[0][1]).toContain('--version'); + // Filtered rather than asserting the raw total, so this doesn't + // depend on perfect mock-call isolation from other test files + // sharing the same auto-mocked child_process module. + const versionProbes = execFileSync.mock.calls.filter((call) => + call[1].includes('--version') + ); + expect(versionProbes).toHaveLength(1); expect(spawnSync).not.toHaveBeenCalled(); expect(collectVitalsModule.launchBrowser).not.toHaveBeenCalled(); expect(collectVitalsModule.collectVitals).not.toHaveBeenCalled(); diff --git a/node-packages/wp-tooling/tests/perf/normalize.test.js b/node-packages/wp-tooling/tests/perf/normalize.test.js index 563fcc7..db5021c 100644 --- a/node-packages/wp-tooling/tests/perf/normalize.test.js +++ b/node-packages/wp-tooling/tests/perf/normalize.test.js @@ -307,6 +307,7 @@ describe('normalizePerf', () => { }); test('a lighthouse performance score below threshold is an issue', () => { + // normalizePerf expects raw.lighthouse already extracted, not a raw LHR. const report = normalizePerf( [ { @@ -316,7 +317,7 @@ describe('normalizePerf', () => { metrics: { LCP: { value: 1000, rating: 'good' } }, attribution: {}, }, - lighthouse: LHR, + lighthouse: extractLighthouse(LHR), server: null, notes: [], }, diff --git a/node-packages/wp-tooling/tests/perf/server-profile.test.js b/node-packages/wp-tooling/tests/perf/server-profile.test.js index d4e9921..25b2e58 100644 --- a/node-packages/wp-tooling/tests/perf/server-profile.test.js +++ b/node-packages/wp-tooling/tests/perf/server-profile.test.js @@ -123,4 +123,12 @@ describe('runServerProfile', () => { expect(result.data).toBeNull(); expect(result.error).toMatch(/no parseable output/); }); + + test('degrades (never throws) on a malformed URL — e.g. a scheme-less base_url typo', () => { + const result = runServerProfile(SERVER, 'not-a-valid-url'); + expect(result.data).toBeNull(); + expect(result.diagnostic).toBeNull(); + expect(result.error).toBeTruthy(); + expect(spawnSync).not.toHaveBeenCalled(); + }); }); diff --git a/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js b/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js index fc3cbb0..4ec7452 100644 --- a/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js @@ -131,7 +131,12 @@ describe('setup/perf rendered config', () => { 'http://localhost:8888/?p=1', 'http://localhost:8888/?s=hello', ]); - expect(config.lighthouse.enabled).toBe(true); + // webVitals/lighthouse/thresholds/server.shim/server.top are + // deliberately absent from the rendered file -- config.js's + // mergeConfig fills them from DEFAULTS at read time, so the scaffold + // never re-hardcodes a value that could drift from those defaults. + expect(config.lighthouse).toBeUndefined(); + expect(config.webVitals).toBeUndefined(); expect(config.server.enabled).toBe(false); expect(config.server.command).toEqual([ 'npx', @@ -144,7 +149,7 @@ describe('setup/perf rendered config', () => { ]); }); - it('renders custom page paths, appends extra_page, and enables the server layer when server_env_cwd is given', async () => { + it('renders custom page paths, appends extra_page, and enables the server layer when server_enabled is given', async () => { const r = registry; const target = makeTmpDir(); await r.execute( @@ -154,6 +159,7 @@ describe('setup/perf rendered config', () => { sample_page: '/hello-world/', search_page: '/?s=wordpress', extra_page: '/about/', + server_enabled: 'true', server_env_cwd: 'wp-content/plugins/dummy-plugin', }, { cwd: target } @@ -179,6 +185,29 @@ describe('setup/perf rendered config', () => { ]); }); + it('enables the server layer at the WordPress root when server_env_cwd is left at its "." default', async () => { + const r = registry; + const target = makeTmpDir(); + await r.execute( + 'setup/perf', + { base_url: 'http://localhost:8888', server_enabled: 'true' }, + { cwd: target } + ); + const config = JSON.parse( + fs.readFileSync(path.join(target, '.perfrc.json'), 'utf8') + ); + expect(config.server.enabled).toBe(true); + expect(config.server.command).toEqual([ + 'npx', + 'wp-env', + 'run', + 'cli', + '--env-cwd=.', + '--', + 'wp', + ]); + }); + it('copies the server-profile.php shim verbatim (raw: true, no mustache rendering)', async () => { const r = registry; const target = makeTmpDir(); From 5153373adc870320bd235989a9ac37b47e93c073 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Wed, 15 Jul 2026 10:19:28 +0530 Subject: [PATCH 03/13] docs(issues): rename tracking file to reflect #36 and #37 scope --- ...runner.md => wp-devtools-22-36-37-perf-runner.md} | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) rename .claude/issues/{wp-devtools-22-perf-runner.md => wp-devtools-22-36-37-perf-runner.md} (90%) diff --git a/.claude/issues/wp-devtools-22-perf-runner.md b/.claude/issues/wp-devtools-22-36-37-perf-runner.md similarity index 90% rename from .claude/issues/wp-devtools-22-perf-runner.md rename to .claude/issues/wp-devtools-22-36-37-perf-runner.md index 8b0cfad..7ca68a0 100644 --- a/.claude/issues/wp-devtools-22-perf-runner.md +++ b/.claude/issues/wp-devtools-22-36-37-perf-runner.md @@ -1,15 +1,17 @@ -# Issue wp-devtools#22 — Add `wp-tooling perf` command + runner (two-layer: web-vitals/Lighthouse + server xhprof) +# Issue wp-devtools#22, #36, #37 — `wp-tooling perf` command + runner, setup/perf scaffold, and server xhprof normalization -**Status:** in-progress +**Status:** in-review **Branch:** `v1.0.0/task/perf-runner` -**PR:** # +**PR:** #36 **Assignee:** @Adi-ty --- ## Summary -rtCamp/wp-devtools#22 asks for a `wp-tooling perf` command mirroring the shipped `a11y` runner: one normalised JSON report an agent (or CI) can act on. Per the (cross-repo) planning docs' two-layer model, Layer 1 is the *symptom* — lab Core Web Vitals via the `web-vitals` attribution build under headless Chromium, plus Lighthouse — and Layer 2 is the *cause* — server-side function hotspots via xhprof, run over WP-CLI through a consumer-installed `server-profile.php` shim. This task ships both layers, the `setup/perf` scaffold that installs the consumer-side wiring, and verifies end-to-end on two consumers with real profiler data. +rtCamp/wp-devtools#22 asks for a `wp-tooling perf` command mirroring the shipped `a11y` runner: one normalised JSON report an agent (or CI) can act on. Per the (cross-repo) planning docs' two-layer model, Layer 1 is the *symptom* — lab Core Web Vitals via the `web-vitals` attribution build under headless Chromium, plus Lighthouse — and Layer 2 is the *cause* — server-side function hotspots via xhprof, run over WP-CLI through a consumer-installed `server-profile.php` shim. + +This single PR delivers #22 together with the two issues chained under it — **#36** (the `setup/perf` scaffold that installs the consumer-side wiring: dev deps, config, the bundled shim) and **#37** (folding the shim's xhprof JSON into a `server` section of the normalised report, with a CLI-context fidelity note) — both of which declare `Depends on #22` in their own issue bodies. The three are delivered together rather than split across PRs because they are one working system in practice: #22's runner cannot be verified end-to-end without #36's scaffold actually installing the shim it invokes, and #37's `server` section is exactly what `normalize.js` needed to shape the shim's output into the final report. Verified end-to-end on two consumers with real profiler data. --- @@ -33,6 +35,7 @@ rtCamp/wp-devtools#22 asks for a `wp-tooling perf` command mirroring the shipped - [2026-07-15] Cleanup: consolidated three near-identical `EBINMISSING` throws in `run.js` into one helper; derived the metric-name list from `THRESHOLDS` (`normalize.js`) instead of repeating it at four call sites across `normalize.js`/`collect-vitals.js`; dropped the unused `REGISTER_SNIPPET` export; de-flaked a dry-run test that asserted a raw total `execFileSync` call count (filters for the `--version` probe specifically instead, since the total isn't isolation-safe across test files sharing the auto-mocked `child_process` module). - [2026-07-15] Left as-is: the `MAX_BUFFER`/timeout constants duplicated across `lighthouse.js`/`server-profile.js`, and the empty-attribution literal repeated across `normalize.js`/`collect-vitals.js` — both match `a11y`'s own established convention and the project's "three similar lines beats a premature abstraction" rule. Also left the dynamic `require()` in `resolve-module.js` as-is — it has direct in-repo precedent (`src/cli/index.js`'s command auto-discovery), and reworking it to `require.resolve` + explicit `paths` would touch an already-verified module for a stylistic gain with no behaviour change. - [2026-07-15] Split the scaffold's single `server_env_cwd` input (empty string doing double duty as both "disabled" and "no path") into `server_enabled` (boolean-string) + `server_env_cwd` (now defaults to `.`), so profiling from the WordPress root — a legitimate choice, not just a leftover default — is expressible; before, an empty `server_env_cwd` could only mean "disabled". The rendered `.perfrc.json` also dropped every section that already matches `config.js`'s built-in defaults (`webVitals`, `lighthouse`, `thresholds`, `server.shim`, `server.top`) — `mergeConfig` fills them in identically at read time, so keeping them out of the template removes a drift risk (a future default change no longer silently diverges between freshly-scaffolded and config-less projects). +- [2026-07-15] Renamed this file from tracking #22 alone to tracking #22, #36, and #37 together — #36 (`setup/perf` scaffold) and #37 (xhprof `server` section in `normalize.js`) were already fully implemented as part of shipping #22 end-to-end (see Summary), so the file should reflect what the PR actually closes rather than only its root issue. --- @@ -91,6 +94,7 @@ Re-verified live after the `server-profile.js`/`collectOne` fixes above (fresh s ## Notes for the reviewer +- This PR is in `wp-tooling`, but the issues it closes (#22, #36, #37) are in `wp-devtools` — GitHub's `Closes #` keyword only auto-closes issues in the *same* repo as the PR, not cross-repo, so all three need closing by hand once this merges (the PR body should still name them for traceability). - The exit-code contract mirrors `a11y`: 0 clean · 1 run failure or unreachable URL · 2 usage/module-missing · 3 issues found. `failedUrls > 0` downgrades an otherwise-clean run to exit 1, same as `a11y`. - `puppeteer`, `web-vitals`, and `lighthouse` are never dependencies of `@rtcamp/wp-tooling` (zero-runtime-deps rule) — the runner resolves the consumer's install; Lighthouse falls back to `npx --no-install` and never fetches from the network. - The server layer's dependency (`rtcamp/wp-dev-tools`) is not on Packagist — consumers add it via a path or VCS composer repository. The scaffold description says so; the shim's `class_exists` guard keeps a project without it working (frontend layers only, `server: null`). From 9255880c1d8ec0fe08a635b5393d4628a9a73cde Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Mon, 31 Aug 2026 11:13:10 +0530 Subject: [PATCH 04/13] fix(perf): require server_env_cwd, fix EBADJSON exit code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No safe default for server_env_cwd — wrong guess silently misroutes the server layer. Malformed config now exits 2 (usage error, not run failure). --- .../scaffolds/setup/perf/scaffold.json | 4 +- .../setup/perf/templates/server-profile.php | 19 ++- .../wp-tooling/src/perf/collect-vitals.js | 20 ++-- .../wp-tooling/src/perf/normalize.js | 112 ++++++++++++------ node-packages/wp-tooling/src/perf/run.js | 46 ++++--- .../wp-tooling/src/perf/server-profile.js | 9 +- .../wp-tooling/tests/perf/cli.test.js | 6 + .../tests/scaffolds/bundled-manifests.test.js | 24 +++- 8 files changed, 167 insertions(+), 73 deletions(-) diff --git a/node-packages/wp-tooling/scaffolds/setup/perf/scaffold.json b/node-packages/wp-tooling/scaffolds/setup/perf/scaffold.json index 8396c18..5a2702b 100644 --- a/node-packages/wp-tooling/scaffolds/setup/perf/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/setup/perf/scaffold.json @@ -43,8 +43,8 @@ }, { "key": "server_env_cwd", - "description": "Project path inside the WP-CLI environment, as `wp-env run cli --env-cwd` expects it (e.g. wp-content/plugins/my-plugin, or `.` to profile from the WordPress root).", - "default": "." + "description": "Project path inside the WP-CLI environment, as `wp-env run cli --env-cwd` expects it. Must match where this scaffold just installed server-profile.php — e.g. wp-content/plugins/my-plugin for a plugin, wp-content/themes/my-theme for a theme — or `.` only if this project IS the WordPress root. There's no safe default: guessing wrong means the server layer can't find its own shim.", + "required": true } ], "npm_dev_dependencies": { diff --git a/node-packages/wp-tooling/scaffolds/setup/perf/templates/server-profile.php b/node-packages/wp-tooling/scaffolds/setup/perf/templates/server-profile.php index ee86028..40ac329 100644 --- a/node-packages/wp-tooling/scaffolds/setup/perf/templates/server-profile.php +++ b/node-packages/wp-tooling/scaffolds/setup/perf/templates/server-profile.php @@ -95,13 +95,30 @@ static function () use ( $server_profile_profiler, $server_profile_top ): void { // Route diagnostic (STDERR): makes a silently mis-routed path -- or a missing // profiling backend -- visible next to the JSON. $server_profile_query = $GLOBALS['wp_query']; + +$server_profile_route_type = static function ( \WP_Query $query ): string { + if ( $query->is_singular() ) { + return $query->is_page() ? 'page' : 'singular'; + } + if ( $query->is_home() ) { + return 'home'; + } + if ( $query->is_archive() ) { + return 'archive'; + } + if ( $query->is_404() ) { + return '404'; + } + return 'other'; +}; + fwrite( STDERR, sprintf( '[server-profile] path=%s backend=%s resolved=%s object_id=%d%s', $server_profile_path, $server_profile_backend, - $server_profile_query->is_singular() ? ( $server_profile_query->is_page() ? 'page' : 'singular' ) : ( $server_profile_query->is_home() ? 'home' : ( $server_profile_query->is_archive() ? 'archive' : ( $server_profile_query->is_404() ? '404' : 'other' ) ) ), + $server_profile_route_type( $server_profile_query ), (int) get_queried_object_id(), PHP_EOL ) diff --git a/node-packages/wp-tooling/src/perf/collect-vitals.js b/node-packages/wp-tooling/src/perf/collect-vitals.js index ca90fe6..b06cc19 100644 --- a/node-packages/wp-tooling/src/perf/collect-vitals.js +++ b/node-packages/wp-tooling/src/perf/collect-vitals.js @@ -27,14 +27,14 @@ const { METRIC_NAMES } = require('./normalize'); const REGISTER_SNIPPET = ` window.__wpToolingVitals = {}; (function () { - var store = function (m) { - window.__wpToolingVitals[m.name] = { - value: m.value, - rating: m.rating, + var store = function (metric) { + window.__wpToolingVitals[metric.name] = { + value: metric.value, + rating: metric.rating, attribution: { - element: (m.attribution && m.attribution.element) || null, - largestShiftTarget: (m.attribution && m.attribution.largestShiftTarget) || null, - interactionTarget: (m.attribution && m.attribution.interactionTarget) || null, + element: (metric.attribution && metric.attribution.element) || null, + largestShiftTarget: (metric.attribution && metric.attribution.largestShiftTarget) || null, + interactionTarget: (metric.attribution && metric.attribution.interactionTarget) || null, }, }; }; @@ -113,10 +113,10 @@ async function collectVitals(browser, scriptSource, url, options = {}) { function buildResult(raw) { const metrics = {}; for (const name of METRIC_NAMES) { - const m = raw[name]; + const metric = raw[name]; metrics[name] = - m && typeof m.value === 'number' - ? { value: m.value, rating: m.rating || null } + metric && typeof metric.value === 'number' + ? { value: metric.value, rating: metric.rating || null } : null; } diff --git a/node-packages/wp-tooling/src/perf/normalize.js b/node-packages/wp-tooling/src/perf/normalize.js index 7d03f17..a424c1d 100644 --- a/node-packages/wp-tooling/src/perf/normalize.js +++ b/node-packages/wp-tooling/src/perf/normalize.js @@ -36,6 +36,9 @@ const MEASURABLE_METRIC_NAMES = METRIC_NAMES.filter((name) => name !== 'INP'); const SERVER_FIDELITY_NOTE = 'profiled via `wp eval-file` in CLI context — representative for hot functions and N+1s, not a real HTTP request (routing/superglobals differ and it will not reflect web-server/opcache warmth).'; +/** Lighthouse audit score (0-1) below which an audit counts as "failing" and gets surfaced. */ +const FAILING_AUDIT_SCORE_THRESHOLD = 0.9; + /** * Rate a metric value against its good/needs-improvement/poor band. Used as * a fallback when the web-vitals library did not supply its own `rating`. @@ -85,13 +88,20 @@ function extractLighthouse(lhr, options = {}) { const audits = lhr.audits && typeof lhr.audits === 'object' ? lhr.audits : {}; const failing = Object.entries(audits) - .filter(([, a]) => a && typeof a.score === 'number' && a.score < 0.9) - .map(([id, a]) => ({ + .filter( + ([, audit]) => + audit && + typeof audit.score === 'number' && + audit.score < FAILING_AUDIT_SCORE_THRESHOLD + ) + .map(([id, audit]) => ({ id, - title: typeof a.title === 'string' ? a.title : id, - score: a.score, + title: typeof audit.title === 'string' ? audit.title : id, + score: audit.score, displayValue: - typeof a.displayValue === 'string' ? a.displayValue : null, + typeof audit.displayValue === 'string' + ? audit.displayValue + : null, })) .sort((a, b) => a.score - b.score) .slice(0, topAudits); @@ -113,14 +123,14 @@ function normalizeServer(result) { const { data, diagnostic, error } = result; const top = []; if (data && typeof data === 'object' && !Array.isArray(data)) { - for (const [fn, m] of Object.entries(data)) { + for (const [fn, stat] of Object.entries(data)) { top.push({ fn, - calls: Number(m.ct) || 0, - wallMs: (Number(m.wt) || 0) / 1000, - cpuMs: (Number(m.cpu) || 0) / 1000, - memBytes: Number(m.mu) || 0, - peakMemBytes: Number(m.pmu) || 0, + calls: Number(stat.ct) || 0, + wallMs: (Number(stat.wt) || 0) / 1000, + cpuMs: (Number(stat.cpu) || 0) / 1000, + memBytes: Number(stat.mu) || 0, + peakMemBytes: Number(stat.pmu) || 0, }); } } @@ -162,14 +172,14 @@ function isCwvIssue(rating, mode) { function buildAssessment(metrics, lighthouse, thresholds) { const lines = []; for (const name of MEASURABLE_METRIC_NAMES) { - const m = metrics[name]; - if (!m) { + const metric = metrics[name]; + if (!metric) { continue; } const band = THRESHOLDS[name]; const unit = name === 'CLS' ? '' : 'ms'; lines.push( - `${name} ${m.value}${unit} — ${m.rating} (good ≤ ${band[0]}${unit}, poor > ${band[1]}${unit})` + `${name} ${metric.value}${unit} — ${metric.rating} (good ≤ ${band[0]}${unit}, poor > ${band[1]}${unit})` ); } lines.push('INP: not measurable in lab (no interaction performed)'); @@ -190,6 +200,44 @@ function buildAssessment(metrics, lighthouse, thresholds) { return lines; } +/** + * Rate one measurable metric's raw vitals reading and produce a worst-issue + * candidate when it isn't "good". Pulled out of `normalizePerf`'s per-URL + * loop so rating, issue-counting and worst-tracking aren't nested three + * levels inside a per-metric branch. + * + * @param {string} name Metric name. + * @param {Object|null} vitalMetric Raw `{value, rating}` reading for this metric, or null/undefined. + * @param {string} url URL this reading belongs to (for the worst-candidate). + * @param {string} cwvMode `thresholds.cwv` mode. + * @return {{metric: ({value: number, rating: string}|null), isIssue: boolean, + * worstCandidate: ({metric: string, url: string, value: number, rating: string, severity: number}|null)}} + * Normalised metric, whether it counts as an issue, and a worst-candidate (or null when not applicable). + */ +function rateMeasurableMetric(name, vitalMetric, url, cwvMode) { + if (!vitalMetric || typeof vitalMetric.value !== 'number') { + return { metric: null, isIssue: false, worstCandidate: null }; + } + + const rating = vitalMetric.rating || rateMetric(name, vitalMetric.value); + let worstCandidate = null; + if (rating !== 'good') { + worstCandidate = { + metric: name, + url, + value: vitalMetric.value, + rating, + severity: vitalMetric.value / THRESHOLDS[name][1], + }; + } + + return { + metric: { value: vitalMetric.value, rating }, + isIssue: isCwvIssue(rating, cwvMode), + worstCandidate, + }; +} + /** * Normalise raw per-URL perf capture into the final two-layer report. * @@ -241,27 +289,21 @@ function normalizePerf(rawResults, options = {}) { let urlIssues = 0; for (const name of MEASURABLE_METRIC_NAMES) { - const m = vitals.metrics[name]; - if (m && typeof m.value === 'number') { - const rating = m.rating || rateMetric(name, m.value); - metrics[name] = { value: m.value, rating }; - if (isCwvIssue(rating, thresholds.cwv)) { - urlIssues++; - } - if (rating !== 'good') { - const severity = m.value / THRESHOLDS[name][1]; - if (!worst || severity > worst.severity) { - worst = { - metric: name, - url: raw.url, - value: m.value, - rating, - severity, - }; - } - } - } else { - metrics[name] = null; + const { metric, isIssue, worstCandidate } = rateMeasurableMetric( + name, + vitals.metrics[name], + raw.url, + thresholds.cwv + ); + metrics[name] = metric; + if (isIssue) { + urlIssues++; + } + if ( + worstCandidate && + (!worst || worstCandidate.severity > worst.severity) + ) { + worst = worstCandidate; } } metrics.INP = null; diff --git a/node-packages/wp-tooling/src/perf/run.js b/node-packages/wp-tooling/src/perf/run.js index 094e042..2797112 100644 --- a/node-packages/wp-tooling/src/perf/run.js +++ b/node-packages/wp-tooling/src/perf/run.js @@ -270,38 +270,39 @@ function emit(report, mode) { process.stdout.write(JSON.stringify(report) + '\n'); return; } - const s = report.summary; - const failed = s.failedUrls > 0 ? `, ${s.failedUrls} failed to load` : ''; + const summary = report.summary; + const failed = + summary.failedUrls > 0 ? `, ${summary.failedUrls} failed to load` : ''; const lines = [ - `${report.tool}: ${s.issues} issue(s) across ${s.urls} URL(s); ${s.passedUrls} clean${failed}.`, + `${report.tool}: ${summary.issues} issue(s) across ${summary.urls} URL(s); ${summary.passedUrls} clean${failed}.`, ]; - if (s.worst) { + if (summary.worst) { lines.push( - `worst: ${s.worst.metric} ${s.worst.value} (${s.worst.rating}) on ${s.worst.url}` + `worst: ${summary.worst.metric} ${summary.worst.value} (${summary.worst.rating}) on ${summary.worst.url}` ); } - for (const r of report.results) { + for (const result of report.results) { lines.push(''); - if (r.scanError) { - lines.push(`${r.url} — scan failed`); - lines.push(` ${r.scanError}`); + if (result.scanError) { + lines.push(`${result.url} — scan failed`); + lines.push(` ${result.scanError}`); continue; } - lines.push(`${r.url}`); - for (const line of r.assessment) { + lines.push(`${result.url}`); + for (const line of result.assessment) { lines.push(` ${line}`); } - if (r.server) { - const top = r.server.top + if (result.server) { + const top = result.server.top .slice(0, 3) - .map((f) => `${f.fn} (${f.wallMs.toFixed(1)}ms)`) + .map((entry) => `${entry.fn} (${entry.wallMs.toFixed(1)}ms)`) .join(', '); lines.push(` server top: ${top || 'none'}`); - if (r.server.error) { - lines.push(` server error: ${r.server.error}`); + if (result.server.error) { + lines.push(` server error: ${result.server.error}`); } } - for (const note of r.notes) { + for (const note of result.notes) { lines.push(` note: ${note}`); } } @@ -406,14 +407,21 @@ function runDryRun(opts, cwd) { /** * Map a thrown error to an exit code and a stderr message. * + * `EBADJSON` here is always `resolveConfig`'s "the project's own perf config + * is malformed" — a usage error. `lighthouse.js` throws the same code for a + * corrupted Lighthouse run, but that one is always caught and degraded + * inside `collectOne`, so it never reaches this function. + * * @param {Error} err The error. - * @return {number} Exit code: 2 (usage / module or binary missing), 1 (run failure). + * @return {number} Exit code: 2 (usage / module or binary missing / bad config), 1 (run failure). */ function handleError(err) { process.stderr.write(`perf: ${err.message}\n`); if ( err instanceof RunnerError && - (err.code === 'EBINMISSING' || err.code === 'ENOURLS') + (err.code === 'EBINMISSING' || + err.code === 'ENOURLS' || + err.code === 'EBADJSON') ) { return 2; } diff --git a/node-packages/wp-tooling/src/perf/server-profile.js b/node-packages/wp-tooling/src/perf/server-profile.js index 3e5ac72..6c9ea56 100644 --- a/node-packages/wp-tooling/src/perf/server-profile.js +++ b/node-packages/wp-tooling/src/perf/server-profile.js @@ -32,8 +32,11 @@ const RUN_TIMEOUT_MS = 120000; * @return {{origin: string, pathAndQuery: string}} Split URL. */ function splitUrl(url) { - const u = new URL(url); - return { origin: u.origin, pathAndQuery: `${u.pathname}${u.search}` }; + const parsedUrl = new URL(url); + return { + origin: parsedUrl.origin, + pathAndQuery: `${parsedUrl.pathname}${parsedUrl.search}`, + }; } /** @@ -47,6 +50,8 @@ function tryParse(text) { return null; } const trimmed = text.trim(); + // Find where JSON actually starts -- an array `[` or object `{` -- so a + // leading non-JSON preamble line (e.g. a PHP notice) doesn't break parsing. const start = trimmed.search(/[[{]/); if (start === -1) { return null; diff --git a/node-packages/wp-tooling/tests/perf/cli.test.js b/node-packages/wp-tooling/tests/perf/cli.test.js index 01454aa..37580f5 100644 --- a/node-packages/wp-tooling/tests/perf/cli.test.js +++ b/node-packages/wp-tooling/tests/perf/cli.test.js @@ -14,6 +14,7 @@ const FIXTURES = path.join(__dirname, 'fixtures'); const FIXTURE_CONFIG = path.join(FIXTURES, '.perfrc.json'); const PARTIAL_CONFIG = path.join(FIXTURES, 'partial.perfrc.json'); const MISSING_CONFIG = path.join(FIXTURES, 'does-not-exist.json'); +const MALFORMED_CONFIG = path.join(FIXTURES, 'malformed.perfrc.json'); const GOOD_METRICS = { metrics: { @@ -150,6 +151,11 @@ describe('perf runCli', () => { expect(stderr.join('')).toMatch(/no URLs to test/); }); + test('a malformed config exits 2 (EBADJSON), not 1', async () => { + expect(await runCli(['--config', MALFORMED_CONFIG])).toBe(2); + expect(stderr.join('')).toMatch(/invalid JSON/); + }); + test('--url runs without any config file at all', async () => { const code = await runCli([ '--config', diff --git a/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js b/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js index 4ec7452..dfbbf30 100644 --- a/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js @@ -120,7 +120,7 @@ describe('setup/perf rendered config', () => { const target = makeTmpDir(); await r.execute( 'setup/perf', - { base_url: 'http://localhost:8888' }, + { base_url: 'http://localhost:8888', server_env_cwd: '.' }, { cwd: target } ); const config = JSON.parse( @@ -185,12 +185,16 @@ describe('setup/perf rendered config', () => { ]); }); - it('enables the server layer at the WordPress root when server_env_cwd is left at its "." default', async () => { + it('enables the server layer at the WordPress root when server_env_cwd is explicitly "."', async () => { const r = registry; const target = makeTmpDir(); await r.execute( 'setup/perf', - { base_url: 'http://localhost:8888', server_enabled: 'true' }, + { + base_url: 'http://localhost:8888', + server_enabled: 'true', + server_env_cwd: '.', + }, { cwd: target } ); const config = JSON.parse( @@ -208,12 +212,24 @@ describe('setup/perf rendered config', () => { ]); }); + it('has no safe default for server_env_cwd, since a wrong guess would silently point the server layer at the wrong shim location', async () => { + const r = registry; + const target = makeTmpDir(); + await expect( + r.execute( + 'setup/perf', + { base_url: 'http://localhost:8888' }, + { cwd: target } + ) + ).rejects.toThrow(/server_env_cwd/); + }); + it('copies the server-profile.php shim verbatim (raw: true, no mustache rendering)', async () => { const r = registry; const target = makeTmpDir(); await r.execute( 'setup/perf', - { base_url: 'http://localhost:8888' }, + { base_url: 'http://localhost:8888', server_env_cwd: '.' }, { cwd: target } ); const shim = fs.readFileSync( From 64a32377fc6cd82488901c7ac733acb31782f70e Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Tue, 1 Sep 2026 09:49:45 +0530 Subject: [PATCH 05/13] =?UTF-8?q?fix(perf):=20Copilot=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20spawnSync=20guard,=20vitalsError,=20script=20render?= =?UTF-8?q?ing,=20report=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wp-devtools-22-36-37-perf-runner.md | 107 ------------ node-packages/wp-tooling/CHANGELOG.md | 2 +- .../scaffolds/setup/perf/scaffold.json | 2 +- node-packages/wp-tooling/src/perf/config.js | 10 +- node-packages/wp-tooling/src/perf/errors.js | 3 + .../wp-tooling/src/perf/normalize.js | 12 +- node-packages/wp-tooling/src/perf/run.js | 30 +++- .../wp-tooling/src/perf/server-profile.js | 19 ++- node-packages/wp-tooling/src/scaffolds/add.js | 26 +++ .../wp-tooling/src/scaffolds/registry.js | 23 ++- .../wp-tooling/tests/perf/cli.test.js | 57 +++++++ .../wp-tooling/tests/perf/config.test.js | 20 +++ .../wp-tooling/tests/perf/normalize.test.js | 47 ++++++ .../wp-tooling/tests/perf/resolve-bin.test.js | 158 ++++++++++++++++++ .../tests/perf/server-profile.test.js | 12 ++ .../wp-tooling/tests/scaffolds/add.test.js | 55 ++++++ .../tests/scaffolds/bundled-manifests.test.js | 25 +++ 17 files changed, 478 insertions(+), 130 deletions(-) delete mode 100644 .claude/issues/wp-devtools-22-36-37-perf-runner.md create mode 100644 node-packages/wp-tooling/tests/perf/resolve-bin.test.js diff --git a/.claude/issues/wp-devtools-22-36-37-perf-runner.md b/.claude/issues/wp-devtools-22-36-37-perf-runner.md deleted file mode 100644 index 7ca68a0..0000000 --- a/.claude/issues/wp-devtools-22-36-37-perf-runner.md +++ /dev/null @@ -1,107 +0,0 @@ -# Issue wp-devtools#22, #36, #37 — `wp-tooling perf` command + runner, setup/perf scaffold, and server xhprof normalization - -**Status:** in-review -**Branch:** `v1.0.0/task/perf-runner` -**PR:** #36 -**Assignee:** @Adi-ty - ---- - -## Summary - -rtCamp/wp-devtools#22 asks for a `wp-tooling perf` command mirroring the shipped `a11y` runner: one normalised JSON report an agent (or CI) can act on. Per the (cross-repo) planning docs' two-layer model, Layer 1 is the *symptom* — lab Core Web Vitals via the `web-vitals` attribution build under headless Chromium, plus Lighthouse — and Layer 2 is the *cause* — server-side function hotspots via xhprof, run over WP-CLI through a consumer-installed `server-profile.php` shim. - -This single PR delivers #22 together with the two issues chained under it — **#36** (the `setup/perf` scaffold that installs the consumer-side wiring: dev deps, config, the bundled shim) and **#37** (folding the shim's xhprof JSON into a `server` section of the normalised report, with a CLI-context fidelity note) — both of which declare `Depends on #22` in their own issue bodies. The three are delivered together rather than split across PRs because they are one working system in practice: #22's runner cannot be verified end-to-end without #36's scaffold actually installing the shim it invokes, and #37's `server` section is exactly what `normalize.js` needed to shape the shim's output into the final report. Verified end-to-end on two consumers with real profiler data. - ---- - -## Decisions made - -- [2026-07-14] The server-side profiler moved mid-task from `rtCamp\WPFramework\Utils\XHProf_Profiler` (wp-framework) to `rtCamp\WPDevTools\Support\XHProfProfiler` (wp-dev-tools) — wp-framework#51 was closed unmerged because profiling is dev tooling, not an architectural component; the reviewed class was ported to wp-dev-tools (PR #66) as a plain class outside the `RT_DEV_TOOLS_DEV_MODE` gate, installed by consumers as `composer require --dev rtcamp/wp-dev-tools`. This task consumes that class; it does not reimplement xhprof. -- [2026-07-14] `server-profile.php`'s hardening (canonical-redirect hazard, `$_GET` query-string copy, output-buffer draining, STDERR route diagnostic, no `declare(strict_types)`) was designed and live-tested against `features-plugin-skeleton` before being adapted into the scaffold template — `redirect_canonical()` ends the request with `exit()`, which bypasses `finally`, so a canonical redirect would otherwise kill the process before the profiler's `stop()` runs or the JSON is echoed; the shim removes that hook, profiles with `start()`/`stop()` instead of `profile()`, and installs a `register_shutdown_function` fallback that drains open output buffers before emitting JSON. -- [2026-07-14] Config file is `.perfrc.json` (analogous to `.pa11yci.json`), but unlike `a11y` it is OPTIONAL when `--url` is supplied — the perf issue explicitly adds a repeatable `--url` flag, and a project should be able to run a one-off perf check with zero setup. `--url` replaces the config's `urls[]` entirely; every other section (webVitals/lighthouse/server/thresholds) still comes from the file when one exists. A malformed config is always `EBADJSON`, even with `--url`. -- [2026-07-14] Every per-URL layer failure downgrades independently rather than aborting the run: a page-load failure becomes a `scanError` (contributes to `summary.failedUrls`, final exit 1 — same semantics as `a11y`'s unreachable-URL handling); a Lighthouse or server-profile runtime failure degrades that URL's layer to `null`/empty + a note, with **no effect on the exit code** — the server layer especially is auxiliary cause-data, so a broken WP-CLI invocation must never take down the frontend result. -- [2026-07-14] `server-profile.js` (the Node-side WP-CLI invoker) never throws — every failure mode (spawn error, non-zero exit, unparseable stdout) returns `{ data: null, error: }` instead, because a thrown error would need per-call try/catch discipline at every call site to preserve the degrade-not-abort policy; returning a tagged result makes that policy the only path. -- [2026-07-14] INP is hardcoded to `null` in the normalised report regardless of what the frontend collector harvests — the collector never performs a user interaction, so a raw INP reading would be a fluke, not a measurement. Surfaced as an explicit `assessment` line rather than silently omitted. -- [2026-07-14] `puppeteer` resolution for `run.js` goes through a new `requireModule()` helper in `resolve-module.js` (walk `node_modules`, then `require()` the resolved directory) rather than an inline dynamic `require()` in `run.js` itself — this keeps `run.js` mockable via `jest.mock('./resolve-module')` with zero real puppeteer install needed in CI, mirroring how `collect-vitals.js` takes the browser as a parameter instead of resolving it. -- [2026-07-14] Housed in wp-tooling (`src/perf/`), distributed via a new `setup/perf` scaffold, matching the `a11y`/`setup/pa11y` precedent — issue #22 defers the scaffold to a later task, but end-to-end verification against a real consumer needs the consumer-side wiring (deps, config, shim) to exist, so it ships in this PR rather than a follow-up. -- [2026-07-14] `npm run check` on the branch point (`release/v1.0.0`) had two pre-existing failures unrelated to this task (`no-shadow` on `cap` + prettier wrapping in `src/init/index.js` and `tests/ui/selects.test.js`) — the same issue already fixed on the (still-open) a11y branch but never merged. Fixed here with the identical minimal rename (`cap` → `entry`) so `npm run check` is verifiable; documented rather than silently folded in. -- [2026-07-14] Live end-to-end testing surfaced a real bug: puppeteer 25.x's `executablePath()` returns a `Promise`, not a string (older versions returned it synchronously). `run.js` now `await`s it — safe either way, since `await` on a non-Promise value just returns it. Caught only because verification used a real, current puppeteer install rather than a mocked one. -- [2026-07-15] `server-profile.js` was calling `new URL()` unguarded; a malformed URL (e.g. a scheme-less `base_url` typo) threw out of the per-URL loop and silently skipped every URL after it, contradicting the module's own "never throws" contract. Now wrapped, degrading that one result instead — re-verified live: a malformed `--url` alongside a working one now degrades just the bad one and still completes the good one, rather than aborting the run. -- [2026-07-15] Lighthouse needs the same network reachability as puppeteer, so `collectOne` was still running a full Lighthouse pass (up to its 180 s timeout) against a URL that had already failed to load, then discarding the result. Now skipped whenever `scanError` is set; the server layer still runs regardless, since it profiles over WP-CLI, not the browser — re-verified live: an unreachable URL alongside a working one now skips Lighthouse for the unreachable one while its server-profile data still comes back complete. -- [2026-07-15] A raw Lighthouse LHR (which can run multi-MB with the full audit tree) was being retained in `rawResults` for every URL until the whole run finished, only to be slimmed down at the very end. `collectOne` now calls `extractLighthouse` immediately after each successful run, so only the slim `{scores, audits}` shape is ever held; `normalizePerf` no longer re-extracts. -- [2026-07-15] Text-mode output showed `server top: none` identically for "no hotspots captured" and "the WP-CLI invocation itself failed" — now prints the error line too when the invocation failed. -- [2026-07-15] `runDryRun`'s server line called `.join(' ')` on `config.server.command` without checking it was an array — an uncaught crash on a malformed config, unlike the real run (which degrades). Guarded. -- [2026-07-15] Cleanup: consolidated three near-identical `EBINMISSING` throws in `run.js` into one helper; derived the metric-name list from `THRESHOLDS` (`normalize.js`) instead of repeating it at four call sites across `normalize.js`/`collect-vitals.js`; dropped the unused `REGISTER_SNIPPET` export; de-flaked a dry-run test that asserted a raw total `execFileSync` call count (filters for the `--version` probe specifically instead, since the total isn't isolation-safe across test files sharing the auto-mocked `child_process` module). -- [2026-07-15] Left as-is: the `MAX_BUFFER`/timeout constants duplicated across `lighthouse.js`/`server-profile.js`, and the empty-attribution literal repeated across `normalize.js`/`collect-vitals.js` — both match `a11y`'s own established convention and the project's "three similar lines beats a premature abstraction" rule. Also left the dynamic `require()` in `resolve-module.js` as-is — it has direct in-repo precedent (`src/cli/index.js`'s command auto-discovery), and reworking it to `require.resolve` + explicit `paths` would touch an already-verified module for a stylistic gain with no behaviour change. -- [2026-07-15] Split the scaffold's single `server_env_cwd` input (empty string doing double duty as both "disabled" and "no path") into `server_enabled` (boolean-string) + `server_env_cwd` (now defaults to `.`), so profiling from the WordPress root — a legitimate choice, not just a leftover default — is expressible; before, an empty `server_env_cwd` could only mean "disabled". The rendered `.perfrc.json` also dropped every section that already matches `config.js`'s built-in defaults (`webVitals`, `lighthouse`, `thresholds`, `server.shim`, `server.top`) — `mergeConfig` fills them in identically at read time, so keeping them out of the template removes a drift risk (a future default change no longer silently diverges between freshly-scaffolded and config-less projects). -- [2026-07-15] Renamed this file from tracking #22 alone to tracking #22, #36, and #37 together — #36 (`setup/perf` scaffold) and #37 (xhprof `server` section in `normalize.js`) were already fully implemented as part of shipping #22 end-to-end (see Summary), so the file should reflect what the PR actually closes rather than only its root issue. - ---- - -## Files changed so far - -- `src/perf/errors.js` — new (`RunnerError`: `EBINMISSING` / `EBINFAIL` / `EBADJSON` / `ENOURLS`) -- `src/perf/resolve-bin.js` — new (consumer binary resolution for `lighthouse`, mirrors `src/a11y/resolve-bin.js`) -- `src/perf/resolve-module.js` — new (consumer module resolution for `puppeteer`/`web-vitals`; `requireModule` loader for testability) -- `src/perf/config.js` — new (`.perfrc.json` resolution, section-merge over defaults, `--url` precedence) -- `src/perf/collect-vitals.js` — new (headless web-vitals attribution collection; puppeteer/browser passed in as parameters) -- `src/perf/lighthouse.js` — new (per-URL Lighthouse invocation, `CHROME_PATH` pin) -- `src/perf/server-profile.js` — new (per-URL WP-CLI shim invocation; never throws, always degrades) -- `src/perf/normalize.js` — new (pure two-layer normaliser: ratings, worst-metric pick, issue counting, Lighthouse/server extraction) -- `src/perf/run.js` — new (`runPerf()` core + `runCli()` adapter; exit codes 0/1/2/3) -- `src/perf/index.js` — new (barrel exposed as `@rtcamp/wp-tooling/perf`) -- `src/cli/commands/perf.js` — new (dispatcher shim) -- `package.json` — edited (`"./perf"` exports entry) -- `tests/perf/*` — new (cli, config, collect-vitals, lighthouse, server-profile, normalize, resolve-module specs + fixtures) -- `scaffolds/setup/perf/scaffold.json` — new -- `scaffolds/setup/perf/templates/.perfrc.json.mustache` — new -- `scaffolds/setup/perf/templates/server-profile.php` — new (raw copy, no Mustache rendering) -- `tests/scaffolds/bundled-manifests.test.js` — edited (three `setup/perf rendered config` cases: default render, custom inputs + server enabled, raw-copy byte-equality) -- `CHANGELOG.md` — edited (two Unreleased entries) -- `src/init/index.js`, `tests/ui/selects.test.js` — edited (pre-existing lint-gate errors at `release/v1.0.0` HEAD: `no-shadow` on `cap`, prettier wrapping; `npm run check` fails without these fixes) - ---- - -## Verification run - -```bash -$ npm run check # eslint src tests && jest -# ESLint: clean -# Tests: 763 passed, 57 suites -``` - -Tested live end-to-end on two independent WordPress installs running under wp-env (Alpine `cli` containers, xhprof pecl-installed fresh into each), each with `@rtcamp/wp-tooling` installed as a dev dependency and `rtcamp/wp-dev-tools` wired in via composer for the server layer: - -- `--dry-run` resolved the local `puppeteer` (25.3.0), the `web-vitals` attribution build, `lighthouse` (13.4.0), and the WP-CLI server command on both installs, with nothing reported `NOT FOUND`. -- A full `--output json` run against three URLs (front page, a single post, a search page) on each install returned complete data for every result: real LCP/CLS/FCP/TTFB values with ratings (INP `null` throughout, as designed), real Lighthouse `performance` scores with audits, and real xhprof top-N function lists (wall-time descending, plausible WordPress call stacks) — `failedUrls: 0` on both, exits split across 0 (clean) and 3 (a forced Lighthouse-threshold breach) as expected. -- Exercised against **two independently written `server-profile.php` variants** (each install's own pre-existing shim, left untouched rather than overwritten by the scaffold — the engine correctly skipped both): both returned identical-shaped, complete profiler output, confirming the Node-side `server-profile.js`/`normalize.js` handle real-world shim variance correctly. -- Specifically proved the canonical-redirect hardening: switched one install to pretty permalinks (`wp rewrite structure '/%postname%/'`), confirmed via `curl` that the query-string URL now issues a real HTTP 301, then re-ran the shim against that exact URL — it still returned complete, valid JSON (no truncation from a mid-render `exit()`), then reverted the permalink structure. -- Verified the config-less path (`--config --url `, no `.perfrc.json` at all): frontend + Lighthouse layers still ran; `server: null` as designed (defaults to disabled without a config). -- Verified `--url` overriding an *existing* config's `urls[]` while its other sections (server, thresholds) still applied. -- Verified the scaffold never overwrites an existing `server-profile.php` (both installs already had one). -- Found and fixed one real bug via this live testing: puppeteer 25.x's `executablePath()` returns a `Promise`, not a string — `run.js` was calling it synchronously. Fixed by awaiting it (safe for both sync and async puppeteer versions). - -Re-verified live after the `server-profile.js`/`collectOne` fixes above (fresh scaffold apply with the new `server_enabled`/`server_env_cwd` inputs, xhprof reinstalled after the container was recreated): the leaner `.perfrc.json` (just `urls` + `server`) still resolves every omitted section correctly via `config.js`'s defaults on `--dry-run`; an unreachable URL alongside a working one now skips Lighthouse for the unreachable one while its server-profile data still comes back complete (proving the server layer's independence from the frontend layer); a malformed `--url` value degrades that one result (`server.error: 'Invalid URL'`) and the run still completes the next URL with full two-layer data, instead of aborting. - ---- - -## Open questions - -- _(none blocking)_ - ---- - -## Notes for the reviewer - -- This PR is in `wp-tooling`, but the issues it closes (#22, #36, #37) are in `wp-devtools` — GitHub's `Closes #` keyword only auto-closes issues in the *same* repo as the PR, not cross-repo, so all three need closing by hand once this merges (the PR body should still name them for traceability). -- The exit-code contract mirrors `a11y`: 0 clean · 1 run failure or unreachable URL · 2 usage/module-missing · 3 issues found. `failedUrls > 0` downgrades an otherwise-clean run to exit 1, same as `a11y`. -- `puppeteer`, `web-vitals`, and `lighthouse` are never dependencies of `@rtcamp/wp-tooling` (zero-runtime-deps rule) — the runner resolves the consumer's install; Lighthouse falls back to `npx --no-install` and never fetches from the network. -- The server layer's dependency (`rtcamp/wp-dev-tools`) is not on Packagist — consumers add it via a path or VCS composer repository. The scaffold description says so; the shim's `class_exists` guard keeps a project without it working (frontend layers only, `server: null`). -- `wp-dev-tools` is itself mid-development (`XHProfProfiler` port on an open PR, not yet on its default branch) — this task tracks the class name/namespace it settled on, not a specific commit; if the class is renamed again before merge, only the scaffold's shim template and this file's decisions need updating, not the Node runner (it only shells out to WP-CLI, it never references the PHP class by name). - ---- - -## Handoff log - -_(no rotations yet — delete this line when the first entry is added)_ diff --git a/node-packages/wp-tooling/CHANGELOG.md b/node-packages/wp-tooling/CHANGELOG.md index 3b183d5..6fac286 100644 --- a/node-packages/wp-tooling/CHANGELOG.md +++ b/node-packages/wp-tooling/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added -- `wp-tooling perf` — two-layer performance runner mirroring `a11y`. Layer 1 (frontend, always on): launches consumer-installed `puppeteer`, injects the `web-vitals` attribution build, and collects LCP/CLS/FCP/TTFB with `reportAllChanges: true` (LCP/CLS never finalize headless without input, so the latest reported candidate is harvested after a settle delay); INP is always `null` in the lab layer (no interaction is performed). Optional Lighthouse pass (`--only-categories=performance`, pinned to the puppeteer-installed Chrome via `CHROME_PATH`) contributes category scores + top failing audits. Layer 2 (server, opt-in via `server.enabled`): runs the consumer's `server-profile.php` shim over WP-CLI (`wp eval-file`) to get xhprof/tideways function hotspots, normalized with a CLI-context fidelity note; degrades to an empty `top[]` with guidance — never an error — when no backend or `rtcamp/wp-dev-tools` is installed, and a broken invocation degrades the same way rather than failing the run (the server layer is auxiliary cause-data). `src/perf/{errors,resolve-bin,resolve-module,config,collect-vitals,lighthouse,server-profile,normalize,run,index}.js`; `"./perf"` exports entry; `src/cli/commands/perf.js` (auto-discovered). Flags mirror `a11y`: `--config ` (default `.perfrc.json`, optional when `--url` is given — unlike `a11y`'s config), repeatable `--url` (replaces the config's `urls[]` entirely), `--output text|json`, `--dry-run`. Same exit contract: 0 clean · 1 run failure or unreachable URL · 2 usage or module/binary missing · 3 issues found (a page-load failure or `EBADJSON`/`EBINFAIL` still exits 1; a missing `puppeteer`/`web-vitals`/config-and-no-`--url` exits 2). Zero runtime dependencies — Node built-ins plus the consumer-installed `puppeteer`, `web-vitals`, and `lighthouse` dev dependencies; no `src/ui`, plain `process.stdout`/`stderr.write`. +- `wp-tooling perf` — two-layer performance runner mirroring `a11y`. Layer 1 (frontend, always on): launches consumer-installed `puppeteer`, injects the `web-vitals` attribution build, and collects LCP/CLS/FCP/TTFB with `reportAllChanges: true` (LCP/CLS never finalize headless without input, so the latest reported candidate is harvested after a settle delay); INP is always `null` in the lab layer (no interaction is performed). Optional Lighthouse pass (`--only-categories=performance`, pinned to the puppeteer-installed Chrome via `CHROME_PATH`) contributes category scores + top failing audits. Layer 2 (server, opt-in via `server.enabled`): runs the consumer's `server-profile.php` shim over WP-CLI (`wp eval-file`) to get xhprof/tideways function hotspots, normalized with a CLI-context fidelity note; degrades to an empty `top[]` with guidance — never an error — when no backend or `rtcamp/wp-dev-tools` is installed, and a broken invocation degrades the same way rather than failing the run (the server layer is auxiliary cause-data). `src/perf/{errors,resolve-bin,resolve-module,config,collect-vitals,lighthouse,server-profile,normalize,run,index}.js`; `"./perf"` exports entry; `src/cli/commands/perf.js` (auto-discovered). Flags mirror `a11y`: `--config ` (default `.perfrc.json`, optional when `--url` is given — unlike `a11y`'s config), repeatable `--url` (replaces the config's `urls[]` entirely), `--output text|json`, `--dry-run`. Same exit contract: 0 clean · 1 run failure or unreachable URL · 2 usage or module/binary missing · 3 issues found (a page-load failure, an empty web-vitals harvest, or `EBINFAIL` still exits 1; a missing `puppeteer`/`web-vitals`/lighthouse, no resolvable URLs, or a malformed/unreadable config — `EBADJSON`/`ECONFIGREAD` — exits 2). Zero runtime dependencies — Node built-ins plus the consumer-installed `puppeteer`, `web-vitals`, and `lighthouse` dev dependencies; no `src/ui`, plain `process.stdout`/`stderr.write`. - `setup/perf` scaffold — renders `.perfrc.json` (project-owned URL slots: `sample_page`/`search_page`/optional `extra_page`, same pattern as `setup/pa11y`) and ships a hardened `server-profile.php` shim (`raw: true`, copied verbatim) plus `web-vitals`/`lighthouse`/`puppeteer` dev-dependency pins and `test:perf` / `profile:server` npm scripts. The shim removes `template_redirect`'s `redirect_canonical` before rendering (a canonical redirect ends the request with `exit()`, which bypasses `finally` and would otherwise kill the process before the profiler stops or the JSON is echoed), profiles with `start()`/`stop()` plus a `register_shutdown_function` fallback that drains open output buffers, copies the target's query string into `$_GET` before `wp()` (`WP::parse_request()` reads `$_GET`, not `REQUEST_URI`), and emits a STDERR route + backend diagnostic. Consumes `rtCamp\WPDevTools\Support\XHProfProfiler` (require-dev `rtcamp/wp-dev-tools`) — never reimplements xhprof; degrades to `[]` + a STDERR note when the class isn't installed. - Remote scaffolds — a scaffold's `scaffold.json` + templates can live in another repo. `scaffolds/sources.json` lists the source repos (pinned `{ repository, ref, path }`); each repo publishes a `scaffolds/index.json` enumerating the scaffolds it offers, which the registry fetches to discover them (one PR in the owning repo adds/changes a scaffold; wp-tooling only changes to onboard a new repo). Manifests + templates are fetched on `add`, cached under `${XDG_CACHE_HOME:-$HOME/.cache}/wp-tooling/remote/` and validated with ETag conditional requests (`304 Not Modified` serves the cache; movable tags refresh when they move). New error code `EFETCHFAIL` (network/HTTP) distinct from `EBADSCAFFOLD` (bad index/manifest). `list` is online-preferred with a cache fallback and reports unreachable sources as warnings; `validate --remote` fetches + schema-validates each index + manifest; `wp-tooling cache clear` empties the cache. Dormant by default — no `sources.json` ships. - Engine-side input discovery (`discover_from`) — an input declaration can say where to source its value from the project, so the engine fills it instead of the caller guessing. Resolves from `composer.json` / `package.json` (dotted paths; `autoload.psr-4` yields the root namespace) and `.wp-tooling.json`, with precedence `supplied → discovered → default`. Fail-safe: a missing or malformed project file falls through to the input's `default`, so a project without those files behaves exactly as before the resolver existed. Adds an optional `transform` step for derived inputs (`json-escape` doubles backslashes for the PSR-4 composer key). The inputs the engine actually rendered with are surfaced on `execute()` as `engine.inputs`. Bundled `setup/psr4` + `wp/*` scaffolds annotated with `discover_from`. diff --git a/node-packages/wp-tooling/scaffolds/setup/perf/scaffold.json b/node-packages/wp-tooling/scaffolds/setup/perf/scaffold.json index 5a2702b..850832e 100644 --- a/node-packages/wp-tooling/scaffolds/setup/perf/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/setup/perf/scaffold.json @@ -55,7 +55,7 @@ "scripts": { "npm": { "test:perf": "wp-tooling perf", - "profile:server": "wp-env run cli --env-cwd=wp-content/plugins/$(basename \"$PWD\") -- wp eval-file server-profile.php" + "profile:server": "wp-env run cli --env-cwd={{server_env_cwd}} -- wp eval-file server-profile.php" } } } diff --git a/node-packages/wp-tooling/src/perf/config.js b/node-packages/wp-tooling/src/perf/config.js index 238818c..3677121 100644 --- a/node-packages/wp-tooling/src/perf/config.js +++ b/node-packages/wp-tooling/src/perf/config.js @@ -89,7 +89,8 @@ function mergeConfig(raw) { * @param {string} [options.cwd] Project root. * @return {{config: Object, configPath: string|null, urls: string[]}} Resolved config, the * config path actually read (`null` when none was read), and the effective URL list. - * @throws {RunnerError} `ENOURLS` when no URLs are available; `EBADJSON` when the config is malformed. + * @throws {RunnerError} `ENOURLS` when no URLs are available; `EBADJSON` when the config + * is malformed; `ECONFIGREAD` when the config path exists but could not be read. */ function resolveConfig(options = {}) { const cwd = options.cwd || process.cwd(); @@ -105,6 +106,13 @@ function resolveConfig(options = {}) { try { text = fs.readFileSync(configPath, 'utf8'); } catch (err) { + if (err.code !== 'ENOENT') { + throw new RunnerError( + 'ECONFIGREAD', + `could not read ${configPath}: ${err.message}`, + { configPath } + ); + } if (explicitUrls.length === 0) { throw new RunnerError( 'ENOURLS', diff --git a/node-packages/wp-tooling/src/perf/errors.js b/node-packages/wp-tooling/src/perf/errors.js index bc7af90..c94bb15 100644 --- a/node-packages/wp-tooling/src/perf/errors.js +++ b/node-packages/wp-tooling/src/perf/errors.js @@ -12,6 +12,9 @@ * EBINFAIL a browser or binary launch failed for a reason other than * "found issues" * EBADJSON the perf config could not be parsed as JSON + * ECONFIGREAD the perf config path exists but could not be read — e.g. it + * is a directory, or permission was denied (distinct from a + * simply-absent config, which is not an error) * ENOURLS no URLs could be resolved from the perf config or --url */ diff --git a/node-packages/wp-tooling/src/perf/normalize.js b/node-packages/wp-tooling/src/perf/normalize.js index a424c1d..f87cfe5 100644 --- a/node-packages/wp-tooling/src/perf/normalize.js +++ b/node-packages/wp-tooling/src/perf/normalize.js @@ -245,7 +245,7 @@ function rateMeasurableMetric(name, vitalMetric, url, cwvMode) { * shape (or `null`), never a raw LHR. * * @param {Object[]} rawResults Raw per-URL capture: `{ url, scanError, - * vitals, lighthouse, server, notes }`. + * vitalsError, vitals, lighthouse, server, notes }`. * @param {Object} [options] * @param {Object} [options.thresholds] Resolved `thresholds` config section. * @return {Object} Normalised report. @@ -319,7 +319,12 @@ function normalizePerf(rawResults, options = {}) { } issues += urlIssues; - if (urlIssues === 0) { + + const notes = raw.notes ? [...raw.notes] : []; + if (raw.vitalsError) { + notes.push(raw.vitalsError); + failedUrls++; + } else if (urlIssues === 0) { passedUrls++; } @@ -335,7 +340,7 @@ function normalizePerf(rawResults, options = {}) { lighthouse, server: normalizeServer(raw.server), assessment: buildAssessment(metrics, lighthouse, thresholds), - notes: raw.notes || [], + notes, }); } @@ -366,5 +371,6 @@ module.exports = { normalizeServer, THRESHOLDS, METRIC_NAMES, + MEASURABLE_METRIC_NAMES, SERVER_FIDELITY_NOTE, }; diff --git a/node-packages/wp-tooling/src/perf/run.js b/node-packages/wp-tooling/src/perf/run.js index 2797112..361efcd 100644 --- a/node-packages/wp-tooling/src/perf/run.js +++ b/node-packages/wp-tooling/src/perf/run.js @@ -31,7 +31,11 @@ const { detectBin } = require('./resolve-bin'); const { launchBrowser, collectVitals } = require('./collect-vitals'); const { runLighthouse, BIN: LIGHTHOUSE_BIN } = require('./lighthouse'); const { runServerProfile } = require('./server-profile'); -const { normalizePerf, extractLighthouse } = require('./normalize'); +const { + normalizePerf, + extractLighthouse, + MEASURABLE_METRIC_NAMES, +} = require('./normalize'); const INSTALL_HINT = 'wp-tooling add setup/perf'; const WEB_VITALS_DIST = 'dist/web-vitals.attribution.iife.js'; @@ -135,7 +139,9 @@ async function runPerf(options = {}) { /** * Run every layer for one URL. Frontend load failure becomes a per-URL * `scanError` (the caller's run continues); lighthouse and server failures - * degrade to `null` + a note without affecting the overall run. + * degrade to `null` + a note without affecting the overall run. A page that + * loads but harvests no metric is a separate `vitalsError` — Lighthouse + * navigates independently, so it still runs for it. * * @param {string} url Target URL. * @param {Object} ctx Shared context for the run. @@ -153,6 +159,7 @@ async function collectOne(url, ctx) { const notes = []; let vitals = null; let scanError = null; + let vitalsError = null; try { vitals = await collectVitals( @@ -161,6 +168,10 @@ async function collectOne(url, ctx) { url, config.webVitals ); + if (MEASURABLE_METRIC_NAMES.every((name) => !vitals.metrics[name])) { + vitalsError = + 'web-vitals harvest returned no metrics — the injected collector may not have registered (e.g. the page never became interactive, or CSP blocked the injected script).'; + } } catch (err) { scanError = (err && err.message ? err.message : '').toString(); } @@ -201,7 +212,7 @@ async function collectOne(url, ctx) { } } - return { url, scanError, vitals, lighthouse, server, notes }; + return { url, scanError, vitalsError, vitals, lighthouse, server, notes }; } const VALID_OUTPUTS = ['text', 'json']; @@ -286,11 +297,11 @@ function emit(report, mode) { if (result.scanError) { lines.push(`${result.url} — scan failed`); lines.push(` ${result.scanError}`); - continue; - } - lines.push(`${result.url}`); - for (const line of result.assessment) { - lines.push(` ${line}`); + } else { + lines.push(`${result.url}`); + for (const line of result.assessment) { + lines.push(` ${line}`); + } } if (result.server) { const top = result.server.top @@ -421,7 +432,8 @@ function handleError(err) { err instanceof RunnerError && (err.code === 'EBINMISSING' || err.code === 'ENOURLS' || - err.code === 'EBADJSON') + err.code === 'EBADJSON' || + err.code === 'ECONFIGREAD') ) { return 2; } diff --git a/node-packages/wp-tooling/src/perf/server-profile.js b/node-packages/wp-tooling/src/perf/server-profile.js index 6c9ea56..3aca6a9 100644 --- a/node-packages/wp-tooling/src/perf/server-profile.js +++ b/node-packages/wp-tooling/src/perf/server-profile.js @@ -103,12 +103,19 @@ function runServerProfile(server, url, options = {}) { } const command = server.command[0]; - const result = spawnSync(command, args, { - cwd, - encoding: 'utf8', - maxBuffer: MAX_BUFFER, - timeout: RUN_TIMEOUT_MS, - }); + let result; + try { + // spawnSync itself can throw synchronously (e.g. command undefined), + // separately from the return-based result.error handled below. + result = spawnSync(command, args, { + cwd, + encoding: 'utf8', + maxBuffer: MAX_BUFFER, + timeout: RUN_TIMEOUT_MS, + }); + } catch (err) { + return { data: null, diagnostic: null, error: err.message }; + } const diagnostic = (result.stderr || '').toString().trim() || null; diff --git a/node-packages/wp-tooling/src/scaffolds/add.js b/node-packages/wp-tooling/src/scaffolds/add.js index 3410547..aaf0c8e 100644 --- a/node-packages/wp-tooling/src/scaffolds/add.js +++ b/node-packages/wp-tooling/src/scaffolds/add.js @@ -132,7 +132,12 @@ function printHumanReport(result) { } lines.push(''); const composer = Object.entries(developer.install.composer); + const composerDev = Object.entries(developer.install.composerDev || {}); + const composerSuggest = Object.entries( + developer.install.composerSuggest || {} + ); const npm = Object.entries(developer.install.npm); + const npmDev = Object.entries(developer.install.npmDev || {}); const npmScripts = developer.scripts ? Object.entries(developer.scripts.npm || {}) : []; @@ -141,7 +146,10 @@ function printHumanReport(result) { : []; if ( composer.length || + composerDev.length || + composerSuggest.length || npm.length || + npmDev.length || developer.secrets.length || npmScripts.length || composerScripts.length @@ -153,12 +161,30 @@ function printHumanReport(result) { lines.push(` composer require ${pkg}:${ver}`); } } + if (composerDev.length) { + lines.push(' Install (composer dev):'); + for (const [pkg, ver] of composerDev) { + lines.push(` composer require --dev ${pkg}:${ver}`); + } + } + if (composerSuggest.length) { + lines.push(' Suggest (composer, informational):'); + for (const [pkg, note] of composerSuggest) { + lines.push(` ${pkg}: ${note}`); + } + } if (npm.length) { lines.push(' Install (npm):'); for (const [pkg, ver] of npm) { lines.push(` npm install ${pkg}@${ver}`); } } + if (npmDev.length) { + lines.push(' Install (npm dev):'); + for (const [pkg, ver] of npmDev) { + lines.push(` npm install --save-dev ${pkg}@${ver}`); + } + } if (npmScripts.length) { lines.push(' Add to package.json "scripts":'); for (const [name, cmd] of npmScripts) { diff --git a/node-packages/wp-tooling/src/scaffolds/registry.js b/node-packages/wp-tooling/src/scaffolds/registry.js index 6dd6647..8819c61 100644 --- a/node-packages/wp-tooling/src/scaffolds/registry.js +++ b/node-packages/wp-tooling/src/scaffolds/registry.js @@ -46,6 +46,22 @@ const { indexEntryToRecord, } = require('./sources'); +/** + * Render every value in a scripts map (npm or composer) through Mustache. + * + * @param {Object} scripts Raw `{name: command}` map from scaffold.json. + * @param {Object} resolved Resolved inputs to render with. + * @return {Object} Rendered `{name: command}` map. + */ +function renderScriptMap(scripts, resolved) { + return Object.fromEntries( + Object.entries(scripts || {}).map(([name, cmd]) => [ + name, + render(cmd, resolved), + ]) + ); +} + class ScaffoldRegistry { /** * @param {Object|string} options @@ -493,8 +509,11 @@ class ScaffoldRegistry { npmDev: { ...(scaffold.npm_dev_dependencies || {}) }, }, scripts: { - npm: { ...(scaffoldScripts.npm || {}) }, - composer: { ...(scaffoldScripts.composer || {}) }, + npm: renderScriptMap(scaffoldScripts.npm, resolved), + composer: renderScriptMap( + scaffoldScripts.composer, + resolved + ), }, secrets: (scaffold.secrets || []).map((s) => ({ ...s })), }, diff --git a/node-packages/wp-tooling/tests/perf/cli.test.js b/node-packages/wp-tooling/tests/perf/cli.test.js index 37580f5..feee251 100644 --- a/node-packages/wp-tooling/tests/perf/cli.test.js +++ b/node-packages/wp-tooling/tests/perf/cli.test.js @@ -247,6 +247,63 @@ describe('perf runCli', () => { expect(spawnSync).toHaveBeenCalled(); }); + test('an empty web-vitals harvest is a run failure (exit 1), and lighthouse still runs for it', async () => { + collectVitalsModule.collectVitals.mockResolvedValue({ + metrics: { LCP: null, CLS: null, INP: null, FCP: null, TTFB: null }, + attribution: { lcpElement: null, clsSources: [], inpTarget: null }, + }); + const code = await runCli([ + '--config', + FIXTURE_CONFIG, + '--output', + 'json', + ]); + expect(code).toBe(1); + const parsed = JSON.parse(stdout.join('')); + expect(parsed.summary.passedUrls).toBe(0); + expect(parsed.summary.failedUrls).toBeGreaterThan(0); + expect(parsed.results[0].notes.join('')).toMatch( + /web-vitals harvest returned no metrics/ + ); + + // Unlike a scanError, the page loaded fine, so lighthouse still runs. + const nonProbeCalls = execFileSync.mock.calls.filter( + (call) => !call[1].includes('--version') + ); + expect(nonProbeCalls.length).toBeGreaterThan(0); + }); + + test('--output text still renders the server section for a URL that failed to scan', async () => { + mockChildProcess({ + serverResult: { + stdout: JSON.stringify({ + 'WP_Query::get_posts': { + ct: 3, + wt: 41200, + cpu: 38000, + mu: 1048576, + pmu: 1148576, + }, + }), + stderr: '', + status: 0, + }, + }); + collectVitalsModule.collectVitals.mockRejectedValue( + new Error('net::ERR_CONNECTION_REFUSED') + ); + const code = await runCli([ + '--config', + FIXTURE_CONFIG, + '--output', + 'text', + ]); + expect(code).toBe(1); + const out = stdout.join(''); + expect(out).toMatch(/— scan failed/); + expect(out).toMatch(/server top: WP_Query::get_posts/); + }); + test('a lighthouse runtime failure degrades that layer without affecting the exit code', async () => { mockChildProcess({ lighthouseRunThrows: true }); const code = await runCli([ diff --git a/node-packages/wp-tooling/tests/perf/config.test.js b/node-packages/wp-tooling/tests/perf/config.test.js index 470714c..8a64c49 100644 --- a/node-packages/wp-tooling/tests/perf/config.test.js +++ b/node-packages/wp-tooling/tests/perf/config.test.js @@ -108,6 +108,26 @@ describe('resolveConfig — errors', () => { expect(err).toBeInstanceOf(RunnerError); expect(err.code).toBe('EBADJSON'); }); + + test('a config path that is a directory throws ECONFIGREAD, not a silent fallback', () => { + const err = grab(() => + resolveConfig({ cwd: FIXTURES, configPath: '.' }) + ); + expect(err).toBeInstanceOf(RunnerError); + expect(err.code).toBe('ECONFIGREAD'); + }); + + test('a config path that is a directory throws ECONFIGREAD even when --url is given', () => { + const err = grab(() => + resolveConfig({ + cwd: FIXTURES, + configPath: '.', + urls: ['http://example.test/'], + }) + ); + expect(err).toBeInstanceOf(RunnerError); + expect(err.code).toBe('ECONFIGREAD'); + }); }); describe('mergeConfig', () => { diff --git a/node-packages/wp-tooling/tests/perf/normalize.test.js b/node-packages/wp-tooling/tests/perf/normalize.test.js index db5021c..e509f53 100644 --- a/node-packages/wp-tooling/tests/perf/normalize.test.js +++ b/node-packages/wp-tooling/tests/perf/normalize.test.js @@ -350,6 +350,53 @@ describe('normalizePerf', () => { expect(report.results[0].server.top[0].fn).toBe('WP_Query::get_posts'); }); + test('vitalsError counts the URL as failed (not passed), even with zero metric issues, and is appended to notes', () => { + const report = normalizePerf([ + { + url: 'http://localhost:8888/', + scanError: null, + vitalsError: 'web-vitals harvest returned no metrics', + vitals: { + metrics: { LCP: null, CLS: null, FCP: null, TTFB: null }, + attribution: {}, + }, + lighthouse: null, + server: null, + notes: [], + }, + ]); + expect(report.summary).toEqual({ + urls: 1, + passedUrls: 0, + failedUrls: 1, + issues: 0, + worst: null, + }); + expect(report.results[0].notes).toEqual([ + 'web-vitals harvest returned no metrics', + ]); + }); + + test('a lighthouse issue still counts towards summary.issues alongside a vitalsError', () => { + const report = normalizePerf( + [ + { + url: 'http://localhost:8888/', + scanError: null, + vitalsError: 'web-vitals harvest returned no metrics', + vitals: { metrics: {}, attribution: {} }, + lighthouse: extractLighthouse(LHR), + server: null, + notes: [], + }, + ], + { thresholds: { cwv: 'poor', lighthousePerformance: 0.9 } } + ); + expect(report.summary.failedUrls).toBe(1); + expect(report.summary.passedUrls).toBe(0); + expect(report.summary.issues).toBe(1); + }); + test('carries per-URL notes through untouched', () => { const report = normalizePerf([ { diff --git a/node-packages/wp-tooling/tests/perf/resolve-bin.test.js b/node-packages/wp-tooling/tests/perf/resolve-bin.test.js new file mode 100644 index 0000000..24be259 --- /dev/null +++ b/node-packages/wp-tooling/tests/perf/resolve-bin.test.js @@ -0,0 +1,158 @@ +'use strict'; + +jest.mock('child_process'); + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { execFileSync } = require('child_process'); +const { + findInNodeModules, + resolveBin, + detectBin, +} = require('../../src/perf/resolve-bin'); + +const BIN_NAME = 'wp-tooling-perf-fixture-bin'; + +function tmpTree() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'perf-bin-')); +} + +function makeBin(dir, name) { + const binDir = path.join(dir, 'node_modules', '.bin'); + fs.mkdirSync(binDir, { recursive: true }); + const binPath = path.join(binDir, name); + fs.writeFileSync(binPath, '#!/usr/bin/env node\n'); + return binPath; +} + +describe('findInNodeModules', () => { + let root; + + afterEach(() => { + if (root) { + fs.rmSync(root, { recursive: true, force: true }); + root = null; + } + }); + + test('finds a directly installed binary as local', () => { + root = tmpTree(); + const binPath = makeBin(root, BIN_NAME); + expect(findInNodeModules(BIN_NAME, root)).toEqual({ + command: binPath, + source: 'local', + }); + }); + + test('finds a hoisted binary in an ancestor as hoisted', () => { + root = tmpTree(); + const binPath = makeBin(root, BIN_NAME); + const child = path.join(root, 'packages', 'app'); + fs.mkdirSync(child, { recursive: true }); + expect(findInNodeModules(BIN_NAME, child)).toEqual({ + command: binPath, + source: 'hoisted', + }); + }); + + test('returns null when no installed copy exists', () => { + root = tmpTree(); + expect( + findInNodeModules('definitely-not-installed-xyz', root) + ).toBeNull(); + }); +}); + +describe('resolveBin', () => { + let root; + + afterEach(() => { + if (root) { + fs.rmSync(root, { recursive: true, force: true }); + root = null; + } + }); + + test('resolves a locally installed binary with no extra args', () => { + root = tmpTree(); + const binPath = makeBin(root, BIN_NAME); + expect(resolveBin(BIN_NAME, { cwd: root })).toEqual({ + command: binPath, + args: [], + source: 'local', + }); + }); + + test('resolves a hoisted binary from a child directory', () => { + root = tmpTree(); + const binPath = makeBin(root, BIN_NAME); + const child = path.join(root, 'packages', 'app'); + fs.mkdirSync(child, { recursive: true }); + expect(resolveBin(BIN_NAME, { cwd: child })).toEqual({ + command: binPath, + args: [], + source: 'hoisted', + }); + }); + + test('falls back to npx --no-install when not installed', () => { + root = tmpTree(); + expect( + resolveBin('definitely-not-installed-xyz', { cwd: root }) + ).toEqual({ + command: 'npx', + args: ['--no-install', 'definitely-not-installed-xyz'], + source: 'npx', + }); + }); +}); + +describe('detectBin', () => { + let root; + + afterEach(() => { + execFileSync.mockReset(); + if (root) { + fs.rmSync(root, { recursive: true, force: true }); + root = null; + } + }); + + test('reports available with the trimmed --version output', () => { + root = tmpTree(); + const binPath = makeBin(root, BIN_NAME); + execFileSync.mockReturnValue('1.2.3\n'); + expect(detectBin(BIN_NAME, { cwd: root })).toEqual({ + available: true, + version: '1.2.3', + command: binPath, + args: [], + source: 'local', + }); + expect(execFileSync).toHaveBeenCalledWith( + binPath, + ['--version'], + expect.objectContaining({ cwd: root }) + ); + }); + + test('reports unavailable with the error detail when the probe fails', () => { + root = tmpTree(); + execFileSync.mockImplementation(() => { + const err = new Error('spawn npx ENOENT'); + err.stderr = 'not found\n'; + throw err; + }); + const result = detectBin('definitely-not-installed-xyz', { cwd: root }); + expect(result).toEqual({ + available: false, + version: null, + command: 'npx', + args: ['--no-install', 'definitely-not-installed-xyz'], + source: 'npx', + error: 'not found', + }); + }); +}); diff --git a/node-packages/wp-tooling/tests/perf/server-profile.test.js b/node-packages/wp-tooling/tests/perf/server-profile.test.js index 25b2e58..b26d151 100644 --- a/node-packages/wp-tooling/tests/perf/server-profile.test.js +++ b/node-packages/wp-tooling/tests/perf/server-profile.test.js @@ -131,4 +131,16 @@ describe('runServerProfile', () => { expect(result.error).toBeTruthy(); expect(spawnSync).not.toHaveBeenCalled(); }); + + test('degrades (never throws) when spawnSync itself throws — e.g. an empty server.command', () => { + spawnSync.mockImplementation(() => { + throw new TypeError('The "file" argument must be of type string'); + }); + const result = runServerProfile( + { ...SERVER, command: [] }, + 'http://localhost:8765/' + ); + expect(result.data).toBeNull(); + expect(result.error).toMatch(/file.*argument/); + }); }); diff --git a/node-packages/wp-tooling/tests/scaffolds/add.test.js b/node-packages/wp-tooling/tests/scaffolds/add.test.js index 15b24a0..4c25d14 100644 --- a/node-packages/wp-tooling/tests/scaffolds/add.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/add.test.js @@ -209,6 +209,61 @@ describe('add command non-interactive flow', () => { }); }); +describe('add command human report', () => { + async function buildProjectWithDevDeps(cwd) { + const scaffoldDir = path.join( + cwd, + 'bin', + 'scaffolds', + 'test', + 'devdeps' + ); + await fs.mkdir(path.join(scaffoldDir, 'templates'), { + recursive: true, + }); + await fs.writeFile( + path.join(scaffoldDir, 'scaffold.json'), + JSON.stringify({ + slug: 'devdeps', + category: 'test', + name: 'Dev deps', + description: + 'Scaffold with dev dependencies, for report testing.', + source: 'template', + files: [{ src: 'templates/out.txt.mustache', dest: 'out.txt' }], + npm_dev_dependencies: { 'pa11y-ci': '^6.0.0' }, + }), + 'utf8' + ); + await fs.writeFile( + path.join(scaffoldDir, 'templates/out.txt.mustache'), + 'ok\n', + 'utf8' + ); + } + + it('prints an "Install (npm dev):" section for npm_dev_dependencies', async () => { + const cwd = makeTmpDir(); + await buildProjectWithDevDeps(cwd); + const originalOut = process.stdout.write.bind(process.stdout); + let captured = ''; + process.stdout.write = (chunk) => { + captured += chunk; + return true; + }; + const code = await add.runCli([ + 'test/devdeps', + '--non-interactive', + '--cwd', + cwd, + ]); + process.stdout.write = originalOut; + expect(code).toBe(0); + expect(captured).toContain('Install (npm dev):'); + expect(captured).toContain('npm install --save-dev pa11y-ci@^6.0.0'); + }); +}); + describe('add debug logging does not leak raw argument values', () => { let logPath; diff --git a/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js b/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js index dfbbf30..ed1265e 100644 --- a/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js @@ -212,6 +212,31 @@ describe('setup/perf rendered config', () => { ]); }); + it('renders the profile:server npm script from the resolved server_env_cwd, not a hardcoded plugin-path guess', async () => { + const r = registry; + + const pluginResult = await r.execute( + 'setup/perf', + { + base_url: 'http://localhost:8888', + server_env_cwd: 'wp-content/plugins/dummy-plugin', + }, + { dryRun: true, cwd: makeTmpDir() } + ); + expect(pluginResult.developer.scripts.npm['profile:server']).toBe( + 'wp-env run cli --env-cwd=wp-content/plugins/dummy-plugin -- wp eval-file server-profile.php' + ); + + const rootResult = await r.execute( + 'setup/perf', + { base_url: 'http://localhost:8888', server_env_cwd: '.' }, + { dryRun: true, cwd: makeTmpDir() } + ); + expect(rootResult.developer.scripts.npm['profile:server']).toBe( + 'wp-env run cli --env-cwd=. -- wp eval-file server-profile.php' + ); + }); + it('has no safe default for server_env_cwd, since a wrong guess would silently point the server layer at the wrong shim location', async () => { const r = registry; const target = makeTmpDir(); From fd4257a5b29b07e51138c0fdcecbb469fd2c28aa Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Tue, 1 Sep 2026 10:06:58 +0530 Subject: [PATCH 06/13] perf: readability refactors in normalize and resolve-module --- .../wp-tooling/src/perf/normalize.js | 244 ++++++++++-------- .../wp-tooling/src/perf/resolve-bin.js | 2 +- .../wp-tooling/src/perf/resolve-module.js | 2 +- 3 files changed, 135 insertions(+), 113 deletions(-) diff --git a/node-packages/wp-tooling/src/perf/normalize.js b/node-packages/wp-tooling/src/perf/normalize.js index f87cfe5..c482864 100644 --- a/node-packages/wp-tooling/src/perf/normalize.js +++ b/node-packages/wp-tooling/src/perf/normalize.js @@ -49,10 +49,7 @@ const FAILING_AUDIT_SCORE_THRESHOLD = 0.9; */ function rateMetric(name, value) { const band = THRESHOLDS[name]; - if (!band) { - return 'good'; - } - if (value <= band[0]) { + if (!band || value <= band[0]) { return 'good'; } if (value <= band[1]) { @@ -61,6 +58,34 @@ function rateMetric(name, value) { return 'poor'; } +/** + * Extract and rank an LHR's failing audits (score below the threshold). + * + * @param {Object} audits Raw LHR `audits` map. + * @param {number} topAudits Max entries to return. + * @return {Object[]} Failing audits, ascending by score. + */ +function extractFailingAudits(audits, topAudits) { + return Object.entries(audits) + .filter( + ([, audit]) => + audit && + typeof audit.score === 'number' && + audit.score < FAILING_AUDIT_SCORE_THRESHOLD + ) + .map(([id, audit]) => ({ + id, + title: typeof audit.title === 'string' ? audit.title : id, + score: audit.score, + displayValue: + typeof audit.displayValue === 'string' + ? audit.displayValue + : null, + })) + .sort((a, b) => a.score - b.score) + .slice(0, topAudits); +} + /** * Extract Lighthouse category scores + top failing audits from a raw LHR. * @@ -87,26 +112,8 @@ function extractLighthouse(lhr, options = {}) { const audits = lhr.audits && typeof lhr.audits === 'object' ? lhr.audits : {}; - const failing = Object.entries(audits) - .filter( - ([, audit]) => - audit && - typeof audit.score === 'number' && - audit.score < FAILING_AUDIT_SCORE_THRESHOLD - ) - .map(([id, audit]) => ({ - id, - title: typeof audit.title === 'string' ? audit.title : id, - score: audit.score, - displayValue: - typeof audit.displayValue === 'string' - ? audit.displayValue - : null, - })) - .sort((a, b) => a.score - b.score) - .slice(0, topAudits); - return { scores, audits: failing }; + return { scores, audits: extractFailingAudits(audits, topAudits) }; } /** @@ -238,6 +245,97 @@ function rateMeasurableMetric(name, vitalMetric, url, cwvMode) { }; } +/** + * Build the per-URL result entry for a browser scan failure — empty metrics + * and no assessment; the server layer (which runs independently of the + * browser) is unaffected. + * + * @param {Object} raw Raw per-URL capture with a truthy `scanError`. + * @return {Object} Result entry for `normalizePerf`'s `results[]`. + */ +function buildScanErrorResult(raw) { + return { + url: raw.url, + scanError: raw.scanError, + metrics: Object.fromEntries(METRIC_NAMES.map((name) => [name, null])), + attribution: { lcpElement: null, clsSources: [], inpTarget: null }, + lighthouse: null, + server: normalizeServer(raw.server), + assessment: [], + notes: raw.notes || [], + }; +} + +/** + * Build the per-URL result entry for a page that loaded successfully, and + * fold its issue count / worst-candidate / pass-fail outcome into `totals`. + * + * @param {Object} raw Raw per-URL capture (no `scanError`). + * @param {Object} thresholds Resolved `thresholds` config section. + * @param {Object} totals Running summary totals, mutated in place. + * @return {Object} Result entry for `normalizePerf`'s `results[]`. + */ +function buildUrlResult(raw, thresholds, totals) { + const vitals = raw.vitals || { metrics: {}, attribution: {} }; + const metrics = {}; + let urlIssues = 0; + + for (const name of MEASURABLE_METRIC_NAMES) { + const { metric, isIssue, worstCandidate } = rateMeasurableMetric( + name, + vitals.metrics[name], + raw.url, + thresholds.cwv + ); + metrics[name] = metric; + if (isIssue) { + urlIssues++; + } + if ( + worstCandidate && + (!totals.worst || worstCandidate.severity > totals.worst.severity) + ) { + totals.worst = worstCandidate; + } + } + metrics.INP = null; + + const lighthouse = raw.lighthouse; + if ( + lighthouse && + typeof lighthouse.scores.performance === 'number' && + typeof thresholds.lighthousePerformance === 'number' && + lighthouse.scores.performance < thresholds.lighthousePerformance + ) { + urlIssues++; + } + + totals.issues += urlIssues; + + const notes = raw.notes ? [...raw.notes] : []; + if (raw.vitalsError) { + notes.push(raw.vitalsError); + totals.failedUrls++; + } else if (urlIssues === 0) { + totals.passedUrls++; + } + + return { + url: raw.url, + scanError: null, + metrics, + attribution: vitals.attribution || { + lcpElement: null, + clsSources: [], + inpTarget: null, + }, + lighthouse, + server: normalizeServer(raw.server), + assessment: buildAssessment(metrics, lighthouse, thresholds), + notes, + }; +} + /** * Normalise raw per-URL perf capture into the final two-layer report. * @@ -257,106 +355,30 @@ function normalizePerf(rawResults, options = {}) { }; const results = []; - let passedUrls = 0; - let failedUrls = 0; - let issues = 0; - let worst = null; + const totals = { passedUrls: 0, failedUrls: 0, issues: 0, worst: null }; for (const raw of rawResults || []) { if (raw.scanError) { - failedUrls++; - results.push({ - url: raw.url, - scanError: raw.scanError, - metrics: Object.fromEntries( - METRIC_NAMES.map((name) => [name, null]) - ), - attribution: { - lcpElement: null, - clsSources: [], - inpTarget: null, - }, - lighthouse: null, - server: normalizeServer(raw.server), - assessment: [], - notes: raw.notes || [], - }); + totals.failedUrls++; + results.push(buildScanErrorResult(raw)); continue; } - - const vitals = raw.vitals || { metrics: {}, attribution: {} }; - const metrics = {}; - let urlIssues = 0; - - for (const name of MEASURABLE_METRIC_NAMES) { - const { metric, isIssue, worstCandidate } = rateMeasurableMetric( - name, - vitals.metrics[name], - raw.url, - thresholds.cwv - ); - metrics[name] = metric; - if (isIssue) { - urlIssues++; - } - if ( - worstCandidate && - (!worst || worstCandidate.severity > worst.severity) - ) { - worst = worstCandidate; - } - } - metrics.INP = null; - - const lighthouse = raw.lighthouse; - if ( - lighthouse && - typeof lighthouse.scores.performance === 'number' && - typeof thresholds.lighthousePerformance === 'number' && - lighthouse.scores.performance < thresholds.lighthousePerformance - ) { - urlIssues++; - } - - issues += urlIssues; - - const notes = raw.notes ? [...raw.notes] : []; - if (raw.vitalsError) { - notes.push(raw.vitalsError); - failedUrls++; - } else if (urlIssues === 0) { - passedUrls++; - } - - results.push({ - url: raw.url, - scanError: null, - metrics, - attribution: vitals.attribution || { - lcpElement: null, - clsSources: [], - inpTarget: null, - }, - lighthouse, - server: normalizeServer(raw.server), - assessment: buildAssessment(metrics, lighthouse, thresholds), - notes, - }); + results.push(buildUrlResult(raw, thresholds, totals)); } return { tool: 'web-vitals+lighthouse', summary: { urls: results.length, - passedUrls, - failedUrls, - issues, - worst: worst + passedUrls: totals.passedUrls, + failedUrls: totals.failedUrls, + issues: totals.issues, + worst: totals.worst ? { - metric: worst.metric, - url: worst.url, - value: worst.value, - rating: worst.rating, + metric: totals.worst.metric, + url: totals.worst.url, + value: totals.worst.value, + rating: totals.worst.rating, } : null, }, diff --git a/node-packages/wp-tooling/src/perf/resolve-bin.js b/node-packages/wp-tooling/src/perf/resolve-bin.js index e0ebb7e..91f3949 100644 --- a/node-packages/wp-tooling/src/perf/resolve-bin.js +++ b/node-packages/wp-tooling/src/perf/resolve-bin.js @@ -27,7 +27,7 @@ const VERSION_PROBE_TIMEOUT_MS = 20000; function findInNodeModules(binName, cwd) { const start = path.resolve(cwd); let dir = start; - for (;;) { + while (true) { const candidate = path.join(dir, 'node_modules', '.bin', binName); if (fs.existsSync(candidate)) { return { diff --git a/node-packages/wp-tooling/src/perf/resolve-module.js b/node-packages/wp-tooling/src/perf/resolve-module.js index 878bfab..12e30aa 100644 --- a/node-packages/wp-tooling/src/perf/resolve-module.js +++ b/node-packages/wp-tooling/src/perf/resolve-module.js @@ -24,7 +24,7 @@ const path = require('path'); function findModuleDir(moduleName, cwd) { const start = path.resolve(cwd); let dir = start; - for (;;) { + while (true) { const candidate = path.join(dir, 'node_modules', moduleName); if (fs.existsSync(path.join(candidate, 'package.json'))) { return { From 80849f60b1af3991ea9d76dfaa0eb8fbc0f40d00 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Tue, 1 Sep 2026 11:27:20 +0530 Subject: [PATCH 07/13] =?UTF-8?q?fix(perf):=20address=20Copilot=20review?= =?UTF-8?q?=20=E2=80=94=20web-vitals=20v5=20API,=20dry-run=20safety,=20ser?= =?UTF-8?q?ver=20resilience?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - read attribution.target (not element) for web-vitals v5; use ?? for settleMs/timeoutMs - skip malformed xhprof entries instead of aborting the entire perf run - unwind output buffers to pre-render level in server-profile.php - tryParse iterates [/{ candidates past preambles; non-zero exit checked before parsing - dry-run uses resolveBin so lighthouse --version is never spawned - pre-render script templates before file writes; validate checks scripts alongside files - emit server fidelity note in default text output --- .../setup/perf/templates/server-profile.php | 7 +++- .../wp-tooling/src/perf/collect-vitals.js | 8 ++--- .../wp-tooling/src/perf/normalize.js | 3 ++ node-packages/wp-tooling/src/perf/run.js | 13 ++++--- .../wp-tooling/src/perf/server-profile.js | 32 +++++++++++------ .../wp-tooling/src/scaffolds/registry.js | 16 ++++----- .../wp-tooling/src/scaffolds/validate.js | 18 ++++++++++ .../wp-tooling/tests/perf/cli.test.js | 9 ++--- .../tests/perf/collect-vitals.test.js | 20 +++++++++-- .../wp-tooling/tests/perf/normalize.test.js | 22 ++++++++++++ .../tests/perf/server-profile.test.js | 33 +++++++++++++++++ .../tests/scaffolds/registry.test.js | 35 +++++++++++++++++++ .../tests/scaffolds/validate.test.js | 22 ++++++++++++ 13 files changed, 204 insertions(+), 34 deletions(-) diff --git a/node-packages/wp-tooling/scaffolds/setup/perf/templates/server-profile.php b/node-packages/wp-tooling/scaffolds/setup/perf/templates/server-profile.php index 40ac329..90c934d 100644 --- a/node-packages/wp-tooling/scaffolds/setup/perf/templates/server-profile.php +++ b/node-packages/wp-tooling/scaffolds/setup/perf/templates/server-profile.php @@ -82,13 +82,18 @@ static function () use ( $server_profile_profiler, $server_profile_top ): void { $server_profile_profiler->start(); +// wp()/template-loader.php may open their own nested buffers; unwind to +// the level recorded here rather than assuming only one was opened. +$server_profile_ob_level = ob_get_level(); ob_start(); wp(); if ( ! defined( 'WP_USE_THEMES' ) ) { define( 'WP_USE_THEMES', true ); } require ABSPATH . WPINC . '/template-loader.php'; -ob_end_clean(); +while ( ob_get_level() > $server_profile_ob_level ) { + ob_end_clean(); +} echo wp_json_encode( $server_profile_profiler->stop( $server_profile_top, 'server-profile' ) ) . PHP_EOL; diff --git a/node-packages/wp-tooling/src/perf/collect-vitals.js b/node-packages/wp-tooling/src/perf/collect-vitals.js index b06cc19..1b05500 100644 --- a/node-packages/wp-tooling/src/perf/collect-vitals.js +++ b/node-packages/wp-tooling/src/perf/collect-vitals.js @@ -32,7 +32,7 @@ window.__wpToolingVitals = {}; value: metric.value, rating: metric.rating, attribution: { - element: (metric.attribution && metric.attribution.element) || null, + target: (metric.attribution && metric.attribution.target) || null, largestShiftTarget: (metric.attribution && metric.attribution.largestShiftTarget) || null, interactionTarget: (metric.attribution && metric.attribution.interactionTarget) || null, }, @@ -85,8 +85,8 @@ async function launchBrowser(puppeteer, options = {}) { * Rejects when the page fails to load (the caller records this as a per-URL scan error). */ async function collectVitals(browser, scriptSource, url, options = {}) { - const settleMs = options.settleMs || 3000; - const timeoutMs = options.timeoutMs || 30000; + const settleMs = options.settleMs ?? 3000; + const timeoutMs = options.timeoutMs ?? 30000; const page = await browser.newPage(); try { @@ -127,7 +127,7 @@ function buildResult(raw) { return { metrics, attribution: { - lcpElement: lcpAttr.element || null, + lcpElement: lcpAttr.target || null, clsSources: clsAttr.largestShiftTarget ? [clsAttr.largestShiftTarget] : [], diff --git a/node-packages/wp-tooling/src/perf/normalize.js b/node-packages/wp-tooling/src/perf/normalize.js index c482864..b192cd1 100644 --- a/node-packages/wp-tooling/src/perf/normalize.js +++ b/node-packages/wp-tooling/src/perf/normalize.js @@ -131,6 +131,9 @@ function normalizeServer(result) { const top = []; if (data && typeof data === 'object' && !Array.isArray(data)) { for (const [fn, stat] of Object.entries(data)) { + if (!stat || typeof stat !== 'object' || Array.isArray(stat)) { + continue; + } top.push({ fn, calls: Number(stat.ct) || 0, diff --git a/node-packages/wp-tooling/src/perf/run.js b/node-packages/wp-tooling/src/perf/run.js index 361efcd..e332085 100644 --- a/node-packages/wp-tooling/src/perf/run.js +++ b/node-packages/wp-tooling/src/perf/run.js @@ -27,7 +27,7 @@ const { requireModule, detectModule, } = require('./resolve-module'); -const { detectBin } = require('./resolve-bin'); +const { detectBin, resolveBin } = require('./resolve-bin'); const { launchBrowser, collectVitals } = require('./collect-vitals'); const { runLighthouse, BIN: LIGHTHOUSE_BIN } = require('./lighthouse'); const { runServerProfile } = require('./server-profile'); @@ -309,6 +309,9 @@ function emit(report, mode) { .map((entry) => `${entry.fn} (${entry.wallMs.toFixed(1)}ms)`) .join(', '); lines.push(` server top: ${top || 'none'}`); + if (result.server.note) { + lines.push(` server note: ${result.server.note}`); + } if (result.server.error) { lines.push(` server error: ${result.server.error}`); } @@ -392,9 +395,11 @@ function runDryRun(opts, cwd) { ]; if (config.lighthouse.enabled) { - const bin = detectBin(LIGHTHOUSE_BIN, { cwd }); - const state = bin.available ? bin.version : 'NOT FOUND'; - lines.push(` lighthouse: ${bin.command} (${bin.source}, ${state})`); + // resolveBin only -- dry-run must not spawn lighthouse --version. + const bin = resolveBin(LIGHTHOUSE_BIN, { cwd }); + lines.push( + ` lighthouse: ${bin.command} (${bin.source}, not probed — dry run)` + ); } else { lines.push(' lighthouse: disabled'); } diff --git a/node-packages/wp-tooling/src/perf/server-profile.js b/node-packages/wp-tooling/src/perf/server-profile.js index 3aca6a9..0d84897 100644 --- a/node-packages/wp-tooling/src/perf/server-profile.js +++ b/node-packages/wp-tooling/src/perf/server-profile.js @@ -52,15 +52,17 @@ function tryParse(text) { const trimmed = text.trim(); // Find where JSON actually starts -- an array `[` or object `{` -- so a // leading non-JSON preamble line (e.g. a PHP notice) doesn't break parsing. - const start = trimmed.search(/[[{]/); - if (start === -1) { - return null; - } - try { - return JSON.parse(trimmed.slice(start)); - } catch { - return null; + // A failed candidate may itself be non-JSON text, so keep searching. + let start = trimmed.search(/[[{]/); + while (start !== -1) { + try { + return JSON.parse(trimmed.slice(start)); + } catch { + const next = trimmed.slice(start + 1).search(/[[{]/); + start = next === -1 ? -1 : start + next + 1; + } } + return null; } /** @@ -123,11 +125,19 @@ function runServerProfile(server, url, options = {}) { return { data: null, diagnostic, error: result.error.message }; } + // A non-zero exit can still leave parseable JSON on stdout (e.g. a fatal + // render error), so check status before trusting the payload. + if (result.status !== 0) { + return { + data: null, + diagnostic, + error: `non-zero exit (${result.status === null ? 'null (timed out?)' : result.status})`, + }; + } + const parsed = tryParse((result.stdout || '').toString()); if (parsed === null) { - const detail = - diagnostic || - `exit code ${result.status === null ? 'null (timed out?)' : result.status}`; + const detail = diagnostic || 'exit code 0'; return { data: null, diagnostic, diff --git a/node-packages/wp-tooling/src/scaffolds/registry.js b/node-packages/wp-tooling/src/scaffolds/registry.js index 8819c61..8446be4 100644 --- a/node-packages/wp-tooling/src/scaffolds/registry.js +++ b/node-packages/wp-tooling/src/scaffolds/registry.js @@ -306,6 +306,13 @@ class ScaffoldRegistry { const discovery = await loadDiscovery(cwd); const resolved = resolveInputs(scaffold, inputs, discovery); + // Rendered before any write, so a bad placeholder throws before partial writes. + const scaffoldScripts = scaffold.scripts || {}; + const renderedScripts = { + npm: renderScriptMap(scaffoldScripts.npm, resolved), + composer: renderScriptMap(scaffoldScripts.composer, resolved), + }; + // Warn on supplied keys the scaffold does not declare. Typos like // `--namspace=Inc` would otherwise be silently dropped while the // real `namespace` input falls back to its default. Soft warning, @@ -478,7 +485,6 @@ class ScaffoldRegistry { }); } - const scaffoldScripts = scaffold.scripts || {}; return { scaffold: { id: makeId(scaffold), @@ -508,13 +514,7 @@ class ScaffoldRegistry { npm: { ...(scaffold.npm_dependencies || {}) }, npmDev: { ...(scaffold.npm_dev_dependencies || {}) }, }, - scripts: { - npm: renderScriptMap(scaffoldScripts.npm, resolved), - composer: renderScriptMap( - scaffoldScripts.composer, - resolved - ), - }, + scripts: renderedScripts, secrets: (scaffold.secrets || []).map((s) => ({ ...s })), }, ai: { wiring: aiWiring, tests: aiTests }, diff --git a/node-packages/wp-tooling/src/scaffolds/validate.js b/node-packages/wp-tooling/src/scaffolds/validate.js index 20a71dd..3e32a6b 100644 --- a/node-packages/wp-tooling/src/scaffolds/validate.js +++ b/node-packages/wp-tooling/src/scaffolds/validate.js @@ -669,6 +669,24 @@ function checkTemplatesRender(scaffold, scaffoldDir) { errs.push(`wiring snippet_template render failed: ${err.message}`); } } + for (const target of ALLOWED_SCRIPT_TARGETS) { + const map = (scaffold.scripts && scaffold.scripts[target]) || {}; + for (const [name, cmd] of Object.entries(map)) { + const missing = collectPlaceholders(cmd).filter( + (k) => !(k in supply) + ); + for (const k of missing) { + supply[k] = 'x'; + } + try { + render(cmd, supply); + } catch (err) { + errs.push( + `scripts.${target}['${name}'] render failed: ${err.message}` + ); + } + } + } return errs; } diff --git a/node-packages/wp-tooling/tests/perf/cli.test.js b/node-packages/wp-tooling/tests/perf/cli.test.js index feee251..bf75534 100644 --- a/node-packages/wp-tooling/tests/perf/cli.test.js +++ b/node-packages/wp-tooling/tests/perf/cli.test.js @@ -302,6 +302,7 @@ describe('perf runCli', () => { const out = stdout.join(''); expect(out).toMatch(/— scan failed/); expect(out).toMatch(/server top: WP_Query::get_posts/); + expect(out).toMatch(/server note: profiled via/); }); test('a lighthouse runtime failure degrades that layer without affecting the exit code', async () => { @@ -335,7 +336,7 @@ describe('perf runCli', () => { ]); expect(code).toBe(0); const parsed = JSON.parse(stdout.join('')); - expect(parsed.results[0].server.error).toMatch(/no parseable output/); + expect(parsed.results[0].server.error).toMatch(/non-zero exit \(255\)/); expect(stderr.join('')).toMatch(/server profile failed for/); }); @@ -360,17 +361,17 @@ describe('perf runCli', () => { const out = stdout.join(''); expect(out).toMatch(/\[dry-run\] perf would run:/); expect(out).toMatch(/puppeteer:/); - expect(out).toMatch(/lighthouse:/); + expect(out).toMatch(/lighthouse:.*not probed — dry run/); expect(out).toMatch(/server:/); - // Only the lighthouse --version probe ran; nothing else was invoked. + // Dry-run must not probe lighthouse (or invoke anything else). // Filtered rather than asserting the raw total, so this doesn't // depend on perfect mock-call isolation from other test files // sharing the same auto-mocked child_process module. const versionProbes = execFileSync.mock.calls.filter((call) => call[1].includes('--version') ); - expect(versionProbes).toHaveLength(1); + expect(versionProbes).toHaveLength(0); expect(spawnSync).not.toHaveBeenCalled(); expect(collectVitalsModule.launchBrowser).not.toHaveBeenCalled(); expect(collectVitalsModule.collectVitals).not.toHaveBeenCalled(); diff --git a/node-packages/wp-tooling/tests/perf/collect-vitals.test.js b/node-packages/wp-tooling/tests/perf/collect-vitals.test.js index 37386ed..c83d3db 100644 --- a/node-packages/wp-tooling/tests/perf/collect-vitals.test.js +++ b/node-packages/wp-tooling/tests/perf/collect-vitals.test.js @@ -68,7 +68,7 @@ describe('collectVitals', () => { LCP: { value: 2431.2, rating: 'good', - attribution: { element: 'img.hero' }, + attribution: { target: 'img.hero' }, }, }, }); @@ -94,6 +94,22 @@ describe('collectVitals', () => { expect(result.attribution.lcpElement).toBe('img.hero'); }); + test('an explicit 0 for settleMs/timeoutMs is respected, not defaulted', async () => { + const { page, calls } = fakePage({ harvested: {} }); + const browser = { newPage: jest.fn(async () => page) }; + + await collectVitals(browser, '/* iife */', 'http://localhost:8888/', { + settleMs: 0, + timeoutMs: 0, + }); + + expect(calls[1]).toEqual([ + 'goto', + 'http://localhost:8888/', + { waitUntil: 'networkidle2', timeout: 0 }, + ]); + }); + test('a goto rejection propagates, but the page is still closed', async () => { const gotoError = new Error('net::ERR_CONNECTION_REFUSED'); const { page, calls } = fakePage({ gotoError }); @@ -115,7 +131,7 @@ describe('buildResult', () => { LCP: { value: 2000, rating: 'good', - attribution: { element: 'img' }, + attribution: { target: 'img' }, }, CLS: { value: 0.05, diff --git a/node-packages/wp-tooling/tests/perf/normalize.test.js b/node-packages/wp-tooling/tests/perf/normalize.test.js index e509f53..d220795 100644 --- a/node-packages/wp-tooling/tests/perf/normalize.test.js +++ b/node-packages/wp-tooling/tests/perf/normalize.test.js @@ -125,6 +125,28 @@ describe('normalizeServer', () => { expect(normalized.note).toMatch(/xhprof\/tideways_xhprof/); }); + test('skips malformed entries in the profiler payload instead of throwing', () => { + const normalized = normalizeServer({ + data: { + 'WP_Query::get_posts': { + ct: 3, + wt: 41200, + cpu: 38000, + mu: 1048576, + pmu: 1148576, + }, + bad_null: null, + bad_array: [1, 2], + bad_primitive: 'oops', + }, + diagnostic: null, + error: null, + }); + expect(normalized.top.map((f) => f.fn)).toEqual([ + 'WP_Query::get_posts', + ]); + }); + test('an invocation failure carries the error and skips the empty-backend guidance', () => { const normalized = normalizeServer({ data: null, diff --git a/node-packages/wp-tooling/tests/perf/server-profile.test.js b/node-packages/wp-tooling/tests/perf/server-profile.test.js index b26d151..3b96e74 100644 --- a/node-packages/wp-tooling/tests/perf/server-profile.test.js +++ b/node-packages/wp-tooling/tests/perf/server-profile.test.js @@ -117,10 +117,43 @@ describe('runServerProfile', () => { spawnSync.mockReturnValue({ stdout: 'PHP Fatal error: something exploded', stderr: '', + status: 0, + }); + const result = runServerProfile(SERVER, 'http://localhost:8765/'); + expect(result.data).toBeNull(); + expect(result.error).toMatch(/no parseable output/); + }); + + test('a non-zero exit is a failure even when stdout happens to be parseable', () => { + spawnSync.mockReturnValue({ + stdout: '{"fn":{"ct":1,"wt":1,"cpu":1,"mu":1,"pmu":1}}', + stderr: '', status: 255, }); const result = runServerProfile(SERVER, 'http://localhost:8765/'); expect(result.data).toBeNull(); + expect(result.error).toMatch(/non-zero exit \(255\)/); + }); + + test('keeps searching past a bracket that is not the real JSON payload', () => { + spawnSync.mockReturnValue({ + stdout: '[notice] filesystem warning\n{"fn":{"ct":1,"wt":10,"cpu":10,"mu":100,"pmu":200}}', + stderr: '', + status: 0, + }); + const result = runServerProfile(SERVER, 'http://localhost:8765/'); + expect(result.data.fn.ct).toBe(1); + expect(result.error).toBeNull(); + }); + + test('falls through to null when no candidate bracket ever parses', () => { + spawnSync.mockReturnValue({ + stdout: '[not json] {also not json', + stderr: '', + status: 0, + }); + const result = runServerProfile(SERVER, 'http://localhost:8765/'); + expect(result.data).toBeNull(); expect(result.error).toMatch(/no parseable output/); }); diff --git a/node-packages/wp-tooling/tests/scaffolds/registry.test.js b/node-packages/wp-tooling/tests/scaffolds/registry.test.js index d0d0493..2aea0be 100644 --- a/node-packages/wp-tooling/tests/scaffolds/registry.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/registry.test.js @@ -589,6 +589,41 @@ describe('execute() passes scripts through to developer block', () => { 'rtcamp/wp-php-toolkit': '^1', }); }); + + it('a bad script placeholder throws before any files are written', async () => { + const tmp = makeTmpDir(); + const sDir = path.join(tmp, 'lint'); + await fs.mkdir(sDir, { recursive: true }); + await fs.writeFile( + path.join(sDir, 'scaffold.json'), + JSON.stringify({ + slug: 'eslint', + category: 'lint', + name: 'ESLint', + description: 'ESLint', + source: 'template', + files: [{ src: 'eslintrc.mustache', dest: '.eslintrc.js' }], + scripts: { + npm: { 'lint:js': '{{does_not_exist}}' }, + }, + }), + 'utf8' + ); + await fs.writeFile( + path.join(sDir, 'eslintrc.mustache'), + 'module.exports = {};', + 'utf8' + ); + const r = new ScaffoldRegistry({ projectDir: tmp }); + await r.scan(); + const targetDir = makeTmpDir(); + await expect( + r.execute('lint/eslint', {}, { cwd: targetDir }) + ).rejects.toMatchObject({ code: 'ERENDERFAIL' }); + expect(fssync.existsSync(path.join(targetDir, '.eslintrc.js'))).toBe( + false + ); + }); }); describe('execute() never embeds secret values', () => { diff --git a/node-packages/wp-tooling/tests/scaffolds/validate.test.js b/node-packages/wp-tooling/tests/scaffolds/validate.test.js index e54aa9a..aa6b546 100644 --- a/node-packages/wp-tooling/tests/scaffolds/validate.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/validate.test.js @@ -409,6 +409,28 @@ describe('validateOne on-disk checks', () => { fssync.rmSync(dir, { recursive: true, force: true }); } }); + + test('scripts commands are render-checked alongside files/tests/wiring', () => { + const { dir, file } = makeOnDiskScaffold({ + slug: 'local', + category: 'wp', + name: 'Local', + description: 'fixture', + source: 'template', + scripts: { npm: { 'lint:js': 123 } }, + }); + try { + const result = validateOne(file); + expect(result.valid).toBe(false); + expect( + result.errors.some((e) => + /scripts\.npm\['lint:js'\] render failed/.test(e) + ) + ).toBe(true); + } finally { + fssync.rmSync(dir, { recursive: true, force: true }); + } + }); }); function makeTmpDir() { From c7221b6bd9700d03388191fb2ecc45e282d81bb7 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Wed, 2 Sep 2026 12:01:34 +0530 Subject: [PATCH 08/13] fix(scaffolds): scan script maps in inferPlaceholders fallback --- .../wp-tooling/src/scaffolds/registry.js | 8 +++++ .../tests/scaffolds/registry.test.js | 36 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/node-packages/wp-tooling/src/scaffolds/registry.js b/node-packages/wp-tooling/src/scaffolds/registry.js index 8446be4..2c07c20 100644 --- a/node-packages/wp-tooling/src/scaffolds/registry.js +++ b/node-packages/wp-tooling/src/scaffolds/registry.js @@ -1113,6 +1113,14 @@ function inferPlaceholders(scaffold) { seen.add(p); } } + for (const target of ['npm', 'composer']) { + const map = (scaffold.scripts && scaffold.scripts[target]) || {}; + for (const cmd of Object.values(map)) { + for (const p of collectPlaceholders(cmd)) { + seen.add(p); + } + } + } return Array.from(seen); } diff --git a/node-packages/wp-tooling/tests/scaffolds/registry.test.js b/node-packages/wp-tooling/tests/scaffolds/registry.test.js index 2aea0be..cc2b37a 100644 --- a/node-packages/wp-tooling/tests/scaffolds/registry.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/registry.test.js @@ -602,6 +602,7 @@ describe('execute() passes scripts through to developer block', () => { name: 'ESLint', description: 'ESLint', source: 'template', + inputs: [], files: [{ src: 'eslintrc.mustache', dest: '.eslintrc.js' }], scripts: { npm: { 'lint:js': '{{does_not_exist}}' }, @@ -624,6 +625,41 @@ describe('execute() passes scripts through to developer block', () => { false ); }); + + it('a supplied value used only in a script survives the no-inputs[] fallback', async () => { + const tmp = makeTmpDir(); + const sDir = path.join(tmp, 'lint'); + await fs.mkdir(sDir, { recursive: true }); + await fs.writeFile( + path.join(sDir, 'scaffold.json'), + JSON.stringify({ + slug: 'eslint', + category: 'lint', + name: 'ESLint', + description: 'ESLint', + source: 'template', + files: [{ src: 'eslintrc.mustache', dest: '.eslintrc.js' }], + scripts: { + npm: { greet: 'echo {{message}}' }, + }, + }), + 'utf8' + ); + await fs.writeFile( + path.join(sDir, 'eslintrc.mustache'), + 'module.exports = {};', + 'utf8' + ); + const r = new ScaffoldRegistry({ projectDir: tmp }); + await r.scan(); + const targetDir = makeTmpDir(); + const result = await r.execute( + 'lint/eslint', + { message: 'hello' }, + { cwd: targetDir } + ); + expect(result.developer.scripts.npm.greet).toBe('echo hello'); + }); }); describe('execute() never embeds secret values', () => { From 04bdcdd8b773d30f9677ad291988e284078a0c96 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Wed, 23 Sep 2026 19:39:05 +0530 Subject: [PATCH 09/13] =?UTF-8?q?fix(perf):=20address=20Copilot=20review?= =?UTF-8?q?=20=E2=80=94=20error=20classification,=20escaping,=20npx,=20eng?= =?UTF-8?q?ines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .nvmrc | 1 + node-packages/eslint-config/CHANGELOG.md | 6 +++ node-packages/eslint-config/package.json | 2 +- node-packages/stylelint-config/CHANGELOG.md | 6 +++ node-packages/stylelint-config/package.json | 2 +- node-packages/tailwind-config/CHANGELOG.md | 6 +++ node-packages/tailwind-config/package.json | 2 +- node-packages/wp-tooling/CHANGELOG.md | 9 +++- .../wp-tooling/docs/authoring-scaffolds.md | 1 + node-packages/wp-tooling/package.json | 2 +- .../scaffolds/setup/perf/scaffold.json | 28 ++++++++++--- .../perf/templates/.perfrc.json.mustache | 2 +- .../wp-tooling/src/perf/collect-vitals.js | 15 ++++++- node-packages/wp-tooling/src/perf/config.js | 11 ++++- node-packages/wp-tooling/src/perf/errors.js | 2 + .../wp-tooling/src/perf/resolve-bin.js | 3 ++ .../wp-tooling/src/perf/resolve-module.js | 9 ++-- node-packages/wp-tooling/src/perf/run.js | 24 +++++++---- .../wp-tooling/src/perf/server-profile.js | 8 +++- .../wp-tooling/src/scaffolds/render.js | 5 +++ .../wp-tooling/src/scaffolds/schema.js | 1 + .../wp-tooling/tests/perf/cli.test.js | 39 +++++++++++++++++- .../tests/perf/collect-vitals.test.js | 5 ++- .../tests/perf/fixtures/.perfrc.json | 2 +- .../tests/scaffolds/bundled-manifests.test.js | 41 +++++++++++++++++++ .../wp-tooling/tests/scaffolds/render.test.js | 10 +++++ package-lock.json | 10 ++--- package.json | 2 +- 28 files changed, 213 insertions(+), 41 deletions(-) create mode 100644 .nvmrc diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..e222811 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.19.0 diff --git a/node-packages/eslint-config/CHANGELOG.md b/node-packages/eslint-config/CHANGELOG.md index 0783e5b..7d08709 100644 --- a/node-packages/eslint-config/CHANGELOG.md +++ b/node-packages/eslint-config/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to `@rtcamp/eslint-config` are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Changed + +- `engines.node` raised to `>=22.19` to match the rest of the monorepo (`@rtcamp/wp-tooling` needs it for Lighthouse 13). + ## [1.0.0] - 2026-07-30 ### Added diff --git a/node-packages/eslint-config/package.json b/node-packages/eslint-config/package.json index 2625dbc..07cf3ff 100644 --- a/node-packages/eslint-config/package.json +++ b/node-packages/eslint-config/package.json @@ -18,7 +18,7 @@ "access": "restricted" }, "engines": { - "node": ">=22" + "node": ">=22.19" }, "main": "index.js", "files": [ diff --git a/node-packages/stylelint-config/CHANGELOG.md b/node-packages/stylelint-config/CHANGELOG.md index a1a6f19..66aef49 100644 --- a/node-packages/stylelint-config/CHANGELOG.md +++ b/node-packages/stylelint-config/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to `@rtcamp/stylelint-config` are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Changed + +- `engines.node` raised to `>=22.19` to match the rest of the monorepo (`@rtcamp/wp-tooling` needs it for Lighthouse 13). + ## [1.0.0] - 2026-07-30 ### Added diff --git a/node-packages/stylelint-config/package.json b/node-packages/stylelint-config/package.json index d0b6e55..0a2c707 100644 --- a/node-packages/stylelint-config/package.json +++ b/node-packages/stylelint-config/package.json @@ -18,7 +18,7 @@ "access": "restricted" }, "engines": { - "node": ">=22" + "node": ">=22.19" }, "main": "index.js", "files": [ diff --git a/node-packages/tailwind-config/CHANGELOG.md b/node-packages/tailwind-config/CHANGELOG.md index 46e376a..24aeedf 100644 --- a/node-packages/tailwind-config/CHANGELOG.md +++ b/node-packages/tailwind-config/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to `@rtcamp/tailwind-config` are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Changed + +- `engines.node` raised to `>=22.19` to match the rest of the monorepo (`@rtcamp/wp-tooling` needs it for Lighthouse 13). + ## [1.0.0] - 2026-07-30 ### Added diff --git a/node-packages/tailwind-config/package.json b/node-packages/tailwind-config/package.json index 66c464e..5fc5a63 100644 --- a/node-packages/tailwind-config/package.json +++ b/node-packages/tailwind-config/package.json @@ -18,7 +18,7 @@ "access": "restricted" }, "engines": { - "node": ">=22" + "node": ">=22.19" }, "main": "index.js", "exports": { diff --git a/node-packages/wp-tooling/CHANGELOG.md b/node-packages/wp-tooling/CHANGELOG.md index c3caf8e..1608fb7 100644 --- a/node-packages/wp-tooling/CHANGELOG.md +++ b/node-packages/wp-tooling/CHANGELOG.md @@ -4,14 +4,19 @@ All notable changes to `@rtcamp/wp-tooling` are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). -## [1.0.0] - 2026-07-30 +## [Unreleased] ### Added - `wp-tooling a11y` subcommand + `@rtcamp/wp-tooling/a11y` library (`runA11y()` core, `runCli()` CLI adapter). Runs the consumer-installed `pa11y-ci` — resolved from the nearest local or hoisted `node_modules/pa11y-ci/package.json` and launched through its `bin` entry with Node; no shim execution, npx fallback, or network install — against the URLs in the project's pa11y config (`.pa11yci.json` by default, `--config ` to point elsewhere) and normalises the JSON into a stable report: `summary` counts plus per-URL `violations` carrying `id`, parsed `wcagCriterion`, `impact`, `runner`, `message`, `selector`, `context`, and grep-ready `domHints` (`tagName`, `classList`, `idAttr`, `attrs`). A URL that fails to load is a `scanError` counted in `summary.failedUrls`, never a violation. Exit codes: 0 clean · 1 run failure or unreachable URL · 2 usage/binary missing · 3 violations found. Supports `--output text|json` and `--dry-run`. The `setup/pa11y` scaffold's dependency pin is corrected to the published `pa11y-ci ^4.1.1`, and its config template now runs both engines (`"runners": ["axe", "htmlcs"]`) and scans project-owned URLs — the front page plus configurable page paths (`sample_page`, `search_page`, optional `extra_page`, each appended to `base_url`) — instead of `wp-admin`/`wp-login` (unauthenticated admin scans only ever audit the core-owned login chrome). - `accessibility` Claude Code skill (`skills/accessibility/`) — a find → fix → re-check lens over `wp-tooling a11y`: triages violations by WCAG criterion and impact, maps each one to the theme/plugin source that rendered it via the report's `domHints`, proposes minimal fixes with consent, and re-verifies until clean; core/third-party markup is classified as upstream and reported, never patched. Installed into consumers by `setup/claude-skills` alongside the `scaffold` and `setup` skills. -- `wp-tooling perf` — two-layer performance runner mirroring `a11y`. Layer 1 (frontend, always on): launches consumer-installed `puppeteer`, injects the `web-vitals` attribution build, and collects LCP/CLS/FCP/TTFB with `reportAllChanges: true` (LCP/CLS never finalize headless without input, so the latest reported candidate is harvested after a settle delay); INP is always `null` in the lab layer (no interaction is performed). Optional Lighthouse pass (`--only-categories=performance`, pinned to the puppeteer-installed Chrome via `CHROME_PATH`) contributes category scores + top failing audits. Layer 2 (server, opt-in via `server.enabled`): runs the consumer's `server-profile.php` shim over WP-CLI (`wp eval-file`) to get xhprof/tideways function hotspots, normalized with a CLI-context fidelity note; degrades to an empty `top[]` with guidance — never an error — when no backend or `rtcamp/wp-dev-tools` is installed, and a broken invocation degrades the same way rather than failing the run (the server layer is auxiliary cause-data). `src/perf/{errors,resolve-bin,resolve-module,config,collect-vitals,lighthouse,server-profile,normalize,run,index}.js`; `"./perf"` exports entry; `src/cli/commands/perf.js` (auto-discovered). Flags mirror `a11y`: `--config ` (default `.perfrc.json`, optional when `--url` is given — unlike `a11y`'s config), repeatable `--url` (replaces the config's `urls[]` entirely), `--output text|json`, `--dry-run`. Same exit contract: 0 clean · 1 run failure or unreachable URL · 2 usage or module/binary missing · 3 issues found (a page-load failure, an empty web-vitals harvest, or `EBINFAIL` still exits 1; a missing `puppeteer`/`web-vitals`/lighthouse, no resolvable URLs, or a malformed/unreadable config — `EBADJSON`/`ECONFIGREAD` — exits 2). Zero runtime dependencies — Node built-ins plus the consumer-installed `puppeteer`, `web-vitals`, and `lighthouse` dev dependencies; no `src/ui`, plain `process.stdout`/`stderr.write`. +- `wp-tooling perf` — two-layer performance runner mirroring `a11y`. Layer 1 (frontend, always on): launches consumer-installed `puppeteer`, injects the `web-vitals` attribution build, and collects LCP/CLS/FCP/TTFB with `reportAllChanges: true` (LCP/CLS never finalize headless without input, so the latest reported candidate is harvested after a settle delay); INP is always `null` in the lab layer (no interaction is performed). Optional Lighthouse pass (`--only-categories=performance`, pinned to the puppeteer-installed Chrome via `CHROME_PATH`) contributes category scores + top failing audits. Layer 2 (server, opt-in via `server.enabled`): runs the consumer's `server-profile.php` shim over WP-CLI (`wp eval-file`) to get xhprof/tideways function hotspots, normalized with a CLI-context fidelity note; degrades to an empty `top[]` with guidance — never an error — when no backend or `rtcamp/wp-dev-tools` is installed, and a broken invocation degrades the same way rather than failing the run (the server layer is auxiliary cause-data). `src/perf/{errors,resolve-bin,resolve-module,config,collect-vitals,lighthouse,server-profile,normalize,run,index}.js`; `"./perf"` exports entry; `src/cli/commands/perf.js` (auto-discovered). Flags mirror `a11y`: `--config ` (default `.perfrc.json`, optional when `--url` is given — unlike `a11y`'s config), repeatable `--url` (replaces the config's `urls[]` entirely), `--output text|json`, `--dry-run`. Same exit contract: 0 clean · 1 run failure or unreachable URL · 2 usage or module/binary missing · 3 issues found (a page-load failure, an empty web-vitals harvest, or `EBINFAIL` still exits 1; a missing `puppeteer`/`web-vitals`/lighthouse, no resolvable URLs, or a malformed/unreadable config — `EBADJSON`/`ECONFIGREAD` — exits 2). Zero runtime dependencies — Node built-ins plus the consumer-installed `puppeteer`, `web-vitals`, and `lighthouse` dev dependencies; no `src/ui`, plain `process.stdout`/`stderr.write`. `engines.node` is `>=22.19` (Lighthouse 13's floor), with a matching root `.nvmrc`. - `setup/perf` scaffold — renders `.perfrc.json` (project-owned URL slots: `sample_page`/`search_page`/optional `extra_page`, same pattern as `setup/pa11y`) and ships a hardened `server-profile.php` shim (`raw: true`, copied verbatim) plus `web-vitals`/`lighthouse`/`puppeteer` dev-dependency pins and `test:perf` / `profile:server` npm scripts. The shim removes `template_redirect`'s `redirect_canonical` before rendering (a canonical redirect ends the request with `exit()`, which bypasses `finally` and would otherwise kill the process before the profiler stops or the JSON is echoed), profiles with `start()`/`stop()` plus a `register_shutdown_function` fallback that drains open output buffers, copies the target's query string into `$_GET` before `wp()` (`WP::parse_request()` reads `$_GET`, not `REQUEST_URI`), and emits a STDERR route + backend diagnostic. Consumes `rtCamp\WPDevTools\Support\XHProfProfiler` (require-dev `rtcamp/wp-dev-tools`) — never reimplements xhprof; degrades to `[]` + a STDERR note when the class isn't installed. + +## [1.0.0] - 2026-07-30 + +### Added + - Remote scaffolds — a scaffold's `scaffold.json` + templates can live in another repo. `scaffolds/sources.json` lists the source repos (pinned `{ repository, ref, path }`); each repo publishes a `scaffolds/index.json` enumerating the scaffolds it offers, which the registry fetches to discover them (one PR in the owning repo adds/changes a scaffold; wp-tooling only changes to onboard a new repo). Manifests + templates are fetched on `add`, cached under `${XDG_CACHE_HOME:-$HOME/.cache}/wp-tooling/remote/` and validated with ETag conditional requests (`304 Not Modified` serves the cache; movable tags refresh when they move). New error code `EFETCHFAIL` (network/HTTP) distinct from `EBADSCAFFOLD` (bad index/manifest). `list` is online-preferred with a cache fallback and reports unreachable sources as warnings; `validate --remote` fetches + schema-validates each index + manifest; `wp-tooling cache clear` empties the cache. Dormant by default — no `sources.json` ships. - Engine-side input discovery (`discover_from`) — an input declaration can say where to source its value from the project, so the engine fills it instead of the caller guessing. Resolves from `composer.json` / `package.json` (dotted paths; `autoload.psr-4` yields the root namespace) and `.wp-tooling.json`, with precedence `supplied → discovered → default`. Fail-safe: a missing or malformed project file falls through to the input's `default`, so a project without those files behaves exactly as before the resolver existed. Adds an optional `transform` step for derived inputs (`json-escape` doubles backslashes for the PSR-4 composer key). The inputs the engine actually rendered with are surfaced on `execute()` as `engine.inputs`. Bundled `setup/psr4` + `wp/*` scaffolds annotated with `discover_from`. - Feature toggle layer — a scaffold may declare an optional `feature` block (`config_key`, `owned_files`, `confirm_remove`, `gitignore`) marking it as a toggleable project feature. New TTY-free `enable` / `disable` / `status` verbs create or remove the owned files idempotently, manage `.gitignore` lines (Mustache-rendered against resolved inputs), prompt before deleting consumer-editable files (`confirm_remove`, overridable with `--force`), and persist on/off state in `.wp-tooling.json`. New `wp-tooling features` command — lists feature status by default, with `--enable` / `--disable` to toggle (plus `--json`, `--force`, `--no-install`, `--dry-run`); `setup/tailwind` ships as the first such feature. Additive — the `feature` block never affects the `add` / `execute` path. diff --git a/node-packages/wp-tooling/docs/authoring-scaffolds.md b/node-packages/wp-tooling/docs/authoring-scaffolds.md index c37a5f5..39a8a96 100644 --- a/node-packages/wp-tooling/docs/authoring-scaffolds.md +++ b/node-packages/wp-tooling/docs/authoring-scaffolds.md @@ -162,6 +162,7 @@ Available via `transform`: - `snake-case`: `qm-export` → `qm_export` - `upper-snake-case`: `wporg-username` → `WPORG_USERNAME` - `json-escape`: `Acme\Blog` → `Acme\\Blog` (embed a PHP namespace in a JSON snippet) +- `shell-escape`: `my plugin` → `'my plugin'` (POSIX-quote one shell argument; safe values like `wp-content/plugins/x` stay bare) Transforms are applied after the value is resolved. Add new transforms in `src/scaffolds/render.js` (`TRANSFORMS` map). diff --git a/node-packages/wp-tooling/package.json b/node-packages/wp-tooling/package.json index 6932e38..0f4ba2e 100644 --- a/node-packages/wp-tooling/package.json +++ b/node-packages/wp-tooling/package.json @@ -17,7 +17,7 @@ "jest": "^29.7.0" }, "engines": { - "node": ">=22" + "node": ">=22.19" }, "exports": { "./ui": "./src/ui/index.js", diff --git a/node-packages/wp-tooling/scaffolds/setup/perf/scaffold.json b/node-packages/wp-tooling/scaffolds/setup/perf/scaffold.json index 850832e..c9b1982 100644 --- a/node-packages/wp-tooling/scaffolds/setup/perf/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/setup/perf/scaffold.json @@ -2,7 +2,7 @@ "slug": "perf", "category": "setup", "name": "perf (web-vitals + lighthouse + server xhprof)", - "description": "Adds .perfrc.json and server-profile.php for two-layer performance testing against a running WordPress environment: lab Core Web Vitals (web-vitals attribution build under headless Chromium) + Lighthouse performance scores, and optional server-side xhprof function profiling via WP-CLI. The server layer needs `composer require --dev rtcamp/wp-dev-tools` (not on Packagist — add it via a path or VCS repository) and the xhprof or tideways_xhprof PHP extension in the WP-CLI environment; without either it degrades gracefully rather than erroring.", + "description": "Adds .perfrc.json and server-profile.php for two-layer performance testing against a running WordPress environment: lab Core Web Vitals (web-vitals attribution build under headless Chromium) + Lighthouse performance scores, and optional server-side xhprof function profiling via WP-CLI. The server layer needs `composer require --dev rtcamp/wp-dev-tools` (not on Packagist — add it via a path or VCS repository) and the xhprof or tideways_xhprof PHP extension in the WP-CLI environment; without either it degrades gracefully rather than erroring. The server command defaults to wp-env; edit `server.command` in .perfrc.json if your WordPress environment runs WP-CLI some other way (Local, DDEV, a remote SSH host, etc.).", "source": "template", "files": [ { @@ -19,22 +19,26 @@ { "key": "base_url", "description": "Base URL of the WordPress environment to test against (e.g. http://localhost:8888).", - "required": true + "required": true, + "transform": "json-escape" }, { "key": "sample_page", "description": "Path of a post or page to test, appended to base_url (e.g. /hello-world/ or a permalink path).", - "default": "/?p=1" + "default": "/?p=1", + "transform": "json-escape" }, { "key": "search_page", "description": "Path of the search-results page to test, appended to base_url.", - "default": "/?s=hello" + "default": "/?s=hello", + "transform": "json-escape" }, { "key": "extra_page", "description": "Optional path of one more page to test, appended to base_url. Omitted when empty; add further URLs directly in .perfrc.json.", - "default": "" + "default": "", + "transform": "json-escape" }, { "key": "server_enabled", @@ -45,6 +49,18 @@ "key": "server_env_cwd", "description": "Project path inside the WP-CLI environment, as `wp-env run cli --env-cwd` expects it. Must match where this scaffold just installed server-profile.php — e.g. wp-content/plugins/my-plugin for a plugin, wp-content/themes/my-theme for a theme — or `.` only if this project IS the WordPress root. There's no safe default: guessing wrong means the server layer can't find its own shim.", "required": true + }, + { + "key": "server_env_cwd_json", + "description": "server_env_cwd, JSON-escaped for .perfrc.json.", + "discover_from": "input:server_env_cwd", + "transform": "json-escape" + }, + { + "key": "server_env_cwd_shell", + "description": "server_env_cwd, shell-quoted for the profile:server script.", + "discover_from": "input:server_env_cwd", + "transform": "shell-escape" } ], "npm_dev_dependencies": { @@ -55,7 +71,7 @@ "scripts": { "npm": { "test:perf": "wp-tooling perf", - "profile:server": "wp-env run cli --env-cwd={{server_env_cwd}} -- wp eval-file server-profile.php" + "profile:server": "wp-env run cli --env-cwd={{server_env_cwd_shell}} -- wp eval-file server-profile.php" } } } diff --git a/node-packages/wp-tooling/scaffolds/setup/perf/templates/.perfrc.json.mustache b/node-packages/wp-tooling/scaffolds/setup/perf/templates/.perfrc.json.mustache index 945e4bb..3664cf6 100644 --- a/node-packages/wp-tooling/scaffolds/setup/perf/templates/.perfrc.json.mustache +++ b/node-packages/wp-tooling/scaffolds/setup/perf/templates/.perfrc.json.mustache @@ -7,6 +7,6 @@ ], "server": { "enabled": {{#server_enabled}}true{{/server_enabled}}{{^server_enabled}}false{{/server_enabled}}, - "command": ["npx", "wp-env", "run", "cli", "--env-cwd={{server_env_cwd}}", "--", "wp"] + "command": ["npx", "--no-install", "wp-env", "run", "cli", "--env-cwd={{server_env_cwd_json}}", "--", "wp"] } } diff --git a/node-packages/wp-tooling/src/perf/collect-vitals.js b/node-packages/wp-tooling/src/perf/collect-vitals.js index 1b05500..230ef58 100644 --- a/node-packages/wp-tooling/src/perf/collect-vitals.js +++ b/node-packages/wp-tooling/src/perf/collect-vitals.js @@ -82,7 +82,8 @@ async function launchBrowser(puppeteer, options = {}) { * @param {number} [options.settleMs=3000] Time to wait after load before harvesting. * @param {number} [options.timeoutMs=30000] Navigation timeout. * @return {Promise<{metrics: Object, attribution: Object}>} Collected metrics + attribution. - * Rejects when the page fails to load (the caller records this as a per-URL scan error). + * Rejects when the page fails to load (the caller records this as a per-URL scan error); + * a navigation failure specifically rejects with a `RunnerError` coded `ENAVFAIL`. */ async function collectVitals(browser, scriptSource, url, options = {}) { const settleMs = options.settleMs ?? 3000; @@ -93,7 +94,17 @@ async function collectVitals(browser, scriptSource, url, options = {}) { await page.evaluateOnNewDocument( `${scriptSource}\n${REGISTER_SNIPPET}` ); - await page.goto(url, { waitUntil: 'networkidle2', timeout: timeoutMs }); + try { + await page.goto(url, { + waitUntil: 'networkidle2', + timeout: timeoutMs, + }); + } catch (err) { + const detail = (err && err.message ? err.message : '').toString(); + throw new RunnerError('ENAVFAIL', `navigation failed: ${detail}`, { + detail, + }); + } await new Promise((resolve) => { setTimeout(resolve, settleMs); }); diff --git a/node-packages/wp-tooling/src/perf/config.js b/node-packages/wp-tooling/src/perf/config.js index 3677121..2c77ad6 100644 --- a/node-packages/wp-tooling/src/perf/config.js +++ b/node-packages/wp-tooling/src/perf/config.js @@ -36,7 +36,16 @@ const DEFAULTS = { }, server: { enabled: false, - command: ['npx', 'wp-env', 'run', 'cli', '--env-cwd=.', '--', 'wp'], + command: [ + 'npx', + '--no-install', + 'wp-env', + 'run', + 'cli', + '--env-cwd=.', + '--', + 'wp', + ], shim: 'server-profile.php', top: 15, }, diff --git a/node-packages/wp-tooling/src/perf/errors.js b/node-packages/wp-tooling/src/perf/errors.js index c94bb15..d135a8c 100644 --- a/node-packages/wp-tooling/src/perf/errors.js +++ b/node-packages/wp-tooling/src/perf/errors.js @@ -16,6 +16,8 @@ * is a directory, or permission was denied (distinct from a * simply-absent config, which is not an error) * ENOURLS no URLs could be resolved from the perf config or --url + * ENAVFAIL a URL failed to load in the browser (per-URL, recorded as + * that URL's `scanError`; never thrown out of the run) */ 'use strict'; diff --git a/node-packages/wp-tooling/src/perf/resolve-bin.js b/node-packages/wp-tooling/src/perf/resolve-bin.js index 91f3949..6931c71 100644 --- a/node-packages/wp-tooling/src/perf/resolve-bin.js +++ b/node-packages/wp-tooling/src/perf/resolve-bin.js @@ -6,6 +6,9 @@ * dependencies. These helpers locate that consumer-installed binary * (a direct or workspace-hoisted `node_modules/.bin/`), falling back to * `npx --no-install` so we never silently fetch it from the network. + * + * POSIX only: binaries are run via `execFileSync` without a shell, which + * cannot execute the `.cmd` shims npm creates under `.bin` on Windows. */ 'use strict'; diff --git a/node-packages/wp-tooling/src/perf/resolve-module.js b/node-packages/wp-tooling/src/perf/resolve-module.js index 12e30aa..6d8479e 100644 --- a/node-packages/wp-tooling/src/perf/resolve-module.js +++ b/node-packages/wp-tooling/src/perf/resolve-module.js @@ -11,6 +11,7 @@ 'use strict'; const fs = require('fs'); +const { createRequire } = require('module'); const path = require('path'); /** @@ -89,11 +90,9 @@ function requireModule(moduleName, options = {}) { if (!dir) { return null; } - // Intentional dynamic require: path resolved by walking the consumer's - // own node_modules for a known package name, mirroring the dispatcher's - // command auto-discovery in src/cli/index.js. No user input involved. - - return require(dir); + // Load by package name through Node's resolver, anchored in the consumer's + // tree, rather than requiring a computed path. + return createRequire(path.join(dir, 'package.json'))(moduleName); } /** diff --git a/node-packages/wp-tooling/src/perf/run.js b/node-packages/wp-tooling/src/perf/run.js index e332085..a9d8cdd 100644 --- a/node-packages/wp-tooling/src/perf/run.js +++ b/node-packages/wp-tooling/src/perf/run.js @@ -137,11 +137,12 @@ async function runPerf(options = {}) { } /** - * Run every layer for one URL. Frontend load failure becomes a per-URL - * `scanError` (the caller's run continues); lighthouse and server failures - * degrade to `null` + a note without affecting the overall run. A page that - * loads but harvests no metric is a separate `vitalsError` — Lighthouse - * navigates independently, so it still runs for it. + * Run every layer for one URL. A navigation failure (`ENAVFAIL`) becomes a + * per-URL `scanError` (the caller's run continues); lighthouse and server + * failures degrade to `null` + a note without affecting the overall run. A + * page that loads but whose collector throws or harvests no metric is a + * separate `vitalsError` — Lighthouse navigates independently, so it still + * runs for it. * * @param {string} url Target URL. * @param {Object} ctx Shared context for the run. @@ -173,13 +174,20 @@ async function collectOne(url, ctx) { 'web-vitals harvest returned no metrics — the injected collector may not have registered (e.g. the page never became interactive, or CSP blocked the injected script).'; } } catch (err) { - scanError = (err && err.message ? err.message : '').toString(); + const detail = (err && err.message ? err.message : '').toString(); + // Only a navigation failure is a scan error; a collector failure after + // a good load keeps the URL's other layers (e.g. Lighthouse) in the report. + if (err instanceof RunnerError && err.code === 'ENAVFAIL') { + scanError = detail; + } else { + vitalsError = `web-vitals collection failed — ${detail}`; + } } let lighthouse = null; // Lighthouse needs the same network reachability as puppeteer -- skip it - // once the page already failed to load, rather than spend its own timeout - // on a dead URL. + // once navigation already failed, rather than spend its own timeout on a + // dead URL. A collector failure after a good load still gets its pass. if (!scanError && config.lighthouse.enabled && lighthouseBin) { try { const lhr = runLighthouse(lighthouseBin, url, config.lighthouse, { diff --git a/node-packages/wp-tooling/src/perf/server-profile.js b/node-packages/wp-tooling/src/perf/server-profile.js index 0d84897..1677904 100644 --- a/node-packages/wp-tooling/src/perf/server-profile.js +++ b/node-packages/wp-tooling/src/perf/server-profile.js @@ -3,7 +3,7 @@ * * Spawns the consumer's `server-profile.php` shim (installed by * `wp-tooling add setup/perf`) through the configured WP-CLI command prefix - * — typically `npx wp-env run cli --env-cwd= -- wp`. The URL's origin + * — typically `npx --no-install wp-env run cli --env-cwd= -- wp`. The URL's origin * is passed as WP-CLI's `--url` (site context; also what arms * `redirect_canonical()` in the shim's render, which is why the shim removes * that hook) and the path+query is passed positionally (the shim reads it @@ -14,6 +14,10 @@ * auxiliary cause-data, so a broken WP-CLI invocation must not take down * the frontend layers or affect the run's exit code. Callers read * `result.error` to detect it. + * + * POSIX only: the command is spawned without a shell, so on Windows npm's + * `.cmd` shims (including `npx`) will not launch. Point `server.command` at a + * directly executable WP-CLI for other environments. */ 'use strict'; @@ -69,7 +73,7 @@ function tryParse(text) { * Run the consumer's `server-profile.php` shim over WP-CLI for one URL. * * @param {Object} server Resolved `server` config section. - * @param {string[]} server.command WP-CLI invocation prefix (e.g. `['npx','wp-env','run','cli','--env-cwd=...','--','wp']`). + * @param {string[]} server.command WP-CLI invocation prefix (e.g. `['npx','--no-install','wp-env','run','cli','--env-cwd=...','--','wp']`). * @param {string} server.shim Shim path, as WP-CLI sees it. * @param {number} server.top Top-N functions to request. * @param {string} url Target URL (origin used for `--url`; path+query passed positionally). diff --git a/node-packages/wp-tooling/src/scaffolds/render.js b/node-packages/wp-tooling/src/scaffolds/render.js index 818303d..1957838 100644 --- a/node-packages/wp-tooling/src/scaffolds/render.js +++ b/node-packages/wp-tooling/src/scaffolds/render.js @@ -225,6 +225,11 @@ const TRANSFORMS = { 'upper-snake-case': (s) => splitWords(s).join('_').toUpperCase(), // Escape JSON string contents; templates supply the surrounding quotes. 'json-escape': (s) => JSON.stringify(String(s)).slice(1, -1), + // POSIX-shell quote a single argument, only when it needs quoting. + 'shell-escape': (s) => { + const v = String(s); + return /^[\w@%+=:,./-]+$/.test(v) ? v : `'${v.replace(/'/g, `'\\''`)}'`; + }, }; function splitWords(s) { diff --git a/node-packages/wp-tooling/src/scaffolds/schema.js b/node-packages/wp-tooling/src/scaffolds/schema.js index c4601f8..09d2690 100644 --- a/node-packages/wp-tooling/src/scaffolds/schema.js +++ b/node-packages/wp-tooling/src/scaffolds/schema.js @@ -54,6 +54,7 @@ const ALLOWED_INPUT_TRANSFORMS = [ 'snake-case', 'upper-snake-case', 'json-escape', + 'shell-escape', ]; /** Optional dependency maps. Each is `{ "": "" }`. */ diff --git a/node-packages/wp-tooling/tests/perf/cli.test.js b/node-packages/wp-tooling/tests/perf/cli.test.js index bf75534..b6dd138 100644 --- a/node-packages/wp-tooling/tests/perf/cli.test.js +++ b/node-packages/wp-tooling/tests/perf/cli.test.js @@ -9,6 +9,7 @@ const { execFileSync, spawnSync } = require('child_process'); const resolveModule = require('../../src/perf/resolve-module'); const collectVitalsModule = require('../../src/perf/collect-vitals'); const { runCli } = require('../../src/perf/run'); +const { RunnerError } = require('../../src/perf/errors'); const FIXTURES = path.join(__dirname, 'fixtures'); const FIXTURE_CONFIG = path.join(FIXTURES, '.perfrc.json'); @@ -222,7 +223,10 @@ describe('perf runCli', () => { test('a page load failure is a run failure (exit 1), not an issue', async () => { collectVitalsModule.collectVitals.mockRejectedValue( - new Error('net::ERR_CONNECTION_REFUSED') + new RunnerError( + 'ENAVFAIL', + 'navigation failed: net::ERR_CONNECTION_REFUSED' + ) ); const code = await runCli([ '--config', @@ -273,6 +277,34 @@ describe('perf runCli', () => { expect(nonProbeCalls.length).toBeGreaterThan(0); }); + test('a collector failure after navigation is a run failure (exit 1), and lighthouse still runs for it', async () => { + collectVitalsModule.collectVitals.mockRejectedValue( + new Error('Execution context was destroyed') + ); + const code = await runCli([ + '--config', + FIXTURE_CONFIG, + '--output', + 'json', + ]); + expect(code).toBe(1); + const parsed = JSON.parse(stdout.join('')); + expect(parsed.summary.failedUrls).toBeGreaterThan(0); + + // Only a navigation failure is a scanError; this page loaded, so its + // lighthouse result survives into the report. + const result = parsed.results[0]; + expect(result.scanError).toBeNull(); + expect(result.lighthouse.scores.performance).toBe(0.95); + expect(result.notes.join('')).toMatch( + /web-vitals collection failed — Execution context was destroyed/ + ); + const nonProbeCalls = execFileSync.mock.calls.filter( + (call) => !call[1].includes('--version') + ); + expect(nonProbeCalls.length).toBeGreaterThan(0); + }); + test('--output text still renders the server section for a URL that failed to scan', async () => { mockChildProcess({ serverResult: { @@ -290,7 +322,10 @@ describe('perf runCli', () => { }, }); collectVitalsModule.collectVitals.mockRejectedValue( - new Error('net::ERR_CONNECTION_REFUSED') + new RunnerError( + 'ENAVFAIL', + 'navigation failed: net::ERR_CONNECTION_REFUSED' + ) ); const code = await runCli([ '--config', diff --git a/node-packages/wp-tooling/tests/perf/collect-vitals.test.js b/node-packages/wp-tooling/tests/perf/collect-vitals.test.js index c83d3db..6ab2474 100644 --- a/node-packages/wp-tooling/tests/perf/collect-vitals.test.js +++ b/node-packages/wp-tooling/tests/perf/collect-vitals.test.js @@ -119,7 +119,10 @@ describe('collectVitals', () => { collectVitals(browser, '/* iife */', 'http://localhost:8888/', { settleMs: 1, }) - ).rejects.toThrow('net::ERR_CONNECTION_REFUSED'); + ).rejects.toMatchObject({ + code: 'ENAVFAIL', + message: expect.stringContaining('net::ERR_CONNECTION_REFUSED'), + }); expect(calls[calls.length - 1][0]).toBe('close'); }); diff --git a/node-packages/wp-tooling/tests/perf/fixtures/.perfrc.json b/node-packages/wp-tooling/tests/perf/fixtures/.perfrc.json index 2575122..d385316 100644 --- a/node-packages/wp-tooling/tests/perf/fixtures/.perfrc.json +++ b/node-packages/wp-tooling/tests/perf/fixtures/.perfrc.json @@ -4,7 +4,7 @@ "lighthouse": { "enabled": true, "categories": ["performance"], "topAudits": 5 }, "server": { "enabled": true, - "command": ["npx", "wp-env", "run", "cli", "--env-cwd=wp-content/plugins/dummy-plugin", "--", "wp"], + "command": ["npx", "--no-install", "wp-env", "run", "cli", "--env-cwd=wp-content/plugins/dummy-plugin", "--", "wp"], "shim": "server-profile.php", "top": 15 }, diff --git a/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js b/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js index d1ceef1..b1721fe 100644 --- a/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js @@ -149,6 +149,7 @@ describe('setup/perf rendered config', () => { expect(config.server.enabled).toBe(false); expect(config.server.command).toEqual([ 'npx', + '--no-install', 'wp-env', 'run', 'cli', @@ -185,6 +186,7 @@ describe('setup/perf rendered config', () => { expect(config.server.enabled).toBe(true); expect(config.server.command).toEqual([ 'npx', + '--no-install', 'wp-env', 'run', 'cli', @@ -212,6 +214,7 @@ describe('setup/perf rendered config', () => { expect(config.server.enabled).toBe(true); expect(config.server.command).toEqual([ 'npx', + '--no-install', 'wp-env', 'run', 'cli', @@ -246,6 +249,44 @@ describe('setup/perf rendered config', () => { ); }); + it('JSON-escapes every URL input while preserving its value', async () => { + const target = makeTmpDir(); + const inputs = { + base_url: 'http://localhost:8888/"quoted"', + sample_page: '/path\\segment', + search_page: '/?s="hello"&page=2', + extra_page: '/line\nbreak', + server_env_cwd: '.', + }; + await registry.execute('setup/perf', inputs, { cwd: target }); + const config = JSON.parse( + fs.readFileSync(path.join(target, '.perfrc.json'), 'utf8') + ); + expect(config.urls).toEqual([ + `${inputs.base_url}/`, + inputs.base_url + inputs.sample_page, + inputs.base_url + inputs.search_page, + inputs.base_url + inputs.extra_page, + ]); + }); + + it('escapes server_env_cwd separately for the JSON config and the shell script', async () => { + const target = makeTmpDir(); + const envCwd = 'wp-content/plugins/my "odd" plugin'; + const result = await registry.execute( + 'setup/perf', + { base_url: 'http://localhost:8888', server_env_cwd: envCwd }, + { cwd: target } + ); + const config = JSON.parse( + fs.readFileSync(path.join(target, '.perfrc.json'), 'utf8') + ); + expect(config.server.command).toContain(`--env-cwd=${envCwd}`); + expect(result.developer.scripts.npm['profile:server']).toBe( + `wp-env run cli --env-cwd='${envCwd}' -- wp eval-file server-profile.php` + ); + }); + it('has no safe default for server_env_cwd, since a wrong guess would silently point the server layer at the wrong shim location', async () => { const r = registry; const target = makeTmpDir(); diff --git a/node-packages/wp-tooling/tests/scaffolds/render.test.js b/node-packages/wp-tooling/tests/scaffolds/render.test.js index 8b0588a..012b130 100644 --- a/node-packages/wp-tooling/tests/scaffolds/render.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/render.test.js @@ -195,6 +195,16 @@ describe('applyTransform', () => { ); }); + it('shell-escape leaves safe values bare and single-quotes the rest', () => { + expect(applyTransform('wp-content/plugins/x', 'shell-escape')).toBe( + 'wp-content/plugins/x' + ); + expect(applyTransform('.', 'shell-escape')).toBe('.'); + expect(applyTransform('my plugin', 'shell-escape')).toBe("'my plugin'"); + expect(applyTransform("it's", 'shell-escape')).toBe("'it'\\''s'"); + expect(applyTransform('', 'shell-escape')).toBe("''"); + }); + it('returns value unchanged when no transform', () => { expect(applyTransform('Foo', undefined)).toBe('Foo'); }); diff --git a/package-lock.json b/package-lock.json index d7fcd08..984ed5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "node-packages/*" ], "engines": { - "node": ">=22" + "node": ">=22.19" } }, "node_modules/@alloc/quick-lru": { @@ -12112,7 +12112,7 @@ "jest": "^29.7.0" }, "engines": { - "node": ">=22" + "node": ">=22.19" }, "peerDependencies": { "@eslint-community/eslint-plugin-eslint-comments": "^4.7.1", @@ -12136,7 +12136,7 @@ "jest": "^29.7.0" }, "engines": { - "node": ">=22" + "node": ">=22.19" }, "peerDependencies": { "@wordpress/stylelint-config": "^23.37.0", @@ -12153,7 +12153,7 @@ "jest": "^29.7.0" }, "engines": { - "node": ">=22" + "node": ">=22.19" }, "peerDependencies": { "@tailwindcss/postcss": "^4.3.0", @@ -12174,7 +12174,7 @@ "jest": "^29.7.0" }, "engines": { - "node": ">=22" + "node": ">=22.19" } } } diff --git a/package.json b/package.json index e0f96d6..f3794f9 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "author": "rtCamp", "private": true, "engines": { - "node": ">=22" + "node": ">=22.19" }, "workspaces": [ "node-packages/*" From f7069f8ddf32103fe200791a430e7349a8a22aea Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Wed, 23 Sep 2026 20:08:16 +0530 Subject: [PATCH 10/13] =?UTF-8?q?fix(perf):=20address=20Copilot=20review?= =?UTF-8?q?=20=E2=80=94=20JSON-safe=20script=20display,=20literal=20puppet?= =?UTF-8?q?eer=20require,=20failure=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wp-tooling/src/perf/resolve-module.js | 22 +++++++-------- node-packages/wp-tooling/src/perf/run.js | 8 +++--- node-packages/wp-tooling/src/scaffolds/add.js | 8 ++++-- .../wp-tooling/tests/perf/cli.test.js | 6 ++-- .../tests/perf/resolve-module.test.js | 14 ++++------ .../wp-tooling/tests/scaffolds/add.test.js | 28 +++++++++++++++++++ 6 files changed, 57 insertions(+), 29 deletions(-) diff --git a/node-packages/wp-tooling/src/perf/resolve-module.js b/node-packages/wp-tooling/src/perf/resolve-module.js index 6d8479e..f61f09e 100644 --- a/node-packages/wp-tooling/src/perf/resolve-module.js +++ b/node-packages/wp-tooling/src/perf/resolve-module.js @@ -74,25 +74,23 @@ function resolveModuleFile(moduleName, relFile, options = {}) { } /** - * Resolve AND `require` a consumer-installed module, returning the loaded - * module (never a path) so callers — and their tests — depend only on this - * function's return value, not on Node's real module resolution. `run.js` - * uses this exclusively to obtain `puppeteer`; tests substitute a fake - * module by mocking this file, no real puppeteer install required. + * Resolve AND `require` the consumer-installed `puppeteer`, returning the + * loaded module (never a path) so `run.js` — and its tests — depend only on + * this function's return value, not on Node's real module resolution; tests + * substitute a fake module by mocking this file, no real puppeteer install + * required. * - * @param {string} moduleName Package name. * @param {Object} [options] * @param {string} [options.cwd] Directory to resolve from. * @return {*} The loaded module, or `null` when not installed. */ -function requireModule(moduleName, options = {}) { - const dir = resolveModuleDir(moduleName, options); +function requirePuppeteer(options = {}) { + const dir = resolveModuleDir('puppeteer', options); if (!dir) { return null; } - // Load by package name through Node's resolver, anchored in the consumer's - // tree, rather than requiring a computed path. - return createRequire(path.join(dir, 'package.json'))(moduleName); + // Literal specifier through Node's resolver, anchored in the consumer's tree. + return createRequire(path.join(dir, 'package.json'))('puppeteer'); } /** @@ -131,6 +129,6 @@ module.exports = { findModuleDir, resolveModuleDir, resolveModuleFile, - requireModule, + requirePuppeteer, detectModule, }; diff --git a/node-packages/wp-tooling/src/perf/run.js b/node-packages/wp-tooling/src/perf/run.js index a9d8cdd..6aedec5 100644 --- a/node-packages/wp-tooling/src/perf/run.js +++ b/node-packages/wp-tooling/src/perf/run.js @@ -24,7 +24,7 @@ const { RunnerError } = require('./errors'); const { resolveConfig } = require('./config'); const { resolveModuleFile, - requireModule, + requirePuppeteer, detectModule, } = require('./resolve-module'); const { detectBin, resolveBin } = require('./resolve-bin'); @@ -77,7 +77,7 @@ async function runPerf(options = {}) { cwd, }); - const puppeteer = requireModule('puppeteer', { cwd }); + const puppeteer = requirePuppeteer({ cwd }); requireInstalled( puppeteer, `puppeteer not found. Install it in the project (\`${INSTALL_HINT}\` sets it up).` @@ -291,7 +291,7 @@ function emit(report, mode) { } const summary = report.summary; const failed = - summary.failedUrls > 0 ? `, ${summary.failedUrls} failed to load` : ''; + summary.failedUrls > 0 ? `, ${summary.failedUrls} incomplete` : ''; const lines = [ `${report.tool}: ${summary.issues} issue(s) across ${summary.urls} URL(s); ${summary.passedUrls} clean${failed}.`, ]; @@ -502,7 +502,7 @@ async function runCli(argv) { emit(report, opts.output); if (report.summary.failedUrls > 0) { process.stderr.write( - `perf: ${report.summary.failedUrls} URL(s) failed to load — treating as a run failure.\n` + `perf: ${report.summary.failedUrls} URL(s) incomplete (failed to load or measure) — treating as a run failure.\n` ); return 1; } diff --git a/node-packages/wp-tooling/src/scaffolds/add.js b/node-packages/wp-tooling/src/scaffolds/add.js index aaf0c8e..5922a29 100644 --- a/node-packages/wp-tooling/src/scaffolds/add.js +++ b/node-packages/wp-tooling/src/scaffolds/add.js @@ -188,13 +188,17 @@ function printHumanReport(result) { if (npmScripts.length) { lines.push(' Add to package.json "scripts":'); for (const [name, cmd] of npmScripts) { - lines.push(` "${name}": "${cmd}"`); + lines.push( + ` ${JSON.stringify(name)}: ${JSON.stringify(cmd)}` + ); } } if (composerScripts.length) { lines.push(' Add to composer.json "scripts":'); for (const [name, cmd] of composerScripts) { - lines.push(` "${name}": "${cmd}"`); + lines.push( + ` ${JSON.stringify(name)}: ${JSON.stringify(cmd)}` + ); } } if (developer.secrets.length) { diff --git a/node-packages/wp-tooling/tests/perf/cli.test.js b/node-packages/wp-tooling/tests/perf/cli.test.js index b6dd138..7e07464 100644 --- a/node-packages/wp-tooling/tests/perf/cli.test.js +++ b/node-packages/wp-tooling/tests/perf/cli.test.js @@ -89,7 +89,7 @@ describe('perf runCli', () => { return true; }); - resolveModule.requireModule.mockReturnValue({ + resolveModule.requirePuppeteer.mockReturnValue({ launch: jest.fn(), executablePath: jest.fn(() => '/chrome-for-testing'), }); @@ -141,7 +141,7 @@ describe('perf runCli', () => { }); test('missing puppeteer exits 2 with the install hint', async () => { - resolveModule.requireModule.mockReturnValue(null); + resolveModule.requirePuppeteer.mockReturnValue(null); expect(await runCli(['--config', FIXTURE_CONFIG])).toBe(2); expect(stderr.join('')).toMatch(/puppeteer not found/); expect(stderr.join('')).toMatch(/wp-tooling add setup\/perf/); @@ -237,7 +237,7 @@ describe('perf runCli', () => { expect(code).toBe(1); const parsed = JSON.parse(stdout.join('')); expect(parsed.summary.failedUrls).toBeGreaterThan(0); - expect(stderr.join('')).toMatch(/failed to load/); + expect(stderr.join('')).toMatch(/incomplete/); // Lighthouse needs the same reachability as puppeteer, so it must be // skipped for a failed URL: only the initial --version probe ran, no diff --git a/node-packages/wp-tooling/tests/perf/resolve-module.test.js b/node-packages/wp-tooling/tests/perf/resolve-module.test.js index 86a75f5..75c0157 100644 --- a/node-packages/wp-tooling/tests/perf/resolve-module.test.js +++ b/node-packages/wp-tooling/tests/perf/resolve-module.test.js @@ -8,7 +8,7 @@ const { findModuleDir, resolveModuleDir, resolveModuleFile, - requireModule, + requirePuppeteer, detectModule, } = require('../../src/perf/resolve-module'); @@ -116,7 +116,7 @@ describe('resolveModuleDir / resolveModuleFile', () => { }); }); -describe('requireModule', () => { +describe('requirePuppeteer', () => { let root; afterEach(() => { @@ -126,23 +126,21 @@ describe('requireModule', () => { } }); - test('requires and returns the resolved module', () => { + test('requires and returns the installed puppeteer', () => { root = tmpTree(); - const dir = makeModule(root, MODULE_NAME, { main: 'index.js' }); + const dir = makeModule(root, 'puppeteer', { main: 'index.js' }); fs.writeFileSync( path.join(dir, 'index.js'), 'module.exports = { marker: "fixture-loaded" };' ); - expect(requireModule(MODULE_NAME, { cwd: root })).toEqual({ + expect(requirePuppeteer({ cwd: root })).toEqual({ marker: 'fixture-loaded', }); }); test('returns null when not installed', () => { root = tmpTree(); - expect( - requireModule('definitely-not-installed-xyz', { cwd: root }) - ).toBeNull(); + expect(requirePuppeteer({ cwd: root })).toBeNull(); }); }); diff --git a/node-packages/wp-tooling/tests/scaffolds/add.test.js b/node-packages/wp-tooling/tests/scaffolds/add.test.js index 4c25d14..bcd7eab 100644 --- a/node-packages/wp-tooling/tests/scaffolds/add.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/add.test.js @@ -232,6 +232,9 @@ describe('add command human report', () => { source: 'template', files: [{ src: 'templates/out.txt.mustache', dest: 'out.txt' }], npm_dev_dependencies: { 'pa11y-ci': '^6.0.0' }, + scripts: { + npm: { 'profile:server': `run --cwd='my "odd" dir'` }, + }, }), 'utf8' ); @@ -262,6 +265,31 @@ describe('add command human report', () => { expect(captured).toContain('Install (npm dev):'); expect(captured).toContain('npm install --save-dev pa11y-ci@^6.0.0'); }); + + it('prints package.json script entries as valid JSON, escaping quotes', async () => { + const cwd = makeTmpDir(); + await buildProjectWithDevDeps(cwd); + const originalOut = process.stdout.write.bind(process.stdout); + let captured = ''; + process.stdout.write = (chunk) => { + captured += chunk; + return true; + }; + const code = await add.runCli([ + 'test/devdeps', + '--non-interactive', + '--cwd', + cwd, + ]); + process.stdout.write = originalOut; + expect(code).toBe(0); + const entry = captured + .split('\n') + .find((l) => l.trim().startsWith('"profile:server"')); + expect(JSON.parse(`{${entry}}`)).toEqual({ + 'profile:server': `run --cwd='my "odd" dir'`, + }); + }); }); describe('add debug logging does not leak raw argument values', () => { From cdb2586f6814d493ab05720e796c5820b894c4b8 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Wed, 23 Sep 2026 21:39:38 +0530 Subject: [PATCH 11/13] fix(perf): pass origin+path as WP-CLI --url for subdirectory multisite --- .../setup/perf/templates/server-profile.php | 2 +- node-packages/wp-tooling/src/perf/run.js | 2 +- .../wp-tooling/src/perf/server-profile.js | 25 +++++++++++-------- .../tests/perf/server-profile.test.js | 15 ++++++++--- 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/node-packages/wp-tooling/scaffolds/setup/perf/templates/server-profile.php b/node-packages/wp-tooling/scaffolds/setup/perf/templates/server-profile.php index 90c934d..21d12c7 100644 --- a/node-packages/wp-tooling/scaffolds/setup/perf/templates/server-profile.php +++ b/node-packages/wp-tooling/scaffolds/setup/perf/templates/server-profile.php @@ -5,7 +5,7 @@ * Usage: * npm run profile:server -- [] [] * # or directly: - * wp eval-file server-profile.php [] [] [--url=] + * wp eval-file server-profile.php [] [] [--url=] * * Profiles the WordPress render path for (default "/") with * rtCamp\WPDevTools\Support\XHProfProfiler and prints the top- diff --git a/node-packages/wp-tooling/src/perf/run.js b/node-packages/wp-tooling/src/perf/run.js index 6aedec5..a6ff2dc 100644 --- a/node-packages/wp-tooling/src/perf/run.js +++ b/node-packages/wp-tooling/src/perf/run.js @@ -417,7 +417,7 @@ function runDryRun(opts, cwd) { ? config.server.command : [String(config.server.command)]; lines.push( - ` server: ${commandParts.join(' ')} eval-file ${config.server.shim} ${config.server.top} --url=` + ` server: ${commandParts.join(' ')} eval-file ${config.server.shim} ${config.server.top} --url=` ); } else { lines.push(' server: disabled'); diff --git a/node-packages/wp-tooling/src/perf/server-profile.js b/node-packages/wp-tooling/src/perf/server-profile.js index 1677904..7fc90e4 100644 --- a/node-packages/wp-tooling/src/perf/server-profile.js +++ b/node-packages/wp-tooling/src/perf/server-profile.js @@ -3,8 +3,9 @@ * * Spawns the consumer's `server-profile.php` shim (installed by * `wp-tooling add setup/perf`) through the configured WP-CLI command prefix - * — typically `npx --no-install wp-env run cli --env-cwd= -- wp`. The URL's origin - * is passed as WP-CLI's `--url` (site context; also what arms + * — typically `npx --no-install wp-env run cli --env-cwd= -- wp`. The URL's + * origin+path is passed as WP-CLI's `--url` (site context — the path lets a + * subdirectory multisite resolve the right site; also what arms * `redirect_canonical()` in the shim's render, which is why the shim removes * that hook) and the path+query is passed positionally (the shim reads it * into `$_GET` itself). @@ -28,17 +29,19 @@ const MAX_BUFFER = 64 * 1024 * 1024; const RUN_TIMEOUT_MS = 120000; /** - * Split a target URL into its origin (scheme+host+port) and its path+query - * — the two pieces the shim's contract expects separately - * (`wp eval-file server-profile.php [] [] [--url=]`). + * Split a target URL into WP-CLI's site URL (origin+path, no query) and its + * path+query — the two pieces the shim's contract expects separately + * (`wp eval-file server-profile.php [] [] [--url=]`). The + * path stays in the site URL because WP-CLI resolves a subdirectory + * multisite's site from it; a bare origin would target the main site. * * @param {string} url Full target URL. - * @return {{origin: string, pathAndQuery: string}} Split URL. + * @return {{siteUrl: string, pathAndQuery: string}} Split URL. */ function splitUrl(url) { const parsedUrl = new URL(url); return { - origin: parsedUrl.origin, + siteUrl: `${parsedUrl.origin}${parsedUrl.pathname}`, pathAndQuery: `${parsedUrl.pathname}${parsedUrl.search}`, }; } @@ -76,7 +79,7 @@ function tryParse(text) { * @param {string[]} server.command WP-CLI invocation prefix (e.g. `['npx','--no-install','wp-env','run','cli','--env-cwd=...','--','wp']`). * @param {string} server.shim Shim path, as WP-CLI sees it. * @param {number} server.top Top-N functions to request. - * @param {string} url Target URL (origin used for `--url`; path+query passed positionally). + * @param {string} url Target URL (origin+path used for `--url`; path+query passed positionally). * @param {Object} [options] * @param {string} [options.cwd] Working directory. * @return {{data: (Object|Array|null), diagnostic: (string|null), error: (string|null)}} @@ -90,11 +93,11 @@ function runServerProfile(server, url, options = {}) { // splitUrl (and building args from server.command) can throw on malformed // input -- this module always degrades instead, so a bad URL or config // must not abort the URLs after this one. - let origin; + let siteUrl; let pathAndQuery; let args; try { - ({ origin, pathAndQuery } = splitUrl(url)); + ({ siteUrl, pathAndQuery } = splitUrl(url)); const [, ...prefix] = server.command; args = [ ...prefix, @@ -102,7 +105,7 @@ function runServerProfile(server, url, options = {}) { server.shim, pathAndQuery, String(server.top), - `--url=${origin}`, + `--url=${siteUrl}`, ]; } catch (err) { return { data: null, diagnostic: null, error: err.message }; diff --git a/node-packages/wp-tooling/tests/perf/server-profile.test.js b/node-packages/wp-tooling/tests/perf/server-profile.test.js index 3b96e74..ecfad24 100644 --- a/node-packages/wp-tooling/tests/perf/server-profile.test.js +++ b/node-packages/wp-tooling/tests/perf/server-profile.test.js @@ -20,19 +20,26 @@ const SERVER = { }; describe('splitUrl', () => { - test('splits origin from path + query', () => { + test('splits the site URL (origin + path) from path + query', () => { expect(splitUrl('http://localhost:8765/?p=1')).toEqual({ - origin: 'http://localhost:8765', + siteUrl: 'http://localhost:8765/', pathAndQuery: '/?p=1', }); }); test('a bare root path has no query', () => { expect(splitUrl('http://localhost:8765/')).toEqual({ - origin: 'http://localhost:8765', + siteUrl: 'http://localhost:8765/', pathAndQuery: '/', }); }); + + test('keeps a subdirectory multisite path in the site URL', () => { + expect(splitUrl('https://example.com/site1/page/?s=x')).toEqual({ + siteUrl: 'https://example.com/site1/page/', + pathAndQuery: '/site1/page/?s=x', + }); + }); }); describe('runServerProfile', () => { @@ -58,7 +65,7 @@ describe('runServerProfile', () => { 'server-profile.php', '/?p=1', '15', - '--url=http://localhost:8765', + '--url=http://localhost:8765/', ]); expect(opts.cwd).toBe('/project'); }); From cc1761403942aebedd1fc4822ea81708eb9473d6 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Wed, 23 Sep 2026 23:14:36 +0530 Subject: [PATCH 12/13] fix(scaffolds): separate multi-script report entries with commas --- node-packages/wp-tooling/src/scaffolds/add.js | 22 ++++++++------- .../wp-tooling/tests/scaffolds/add.test.js | 27 ++++++++++++++----- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/node-packages/wp-tooling/src/scaffolds/add.js b/node-packages/wp-tooling/src/scaffolds/add.js index 5922a29..9a6535e 100644 --- a/node-packages/wp-tooling/src/scaffolds/add.js +++ b/node-packages/wp-tooling/src/scaffolds/add.js @@ -107,6 +107,16 @@ function printHelp() { ); } +// Render [name, cmd] pairs as a pasteable JSON object body: escaped, comma-separated. +function scriptEntryLines(entries) { + return entries.map( + ([name, cmd], i) => + ` ${JSON.stringify(name)}: ${JSON.stringify(cmd)}${ + i < entries.length - 1 ? ',' : '' + }` + ); +} + function printHumanReport(result) { const { scaffold, engine, developer, ai, warnings } = result; const lines = []; @@ -187,19 +197,11 @@ function printHumanReport(result) { } if (npmScripts.length) { lines.push(' Add to package.json "scripts":'); - for (const [name, cmd] of npmScripts) { - lines.push( - ` ${JSON.stringify(name)}: ${JSON.stringify(cmd)}` - ); - } + lines.push(...scriptEntryLines(npmScripts)); } if (composerScripts.length) { lines.push(' Add to composer.json "scripts":'); - for (const [name, cmd] of composerScripts) { - lines.push( - ` ${JSON.stringify(name)}: ${JSON.stringify(cmd)}` - ); - } + lines.push(...scriptEntryLines(composerScripts)); } if (developer.secrets.length) { lines.push( diff --git a/node-packages/wp-tooling/tests/scaffolds/add.test.js b/node-packages/wp-tooling/tests/scaffolds/add.test.js index bcd7eab..13accfe 100644 --- a/node-packages/wp-tooling/tests/scaffolds/add.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/add.test.js @@ -233,7 +233,11 @@ describe('add command human report', () => { files: [{ src: 'templates/out.txt.mustache', dest: 'out.txt' }], npm_dev_dependencies: { 'pa11y-ci': '^6.0.0' }, scripts: { - npm: { 'profile:server': `run --cwd='my "odd" dir'` }, + npm: { + 'test:perf': 'wp-tooling perf', + 'profile:server': `run --cwd='my "odd" dir'`, + }, + composer: { lint: 'phpcs', analyse: 'phpstan analyse' }, }, }), 'utf8' @@ -266,7 +270,7 @@ describe('add command human report', () => { expect(captured).toContain('npm install --save-dev pa11y-ci@^6.0.0'); }); - it('prints package.json script entries as valid JSON, escaping quotes', async () => { + it('prints each scripts block as a pasteable JSON body — escaped and comma-separated', async () => { const cwd = makeTmpDir(); await buildProjectWithDevDeps(cwd); const originalOut = process.stdout.write.bind(process.stdout); @@ -283,12 +287,23 @@ describe('add command human report', () => { ]); process.stdout.write = originalOut; expect(code).toBe(0); - const entry = captured - .split('\n') - .find((l) => l.trim().startsWith('"profile:server"')); - expect(JSON.parse(`{${entry}}`)).toEqual({ + // The entry lines right under a heading, parsed together as one object. + const block = (heading) => { + const lines = captured.split('\n'); + const start = lines.indexOf(heading) + 1; + const end = lines.findIndex( + (l, i) => i >= start && !l.startsWith(' ') + ); + return JSON.parse(`{${lines.slice(start, end).join('\n')}}`); + }; + expect(block(' Add to package.json "scripts":')).toEqual({ + 'test:perf': 'wp-tooling perf', 'profile:server': `run --cwd='my "odd" dir'`, }); + expect(block(' Add to composer.json "scripts":')).toEqual({ + lint: 'phpcs', + analyse: 'phpstan analyse', + }); }); }); From 90ac4888eb68023ad38d43119e9d292d7d546df9 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Thu, 24 Sep 2026 20:49:32 +0530 Subject: [PATCH 13/13] fix(perf): validate config types, run lighthouse independently, share a11y bin resolver --- node-packages/wp-tooling/CHANGELOG.md | 2 +- .../wp-tooling/src/a11y/resolve-bin.js | 1 + node-packages/wp-tooling/src/perf/config.js | 84 +++++++++- node-packages/wp-tooling/src/perf/errors.js | 2 + .../wp-tooling/src/perf/lighthouse.js | 8 +- .../wp-tooling/src/perf/normalize.js | 6 +- .../wp-tooling/src/perf/resolve-bin.js | 108 ------------ .../wp-tooling/src/perf/resolve-module.js | 2 +- node-packages/wp-tooling/src/perf/run.js | 29 +++- .../wp-tooling/tests/perf/cli.test.js | 106 +++++++++++- .../wp-tooling/tests/perf/config.test.js | 57 +++++++ .../wp-tooling/tests/perf/normalize.test.js | 18 ++ .../wp-tooling/tests/perf/resolve-bin.test.js | 158 ------------------ 13 files changed, 292 insertions(+), 289 deletions(-) delete mode 100644 node-packages/wp-tooling/src/perf/resolve-bin.js delete mode 100644 node-packages/wp-tooling/tests/perf/resolve-bin.test.js diff --git a/node-packages/wp-tooling/CHANGELOG.md b/node-packages/wp-tooling/CHANGELOG.md index 1608fb7..7276969 100644 --- a/node-packages/wp-tooling/CHANGELOG.md +++ b/node-packages/wp-tooling/CHANGELOG.md @@ -10,7 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `wp-tooling a11y` subcommand + `@rtcamp/wp-tooling/a11y` library (`runA11y()` core, `runCli()` CLI adapter). Runs the consumer-installed `pa11y-ci` — resolved from the nearest local or hoisted `node_modules/pa11y-ci/package.json` and launched through its `bin` entry with Node; no shim execution, npx fallback, or network install — against the URLs in the project's pa11y config (`.pa11yci.json` by default, `--config ` to point elsewhere) and normalises the JSON into a stable report: `summary` counts plus per-URL `violations` carrying `id`, parsed `wcagCriterion`, `impact`, `runner`, `message`, `selector`, `context`, and grep-ready `domHints` (`tagName`, `classList`, `idAttr`, `attrs`). A URL that fails to load is a `scanError` counted in `summary.failedUrls`, never a violation. Exit codes: 0 clean · 1 run failure or unreachable URL · 2 usage/binary missing · 3 violations found. Supports `--output text|json` and `--dry-run`. The `setup/pa11y` scaffold's dependency pin is corrected to the published `pa11y-ci ^4.1.1`, and its config template now runs both engines (`"runners": ["axe", "htmlcs"]`) and scans project-owned URLs — the front page plus configurable page paths (`sample_page`, `search_page`, optional `extra_page`, each appended to `base_url`) — instead of `wp-admin`/`wp-login` (unauthenticated admin scans only ever audit the core-owned login chrome). - `accessibility` Claude Code skill (`skills/accessibility/`) — a find → fix → re-check lens over `wp-tooling a11y`: triages violations by WCAG criterion and impact, maps each one to the theme/plugin source that rendered it via the report's `domHints`, proposes minimal fixes with consent, and re-verifies until clean; core/third-party markup is classified as upstream and reported, never patched. Installed into consumers by `setup/claude-skills` alongside the `scaffold` and `setup` skills. -- `wp-tooling perf` — two-layer performance runner mirroring `a11y`. Layer 1 (frontend, always on): launches consumer-installed `puppeteer`, injects the `web-vitals` attribution build, and collects LCP/CLS/FCP/TTFB with `reportAllChanges: true` (LCP/CLS never finalize headless without input, so the latest reported candidate is harvested after a settle delay); INP is always `null` in the lab layer (no interaction is performed). Optional Lighthouse pass (`--only-categories=performance`, pinned to the puppeteer-installed Chrome via `CHROME_PATH`) contributes category scores + top failing audits. Layer 2 (server, opt-in via `server.enabled`): runs the consumer's `server-profile.php` shim over WP-CLI (`wp eval-file`) to get xhprof/tideways function hotspots, normalized with a CLI-context fidelity note; degrades to an empty `top[]` with guidance — never an error — when no backend or `rtcamp/wp-dev-tools` is installed, and a broken invocation degrades the same way rather than failing the run (the server layer is auxiliary cause-data). `src/perf/{errors,resolve-bin,resolve-module,config,collect-vitals,lighthouse,server-profile,normalize,run,index}.js`; `"./perf"` exports entry; `src/cli/commands/perf.js` (auto-discovered). Flags mirror `a11y`: `--config ` (default `.perfrc.json`, optional when `--url` is given — unlike `a11y`'s config), repeatable `--url` (replaces the config's `urls[]` entirely), `--output text|json`, `--dry-run`. Same exit contract: 0 clean · 1 run failure or unreachable URL · 2 usage or module/binary missing · 3 issues found (a page-load failure, an empty web-vitals harvest, or `EBINFAIL` still exits 1; a missing `puppeteer`/`web-vitals`/lighthouse, no resolvable URLs, or a malformed/unreadable config — `EBADJSON`/`ECONFIGREAD` — exits 2). Zero runtime dependencies — Node built-ins plus the consumer-installed `puppeteer`, `web-vitals`, and `lighthouse` dev dependencies; no `src/ui`, plain `process.stdout`/`stderr.write`. `engines.node` is `>=22.19` (Lighthouse 13's floor), with a matching root `.nvmrc`. +- `wp-tooling perf` — two-layer performance runner mirroring `a11y`. Layer 1 (frontend, always on): launches consumer-installed `puppeteer`, injects the `web-vitals` attribution build, and collects LCP/CLS/FCP/TTFB with `reportAllChanges: true` (LCP/CLS never finalize headless without input, so the latest reported candidate is harvested after a settle delay); INP is always `null` in the lab layer (no interaction is performed). Optional Lighthouse pass (launched through its package `bin` entry with Node via the shared `src/a11y/resolve-bin.js` resolver, like `pa11y-ci` — no `.bin` shim or npx fallback; `--only-categories=performance`, pinned to the puppeteer-installed Chrome via `CHROME_PATH`) contributes category scores + top failing audits; it navigates in its own browser, so it still runs (and records or degrades on its own) after a puppeteer navigation failure. Layer 2 (server, opt-in via `server.enabled`): runs the consumer's `server-profile.php` shim over WP-CLI (`wp eval-file`) to get xhprof/tideways function hotspots, normalized with a CLI-context fidelity note; degrades to an empty `top[]` with guidance — never an error — when no backend or `rtcamp/wp-dev-tools` is installed, and a broken invocation degrades the same way rather than failing the run (the server layer is auxiliary cause-data). `src/perf/{errors,resolve-module,config,collect-vitals,lighthouse,server-profile,normalize,run,index}.js`; `"./perf"` exports entry; `src/cli/commands/perf.js` (auto-discovered). Flags mirror `a11y`: `--config ` (default `.perfrc.json`, optional when `--url` is given — unlike `a11y`'s config), repeatable `--url` (replaces the config's `urls[]` entirely), `--output text|json`, `--dry-run`. Same exit contract: 0 clean · 1 run failure or unreachable URL · 2 usage or module/binary missing · 3 issues found (a page-load failure, an empty web-vitals harvest, or `EBINFAIL` still exits 1; a missing `puppeteer`/`web-vitals`/lighthouse, no resolvable URLs, or a malformed/unreadable config — `EBADJSON`/`ECONFIGREAD`, or `EBADCONFIG` for a known field with the wrong type or value, e.g. `"enabled": "false"` — exits 2). Zero runtime dependencies — Node built-ins plus the consumer-installed `puppeteer`, `web-vitals`, and `lighthouse` dev dependencies; no `src/ui`, plain `process.stdout`/`stderr.write`. `engines.node` is `>=22.19` (Lighthouse 13's floor), with a matching root `.nvmrc`. - `setup/perf` scaffold — renders `.perfrc.json` (project-owned URL slots: `sample_page`/`search_page`/optional `extra_page`, same pattern as `setup/pa11y`) and ships a hardened `server-profile.php` shim (`raw: true`, copied verbatim) plus `web-vitals`/`lighthouse`/`puppeteer` dev-dependency pins and `test:perf` / `profile:server` npm scripts. The shim removes `template_redirect`'s `redirect_canonical` before rendering (a canonical redirect ends the request with `exit()`, which bypasses `finally` and would otherwise kill the process before the profiler stops or the JSON is echoed), profiles with `start()`/`stop()` plus a `register_shutdown_function` fallback that drains open output buffers, copies the target's query string into `$_GET` before `wp()` (`WP::parse_request()` reads `$_GET`, not `REQUEST_URI`), and emits a STDERR route + backend diagnostic. Consumes `rtCamp\WPDevTools\Support\XHProfProfiler` (require-dev `rtcamp/wp-dev-tools`) — never reimplements xhprof; degrades to `[]` + a STDERR note when the class isn't installed. ## [1.0.0] - 2026-07-30 diff --git a/node-packages/wp-tooling/src/a11y/resolve-bin.js b/node-packages/wp-tooling/src/a11y/resolve-bin.js index 0cd9b94..05d1a53 100644 --- a/node-packages/wp-tooling/src/a11y/resolve-bin.js +++ b/node-packages/wp-tooling/src/a11y/resolve-bin.js @@ -1,5 +1,6 @@ /** * Resolve the consumer's installed Node CLI, including workspace-hoisted copies. + * Shared by the a11y (pa11y-ci) and perf (lighthouse) runners. * Launch its package.json bin entry with Node on every platform: npm's .bin * shims are platform-specific and Windows .cmd files cannot use execFileSync. * No npx fallback: a cached/global package is not the consumer's dependency. diff --git a/node-packages/wp-tooling/src/perf/config.js b/node-packages/wp-tooling/src/perf/config.js index 2c77ad6..388ff24 100644 --- a/node-packages/wp-tooling/src/perf/config.js +++ b/node-packages/wp-tooling/src/perf/config.js @@ -55,6 +55,86 @@ const DEFAULTS = { }, }; +/** Accepted `thresholds.cwv` modes (see `normalize.js` `isCwvIssue`). */ +const CWV_MODES = ['poor', 'needs-improvement', 'never']; + +/** + * Describe why a section field's value is invalid, judged against the type of + * its built-in default. Unknown keys are not checked — they are never read. + * + * @param {string} key Field name. + * @param {*} value Configured value. + * @param {*} def Built-in default for the field. + * @return {string|null} What was expected, or `null` when the value is valid. + */ +function fieldProblem(key, value, def) { + if (Array.isArray(def)) { + return Array.isArray(value) && + value.length > 0 && + value.every((v) => typeof v === 'string' && v.length > 0) + ? null + : 'a non-empty array of strings'; + } + if (typeof def === 'number') { + return Number.isFinite(value) && value >= 0 + ? null + : 'a non-negative number'; + } + if (key === 'cwv') { + return CWV_MODES.includes(value) + ? null + : `one of ${CWV_MODES.map((m) => `"${m}"`).join(', ')}`; + } + return typeof value === typeof def ? null : `a ${typeof def}`; +} + +/** + * Reject a parsed config whose known fields have the wrong type, so a typo + * like `"enabled": "false"` cannot silently invert a layer or surface later + * as a degraded layer instead of a config error. + * + * @param {*} raw Parsed config. + * @param {string} configPath Config path, for the error message. + * @throws {RunnerError} `EBADCONFIG` naming the first invalid field. + */ +function validateConfig(raw, configPath) { + const fail = (field, expected) => { + throw new RunnerError( + 'EBADCONFIG', + `invalid ${configPath}: "${field}" must be ${expected}`, + { configPath } + ); + }; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + fail('(root)', 'an object'); + } + if (raw.urls !== undefined && !Array.isArray(raw.urls)) { + fail('urls', 'an array of URL strings'); + } + for (const section of Object.keys(DEFAULTS)) { + const override = raw[section]; + if (section === 'urls' || override === undefined) { + continue; + } + if ( + !override || + typeof override !== 'object' || + Array.isArray(override) + ) { + fail(section, 'an object'); + } + for (const [key, def] of Object.entries(DEFAULTS[section])) { + if (override[key] === undefined) { + continue; + } + const expected = fieldProblem(key, override[key], def); + if (expected) { + fail(`${section}.${key}`, expected); + } + } + } +} + /** * Shallow-merge a config section over its defaults. * @@ -99,7 +179,8 @@ function mergeConfig(raw) { * @return {{config: Object, configPath: string|null, urls: string[]}} Resolved config, the * config path actually read (`null` when none was read), and the effective URL list. * @throws {RunnerError} `ENOURLS` when no URLs are available; `EBADJSON` when the config - * is malformed; `ECONFIGREAD` when the config path exists but could not be read. + * is malformed; `EBADCONFIG` when a known field has the wrong type or value; + * `ECONFIGREAD` when the config path exists but could not be read. */ function resolveConfig(options = {}) { const cwd = options.cwd || process.cwd(); @@ -145,6 +226,7 @@ function resolveConfig(options = {}) { { configPath } ); } + validateConfig(raw, configPath); } const config = mergeConfig(raw); diff --git a/node-packages/wp-tooling/src/perf/errors.js b/node-packages/wp-tooling/src/perf/errors.js index d135a8c..219f360 100644 --- a/node-packages/wp-tooling/src/perf/errors.js +++ b/node-packages/wp-tooling/src/perf/errors.js @@ -12,6 +12,8 @@ * EBINFAIL a browser or binary launch failed for a reason other than * "found issues" * EBADJSON the perf config could not be parsed as JSON + * EBADCONFIG the perf config parsed, but a known field has the wrong type + * or value (e.g. `"enabled": "false"`) * ECONFIGREAD the perf config path exists but could not be read — e.g. it * is a directory, or permission was denied (distinct from a * simply-absent config, which is not an error) diff --git a/node-packages/wp-tooling/src/perf/lighthouse.js b/node-packages/wp-tooling/src/perf/lighthouse.js index 8df337a..e6ab433 100644 --- a/node-packages/wp-tooling/src/perf/lighthouse.js +++ b/node-packages/wp-tooling/src/perf/lighthouse.js @@ -1,10 +1,10 @@ /** * Lighthouse performance layer for one URL. * - * Runs the consumer-installed `lighthouse` binary (resolved via - * `resolve-bin.js`, the same local/hoisted/`npx --no-install` chain as the - * a11y runner's `pa11y-ci` resolution) restricted to the `performance` - * category, with `--chrome-flags` pointed at Chrome for Testing via + * Runs the consumer-installed `lighthouse` CLI (resolved and launched by the + * a11y runner's `resolve-bin.js`, exactly like `pa11y-ci`: the package's `bin` + * entry run with Node, no `.bin` shim or npx fallback) restricted to the + * `performance` category, with `--chrome-flags` pointed at Chrome for Testing via * `CHROME_PATH` so the consumer machine needs no system Chrome install. * A per-URL failure here is a degrade, not a run failure — `run.js` catches * `RunnerError`s from this module and continues with `lighthouse: null` for diff --git a/node-packages/wp-tooling/src/perf/normalize.js b/node-packages/wp-tooling/src/perf/normalize.js index b192cd1..d6b7952 100644 --- a/node-packages/wp-tooling/src/perf/normalize.js +++ b/node-packages/wp-tooling/src/perf/normalize.js @@ -250,8 +250,8 @@ function rateMeasurableMetric(name, vitalMetric, url, cwvMode) { /** * Build the per-URL result entry for a browser scan failure — empty metrics - * and no assessment; the server layer (which runs independently of the - * browser) is unaffected. + * and no assessment; the lighthouse and server layers (which run + * independently of the puppeteer browser) are kept as captured. * * @param {Object} raw Raw per-URL capture with a truthy `scanError`. * @return {Object} Result entry for `normalizePerf`'s `results[]`. @@ -262,7 +262,7 @@ function buildScanErrorResult(raw) { scanError: raw.scanError, metrics: Object.fromEntries(METRIC_NAMES.map((name) => [name, null])), attribution: { lcpElement: null, clsSources: [], inpTarget: null }, - lighthouse: null, + lighthouse: raw.lighthouse || null, server: normalizeServer(raw.server), assessment: [], notes: raw.notes || [], diff --git a/node-packages/wp-tooling/src/perf/resolve-bin.js b/node-packages/wp-tooling/src/perf/resolve-bin.js deleted file mode 100644 index 6931c71..0000000 --- a/node-packages/wp-tooling/src/perf/resolve-bin.js +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Consumer binary resolution for the perf runner. - * - * `@rtcamp/wp-tooling` has zero runtime dependencies, so `lighthouse` is - * never a dependency here — it lives in the CONSUMER project's own dev - * dependencies. These helpers locate that consumer-installed binary - * (a direct or workspace-hoisted `node_modules/.bin/`), falling back to - * `npx --no-install` so we never silently fetch it from the network. - * - * POSIX only: binaries are run via `execFileSync` without a shell, which - * cannot execute the `.cmd` shims npm creates under `.bin` on Windows. - */ - -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const { execFileSync } = require('child_process'); - -const VERSION_PROBE_TIMEOUT_MS = 20000; - -/** - * Walk up from `cwd` looking for `node_modules/.bin/`. - * - * @param {string} binName Binary name (e.g. `lighthouse`). - * @param {string} cwd Directory to start the search from. - * @return {{command: string, source: 'local'|'hoisted'}|null} The resolved - * binary, or `null` when no installed copy is found. - */ -function findInNodeModules(binName, cwd) { - const start = path.resolve(cwd); - let dir = start; - while (true) { - const candidate = path.join(dir, 'node_modules', '.bin', binName); - if (fs.existsSync(candidate)) { - return { - command: candidate, - source: dir === start ? 'local' : 'hoisted', - }; - } - const parent = path.dirname(dir); - if (parent === dir) { - return null; - } - dir = parent; - } -} - -/** - * Resolve how to invoke a consumer-installed binary. - * - * @param {string} binName Binary name. - * @param {Object} [options] - * @param {string} [options.cwd] Directory to resolve from. - * @return {{command: string, args: string[], source: 'local'|'hoisted'|'npx'}} - * Command + leading args + how it was resolved. - */ -function resolveBin(binName, options = {}) { - const cwd = options.cwd || process.cwd(); - const found = findInNodeModules(binName, cwd); - if (found) { - return { command: found.command, args: [], source: found.source }; - } - // `--no-install` keeps npx from fetching the package: if the consumer has - // not installed it, the probe below simply reports it unavailable and the - // caller surfaces the install hint. - return { command: 'npx', args: ['--no-install', binName], source: 'npx' }; -} - -/** - * Probe a binary's `--version` to confirm it is actually runnable. - * - * @param {string} binName Binary name. - * @param {Object} [options] - * @param {string} [options.cwd] Directory to run in. - * @return {{available: boolean, version: string|null, command: string, - * args: string[], source: string, error?: string}} Probe result. - */ -function detectBin(binName, options = {}) { - const cwd = options.cwd || process.cwd(); - const { command, args, source } = resolveBin(binName, { cwd }); - try { - const out = execFileSync(command, [...args, '--version'], { - cwd, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - timeout: VERSION_PROBE_TIMEOUT_MS, - }); - return { - available: true, - version: out.toString().trim(), - command, - args, - source, - }; - } catch (err) { - return { - available: false, - version: null, - command, - args, - source, - error: (err.stderr || err.message || '').toString().trim(), - }; - } -} - -module.exports = { resolveBin, detectBin, findInNodeModules }; diff --git a/node-packages/wp-tooling/src/perf/resolve-module.js b/node-packages/wp-tooling/src/perf/resolve-module.js index f61f09e..9666ad1 100644 --- a/node-packages/wp-tooling/src/perf/resolve-module.js +++ b/node-packages/wp-tooling/src/perf/resolve-module.js @@ -4,7 +4,7 @@ * `puppeteer` and `web-vitals` are consumer dev dependencies, never runtime * dependencies of `@rtcamp/wp-tooling`. These helpers walk up from `cwd` * looking for an installed copy in `node_modules`, the same shape as - * `resolve-bin.js` for binaries. Unlike `detectBin`, no child process is + * `../a11y/resolve-bin.js` for binaries. Unlike `detectBin`, no child process is * spawned — the version comes straight from the module's own package.json. */ diff --git a/node-packages/wp-tooling/src/perf/run.js b/node-packages/wp-tooling/src/perf/run.js index a6ff2dc..6a5f345 100644 --- a/node-packages/wp-tooling/src/perf/run.js +++ b/node-packages/wp-tooling/src/perf/run.js @@ -27,7 +27,7 @@ const { requirePuppeteer, detectModule, } = require('./resolve-module'); -const { detectBin, resolveBin } = require('./resolve-bin'); +const { detectBin, resolveBin } = require('../a11y/resolve-bin'); const { launchBrowser, collectVitals } = require('./collect-vitals'); const { runLighthouse, BIN: LIGHTHOUSE_BIN } = require('./lighthouse'); const { runServerProfile } = require('./server-profile'); @@ -67,7 +67,7 @@ function requireInstalled(condition, message, details = {}) { * @param {string[]} [options.urls] Repeatable `--url` values. * @param {string} [options.cwd] Project root. * @return {Promise} Normalized report (see normalize.js). - * @throws {RunnerError} EBINMISSING / EBINFAIL / EBADJSON / ENOURLS. + * @throws {RunnerError} EBINMISSING / EBINFAIL / EBADJSON / EBADCONFIG / ENOURLS. */ async function runPerf(options = {}) { const cwd = options.cwd || process.cwd(); @@ -141,8 +141,8 @@ async function runPerf(options = {}) { * per-URL `scanError` (the caller's run continues); lighthouse and server * failures degrade to `null` + a note without affecting the overall run. A * page that loads but whose collector throws or harvests no metric is a - * separate `vitalsError` — Lighthouse navigates independently, so it still - * runs for it. + * separate `vitalsError`. Lighthouse navigates independently, so it runs in + * every case. * * @param {string} url Target URL. * @param {Object} ctx Shared context for the run. @@ -185,10 +185,10 @@ async function collectOne(url, ctx) { } let lighthouse = null; - // Lighthouse needs the same network reachability as puppeteer -- skip it - // once navigation already failed, rather than spend its own timeout on a - // dead URL. A collector failure after a good load still gets its pass. - if (!scanError && config.lighthouse.enabled && lighthouseBin) { + // Lighthouse navigates in its own browser, so it runs even after a + // puppeteer navigation failure (e.g. a networkidle2 timeout on a page + // that did load) and records or degrades independently. + if (config.lighthouse.enabled && lighthouseBin) { try { const lhr = runLighthouse(lighthouseBin, url, config.lighthouse, { cwd, @@ -305,6 +305,14 @@ function emit(report, mode) { if (result.scanError) { lines.push(`${result.url} — scan failed`); lines.push(` ${result.scanError}`); + if ( + result.lighthouse && + typeof result.lighthouse.scores.performance === 'number' + ) { + lines.push( + ` lighthouse performance ${result.lighthouse.scores.performance}` + ); + } } else { lines.push(`${result.url}`); for (const line of result.assessment) { @@ -406,7 +414,9 @@ function runDryRun(opts, cwd) { // resolveBin only -- dry-run must not spawn lighthouse --version. const bin = resolveBin(LIGHTHOUSE_BIN, { cwd }); lines.push( - ` lighthouse: ${bin.command} (${bin.source}, not probed — dry run)` + bin.source === 'missing' + ? ' lighthouse: NOT FOUND' + : ` lighthouse: ${[bin.command, ...bin.args].join(' ')} (${bin.source}, not probed — dry run)` ); } else { lines.push(' lighthouse: disabled'); @@ -446,6 +456,7 @@ function handleError(err) { (err.code === 'EBINMISSING' || err.code === 'ENOURLS' || err.code === 'EBADJSON' || + err.code === 'EBADCONFIG' || err.code === 'ECONFIGREAD') ) { return 2; diff --git a/node-packages/wp-tooling/tests/perf/cli.test.js b/node-packages/wp-tooling/tests/perf/cli.test.js index 7e07464..9e1c141 100644 --- a/node-packages/wp-tooling/tests/perf/cli.test.js +++ b/node-packages/wp-tooling/tests/perf/cli.test.js @@ -4,6 +4,8 @@ jest.mock('child_process'); jest.mock('../../src/perf/resolve-module'); jest.mock('../../src/perf/collect-vitals'); +const fs = require('fs'); +const os = require('os'); const path = require('path'); const { execFileSync, spawnSync } = require('child_process'); const resolveModule = require('../../src/perf/resolve-module'); @@ -76,8 +78,23 @@ describe('perf runCli', () => { let stderr; let outSpy; let errSpy; + let cwdSpy; + let root; + let lighthouseEntry; beforeEach(() => { + // A consumer project with lighthouse installed: the shared resolver + // reads its package.json bin entry and launches it with Node. + root = fs.mkdtempSync(path.join(os.tmpdir(), 'perf-cli-')); + const packageDir = path.join(root, 'node_modules', 'lighthouse'); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync( + path.join(packageDir, 'package.json'), + JSON.stringify({ bin: { lighthouse: './cli/index.js' } }) + ); + lighthouseEntry = path.join(packageDir, 'cli', 'index.js'); + cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(root); + stdout = []; stderr = []; outSpy = jest.spyOn(process.stdout, 'write').mockImplementation((c) => { @@ -116,6 +133,8 @@ describe('perf runCli', () => { afterEach(() => { outSpy.mockRestore(); errSpy.mockRestore(); + cwdSpy.mockRestore(); + fs.rmSync(root, { recursive: true, force: true }); }); test('--help prints usage and exits 0', async () => { @@ -147,11 +166,54 @@ describe('perf runCli', () => { expect(stderr.join('')).toMatch(/wp-tooling add setup\/perf/); }); + test('launches lighthouse through Node and its package bin entry, never a .bin shim', async () => { + const code = await runCli(['--config', FIXTURE_CONFIG]); + expect(code).toBe(0); + const lighthouseCalls = execFileSync.mock.calls.filter( + (call) => call[1][0] === lighthouseEntry + ); + // The --version probe and one run per fixture URL. + expect(lighthouseCalls.length).toBeGreaterThan(1); + for (const [command] of lighthouseCalls) { + expect(command).toBe(process.execPath); + } + }); + + test('an uninstalled lighthouse exits 2 without probing or falling back to npx', async () => { + fs.rmSync(path.join(root, 'node_modules', 'lighthouse'), { + recursive: true, + }); + expect(await runCli(['--config', FIXTURE_CONFIG])).toBe(2); + expect(stderr.join('')).toMatch(/lighthouse not found/); + expect(execFileSync).not.toHaveBeenCalled(); + }); + + test('a lighthouse that fails its --version probe exits 2', async () => { + mockChildProcess({ lighthouseAvailable: false }); + expect(await runCli(['--config', FIXTURE_CONFIG])).toBe(2); + expect(stderr.join('')).toMatch(/lighthouse not found/); + }); + test('no config and no --url exits 2 (ENOURLS)', async () => { expect(await runCli(['--config', MISSING_CONFIG])).toBe(2); expect(stderr.join('')).toMatch(/no URLs to test/); }); + test('a config with a wrongly typed field exits 2 (EBADCONFIG) before any layer runs', async () => { + const configPath = path.join(root, '.perfrc.json'); + fs.writeFileSync( + configPath, + JSON.stringify({ + urls: ['http://localhost:8888/'], + server: { enabled: 'false' }, + }) + ); + expect(await runCli(['--config', configPath])).toBe(2); + expect(stderr.join('')).toMatch(/"server\.enabled" must be a boolean/); + expect(spawnSync).not.toHaveBeenCalled(); + expect(collectVitalsModule.collectVitals).not.toHaveBeenCalled(); + }); + test('a malformed config exits 2 (EBADJSON), not 1', async () => { expect(await runCli(['--config', MALFORMED_CONFIG])).toBe(2); expect(stderr.join('')).toMatch(/invalid JSON/); @@ -239,13 +301,15 @@ describe('perf runCli', () => { expect(parsed.summary.failedUrls).toBeGreaterThan(0); expect(stderr.join('')).toMatch(/incomplete/); - // Lighthouse needs the same reachability as puppeteer, so it must be - // skipped for a failed URL: only the initial --version probe ran, no - // per-URL lighthouse invocation. + // Lighthouse navigates in its own browser, so a puppeteer navigation + // failure (e.g. a networkidle2 timeout) doesn't skip it: it still runs + // and its result is recorded on the failed URL. const nonProbeCalls = execFileSync.mock.calls.filter( (call) => !call[1].includes('--version') ); - expect(nonProbeCalls).toHaveLength(0); + expect(nonProbeCalls.length).toBeGreaterThan(0); + expect(parsed.results[0].scanError).toMatch(/navigation failed/); + expect(parsed.results[0].lighthouse.scores.performance).toBe(0.95); // The server layer profiles via WP-CLI, not the browser, so it still // runs even though the frontend layer failed to load. expect(spawnSync).toHaveBeenCalled(); @@ -336,10 +400,32 @@ describe('perf runCli', () => { expect(code).toBe(1); const out = stdout.join(''); expect(out).toMatch(/— scan failed/); + expect(out).toMatch(/lighthouse performance 0\.95/); expect(out).toMatch(/server top: WP_Query::get_posts/); expect(out).toMatch(/server note: profiled via/); }); + test('after a navigation failure, a failing lighthouse degrades on its own', async () => { + mockChildProcess({ lighthouseRunThrows: true }); + collectVitalsModule.collectVitals.mockRejectedValue( + new RunnerError( + 'ENAVFAIL', + 'navigation failed: Navigation timeout of 5000 ms exceeded' + ) + ); + const code = await runCli([ + '--config', + FIXTURE_CONFIG, + '--output', + 'json', + ]); + expect(code).toBe(1); + const result = JSON.parse(stdout.join('')).results[0]; + expect(result.scanError).toMatch(/Navigation timeout/); + expect(result.lighthouse).toBeNull(); + expect(result.notes.join('')).toMatch(/lighthouse: failed/); + }); + test('a lighthouse runtime failure degrades that layer without affecting the exit code', async () => { mockChildProcess({ lighthouseRunThrows: true }); const code = await runCli([ @@ -390,6 +476,15 @@ describe('perf runCli', () => { expect(parsed.results[0].server).toBeNull(); }); + test('--dry-run reports an uninstalled lighthouse as NOT FOUND', async () => { + fs.rmSync(path.join(root, 'node_modules', 'lighthouse'), { + recursive: true, + }); + const code = await runCli(['--dry-run', '--config', FIXTURE_CONFIG]); + expect(code).toBe(0); + expect(stdout.join('')).toContain('lighthouse: NOT FOUND'); + }); + test('--dry-run resolves everything but runs nothing', async () => { const code = await runCli(['--dry-run', '--config', FIXTURE_CONFIG]); expect(code).toBe(0); @@ -397,6 +492,9 @@ describe('perf runCli', () => { expect(out).toMatch(/\[dry-run\] perf would run:/); expect(out).toMatch(/puppeteer:/); expect(out).toMatch(/lighthouse:.*not probed — dry run/); + expect(out).toContain( + `lighthouse: ${process.execPath} ${lighthouseEntry} (local,` + ); expect(out).toMatch(/server:/); // Dry-run must not probe lighthouse (or invoke anything else). diff --git a/node-packages/wp-tooling/tests/perf/config.test.js b/node-packages/wp-tooling/tests/perf/config.test.js index 8a64c49..a4ee558 100644 --- a/node-packages/wp-tooling/tests/perf/config.test.js +++ b/node-packages/wp-tooling/tests/perf/config.test.js @@ -1,5 +1,7 @@ 'use strict'; +const fs = require('fs'); +const os = require('os'); const path = require('path'); const { @@ -130,6 +132,61 @@ describe('resolveConfig — errors', () => { }); }); +describe('resolveConfig — field validation', () => { + let dir; + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'perf-config-')); + }); + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + /** + * Write `raw` as the config and resolve it with a --url, returning the error. + * + * @param {*} raw Config value to serialise. + * @return {Error|null} The thrown error, or null. + */ + function resolveRaw(raw) { + fs.writeFileSync(path.join(dir, '.perfrc.json'), JSON.stringify(raw)); + return grab(() => + resolveConfig({ cwd: dir, urls: ['http://example.test/'] }) + ); + } + + test.each([ + [{ server: { enabled: 'false' } }, 'server.enabled', 'a boolean'], + [ + { lighthouse: { categories: 'performance' } }, + 'lighthouse.categories', + 'array', + ], + [{ lighthouse: { topAudits: '5' } }, 'lighthouse.topAudits', 'number'], + [{ webVitals: { settleMs: -1 } }, 'webVitals.settleMs', 'number'], + [{ server: { command: [] } }, 'server.command', 'array'], + [{ server: { shim: 42 } }, 'server.shim', 'a string'], + [{ thresholds: { cwv: 'bad' } }, 'thresholds.cwv', '"poor"'], + [{ server: 'yes' }, 'server', 'an object'], + [{ urls: 'http://x/' }, 'urls', 'an array'], + [['http://x/'], '(root)', 'an object'], + ])('%j throws EBADCONFIG naming %s', (raw, field, expected) => { + const err = resolveRaw(raw); + expect(err).toBeInstanceOf(RunnerError); + expect(err.code).toBe('EBADCONFIG'); + expect(err.message).toContain(`"${field}" must be`); + expect(err.message).toContain(expected); + }); + + test('valid fields and unknown keys pass through', () => { + expect( + resolveRaw({ + server: { enabled: true, top: 0, futureKey: 'x' }, + thresholds: { cwv: 'never', lighthousePerformance: 0.9 }, + }) + ).toBeNull(); + }); +}); + describe('mergeConfig', () => { test('merges a partial section over its defaults without touching others', () => { const merged = mergeConfig({ diff --git a/node-packages/wp-tooling/tests/perf/normalize.test.js b/node-packages/wp-tooling/tests/perf/normalize.test.js index d220795..0c31fac 100644 --- a/node-packages/wp-tooling/tests/perf/normalize.test.js +++ b/node-packages/wp-tooling/tests/perf/normalize.test.js @@ -189,6 +189,24 @@ describe('normalizePerf', () => { expect(result.assessment).toEqual([]); }); + test('a scan error keeps the independently captured lighthouse layer, still not an issue', () => { + const lighthouse = { scores: { performance: 0.2 }, audits: [] }; + const report = normalizePerf([ + { + url: 'http://localhost:8888/', + scanError: + 'navigation failed: Navigation timeout of 30000 ms exceeded', + vitals: null, + lighthouse, + server: null, + notes: [], + }, + ]); + expect(report.results[0].lighthouse).toEqual(lighthouse); + expect(report.summary.failedUrls).toBe(1); + expect(report.summary.issues).toBe(0); + }); + test('a clean URL with good metrics counts as passed with zero issues', () => { const report = normalizePerf([ { diff --git a/node-packages/wp-tooling/tests/perf/resolve-bin.test.js b/node-packages/wp-tooling/tests/perf/resolve-bin.test.js deleted file mode 100644 index 24be259..0000000 --- a/node-packages/wp-tooling/tests/perf/resolve-bin.test.js +++ /dev/null @@ -1,158 +0,0 @@ -'use strict'; - -jest.mock('child_process'); - -const fs = require('fs'); -const os = require('os'); -const path = require('path'); - -const { execFileSync } = require('child_process'); -const { - findInNodeModules, - resolveBin, - detectBin, -} = require('../../src/perf/resolve-bin'); - -const BIN_NAME = 'wp-tooling-perf-fixture-bin'; - -function tmpTree() { - return fs.mkdtempSync(path.join(os.tmpdir(), 'perf-bin-')); -} - -function makeBin(dir, name) { - const binDir = path.join(dir, 'node_modules', '.bin'); - fs.mkdirSync(binDir, { recursive: true }); - const binPath = path.join(binDir, name); - fs.writeFileSync(binPath, '#!/usr/bin/env node\n'); - return binPath; -} - -describe('findInNodeModules', () => { - let root; - - afterEach(() => { - if (root) { - fs.rmSync(root, { recursive: true, force: true }); - root = null; - } - }); - - test('finds a directly installed binary as local', () => { - root = tmpTree(); - const binPath = makeBin(root, BIN_NAME); - expect(findInNodeModules(BIN_NAME, root)).toEqual({ - command: binPath, - source: 'local', - }); - }); - - test('finds a hoisted binary in an ancestor as hoisted', () => { - root = tmpTree(); - const binPath = makeBin(root, BIN_NAME); - const child = path.join(root, 'packages', 'app'); - fs.mkdirSync(child, { recursive: true }); - expect(findInNodeModules(BIN_NAME, child)).toEqual({ - command: binPath, - source: 'hoisted', - }); - }); - - test('returns null when no installed copy exists', () => { - root = tmpTree(); - expect( - findInNodeModules('definitely-not-installed-xyz', root) - ).toBeNull(); - }); -}); - -describe('resolveBin', () => { - let root; - - afterEach(() => { - if (root) { - fs.rmSync(root, { recursive: true, force: true }); - root = null; - } - }); - - test('resolves a locally installed binary with no extra args', () => { - root = tmpTree(); - const binPath = makeBin(root, BIN_NAME); - expect(resolveBin(BIN_NAME, { cwd: root })).toEqual({ - command: binPath, - args: [], - source: 'local', - }); - }); - - test('resolves a hoisted binary from a child directory', () => { - root = tmpTree(); - const binPath = makeBin(root, BIN_NAME); - const child = path.join(root, 'packages', 'app'); - fs.mkdirSync(child, { recursive: true }); - expect(resolveBin(BIN_NAME, { cwd: child })).toEqual({ - command: binPath, - args: [], - source: 'hoisted', - }); - }); - - test('falls back to npx --no-install when not installed', () => { - root = tmpTree(); - expect( - resolveBin('definitely-not-installed-xyz', { cwd: root }) - ).toEqual({ - command: 'npx', - args: ['--no-install', 'definitely-not-installed-xyz'], - source: 'npx', - }); - }); -}); - -describe('detectBin', () => { - let root; - - afterEach(() => { - execFileSync.mockReset(); - if (root) { - fs.rmSync(root, { recursive: true, force: true }); - root = null; - } - }); - - test('reports available with the trimmed --version output', () => { - root = tmpTree(); - const binPath = makeBin(root, BIN_NAME); - execFileSync.mockReturnValue('1.2.3\n'); - expect(detectBin(BIN_NAME, { cwd: root })).toEqual({ - available: true, - version: '1.2.3', - command: binPath, - args: [], - source: 'local', - }); - expect(execFileSync).toHaveBeenCalledWith( - binPath, - ['--version'], - expect.objectContaining({ cwd: root }) - ); - }); - - test('reports unavailable with the error detail when the probe fails', () => { - root = tmpTree(); - execFileSync.mockImplementation(() => { - const err = new Error('spawn npx ENOENT'); - err.stderr = 'not found\n'; - throw err; - }); - const result = detectBin('definitely-not-installed-xyz', { cwd: root }); - expect(result).toEqual({ - available: false, - version: null, - command: 'npx', - args: ['--no-install', 'definitely-not-installed-xyz'], - source: 'npx', - error: 'not found', - }); - }); -});