Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions .claude/issues/wp-devtools-22-36-37-perf-runner.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions node-packages/wp-tooling/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` (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.
Expand Down
1 change: 1 addition & 0 deletions node-packages/wp-tooling/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
61 changes: 61 additions & 0 deletions node-packages/wp-tooling/scaffolds/setup/perf/scaffold.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
Original file line number Diff line number Diff line change
@@ -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"]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php
/**
* Server-side XHProf profile of a front-end render path, for `wp eval-file`.
*
* Usage:
* npm run profile:server -- [<path>] [<top>]
* # or directly:
* wp eval-file server-profile.php [<path>] [<top>] [--url=<host>]
*
* Profiles the WordPress render path for <path> (default "/") with
* rtCamp\WPDevTools\Support\XHProfProfiler and prints the top-<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 [<path>] [<top>]' . 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
)
);
17 changes: 17 additions & 0 deletions node-packages/wp-tooling/src/cli/commands/perf.js
Original file line number Diff line number Diff line change
@@ -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),
};
20 changes: 11 additions & 9 deletions node-packages/wp-tooling/src/init/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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(
Expand Down
Loading