diff --git a/.claude/issues/wp-devtools-22-36-37-perf-runner.md b/.claude/issues/wp-devtools-22-36-37-perf-runner.md new file mode 100644 index 0000000..7ca68a0 --- /dev/null +++ b/.claude/issues/wp-devtools-22-36-37-perf-runner.md @@ -0,0 +1,107 @@ +# 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 8a77b0d..e66dc1a 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 3e14634..897d8a8 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..8396c18 --- /dev/null +++ b/node-packages/wp-tooling/scaffolds/setup/perf/scaffold.json @@ -0,0 +1,61 @@ +{ + "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_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, 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": { + "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..945e4bb --- /dev/null +++ b/node-packages/wp-tooling/scaffolds/setup/perf/templates/.perfrc.json.mustache @@ -0,0 +1,12 @@ +{ + "urls": [ + "{{base_url}}/", + "{{base_url}}{{sample_page}}", + "{{base_url}}{{search_page}}"{{#extra_page}}, + "{{base_url}}{{extra_page}}"{{/extra_page}} + ], + "server": { + "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/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/init/index.js b/node-packages/wp-tooling/src/init/index.js index b952384..7b4bdec 100644 --- a/node-packages/wp-tooling/src/init/index.js +++ b/node-packages/wp-tooling/src/init/index.js @@ -347,7 +347,9 @@ const setupSteps = (config, root, flags) => { skip: (c) => c.cancelled || (!(config.features || []).length && - !(config.examples && (config.examples.groups || []).length)), + !( + config.examples && (config.examples.groups || []).length + )), async run(c) { const features = config.features || []; const groups = @@ -387,18 +389,18 @@ const setupSteps = (config, root, flags) => { ]; const order = []; const byCat = new Map(); - for (const cap of caps) { - if (!byCat.has(cap.category)) { - byCat.set(cap.category, []); - order.push(cap.category); + for (const entry of caps) { + if (!byCat.has(entry.category)) { + byCat.set(entry.category, []); + order.push(entry.category); } - byCat.get(cap.category).push(cap); + byCat.get(entry.category).push(entry); } const treeGroups = order.map((category) => ({ label: category, - items: byCat.get(category).map((cap) => ({ - label: cap.label, - checked: cap.checked, + items: byCat.get(category).map((entry) => ({ + label: entry.label, + checked: entry.checked, })), })); const checked = new Set( 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..ca90fe6 --- /dev/null +++ b/node-packages/wp-tooling/src/perf/collect-vitals.js @@ -0,0 +1,143 @@ +/** + * 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 } = require('./normalize'); + +/** + * 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, +}; 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..7d03f17 --- /dev/null +++ b/node-packages/wp-tooling/src/perf/normalize.js @@ -0,0 +1,328 @@ +/** + * 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], +}; + +/** 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).'; + +/** + * 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 MEASURABLE_METRIC_NAMES) { + 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. + * + * `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. + * @return {Object} Normalised report. + */ +function normalizePerf(rawResults, options = {}) { + const thresholds = options.thresholds || { + cwv: 'poor', + lighthousePerformance: 0.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: Object.fromEntries( + METRIC_NAMES.map((name) => [name, 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 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; + } + } + 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; + 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, + METRIC_NAMES, + 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..094e042 --- /dev/null +++ b/node-packages/wp-tooling/src/perf/run.js @@ -0,0 +1,479 @@ +/** + * 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, 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. + * + * @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 }); + requireInstalled( + puppeteer, + `puppeteer not found. Install it in the project (\`${INSTALL_HINT}\` sets it up).` + ); + + const webVitalsFile = resolveModuleFile('web-vitals', WEB_VITALS_DIST, { + cwd, + }); + 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 }); + 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; + 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 }); +} + +/** + * 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; + // 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 { + 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}`); + process.stderr.write( + `perf: lighthouse failed for ${url}: ${detail}\n` + ); + } + } + + // 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 }); + 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'}`); + if (r.server.error) { + lines.push(` server error: ${r.server.error}`); + } + } + 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) { + const commandParts = Array.isArray(config.server.command) + ? config.server.command + : [String(config.server.command)]; + lines.push( + ` server: ${commandParts.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..3e5ac72 --- /dev/null +++ b/node-packages/wp-tooling/src/perf/server-profile.js @@ -0,0 +1,129 @@ +/** + * 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(); + + // 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', + 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..01454aa --- /dev/null +++ b/node-packages/wp-tooling/tests/perf/cli.test.js @@ -0,0 +1,315 @@ +'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/); + + // 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 () => { + 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. + // 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/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..db5021c --- /dev/null +++ b/node-packages/wp-tooling/tests/perf/normalize.test.js @@ -0,0 +1,371 @@ +'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', () => { + // normalizePerf expects raw.lighthouse already extracted, not a raw LHR. + const report = normalizePerf( + [ + { + url: 'http://localhost:8888/', + scanError: null, + vitals: { + metrics: { LCP: { value: 1000, rating: 'good' } }, + attribution: {}, + }, + lighthouse: extractLighthouse(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..25b2e58 --- /dev/null +++ b/node-packages/wp-tooling/tests/perf/server-profile.test.js @@ -0,0 +1,134 @@ +'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/); + }); + + 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 2c44225..a464a61 100644 --- a/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js @@ -88,3 +88,124 @@ describe('wiring targetFile normalisation', () => { expect(target).not.toContain('..'); }); }); + +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', + ]); + // 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', + 'wp-env', + 'run', + 'cli', + '--env-cwd=.', + '--', + 'wp', + ]); + }); + + 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( + 'setup/perf', + { + base_url: 'http://localhost:8765', + sample_page: '/hello-world/', + search_page: '/?s=wordpress', + extra_page: '/about/', + server_enabled: 'true', + 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('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(); + 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'); + }); +}); diff --git a/node-packages/wp-tooling/tests/ui/selects.test.js b/node-packages/wp-tooling/tests/ui/selects.test.js index 54edfb5..7078216 100644 --- a/node-packages/wp-tooling/tests/ui/selects.test.js +++ b/node-packages/wp-tooling/tests/ui/selects.test.js @@ -205,7 +205,10 @@ describe('checkboxTree (non-TTY)', () => { { label: 'Taxonomies', checked: true }, ], }, - { label: 'Dev', items: [{ label: 'Tailwind', checked: false }] }, + { + label: 'Dev', + items: [{ label: 'Tailwind', checked: false }], + }, ], }); @@ -227,7 +230,10 @@ describe('checkboxTree (non-TTY)', () => { { label: 'Taxonomies', checked: true }, ], }, - { label: 'Dev', items: [{ label: 'Tailwind', checked: false }] }, + { + label: 'Dev', + items: [{ label: 'Tailwind', checked: false }], + }, ], });